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/flux.py CHANGED
@@ -15,794 +15,880 @@ import fargopy as fp
15
15
 
16
16
 
17
17
  class Surface:
18
- """
19
- Factory class to generate and manage surfaces (e.g., spheres).
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
- self.surface = None
24
-
25
- def Sphere(self, radius=1.0, subdivisions=1, center=(0.0, 0.0, 0.0)):
26
- self.surface = self.Sphere(radius, subdivisions, center)
27
- return self.surface
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
- class Sphere:
30
- def __init__(self, radius=1.0, subdivisions=1, center=(0.0, 0.0, 0.0)):
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.triangle_index = 0
39
- self.volume = None
40
- self.tessellate()
41
-
42
- def filter(self, condition):
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("Either a surface (3D) or a slice (2D) must be specified.")
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
- def evaluate_fields(
198
- self, time, coordinates,
199
- griddata_kwargs=None, rbf_kwargs=None, idw_kwargs=None, linearnd_kwargs=None
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
- Evaluate interpolated fields at a given time and coordinates, allowing specific kwargs for each interpolator.
203
-
204
- :param time: The time at which to evaluate.
205
- :param coordinates: The coordinates (x, y, z) or (x, z).
206
- :param griddata_kwargs: Optional kwargs for griddata.
207
- :param rbf_kwargs: Optional kwargs for RBF.
208
- :param idw_kwargs: Optional kwargs for IDW.
209
- :param linearnd_kwargs: Optional kwargs for LinearND.
210
- :return: Dictionary with the field values.
211
- """
212
- results = {}
213
- for field, interp in zip(self.fields, self.interpolated_fields):
214
- # Prepare kwargs in the same format as FieldInterpolator.evaluate
215
- eval_kwargs = {}
216
- if griddata_kwargs is not None:
217
- eval_kwargs["griddata_kwargs"] = griddata_kwargs
218
- if rbf_kwargs is not None:
219
- eval_kwargs["rbf_kwargs"] = rbf_kwargs
220
- if idw_kwargs is not None:
221
- eval_kwargs["idw_kwargs"] = idw_kwargs
222
- if linearnd_kwargs is not None:
223
- eval_kwargs["linearnd_kwargs"] = linearnd_kwargs
224
-
225
- field_values = interp.evaluate(
226
- time=time,
227
- var1=coordinates[0],
228
- var2=coordinates[1],
229
- var3=coordinates[2] if len(coordinates) > 2 else None,
230
- interpolator=self.interpolator,
231
- method=self.method,
232
- **eval_kwargs
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
- if field == 'gasv':
236
- results[field] = np.array(field_values).T
237
- else:
238
- results[field] = field_values
217
+ self.volume = self.areas * (self.radius / 3)
218
+
239
219
 
240
- return results
241
-
220
+ def _tessellate_cylinder(self):
221
+ """Discretize a right circular cylinder into top, bottom and lateral patches.
242
222
 
243
- def hill_radius(self, planet_index=0):
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
- Calculates the Hill radius of the selected planet using simulation parameters.
246
- Returns the Hill radius in cm and AU.
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
- # Conversion constants
249
- AU_to_cm = 1.495978707e13
250
- Mjup_to_g = 1.898e30
251
- Msun_to_g = 1.989e33
252
-
253
- # Check planet data
254
- if not hasattr(self.sim, "planets") or not self.sim.planets:
255
- raise ValueError("No planet data found. Run sim.load_planet_summary() first.")
256
-
257
- # Check stellar mass in macros
258
- if not hasattr(self.sim, "simulation_macros") or 'MSTAR' not in self.sim.simulation_macros:
259
- raise ValueError("Stellar mass (MSTAR) not found. Run sim.load_macros() first.")
260
-
261
- planet = self.sim.planets[planet_index]
262
- a_au = planet['distance'] # AU
263
- m_jup = planet['mass'] # Mjup
264
-
265
- # Get stellar mass in Msun and convert to grams
266
- mstar_msun = self.sim.simulation_macros['MSTAR']
267
- mstar_g = float(mstar_msun) * Msun_to_g
268
-
269
- # Convert planet mass to grams and distance to cm
270
- a_cm = a_au * AU_to_cm
271
- m_p = m_jup * Mjup_to_g
272
-
273
- # Hill radius formula
274
- r_hill_cm = a_cm * (m_p / (3 * mstar_g))**(1/3)
275
- r_hill_au = r_hill_cm / AU_to_cm
276
-
277
- return r_hill_cm, r_hill_au
278
-
279
- def calculate_integral(self, integrand, time_steps, dtype):
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
- Calculates an integral based on the provided integrand and integration type.
282
-
283
- :param integrand: A callable function defining the integrand.
284
- :param time_steps: Number of time steps for the calculation.
285
- :param type: 'line', 'area', or 'volume' (default: 'area').
286
- :return: Array of results for each time step.
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
- self.time = np.linspace(0, 1, time_steps)
289
- results = np.zeros(len(self.time))
290
-
291
- if self.surface is not None: # 3D case
292
- xc, yc, zc = self.surface.centers[:, 0], self.surface.centers[:, 1], self.surface.centers[:, 2]
293
- # Select weights according to the integration type
294
- if dtype == 'volume':
295
- weights = self.surface.volume
296
- elif dtype == 'area':
297
- weights = self.surface.areas
298
- else:
299
- raise ValueError("For 3D, dtype must be 'area' or 'volume'.")
300
- for i, t in enumerate(tqdm(self.time, desc="Calculating integral")):
301
- field_values = self.evaluate_fields(t, (xc, yc, zc))
302
- integrand_values = integrand(**field_values)
303
- results[i] = np.sum(integrand_values * weights)
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.sim = fp.Simulation(output_dir=output_dir)
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
- # Interpolate density
370
- rho = self.data_handler.interpolate_density(t, xc, yc, zc)
371
- self.densities[i] = rho
356
+ @staticmethod
357
+ def _calculate_triangle_area(v1, v2, v3):
358
+ """Compute the area of a triangle using the cross product.
372
359
 
373
- # Filter triangles where density is greater than 0
374
- valid_mask = rho > 0
375
- if valid_triangles is None:
376
- valid_triangles = valid_mask # Initialize valid triangles
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
- # Update sphere centers and areas to include only valid triangles
381
- self.valid_centers = self.sphere.centers[valid_triangles]
382
- self.valid_areas = self.sphere.areas[valid_triangles]
383
- self.valid_normals = None # Normals will be recalculated for valid triangles
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
- return self.velocities, self.densities
376
+ def _calculate_all_triangle_areas(self):
377
+ """Evaluate and cache areas for every triangle in ``self.triangles``.
386
378
 
387
- def calculate_normals(self):
388
- """Calculates the normal vectors of the valid triangles."""
389
- if self.valid_normals is not None:
390
- return self.valid_normals # Use cached normals if already calculated
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
- valid_triangles = self.sphere.triangles[self.sphere.areas > 0] # Use valid triangles
393
- self.valid_normals = np.zeros((len(valid_triangles), 3))
384
+ def _calculate_normals(self):
385
+ """Compute outward-facing unit normals for each stored triangle.
394
386
 
395
- for i, tri in enumerate(valid_triangles):
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.sphere_center
398
+ to_centroid = centroid - self.center
402
399
  if np.dot(normal, to_centroid) < 0:
403
400
  normal = -normal
404
- self.valid_normals[i] = normal
405
-
406
- return self.valid_normals
401
+ self.normals[i] = normal
407
402
 
408
- def calculate_fluxes(self):
409
- """Calculates the total flux at each time step."""
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
- self.flows = np.zeros(len(self.time))
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
- for i in range(len(self.time)):
416
- total_flux = np.sum(
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
- return self.flows
424
-
425
- def calculate_accretion(self):
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
- Calculates the accretion rate (dM/dt) and the total accreted mass inside the sphere.
428
-
429
- :return: A tuple containing:
430
- - accretion_rate: Array of accretion rates at each time step in Msun/yr.
431
- - total_accreted_mass: Total accreted mass over the simulation time in Msun.
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
- # Ensure densities have been interpolated
434
- if self.densities is None:
435
- raise ValueError("Densities have not been interpolated. Call interpolate() first.")
436
-
437
- # Convert density to kg/m³
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
- vol_elem = self.sphere.areas * area_m2 * (r_m / 3) # m³
447
-
448
- # Calculate the total mass inside the sphere at each time step
449
- total_mass = np.array([
450
- np.sum(self.densities[i] * rho_conv * vol_elem) # Mass in kg
451
- for i in range(len(self.time))
452
- ])
453
-
454
- # Calculate the time step in physical units (seconds)
455
- dt = (self.time[1] - self.time[0]) * self.sim.UT # Time step in seconds
456
-
457
- # Calculate the accretion rate as the time derivative of the total mass
458
- acc_rate = np.gradient(total_mass, dt) # dM/dt in kg/s
459
-
460
- # Convert accretion rate to Msun/yr
461
- acc_rate_msun_yr = acc_rate * (1 / 1.989e30) * fp.YEAR # Convert kg/s to Msun/yr
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
- # Calculate the total accreted mass (in Msun)
464
- total_mass_msun = np.sum(acc_rate_msun_yr * dt / fp.YEAR) # Convert Msun/yr to Msun
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
- return acc_rate_msun_yr, float(total_mass_msun)
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
- def plot_fluxes(self):
470
- """Plots the total flux as a function of time."""
471
- if self.flows is None:
472
- raise ValueError("Flows have not been calculated. Call calculate_flows() first.")
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
- #times
475
- duration=(self.snapf - self.snapi + 1) * self.sim.UT / fp.YEAR
476
- times= self.time * duration
477
-
478
- start_time = self.snapi * self.sim.UT / fp.YEAR
479
- times += start_time
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
- average_flux = np.mean(self.flows)
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
- Parameters:
515
- snapshot (int): The snapshot to visualize (default is 1).
516
- """
517
- # Load the density field for the snapshot
518
- gasdens = self.sim.load_field("gasdens", snapshot=snapshot, type="scalar")
519
-
520
- # Get the density slice and coordinates for the XZ plane
521
- density_slice_xz, mesh_xz = gasdens.meshslice(slice="phi=0")
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
- :param output_dir: Directory containing simulation data.
593
- :param plane: Plane to analyze ("XY" or "XZ").
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
- :param time_steps: Number of time steps for interpolation.
618
- """
619
- self.time = np.linspace(0, 1, time_steps)
620
- angles = np.linspace(0, 2 * np.pi, self.subdivisions, endpoint=False)
621
-
622
- if self.plane == "XY":
623
- x = self.center[0] + self.radius * np.cos(angles)
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
- # Store interpolated values for valid points
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
- # Update angles and coordinates to only include valid points
657
- self.valid_angles = angles[valid_points]
658
- self.valid_x = x[valid_points]
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
- return self.velocities, self.densities
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
- def calculate_fluxes(self):
665
- """
666
- Calculates the total flux at each time step.
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
- normals = np.stack((np.cos(self.valid_angles), np.sin(self.valid_angles)), axis=1)
672
- dl = 2 * np.pi * self.radius / self.subdivisions # Differential length
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
- self.flows = np.zeros(len(self.time))
813
+ dV = (RR**2) * np.sin(TH) * DR * DTH * DPH
675
814
 
676
- for i in range(len(self.time)):
677
- velocity_dot_normal = np.einsum('ij,ij->i', self.velocities[i, :len(self.valid_angles)], normals)
678
- total_flux = np.sum(self.densities[i, :len(self.valid_angles)] * velocity_dot_normal * dl)
679
- self.flows[i] = (total_flux * self.sim.URHO * self.sim.UL**2 * self.sim.UV)* 1e-3 * 1.587e-23 # Convert to physical units
815
+ # ----------------------------------------
816
+ # Detect geometry
817
+ # ----------------------------------------
818
+ geom = self.type.lower()
680
819
 
681
- return self.flows
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
- :return: A tuple containing:
688
- - accretion_rate: Array of accretion rates at each time step in Msun/yr.
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
- # Differential area for each subdivision
696
- dA = (np.pi * self.radius**2) / self.subdivisions # Area of each segment in AU²
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
- # Convert density to kg/m² (2D case)
699
- rho_conv = self.sim.UM/self.sim.UL**2 *10 # g/cm2 to kg/m2
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
- # Convert dA to m²
702
- dA_m2 = dA * (self.sim.UL * 1e-2)**2 # Convert from cm² to m²
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
- # Calculate the time step in physical units (seconds)
711
- dt = (self.time[1] - self.time[0]) * self.sim.UT # Time step in seconds
839
+ Xc = X - xp
840
+ Yc = Y - yp
841
+ Zc = Z - zp
712
842
 
713
- # Calculate the accretion rate as the time derivative of the total mass
714
- acc_rate = np.gradient(total_mass, dt) # dM/dt in kg/s
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
- # Convert accretion rate to Msun/yr
717
- acc_rate_msun_yr = acc_rate * (1 / 1.989e30) * fp.YEAR # Convert kg/s to Msun/yr
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
- # Calculate the total accreted mass (in Msun)
720
- total_mass_msun = np.sum(acc_rate_msun_yr * dt / fp.YEAR) # Convert Msun/yr to Msun
854
+ Hlim = None
721
855
 
722
- return acc_rate_msun_yr, float(total_mass_msun)
856
+ elif geom == 'cylinder':
857
+ Rlim = self.radius
858
+ Hlim = self.height # provided by Surface(type='cylinder', height=...)
723
859
 
724
- def plot_fluxes(self):
725
- """
726
- Plots the total flux as a function of time.
727
- """
728
- if self.flows is None:
729
- raise ValueError("Flows have not been calculated. Call calculate_fluxes() first.")
730
-
731
- # Convert time to physical units
732
- duration = (self.snapf - self.snapi + 1) * self.sim.UT / fp.YEAR
733
- times = self.time * duration
734
- start_time = self.snapi * self.sim.UT / fp.YEAR
735
- times += start_time
736
-
737
- average_flux = np.mean(self.flows)
738
-
739
- fig = go.Figure()
740
- fig.add_trace(go.Scatter(
741
- x=self.time,
742
- y=self.flows,
743
- mode='lines',
744
- name='Flux',
745
- line=dict(color='dodgerblue', width=2)
746
- ))
747
- fig.add_trace(go.Scatter(
748
- x=self.time,
749
- y=[average_flux] * len(times),
750
- mode='lines',
751
- name=f'Avg: {average_flux:.2e} [Msun/yr]',
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
- :param snapshot: Snapshot to visualize.
770
- """
771
- # Load the density field for the snapshot
772
- gasdens = self.sim.load_field("gasdens", snapshot=snapshot, type="scalar")
773
-
774
- # Get the density slice and coordinates for the selected plane
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)