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/__init__.py +9 -346
- fargopy/base.py +377 -0
- fargopy/bin/ifargopy +91 -0
- fargopy/bin/vfargopy +2111 -0
- fargopy/data/fargopy_logo.png +0 -0
- fargopy/fields.py +1590 -44
- fargopy/flux.py +894 -0
- fargopy/plot.py +553 -8
- fargopy/simulation.py +1597 -438
- fargopy/sys.py +116 -65
- fargopy/tests/test_base.py +8 -0
- fargopy/tests/test_flux.py +76 -0
- fargopy/tests/test_interp.py +132 -0
- fargopy-1.0.0.data/scripts/ifargopy +91 -0
- fargopy-1.0.0.data/scripts/vfargopy +2111 -0
- fargopy-1.0.0.dist-info/METADATA +425 -0
- fargopy-1.0.0.dist-info/RECORD +21 -0
- {fargopy-0.3.15.dist-info → fargopy-1.0.0.dist-info}/WHEEL +1 -1
- fargopy-1.0.0.dist-info/licenses/LICENSE +661 -0
- fargopy/fsimulation.py +0 -603
- fargopy/tests/test___init__.py +0 -0
- fargopy/util.py +0 -21
- fargopy/version.py +0 -1
- fargopy-0.3.15.data/scripts/ifargopy +0 -15
- fargopy-0.3.15.dist-info/METADATA +0 -489
- fargopy-0.3.15.dist-info/RECORD +0 -16
- fargopy-0.3.15.dist-info/licenses/LICENSE +0 -21
- {fargopy-0.3.15.dist-info → fargopy-1.0.0.dist-info}/entry_points.txt +0 -0
- {fargopy-0.3.15.dist-info → fargopy-1.0.0.dist-info}/top_level.txt +0 -0
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)
|