fargopy 0.3.15__py3-none-any.whl → 1.0.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.
fargopy/flux.py ADDED
@@ -0,0 +1,894 @@
1
+ ###############################################################
2
+ # FARGOpy interdependencies
3
+ ###############################################################
4
+ import fargopy
5
+
6
+ ###############################################################
7
+ # Required packages
8
+ ###############################################################
9
+ import numpy as np
10
+ import pandas as pd
11
+ import matplotlib.pyplot as plt
12
+ import plotly.graph_objects as go
13
+ from tqdm import tqdm
14
+ import fargopy as fp
15
+
16
+
17
+ class Surface:
18
+ """Analytic surface tessellation and helpers for flux and mass analysis.
19
+
20
+ The ``Surface`` class provides tools to define analytic control surfaces
21
+ (sphere, cylinder, plane) and tessellate them into small patches.
22
+ These surfaces are used to calculate physical quantities like mass flux
23
+ (accretion rates) and enclosed mass by integrating simulation fields.
24
+
25
+ Attributes
26
+ ----------
27
+ type : str
28
+ Surface type ('sphere', 'cylinder', 'plane').
29
+ radius : float
30
+ Radius or characteristic dimension (e.g., hill radius factor).
31
+ centers : np.ndarray
32
+ (N, 3) array of patch centroids.
33
+ normals : np.ndarray
34
+ (N, 3) array of outward-facing unit normals for each patch.
35
+ areas : np.ndarray
36
+ (N,) array of patch areas.
37
+
38
+ Examples
39
+ --------
40
+ Define a spherical surface around a planet:
41
+
42
+ >>> surface = fp.Flux.Surface(type='sphere', radius=0.5, subdivisions=2)
43
+
44
+ Calculate mass flux through the surface:
45
+
46
+ >>> flux = surface.mass_flux(sim, field_density='gasdens', field_velocity='gasv')
47
+ """
48
+
49
+ def __init__(self, type="sphere", radius=1.0, height=None, subdivisions=1,
50
+ center=(0.0, 0.0, 0.0), z_cut=None, x_axis=1, y_axis=0, z_axis=0,
51
+ width=None, length=None):
52
+ """Initialize a Surface instance.
53
+
54
+ Parameters
55
+ ----------
56
+ type : str, optional
57
+ Surface type. One of ``'sphere'``, ``'cylinder'`` or ``'plane'``.
58
+ radius : float, optional
59
+ Radius for spheres or radial extent for planes/cylinders.
60
+ height : float, optional
61
+ Cylinder height; required when ``type=='cylinder'``.
62
+ subdivisions : int, optional
63
+ For ``'sphere'``: number of recursive icosahedron subdivisions.
64
+ For ``'cylinder'`` and ``'plane'``: number of divisions along
65
+ each logical axis used to build patches.
66
+ center : tuple of float, optional
67
+ Cartesian coordinates ``(x, y, z)`` of the surface center.
68
+ z_cut : float, optional
69
+ Optional z-plane clipping value. When provided, portions with
70
+ z < ``z_cut`` are removed for spherical tessellations.
71
+ x_axis, y_axis, z_axis : int, optional
72
+ Axis flags (0 or 1) that select the plane normal for
73
+ ``type=='plane'``. Exactly one flag must be 1.
74
+ width, length : float, optional
75
+ Explicit span of the plane along the two in-plane axes. If
76
+ omitted, each defaults to ``2 * radius``.
77
+ """
78
+ self.type = type
79
+ self.radius = radius
80
+ self.height = height
81
+ self.subdivisions = subdivisions
82
+ self.center = np.array(center)
83
+ self.z_cut = z_cut
84
+ self.x_axis = x_axis
85
+ self.y_axis = y_axis
86
+ self.z_axis = z_axis
87
+ # Plane dimensions: if not provided, fall back to diameter defined by radius
88
+ self.width = width if width is not None else 2.0 * self.radius
89
+ self.length = length if length is not None else 2.0 * self.radius
90
+ if self.width <= 0 or self.length <= 0:
91
+ raise ValueError("width and length must be positive numbers")
92
+ self.volume = None
93
+
94
+ # Attributes for tessellation
95
+ self.centers = None
96
+ self.normals = None
97
+ self.areas = None
98
+ self.triangles = None
99
+ self.num_triangles = 0
100
+ self.triangle_index = 0
101
+
102
+ if self.type == "sphere":
103
+ self.num_triangles = 20 * (4 ** self.subdivisions)
104
+ self.triangles = np.zeros((self.num_triangles, 3, 3))
105
+ self.centers = np.zeros((self.num_triangles, 3))
106
+ self.areas = np.zeros(self.num_triangles)
107
+ self._tessellate_sphere()
108
+ elif self.type == "cylinder":
109
+ self._tessellate_cylinder()
110
+ elif self.type == "plane":
111
+ self._tessellate_plane()
112
+ else:
113
+ raise ValueError("Unsupported surface type. Use 'sphere', 'cylinder', or 'plane'.")
114
+
115
+ def _tessellate_sphere(self):
116
+ """Construct a spherical triangle tessellation.
117
+
118
+ The method builds an icosahedron and recursively subdivides its
119
+ faces to produce approximately uniform triangular patches on the
120
+ sphere of radius ``self.radius``. If ``self.z_cut`` is defined,
121
+ triangles crossing the plane ``z = z_cut`` are clipped so that
122
+ only the portion with ``z >= z_cut`` remains.
123
+ """
124
+ phi = (1.0 + np.sqrt(5.0)) / 2.0
125
+ patterns = [
126
+ (-1, phi, 0), (1, phi, 0), (-1, -phi, 0), (1, -phi, 0),
127
+ (0, -1, phi), (0, 1, phi), (0, -1, -phi), (0, 1, -phi),
128
+ (phi, 0, -1), (phi, 0, 1), (-phi, 0, -1), (-phi, 0, 1),
129
+ ]
130
+ vertices = np.array([Surface._normalize(np.array(p)) * self.radius for p in patterns])
131
+ faces = [
132
+ (0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11),
133
+ (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6), (7, 1, 8),
134
+ (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9),
135
+ (4, 9, 5), (2, 4, 11), (6, 2, 10), (8, 6, 7), (9, 8, 1),
136
+ ]
137
+ # Reset triangle index and arrays
138
+ self.num_triangles = 20 * (4 ** self.subdivisions)
139
+ self.triangle_index = 0
140
+ self.triangles = np.zeros((self.num_triangles, 3, 3))
141
+ self.centers = np.zeros((self.num_triangles, 3))
142
+ self.areas = np.zeros(self.num_triangles)
143
+
144
+ for face in faces:
145
+ v1, v2, v3 = vertices[face[0]], vertices[face[1]], vertices[face[2]]
146
+ self._subdivide_triangle(v1, v2, v3, self.subdivisions)
147
+
148
+ self._calculate_polygon_centers()
149
+
150
+ # If z_cut is provided, clip each triangle against the plane instead of
151
+ # simply filtering by centroid position.
152
+ if self.z_cut is not None:
153
+ def clip_triangle_with_plane(tri, z_plane):
154
+ # tri: (3,3) array
155
+ # returns list of triangles (each (3,3)) above or on the plane
156
+ verts = tri
157
+ z = verts[:, 2]
158
+ above = z >= z_plane
159
+ if np.all(above):
160
+ return [verts]
161
+ elif np.all(~above):
162
+ return []
163
+ # Identify configuration
164
+ idx_above = np.where(above)[0]
165
+ idx_below = np.where(~above)[0]
166
+ v = verts
167
+ tris = []
168
+ if len(idx_above) == 2 and len(idx_below) == 1:
169
+ # Two above, one below: split into two triangles
170
+ a, b = idx_above
171
+ c = idx_below[0]
172
+ va, vb, vc = v[a], v[b], v[c]
173
+ # Intersect edges ac and bc with plane
174
+ def interp(v1, v2):
175
+ dz = v2[2] - v1[2]
176
+ if dz == 0: return v1
177
+ t = (z_plane - v1[2]) / dz
178
+ return v1 + t * (v2 - v1)
179
+ vab = va
180
+ vbb = vb
181
+ vac = interp(va, vc)
182
+ vbc = interp(vb, vc)
183
+ # Triangle 1: va, vb, vbc
184
+ tris.append(np.array([va, vb, vbc]))
185
+ # Triangle 2: va, vbc, vac
186
+ tris.append(np.array([va, vbc, vac]))
187
+ elif len(idx_above) == 1 and len(idx_below) == 2:
188
+ # One above, two below: split into one triangle
189
+ a = idx_above[0]
190
+ b, c = idx_below
191
+ va, vb, vc = v[a], v[b], v[c]
192
+ # Intersect edges ab and ac with plane
193
+ def interp(v1, v2):
194
+ dz = v2[2] - v1[2]
195
+ if dz == 0: return v1
196
+ t = (z_plane - v1[2]) / dz
197
+ return v1 + t * (v2 - v1)
198
+ vab = interp(va, vb)
199
+ vac = interp(va, vc)
200
+ # Triangle: va, vab, vac
201
+ tris.append(np.array([va, vab, vac]))
202
+ return tris
203
+
204
+ new_triangles = []
205
+ for tri in self.triangles:
206
+ clipped = clip_triangle_with_plane(tri, self.z_cut)
207
+ new_triangles.extend(clipped)
208
+ self.triangles = np.array(new_triangles)
209
+ self.num_triangles = len(self.triangles)
210
+ self.centers = np.mean(self.triangles, axis=1)
211
+ self.areas = np.array([self._calculate_triangle_area(*tri) for tri in self.triangles])
212
+ self._calculate_normals()
213
+ else:
214
+ self._calculate_all_triangle_areas()
215
+ self._calculate_normals()
216
+
217
+ self.volume = self.areas * (self.radius / 3)
218
+
219
+
220
+ def _tessellate_cylinder(self):
221
+ """Discretize a right circular cylinder into top, bottom and lateral patches.
222
+
223
+ The cylinder is split into a regular grid on the top and bottom
224
+ circular faces and into strips along the azimuth and height for
225
+ the lateral surface. The method sets per-patch centers, normals
226
+ and areas on attributes named ``top_centers``, ``bottom_centers``,
227
+ ``lateral_centers`` and their corresponding ``_normals`` and
228
+ ``_areas`` attributes.
229
+ """
230
+ theta = np.linspace(0, 2 * np.pi, self.subdivisions, endpoint=False)
231
+ r = np.linspace(0, self.radius, self.subdivisions)
232
+ R, Theta = np.meshgrid(r, theta, indexing='ij')
233
+ X = R * np.cos(Theta) + self.center[0]
234
+ Y = R * np.sin(Theta) + self.center[1]
235
+ Z_top = np.full_like(X, self.center[2] + self.height / 2)
236
+ Z_bottom = np.full_like(X, self.center[2] - self.height / 2)
237
+
238
+ self.top_centers = np.stack([X.ravel(), Y.ravel(), Z_top.ravel()], axis=1)
239
+ self.bottom_centers = np.stack([X.ravel(), Y.ravel(), Z_bottom.ravel()], axis=1)
240
+ self.top_normals = np.tile([0, 0, 1], (self.top_centers.shape[0], 1))
241
+ self.bottom_normals = np.tile([0, 0, -1], (self.bottom_centers.shape[0], 1))
242
+ self.top_areas = (self.radius / self.subdivisions) ** 2 * np.pi
243
+ self.bottom_areas = self.top_areas
244
+
245
+ theta = np.linspace(0, 2 * np.pi, self.subdivisions, endpoint=False)
246
+ z = np.linspace(-self.height / 2, self.height / 2, self.subdivisions) + self.center[2] # Adjust z by center
247
+ Theta, Z = np.meshgrid(theta, z, indexing='ij')
248
+ X = self.radius * np.cos(Theta) + self.center[0]
249
+ Y = self.radius * np.sin(Theta) + self.center[1]
250
+
251
+ self.lateral_centers = np.stack([X.ravel(), Y.ravel(), Z.ravel()], axis=1)
252
+ self.lateral_normals = np.stack([np.cos(Theta).ravel(), np.sin(Theta).ravel(), np.zeros_like(Z).ravel()], axis=1)
253
+ self.lateral_areas = (2 * np.pi * self.radius / self.subdivisions) * (self.height / self.subdivisions)
254
+
255
+ def _tessellate_plane(self):
256
+ """Discretize an axis-aligned plane into rectangular patches.
257
+
258
+ The plane is centered at ``self.center`` and aligned with the axis
259
+ selected by the triple ``(x_axis, y_axis, z_axis)``. The in-plane
260
+ spans are ``self.width`` and ``self.length`` which are split into
261
+ ``self.subdivisions`` cells along each axis. The method sets
262
+ ``self.centers``, ``self.normals``, ``self.areas`` and related
263
+ attributes for downstream use.
264
+ """
265
+ axis_flags = np.array([self.x_axis, self.y_axis, self.z_axis], dtype=int)
266
+ if np.any((axis_flags != 0) & (axis_flags != 1)) or axis_flags.sum() != 1:
267
+ raise ValueError("Plane normal must align with exactly one axis using 0/1 flags.")
268
+ if self.subdivisions <= 0:
269
+ raise ValueError("subdivisions must be >= 1 for plane tessellation.")
270
+ normal_axis = int(np.argmax(axis_flags))
271
+ plane_axes = [idx for idx in range(3) if idx != normal_axis]
272
+ # lin = np.linspace(-self.radius, self.radius, self.subdivisions + 1)
273
+ # centers_axis = 0.5 * (lin[:-1] + lin[1:])
274
+ # grid_a, grid_b = np.meshgrid(centers_axis, centers_axis, indexing='ij')
275
+ # num_cells = self.subdivisions ** 2
276
+ # centers = np.zeros((num_cells, 3))
277
+ # centers[:, plane_axes[0]] = grid_a.ravel() + self.center[plane_axes[0]]
278
+ # centers[:, plane_axes[1]] = grid_b.ravel() + self.center[plane_axes[1]]
279
+ # centers[:, normal_axis] = self.center[normal_axis]
280
+ # self.centers = centers
281
+ # normal_vector = np.zeros(3)
282
+ # normal_vector[normal_axis] = 1.0
283
+ # self.normals = np.tile(normal_vector, (num_cells, 1))
284
+ # cell_edge = (2 * self.radius) / self.subdivisions
285
+ # self.areas = np.full(num_cells, cell_edge ** 2)
286
+ # self.num_triangles = num_cells
287
+ # self.triangles = None
288
+ # Use width and length (span) to build grid along the two in-plane axes
289
+ lin_a = np.linspace(-self.width/2.0, self.width/2.0, self.subdivisions + 1)
290
+ lin_b = np.linspace(-self.length/2.0, self.length/2.0, self.subdivisions + 1)
291
+ centers_a = 0.5 * (lin_a[:-1] + lin_a[1:])
292
+ centers_b = 0.5 * (lin_b[:-1] + lin_b[1:])
293
+ grid_a, grid_b = np.meshgrid(centers_a, centers_b, indexing='ij')
294
+ num_cells = self.subdivisions ** 2
295
+ centers = np.zeros((num_cells, 3))
296
+ centers[:, plane_axes[0]] = grid_a.ravel() + self.center[plane_axes[0]]
297
+ centers[:, plane_axes[1]] = grid_b.ravel() + self.center[plane_axes[1]]
298
+ centers[:, normal_axis] = self.center[normal_axis]
299
+ self.centers = centers
300
+ normal_vector = np.zeros(3)
301
+ normal_vector[normal_axis] = 1.0
302
+ self.normals = np.tile(normal_vector, (num_cells, 1))
303
+ cell_edge_a = (self.width) / self.subdivisions
304
+ cell_edge_b = (self.length) / self.subdivisions
305
+ self.areas = np.full(num_cells, cell_edge_a * cell_edge_b)
306
+ self.num_triangles = num_cells
307
+ self.triangles = None
308
+
309
+ @staticmethod
310
+ def _normalize(v):
311
+ """Return a unit-length copy of the input vector.
312
+
313
+ Parameters
314
+ ----------
315
+ v : array_like
316
+ Input vector.
317
+
318
+ Returns
319
+ -------
320
+ ndarray
321
+ Unit-normalized copy of ``v``.
322
+ """
323
+ return v / np.linalg.norm(v)
324
+
325
+ def _subdivide_triangle(self, v1, v2, v3, depth):
326
+ """Recursively subdivide a triangle and store leaf triangles.
327
+
328
+ Parameters
329
+ ----------
330
+ v1, v2, v3 : array_like
331
+ Triangle vertices in Cartesian coordinates (on the unit sphere
332
+ before scaling by ``self.radius``).
333
+ depth : int
334
+ Number of remaining subdivision levels. When ``depth==0`` the
335
+ triangle is written into ``self.triangles``.
336
+ """
337
+ if depth == 0:
338
+ self.triangles[self.triangle_index] = [v1 + self.center, v2 + self.center, v3 + self.center]
339
+ self.triangle_index += 1
340
+ return
341
+ v12 = Surface._normalize((v1 + v2) / 2) * self.radius
342
+ v23 = Surface._normalize((v2 + v3) / 2) * self.radius
343
+ v31 = Surface._normalize((v3 + v1) / 2) * self.radius
344
+ self._subdivide_triangle(v1, v12, v31, depth - 1)
345
+ self._subdivide_triangle(v12, v2, v23, depth - 1)
346
+ self._subdivide_triangle(v31, v23, v3, depth - 1)
347
+ self._subdivide_triangle(v12, v23, v31, depth - 1)
348
+
349
+ def _calculate_polygon_centers(self):
350
+ """Compute and cache centroids for all stored triangles.
351
+
352
+ The computed centroids are assigned to ``self.centers``.
353
+ """
354
+ self.centers = np.mean(self.triangles, axis=1)
355
+
356
+ @staticmethod
357
+ def _calculate_triangle_area(v1, v2, v3):
358
+ """Compute the area of a triangle using the cross product.
359
+
360
+ Parameters
361
+ ----------
362
+ v1, v2, v3 : array_like
363
+ Triangle vertex coordinates.
364
+
365
+ Returns
366
+ -------
367
+ float
368
+ Triangle area.
369
+ """
370
+ side1 = v2 - v1
371
+ side2 = v3 - v1
372
+ cross_product = np.cross(side1, side2)
373
+ area = np.linalg.norm(cross_product) / 2
374
+ return area
375
+
376
+ def _calculate_all_triangle_areas(self):
377
+ """Evaluate and cache areas for every triangle in ``self.triangles``.
378
+
379
+ This fills ``self.areas`` using :meth:`_calculate_triangle_area`.
380
+ """
381
+ for i, (v1, v2, v3) in enumerate(self.triangles):
382
+ self.areas[i] = self._calculate_triangle_area(v1, v2, v3)
383
+
384
+ def _calculate_normals(self):
385
+ """Compute outward-facing unit normals for each stored triangle.
386
+
387
+ The method enforces that each normal points away from ``self.center``;
388
+ if the computed normal points inward it is flipped.
389
+ Results are stored in ``self.normals``.
390
+ """
391
+ self.normals = np.zeros((self.num_triangles, 3))
392
+ for i, tri in enumerate(self.triangles):
393
+ AB = tri[1] - tri[0]
394
+ AC = tri[2] - tri[0]
395
+ normal = np.cross(AB, AC)
396
+ normal /= np.linalg.norm(normal)
397
+ centroid = np.mean(tri, axis=0)
398
+ to_centroid = centroid - self.center
399
+ if np.dot(normal, to_centroid) < 0:
400
+ normal = -normal
401
+ self.normals[i] = normal
402
+
403
+ def tessellate(self):
404
+ """Recompute the tessellation from current instance parameters.
405
+
406
+ This method is a convenience wrapper that dispatches to the
407
+ appropriate internal tessellation implementation depending on
408
+ ``self.type``.
409
+ """
410
+ if self.type == "sphere":
411
+ self._tessellate_sphere()
412
+ elif self.type == "cylinder":
413
+ self._tessellate_cylinder()
414
+ elif self.type == "plane":
415
+ self._tessellate_plane()
416
+ else:
417
+ raise ValueError("Unsupported surface type. Use 'sphere', 'cylinder', or 'plane'.")
418
+
419
+ def generate_dataframe(self):
420
+ """Return tessellation metadata as a pandas :class:`DataFrame`.
421
+
422
+ The returned DataFrame contains one row per surface patch with
423
+ columns ``'Center'``, ``'Normal'`` and ``'Area'``. Coordinates are
424
+ represented as Python lists to ensure JSON-serializable cell values.
425
+ """
426
+ data = []
427
+ for i, (center, normal, area) in enumerate(zip(self.centers, self.normals, self.areas)):
428
+ data.append({
429
+ "Center": center.tolist(),
430
+ "Normal": normal.tolist(),
431
+ "Area": area
432
+ })
433
+ return pd.DataFrame(data)
434
+
435
+ def total_mass_mtc(self, sim, field='gasdens', n_samples=10000, snapshot=[0,1],
436
+ interpolator='griddata', method='linear', cut_r=None,
437
+ follow_planet=True, planet_index=0):
438
+ """Estimate enclosed mass using Monte Carlo sampling.
439
+
440
+ The method samples ``n_samples`` points uniformly within the
441
+ spherical region defined by this surface (accounting for ``z_cut``
442
+ when present), interpolates the requested density field at the
443
+ sample points and returns an estimate of the enclosed mass.
444
+
445
+ Parameters
446
+ ----------
447
+ sim : object
448
+ Simulation object providing ``load_field`` and ``load_planets``
449
+ methods (FARGOpy simulation API).
450
+ field : str, optional
451
+ Density field name to sample (default ``'gasdens'``).
452
+ n_samples : int, optional
453
+ Number of Monte Carlo samples per snapshot.
454
+ snapshot : int or [start, end], optional
455
+ Snapshot index or inclusive snapshot range.
456
+ interpolator : str, optional
457
+ Interpolator backend passed to the field evaluator.
458
+ method : str, optional
459
+ Interpolation method passed to the field evaluator.
460
+ cut_r : float, optional
461
+ Radial cut passed to ``sim.load_field`` to limit data transfer.
462
+ follow_planet : bool, optional
463
+ If True follow the planet position when sampling (useful for
464
+ Hill-sphere based regions).
465
+ planet_index : int, optional
466
+ Index of the planet to follow when ``follow_planet`` is True.
467
+
468
+ Returns
469
+ -------
470
+ float or numpy.ndarray
471
+ Estimated enclosed mass. Returns a single float if a single
472
+ snapshot is requested, otherwise an array of estimates.
473
+ """
474
+ if isinstance(snapshot, int):
475
+ times = [snapshot]
476
+ else:
477
+ times = np.linspace(snapshot[0], snapshot[1], snapshot[1]-snapshot[0]+1)
478
+ mass = np.zeros(len(times))
479
+
480
+ planet = sim.load_planets(snapshot=0)[planet_index]
481
+ factor = self.radius/planet.hill_radius
482
+
483
+ for i, t in enumerate(tqdm(times, desc="Calculating total mass")):
484
+ # Update surface if following planet
485
+ if follow_planet:
486
+ planet = sim.load_planets(snapshot=int(t))[planet_index]
487
+ self.center = np.array([planet.pos.x, planet.pos.y, planet.pos.z])
488
+ if hasattr(planet, 'hill_radius'):
489
+ self.radius = factor * planet.hill_radius
490
+ self.tessellate()
491
+
492
+ # Generate random points inside the sphere
493
+ u = np.random.uniform(0, 1, int(n_samples))
494
+ v = np.random.uniform(0, 1, int(n_samples))
495
+ w = np.random.uniform(0, 1, int(n_samples))
496
+
497
+ r = self.radius * np.cbrt(u)
498
+ theta = np.arccos(1 - 2 * v)
499
+ phi = 2 * np.pi * w
500
+
501
+ x = r * np.sin(theta) * np.cos(phi) + self.center[0]
502
+ y = r * np.sin(theta) * np.sin(phi) + self.center[1]
503
+ z = r * np.cos(theta) + self.center[2]
504
+
505
+ # Apply z_cut if specified
506
+ if self.z_cut is not None:
507
+ mask = z > self.z_cut
508
+ x, y, z = x[mask], y[mask], z[mask]
509
+ n_effective = len(x)
510
+ h = self.radius + self.center[2] - self.z_cut
511
+ h = np.clip(h, 0, 2*self.radius)
512
+ volume = (1/3) * np.pi * h**2 * (3*self.radius - h)
513
+ else:
514
+ n_effective = int(n_samples)
515
+ volume = (4/3) * np.pi * self.radius**3
516
+
517
+ # Interpolate density at the random points
518
+ if cut_r is not None:
519
+ field_interp = sim.load_field(
520
+ fields=[field],
521
+ snapshot=[int(t)],
522
+ cut=(self.center[0], self.center[1], self.center[2], cut_r),
523
+ )
524
+ else:
525
+ field_interp = sim.load_field(
526
+ fields=[field],
527
+ snapshot=[int(t)],
528
+ cut=(self.center[0], self.center[1], self.center[2], self.radius*2),
529
+ )
530
+
531
+ rho = field_interp.evaluate(
532
+ time=t,
533
+ var1=x,
534
+ var2=y,
535
+ var3=z,
536
+ interpolator=interpolator,
537
+ method=method
538
+ )
539
+ avg_rho = np.mean(rho[~np.isnan(rho)])
540
+ mass[i] = avg_rho * volume
541
+
542
+ if len(mass) == 1:
543
+ return mass[0]
544
+ return mass
545
+
546
+
547
+ def mass_flux(self, sim, field_density='gasdens', field_velocity='gasv',
548
+ snapshot=[0, 1], interpolator='griddata', method='linear',
549
+ follow_planet=True, planet_index=0,
550
+ correct_normals=True, relative_velocity=False):
551
+ """Compute mass flux through the surface patches.
552
+
553
+ The instantaneous mass flux for each patch is computed as::
554
+
555
+ dΦ = ρ * (v_rel · n_out) * dA
556
+
557
+ and the returned value is the sum over all patches. Velocities can
558
+ optionally be converted to the planet rest frame by enabling
559
+ ``relative_velocity``.
560
+
561
+ Parameters
562
+ ----------
563
+ sim : object
564
+ Simulation object exposing ``load_field`` and ``load_planets``.
565
+ field_density : str, optional
566
+ Density field name (default ``'gasdens'``).
567
+ field_velocity : str, optional
568
+ Velocity field name (default ``'gasv'``).
569
+ snapshot : [start, end], optional
570
+ Inclusive snapshot range to evaluate.
571
+ interpolator, method : str, optional
572
+ Passed to the field evaluator for interpolation.
573
+ follow_planet : bool, optional
574
+ If True follow the planet position and scale the surface
575
+ (useful for Hill-sphere analysis).
576
+ planet_index : int, optional
577
+ Index of the planet to follow.
578
+ correct_normals : bool, optional
579
+ If True ensure per-patch normals point outward from the
580
+ surface center before computing the flux.
581
+ relative_velocity : bool, optional
582
+ If True subtract the planet velocity from the interpolated
583
+ fluid velocity prior to flux computation.
584
+
585
+ Returns
586
+ -------
587
+ numpy.ndarray
588
+ Array of flux values, one per requested snapshot.
589
+
590
+ Examples
591
+ --------
592
+ Compute accretion rate (mass flux) onto a planet:
593
+
594
+ >>> surface = fp.Flux.Surface(type='sphere', radius=0.5, subdivisions=3)
595
+ >>> mdot = surface.mass_flux(sim, field_density='gasdens', field_velocity='gasv', follow_planet=True)
596
+ >>> plt.plot(mdot)
597
+ """
598
+
599
+ steps = snapshot[1] - snapshot[0] + 1
600
+ times = np.linspace(snapshot[0], snapshot[1], steps)
601
+ flux = np.zeros(len(times))
602
+
603
+ # Initial planet for scaling
604
+ planet0 = sim.load_planets()[planet_index]
605
+ factor = self.radius / planet0.hill_radius
606
+
607
+ for i, t in enumerate(tqdm(times, desc="Calculating mass flux")):
608
+
609
+ # -------------------------------------------------------------
610
+ # Update the surface position and scale if following the planet
611
+ # -------------------------------------------------------------
612
+ if follow_planet:
613
+ planet = sim.load_planets(snapshot=int(t))[planet_index]
614
+ self.center = np.array([planet.pos.x, planet.pos.y, planet.pos.z])
615
+
616
+ if hasattr(planet, 'hill_radius'):
617
+ self.radius = factor * planet.hill_radius
618
+
619
+ self.tessellate()
620
+
621
+ # Planet velocity (for relative velocities)
622
+ vpx, vpy, vpz = planet.vel.x, planet.vel.y, planet.vel.z
623
+
624
+ # -------------------------------------------------------------
625
+ # Select geometric properties of the surface
626
+ # -------------------------------------------------------------
627
+ if self.type == "sphere":
628
+ centers = self.centers
629
+ normals = self.normals
630
+ areas = self.areas
631
+ surface_cut = (self.center[0], self.center[1], self.center[2], 2*self.radius)
632
+
633
+ elif self.type == "cylinder":
634
+ centers = np.concatenate([self.top_centers,
635
+ self.bottom_centers,
636
+ self.lateral_centers], axis=0)
637
+ normals = np.concatenate([self.top_normals,
638
+ self.bottom_normals,
639
+ self.lateral_normals], axis=0)
640
+ areas = np.concatenate([
641
+ np.full(self.top_centers.shape[0], self.top_areas),
642
+ np.full(self.bottom_centers.shape[0], self.bottom_areas),
643
+ np.full(self.lateral_centers.shape[0], self.lateral_areas)
644
+ ])
645
+ surface_cut = (self.center[0], self.center[1], self.center[2],
646
+ 2*self.radius, 2*self.height)
647
+
648
+ elif self.type == "plane":
649
+ centers = self.centers
650
+ normals = self.normals
651
+ areas = self.areas
652
+ surface_cut = (self.center[0], self.center[1], self.center[2], 2*self.radius)
653
+
654
+ else:
655
+ raise ValueError("Unsupported surface type.")
656
+
657
+ # -------------------------------------------------------------
658
+ # Load both fields simultaneously into a single DataFrame
659
+ # -------------------------------------------------------------
660
+ fields = sim.load_field(
661
+ fields=[field_density, field_velocity],
662
+ snapshot=[int(t)],
663
+ cut=surface_cut
664
+ )
665
+
666
+
667
+ # -------------------------------------------------------------
668
+ # Interpolate density
669
+ # -------------------------------------------------------------
670
+ rho = fields.evaluate(
671
+ time=t,
672
+ var1=centers[:, 0],
673
+ var2=centers[:, 1],
674
+ var3=centers[:, 2],
675
+ interpolator=interpolator,
676
+ method=method,
677
+ field="gasdens"
678
+ )
679
+
680
+ # -------------------------------------------------------------
681
+ # Interpolate velocity vector
682
+ # -------------------------------------------------------------
683
+ vel = fields.evaluate(
684
+ time=t,
685
+ var1=centers[:, 0],
686
+ var2=centers[:, 1],
687
+ var3=centers[:, 2],
688
+ interpolator=interpolator,
689
+ method=method,
690
+ field="gasv"
691
+ )
692
+
693
+ # Shape fix (3,N → N,3)
694
+ if vel.ndim == 2 and vel.shape[0] == 3:
695
+ vel = vel.T
696
+
697
+ # -------------------------------------------------------------
698
+ # Ensure normals point outward
699
+ # -------------------------------------------------------------
700
+ if correct_normals:
701
+ to_centers = centers - self.center
702
+ flip = (np.einsum('ij,ij->i', normals, to_centers) < 0)
703
+ normals[flip] *= -1
704
+
705
+ # -------------------------------------------------------------
706
+ # Convert to velocity in planet's rest frame
707
+ # -------------------------------------------------------------
708
+ if relative_velocity:
709
+ vel[:, 0] -= vpx
710
+ vel[:, 1] -= vpy
711
+ vel[:, 2] -= vpz
712
+
713
+ # -------------------------------------------------------------
714
+ # Compute flux for each surface element
715
+ # -------------------------------------------------------------
716
+ v_dot_n = np.einsum('ij,ij->i', vel, normals)
717
+ dF = rho * v_dot_n * areas
718
+ flux[i] = np.nansum(dF)
719
+
720
+ return flux
721
+
722
+
723
+
724
+ def total_mass(self,
725
+ sim,
726
+ field='gasdens',
727
+ snapshot=[0,1],
728
+ follow_planet=True,
729
+ planet_index=0,
730
+ return_resolution=False):
731
+ """Compute enclosed mass by direct grid integration.
732
+
733
+ The method integrates the requested density field on the simulation
734
+ spherical-polar grid accounting for the region geometry defined by
735
+ this Surface instance. ``self.type`` must be either ``'sphere'`` or
736
+ ``'cylinder'``; the integration mask is constructed accordingly.
737
+
738
+ Parameters
739
+ ----------
740
+ sim : object
741
+ Simulation object providing access to the raw grid and fields.
742
+ field : str, optional
743
+ Density field name (default ``'gasdens'``).
744
+ snapshot : int or [start, end], optional
745
+ Snapshot index or inclusive snapshot range to integrate.
746
+ follow_planet : bool, optional
747
+ If True update the integration center and radius from the
748
+ specified planet's position/hill radius.
749
+ planet_index : int, optional
750
+ Planet index to follow when ``follow_planet`` is True.
751
+ return_resolution : bool, optional
752
+ If True return detailed resolution metadata for each snapshot
753
+ alongside the integrated mass.
754
+
755
+ Returns
756
+ -------
757
+ float or numpy.ndarray or list
758
+ If ``return_resolution`` is False and a single snapshot is
759
+ requested, returns a float. If multiple snapshots are
760
+ requested, returns a numpy array of masses. If
761
+ ``return_resolution`` is True a list of dictionaries with
762
+ per-snapshot metadata is returned.
763
+
764
+ Examples
765
+ --------
766
+ Compute total mass inside a Hill sphere:
767
+
768
+ >>> surface = fp.Flux.Surface(type='sphere', radius=1.0) # radius is factor of Hill radius
769
+ >>> mass = surface.total_mass(sim, field='gasdens', follow_planet=True)
770
+ """
771
+
772
+ # --------------------
773
+ # Handle snapshot list
774
+ # --------------------
775
+ if isinstance(snapshot, int):
776
+ times = [snapshot]
777
+ else:
778
+ s0, s1 = snapshot
779
+ times = np.arange(s0, s1+1)
780
+
781
+ masses = []
782
+ resolutions = []
783
+
784
+ # ----------------------------
785
+ # Load grid info once
786
+ # ----------------------------
787
+ gas0 = sim.load_field(field, snapshot=times[0], interpolate=False)
788
+ r_arr = gas0.domains.r
789
+ th_arr = gas0.domains.theta
790
+ ph_arr = gas0.domains.phi
791
+
792
+ TH, RR, PH = np.meshgrid(th_arr, r_arr, ph_arr, indexing='ij')
793
+
794
+ X = RR * np.sin(TH) * np.cos(PH)
795
+ Y = RR * np.sin(TH) * np.sin(PH)
796
+ Z = RR * np.cos(TH)
797
+
798
+ # ----------------------------------------
799
+ # Precompute cell volumes (FARGO3D metric)
800
+ # ----------------------------------------
801
+ dr = np.diff(r_arr)
802
+ dth = np.diff(th_arr)
803
+ dph = np.diff(ph_arr)
804
+
805
+ dr_full = np.empty_like(r_arr); dr_full[:-1] = dr; dr_full[-1] = dr[-1]
806
+ dth_full = np.empty_like(th_arr); dth_full[:-1] = dth; dth_full[-1] = dth[-1]
807
+ dph_full = np.empty_like(ph_arr); dph_full[:-1] = dph; dph_full[-1] = dph[-1]
808
+
809
+ DR = dr_full[np.newaxis, :, np.newaxis]
810
+ DTH = dth_full[:, np.newaxis, np.newaxis]
811
+ DPH = dph_full[np.newaxis, np.newaxis, :]
812
+
813
+ dV = (RR**2) * np.sin(TH) * DR * DTH * DPH
814
+
815
+ # ----------------------------------------
816
+ # Detect geometry
817
+ # ----------------------------------------
818
+ geom = self.type.lower()
819
+
820
+ if geom not in ['sphere', 'cylinder']:
821
+ raise ValueError("Surface.type must be 'sphere' or 'cylinder'")
822
+
823
+ # Loop over snapshots
824
+ for t in times:
825
+
826
+ # Follow planet
827
+ if follow_planet:
828
+ planet = sim.load_planets(snapshot=t)[planet_index]
829
+ xp, yp, zp = planet.pos.x, planet.pos.y, planet.pos.z
830
+
831
+ # Update center and radius according to Hill radius
832
+ factor = np.round(self.radius / sim.load_planets(snapshot=t)[planet_index].hill_radius,2)
833
+ self.center = (xp, yp, zp)
834
+ self.radius = factor * planet.hill_radius
835
+
836
+ else:
837
+ xp, yp, zp = self.center
838
+
839
+ Xc = X - xp
840
+ Yc = Y - yp
841
+ Zc = Z - zp
842
+
843
+ # ---------------------------------------
844
+ # Apply geometry mask
845
+ # ---------------------------------------
846
+ if geom == 'sphere':
847
+ Rlim = self.radius
848
+ mask = (Xc**2 + Yc**2 + Zc**2) <= Rlim**2
849
+
850
+ # If z_cut exists → semisphere
851
+ if hasattr(self, 'z_cut') and (self.z_cut is not None):
852
+ mask &= (Z >= self.z_cut)
853
+
854
+ Hlim = None
855
+
856
+ elif geom == 'cylinder':
857
+ Rlim = self.radius
858
+ Hlim = self.height # provided by Surface(type='cylinder', height=...)
859
+
860
+ Rcyl = np.sqrt(Xc**2 + Yc**2)
861
+
862
+ mask = (Rcyl <= Rlim) & (np.abs(Zc) <= Hlim) & (np.abs(Zc) >= -Hlim)
863
+
864
+ # ---------------------------------------
865
+ # Load density for this snapshot
866
+ # ---------------------------------------
867
+ rho = sim.load_field(field, snapshot=t, interpolate=False).data
868
+
869
+ # Enclosed mass
870
+ M = np.sum(rho[mask] * dV[mask])
871
+ masses.append(M)
872
+
873
+ # Resolution info
874
+ if return_resolution:
875
+ idx_th, idx_r, idx_ph = np.where(mask)
876
+
877
+ resolutions.append({
878
+ "snapshot": t,
879
+ "mass": M,
880
+ "geometry": geom,
881
+ "R_extent": Rlim,
882
+ "H_extent": Hlim,
883
+ "N_theta": len(np.unique(idx_th)),
884
+ "N_r": len(np.unique(idx_r)),
885
+ "N_phi": len(np.unique(idx_ph)),
886
+ "N_total": mask.sum()
887
+ })
888
+
889
+ # Return logic
890
+ if return_resolution:
891
+ return resolutions
892
+ if len(masses) == 1:
893
+ return masses[0]
894
+ return np.array(masses)