fargopy 0.3.15__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fargopy/flux.py ADDED
@@ -0,0 +1,808 @@
1
+ ###############################################################
2
+ # FARGOpy interdependencies
3
+ ###############################################################
4
+ import fargopy
5
+
6
+ ###############################################################
7
+ # Required packages
8
+ ###############################################################
9
+ import numpy as np
10
+ import pandas as pd
11
+ import matplotlib.pyplot as plt
12
+ import plotly.graph_objects as go
13
+ from tqdm import tqdm
14
+ import fargopy as fp
15
+
16
+
17
+ class Surface:
18
+ """
19
+ Factory class to generate and manage surfaces (e.g., spheres).
20
+ """
21
+
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
28
+
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)
35
+ self.triangles = np.zeros((self.num_triangles, 3, 3))
36
+ self.centers = np.zeros((self.num_triangles, 3))
37
+ 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]
193
+ else:
194
+ raise ValueError("Either a surface (3D) or a slice (2D) must be specified.")
195
+
196
+
197
+ def evaluate_fields(
198
+ self, time, coordinates,
199
+ griddata_kwargs=None, rbf_kwargs=None, idw_kwargs=None, linearnd_kwargs=None
200
+ ):
201
+ """
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
+ )
234
+
235
+ if field == 'gasv':
236
+ results[field] = np.array(field_values).T
237
+ else:
238
+ results[field] = field_values
239
+
240
+ return results
241
+
242
+
243
+ def hill_radius(self, planet_index=0):
244
+ """
245
+ Calculates the Hill radius of the selected planet using simulation parameters.
246
+ Returns the Hill radius in cm and AU.
247
+ """
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):
280
+ """
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.
287
+ """
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.
336
+ """
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
368
+
369
+ # Interpolate density
370
+ rho = self.data_handler.interpolate_density(t, xc, yc, zc)
371
+ self.densities[i] = rho
372
+
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
379
+
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
384
+
385
+ return self.velocities, self.densities
386
+
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
391
+
392
+ valid_triangles = self.sphere.triangles[self.sphere.areas > 0] # Use valid triangles
393
+ self.valid_normals = np.zeros((len(valid_triangles), 3))
394
+
395
+ for i, tri in enumerate(valid_triangles):
396
+ AB = tri[1] - tri[0]
397
+ AC = tri[2] - tri[0]
398
+ normal = np.cross(AB, AC)
399
+ normal /= np.linalg.norm(normal)
400
+ centroid = np.mean(tri, axis=0)
401
+ to_centroid = centroid - self.sphere_center
402
+ if np.dot(normal, to_centroid) < 0:
403
+ normal = -normal
404
+ self.valid_normals[i] = normal
405
+
406
+ return self.valid_normals
407
+
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()
412
+
413
+ self.flows = np.zeros(len(self.time))
414
+
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
422
+
423
+ return self.flows
424
+
425
+ def calculate_accretion(self):
426
+ """
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.
432
+ """
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²
445
+
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
462
+
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
465
+
466
+ return acc_rate_msun_yr, float(total_mass_msun)
467
+
468
+
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.")
473
+
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
480
+
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):
510
+ """
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
+
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.
591
+
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.
616
+
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
650
+
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]
655
+
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)
661
+
662
+ return self.velocities, self.densities
663
+
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.")
670
+
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
673
+
674
+ self.flows = np.zeros(len(self.time))
675
+
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
680
+
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.
686
+
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.")
694
+
695
+ # Differential area for each subdivision
696
+ dA = (np.pi * self.radius**2) / self.subdivisions # Area of each segment in AU²
697
+
698
+ # Convert density to kg/m² (2D case)
699
+ rho_conv = self.sim.UM/self.sim.UL**2 *10 # g/cm2 to kg/m2
700
+
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
+ ])
709
+
710
+ # Calculate the time step in physical units (seconds)
711
+ dt = (self.time[1] - self.time[0]) * self.sim.UT # Time step in seconds
712
+
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
715
+
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
718
+
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
721
+
722
+ return acc_rate_msun_yr, float(total_mass_msun)
723
+
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.
768
+
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()