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.
@@ -0,0 +1,2111 @@
1
+ #!python
2
+ import numpy as np
3
+ from PyQt5.QtWidgets import (
4
+ QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QCheckBox, QLineEdit,
5
+ QGridLayout, QSpinBox, QDoubleSpinBox, QGroupBox, QFrame, QSizePolicy, QFileDialog,
6
+ QDialog, QTextEdit, QFormLayout, QDialogButtonBox
7
+ )
8
+ from PyQt5.QtCore import Qt, QTimer
9
+ from PyQt5.QtGui import QFont, QIcon, QPixmap
10
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
11
+ from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
12
+ from matplotlib.figure import Figure
13
+ import sys
14
+ import fargopy as fp
15
+ import matplotlib as plt
16
+ plt.style.use('seaborn-v0_8-whitegrid')
17
+ from matplotlib.animation import FuncAnimation
18
+ from matplotlib.animation import FFMpegWriter
19
+ import os
20
+ import re
21
+ import subprocess
22
+
23
+ COLORMAPS = ['Spectral_r', 'viridis', 'plasma', 'inferno', 'magma', 'cividis', 'YlGnBu', 'cubehelix', 'twilight', 'turbo']
24
+ MAP_TYPES = ['Density', 'Energy', 'Velocity']
25
+ VELOCITY_COMPONENTS = ['vx', 'vy', 'vz']
26
+ VEL_INDEX = {'vx': 0, 'vy': 1, 'vz': 2}
27
+
28
+ # Unit helpers reused across dialogs and plotting
29
+ LENGTH_FACTORS = {
30
+ "cgs": {
31
+ "cm (CGS)": 1.0,
32
+ "m (MKS)": 100.0,
33
+ "AU": 1.495978707e13,
34
+ "Earth radii": 6.371e8,
35
+ "Jupiter radii": 7.1492e9,
36
+ "Solar radii": 6.957e10,
37
+ },
38
+ "mks": {
39
+ "cm (CGS)": 0.01,
40
+ "m (MKS)": 1.0,
41
+ "AU": 1.495978707e11,
42
+ "Earth radii": 6.371e6,
43
+ "Jupiter radii": 7.1492e7,
44
+ "Solar radii": 6.957e8,
45
+ },
46
+ }
47
+
48
+ MASS_FACTORS = {
49
+ "cgs": {
50
+ "g (CGS)": 1.0,
51
+ "kg (MKS)": 1000.0,
52
+ "Earth masses": 5.9722e27,
53
+ "Jupiter masses": 1.89813e30,
54
+ "Solar masses": 1.98847e33,
55
+ },
56
+ "mks": {
57
+ "g (CGS)": 0.001,
58
+ "kg (MKS)": 1.0,
59
+ "Earth masses": 5.9722e24,
60
+ "Jupiter masses": 1.89813e27,
61
+ "Solar masses": 1.98847e30,
62
+ },
63
+ }
64
+
65
+ AXIS_UNIT_LABELS = {
66
+ "cgs": {
67
+ "Simulation UL": "UL",
68
+ "cm (CGS)": "cm",
69
+ "m (MKS)": "m",
70
+ "AU": "AU",
71
+ "Earth radii": "R$_\\oplus$",
72
+ "Jupiter radii": "R$_J$",
73
+ "Solar radii": "R$_\\odot$",
74
+ },
75
+ "mks": {
76
+ "Simulation UL": "UL (m)",
77
+ "cm (CGS)": "cm",
78
+ "m (MKS)": "m",
79
+ "AU": "AU",
80
+ "Earth radii": "R$_\\oplus$",
81
+ "Jupiter radii": "R$_J$",
82
+ "Solar radii": "R$_\\odot$",
83
+ },
84
+ }
85
+
86
+
87
+ def _units_table(unitsys: str, table: dict):
88
+ """Return the conversion table for the requested unit system."""
89
+ return table["mks"] if str(unitsys).lower() == "mks" else table["cgs"]
90
+
91
+ class SimInfoDialog(QDialog):
92
+ def __init__(self, sim, parent=None):
93
+ super().__init__(parent)
94
+ self.sim = sim
95
+ self.setWindowTitle("Simulation Info & Units")
96
+ self.setMinimumWidth(500)
97
+ layout = QFormLayout(self)
98
+
99
+ # Store initial units for reset
100
+ self.initial_unitsystem = getattr(self.sim, "unitsystem", "cgs")
101
+ self.initial_UL = getattr(self.sim, "UL", 1.0)
102
+ self.initial_UM = getattr(self.sim, "UM", 1.0)
103
+
104
+ # --- Units selector ---
105
+ self.units_combo = QComboBox()
106
+ self.units_combo.addItems(["CGS", "MKS"])
107
+ try:
108
+ current_units = self.sim.unitsystem.upper()
109
+ except Exception:
110
+ current_units = "CGS"
111
+ self.units_combo.setCurrentText(current_units)
112
+ layout.addRow("Units system:", self.units_combo)
113
+
114
+ # --- UL controls ---
115
+ self.ul_spin = QDoubleSpinBox()
116
+ self.ul_spin.setDecimals(3)
117
+ self.ul_spin.setMaximum(1e20)
118
+ self.ul_spin.setValue(getattr(self.sim, "UL", 1.0))
119
+ layout.addRow("UL (length unit):", self.ul_spin)
120
+
121
+ self.ul_unit_combo = QComboBox()
122
+ self.ul_unit_combo.addItems([
123
+ "cm (CGS)", "m (MKS)", "Earth radii", "Jupiter radii", "Solar radii", "AU"
124
+ ])
125
+ layout.addRow("UL as:", self.ul_unit_combo)
126
+
127
+ # --- UM controls ---
128
+ self.um_spin = QDoubleSpinBox()
129
+ self.um_spin.setDecimals(3)
130
+ self.um_spin.setMaximum(1e30)
131
+ self.um_spin.setValue(getattr(self.sim, "UM", 1.0))
132
+ layout.addRow("UM (mass unit):", self.um_spin)
133
+
134
+ self.um_unit_combo = QComboBox()
135
+ self.um_unit_combo.addItems([
136
+ "g (CGS)", "kg (MKS)", "Earth masses", "Jupiter masses", "Solar masses"
137
+ ])
138
+ layout.addRow("UM as:", self.um_unit_combo)
139
+
140
+ # Info area
141
+ self.info_text = QTextEdit()
142
+ self.info_text.setReadOnly(True)
143
+ self.update_info_text()
144
+ layout.addRow("Simulation properties:", self.info_text)
145
+
146
+ # Buttons
147
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Apply)
148
+ layout.addRow(buttons)
149
+ buttons.accepted.connect(self.accept)
150
+ buttons.button(QDialogButtonBox.Apply).clicked.connect(self.apply_changes)
151
+
152
+ # Reset units button
153
+ self.reset_button = QPushButton("Reset Units")
154
+ layout.addRow(self.reset_button)
155
+ self.reset_button.clicked.connect(self.reset_units)
156
+
157
+ # Connect signals
158
+ self.units_combo.currentTextChanged.connect(self.apply_unitsystem)
159
+ self.ul_unit_combo.currentTextChanged.connect(self.update_ul_display)
160
+ self.um_unit_combo.currentTextChanged.connect(self.update_um_display)
161
+
162
+ def update_ul_display(self):
163
+ ul = getattr(self.sim, "UL", 1.0)
164
+ unitsys = self.units_combo.currentText()
165
+ factors = _units_table(unitsys, LENGTH_FACTORS)
166
+ unit_label = self.ul_unit_combo.currentText()
167
+
168
+ if unit_label.startswith("Earth radii"):
169
+ value = ul / factors["Earth radii"]
170
+ elif unit_label.startswith("Jupiter radii"):
171
+ value = ul / factors["Jupiter radii"]
172
+ elif unit_label.startswith("Solar radii"):
173
+ value = ul / factors["Solar radii"]
174
+ elif unit_label.endswith("AU"):
175
+ value = ul / factors["AU"]
176
+ elif unit_label.endswith("cm"):
177
+ value = ul / factors["cm (CGS)"]
178
+ elif unit_label.endswith("m"):
179
+ value = ul / factors["m (MKS)"]
180
+ else:
181
+ value = ul
182
+ self.ul_spin.setValue(value)
183
+
184
+ def update_um_display(self):
185
+ um = getattr(self.sim, "UM", 1.0)
186
+ unitsys = self.units_combo.currentText()
187
+ factors = _units_table(unitsys, MASS_FACTORS)
188
+ unit_label = self.um_unit_combo.currentText()
189
+
190
+ if unit_label.startswith("Earth masses"):
191
+ value = um / factors["Earth masses"]
192
+ elif unit_label.startswith("Jupiter masses"):
193
+ value = um / factors["Jupiter masses"]
194
+ elif unit_label.startswith("Solar masses"):
195
+ value = um / factors["Solar masses"]
196
+ elif unit_label.endswith("g"):
197
+ value = um / factors["g (CGS)"]
198
+ elif unit_label.endswith("kg"):
199
+ value = um / factors["kg (MKS)"]
200
+ else:
201
+ value = um
202
+ self.um_spin.setValue(value)
203
+
204
+ def update_info_text(self):
205
+ try:
206
+ props = self.sim.load_properties()
207
+ domain_info = ""
208
+ try:
209
+ rmin = self.sim.domains.r.min()
210
+ rmax = self.sim.domains.r.max()
211
+ domain_info += f"r domain: [{rmin:.3g}, {rmax:.3g}]\n"
212
+ except Exception:
213
+ domain_info += "r domain: not available\n"
214
+ try:
215
+ thetamin = self.sim.domains.theta.min()
216
+ thetamax = self.sim.domains.theta.max()
217
+ domain_info += f"theta domain: [{thetamin:.3g}, {thetamax:.3g}]\n"
218
+ except Exception:
219
+ domain_info += "theta domain: not available\n"
220
+ try:
221
+ phimin = self.sim.domains.phi.min()
222
+ phimax = self.sim.domains.phi.max()
223
+ domain_info += f"phi domain: [{phimin:.3g}, {phimax:.3g}]\n"
224
+ except Exception:
225
+ domain_info += "phi domain: not available\n"
226
+ full_info = f"{props}\n\n{domain_info}"
227
+ self.info_text.setText(full_info)
228
+ except Exception as e:
229
+ self.info_text.setText(f"Error loading properties:\n{e}")
230
+
231
+ def apply_unitsystem(self, text):
232
+ self.sim.units(text.lower())
233
+ self.ul_spin.setValue(getattr(self.sim, "UL", 1.0))
234
+ self.um_spin.setValue(getattr(self.sim, "UM", 1.0))
235
+ self.update_ul_display()
236
+ self.update_um_display()
237
+ self.update_info_text()
238
+ if self.parent() and hasattr(self.parent(), "plot_density"):
239
+ self.parent().plot_density(self.units_combo.currentText())
240
+
241
+ def apply_changes(self):
242
+ unitsys = self.units_combo.currentText()
243
+ length_factors = _units_table(unitsys, LENGTH_FACTORS)
244
+ mass_factors = _units_table(unitsys, MASS_FACTORS)
245
+ unitsys_upper = str(unitsys).upper()
246
+
247
+ ul_val = self.ul_spin.value()
248
+ ul_unit = self.ul_unit_combo.currentText()
249
+ if ul_unit.startswith("Earth radii"):
250
+ ul = ul_val * length_factors["Earth radii"]
251
+ elif ul_unit.startswith("Jupiter radii"):
252
+ ul = ul_val * length_factors["Jupiter radii"]
253
+ elif ul_unit.startswith("Solar radii"):
254
+ ul = ul_val * length_factors["Solar radii"]
255
+ elif ul_unit.endswith("AU"):
256
+ ul = ul_val * length_factors["AU"]
257
+ elif ul_unit.endswith("cm"):
258
+ ul = ul_val if unitsys_upper == "CGS" else ul_val * 100
259
+ elif ul_unit.endswith("m"):
260
+ ul = ul_val if unitsys_upper == "MKS" else ul_val / 100
261
+ else:
262
+ ul = ul_val
263
+
264
+ um_val = self.um_spin.value()
265
+ um_unit = self.um_unit_combo.currentText()
266
+ if um_unit.startswith("Earth masses"):
267
+ um = um_val * mass_factors["Earth masses"]
268
+ elif um_unit.startswith("Jupiter masses"):
269
+ um = um_val * mass_factors["Jupiter masses"]
270
+ elif um_unit.startswith("Solar masses"):
271
+ um = um_val * mass_factors["Solar masses"]
272
+ elif um_unit.endswith("g"):
273
+ if unitsys_upper == "CGS":
274
+ um = um_val * mass_factors["g (CGS)"]
275
+ else:
276
+ um = um_val * 1000
277
+ elif um_unit.endswith("kg"):
278
+ if unitsys_upper == "MKS":
279
+ um = um_val * mass_factors["kg (MKS)"]
280
+ else:
281
+ um = um_val / 1000
282
+ else:
283
+ um = um_val
284
+
285
+ self.sim.set_units(UL=ul, UM=um)
286
+ self.update_ul_display()
287
+ self.update_um_display()
288
+ self.update_info_text()
289
+ if self.parent() and hasattr(self.parent(), "plot_density"):
290
+ self.parent().plot_density(self.units_combo.currentText())
291
+
292
+ def reset_units(self):
293
+ self.sim.units(str(self.initial_unitsystem).lower())
294
+ self.sim.set_units(UL=self.initial_UL, UM=self.initial_UM)
295
+ self.units_combo.setCurrentText(str(self.initial_unitsystem).upper())
296
+ self.ul_spin.setValue(self.initial_UL)
297
+ self.um_spin.setValue(self.initial_UM)
298
+ self.update_ul_display()
299
+ self.update_um_display()
300
+ self.update_info_text()
301
+ if self.parent() and hasattr(self.parent(), "plot_density"):
302
+ self.parent().plot_density(self.units_combo.currentText())
303
+
304
+ class PlotOptionsDialog(QDialog):
305
+ def __init__(self, parent):
306
+ super().__init__(parent)
307
+ self.setWindowTitle("Graph Options")
308
+ self.setMinimumWidth(400)
309
+ self.parent = parent
310
+
311
+ layout = QFormLayout(self)
312
+
313
+ # Main colormap
314
+ self.cmap_dropdown = QComboBox()
315
+ self.cmap_dropdown.addItems(COLORMAPS)
316
+ layout.addRow("Colormap:", self.cmap_dropdown)
317
+
318
+ # Streamlines colormap
319
+ self.stream_cmap_dropdown = QComboBox()
320
+ self.stream_cmap_dropdown.addItems(COLORMAPS)
321
+ layout.addRow("Streamlines colormap:", self.stream_cmap_dropdown)
322
+
323
+ # Map type
324
+ self.map_dropdown = QComboBox()
325
+ self.map_dropdown.addItems(MAP_TYPES)
326
+ layout.addRow("Map type:", self.map_dropdown)
327
+
328
+ # Velocity component
329
+ self.vel_dropdown = QComboBox()
330
+ self.vel_dropdown.addItems(VELOCITY_COMPONENTS)
331
+ layout.addRow("Velocity component:", self.vel_dropdown)
332
+
333
+ # Fixed colorbar
334
+ self.fixed_cbar_checkbox = QCheckBox("Fixed colorbar range")
335
+ layout.addRow(self.fixed_cbar_checkbox)
336
+
337
+ # Reference snapshot
338
+ self.fixed_cbar_snap_spin = QSpinBox()
339
+ self.fixed_cbar_snap_spin.setMinimum(0)
340
+ self.fixed_cbar_snap_spin.setMaximum(0)
341
+ self.fixed_cbar_snap_spin.setValue(1)
342
+ layout.addRow("Reference snapshot:", self.fixed_cbar_snap_spin)
343
+
344
+ # --- Manual vmin/vmax controls ---
345
+ self.manual_vmin_vmax_checkbox = QCheckBox("Set vmin/vmax manually (log10 scale)")
346
+ layout.addRow(self.manual_vmin_vmax_checkbox)
347
+
348
+ self.vmin_spin = QDoubleSpinBox()
349
+ self.vmin_spin.setDecimals(2)
350
+ self.vmin_spin.setMinimum(-30)
351
+ self.vmin_spin.setMaximum(30)
352
+ self.vmin_spin.setValue(0.0)
353
+ layout.addRow("vmin (log10):", self.vmin_spin)
354
+
355
+ self.vmax_spin = QDoubleSpinBox()
356
+ self.vmax_spin.setDecimals(2)
357
+ self.vmax_spin.setMinimum(-30)
358
+ self.vmax_spin.setMaximum(30)
359
+ self.vmax_spin.setValue(1.0)
360
+ layout.addRow("vmax (log10):", self.vmax_spin)
361
+
362
+ # Streamlines arrow size
363
+ self.stream_arrow_size_spin = QDoubleSpinBox()
364
+ self.stream_arrow_size_spin.setDecimals(2)
365
+ self.stream_arrow_size_spin.setMinimum(0.1)
366
+ self.stream_arrow_size_spin.setMaximum(5.0)
367
+ self.stream_arrow_size_spin.setSingleStep(0.1)
368
+ self.stream_arrow_size_spin.setValue(parent.stream_arrow_size if hasattr(parent, "stream_arrow_size") else 1.0)
369
+ layout.addRow("Streamlines arrow size:", self.stream_arrow_size_spin)
370
+
371
+ # Hill radius color selector
372
+ self.hill_color_combo = QComboBox()
373
+ self.hill_color_combo.addItems([
374
+ "red", "blue", "white", "black", "green", "yellow", "magenta", "cyan", "orange", "gray"
375
+ ])
376
+ self.hill_color_combo.setCurrentText(getattr(parent, "hill_color", "red"))
377
+ layout.addRow("Hill radius color:", self.hill_color_combo)
378
+
379
+ # Buttons
380
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Apply)
381
+ layout.addRow(buttons)
382
+ buttons.accepted.connect(self.accept)
383
+ buttons.button(QDialogButtonBox.Apply).clicked.connect(self.apply_changes)
384
+
385
+ # Initialize values from parent
386
+ self.sync_from_parent()
387
+
388
+ # Connections
389
+ self.map_dropdown.currentTextChanged.connect(self.on_map_change)
390
+ self.fixed_cbar_checkbox.stateChanged.connect(self.on_fixed_cbar_toggle)
391
+ self.manual_vmin_vmax_checkbox.stateChanged.connect(self.on_manual_vmin_vmax_toggle)
392
+
393
+ def sync_from_parent(self):
394
+ p = self.parent
395
+ self.cmap_dropdown.setCurrentText(p.cmap_dropdown.currentText())
396
+ self.stream_cmap_dropdown.setCurrentText(p.stream_cmap_dropdown.currentText())
397
+ self.map_dropdown.setCurrentText(p.map_dropdown.currentText())
398
+ self.vel_dropdown.setCurrentText(p.vel_dropdown.currentText())
399
+ self.fixed_cbar_checkbox.setChecked(p.fixed_cbar_enabled)
400
+ self.fixed_cbar_snap_spin.setMaximum(p.fixed_cbar_snap_spin.maximum())
401
+ self.fixed_cbar_snap_spin.setValue(p.fixed_cbar_snap_spin.value())
402
+ self.vel_dropdown.setEnabled(self.map_dropdown.currentText() == 'Velocity')
403
+ self.manual_vmin_vmax_checkbox.setChecked(p.manual_vmin_vmax_enabled)
404
+ self.vmin_spin.setValue(p.manual_vmin)
405
+ self.vmax_spin.setValue(p.manual_vmax)
406
+ self.vmin_spin.setEnabled(p.manual_vmin_vmax_enabled)
407
+ self.vmax_spin.setEnabled(p.manual_vmin_vmax_enabled)
408
+ self.stream_arrow_size_spin.setValue(getattr(p, "stream_arrow_size", 1.0))
409
+ self.hill_color_combo.setCurrentText(getattr(p, "hill_color", "red"))
410
+
411
+ def apply_changes(self):
412
+ p = self.parent
413
+ p.cmap_dropdown.setCurrentText(self.cmap_dropdown.currentText())
414
+ p.stream_cmap_dropdown.setCurrentText(self.stream_cmap_dropdown.currentText())
415
+ p.map_dropdown.setCurrentText(self.map_dropdown.currentText())
416
+ p.vel_dropdown.setCurrentText(self.vel_dropdown.currentText())
417
+ p.fixed_cbar_enabled = self.fixed_cbar_checkbox.isChecked()
418
+ p.fixed_cbar_snap_spin.setValue(self.fixed_cbar_snap_spin.value())
419
+ p.manual_vmin_vmax_enabled = self.manual_vmin_vmax_checkbox.isChecked()
420
+ p.manual_vmin = self.vmin_spin.value()
421
+ p.manual_vmax = self.vmax_spin.value()
422
+ p.stream_arrow_size = self.stream_arrow_size_spin.value()
423
+ p.hill_color = self.hill_color_combo.currentText()
424
+ if p.fixed_cbar_enabled:
425
+ p.update_fixed_cbar_limits()
426
+ p.plot_density()
427
+ self.sync_from_parent()
428
+
429
+ def on_map_change(self, text):
430
+ self.vel_dropdown.setEnabled(text == 'Velocity')
431
+
432
+ def on_fixed_cbar_toggle(self, state):
433
+ # Notify parent to update when fixed colorbar setting changes
434
+ if self.parent.fixed_cbar_enabled:
435
+ self.parent.update_fixed_cbar_limits()
436
+ self.parent.plot_density()
437
+
438
+ def on_fixed_cbar_snap_change(self, value):
439
+ if self.fixed_cbar_enabled:
440
+ self.update_fixed_cbar_limits()
441
+ self.plot_density()
442
+
443
+ def on_manual_vmin_vmax_toggle(self, state):
444
+ enabled = bool(state)
445
+ self.vmin_spin.setEnabled(enabled)
446
+ self.vmax_spin.setEnabled(enabled)
447
+
448
+ class ReflectDialog(QDialog):
449
+ """Lightweight dialog to toggle the reflection overlay."""
450
+
451
+ AXES = ["X-axis", "Y-axis", "Origin"]
452
+
453
+ def __init__(self, parent=None, enabled=False, axis="X-axis"):
454
+ super().__init__(parent)
455
+ self.setWindowTitle("Reflect Overlay")
456
+ self.setMinimumWidth(320)
457
+
458
+ layout = QVBoxLayout(self)
459
+ description = QLabel("Reflect the current slice across a selected axis \nand overlay it on top of the original map.")
460
+ description.setWordWrap(True)
461
+ layout.addWidget(description)
462
+
463
+ form = QFormLayout()
464
+ self.enabled_checkbox = QCheckBox("Show reflected copy")
465
+ self.enabled_checkbox.setChecked(enabled)
466
+ form.addRow(self.enabled_checkbox)
467
+
468
+ self.axis_combo = QComboBox()
469
+ self.axis_combo.addItems(self.AXES)
470
+ if axis in self.AXES:
471
+ self.axis_combo.setCurrentText(axis)
472
+ self.axis_combo.setEnabled(enabled)
473
+ form.addRow("Axis:", self.axis_combo)
474
+ layout.addLayout(form)
475
+
476
+ # Keep controls in sync
477
+ self.enabled_checkbox.stateChanged.connect(self.axis_combo.setEnabled)
478
+
479
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
480
+ buttons.accepted.connect(self.accept)
481
+ buttons.rejected.connect(self.reject)
482
+ layout.addWidget(buttons)
483
+
484
+ def values(self):
485
+ return self.enabled_checkbox.isChecked(), self.axis_combo.currentText()
486
+
487
+ class VideoOptionsDialog(QDialog):
488
+ def __init__(self, parent=None, nmax=100):
489
+ super().__init__(parent)
490
+ self.setWindowTitle("Video Options")
491
+ self.setMinimumWidth(350)
492
+ layout = QFormLayout(self)
493
+
494
+ self.fps_spin = QSpinBox()
495
+ self.fps_spin.setMinimum(1)
496
+ self.fps_spin.setMaximum(60)
497
+ self.fps_spin.setValue(8)
498
+ layout.addRow("Frames per second (FPS):", self.fps_spin)
499
+
500
+ self.bitrate_spin = QSpinBox()
501
+ self.bitrate_spin.setMinimum(100)
502
+ self.bitrate_spin.setMaximum(10000)
503
+ self.bitrate_spin.setValue(1800)
504
+ layout.addRow("Bitrate (kbps):", self.bitrate_spin)
505
+
506
+ self.start_snap_spin = QSpinBox()
507
+ self.start_snap_spin.setMinimum(0)
508
+ self.start_snap_spin.setMaximum(nmax)
509
+ self.start_snap_spin.setValue(0)
510
+ layout.addRow("Start snapshot:", self.start_snap_spin)
511
+
512
+ self.end_snap_spin = QSpinBox()
513
+ self.end_snap_spin.setMinimum(0)
514
+ self.end_snap_spin.setMaximum(nmax)
515
+ self.end_snap_spin.setValue(nmax)
516
+ layout.addRow("End snapshot:", self.end_snap_spin)
517
+
518
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
519
+ layout.addRow(buttons)
520
+ buttons.accepted.connect(self.accept)
521
+ buttons.rejected.connect(self.reject)
522
+ self.setLayout(layout)
523
+
524
+ class RecordingProgressDialog(QDialog):
525
+ def __init__(self, parent=None):
526
+ super().__init__(parent)
527
+ self.setWindowTitle("Recording video")
528
+ self.setModal(True)
529
+ layout = QVBoxLayout(self)
530
+ self.status_label = QLabel("Recording... Press Stop to finish early.")
531
+ layout.addWidget(self.status_label)
532
+ self.stop_button = QPushButton("Stop recording")
533
+ layout.addWidget(self.stop_button)
534
+ self.stop_button.clicked.connect(self._request_stop)
535
+ self.stop_requested = False
536
+
537
+ def _request_stop(self):
538
+ self.stop_requested = True
539
+ self.stop_button.setEnabled(False)
540
+ self.status_label.setText("Stopping… finishing current frame.")
541
+
542
+ class PlotInteractiveWindow(QWidget):
543
+ def __init__(self):
544
+ super().__init__()
545
+ self.sim = None
546
+
547
+ # --- Plot options (hidden widgets used for logic/dialog) ---
548
+ self.cmap_dropdown = QComboBox()
549
+ self.cmap_dropdown.addItems(COLORMAPS)
550
+ self.stream_cmap_dropdown = QComboBox()
551
+ self.stream_cmap_dropdown.addItems(COLORMAPS)
552
+ self.map_dropdown = QComboBox()
553
+ self.map_dropdown.addItems(MAP_TYPES)
554
+ self.vel_dropdown = QComboBox()
555
+ self.vel_dropdown.addItems(VELOCITY_COMPONENTS)
556
+ self.fixed_cbar_checkbox = QCheckBox("Fixed colorbar range")
557
+ self.fixed_cbar_snap_spin = QSpinBox()
558
+ self.fixed_cbar_snap_spin.setMinimum(0)
559
+ self.fixed_cbar_snap_spin.setMaximum(0)
560
+ self.fixed_cbar_snap_spin.setValue(1)
561
+ self.stream_arrow_size_spin = QDoubleSpinBox()
562
+ self.stream_arrow_size_spin.setDecimals(2)
563
+ self.stream_arrow_size_spin.setMinimum(0.1)
564
+ self.stream_arrow_size_spin.setMaximum(5.0)
565
+ self.stream_arrow_size_spin.setSingleStep(0.1)
566
+ self.stream_arrow_size_spin.setValue(1.0)
567
+
568
+ # --- Manual vmin/vmax state ---
569
+ self.manual_vmin_vmax_enabled = False
570
+ self.manual_vmin = 0.0 # log10 value
571
+ self.manual_vmax = 1.0 # log10 value
572
+
573
+ # --- Fixed colorbar state ---
574
+ self.fixed_cbar_enabled = False # <-- Add this line to initialize the attribute
575
+
576
+ # --- Reflection overlay state ---
577
+ self.reflect_enabled = False
578
+ self.reflect_axis = "X-axis"
579
+
580
+ self.init_ui()
581
+ self.slice_type = "theta"
582
+ self.last_slice_str = ""
583
+
584
+ def _set_velocity_options(self, components):
585
+ """Only touch the velocity dropdown if the options actually change."""
586
+ desired = list(components)
587
+ current = [self.vel_dropdown.itemText(i) for i in range(self.vel_dropdown.count())]
588
+ if current == desired:
589
+ return
590
+ self.vel_dropdown.blockSignals(True)
591
+ self.vel_dropdown.clear()
592
+ self.vel_dropdown.addItems(desired)
593
+ self.vel_dropdown.blockSignals(False)
594
+
595
+ def _reflect_coordinates(self, axis, X, Y):
596
+ if axis == "X-axis":
597
+ return X, -Y
598
+ if axis == "Y-axis":
599
+ return -X, Y
600
+ return -X, -Y # Origin (both axes)
601
+
602
+ def _reflect_vectors(self, axis, vx, vy):
603
+ if axis == "X-axis":
604
+ return vx, -vy
605
+ if axis == "Y-axis":
606
+ return -vx, vy
607
+ return -vx, -vy
608
+
609
+ def _length_scale_info(self):
610
+ if not self.sim:
611
+ return None, None, getattr(self, "length_scale_combo", None), "cgs"
612
+ sim_unitsys = getattr(self.sim, "unitsystem", "cgs").lower()
613
+ length_unit = self.length_scale_combo.currentText()
614
+ unit_factors = dict(_units_table(sim_unitsys, LENGTH_FACTORS))
615
+ unit_factors["Simulation UL"] = self.sim.UL
616
+ scale_factor = unit_factors.get(length_unit, self.sim.UL)
617
+ axis_labels = AXIS_UNIT_LABELS.get(sim_unitsys, AXIS_UNIT_LABELS["cgs"])
618
+ axis_label = axis_labels.get(length_unit, length_unit)
619
+ return scale_factor, axis_label, sim_unitsys, length_unit
620
+
621
+ def _length_input_to_sim_units(self, value_str):
622
+ if not value_str or not self.sim:
623
+ return value_str
624
+ try:
625
+ value = float(value_str)
626
+ except ValueError:
627
+ return value_str
628
+ scale_factor, _, _, _ = self._length_scale_info()
629
+ if not scale_factor:
630
+ return value_str
631
+ converted = value * scale_factor / self.sim.UL
632
+ return f"{converted:.10g}"
633
+
634
+ def _sim_length_to_display(self, value):
635
+ if value is None or not self.sim:
636
+ return value
637
+ scale_factor, _, _, _ = self._length_scale_info()
638
+ if not scale_factor:
639
+ return value
640
+ display_val = value * self.sim.UL / scale_factor
641
+ return f"{display_val:.3f}"
642
+
643
+ def _format_angle(self, value):
644
+ try:
645
+ return f"{float(value):.3f}"
646
+ except (TypeError, ValueError):
647
+ return str(value) if value is not None else ""
648
+
649
+ def init_ui(self):
650
+ self.setFont(QFont("Segoe UI", 13))
651
+
652
+ logo_label = QLabel()
653
+ # Reduce logo size to occupy less vertical space
654
+ try:
655
+ import pkg_resources
656
+ logo_path = pkg_resources.resource_filename('fargopy', 'data/fargopy_logo.png')
657
+ if not os.path.exists(logo_path):
658
+ # Fallback to local file if not installed as package or running from source w/o install
659
+ logo_path = "fargopy_logo.png"
660
+ logo_pixmap = QPixmap(logo_path)
661
+ except Exception:
662
+ logo_pixmap = QPixmap("fargopy_logo.png")
663
+
664
+ logo_pixmap = logo_pixmap.scaledToWidth(300, Qt.SmoothTransformation) # <-- previously 340
665
+ logo_label.setPixmap(logo_pixmap)
666
+ logo_label.setAlignment(Qt.AlignCenter)
667
+ logo_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
668
+
669
+ controls_group = QGroupBox("Visualization controls")
670
+ controls_group.setFont(QFont("Segoe UI", 15, QFont.Bold))
671
+ controls_layout = QGridLayout()
672
+ controls_layout.setHorizontalSpacing(14)
673
+ controls_layout.setVerticalSpacing(10)
674
+
675
+ # --- Path selection for simulation (FIRST) ---
676
+ self.path_line = QLineEdit()
677
+ self.path_line.setText("")
678
+ self.path_line.setPlaceholderText("Select simulation path...")
679
+ self.path_line.setReadOnly(True)
680
+ self.browse_button = QPushButton("Browse...")
681
+ self.browse_button.setStyleSheet("""
682
+ QPushButton {
683
+ background-color: #ff9800;
684
+ color: white;
685
+ font-size: 13px;
686
+ font-weight: bold;
687
+ border-radius: 8px;
688
+ padding: 6px 12px;
689
+ }
690
+ QPushButton:hover {
691
+ background-color: #e65100;
692
+ }
693
+ """)
694
+ controls_layout.addWidget(QLabel("Simulation path:"), 0, 0)
695
+ controls_layout.addWidget(self.path_line, 0, 1)
696
+ controls_layout.addWidget(self.browse_button, 1, 1)
697
+
698
+ # --- Simulation Info/Units button ---
699
+ self.info_button = QPushButton("Simulation Info / Units")
700
+ self.info_button.setStyleSheet("""
701
+ QPushButton {
702
+ background-color: #ff9800;
703
+ color: white;
704
+ font-size: 14px;
705
+ font-weight: bold;
706
+ border-radius: 8px;
707
+ padding: 8px 16px;
708
+ }
709
+ QPushButton:hover {
710
+ background-color: #e65100;
711
+ }
712
+ """)
713
+ self.info_button.setEnabled(False)
714
+ controls_layout.addWidget(self.info_button, 1, 0)
715
+
716
+ # --- Snapshot (disabled until sim loaded) ---
717
+ self.time_slider = QSpinBox()
718
+ self.time_slider.setEnabled(False)
719
+ controls_layout.addWidget(QLabel("Snapshot:"), 2, 0)
720
+ controls_layout.addWidget(self.time_slider, 2, 1)
721
+
722
+ # --- Slices: compact and aligned (disabled until sim loaded) ---
723
+ slice_grid = QGridLayout()
724
+ slice_grid.setHorizontalSpacing(6)
725
+ slice_grid.setVerticalSpacing(4)
726
+ slice_grid.addWidget(QLabel(""), 0, 0, alignment=Qt.AlignCenter)
727
+ min_label = QLabel("min")
728
+ min_label.setAlignment(Qt.AlignCenter)
729
+ slice_grid.addWidget(min_label, 0, 1)
730
+ max_label = QLabel("max")
731
+ max_label.setAlignment(Qt.AlignCenter)
732
+ slice_grid.addWidget(max_label, 0, 2)
733
+
734
+ self.r_min = QLineEdit()
735
+ self.r_max = QLineEdit()
736
+ self.theta_min = QLineEdit()
737
+ self.theta_max = QLineEdit()
738
+ self.phi_min = QLineEdit()
739
+ self.phi_max = QLineEdit()
740
+ for edit in [self.r_min, self.r_max, self.theta_min, self.theta_max, self.phi_min, self.phi_max]:
741
+ edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
742
+ edit.setMaximumHeight(32)
743
+ edit.setEnabled(False)
744
+
745
+ slice_grid.addWidget(QLabel("r:"), 1, 0, alignment=Qt.AlignRight | Qt.AlignVCenter)
746
+ slice_grid.addWidget(self.r_min, 1, 1)
747
+ slice_grid.addWidget(self.r_max, 1, 2)
748
+ slice_grid.addWidget(QLabel("θ:"), 2, 0, alignment=Qt.AlignRight | Qt.AlignVCenter)
749
+ slice_grid.addWidget(self.theta_min, 2, 1)
750
+ slice_grid.addWidget(self.theta_max, 2, 2)
751
+ slice_grid.addWidget(QLabel("φ:"), 3, 0, alignment=Qt.AlignRight | Qt.AlignVCenter)
752
+ slice_grid.addWidget(self.phi_min, 3, 1)
753
+ slice_grid.addWidget(self.phi_max, 3, 2)
754
+
755
+ # --- Slice type selector inside the slice box ---
756
+ self.slice_type_combo = QComboBox()
757
+ self.slice_type_combo.addItems(['theta', 'phi'])
758
+ self.slice_type_combo.setCurrentText('theta')
759
+ self.slice_type_combo.currentTextChanged.connect(self.on_slice_type_change)
760
+ slice_grid.addWidget(QLabel("Slice type:"), 4, 0)
761
+ slice_grid.addWidget(self.slice_type_combo, 4, 1, 1, 2)
762
+
763
+ slice_box = QGroupBox("Slices")
764
+ slice_box.setLayout(slice_grid)
765
+ slice_box.setStyleSheet("""
766
+ QGroupBox {
767
+ font-weight: normal;
768
+ border: 1px solid #bdbdbd;
769
+ border-radius: 6px;
770
+ margin-top: 4px;
771
+ padding: 2px 2px 2px 2px;
772
+ color: #f5f5f5;
773
+ background: #23272b;
774
+ }
775
+ """)
776
+ slice_box.setSizePolicy(slice_box.sizePolicy().horizontalPolicy(), QSizePolicy.Fixed)
777
+ controls_layout.addWidget(slice_box, 3, 0, 1, 2, alignment=Qt.AlignLeft | Qt.AlignTop)
778
+
779
+ # --- Other controls (disabled until sim loaded) ---
780
+ self.res_slider = QSpinBox()
781
+ self.res_slider.setMinimum(50)
782
+ self.res_slider.setMaximum(1000)
783
+ self.res_slider.setSingleStep(10)
784
+ self.res_slider.setValue(500)
785
+ self.res_slider.setEnabled(False)
786
+ controls_layout.addWidget(QLabel("Resolution:"), 4, 0)
787
+ controls_layout.addWidget(self.res_slider, 4, 1)
788
+
789
+ self.interp_toggle = QCheckBox("Interpolate")
790
+ self.interp_toggle.setEnabled(False)
791
+ controls_layout.addWidget(self.interp_toggle, 5, 0)
792
+
793
+ self.streamlines_toggle = QCheckBox("Streamlines")
794
+ self.streamlines_toggle.setEnabled(False)
795
+ controls_layout.addWidget(self.streamlines_toggle, 5, 1)
796
+
797
+ self.density_slider = QDoubleSpinBox()
798
+ self.density_slider.setMinimum(1)
799
+ self.density_slider.setMaximum(10)
800
+ self.density_slider.setSingleStep(0.5)
801
+ self.density_slider.setValue(3)
802
+ self.density_slider.setEnabled(False)
803
+ controls_layout.addWidget(QLabel("Streamline density:"), 6, 0)
804
+ controls_layout.addWidget(self.density_slider, 6, 1)
805
+
806
+ self.hill_frac_slider = QDoubleSpinBox()
807
+ self.hill_frac_slider.setMinimum(0.1)
808
+ self.hill_frac_slider.setMaximum(2.0)
809
+ self.hill_frac_slider.setSingleStep(0.05)
810
+ self.hill_frac_slider.setValue(1.0)
811
+ self.hill_frac_slider.setEnabled(False)
812
+ controls_layout.addWidget(QLabel("Hill fraction:"), 7, 0)
813
+ controls_layout.addWidget(self.hill_frac_slider, 7, 1)
814
+
815
+ self.show_circle_toggle = QCheckBox("Show Hill")
816
+ controls_layout.addWidget(self.show_circle_toggle, 8, 0)
817
+
818
+ self.reflect_button = QPushButton("Reflect")
819
+ self.reflect_button.setEnabled(False)
820
+ self.reflect_button.setCursor(Qt.PointingHandCursor)
821
+ self.reflect_button.setFixedHeight(32)
822
+ self.reflect_button.setStyleSheet(
823
+ """
824
+ QPushButton {
825
+ background-color: #3949ab;
826
+ color: white;
827
+ font-size: 13px;
828
+ border-radius: 6px;
829
+ padding: 4px 10px;
830
+ }
831
+ QPushButton:hover {
832
+ background-color: #283593;
833
+ }
834
+ """
835
+ )
836
+ controls_layout.addWidget(self.reflect_button, 8, 1, alignment=Qt.AlignRight)
837
+
838
+ # --- Update plot button ---
839
+ self.update_button = QPushButton("Update plot")
840
+ self.update_button.setIcon(QIcon.fromTheme("view-refresh"))
841
+ self.update_button.setEnabled(False)
842
+ controls_layout.addWidget(self.update_button, 13, 0, 1, 2)
843
+
844
+ # --- Status label ---
845
+ self.status_label = QLabel("")
846
+ self.status_label.setAlignment(Qt.AlignCenter)
847
+ self.status_label.setFont(QFont("Segoe UI", 12, QFont.Bold))
848
+ controls_layout.addWidget(self.status_label, 14, 0, 1, 2)
849
+
850
+ # --- Length scale selector for axes (always allow all units) ---
851
+ self.length_scale_combo = QComboBox()
852
+ self.length_scale_combo.addItems([
853
+ "Simulation UL", "cm (CGS)", "m (MKS)", "AU", "Earth radii", "Jupiter radii", "Solar radii"
854
+ ])
855
+ self.length_scale_combo.setCurrentText("Simulation UL")
856
+ controls_layout.addWidget(QLabel("Length axis in:"), 15, 0)
857
+ controls_layout.addWidget(self.length_scale_combo, 15, 1)
858
+ self.length_scale_combo.currentTextChanged.connect(lambda _: self.plot_density())
859
+
860
+ # --- Graph options button ---
861
+ self.plot_options_button = QPushButton("Graph Options")
862
+ self.plot_options_button.setStyleSheet("""
863
+ QPushButton {
864
+ background-color: #ff9800;
865
+ color: white;
866
+ font-size: 14px;
867
+ font-weight: bold;
868
+ border-radius: 8px;
869
+ padding: 8px 16px;
870
+ }
871
+ QPushButton:hover {
872
+ background-color: #e65100;
873
+ }
874
+ """)
875
+ controls_layout.addWidget(self.plot_options_button, 20, 0, 1, 2)
876
+
877
+ # --- Button to create video ---
878
+ self.video_button = QPushButton("Create video")
879
+ controls_layout.addWidget(self.video_button, 21, 0, 1, 2)
880
+ self.video_button.clicked.connect(self.open_video_options_dialog)
881
+
882
+ # --- Controls reserved for dialog (excluded from main panel) ---
883
+ # Do not create or add these widgets to the main panel:
884
+ # self.cmap_dropdown
885
+ # self.stream_cmap_dropdown
886
+ # self.map_dropdown
887
+ # self.vel_dropdown
888
+ # self.fixed_cbar_checkbox
889
+ # self.fixed_cbar_snap_spin
890
+ # self.density_min_thresh_spin
891
+ # self.density_max_thresh_spin
892
+
893
+ controls_group.setLayout(controls_layout)
894
+ controls_group.setStyleSheet("""
895
+ QGroupBox {
896
+ font-weight: bold;
897
+ border: 1.5px solid #1976D2;
898
+ border-radius: 8px;
899
+ margin-top: 8px;
900
+ padding: 16px;
901
+ color: #f5f5f5;
902
+ }
903
+ QGroupBox:title {
904
+ subcontrol-origin: margin;
905
+ left: 10px;
906
+ padding: 0 3px 0 3px;
907
+ }
908
+ """)
909
+
910
+ self.update_button.setStyleSheet("""
911
+ QPushButton {
912
+ background-color: #ff9800;
913
+ color: white;
914
+ font-size: 15px;
915
+ font-weight: bold;
916
+ border-radius: 8px;
917
+ padding: 10px 20px;
918
+ }
919
+ QPushButton:hover {
920
+ background-color: #e65100;
921
+ }
922
+ """)
923
+ for widget in [self.time_slider, self.res_slider, self.density_slider, self.hill_frac_slider]:
924
+ widget.setStyleSheet("""
925
+ QSpinBox, QDoubleSpinBox {
926
+ background: #2c3136;
927
+ border: 1px solid #444;
928
+ border-radius: 4px;
929
+ padding: 2px 4px;
930
+ color: #f5f5f5;
931
+ font-size: 14px;
932
+ }
933
+ """)
934
+
935
+ left_panel_widget = QWidget()
936
+ left_panel_layout = QVBoxLayout(left_panel_widget)
937
+ left_panel_layout.setSpacing(8)
938
+ left_panel_layout.setContentsMargins(24, 12, 24, 12)
939
+ left_panel_layout.addWidget(logo_label, alignment=Qt.AlignHCenter)
940
+ left_panel_layout.addWidget(controls_group, stretch=1)
941
+
942
+ left_panel_widget.setMinimumWidth(420)
943
+ left_panel_widget.setMaximumWidth(520)
944
+ left_panel_widget.setStyleSheet("""
945
+ QWidget {
946
+ background-color: #23272b;
947
+ }
948
+ QLabel, QCheckBox {
949
+ color: #f5f5f5;
950
+ background: #23272b;
951
+ font-size: 15px;
952
+ }
953
+ QGroupBox {
954
+ color: #f5f5f5;
955
+ }
956
+ QLineEdit, QSpinBox, QDoubleSpinBox {
957
+ background: #2c3136;
958
+ border: 1px solid #444;
959
+ color: #f5f5f5;
960
+ font-size: 15px;
961
+ }
962
+ QComboBox {
963
+ background: #2c3136;
964
+ border: 1px solid #444;
965
+ color: #f5f5f5;
966
+ selection-background-color: #1976D2;
967
+ selection-color: #fff;
968
+ font-size: 15px;
969
+ }
970
+ QComboBox QAbstractItemView {
971
+ background: #23272b;
972
+ color: #f5f5f5;
973
+ selection-background-color: #1976D2;
974
+ selection-color: #fff;
975
+ font-size: 15px;
976
+ }
977
+ QPushButton {
978
+ background-color: #ff9800;
979
+ color: white;
980
+ font-size: 15px;
981
+ }
982
+ QPushButton:hover {
983
+ background-color: #e65100;
984
+ }
985
+ """)
986
+
987
+ self.figure = Figure(figsize=(7, 5))
988
+ self.canvas = FigureCanvas(self.figure)
989
+ self.toolbar = NavigationToolbar(self.canvas, self)
990
+
991
+ self.show_logo_on_canvas()
992
+
993
+ right_panel = QVBoxLayout()
994
+ right_panel.addWidget(self.toolbar)
995
+ right_panel.addWidget(self.canvas)
996
+
997
+ h_layout = QHBoxLayout()
998
+ h_layout.addWidget(left_panel_widget, 0)
999
+ line = QFrame()
1000
+ line.setFrameShape(QFrame.VLine)
1001
+ line.setFrameShadow(QFrame.Sunken)
1002
+ h_layout.addWidget(line)
1003
+ h_layout.addLayout(right_panel, 1)
1004
+
1005
+ self.setLayout(h_layout)
1006
+
1007
+ # Connect signals
1008
+ self.update_button.clicked.connect(self.update_plot)
1009
+ self.browse_button.clicked.connect(self.select_simulation_path)
1010
+ self.info_button.clicked.connect(self.show_sim_info)
1011
+ for edit in [self.r_min, self.r_max, self.theta_min, self.theta_max, self.phi_min, self.phi_max]:
1012
+ edit.editingFinished.connect(lambda e=edit: self.normalize_decimal(e))
1013
+ edit.editingFinished.connect(self.on_slice_change)
1014
+ self.plot_options_button.clicked.connect(self.show_plot_options_dialog)
1015
+ self.reflect_button.clicked.connect(self.open_reflect_dialog)
1016
+ self.canvas.mpl_connect('button_release_event', self.on_zoom_release)
1017
+
1018
+ def normalize_decimal(self, lineedit):
1019
+ text = lineedit.text()
1020
+ if ',' in text:
1021
+ lineedit.setText(text.replace(',', '.'))
1022
+
1023
+ def show_logo_on_canvas(self):
1024
+ self.figure.clear()
1025
+ ax = self.figure.add_subplot(111)
1026
+ ax.axis('off')
1027
+ ax.set_facecolor('#23272b')
1028
+ self.figure.set_facecolor('#23272b')
1029
+ try:
1030
+ import matplotlib.image as mpimg
1031
+ img = mpimg.imread("fargopy_logo.png")
1032
+ ax.imshow(img, aspect='auto')
1033
+ except Exception:
1034
+ ax.text(0.5, 0.5, "FARGOpy", fontsize=40, ha='center', va='center', color='white')
1035
+ self.canvas.draw()
1036
+
1037
+ def select_simulation_path(self):
1038
+ path = QFileDialog.getExistingDirectory(self, "Select simulation output directory")
1039
+ if path:
1040
+ self.path_line.setText(path)
1041
+ self.load_simulation(path)
1042
+
1043
+ def load_simulation(self, path):
1044
+ self.sim = fp.Simulation(output_dir=path)
1045
+ # self.sim.units('CGS') # <-- Remove this line, respeta las unidades originales
1046
+ self.time_slider.setEnabled(True)
1047
+ self.time_slider.setMinimum(0)
1048
+ self.time_slider.setMaximum(self.sim._get_nsnaps()-1)
1049
+ self.time_slider.setValue(1)
1050
+ self.r_min.setText("")
1051
+ self.r_max.setText("")
1052
+ self.theta_min.setText(self._format_angle(self.sim.domains.theta.max()))
1053
+ self.theta_max.setText('')
1054
+ self.phi_min.setText("")
1055
+ self.phi_max.setText("")
1056
+ self.last_slice_str = ""
1057
+ for edit in [self.r_min, self.r_max, self.theta_min, self.theta_max, self.phi_min, self.phi_max]:
1058
+ edit.setEnabled(True)
1059
+ self.res_slider.setEnabled(True)
1060
+ self.interp_toggle.setEnabled(True)
1061
+ self.streamlines_toggle.setEnabled(True)
1062
+ self.density_slider.setEnabled(True)
1063
+ self.hill_frac_slider.setEnabled(True)
1064
+ self.show_circle_toggle.setEnabled(True)
1065
+ self.reflect_button.setEnabled(True)
1066
+ self.update_button.setEnabled(True)
1067
+ self.info_button.setEnabled(True)
1068
+ # Update snapshot limits in the dialog
1069
+ self.fixed_cbar_snap_spin.setMaximum(self.sim._get_nsnaps()-1)
1070
+ self.fixed_cbar_snap_spin.setValue(1)
1071
+ self.fixed_cbar_limits = {
1072
+ 'Density': None,
1073
+ 'Velocity': None,
1074
+ 'Energy': None
1075
+ }
1076
+ self.plot_density()
1077
+
1078
+ def show_sim_info(self):
1079
+ if self.sim is not None:
1080
+ dlg = SimInfoDialog(self.sim, self)
1081
+ dlg.exec_()
1082
+
1083
+ def on_slice_type_change(self, text):
1084
+ self.slice_type = text
1085
+ # When changing slice type, reset fields according to convention
1086
+ if self.slice_type == "theta":
1087
+ theta_val = self._format_angle(self.sim.domains.theta.max())
1088
+ self.theta_min.setText(theta_val)
1089
+ self.theta_max.setText('')
1090
+ self.phi_min.setText("")
1091
+ self.phi_max.setText("")
1092
+ else:
1093
+ zero = self._format_angle(0.0)
1094
+ self.phi_min.setText(zero)
1095
+ self.phi_max.setText(zero)
1096
+ self.theta_min.setText("")
1097
+ self.theta_max.setText("")
1098
+ self.r_min.setText("")
1099
+ self.r_max.setText("")
1100
+ self.last_slice_str = "" # <-- Ensure previous slice is cleared
1101
+ self.plot_density()
1102
+
1103
+ def on_zoom_release(self, event):
1104
+ # Only if user used zoom (right button or wheel)
1105
+ if event.button not in [1, 3]:
1106
+ return
1107
+ ax = self.figure.gca()
1108
+ xlim = ax.get_xlim()
1109
+ ylim = ax.get_ylim()
1110
+ scale_factor, _, _, _ = self._length_scale_info()
1111
+ if not self.sim or not scale_factor:
1112
+ return
1113
+ x_min_plot, x_max_plot = xlim
1114
+ y_min_plot, y_max_plot = ylim
1115
+ to_ul = scale_factor / self.sim.UL
1116
+ x_min_ul = x_min_plot * to_ul
1117
+ x_max_ul = x_max_plot * to_ul
1118
+ y_min_ul = y_min_plot * to_ul
1119
+ y_max_ul = y_max_plot * to_ul
1120
+
1121
+ corners = [
1122
+ (x_min_ul, y_min_ul),
1123
+ (x_min_ul, y_max_ul),
1124
+ (x_max_ul, y_min_ul),
1125
+ (x_max_ul, y_max_ul)
1126
+ ]
1127
+
1128
+ r_list = []
1129
+ theta_list = []
1130
+ phi_list = []
1131
+ for x, y in corners:
1132
+ if self.slice_type == "theta":
1133
+ z = 0.0
1134
+ r = np.sqrt(x**2 + y**2 + z**2)
1135
+ theta = np.arccos(z / r) if r != 0 else 0.0
1136
+ phi = np.arctan2(y, x)
1137
+ else:
1138
+ y_ = 0.0
1139
+ z = y
1140
+ r = np.sqrt(x**2 + y_**2 + z**2)
1141
+ theta = np.arccos(z / r) if r != 0 else 0.0
1142
+ phi = np.arctan2(y_, x)
1143
+ r_list.append(r)
1144
+ theta_list.append(theta)
1145
+ phi_list.append(phi)
1146
+
1147
+
1148
+ r_min = np.min(r_list)
1149
+ r_max = np.max(r_list)
1150
+
1151
+ theta_min = np.min(theta_list)
1152
+ theta_max = np.max(theta_list)
1153
+ phi_min = np.min(phi_list)
1154
+ phi_max = np.max(phi_list)
1155
+
1156
+ if self.slice_type == "theta":
1157
+ theta_val = self._format_angle(self.sim.domains.theta.max())
1158
+ self.theta_min.setText(theta_val)
1159
+ self.theta_max.setText(theta_val)
1160
+ self.r_min.setText(self._sim_length_to_display(r_min))
1161
+ self.r_max.setText(self._sim_length_to_display(r_max))
1162
+ self.phi_min.setText(self._format_angle(phi_min))
1163
+ self.phi_max.setText(self._format_angle(phi_max))
1164
+ slice_str = (
1165
+ f"theta={theta_val},"
1166
+ f"r=[{r_min:.3f},{r_max:.3f}],"
1167
+ f"phi=[{phi_min:.3f},{phi_max:.3f}]"
1168
+ )
1169
+ else:
1170
+ phi_val = self.phi_min.text() if self.phi_min.text() != "" else self._format_angle(0.0)
1171
+ self.phi_min.setText(phi_val)
1172
+ self.phi_max.setText(phi_val)
1173
+ self.r_min.setText(self._sim_length_to_display(r_min))
1174
+ self.r_max.setText(self._sim_length_to_display(r_max))
1175
+ self.theta_min.setText(self._format_angle(theta_min))
1176
+ self.theta_max.setText(self._format_angle(theta_max))
1177
+ slice_str = (
1178
+ f"phi={phi_val},"
1179
+ f"r=[{r_min:.3f},{r_max:.3f}],"
1180
+ f"theta=[{theta_min:.3f},{theta_max:.3f}]"
1181
+ )
1182
+ self.last_slice_str = slice_str
1183
+ self._fields_edited = False
1184
+ # DO NOT call self.plot_density() here
1185
+
1186
+ def build_slice_str(self):
1187
+ # If the user edited any field manually SINCE the last update, ignore last_slice_str
1188
+ if getattr(self, "_fields_edited", False):
1189
+ return self._manual_slice_str()
1190
+ # Otherwise, use the zoom slice if present
1191
+ if self.last_slice_str:
1192
+ return self.last_slice_str
1193
+ return self._manual_slice_str()
1194
+
1195
+ def _manual_slice_str(self):
1196
+ def norm(txt):
1197
+ return txt.replace(',', '.').strip()
1198
+ r_min_val = norm(self.r_min.text())
1199
+ r_max_val = norm(self.r_max.text())
1200
+ theta_min_val = norm(self.theta_min.text())
1201
+ theta_max_val = norm(self.theta_max.text())
1202
+ phi_min_val = norm(self.phi_min.text())
1203
+ phi_max_val = norm(self.phi_max.text())
1204
+
1205
+ r_min_val = self._length_input_to_sim_units(r_min_val)
1206
+ r_max_val = self._length_input_to_sim_units(r_max_val)
1207
+
1208
+ slice_parts = []
1209
+ # If both r_min and r_max are empty, and both phi_min and phi_max are empty, and theta is set, treat as a theta slice
1210
+ if not r_min_val and not r_max_val and not phi_min_val and not phi_max_val and theta_min_val:
1211
+ slice_parts.append(f"theta={theta_min_val}")
1212
+ else:
1213
+ if r_min_val and r_max_val:
1214
+ if r_min_val == r_max_val:
1215
+ slice_parts.append(f"r={r_min_val}")
1216
+ else:
1217
+ slice_parts.append(f"r=[{r_min_val},{r_max_val}]")
1218
+ elif r_min_val:
1219
+ slice_parts.append(f"r={r_min_val}")
1220
+ elif r_max_val:
1221
+ slice_parts.append(f"r={r_max_val}")
1222
+ if theta_min_val and theta_max_val:
1223
+ if theta_min_val == theta_max_val:
1224
+ slice_parts.append(f"theta={theta_min_val}")
1225
+ else:
1226
+ slice_parts.append(f"theta=[{theta_min_val},{theta_max_val}]")
1227
+ elif theta_min_val:
1228
+ slice_parts.append(f"theta={theta_min_val}")
1229
+ elif theta_max_val:
1230
+ slice_parts.append(f"theta={theta_max_val}")
1231
+ if phi_min_val and phi_max_val:
1232
+ if phi_min_val == phi_max_val:
1233
+ slice_parts.append(f"phi={phi_min_val}")
1234
+ else:
1235
+ slice_parts.append(f"phi=[{phi_min_val},{phi_max_val}]")
1236
+ elif phi_min_val:
1237
+ slice_parts.append(f"phi={phi_min_val}")
1238
+ elif phi_max_val:
1239
+ slice_parts.append(f"phi={phi_max_val}")
1240
+ return ",".join(slice_parts)
1241
+
1242
+ def on_slice_change(self):
1243
+ # Mark that a manual edit occurred and clear the zoom slice
1244
+ self._fields_edited = True
1245
+ self.last_slice_str = ""
1246
+
1247
+ def update_plot(self):
1248
+ # Call only from the Update plot button
1249
+ self._fields_edited = False # Reset flag; manual fields are the source of truth
1250
+ self.last_slice_str = "" # Always use manual fields after updating
1251
+ self.plot_density()
1252
+
1253
+ def on_map_change(self, text):
1254
+ if text == 'Velocity':
1255
+ self.vel_dropdown.setEnabled(True)
1256
+ else:
1257
+ self.vel_dropdown.setEnabled(False)
1258
+
1259
+ def on_fixed_cbar_toggle(self, state):
1260
+ # Notify parent to update fixed colorbar limits if enabled
1261
+ if self.parent.fixed_cbar_enabled:
1262
+ self.parent.update_fixed_cbar_limits()
1263
+ self.parent.plot_density()
1264
+
1265
+ def on_fixed_cbar_snap_change(self, value):
1266
+ if self.fixed_cbar_enabled:
1267
+ self.update_fixed_cbar_limits()
1268
+ self.plot_density()
1269
+
1270
+ def update_fixed_cbar_limits(self):
1271
+ slice_str = self.build_slice_str()
1272
+ res = self.res_slider.value()
1273
+ interpolate = self.interp_toggle.isChecked()
1274
+ map_types = MAP_TYPES
1275
+ vel_comp = self.vel_dropdown.currentText() if hasattr(self, 'vel_dropdown') else 'vx'
1276
+ snap = self.fixed_cbar_snap_spin.value()
1277
+
1278
+ for map_type in map_types:
1279
+ try:
1280
+ if map_type == 'Density':
1281
+ data = self.sim.load_field(
1282
+ fields=['gasdens', 'gasv'],
1283
+ slice=slice_str,
1284
+ snapshot=snap,
1285
+ interpolate=True
1286
+ )
1287
+ if hasattr(data, 'evaluate'):
1288
+ mesh_x_name = 'var1_mesh'
1289
+ mesh_y_name = 'var2_mesh'
1290
+ xmin, xmax = getattr(data, mesh_x_name)[0].min(), getattr(data, mesh_x_name)[0].max()
1291
+ ymin, ymax = getattr(data, mesh_y_name)[0].min(), getattr(data, mesh_y_name)[0].max()
1292
+ xs = np.linspace(xmin, xmax, res)
1293
+ ys = np.linspace(ymin, ymax, res)
1294
+ X, Y = np.meshgrid(xs, ys)
1295
+ data_map = data.evaluate(field='gasdens',time=snap, var1=X, var2=Y)
1296
+ dens_raw = data_map * self.sim.URHO
1297
+ data_map = np.where(dens_raw > 0, np.log10(dens_raw), np.nan)
1298
+ else:
1299
+ dens_raw = data.gasdens_mesh[0] * self.sim.URHO
1300
+ valid_mask = dens_raw > 0
1301
+ data_map = np.where(valid_mask, np.log10(dens_raw), np.nan)
1302
+ elif map_type == 'Velocity':
1303
+ gasv = self.sim.load_field(
1304
+ fields='gasv',
1305
+ slice=slice_str,
1306
+ snapshot=snap,
1307
+ interpolate=True
1308
+ )
1309
+
1310
+ if hasattr(gasv, 'evaluate'):
1311
+ mesh_x_name = 'var1_mesh'
1312
+ mesh_y_name = 'var2_mesh'
1313
+ xmin, xmax = getattr(gasv, mesh_x_name)[0].min(), getattr(gasv, mesh_x_name)[0].max()
1314
+ ymin, ymax = getattr(gasv, mesh_y_name)[0].min(), getattr(gasv, mesh_y_name)[0].max()
1315
+ xs = np.linspace(xmin, xmax, res)
1316
+ ys = np.linspace(ymin, ymax, res)
1317
+ X, Y = np.meshgrid(xs, ys)
1318
+ vel = gasv.evaluate(time=snap, var1=X, var2=Y)
1319
+ idx = VEL_INDEX.get(vel_comp, 0)
1320
+ data_map = vel[idx]
1321
+ else:
1322
+ vel = gasv.gasv_mesh[0]
1323
+ idx = VEL_INDEX.get(vel_comp, 0)
1324
+ data_map = vel[idx]
1325
+ elif map_type == 'Energy':
1326
+ gasenergy = self.sim.load_field(
1327
+ fields='gasenergy',
1328
+ slice=slice_str,
1329
+ snapshot=snap,
1330
+ interpolate=True
1331
+ )
1332
+ if hasattr(gasenergy, 'evaluate'):
1333
+ mesh_x_name = 'var1_mesh'
1334
+ mesh_y_name = 'var2_mesh'
1335
+ xmin, xmax = getattr(gasenergy, mesh_x_name)[0].min(), getattr(gasenergy, mesh_x_name)[0].max()
1336
+ ymin, ymax = getattr(gasenergy, mesh_y_name)[0].min(), getattr(gasenergy, mesh_y_name)[0].max()
1337
+ xs = np.linspace(xmin, xmax, res)
1338
+ ys = np.linspace(ymin, ymax, res)
1339
+ X, Y = np.meshgrid(xs, ys)
1340
+ data_map = gasenergy.evaluate(time=snap, var1=X, var2=Y)
1341
+ else:
1342
+ data_map = gasenergy.gasenergy_mesh[0]
1343
+ # Ignore NaNs for min/max
1344
+ valid = np.isfinite(data_map)
1345
+ if np.any(valid):
1346
+ vmin = np.nanmin(data_map)
1347
+ vmax = np.nanmax(data_map)
1348
+ self.fixed_cbar_limits[map_type] = (vmin, vmax)
1349
+ else:
1350
+ self.fixed_cbar_limits[map_type] = None
1351
+ except Exception:
1352
+ self.fixed_cbar_limits[map_type] = None
1353
+
1354
+ def _plot_reflection_overlay(self, ax, X_plot, Y_plot, data_map, cmap, vmin, vmax,
1355
+ show_streamlines, stream_density, stream_cmap,
1356
+ vx_plot, vy_plot, vmag_kms, skip_coord_reflect=False):
1357
+ if data_map is None:
1358
+ return
1359
+ axis = getattr(self, "reflect_axis", "X-axis")
1360
+ if skip_coord_reflect:
1361
+ X_ref, Y_ref = X_plot, Y_plot
1362
+ else:
1363
+ X_ref, Y_ref = self._reflect_coordinates(axis, X_plot, Y_plot)
1364
+ ax.pcolormesh(
1365
+ X_ref,
1366
+ Y_ref,
1367
+ data_map,
1368
+ shading='auto',
1369
+ cmap=cmap,
1370
+ vmin=vmin,
1371
+ vmax=vmax
1372
+
1373
+ )
1374
+ if show_streamlines and vx_plot is not None and vy_plot is not None:
1375
+ if skip_coord_reflect:
1376
+ vx_ref, vy_ref = vx_plot, vy_plot
1377
+ else:
1378
+ vx_ref, vy_ref = self._reflect_vectors(axis, vx_plot, vy_plot)
1379
+ # Ensure X_ref/Y_ref are strictly increasing along axes as required
1380
+ # by matplotlib.streamplot. If not, sort axes and reorder data
1381
+ # accordingly so streamplot receives monotonic grids.
1382
+ def _ensure_monotonic(X, Y, U=None, V=None, vm=None):
1383
+ try:
1384
+ if X.ndim == 2 and Y.ndim == 2:
1385
+ x_row = X[0, :]
1386
+ y_col = Y[:, 0]
1387
+ ix = None
1388
+ iy = None
1389
+ if not np.all(np.diff(x_row) > 0):
1390
+ ix = np.argsort(x_row)
1391
+ if not np.all(np.diff(y_col) > 0):
1392
+ iy = np.argsort(y_col)
1393
+ if ix is None and iy is None:
1394
+ return X, Y, U, V, vm
1395
+ if ix is None:
1396
+ ix = np.arange(X.shape[1])
1397
+ if iy is None:
1398
+ iy = np.arange(X.shape[0])
1399
+ X2 = X[np.ix_(iy, ix)]
1400
+ Y2 = Y[np.ix_(iy, ix)]
1401
+ U2 = U[np.ix_(iy, ix)] if U is not None else None
1402
+ V2 = V[np.ix_(iy, ix)] if V is not None else None
1403
+ vm2 = vm[np.ix_(iy, ix)] if vm is not None else None
1404
+ return X2, Y2, U2, V2, vm2
1405
+ except Exception:
1406
+ pass
1407
+ return X, Y, U, V, vm
1408
+
1409
+ Xr, Yr, vxr, vyr, vmr = _ensure_monotonic(X_ref, Y_ref, vx_ref, vy_ref, vmag_kms)
1410
+ ax.streamplot(
1411
+ Xr,
1412
+ Yr,
1413
+ vxr,
1414
+ vyr,
1415
+ color=vmr if vmr is not None else None,
1416
+ linewidth=0.45,
1417
+ density=stream_density,
1418
+ cmap=stream_cmap,
1419
+ arrowsize=getattr(self, "stream_arrow_size", 1.0),
1420
+ )
1421
+
1422
+ def plot_density(self, unitsys_override=None):
1423
+ if not self.sim:
1424
+ return
1425
+
1426
+ self.status_label.setText("🐍 The Python snake is exploring the disk...")
1427
+ QApplication.processEvents()
1428
+
1429
+ slice_str = self.build_slice_str()
1430
+ res = self.res_slider.value()
1431
+ interpolate = self.interp_toggle.isChecked()
1432
+ show_streamlines = self.streamlines_toggle.isChecked()
1433
+ stream_density = self.density_slider.value()
1434
+ hill_frac = self.hill_frac_slider.value()
1435
+ show_circle = self.show_circle_toggle.isChecked()
1436
+ cmap = self.cmap_dropdown.currentText()
1437
+ stream_cmap = self.stream_cmap_dropdown.currentText()
1438
+ map_type = self.map_dropdown.currentText()
1439
+ vel_comp = self.vel_dropdown.currentText()
1440
+ n = self.time_slider.value()
1441
+
1442
+ # --- UNITS ---
1443
+ sim_unitsys = getattr(self.sim, "unitsystem", "cgs").lower()
1444
+ length_unit = self.length_scale_combo.currentText()
1445
+
1446
+ unit_factors = dict(_units_table(sim_unitsys, LENGTH_FACTORS))
1447
+ unit_factors["Simulation UL"] = self.sim.UL
1448
+ axis_unit_label_map = AXIS_UNIT_LABELS.get(sim_unitsys, AXIS_UNIT_LABELS["cgs"])
1449
+
1450
+ scale_factor = unit_factors.get(length_unit, self.sim.UL)
1451
+
1452
+ if sim_unitsys == "cgs":
1453
+ dens_unit = "g/cm³"
1454
+ vel_unit = "cm/s"
1455
+ v_factor = self.sim.UL / self.sim.UT # cm/s
1456
+ elif sim_unitsys == "mks":
1457
+ dens_unit = "kg/m³"
1458
+ vel_unit = "m/s"
1459
+ v_factor = self.sim.UL / self.sim.UT # m/s
1460
+ else:
1461
+ dens_unit = r"UM/UL$^3$"
1462
+ vel_unit = "UL/UT"
1463
+ v_factor = self.sim.UL / self.sim.UT # cm/s
1464
+ # Determine axes and mesh names based on fixed coordinates
1465
+ def is_fixed(var, slice_str):
1466
+ match = re.search(rf'{var}=([^\[\],]+)', slice_str.replace(' ', ''))
1467
+ return match is not None
1468
+
1469
+ if is_fixed('theta', slice_str):
1470
+ mesh_x_name = 'var1_mesh'
1471
+ mesh_y_name = 'var2_mesh'
1472
+ self._set_velocity_options(['vx', 'vy'])
1473
+ elif is_fixed('phi', slice_str):
1474
+ mesh_x_name = 'var1_mesh'
1475
+ mesh_y_name = 'var3_mesh'
1476
+ self._set_velocity_options(['vx', 'vz'])
1477
+ else:
1478
+ mesh_x_name = 'var1_mesh'
1479
+ mesh_y_name = 'var2_mesh'
1480
+ self._set_velocity_options(['vx', 'vy'])
1481
+
1482
+ # Load data according to selection
1483
+ if map_type == 'Density':
1484
+ data = self.sim.load_field(
1485
+ fields=['gasdens', 'gasv'],
1486
+ slice=slice_str,
1487
+ snapshot=n,
1488
+ interpolate=True
1489
+ )
1490
+ elif map_type == 'Energy':
1491
+ gasenergy = self.sim.load_field(
1492
+ fields='gasenergy',
1493
+ slice=slice_str,
1494
+ snapshot=n,
1495
+ interpolate=True
1496
+ )
1497
+ gasv = self.sim.load_field(
1498
+ fields='gasv',
1499
+ slice=slice_str,
1500
+ snapshot=n,
1501
+ interpolate=True
1502
+ )
1503
+ elif map_type == 'Velocity':
1504
+ gasv = self.sim.load_field(
1505
+ fields='gasv',
1506
+ slice=slice_str,
1507
+ snapshot=n,
1508
+ interpolate=True
1509
+ )
1510
+
1511
+ # Select the object that provides the mesh arrays (used for xmin/xmax/ymin/ymax)
1512
+ if map_type == 'Density':
1513
+ mesh_source = data
1514
+ elif map_type == 'Energy':
1515
+ mesh_source = gasenergy
1516
+ else: # Velocity
1517
+ mesh_source = gasv
1518
+
1519
+ # --- Interpolation ---
1520
+ if interpolate:
1521
+ if mesh_y_name == 'var2_mesh':
1522
+ xmin, xmax = getattr(mesh_source, mesh_x_name)[0].min(), getattr(mesh_source, mesh_x_name)[0].max()
1523
+ ymin, ymax = getattr(mesh_source, mesh_y_name)[0].min(), getattr(mesh_source, mesh_y_name)[0].max()
1524
+ xs = np.linspace(xmin, xmax, res)
1525
+ ys = np.linspace(ymin, ymax, res)
1526
+ X, Y = np.meshgrid(xs, ys)
1527
+ if map_type == 'Density':
1528
+ data_map = data.evaluate(field='gasdens', time=n, var1=X, var2=Y)
1529
+ data_map = np.log10(data_map * self.sim.URHO)
1530
+ vel = data.evaluate(field='gasv', time=n, var1=X, var2=Y)
1531
+ vx = vel[0]
1532
+ vy = vel[1]
1533
+ vmag = np.sqrt(vx**2 + vy**2)
1534
+ elif map_type == 'Energy':
1535
+ data_map = gasenergy.evaluate(time=n, var1=X, var2=Y)
1536
+ vel = gasv.evaluate(time=n, var1=X, var2=Y)
1537
+ vx = vel[0]
1538
+ vy = vel[1]
1539
+ vmag = np.sqrt(vx**2 + vy**2)
1540
+ elif map_type == 'Velocity':
1541
+ vel = gasv.evaluate(time=n, var1=X, var2=Y)
1542
+ idx = VEL_INDEX[vel_comp]
1543
+ data_map = vel[idx]
1544
+ vx = vel[0]
1545
+ vy = vel[1]
1546
+ vmag = np.sqrt(vx**2 + vy**2)
1547
+ else:
1548
+ xmin, xmax = getattr(mesh_source, mesh_x_name)[0].min(), getattr(mesh_source, mesh_x_name)[0].max()
1549
+ zmin, zmax = getattr(mesh_source, mesh_y_name)[0].min(), getattr(mesh_source, mesh_y_name)[0].max()
1550
+ xs = np.linspace(xmin, xmax, res)
1551
+ zs = np.linspace(zmin, zmax, res)
1552
+ X, Y = np.meshgrid(xs, zs)
1553
+ if map_type == 'Density':
1554
+ data_map = data.evaluate(field='gasdens', time=n, var1=X, var3=Y)
1555
+ data_map = np.log10(data_map * self.sim.URHO)
1556
+ vel = data.evaluate(field='gasv', time=n, var1=X, var3=Y)
1557
+ vx = vel[0]
1558
+ vy = vel[2]
1559
+ vmag = np.sqrt(vx**2 + vy**2)
1560
+ elif map_type == 'Energy':
1561
+ data_map = gasenergy.evaluate(time=n, var1=X, var3=Y)
1562
+ vel = gasv.evaluate(time=n, var1=X, var3=Y)
1563
+ vx = vel[0]
1564
+ vy = vel[2]
1565
+ vmag = np.sqrt(vx**2 + vy**2)
1566
+ elif map_type == 'Velocity':
1567
+ vel = gasv.evaluate(time=n, var1=X, var3=Y)
1568
+ idx = VEL_INDEX[vel_comp]
1569
+ data_map = vel[idx]
1570
+ vx = vel[0]
1571
+ vy = vel[2]
1572
+ vmag = np.sqrt(vx**2 + vy**2)
1573
+ else:
1574
+ if mesh_y_name == 'var2_mesh':
1575
+ X = getattr(mesh_source, mesh_x_name)[0]
1576
+ Y = getattr(mesh_source, mesh_y_name)[0]
1577
+ if map_type == 'Density':
1578
+ data_map = np.log10(data.gasdens_mesh[0] * self.sim.URHO)
1579
+ vel = data.gasv_mesh[0]
1580
+ vx = vel[0]
1581
+ vy = vel[1]
1582
+ vmag = np.sqrt(vx**2 + vy**2)
1583
+ elif map_type == 'Energy':
1584
+ data_map = gasenergy.gasenergy_mesh[0]
1585
+ vel = gasv.gasv_mesh[0]
1586
+ vx = vel[0]
1587
+ vy = vel[1]
1588
+ vmag = np.sqrt(vx**2 + vy**2)
1589
+ elif map_type == 'Velocity':
1590
+ vel = gasv.gasv_mesh[0]
1591
+ idx = VEL_INDEX[vel_comp]
1592
+ data_map = vel[idx]
1593
+ vx = vel[0]
1594
+ vy = vel[1]
1595
+ vmag = np.sqrt(vx**2 + vy**2)
1596
+ else:
1597
+ X = getattr(mesh_source, mesh_x_name)[0]
1598
+ Y = getattr(mesh_source, mesh_y_name)[0]
1599
+ if map_type == 'Density':
1600
+ data_map = np.log10(data.gasdens_mesh[0] * self.sim.URHO)
1601
+ vel = data.gasv_mesh[0]
1602
+ vx = vel[0]
1603
+ vy = vel[2]
1604
+ vmag = np.sqrt(vx**2 + vy**2)
1605
+ elif map_type == 'Energy':
1606
+ data_map = gasenergy.gasenergy_mesh[0]
1607
+ vel = gasv.gasv_mesh[0]
1608
+ vx = vel[0]
1609
+ vy = vel[2]
1610
+ vmag = np.sqrt(vx**2 + vy**2)
1611
+ elif map_type == 'Velocity':
1612
+ vel = gasv.gasv_mesh[0]
1613
+ idx = {'vx': 0, 'vy': 1, 'vz': 2}[vel_comp]
1614
+ data_map = vel[idx]
1615
+ vx = vel[0]
1616
+ vy = vel[2]
1617
+ vmag = np.sqrt(vx**2 + vy**2)
1618
+
1619
+ # --- Apply units and scaling to axes and velocity ---
1620
+ # X, Y are in simulation UL units. Convert to cm or m, then to target unit.
1621
+ X_plot = X * self.sim.UL / scale_factor
1622
+ Y_plot = Y * self.sim.UL / scale_factor
1623
+
1624
+ # Convert velocities consistently:
1625
+ # - data returned by evaluate / meshes is in simulation velocity units (UL/UT).
1626
+ # - vx_phys, vy_phys, vmag_phys => physical units (cm/s for CGS, m/s for MKS)
1627
+ # - vx_plot, vy_plot => units matching X_plot/Y_plot (needed by streamplot)
1628
+ # - vmag_kms => km/s for colorbar (always)
1629
+ vx_phys = vy_phys = vmag_phys = None
1630
+ vx_plot = vy_plot = vmag_kms = None
1631
+
1632
+ if map_type == 'Velocity':
1633
+ # data_map, vx, vy, vmag were set above (in sim units)
1634
+ try:
1635
+ vx_phys = vx * v_factor
1636
+ vy_phys = vy * v_factor
1637
+ vmag_phys = vmag * v_factor
1638
+ except Exception:
1639
+ vx_phys = vy_phys = vmag_phys = None
1640
+
1641
+ # Convert physical velocities to plotting units (axis units per second)
1642
+ if vx_phys is not None and scale_factor is not None:
1643
+ vx_plot = vx_phys / scale_factor
1644
+ if vy_phys is not None and scale_factor is not None:
1645
+ vy_plot = vy_phys / scale_factor
1646
+
1647
+ # Convert physical velocities to km/s for colorbar (always)
1648
+ if vmag_phys is not None:
1649
+ if sim_unitsys == "cgs":
1650
+ denom = 1e5 # 1 km = 1e5 cm
1651
+ else:
1652
+ denom = 1e3 # 1 km = 1e3 m
1653
+ vmag_kms = vmag_phys / denom
1654
+
1655
+ # Also convert the scalar field used by pcolormesh (data_map) to km/s
1656
+ if 'data_map' in locals() and data_map is not None:
1657
+ try:
1658
+ data_map = (data_map * v_factor) / (1e5 if sim_unitsys == "cgs" else 1e3)
1659
+ except Exception:
1660
+ pass
1661
+ else:
1662
+ # For non-velocity maps, still prepare vmag_kms if streamlines requested
1663
+ if 'vmag' in locals() and vmag is not None:
1664
+ try:
1665
+ vmag_phys = vmag * v_factor
1666
+ denom = 1e5 if sim_unitsys == "cgs" else 1e3
1667
+ vmag_kms = vmag_phys / denom
1668
+ # streamline vector components must be in plot units for correct arrows:
1669
+ if 'vx' in locals() and 'vy' in locals():
1670
+ vx_plot = (vx * v_factor) / scale_factor
1671
+ vy_plot = (vy * v_factor) / scale_factor
1672
+ except Exception:
1673
+ vmag_kms = None
1674
+
1675
+ # --- Masking and plotting ---
1676
+ # r in plot units (same as X_plot/Y_plot)
1677
+ r = np.sqrt(X_plot**2 + Y_plot**2)
1678
+ # Extract r_min/r_max from slice_str (these are in the original simulation units, no rescaling)
1679
+ r_match = re.search(r"r=\[([0-9\.]+),([0-9\.]+)\]", slice_str.replace(" ", ""))
1680
+ if r_match:
1681
+ # r_min_sim and r_max_sim are in simulation units (no rescaling)
1682
+ r_min_sim = float(r_match.group(1))
1683
+ r_max_sim = float(r_match.group(2))
1684
+ # Convert to plot units for masking
1685
+ r_min = r_min_sim * self.sim.UL / scale_factor
1686
+ r_max = r_max_sim * self.sim.UL / scale_factor
1687
+ else:
1688
+ r_min = None
1689
+ r_max = None
1690
+
1691
+ # Mask using r in plot units
1692
+ if r_min is not None and r_max is not None:
1693
+ mask = (r >= r_min) & (r <= r_max)
1694
+ data_map = np.where(mask, data_map, np.nan)
1695
+ if show_streamlines and vx is not None and vy is not None and vmag is not None:
1696
+ vx = np.where(mask, vx, np.nan)
1697
+ vy = np.where(mask, vy, np.nan)
1698
+ vmag = np.where(mask, vmag, np.nan)
1699
+
1700
+ self.figure.clear()
1701
+ ax = self.figure.add_subplot(111)
1702
+ ax.set_facecolor('white')
1703
+ self.figure.set_facecolor('white')
1704
+
1705
+ # --- Set colorbar limits if fixed colorbar is enabled or manual vmin/vmax is enabled ---
1706
+ vmin = vmax = None
1707
+ if self.manual_vmin_vmax_enabled:
1708
+ # User provides exponent x; values are treated as 10^x (manual vmin/vmax)
1709
+ vmin = self.manual_vmin
1710
+ vmax = self.manual_vmax
1711
+ elif self.fixed_cbar_enabled:
1712
+ limits = self.fixed_cbar_limits.get(map_type)
1713
+ if limits is not None:
1714
+ vmin, vmax = limits
1715
+
1716
+ # Defensive: ensure X_plot/Y_plot/data_map have compatible shapes
1717
+ X_plot = np.asarray(X_plot)
1718
+ Y_plot = np.asarray(Y_plot)
1719
+ data_map = np.asarray(data_map)
1720
+ try:
1721
+ pcm = ax.pcolormesh(X_plot, Y_plot, data_map, shading='auto', cmap=cmap, vmin=vmin, vmax=vmax)
1722
+ except ValueError:
1723
+ # Safer fallback: infer grid shape from data_map instead of
1724
+ # calling np.unique on potentially huge X/Y arrays.
1725
+ try:
1726
+ data_map2 = data_map
1727
+ # If data_map is 2D, prefer its shape
1728
+ if getattr(data_map, 'ndim', 1) == 2:
1729
+ ny, nx = data_map.shape
1730
+ # Extract coordinate vectors from the first row/col when possible
1731
+ if getattr(X_plot, 'ndim', 1) == 2 and X_plot.shape[1] == nx:
1732
+ xs = X_plot[0, :].copy()
1733
+ else:
1734
+ xs = np.linspace(np.nanmin(X_plot), np.nanmax(X_plot), nx)
1735
+ if getattr(Y_plot, 'ndim', 1) == 2 and Y_plot.shape[0] == ny:
1736
+ ys = Y_plot[:, 0].copy()
1737
+ else:
1738
+ ys = np.linspace(np.nanmin(Y_plot), np.nanmax(Y_plot), ny)
1739
+ Xg, Yg = np.meshgrid(xs, ys)
1740
+ else:
1741
+ # data_map is 1D/flat: reshape to near-square grid safely
1742
+ size = data_map.size
1743
+ nx = int(np.sqrt(size))
1744
+ ny = int(np.ceil(size / nx))
1745
+ if nx * ny != size:
1746
+ data_map2 = np.full((ny, nx), np.nan)
1747
+ data_map2.flat[:size] = data_map.flat
1748
+ else:
1749
+ data_map2 = data_map.reshape((ny, nx))
1750
+ xs = np.linspace(np.nanmin(X_plot), np.nanmax(X_plot), nx)
1751
+ ys = np.linspace(np.nanmin(Y_plot), np.nanmax(Y_plot), ny)
1752
+ Xg, Yg = np.meshgrid(xs, ys)
1753
+ pcm = ax.pcolormesh(Xg, Yg, data_map2, shading='auto', cmap=cmap, vmin=vmin, vmax=vmax)
1754
+ except Exception:
1755
+ # If fallback fails, raise original ValueError for debugging
1756
+ raise
1757
+ # --- Axis label according to scaling ---
1758
+ axis_unit_label = axis_unit_label_map.get(length_unit, length_unit)
1759
+ xlabel, ylabel = f'X [{axis_unit_label}]', f'Y [{axis_unit_label}]'
1760
+
1761
+ stream_obj = None
1762
+ # Use vx_plot/vy_plot (axis-units per second) for streamplot so arrows scale correctly.
1763
+ if show_streamlines and vx_plot is not None and vy_plot is not None:
1764
+ # Ensure X_plot/Y_plot are acceptable for streamplot: rows of X
1765
+ # must be equal and columns of Y must be equal. If not, try to
1766
+ # reorder axes so they become monotonic grids (like meshgrid).
1767
+ def _sanitize_grid(X, Y, U=None, V=None, vm=None):
1768
+ try:
1769
+ if getattr(X, 'ndim', 1) == 2 and getattr(Y, 'ndim', 1) == 2:
1770
+ x_row = X[0, :]
1771
+ y_col = Y[:, 0]
1772
+ ix = None
1773
+ iy = None
1774
+ if not np.all(np.diff(x_row) > 0):
1775
+ ix = np.argsort(x_row)
1776
+ if not np.all(np.diff(y_col) > 0):
1777
+ iy = np.argsort(y_col)
1778
+ if ix is None and iy is None:
1779
+ return X, Y, U, V, vm
1780
+ if ix is None:
1781
+ ix = np.arange(X.shape[1])
1782
+ if iy is None:
1783
+ iy = np.arange(X.shape[0])
1784
+ X2 = X[np.ix_(iy, ix)]
1785
+ Y2 = Y[np.ix_(iy, ix)]
1786
+ U2 = U[np.ix_(iy, ix)] if U is not None else None
1787
+ V2 = V[np.ix_(iy, ix)] if V is not None else None
1788
+ vm2 = vm[np.ix_(iy, ix)] if vm is not None else None
1789
+ return X2, Y2, U2, V2, vm2
1790
+ except Exception:
1791
+ pass
1792
+ # Fallback: if X/Y are 1D or sanitization failed, try to
1793
+ # build a regular mesh from unique/mean coordinates.
1794
+ try:
1795
+ if getattr(X, 'ndim', 1) == 2:
1796
+ xs = np.mean(X, axis=0)
1797
+ else:
1798
+ xs = X
1799
+ if getattr(Y, 'ndim', 1) == 2:
1800
+ ys = np.mean(Y, axis=1)
1801
+ else:
1802
+ ys = Y
1803
+ xs_sorted = np.sort(xs)
1804
+ ys_sorted = np.sort(ys)
1805
+ Xg, Yg = np.meshgrid(xs_sorted, ys_sorted)
1806
+ # If U/V provided and shapes match, try to reorder using
1807
+ # argsort indices derived from means; otherwise return mesh.
1808
+ if U is not None and V is not None:
1809
+ ix = np.argsort(xs)
1810
+ iy = np.argsort(ys)
1811
+ U2 = U[np.ix_(iy, ix)] if U.ndim == 2 else U
1812
+ V2 = V[np.ix_(iy, ix)] if V.ndim == 2 else V
1813
+ vm2 = vm[np.ix_(iy, ix)] if (vm is not None and vm.ndim == 2) else vm
1814
+ return Xg, Yg, U2, V2, vm2
1815
+ return Xg, Yg, U, V, vm
1816
+ except Exception:
1817
+ return X, Y, U, V, vm
1818
+
1819
+ Xs, Ys, vxs, vys, vms = _sanitize_grid(X_plot, Y_plot, vx_plot, vy_plot, vmag_kms)
1820
+ stream_obj = ax.streamplot(
1821
+ Xs, Ys, vxs, vys,
1822
+ color=vms if vms is not None else None,
1823
+ linewidth=0.5,
1824
+ density=stream_density,
1825
+ cmap=stream_cmap,
1826
+ arrowsize=getattr(self, "stream_arrow_size", 1.0)
1827
+ )
1828
+
1829
+ if self.reflect_enabled:
1830
+ # Prefer using the FieldInterpolator's reflect augmentation
1831
+ # via evaluate(..., reflect=True) when available. For XZ
1832
+ # cuts (phi fixed) build a regular mesh using var1 (x)
1833
+ # and var3 (z) from the FieldInterpolator and set
1834
+ # zs = linspace(-zmax, zmin, res) as requested.
1835
+ try:
1836
+ # Use reflect via the FieldInterpolator only when we actually
1837
+ # interpolated the field (i.e. `interpolate` is True). If not
1838
+ # interpolating, keep the previous fallback behaviour.
1839
+ if interpolate and hasattr(mesh_source, 'evaluate') and is_fixed('phi', slice_str):
1840
+ # Extract native mesh ranges (simulation units)
1841
+ xvals = getattr(mesh_source, 'var1_mesh')[0]
1842
+ zvals = getattr(mesh_source, 'var3_mesh')[0]
1843
+ xmin, xmax = float(np.nanmin(xvals)), float(np.nanmax(xvals))
1844
+ zmin, zmax = float(np.nanmin(zvals)), float(np.nanmax(zvals))
1845
+
1846
+ xs = np.linspace(xmin, xmax, res)
1847
+ zs = np.linspace(-zmax, zmin, res)
1848
+ V1, V3 = np.meshgrid(xs, zs)
1849
+
1850
+ # Interpolate reflected fields on that mesh depending on map type
1851
+ try:
1852
+ if map_type == 'Density':
1853
+ data_map_ref = mesh_source.evaluate(field='gasdens', time=n, var1=V1, var3=V3, reflect=True)
1854
+ # Match main path: convert to log10 density in physical units
1855
+ data_map_ref = np.log10(data_map_ref * self.sim.URHO)
1856
+ # Also get velocity for streamlines
1857
+ vel_ref = mesh_source.evaluate(field='gasv', time=n, var1=V1, var3=V3, reflect=True)
1858
+ elif map_type == 'Energy':
1859
+ data_map_ref = mesh_source.evaluate(field='gasenergy', time=n, var1=V1, var3=V3, reflect=True)
1860
+ vel_ref = mesh_source.evaluate(field='gasv', time=n, var1=V1, var3=V3, reflect=True)
1861
+ else: # Velocity
1862
+ vel_ref = mesh_source.evaluate(field='gasv', time=n, var1=V1, var3=V3, reflect=True)
1863
+ idx_ref = VEL_INDEX.get(vel_comp, 0)
1864
+ data_map_ref = vel_ref[idx_ref]
1865
+
1866
+ # Convert reflected mesh to plot units
1867
+ Xr_plot = V1 * self.sim.UL / scale_factor
1868
+ Yr_plot = V3 * self.sim.UL / scale_factor
1869
+
1870
+ # Convert velocities to plot units and compute vmr_kms as in main path
1871
+ try:
1872
+ vxr_plot = (vel_ref[0] * v_factor) / scale_factor
1873
+ vyr_plot = (vel_ref[2] * v_factor) / scale_factor
1874
+ except Exception:
1875
+ vxr_plot = vyr_plot = None
1876
+
1877
+ try:
1878
+ vmag_phys_ref = np.sqrt((vel_ref[0] * v_factor)**2 + (vel_ref[2] * v_factor)**2)
1879
+ denom = 1e5 if sim_unitsys == 'cgs' else 1e3
1880
+ vmr_kms = vmag_phys_ref / denom
1881
+ except Exception:
1882
+ vmr_kms = None
1883
+
1884
+ # If map_type is Velocity, convert scalar field to km/s for coloring
1885
+ if map_type == 'Velocity' and data_map_ref is not None:
1886
+ try:
1887
+ denom = 1e5 if sim_unitsys == 'cgs' else 1e3
1888
+ data_map_ref = (data_map_ref * v_factor) / denom
1889
+ except Exception:
1890
+ pass
1891
+
1892
+ self._plot_reflection_overlay(
1893
+ ax,
1894
+ Xr_plot,
1895
+ Yr_plot,
1896
+ data_map_ref,
1897
+ cmap,
1898
+ vmin,
1899
+ vmax,
1900
+ show_streamlines,
1901
+ stream_density,
1902
+ stream_cmap,
1903
+ vxr_plot,
1904
+ vyr_plot,
1905
+ vmr_kms,
1906
+ skip_coord_reflect=True,
1907
+ )
1908
+ except Exception:
1909
+ # On any error fall back to default overlay
1910
+ self._plot_reflection_overlay(
1911
+ ax,
1912
+ X_plot,
1913
+ Y_plot,
1914
+ data_map,
1915
+ cmap,
1916
+ vmin,
1917
+ vmax,
1918
+ show_streamlines,
1919
+ stream_density,
1920
+ stream_cmap,
1921
+ vx_plot,
1922
+ vy_plot,
1923
+ vmag_kms,
1924
+ )
1925
+ else:
1926
+ # Fallback to previous behaviour using already computed data_map
1927
+ self._plot_reflection_overlay(
1928
+ ax,
1929
+ X_plot,
1930
+ Y_plot,
1931
+ data_map,
1932
+ cmap,
1933
+ vmin,
1934
+ vmax,
1935
+ show_streamlines,
1936
+ stream_density,
1937
+ stream_cmap,
1938
+ vx_plot,
1939
+ vy_plot,
1940
+ vmag_kms,
1941
+ )
1942
+ except Exception:
1943
+ # On any error, fallback silently to previous behaviour
1944
+ self._plot_reflection_overlay(
1945
+ ax,
1946
+ X_plot,
1947
+ Y_plot,
1948
+ data_map,
1949
+ cmap,
1950
+ vmin,
1951
+ vmax,
1952
+ show_streamlines,
1953
+ stream_density,
1954
+ stream_cmap,
1955
+ vx_plot,
1956
+ vy_plot,
1957
+ vmag_kms,
1958
+ )
1959
+
1960
+ planets = self.sim.load_planets(snapshot=n)
1961
+ if planets:
1962
+ center_x = planets[0].pos.x * self.sim.UL / scale_factor
1963
+ center_y = planets[0].pos.y * self.sim.UL / scale_factor
1964
+ radius = hill_frac * planets[0].hill_radius * self.sim.UL / scale_factor
1965
+ else:
1966
+ center_x = 0
1967
+ center_y = 0
1968
+ radius = 0
1969
+
1970
+ hill_color = getattr(self, "hill_color", "red")
1971
+ if show_circle:
1972
+ if is_fixed('theta', slice_str):
1973
+ from matplotlib.patches import Circle
1974
+ circle = Circle((center_x, center_y), radius, color=hill_color, fill=False, linestyle='--', linewidth=3,label=fr'${hill_frac:.1f}\,R_H$')
1975
+ ax.add_patch(circle)
1976
+ elif is_fixed('phi', slice_str):
1977
+ theta = np.linspace(0, np.pi, 100)
1978
+ x = center_x + radius * np.cos(theta)
1979
+ y = center_y + radius * np.sin(theta)
1980
+ ax.plot(x, y, color=hill_color, linewidth=3,label=f'{hill_frac:.1f}'+r'$R_H$', linestyle='--')
1981
+
1982
+ # Change font and size of axis labels
1983
+ font_properties = {'fontsize': 15, 'fontname': 'Serif'}
1984
+ ax.set_xlabel(xlabel, **font_properties)
1985
+ ax.set_ylabel(ylabel, **font_properties)
1986
+ fp.Plot.fargopy_mark(ax)
1987
+ ax.legend(fontsize=15, prop={'family': 'Serif'}, loc='upper left')
1988
+ ax.grid(0.1)
1989
+
1990
+ # --- Colorbar label ---
1991
+ if show_streamlines and stream_obj is not None and vmag_kms is not None:
1992
+ cbar = self.figure.colorbar(stream_obj.lines, ax=ax)
1993
+ cbar.set_label('$|v|$ [km/s]', fontsize=15, fontname='Serif')
1994
+ else:
1995
+ if map_type == 'Density':
1996
+ cbar_label = r'$\log_{10}(\rho)$' + f' [{dens_unit}]'
1997
+ elif map_type == 'Energy':
1998
+ cbar_label = r'$\log_{10}(\mathrm{{energy}})$'
1999
+ else:
2000
+ # Always show velocity colorbar in km/s
2001
+ cbar_label = f'{vel_comp} [km/s]'
2002
+ cbar = self.figure.colorbar(pcm, ax=ax)
2003
+ cbar.set_label(cbar_label, fontsize=18, fontname='Serif')
2004
+
2005
+ self.canvas.draw()
2006
+ QTimer.singleShot(800, lambda: self.status_label.setText(""))
2007
+
2008
+ def show_plot_options_dialog(self):
2009
+ dlg = PlotOptionsDialog(self)
2010
+ dlg.exec_()
2011
+
2012
+ def open_reflect_dialog(self):
2013
+ if not self.sim:
2014
+ return
2015
+ dlg = ReflectDialog(self, enabled=self.reflect_enabled, axis=self.reflect_axis)
2016
+ if dlg.exec_() == QDialog.Accepted:
2017
+ enabled, axis = dlg.values()
2018
+ self.reflect_enabled = enabled
2019
+ self.reflect_axis = axis
2020
+ self.plot_density()
2021
+
2022
+ def open_video_options_dialog(self):
2023
+ if not self.sim:
2024
+ return
2025
+ nmax = self.sim._get_nsnaps() - 1
2026
+ dlg = VideoOptionsDialog(self, nmax=nmax)
2027
+ if dlg.exec_() == QDialog.Accepted:
2028
+ fps = dlg.fps_spin.value()
2029
+ bitrate = dlg.bitrate_spin.value()
2030
+ start_snap = dlg.start_snap_spin.value()
2031
+ end_snap = dlg.end_snap_spin.value()
2032
+ self.create_video_with_options(fps, bitrate, start_snap, end_snap)
2033
+
2034
+ def create_video_with_options(self, fps, bitrate, start_snap, end_snap):
2035
+ from PyQt5.QtWidgets import QFileDialog, QMessageBox
2036
+ video_path, _ = QFileDialog.getSaveFileName(self, "Save video", "fargopy_video.mp4", "MP4 Files (*.mp4)")
2037
+ if not video_path:
2038
+ self.video_button.setEnabled(True)
2039
+ return
2040
+
2041
+ original_snapshot = self.time_slider.value()
2042
+ fig = self.figure
2043
+ self.video_button.setEnabled(False)
2044
+ self._video_animating = True
2045
+
2046
+ frames = list(range(start_snap, end_snap + 1))
2047
+ progress = RecordingProgressDialog(self)
2048
+ progress.show()
2049
+ QApplication.processEvents()
2050
+
2051
+ writer = FFMpegWriter(fps=fps, metadata=dict(artist='FARGOpy'), bitrate=bitrate)
2052
+ frames_written = 0
2053
+
2054
+ try:
2055
+ with writer.saving(fig, video_path, dpi=fig.dpi or 100):
2056
+ for snap in frames:
2057
+ QApplication.processEvents()
2058
+ if progress.stop_requested:
2059
+ break
2060
+ self.time_slider.blockSignals(True)
2061
+ self.time_slider.setValue(snap)
2062
+ self.time_slider.blockSignals(False)
2063
+ self.plot_density()
2064
+ writer.grab_frame()
2065
+ frames_written += 1
2066
+ except Exception as e:
2067
+ progress.close()
2068
+ QMessageBox.critical(self, "Error creating video", f"Could not create video:\n{e}")
2069
+ self.time_slider.setValue(original_snapshot)
2070
+ self.video_button.setEnabled(True)
2071
+ self._video_animating = False
2072
+ return
2073
+ finally:
2074
+ progress.close()
2075
+
2076
+ self._video_animating = False
2077
+ self.time_slider.setValue(original_snapshot)
2078
+ self.video_button.setEnabled(True)
2079
+
2080
+ if frames_written == 0:
2081
+ try:
2082
+ os.remove(video_path)
2083
+ except Exception:
2084
+ pass
2085
+ QMessageBox.warning(self, "Video not created", "Recording stopped before any frame was captured.")
2086
+ return
2087
+
2088
+ try:
2089
+ if sys.platform.startswith('linux'):
2090
+ subprocess.Popen(['xdg-open', video_path])
2091
+ elif sys.platform.startswith('darwin'):
2092
+ subprocess.Popen(['open', video_path])
2093
+ elif sys.platform.startswith('win'):
2094
+ os.startfile(video_path)
2095
+ except Exception:
2096
+ pass
2097
+
2098
+ msg = "Video saved successfully."
2099
+ if progress.stop_requested:
2100
+ msg += f"\nStopped early after {frames_written} frame(s)."
2101
+ QMessageBox.information(self, "Video created", f"{msg}\nPath:\n{video_path}")
2102
+
2103
+ if __name__ == "__main__":
2104
+ print("Starting GUI...")
2105
+ app = QApplication(sys.argv)
2106
+ window = PlotInteractiveWindow()
2107
+ window.setWindowTitle("FARGOpy Interactive Plot")
2108
+ window.resize(1350, 800)
2109
+ window.show()
2110
+ print("Window shown. Running app...")
2111
+ sys.exit(app.exec_())