fargopy 0.4.0__py3-none-any.whl → 1.0.1__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/plot.py CHANGED
@@ -7,6 +7,7 @@ import fargopy
7
7
  # Required packages
8
8
  ###############################################################
9
9
  import matplotlib.pyplot as plt
10
+ import numpy as np
10
11
 
11
12
  ###############################################################
12
13
  # Constants
@@ -16,28 +17,50 @@ import matplotlib.pyplot as plt
16
17
  # Classes
17
18
  ###############################################################
18
19
  class Plot(object):
20
+ """Plotting utilities and visualization helpers for FARGO3D data.
21
+
22
+ The ``Plot`` class encapsulates static methods for common plotting tasks,
23
+ such as adding watermarks to figures and creating standardized heatmaps
24
+ for simulation fields.
25
+ """
19
26
 
20
27
  @staticmethod
21
28
  def fargopy_mark(ax):
22
- """Add a water mark to a 2d or 3d plot.
23
-
24
- Parameters:
29
+ """Add a watermark to a 2D or 3D plot.
30
+
31
+ Places a rotated "FARGOpy {version}" watermark in the top-right corner
32
+ of the specified axes.
33
+
34
+ Parameters
35
+ ----------
36
+ ax : matplotlib.axes.Axes
37
+ The axes object where the watermark will be added.
38
+
39
+ Returns
40
+ -------
41
+ matplotlib.text.Text
42
+ The created text object.
43
+
44
+ Examples
45
+ --------
46
+ Add watermark to a plot:
25
47
 
26
- ax: Class axes:
27
- Axe where the watermark will be placed.
48
+ >>> fig, ax = plt.subplots()
49
+ >>> ax.plot([1, 2, 3], [1, 2, 3])
50
+ >>> fp.Plot.fargopy_mark(ax)
28
51
  """
29
52
  #Get the height of axe
30
53
  axh=ax.get_window_extent().transformed(ax.get_figure().dpi_scale_trans.inverted()).height
31
- fig_factor=axh/4
54
+ fig_factor=axh/8
32
55
 
33
56
  #Options of the water mark
34
57
  args=dict(
35
58
  rotation=270,ha='left',va='top',
36
- transform=ax.transAxes,color='pink',fontsize=6*fig_factor,zorder=100
59
+ transform=ax.transAxes,color='pink',fontsize=10*fig_factor,zorder=100
37
60
  )
38
61
 
39
62
  #Text of the water mark
40
- mark=f"FARGOpy {fargopy.version}"
63
+ mark=f"FARGOpy {fargopy.__version__}"
41
64
 
42
65
  #Choose the according to the fact it is a 2d or 3d plot
43
66
  try:
@@ -48,3 +71,525 @@ class Plot(object):
48
71
 
49
72
  text=plt_text(1,1,mark,**args);
50
73
  return text
74
+
75
+
76
+ @staticmethod
77
+ def plot_heatmap(data, x=None, y=None, title="Heatmap", xlabel="X", ylabel="Y", contour_levels=10):
78
+ """Plot a 2D heatmap with pcolormesh and contours.
79
+
80
+ Creates a figure displaying the provided 2D data as a heatmap using a
81
+ reversed Spectral colormap, overlaid with black contour lines.
82
+
83
+ Parameters
84
+ ----------
85
+ data : np.ndarray
86
+ 2D array of data to plot.
87
+ x : np.ndarray, optional
88
+ 1D array of X-axis coordinates.
89
+ y : np.ndarray, optional
90
+ 1D array of Y-axis coordinates.
91
+ title : str, optional
92
+ Plot title (default: "Heatmap").
93
+ xlabel : str, optional
94
+ X-axis label (default: "X").
95
+ ylabel : str, optional
96
+ Y-axis label (default: "Y").
97
+ contour_levels : int or list, optional
98
+ Number of contour levels or specific level values (default: 10).
99
+
100
+ Examples
101
+ --------
102
+ Plot a random heatmap:
103
+
104
+ >>> data = np.random.rand(10, 10)
105
+ >>> fp.Plot.plot_heatmap(data, title="Random Field")
106
+ """
107
+ plt.figure(figsize=(8, 6))
108
+
109
+ if x is not None and y is not None:
110
+ extent = [x.min(), x.max(), y.min(), y.max()]
111
+ X, Y = np.meshgrid(x, y)
112
+ # Plot the heatmap with pcolormesh
113
+ mesh = plt.pcolormesh(X, Y, data, shading='auto', cmap='Spectral_r')
114
+ # Add contour lines
115
+ contours = plt.contour(X, Y, data, levels=contour_levels, colors='black', linewidths=0.5)
116
+ plt.clabel(contours, inline=True, fontsize=8, fmt="%.1f")
117
+ else:
118
+ # Plot the heatmap with pcolormesh
119
+ mesh = plt.pcolormesh(data, shading='auto', cmap='Spectral_r')
120
+ # Add contour lines
121
+ contours = plt.contour(data, levels=contour_levels, colors='black', linewidths=0.5)
122
+ plt.clabel(contours, inline=True, fontsize=8, fmt="%.1f")
123
+
124
+ plt.colorbar(mesh, label="Value")
125
+ plt.title(title)
126
+ plt.xlabel(xlabel)
127
+ plt.ylabel(ylabel)
128
+ plt.show()
129
+
130
+ @staticmethod
131
+ def interactive(sim):
132
+ """
133
+ Interactive plot for the simulation using ipywidgets.
134
+
135
+ Allows selection of density, energy, or velocity (and component) for the colormap.
136
+ Provides controls for slice, resolution, interpolation, streamlines, and Hill radius overlay.
137
+
138
+ Parameters
139
+ ----------
140
+ sim : Simulation
141
+ The simulation object to interact with.
142
+
143
+ Examples
144
+ --------
145
+ >>> fp.Plot.interactive(sim)
146
+ """
147
+ import ipywidgets as widgets
148
+ import matplotlib.pyplot as plt
149
+ from IPython.display import display, clear_output
150
+
151
+ # --- Widgets ---
152
+ time_slider = widgets.IntSlider(min=0, max=sim._get_nsnaps()-1, step=1, value=1, description='Snapshot')
153
+ slice_text = widgets.Text(value='theta=1.568', description='Slice')
154
+ res_slider = widgets.IntSlider(min=50, max=1000, step=10, value=500, description='Res')
155
+ interp_toggle = widgets.ToggleButton(value=False, description='Interpolate', icon='check')
156
+ progress = widgets.Label(value='')
157
+ streamlines_toggle = widgets.ToggleButton(value=False, description='Streamlines', icon='random')
158
+ density_slider = widgets.FloatSlider(min=1, max=10, step=0.5, value=3, description='Stream density')
159
+ hill_frac_slider = widgets.FloatSlider(min=0.1, max=2.0, step=0.05, value=1.0, description='Hill frac')
160
+ show_circle_toggle = widgets.ToggleButton(value=False, description='Show Hill', icon='circle')
161
+ cmap_options = ['Spectral_r', 'viridis', 'plasma', 'inferno', 'magma', 'cividis', 'YlGnBu', 'cubehelix', 'twilight', 'turbo']
162
+ cmap_dropdown = widgets.Dropdown(options=cmap_options, value='Spectral_r', description='Colormap')
163
+ update_button = widgets.Button(description="Update", icon="refresh")
164
+ map_options = ['Densidad', 'Energia', 'Velocidad']
165
+ map_dropdown = widgets.Dropdown(options=map_options, value='Densidad', description='Mapa')
166
+ vel_components = ['vx', 'vy', 'vz']
167
+ vel_dropdown = widgets.Dropdown(options=vel_components, value='vx', description='Componente v')
168
+ vel_dropdown.layout.display = 'none' # Ocultar por defecto
169
+
170
+ def is_fixed(var, slice_str):
171
+ import re
172
+ match = re.search(rf'{var}=([^\[\],]+)', slice_str.replace(' ', ''))
173
+ return match is not None
174
+
175
+ # show or hide velocity component dropdown based on map selection
176
+ def on_map_change(change):
177
+ if change['new'] == 'Velocidad':
178
+ vel_dropdown.layout.display = ''
179
+ else:
180
+ vel_dropdown.layout.display = 'none'
181
+ map_dropdown.observe(on_map_change, names='value')
182
+
183
+ def plot_density(change=None):
184
+ clear_output(wait=True)
185
+ display(
186
+ time_slider, slice_text, res_slider, interp_toggle, streamlines_toggle,
187
+ density_slider, hill_frac_slider, show_circle_toggle, cmap_dropdown, map_dropdown, vel_dropdown, progress, update_button
188
+ )
189
+ import numpy as np
190
+ import re
191
+
192
+ slice_str = slice_text.value
193
+ res = res_slider.value
194
+ interpolate = interp_toggle.value
195
+ show_streamlines = streamlines_toggle.value
196
+ stream_density = density_slider.value
197
+ hill_frac = hill_frac_slider.value
198
+ show_circle = show_circle_toggle.value
199
+ cmap = cmap_dropdown.value
200
+ map_type = map_dropdown.value
201
+ vel_comp = vel_dropdown.value
202
+
203
+ # --- Ejes y nombres de malla ---
204
+ if is_fixed('theta', slice_str):
205
+ xlabel, ylabel = 'X', 'Y'
206
+ mesh_x_name = 'var1_mesh'
207
+ mesh_y_name = 'var2_mesh'
208
+ elif is_fixed('phi', slice_str):
209
+ xlabel, ylabel = 'X', 'Z'
210
+ mesh_x_name = 'var1_mesh'
211
+ mesh_y_name = 'var3_mesh'
212
+ else:
213
+ print("Warning: Please fix either theta or phi for a valid 2D slice (XY or XZ plane).")
214
+ return
215
+
216
+ n = time_slider.value
217
+
218
+ if mesh_y_name == 'var2_mesh':
219
+ vel_dropdown.options = ['vx', 'vy']
220
+ if vel_dropdown.value not in vel_dropdown.options:
221
+ vel_dropdown.value = 'vx'
222
+ elif mesh_y_name == 'var3_mesh':
223
+ vel_dropdown.options = ['vx', 'vz']
224
+ if vel_dropdown.value not in vel_dropdown.options:
225
+ vel_dropdown.value = 'vx'
226
+
227
+ # --- Carga de datos según selección ---
228
+ if map_type == 'Densidad':
229
+ loader = sim.load_field(
230
+ fields=['gasdens', 'gasv'],
231
+ slice=slice_str,
232
+ snapshot=[n],
233
+ interpolate=interpolate
234
+ )
235
+ if interpolate:
236
+ gasdens = loader
237
+ gasv = loader
238
+ else:
239
+ gasdens, gasv = loader
240
+ elif map_type == 'Energia':
241
+ gasenergy_loader = sim.load_field(
242
+ fields='gasenergy',
243
+ slice=slice_str,
244
+ snapshot=[n],
245
+ interpolate=interpolate
246
+ )
247
+ if interpolate:
248
+ gasenergy = gasenergy_loader
249
+ else:
250
+ gasenergy = gasenergy_loader
251
+ gasv_loader = sim.load_field(
252
+ fields='gasv',
253
+ slice=slice_str,
254
+ snapshot=[n],
255
+ interpolate=interpolate
256
+ )
257
+ if interpolate:
258
+ gasv = gasv_loader
259
+ else:
260
+ gasv = gasv_loader
261
+ elif map_type == 'Velocidad':
262
+ gasv_loader = sim.load_field(
263
+ fields='gasv',
264
+ slice=slice_str,
265
+ snapshot=[n],
266
+ interpolate=interpolate
267
+ )
268
+ if interpolate:
269
+ gasv = gasv_loader
270
+ else:
271
+ gasv = gasv_loader
272
+
273
+ # --- Interpolación y selección de variable a graficar ---
274
+ if not interpolate:
275
+ if map_type == 'Densidad':
276
+ X = getattr(gasdens, mesh_x_name)[0]
277
+ Y = getattr(gasdens, mesh_y_name)[0]
278
+ data_map = np.log10(gasdens.gasdens_mesh[0] * sim.URHO)
279
+ elif map_type == 'Energia':
280
+ X = getattr(gasenergy, mesh_x_name)[0]
281
+ Y = getattr(gasenergy, mesh_y_name)[0]
282
+ data_map = np.log10(gasenergy.gasenergy_mesh[0])
283
+ elif map_type == 'Velocidad':
284
+ X = getattr(gasv, mesh_x_name)[0]
285
+ Y = getattr(gasv, mesh_y_name)[0]
286
+ idx = {'vx': 0, 'vy': 1, 'vz': 2}[vel_comp]
287
+ data_map = gasv.gasv_mesh[0][idx]
288
+ vx = vy = vmag = None
289
+ else:
290
+ progress.value = "Interpolando..."
291
+ if mesh_y_name == 'var2_mesh':
292
+ xmin, xmax = getattr(gasv, mesh_x_name)[0].min(), getattr(gasv, mesh_x_name)[0].max()
293
+ ymin, ymax = getattr(gasv, mesh_y_name)[0].min(), getattr(gasv, mesh_y_name)[0].max()
294
+ xs = np.linspace(xmin, xmax, res)
295
+ ys = np.linspace(ymin, ymax, res)
296
+ X, Y = np.meshgrid(xs, ys)
297
+ if map_type == 'Densidad':
298
+ data_map = gasdens.evaluate(time=n, var1=X, var2=Y, field='gasdens')
299
+ data_map = np.log10(data_map * sim.URHO)
300
+ vel = gasv.evaluate(time=n, var1=X, var2=Y, field='gasv')
301
+ vx = vel[0]
302
+ vy = vel[1]
303
+ vmag = np.sqrt(vx**2 + vy**2)
304
+ elif map_type == 'Energia':
305
+ data_map = gasenergy.evaluate(time=n, var1=X, var2=Y, field='gasenergy')
306
+ #data_map = np.log10(data_map)
307
+ vel = gasv.evaluate(time=n, var1=X, var2=Y, field='gasv')
308
+ vx = vel[0]
309
+ vy = vel[1]
310
+ vmag = np.sqrt(vx**2 + vy**2)
311
+ elif map_type == 'Velocidad':
312
+ vel = gasv.evaluate(time=n, var1=X, var2=Y, field='gasv')
313
+ idx = {'vx': 0, 'vy': 1, 'vz': 2}[vel_comp]
314
+ data_map = vel[idx]
315
+ vx = vel[0]
316
+ vy = vel[1]
317
+ vmag = np.sqrt(vx**2 + vy**2)
318
+ else:
319
+ xmin, xmax = getattr(gasv, mesh_x_name)[0].min(), getattr(gasv, mesh_x_name)[0].max()
320
+ zmin, zmax = getattr(gasv, mesh_y_name)[0].min(), getattr(gasv, mesh_y_name)[0].max()
321
+ xs = np.linspace(xmin, xmax, res)
322
+ zs = np.linspace(zmin, zmax, res)
323
+ X, Y = np.meshgrid(xs, zs)
324
+ if map_type == 'Densidad':
325
+ data_map = gasdens.evaluate(time=n, var1=X, var3=Y, field='gasdens')
326
+ data_map = np.log10(data_map * sim.URHO)
327
+ vel = gasv.evaluate(time=n, var1=X, var3=Y, field='gasv')
328
+ vx = vel[0]
329
+ vy = vel[2]
330
+ vmag = np.sqrt(vx**2 + vy**2)
331
+ elif map_type == 'Energia':
332
+ data_map = gasenergy.evaluate(time=n, var1=X, var3=Y, field='gasenergy')
333
+ #data_map = np.log10(data_map)
334
+ vel = gasv.evaluate(time=n, var1=X, var3=Y, field='gasv')
335
+ vx = vel[0]
336
+ vy = vel[2]
337
+ vmag = np.sqrt(vx**2 + vy**2)
338
+ elif map_type == 'Velocidad':
339
+ vel = gasv.evaluate(time=n, var1=X, var3=Y, field='gasv')
340
+
341
+ idx = {'vx': 0, 'vy': 1, 'vz': 2}[vel_comp]
342
+ data_map = vel[idx]
343
+ vx = vel[0]
344
+ vy = vel[2]
345
+ vmag = np.sqrt(vx**2 + vy**2)
346
+
347
+ # --- Máscara por rango r (igual que antes) ---
348
+ r = np.sqrt(X**2 + Y**2)
349
+ r_match = re.search(r"r=\[([0-9\.]+),([0-9\.]+)\]", slice_str.replace(" ", ""))
350
+ if r_match:
351
+ r_min = float(r_match.group(1))
352
+ r_max = float(r_match.group(2))
353
+ else:
354
+ r_min = None
355
+ r_max = None
356
+
357
+ if r_min is not None and r_max is not None:
358
+ mask = (r >= r_min) & (r <= r_max)
359
+ data_map = np.where(mask, data_map, np.nan)
360
+ if show_streamlines and vx is not None and vy is not None and vmag is not None:
361
+ vx = np.where(mask, vx, np.nan)
362
+ vy = np.where(mask, vy, np.nan)
363
+ vmag = np.where(mask, vmag, np.nan)
364
+
365
+ # --- Plot ---
366
+ fig, ax = plt.subplots(figsize=(7,5))
367
+ pcm = ax.pcolormesh(X*sim.UL/sim.AU, Y*sim.UL/sim.AU, data_map, shading='auto', cmap=cmap)
368
+
369
+ # Mostrar streamlines para cualquier tipo de mapa si están disponibles
370
+ stream_obj = None
371
+ if interpolate and show_streamlines and vx is not None and vy is not None:
372
+ stream_obj = ax.streamplot(
373
+ X*sim.UL/sim.AU, Y*sim.UL/sim.AU, vx, vy,
374
+ color=vmag*sim.UL/sim.UT*1e-5 if vmag is not None else None,
375
+ linewidth=0.5,
376
+ density=stream_density,
377
+ cmap='viridis',
378
+ arrowsize=1
379
+ )
380
+
381
+ # --- Hill radius
382
+ planets = sim.load_planets(snapshot=n)
383
+ if planets:
384
+ center_x = planets[0].pos.x
385
+ center_y = planets[0].pos.y
386
+ radius = hill_frac * planets[0].hill_radius
387
+ else:
388
+ center_x = 0
389
+ center_y = 0
390
+ radius = 0
391
+
392
+ if show_circle:
393
+ if is_fixed('theta', slice_str):
394
+ circle = plt.Circle((center_x*sim.UL/sim.AU, center_y*sim.UL/sim.AU), radius*sim.UL/sim.AU, color='black', fill=False, linestyle='--', linewidth=1)
395
+ ax.add_patch(circle)
396
+ elif is_fixed('phi', slice_str):
397
+ theta = np.linspace(0, np.pi, 100)
398
+ x = center_x + radius * np.cos(theta)
399
+ y = center_y + radius * np.sin(theta)
400
+ ax.plot(x*sim.UL/sim.AU, y*sim.UL/sim.AU, color='black', linewidth=2)
401
+
402
+ ax.set_xlabel(xlabel+' [AU]')
403
+ ax.set_ylabel(ylabel+' [AU]')
404
+ #ax.axis('equal')
405
+
406
+ if interpolate and show_streamlines and stream_obj is not None and vmag is not None:
407
+ # Colorbar for velocity magnitude (streamlines)
408
+ cbar = fig.colorbar(stream_obj.lines, ax=ax, label=r'$|v|$ [km/s]')
409
+ else:
410
+ # Colorbar for main map
411
+ if map_type == 'Densidad':
412
+ cbar_label = r'$\log_{10}(\rho) [g/cm^3]$'
413
+ elif map_type == 'Energia':
414
+ cbar_label = r'$\log_{10}(\mathrm{energy})$'
415
+ else:
416
+ cbar_label = f'{vel_comp} [AU]'
417
+ fig.colorbar(pcm, ax=ax, label=cbar_label)
418
+
419
+ fargopy.Plot.fargopy_mark(ax)
420
+ plt.show()
421
+
422
+ # --- Events ---
423
+ update_button.on_click(plot_density)
424
+ slice_text.on_submit(plot_density)
425
+ show_circle_toggle.observe(plot_density, names='value')
426
+ interp_toggle.observe(plot_density, names='value')
427
+ streamlines_toggle.observe(plot_density, names='value')
428
+ cmap_dropdown.observe(plot_density, names='value')
429
+ map_dropdown.observe(plot_density, names='value')
430
+ vel_dropdown.observe(plot_density, names='value')
431
+
432
+ # --- Display inicial ---
433
+ display(
434
+ time_slider, slice_text, res_slider, interp_toggle, streamlines_toggle,
435
+ density_slider, hill_frac_slider, show_circle_toggle, cmap_dropdown, map_dropdown, vel_dropdown, progress, update_button
436
+ )
437
+ plot_density()
438
+
439
+ @staticmethod
440
+ def mesh(sim, snapshot=0, slice='theta=1.56', planet=0, draw_hill=True, hill_frac=1.0,
441
+ figsize=(8,8), point_size=1, line_alpha=0.5, cmap='viridis', show=True):
442
+ """
443
+ Plot the simulation mesh in the XY plane and (optionally) the planet Hill circle.
444
+
445
+ Parameters
446
+ ----------
447
+ sim : Simulation
448
+ The simulation object.
449
+ snapshot : int, optional
450
+ Snapshot to plot, by default 0.
451
+ slice : str, optional
452
+ Slice definition, by default 'theta=1.56'.
453
+ planet : int or str, optional
454
+ Planet index or name to focus, by default 0.
455
+ draw_hill : bool, optional
456
+ Whether to draw the Hill sphere, by default True.
457
+ hill_frac : float, optional
458
+ Fraction of Hill radius to draw, by default 1.0.
459
+ figsize : tuple, optional
460
+ Figure size, by default (8,8).
461
+ point_size : int, optional
462
+ Size of mesh points, by default 1.
463
+ line_alpha : float, optional
464
+ Alpha transparency of mesh lines, by default 0.5.
465
+ cmap : str, optional
466
+ Colormap for points, by default 'viridis'.
467
+ show : bool, optional
468
+ Whether to show the plot, by default True.
469
+
470
+ Returns
471
+ -------
472
+ tuple
473
+ (fig, ax, nr_celdas_radial, nr_celdas_azimutal, n_inside)
474
+ Matplotlib figure and axes, max contiguous radial cells, max contiguous azimuthal cells,
475
+ and the number of mesh cells inside the hill_frac * Hill radius.
476
+
477
+ Examples
478
+ --------
479
+ >>> fp.Plot.mesh(sim, snapshot=0)
480
+ """
481
+ import matplotlib.pyplot as plt
482
+ import matplotlib.patches as patches
483
+ import numpy as np
484
+
485
+ # Load a 2D interpolated field (keeps same interface used elsewhere)
486
+ gasdens = sim.load_field(fields=['gasdens'], snapshot=snapshot, slice=slice, interpolate=True)
487
+
488
+ # Expect interpolator result with var1_mesh / var2_mesh (as used in plot_interactive)
489
+ try:
490
+ X = gasdens.var1_mesh[0]
491
+ Y = gasdens.var2_mesh[0]
492
+ except Exception:
493
+ # Fallback: if a raw Field-like object is returned with mesh names var1_mesh/var2_mesh attributes
494
+ X = getattr(gasdens, 'var1_mesh', None)
495
+ Y = getattr(gasdens, 'var2_mesh', None)
496
+ if X is None or Y is None:
497
+ raise RuntimeError("Could not obtain var1_mesh/var2_mesh from loaded field. Use interpolate=True and a valid slice.")
498
+
499
+ # Prepare figure
500
+ plt.close('all')
501
+ fig, ax = plt.subplots(figsize=figsize)
502
+
503
+ # Plot points (convert to AU for axis if simulation units defined)
504
+ scale = getattr(sim, 'UL', 1.0) / getattr(sim, 'AU', 1.0)
505
+ ax.scatter((X * scale).ravel(), (Y * scale).ravel(), s=point_size, c=(X*0+0.5).ravel(),
506
+ cmap=cmap, marker='.', linewidths=0)
507
+
508
+ # If mesh is 2D arrays, draw grid lines
509
+ if X.ndim == 2 and Y.ndim == 2:
510
+ # rows
511
+ for i in range(X.shape[0]):
512
+ ax.plot(X[i, :]*scale, Y[i, :]*scale, color='gray', linewidth=0.5, alpha=line_alpha)
513
+ # columns
514
+ for j in range(X.shape[1]):
515
+ ax.plot(X[:, j]*scale, Y[:, j]*scale, color='gray', linewidth=0.5, alpha=line_alpha)
516
+
517
+ # Planet selection
518
+ planets = sim.load_planets(snapshot=snapshot)
519
+ center_x = center_y = None
520
+ radius = 0.0
521
+ if planets:
522
+ sel = None
523
+ if isinstance(planet, int):
524
+ try:
525
+ sel = planets[planet]
526
+ except Exception:
527
+ sel = planets[0]
528
+ else:
529
+ # name lookup
530
+ for p in planets:
531
+ if getattr(p, 'name', None) == planet:
532
+ sel = p
533
+ break
534
+ if sel is None:
535
+ sel = planets[0]
536
+ # planet object expected to have pos.x / pos.y and hill_radius property
537
+ center_x = sel.pos.x
538
+ center_y = sel.pos.y
539
+ if draw_hill:
540
+ radius = hill_frac * getattr(sel, 'hill_radius', 0.0)
541
+
542
+ # Draw Hill circle if requested and compute counts
543
+ nr_celdas_radial = 0
544
+ nr_celdas_azimutal = 0
545
+ n_inside = 0
546
+ if draw_hill and center_x is not None and center_y is not None and radius > 0:
547
+ circle = patches.Circle((center_x*scale, center_y*scale), radius*scale,
548
+ edgecolor='red', facecolor='lightblue', linestyle='-', linewidth=1.5)
549
+ ax.add_patch(circle)
550
+
551
+ # Count mesh cells (points) inside the requested fraction of Hill radius
552
+ try:
553
+ # X,Y are in simulation length units (same as center_x, center_y)
554
+ mask_inside = ((X - center_x)**2 + (Y - center_y)**2) <= (radius**2)
555
+ n_inside = int(np.count_nonzero(mask_inside))
556
+
557
+ # If mesh is structured 2D array, compute contiguous runs:
558
+ if X.ndim == 2 and Y.ndim == 2:
559
+ # Helper to get max contiguous True length in a 1D boolean array
560
+ def max_contiguous_true(arr1d):
561
+ idx = np.flatnonzero(arr1d)
562
+ if idx.size == 0:
563
+ return 0
564
+ splits = np.split(idx, np.where(np.diff(idx) > 1)[0] + 1)
565
+ lengths = [s.size for s in splits]
566
+ return max(lengths) if lengths else 0
567
+
568
+ # Azimutal: along rows (axis 1) -> for each row find longest contiguous True segment
569
+ max_az = 0
570
+ for i in range(mask_inside.shape[0]):
571
+ l = max_contiguous_true(mask_inside[i, :])
572
+ if l > max_az:
573
+ max_az = l
574
+ nr_celdas_azimutal = max_az
575
+
576
+ # Radial: along cols (axis 0) -> for each col find longest contiguous True segment
577
+ max_rad = 0
578
+ for j in range(mask_inside.shape[1]):
579
+ l = max_contiguous_true(mask_inside[:, j])
580
+ if l > max_rad:
581
+ max_rad = l
582
+ nr_celdas_radial = max_rad
583
+
584
+ except Exception as e:
585
+ print(f"Warning computing counts: {e}")
586
+
587
+ fargopy.Plot.fargopy_mark(ax)
588
+ ax.set_aspect('equal')
589
+ ax.set_xlabel('x [AU]')
590
+ ax.set_ylabel('y [AU]')
591
+
592
+ if show:
593
+ plt.show()
594
+
595
+ return fig, ax, nr_celdas_radial, nr_celdas_azimutal, n_inside