fargopy 0.3.14__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/__init__.py +2 -1
- fargopy/fields.py +561 -0
- fargopy/flux.py +808 -0
- fargopy/fsimulation.py +299 -473
- fargopy/simulation.py +224 -36
- fargopy/version.py +1 -1
- {fargopy-0.3.14.dist-info → fargopy-0.4.0.dist-info}/METADATA +6 -3
- fargopy-0.4.0.dist-info/RECORD +17 -0
- {fargopy-0.3.14.dist-info → fargopy-0.4.0.dist-info}/WHEEL +1 -1
- fargopy-0.3.14.dist-info/RECORD +0 -16
- {fargopy-0.3.14.data → fargopy-0.4.0.data}/scripts/ifargopy +0 -0
- {fargopy-0.3.14.dist-info → fargopy-0.4.0.dist-info}/entry_points.txt +0 -0
- {fargopy-0.3.14.dist-info → fargopy-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {fargopy-0.3.14.dist-info → fargopy-0.4.0.dist-info}/top_level.txt +0 -0
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()
|