fargopy 0.3.15__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fargopy/__init__.py +9 -346
- fargopy/base.py +377 -0
- fargopy/bin/ifargopy +91 -0
- fargopy/bin/vfargopy +2111 -0
- fargopy/data/fargopy_logo.png +0 -0
- fargopy/fields.py +1590 -44
- fargopy/flux.py +894 -0
- fargopy/plot.py +553 -8
- fargopy/simulation.py +1597 -438
- fargopy/sys.py +116 -65
- fargopy/tests/test_base.py +8 -0
- fargopy/tests/test_flux.py +76 -0
- fargopy/tests/test_interp.py +132 -0
- fargopy-1.0.0.data/scripts/ifargopy +91 -0
- fargopy-1.0.0.data/scripts/vfargopy +2111 -0
- fargopy-1.0.0.dist-info/METADATA +425 -0
- fargopy-1.0.0.dist-info/RECORD +21 -0
- {fargopy-0.3.15.dist-info → fargopy-1.0.0.dist-info}/WHEEL +1 -1
- fargopy-1.0.0.dist-info/licenses/LICENSE +661 -0
- fargopy/fsimulation.py +0 -603
- fargopy/tests/test___init__.py +0 -0
- fargopy/util.py +0 -21
- fargopy/version.py +0 -1
- fargopy-0.3.15.data/scripts/ifargopy +0 -15
- fargopy-0.3.15.dist-info/METADATA +0 -489
- fargopy-0.3.15.dist-info/RECORD +0 -16
- fargopy-0.3.15.dist-info/licenses/LICENSE +0 -21
- {fargopy-0.3.15.dist-info → fargopy-1.0.0.dist-info}/entry_points.txt +0 -0
- {fargopy-0.3.15.dist-info → fargopy-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -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_())
|