fargopy 0.4.0__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 +8 -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 +1400 -415
- fargopy/flux.py +809 -723
- fargopy/plot.py +553 -8
- fargopy/simulation.py +1548 -577
- 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.4.0.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 -429
- fargopy/tests/test___init__.py +0 -0
- fargopy/util.py +0 -21
- fargopy/version.py +0 -1
- fargopy-0.4.0.data/scripts/ifargopy +0 -15
- fargopy-0.4.0.dist-info/METADATA +0 -492
- fargopy-0.4.0.dist-info/RECORD +0 -17
- fargopy-0.4.0.dist-info/licenses/LICENSE +0 -21
- {fargopy-0.4.0.dist-info → fargopy-1.0.0.dist-info}/entry_points.txt +0 -0
- {fargopy-0.4.0.dist-info → fargopy-1.0.0.dist-info}/top_level.txt +0 -0
fargopy/flux.py
CHANGED
|
@@ -15,794 +15,880 @@ import fargopy as fp
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class Surface:
|
|
18
|
-
"""
|
|
19
|
-
|
|
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')
|
|
20
47
|
"""
|
|
21
48
|
|
|
22
|
-
def __init__(self
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
28
101
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
self.radius = radius
|
|
32
|
-
self.subdivisions = subdivisions
|
|
33
|
-
self.center = np.array(center)
|
|
34
|
-
self.num_triangles = 20 * (4 ** subdivisions)
|
|
102
|
+
if self.type == "sphere":
|
|
103
|
+
self.num_triangles = 20 * (4 ** self.subdivisions)
|
|
35
104
|
self.triangles = np.zeros((self.num_triangles, 3, 3))
|
|
36
105
|
self.centers = np.zeros((self.num_triangles, 3))
|
|
37
106
|
self.areas = np.zeros(self.num_triangles)
|
|
38
|
-
self.
|
|
39
|
-
|
|
40
|
-
self.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"""
|
|
44
|
-
Filter the sphere's centers, areas, normals, etc. by a string condition.
|
|
45
|
-
Example: sphere.filter("z > 0")
|
|
46
|
-
"""
|
|
47
|
-
x = self.centers[:, 0]
|
|
48
|
-
y = self.centers[:, 1]
|
|
49
|
-
z = self.centers[:, 2]
|
|
50
|
-
mask = eval(condition)
|
|
51
|
-
self.centers = self.centers[mask]
|
|
52
|
-
self.areas = self.areas[mask]
|
|
53
|
-
if hasattr(self, "normals"):
|
|
54
|
-
self.normals = self.normals[mask]
|
|
55
|
-
if hasattr(self, "volume"):
|
|
56
|
-
self.volume = self.volume[mask]
|
|
57
|
-
|
|
58
|
-
@staticmethod
|
|
59
|
-
def normalize(v):
|
|
60
|
-
return v / np.linalg.norm(v)
|
|
61
|
-
|
|
62
|
-
def subdivide_triangle(self, v1, v2, v3, depth):
|
|
63
|
-
if depth == 0:
|
|
64
|
-
self.triangles[self.triangle_index] = [v1 + self.center, v2 + self.center, v3 + self.center]
|
|
65
|
-
self.triangle_index += 1
|
|
66
|
-
return
|
|
67
|
-
v12 = self.normalize((v1 + v2) / 2) * self.radius
|
|
68
|
-
v23 = self.normalize((v2 + v3) / 2) * self.radius
|
|
69
|
-
v31 = self.normalize((v3 + v1) / 2) * self.radius
|
|
70
|
-
self.subdivide_triangle(v1, v12, v31, depth - 1)
|
|
71
|
-
self.subdivide_triangle(v12, v2, v23, depth - 1)
|
|
72
|
-
self.subdivide_triangle(v31, v23, v3, depth - 1)
|
|
73
|
-
self.subdivide_triangle(v12, v23, v31, depth - 1)
|
|
74
|
-
|
|
75
|
-
def generate_icosphere(self):
|
|
76
|
-
phi = (1.0 + np.sqrt(5.0)) / 2.0
|
|
77
|
-
patterns = [
|
|
78
|
-
(-1, phi, 0), (1, phi, 0), (-1, -phi, 0), (1, -phi, 0),
|
|
79
|
-
(0, -1, phi), (0, 1, phi), (0, -1, -phi), (0, 1, -phi),
|
|
80
|
-
(phi, 0, -1), (phi, 0, 1), (-phi, 0, -1), (-phi, 0, 1),
|
|
81
|
-
]
|
|
82
|
-
vertices = np.array([self.normalize(np.array(p)) * self.radius for p in patterns])
|
|
83
|
-
faces = [
|
|
84
|
-
(0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11),
|
|
85
|
-
(1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6), (7, 1, 8),
|
|
86
|
-
(3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9),
|
|
87
|
-
(4, 9, 5), (2, 4, 11), (6, 2, 10), (8, 6, 7), (9, 8, 1),
|
|
88
|
-
]
|
|
89
|
-
for face in faces:
|
|
90
|
-
v1, v2, v3 = vertices[face[0]], vertices[face[1]], vertices[face[2]]
|
|
91
|
-
self.subdivide_triangle(v1, v2, v3, self.subdivisions)
|
|
92
|
-
|
|
93
|
-
def calculate_polygon_centers(self):
|
|
94
|
-
self.centers = np.mean(self.triangles, axis=1)
|
|
95
|
-
|
|
96
|
-
@staticmethod
|
|
97
|
-
def calculate_triangle_area(v1, v2, v3):
|
|
98
|
-
side1 = v2 - v1
|
|
99
|
-
side2 = v3 - v1
|
|
100
|
-
cross_product = np.cross(side1, side2)
|
|
101
|
-
area = np.linalg.norm(cross_product) / 2
|
|
102
|
-
return area
|
|
103
|
-
|
|
104
|
-
def calculate_all_triangle_areas(self):
|
|
105
|
-
for i, (v1, v2, v3) in enumerate(self.triangles):
|
|
106
|
-
self.areas[i] = self.calculate_triangle_area(v1, v2, v3)
|
|
107
|
-
|
|
108
|
-
def calculate_normals(self):
|
|
109
|
-
self.normals = np.zeros((self.num_triangles, 3))
|
|
110
|
-
for i, tri in enumerate(self.triangles):
|
|
111
|
-
AB = tri[1] - tri[0]
|
|
112
|
-
AC = tri[2] - tri[0]
|
|
113
|
-
normal = np.cross(AB, AC)
|
|
114
|
-
normal /= np.linalg.norm(normal)
|
|
115
|
-
centroid = np.mean(tri, axis=0)
|
|
116
|
-
to_centroid = centroid - self.center
|
|
117
|
-
if np.dot(normal, to_centroid) < 0:
|
|
118
|
-
normal = -normal
|
|
119
|
-
self.normals[i] = normal
|
|
120
|
-
|
|
121
|
-
def tessellate(self):
|
|
122
|
-
self.generate_icosphere()
|
|
123
|
-
self.calculate_polygon_centers()
|
|
124
|
-
self.calculate_all_triangle_areas()
|
|
125
|
-
self.calculate_normals()
|
|
126
|
-
self.volume = self.areas * (self.radius / 3)
|
|
127
|
-
|
|
128
|
-
def generate_dataframe(self):
|
|
129
|
-
data = []
|
|
130
|
-
for i, (triangle, center, area) in enumerate(zip(self.triangles, self.centers, self.areas)):
|
|
131
|
-
data.append({
|
|
132
|
-
"Triangle": triangle.tolist(),
|
|
133
|
-
"Center": center.tolist(),
|
|
134
|
-
"Area": area
|
|
135
|
-
})
|
|
136
|
-
df = pd.DataFrame(data)
|
|
137
|
-
return df
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
class Analyzer:
|
|
142
|
-
def __init__(self, simulation, surface=None, slice=None, fields=None, snapshots=(1, 10), interpolator='griddata', method='linear', interp_kwargs=None):
|
|
143
|
-
"""
|
|
144
|
-
General class for performing calculations on 3D surfaces or 2D planes.
|
|
145
|
-
|
|
146
|
-
:param simulation: The simulation object (e.g., fp.Simulation).
|
|
147
|
-
:param surface: The 3D surface object (e.g., Sphere) for 3D calculations.
|
|
148
|
-
:param plane: The 2D plane ('XY', 'XZ', etc.) for 2D calculations.
|
|
149
|
-
:param angle: The angle for slicing the 2D plane (e.g., 'phi=0').
|
|
150
|
-
:param fields: List of fields to load (e.g., ['gasdens', 'gasv']).
|
|
151
|
-
:param snapshots: Tuple indicating the range of snapshots to load (e.g., (1, 10)).
|
|
152
|
-
:param interpolator: Interpolation algorithm ('griddata', 'rbf', etc.).
|
|
153
|
-
:param method: Interpolation method ('linear', 'cubic', etc.).
|
|
154
|
-
:param interp_kwargs: Dict of extra kwargs for the interpolator.
|
|
155
|
-
"""
|
|
156
|
-
self.sim = simulation
|
|
157
|
-
self.surface = surface
|
|
158
|
-
self.slice = slice
|
|
159
|
-
self.fields = fields
|
|
160
|
-
self.snapshots = snapshots
|
|
161
|
-
self.interpolator = interpolator
|
|
162
|
-
self.method = method
|
|
163
|
-
self.interp_kwargs = interp_kwargs or {}
|
|
164
|
-
self.time = None
|
|
165
|
-
self.interpolated_fields = None
|
|
166
|
-
|
|
167
|
-
# Load fields with interpolation
|
|
168
|
-
self.load_fields()
|
|
169
|
-
|
|
170
|
-
def load_fields(self):
|
|
171
|
-
"""
|
|
172
|
-
Loads and interpolates the fields based on the provided configuration.
|
|
173
|
-
Ensures self.interpolated_fields is always a list, even for a single field.
|
|
174
|
-
"""
|
|
175
|
-
if self.surface is not None: # 3D case
|
|
176
|
-
self.interpolated_fields = self.sim.load_field(
|
|
177
|
-
fields=self.fields,
|
|
178
|
-
snapshot=self.snapshots,
|
|
179
|
-
interpolate=True
|
|
180
|
-
)
|
|
181
|
-
# Ensure it's always a list
|
|
182
|
-
if not isinstance(self.interpolated_fields, (list, tuple)):
|
|
183
|
-
self.interpolated_fields = [self.interpolated_fields]
|
|
184
|
-
elif self.slice is not None: # 2D case
|
|
185
|
-
self.interpolated_fields = self.sim.load_field(
|
|
186
|
-
fields=self.fields,
|
|
187
|
-
slice=self.slice,
|
|
188
|
-
snapshot=self.snapshots,
|
|
189
|
-
interpolate=True
|
|
190
|
-
)
|
|
191
|
-
if not isinstance(self.interpolated_fields, (list, tuple)):
|
|
192
|
-
self.interpolated_fields = [self.interpolated_fields]
|
|
107
|
+
self._tessellate_sphere()
|
|
108
|
+
elif self.type == "cylinder":
|
|
109
|
+
self._tessellate_cylinder()
|
|
110
|
+
elif self.type == "plane":
|
|
111
|
+
self._tessellate_plane()
|
|
193
112
|
else:
|
|
194
|
-
raise ValueError("
|
|
113
|
+
raise ValueError("Unsupported surface type. Use 'sphere', 'cylinder', or 'plane'.")
|
|
195
114
|
|
|
115
|
+
def _tessellate_sphere(self):
|
|
116
|
+
"""Construct a spherical triangle tessellation.
|
|
196
117
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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.
|
|
201
123
|
"""
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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()
|
|
234
216
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
else:
|
|
238
|
-
results[field] = field_values
|
|
217
|
+
self.volume = self.areas * (self.radius / 3)
|
|
218
|
+
|
|
239
219
|
|
|
240
|
-
|
|
241
|
-
|
|
220
|
+
def _tessellate_cylinder(self):
|
|
221
|
+
"""Discretize a right circular cylinder into top, bottom and lateral patches.
|
|
242
222
|
|
|
243
|
-
|
|
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.
|
|
244
229
|
"""
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
247
264
|
"""
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
#
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
#
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
#
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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``.
|
|
280
322
|
"""
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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``.
|
|
287
336
|
"""
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
elif self.slice is not None: # 2D case
|
|
306
|
-
n_points = len(self.surface.centers)
|
|
307
|
-
angles = np.linspace(0, 2 * np.pi, n_points, endpoint=False)
|
|
308
|
-
x = self.surface.center[0] + self.surface.radius * np.cos(angles)
|
|
309
|
-
y = self.surface.center[1] + self.surface.radius * np.sin(angles)
|
|
310
|
-
# Select weights according to the integration type
|
|
311
|
-
if dtype == 'line':
|
|
312
|
-
dl = 2 * np.pi * self.surface.radius / n_points
|
|
313
|
-
weights = dl
|
|
314
|
-
elif dtype == 'area':
|
|
315
|
-
weights = np.ones(n_points) # You can define area elements if needed
|
|
316
|
-
else:
|
|
317
|
-
raise ValueError("For 2D, dtype must be 'line' or 'area'.")
|
|
318
|
-
for i, t in enumerate(tqdm(self.time, desc="Calculating integral")):
|
|
319
|
-
field_values = self.evaluate_fields(t, (x, y))
|
|
320
|
-
integrand_values = integrand(**field_values)
|
|
321
|
-
results[i] = np.sum(integrand_values * weights)
|
|
322
|
-
|
|
323
|
-
else:
|
|
324
|
-
raise ValueError("Either a surface (3D) or a slice (2D) must be specified.")
|
|
325
|
-
|
|
326
|
-
return results
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
class FluxAnalyzer3D:
|
|
332
|
-
|
|
333
|
-
def __init__(self, output_dir, sphere_center=(0.0, 0.0, 0.0), radius=1.0, subdivisions=1, snapi=110, snapf=210):
|
|
334
|
-
"""
|
|
335
|
-
Initializes the class with the simulation and sphere parameters.
|
|
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``.
|
|
336
353
|
"""
|
|
337
|
-
self.
|
|
338
|
-
self.radius = radius
|
|
339
|
-
self.data_handler = fargopy.DataHandler(self.sim)
|
|
340
|
-
self.data_handler.load_data(snapi=snapi, snapf=snapf) # Load 3D data using the unified method
|
|
341
|
-
self.sphere = Sphere(radius=radius, subdivisions=subdivisions, center=sphere_center)
|
|
342
|
-
self.sphere.tessellate()
|
|
343
|
-
self.sphere_center = np.array(sphere_center)
|
|
344
|
-
self.time = None
|
|
345
|
-
self.snapi = snapi
|
|
346
|
-
self.snapf = snapf
|
|
347
|
-
self.velocities = None
|
|
348
|
-
self.densities = None
|
|
349
|
-
self.normals = None
|
|
350
|
-
self.flows = None
|
|
351
|
-
|
|
352
|
-
def interpolate(self, time_steps):
|
|
353
|
-
"""Interpolates velocity and density fields at the sphere's points."""
|
|
354
|
-
self.time = np.linspace(0, 1, time_steps)
|
|
355
|
-
xc, yc, zc = self.sphere.centers[:, 0], self.sphere.centers[:, 1], self.sphere.centers[:, 2]
|
|
356
|
-
|
|
357
|
-
self.velocities = np.zeros((time_steps, len(xc), 3))
|
|
358
|
-
self.densities = np.zeros((time_steps, len(xc)))
|
|
359
|
-
|
|
360
|
-
valid_triangles = None # To store valid triangles across all time steps
|
|
361
|
-
|
|
362
|
-
for i, t in enumerate(tqdm(self.time, desc="Interpolating fields")):
|
|
363
|
-
# Interpolate velocity
|
|
364
|
-
velx, vely, velz = self.data_handler.interpolate_velocity(t, xc, yc, zc)
|
|
365
|
-
self.velocities[i, :, 0] = velx
|
|
366
|
-
self.velocities[i, :, 1] = vely
|
|
367
|
-
self.velocities[i, :, 2] = velz
|
|
354
|
+
self.centers = np.mean(self.triangles, axis=1)
|
|
368
355
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _calculate_triangle_area(v1, v2, v3):
|
|
358
|
+
"""Compute the area of a triangle using the cross product.
|
|
372
359
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
else:
|
|
378
|
-
valid_triangles &= valid_mask # Keep only triangles valid across all time steps
|
|
360
|
+
Parameters
|
|
361
|
+
----------
|
|
362
|
+
v1, v2, v3 : array_like
|
|
363
|
+
Triangle vertex coordinates.
|
|
379
364
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
384
375
|
|
|
385
|
-
|
|
376
|
+
def _calculate_all_triangle_areas(self):
|
|
377
|
+
"""Evaluate and cache areas for every triangle in ``self.triangles``.
|
|
386
378
|
|
|
387
|
-
|
|
388
|
-
"""
|
|
389
|
-
|
|
390
|
-
|
|
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)
|
|
391
383
|
|
|
392
|
-
|
|
393
|
-
|
|
384
|
+
def _calculate_normals(self):
|
|
385
|
+
"""Compute outward-facing unit normals for each stored triangle.
|
|
394
386
|
|
|
395
|
-
|
|
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):
|
|
396
393
|
AB = tri[1] - tri[0]
|
|
397
394
|
AC = tri[2] - tri[0]
|
|
398
395
|
normal = np.cross(AB, AC)
|
|
399
396
|
normal /= np.linalg.norm(normal)
|
|
400
397
|
centroid = np.mean(tri, axis=0)
|
|
401
|
-
to_centroid = centroid - self.
|
|
398
|
+
to_centroid = centroid - self.center
|
|
402
399
|
if np.dot(normal, to_centroid) < 0:
|
|
403
400
|
normal = -normal
|
|
404
|
-
self.
|
|
405
|
-
|
|
406
|
-
return self.valid_normals
|
|
401
|
+
self.normals[i] = normal
|
|
407
402
|
|
|
408
|
-
def
|
|
409
|
-
"""
|
|
410
|
-
if self.valid_normals is None:
|
|
411
|
-
self.calculate_normals()
|
|
403
|
+
def tessellate(self):
|
|
404
|
+
"""Recompute the tessellation from current instance parameters.
|
|
412
405
|
|
|
413
|
-
|
|
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'.")
|
|
414
418
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
self.densities[i][self.sphere.areas > 0] * # Use valid densities
|
|
418
|
-
np.einsum('ij,ij->i', self.velocities[i][self.sphere.areas > 0], self.valid_normals) *
|
|
419
|
-
self.valid_areas
|
|
420
|
-
)
|
|
421
|
-
self.flows[i] = (total_flux * self.sim.URHO * self.sim.UL**2 * self.sim.UV) * 1e-3 * 1.587e-23 # en Msun_yr
|
|
419
|
+
def generate_dataframe(self):
|
|
420
|
+
"""Return tessellation metadata as a pandas :class:`DataFrame`.
|
|
422
421
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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.
|
|
426
425
|
"""
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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.
|
|
432
473
|
"""
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
rho_conv = self.sim.URHO * 1e3 # g/cm³ to kg/m³
|
|
439
|
-
|
|
440
|
-
# Convert radius to meters
|
|
441
|
-
r_m = self.radius * self.sim.UL * 1e-2 # cm to m
|
|
442
|
-
|
|
443
|
-
# Convert areas to m² and calculate volume elements
|
|
444
|
-
area_m2 = (self.sim.UL * 1e-2) ** 2 # cm² to m²
|
|
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))
|
|
445
479
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
+
"""
|
|
462
598
|
|
|
463
|
-
|
|
464
|
-
|
|
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)
|
|
465
653
|
|
|
466
|
-
|
|
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
|
+
|
|
467
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
|
+
)
|
|
468
679
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
+
)
|
|
473
692
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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:
|
|
480
767
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
fig = go.Figure()
|
|
484
|
-
fig.add_trace(go.Scatter(
|
|
485
|
-
x=self.time,
|
|
486
|
-
y=self.flows,
|
|
487
|
-
mode='lines',
|
|
488
|
-
name='Flux',
|
|
489
|
-
line=dict(color='dodgerblue', width=2)
|
|
490
|
-
))
|
|
491
|
-
fig.add_trace(go.Scatter(
|
|
492
|
-
x=self.time,
|
|
493
|
-
y=[average_flux]* len(self.time),
|
|
494
|
-
mode='lines',
|
|
495
|
-
name=f'Avg: {average_flux:.2e} Msun/yr',
|
|
496
|
-
line=dict(color='orangered', width=2, dash='dash')
|
|
497
|
-
))
|
|
498
|
-
fig.update_layout(
|
|
499
|
-
title=f"Matter Flux over Planet-Centered Sphere (R={self.radius*self.sim.UL/fp.AU:.3f} [AU])",
|
|
500
|
-
xaxis_title="Normalized Time",
|
|
501
|
-
yaxis_title="Flux [Msun/yr]",
|
|
502
|
-
template="plotly_white",
|
|
503
|
-
font=dict(size=14),
|
|
504
|
-
xaxis=dict(showgrid=True),
|
|
505
|
-
yaxis=dict(showgrid=True,exponentformat="e"),
|
|
506
|
-
)
|
|
507
|
-
fig.show()
|
|
508
|
-
|
|
509
|
-
def planet_sphere(self, snapshot=1):
|
|
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)
|
|
510
770
|
"""
|
|
511
|
-
Plots the density map in both the XZ and XY planes for a given snapshot,
|
|
512
|
-
along with the circle representing the tessellation sphere.
|
|
513
771
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
x_xz, z_xz = mesh_xz.x, mesh_xz.z
|
|
523
|
-
|
|
524
|
-
# Get the density slice and coordinates for the XY plane
|
|
525
|
-
density_slice_xy, mesh_xy = gasdens.meshslice(slice="theta=1.56")
|
|
526
|
-
x_xy, y_xy = mesh_xy.x, mesh_xy.y
|
|
527
|
-
|
|
528
|
-
# Extract sphere center and radius
|
|
529
|
-
sphere_center_x, sphere_center_y, sphere_center_z = self.sphere.center
|
|
530
|
-
sphere_radius = self.sphere.radius * self.sim.UL / fp.AU # Convert radius to AU
|
|
531
|
-
|
|
532
|
-
# Create the figure with two subplots
|
|
533
|
-
fig, axes = plt.subplots(1, 2, figsize=(16, 8))
|
|
534
|
-
|
|
535
|
-
# Plot the density map for the XZ plane
|
|
536
|
-
c1 = axes[0].pcolormesh(
|
|
537
|
-
x_xz,
|
|
538
|
-
z_xz,
|
|
539
|
-
np.log10(density_slice_xz * self.sim.URHO ),
|
|
540
|
-
cmap="Spectral_r",
|
|
541
|
-
shading="auto"
|
|
542
|
-
)
|
|
543
|
-
fig.colorbar(c1, ax=axes[0], label=r"$\log_{10}(\rho)$ [g/cm³]")
|
|
544
|
-
circle_xz = plt.Circle(
|
|
545
|
-
(sphere_center_x, sphere_center_z), # Sphere center in XZ plane
|
|
546
|
-
sphere_radius, # Sphere radius
|
|
547
|
-
color="red",
|
|
548
|
-
fill=False,
|
|
549
|
-
linestyle="--",
|
|
550
|
-
linewidth=3,
|
|
551
|
-
label="Tessellation Sphere"
|
|
552
|
-
)
|
|
553
|
-
axes[0].add_artist(circle_xz)
|
|
554
|
-
axes[0].set_xlabel("X [AU]")
|
|
555
|
-
axes[0].set_ylabel("Z [AU]")
|
|
556
|
-
axes[0].set_xlim(x_xz.min(), x_xz.max())
|
|
557
|
-
axes[0].set_ylim(z_xz.min(), z_xz.max())
|
|
558
|
-
axes[0].legend()
|
|
559
|
-
|
|
560
|
-
# Plot the density map for the XY plane
|
|
561
|
-
c2 = axes[1].pcolormesh(
|
|
562
|
-
x_xy,
|
|
563
|
-
y_xy,
|
|
564
|
-
np.log10(density_slice_xy * self.sim.URHO),
|
|
565
|
-
cmap="Spectral_r",
|
|
566
|
-
shading="auto"
|
|
567
|
-
)
|
|
568
|
-
fig.colorbar(c2, ax=axes[1], label=r"$\log_{10}(\rho)$ [g/cm³]")
|
|
569
|
-
circle_xy = plt.Circle(
|
|
570
|
-
(sphere_center_x, sphere_center_y), # Sphere center in XY plane
|
|
571
|
-
sphere_radius, # Sphere radius
|
|
572
|
-
color="red",
|
|
573
|
-
fill=False,
|
|
574
|
-
linestyle="--",
|
|
575
|
-
linewidth=3,
|
|
576
|
-
label="Tessellation Sphere"
|
|
577
|
-
)
|
|
578
|
-
axes[1].add_artist(circle_xy)
|
|
579
|
-
axes[1].set_xlabel("X [AU]")
|
|
580
|
-
axes[1].set_ylabel("Y [AU]")
|
|
581
|
-
axes[1].set_xlim(x_xy.min(), x_xy.max())
|
|
582
|
-
axes[1].set_ylim(y_xy.min(), y_xy.max())
|
|
583
|
-
axes[1].legend()
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
class FluxAnalyzer2D:
|
|
588
|
-
def __init__(self, output_dir, plane="XY",angle="theta=1.56",snapi=110, snapf=210, center=(0,0),radius=1,subdivisions=10):
|
|
589
|
-
"""
|
|
590
|
-
Initializes the class for 2D flux analysis.
|
|
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)
|
|
591
780
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
:param snapi: Initial snapshot index.
|
|
595
|
-
:param snapf: Final snapshot index.
|
|
596
|
-
"""
|
|
597
|
-
self.sim = fp.Simulation(output_dir=output_dir)
|
|
598
|
-
self.data_handler = fargopy.DataHandler(self.sim)
|
|
599
|
-
self.data_handler.load_data(plane=plane,angle=angle, snapi=snapi, snapf=snapf) # Load 2D data
|
|
600
|
-
self.plane = plane
|
|
601
|
-
self.subdivisions = subdivisions
|
|
602
|
-
self.center = center
|
|
603
|
-
self.radius = radius
|
|
604
|
-
self.angle = angle
|
|
605
|
-
self.snapi = snapi
|
|
606
|
-
self.snapf = snapf
|
|
607
|
-
self.time = None
|
|
608
|
-
self.velocities = None
|
|
609
|
-
self.densities = None
|
|
610
|
-
self.flows = None
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
def interpolate(self, time_steps):
|
|
614
|
-
"""
|
|
615
|
-
Interpolates velocity and density fields at the circle's perimeter points.
|
|
781
|
+
masses = []
|
|
782
|
+
resolutions = []
|
|
616
783
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
y = self.center[1] + self.radius * np.sin(angles)
|
|
625
|
-
z = np.zeros_like(x) # z = 0 for the XY plane
|
|
626
|
-
elif self.plane == "XZ":
|
|
627
|
-
x = self.center[0] + self.radius * np.cos(angles)
|
|
628
|
-
z = self.center[1] + self.radius * np.sin(angles)
|
|
629
|
-
y = np.zeros_like(x) # y = 0 for the XZ plane
|
|
630
|
-
|
|
631
|
-
self.velocities = np.zeros((time_steps, self.subdivisions, 2))
|
|
632
|
-
self.densities = np.zeros((time_steps, self.subdivisions))
|
|
633
|
-
|
|
634
|
-
valid_points = None # To store valid points for all time steps
|
|
635
|
-
|
|
636
|
-
for i, t in enumerate(tqdm(self.time, desc="Interpolating fields")):
|
|
637
|
-
if self.plane == "XY":
|
|
638
|
-
vx, vy = self.data_handler.interpolate_velocity(t, x, y)
|
|
639
|
-
rho = self.data_handler.interpolate_density(t, x, y)
|
|
640
|
-
elif self.plane == "XZ":
|
|
641
|
-
vx, vz = self.data_handler.interpolate_velocity(t, x, z)
|
|
642
|
-
rho = self.data_handler.interpolate_density(t, x, z)
|
|
643
|
-
|
|
644
|
-
# Filter points where density is not zero
|
|
645
|
-
valid_mask = rho > 0
|
|
646
|
-
if valid_points is None:
|
|
647
|
-
valid_points = valid_mask # Initialize valid points
|
|
648
|
-
else:
|
|
649
|
-
valid_points &= valid_mask # Keep only points valid across all time steps
|
|
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
|
|
650
791
|
|
|
651
|
-
|
|
652
|
-
self.velocities[i, valid_mask, 0] = vx[valid_mask]
|
|
653
|
-
self.velocities[i, valid_mask, 1] = vy[valid_mask] if self.plane == "XY" else vz[valid_mask]
|
|
654
|
-
self.densities[i, valid_mask] = rho[valid_mask]
|
|
792
|
+
TH, RR, PH = np.meshgrid(th_arr, r_arr, ph_arr, indexing='ij')
|
|
655
793
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
self.valid_y = y[valid_points]
|
|
660
|
-
self.valid_z = z[valid_points] if self.plane == "XZ" else np.zeros_like(self.valid_x)
|
|
794
|
+
X = RR * np.sin(TH) * np.cos(PH)
|
|
795
|
+
Y = RR * np.sin(TH) * np.sin(PH)
|
|
796
|
+
Z = RR * np.cos(TH)
|
|
661
797
|
|
|
662
|
-
|
|
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)
|
|
663
804
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
"""
|
|
668
|
-
if self.velocities is None or self.densities is None:
|
|
669
|
-
raise ValueError("Fields have not been interpolated. Call interpolate() first.")
|
|
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]
|
|
670
808
|
|
|
671
|
-
|
|
672
|
-
|
|
809
|
+
DR = dr_full[np.newaxis, :, np.newaxis]
|
|
810
|
+
DTH = dth_full[:, np.newaxis, np.newaxis]
|
|
811
|
+
DPH = dph_full[np.newaxis, np.newaxis, :]
|
|
673
812
|
|
|
674
|
-
|
|
813
|
+
dV = (RR**2) * np.sin(TH) * DR * DTH * DPH
|
|
675
814
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
815
|
+
# ----------------------------------------
|
|
816
|
+
# Detect geometry
|
|
817
|
+
# ----------------------------------------
|
|
818
|
+
geom = self.type.lower()
|
|
680
819
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def calculate_accretion(self):
|
|
684
|
-
"""
|
|
685
|
-
Calculates the accretion rate (dM/dt) and the total accreted mass in the 2D plane.
|
|
820
|
+
if geom not in ['sphere', 'cylinder']:
|
|
821
|
+
raise ValueError("Surface.type must be 'sphere' or 'cylinder'")
|
|
686
822
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
- total_accreted_mass: Total accreted mass over the simulation time in Msun.
|
|
690
|
-
"""
|
|
691
|
-
# Ensure densities have been interpolated
|
|
692
|
-
if self.densities is None:
|
|
693
|
-
raise ValueError("Densities have not been interpolated. Call interpolate() first.")
|
|
823
|
+
# Loop over snapshots
|
|
824
|
+
for t in times:
|
|
694
825
|
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
697
830
|
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
700
835
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
# Calculate the total mass in the 2D plane at each time step
|
|
705
|
-
total_mass = np.array([
|
|
706
|
-
np.sum(self.densities[i] * rho_conv * dA_m2) # Mass in kg
|
|
707
|
-
for i in range(len(self.time))
|
|
708
|
-
])
|
|
836
|
+
else:
|
|
837
|
+
xp, yp, zp = self.center
|
|
709
838
|
|
|
710
|
-
|
|
711
|
-
|
|
839
|
+
Xc = X - xp
|
|
840
|
+
Yc = Y - yp
|
|
841
|
+
Zc = Z - zp
|
|
712
842
|
|
|
713
|
-
|
|
714
|
-
|
|
843
|
+
# ---------------------------------------
|
|
844
|
+
# Apply geometry mask
|
|
845
|
+
# ---------------------------------------
|
|
846
|
+
if geom == 'sphere':
|
|
847
|
+
Rlim = self.radius
|
|
848
|
+
mask = (Xc**2 + Yc**2 + Zc**2) <= Rlim**2
|
|
715
849
|
|
|
716
|
-
|
|
717
|
-
|
|
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)
|
|
718
853
|
|
|
719
|
-
|
|
720
|
-
total_mass_msun = np.sum(acc_rate_msun_yr * dt / fp.YEAR) # Convert Msun/yr to Msun
|
|
854
|
+
Hlim = None
|
|
721
855
|
|
|
722
|
-
|
|
856
|
+
elif geom == 'cylinder':
|
|
857
|
+
Rlim = self.radius
|
|
858
|
+
Hlim = self.height # provided by Surface(type='cylinder', height=...)
|
|
723
859
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
line=dict(color='orangered', width=2, dash='dash')
|
|
753
|
-
))
|
|
754
|
-
fig.update_layout(
|
|
755
|
-
title=f"Total Flux over Region (R={self.radius:.3f} [AU])",
|
|
756
|
-
xaxis_title="Normalized Time",
|
|
757
|
-
yaxis_title="Flux [Msun/yr]",
|
|
758
|
-
template="plotly_white",
|
|
759
|
-
font=dict(size=14),
|
|
760
|
-
xaxis=dict(showgrid=True),
|
|
761
|
-
yaxis=dict(showgrid=True, exponentformat="e"),
|
|
762
|
-
)
|
|
763
|
-
fig.show()
|
|
764
|
-
|
|
765
|
-
def plot_region(self, snapshot=1):
|
|
766
|
-
"""
|
|
767
|
-
Plots the density map in 2D with the valid circular perimeter overlaid.
|
|
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
|
+
})
|
|
768
888
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
if self.plane == "XY":
|
|
776
|
-
density_slice, mesh = gasdens.meshslice(slice=self.angle)
|
|
777
|
-
x, y = mesh.x, mesh.y
|
|
778
|
-
elif self.plane == "XZ":
|
|
779
|
-
density_slice, mesh = gasdens.meshslice(slice=self.angle)
|
|
780
|
-
x, y = mesh.x, mesh.z
|
|
781
|
-
else:
|
|
782
|
-
raise ValueError("Invalid plane. Choose 'XY' or 'XZ'.")
|
|
783
|
-
|
|
784
|
-
# Plot the density map
|
|
785
|
-
fig, ax = plt.subplots(figsize=(6, 6))
|
|
786
|
-
c = ax.pcolormesh(
|
|
787
|
-
x, y, np.log10(density_slice * self.sim.URHO),
|
|
788
|
-
cmap="Spectral_r", shading="auto"
|
|
789
|
-
)
|
|
790
|
-
fig.colorbar(c, ax=ax, label=r"$\log_{10}(\rho)$ $[g/cm^3]$")
|
|
791
|
-
|
|
792
|
-
# Add the circular perimeter
|
|
793
|
-
circle = plt.Circle(
|
|
794
|
-
self.center, # Sphere center in the selected plane
|
|
795
|
-
self.radius, # Sphere radius
|
|
796
|
-
color="red",
|
|
797
|
-
fill=False,
|
|
798
|
-
linestyle="--",
|
|
799
|
-
linewidth=2
|
|
800
|
-
)
|
|
801
|
-
ax.add_artist(circle) # Add the circle to the plot
|
|
802
|
-
|
|
803
|
-
# Set plot labels and limits
|
|
804
|
-
ax.set_xlabel(f"{self.plane[0]} [AU]")
|
|
805
|
-
ax.set_ylabel(f"{self.plane[1]} [AU]")
|
|
806
|
-
ax.set_xlim(x.min(), x.max())
|
|
807
|
-
ax.set_ylim(y.min(), y.max())
|
|
808
|
-
ax.legend()
|
|
889
|
+
# Return logic
|
|
890
|
+
if return_resolution:
|
|
891
|
+
return resolutions
|
|
892
|
+
if len(masses) == 1:
|
|
893
|
+
return masses[0]
|
|
894
|
+
return np.array(masses)
|