cubespa 0.1.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.
- cubespa/__init__.py +16 -0
- cubespa/analysis/__init__.py +5 -0
- cubespa/analysis/radii_measurements.py +498 -0
- cubespa/analysis/velocityanalysis.py +177 -0
- cubespa/cubespa.py +304 -0
- cubespa/data.py +217 -0
- cubespa/flux_analysis.py +241 -0
- cubespa/modeling/__init__.py +3 -0
- cubespa/modeling/velocity_map.py +103 -0
- cubespa/moment_maps.py +97 -0
- cubespa/overlays.py +30 -0
- cubespa/plotting/__init__.py +11 -0
- cubespa/plotting/channel_map_plots.py +62 -0
- cubespa/plotting/mommap_plots.py +135 -0
- cubespa/plotting/overlay_plots.py +120 -0
- cubespa/plotting/pvd_plots.py +43 -0
- cubespa/plotting/spectra_plots.py +218 -0
- cubespa/plotting/util_plots.py +67 -0
- cubespa/plotting/utils.py +69 -0
- cubespa/pvds.py +20 -0
- cubespa/spectra.py +139 -0
- cubespa/utils.py +383 -0
- cubespa-0.1.0.dist-info/METADATA +20 -0
- cubespa-0.1.0.dist-info/RECORD +26 -0
- cubespa-0.1.0.dist-info/WHEEL +5 -0
- cubespa-0.1.0.dist-info/top_level.txt +1 -0
cubespa/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
from .analysis import * # Plots for flux analysis
|
|
3
|
+
from .modeling import *
|
|
4
|
+
|
|
5
|
+
from .cubespa import * # Module with all CubeSPA object functions
|
|
6
|
+
from .data import * # Data handling modules
|
|
7
|
+
from .flux_analysis import * # Flux analysis routines
|
|
8
|
+
from .moment_maps import * # Module for generating moment maps using masking
|
|
9
|
+
from .overlays import * # Module for reprojection and creating overlays
|
|
10
|
+
from .pvds import * # Module for position velocity diagrams (PVDs)
|
|
11
|
+
from .spectra import * # Module for generating and analyzing spectra
|
|
12
|
+
from .utils import * # All miscellaneous utility modules
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from photutils.aperture import EllipticalAperture
|
|
6
|
+
|
|
7
|
+
from scipy.signal import savgol_filter
|
|
8
|
+
from scipy.interpolate import interp1d
|
|
9
|
+
|
|
10
|
+
from matplotlib import pyplot as plt
|
|
11
|
+
from matplotlib.animation import FuncAnimation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def R_ellip(r, ellip, theta):
|
|
15
|
+
axial_ratio = 1 - ellip
|
|
16
|
+
b = r * axial_ratio
|
|
17
|
+
eccentricity = np.sqrt(1 - axial_ratio**2)
|
|
18
|
+
|
|
19
|
+
return b / np.sqrt(1 - eccentricity**2 * np.cos(theta)**2)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def deviation_from_major_axis(theta, pa):
|
|
23
|
+
"""
|
|
24
|
+
Calculate the deviation from the major axis in radians.
|
|
25
|
+
"""
|
|
26
|
+
delta = (theta - pa) % np.pi
|
|
27
|
+
|
|
28
|
+
return np.min([delta, np.pi - delta], axis=0) # Return the smallest angle to the major axis
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Profile:
|
|
32
|
+
def __init__(self, rs, fluxes, rmax=None, **kwargs):
|
|
33
|
+
self.rs = rs
|
|
34
|
+
self.fluxes = fluxes
|
|
35
|
+
self.rmax = rmax
|
|
36
|
+
|
|
37
|
+
nanmask = np.isnan(fluxes)
|
|
38
|
+
if np.all(nanmask):
|
|
39
|
+
raise ValueError("All fluxes are NaN. Cannot create profile.")
|
|
40
|
+
self.rs = rs[~nanmask]
|
|
41
|
+
self.fluxes = fluxes[~nanmask]
|
|
42
|
+
|
|
43
|
+
if rmax is not None:
|
|
44
|
+
self.rs = self.rs[self.rs <= rmax]
|
|
45
|
+
self.fluxes = self.fluxes[:len(self.rs)]
|
|
46
|
+
|
|
47
|
+
self.prof_interp = interp1d(rs, fluxes, bounds_error=False, fill_value=np.nan)
|
|
48
|
+
|
|
49
|
+
self.theta = kwargs.get("theta", 0)
|
|
50
|
+
self.tmin = kwargs.get("tmin", 0)
|
|
51
|
+
self.tmax = kwargs.get("tmax", 2 * np.pi)
|
|
52
|
+
|
|
53
|
+
self.min_val = kwargs.get("min_val", 0.1)
|
|
54
|
+
self.r_trunc = self.calc_rtrunc(self.min_val)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def prof_smoothed(self, fluxes=None, **kwargs):
|
|
58
|
+
if fluxes is None:
|
|
59
|
+
fluxes = self.fluxes
|
|
60
|
+
return savgol_filter(fluxes, kwargs.get("window_length", 7), kwargs.get("polyorder", 1), mode='nearest')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def oversampled_fluxes(self, dsub=0.2, smooth=True, **kwargs):
|
|
64
|
+
rs_fine = np.arange(self.rs.min(), self.rs.max(), dsub)
|
|
65
|
+
fluxes_fine = self.prof_interp(rs_fine)
|
|
66
|
+
if smooth:
|
|
67
|
+
fluxes_fine = self.prof_smoothed(fluxes_fine, window_length=kwargs.get("window_length", 11),
|
|
68
|
+
polyorder=kwargs.get("polyorder", 3))
|
|
69
|
+
return rs_fine, fluxes_fine
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def calc_rtrunc(self, min_val = 0.1):
|
|
73
|
+
rs_fine = np.linspace(self.rs.min(), self.rs.max(), 1000)
|
|
74
|
+
fluxes_smoothed = self.prof_smoothed(self.prof_interp(rs_fine))
|
|
75
|
+
|
|
76
|
+
below_min = fluxes_smoothed < min_val
|
|
77
|
+
if not np.any(below_min):
|
|
78
|
+
return rs_fine[-1]
|
|
79
|
+
else:
|
|
80
|
+
return rs_fine[np.argmax(below_min)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RadiiMeasurer:
|
|
84
|
+
def __init__(self, mom0, **kwargs):
|
|
85
|
+
self.mom0 = mom0
|
|
86
|
+
self.name = kwargs.get("name", "Unknown")
|
|
87
|
+
self.tail_angle = kwargs.get("tail_angle", 0)
|
|
88
|
+
self.cent_x, self.cent_y = kwargs.get("cent", (mom0.shape[1] // 2, mom0.shape[0] // 2))
|
|
89
|
+
self.ellip = kwargs.get("ellip", 0.1) # Default value if not provided
|
|
90
|
+
self.pa = kwargs.get("pa", 0) #
|
|
91
|
+
|
|
92
|
+
self.ra = kwargs.get("ra", 0)
|
|
93
|
+
self.dec = kwargs.get("dec", 0)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
self.leading_side_angle = (self.tail_angle + np.pi) % (2 * np.pi)
|
|
97
|
+
|
|
98
|
+
ys, xs = np.indices(mom0.shape)
|
|
99
|
+
self.rmap = np.sqrt((xs - self.cent_x)**2 + (ys - self.cent_y)**2)
|
|
100
|
+
self.theta_map = np.arctan2(ys - self.cent_y, xs - self.cent_x) % (2 * np.pi)
|
|
101
|
+
self.deviation_map = deviation_from_major_axis(self.theta_map, self.pa)
|
|
102
|
+
self.correction_map = R_ellip(1, self.ellip, self.deviation_map)
|
|
103
|
+
|
|
104
|
+
self.theta_range = kwargs.get("theta_range", np.pi / 2)
|
|
105
|
+
self.tmin = self.leading_side_angle - self.theta_range / 2
|
|
106
|
+
self.tmax = self.leading_side_angle + self.theta_range / 2
|
|
107
|
+
|
|
108
|
+
self.truncation_value = kwargs.get("truncation_value", 0.5)
|
|
109
|
+
|
|
110
|
+
self.profiles = []
|
|
111
|
+
self.r_trunc = 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def gen_theta_mask(self, tmin, tmax):
|
|
115
|
+
"""
|
|
116
|
+
Generate a mask for the theta map based on the provided angle range.
|
|
117
|
+
"""
|
|
118
|
+
tmin = tmin % (2 * np.pi)
|
|
119
|
+
tmax = tmax % (2 * np.pi)
|
|
120
|
+
|
|
121
|
+
if tmin < tmax:
|
|
122
|
+
# Simple case: no wraparound
|
|
123
|
+
return (self.theta_map >= tmin) & (self.theta_map <= tmax)
|
|
124
|
+
else:
|
|
125
|
+
# Wraparound case: e.g. tmin=3π/4, tmax=-3π/4
|
|
126
|
+
return (self.theta_map >= tmin) | (self.theta_map <= tmax)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def obtain_profile(self, tmin, tmax, mask=None, ellip_correction=True, **kwargs):
|
|
130
|
+
if mask is None:
|
|
131
|
+
mask = self.gen_theta_mask(tmin, tmax)
|
|
132
|
+
|
|
133
|
+
mom0_masked = np.copy(self.mom0)
|
|
134
|
+
mom0_masked[~mask] = np.nan # Mask out the regions
|
|
135
|
+
|
|
136
|
+
rs_flat = self.rmap[mask].flatten()
|
|
137
|
+
|
|
138
|
+
if ellip_correction:
|
|
139
|
+
corr_map = np.copy(self.correction_map)
|
|
140
|
+
rs_flat /= corr_map[mask].flatten()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
fluxes_flat = mom0_masked[mask].flatten()
|
|
144
|
+
|
|
145
|
+
sort_indices = np.argsort(rs_flat)
|
|
146
|
+
rs_flat = rs_flat[sort_indices]
|
|
147
|
+
fluxes_flat = fluxes_flat[sort_indices]
|
|
148
|
+
|
|
149
|
+
profile = Profile(
|
|
150
|
+
rs=rs_flat,
|
|
151
|
+
fluxes=fluxes_flat,
|
|
152
|
+
theta=np.nanmean([tmin, tmax]),
|
|
153
|
+
tmin =tmin,
|
|
154
|
+
tmax=tmax,
|
|
155
|
+
min_val=self.truncation_value,
|
|
156
|
+
rmax = kwargs.get("rmax", None),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return profile
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def profile_at_angle(self, angle, width = 15, **kwargs):
|
|
163
|
+
"""
|
|
164
|
+
Obtain a profile at a specific angle with a given width.
|
|
165
|
+
"""
|
|
166
|
+
tmin, tmax = angle - np.deg2rad(width) / 2, angle + np.deg2rad(width) / 2
|
|
167
|
+
mask = self.gen_theta_mask(tmin, tmax)
|
|
168
|
+
|
|
169
|
+
return self.obtain_profile(tmin, tmax, mask, **kwargs)
|
|
170
|
+
|
|
171
|
+
def calc_truncs(self):
|
|
172
|
+
rtruncs = [prof.r_trunc for prof in self.profiles if prof.r_trunc is not None]
|
|
173
|
+
if not rtruncs:
|
|
174
|
+
print(f"No valid truncation radii found for {self.name}.")
|
|
175
|
+
self.r_trunc = 0
|
|
176
|
+
self.r_trunc_scatter = 0
|
|
177
|
+
return
|
|
178
|
+
self.r_trunc = np.nanmedian(rtruncs)
|
|
179
|
+
self.r_trunc_scatter = np.nanstd(rtruncs)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def measure_rtrunc(self, n_slices=12):
|
|
183
|
+
|
|
184
|
+
self.theta_bins = np.linspace(self.tmin, self.tmax, n_slices)
|
|
185
|
+
|
|
186
|
+
self.theta_avgs = np.mean([self.theta_bins[:-1], self.theta_bins[1:]], axis=0)
|
|
187
|
+
|
|
188
|
+
for i in range(0, len(self.theta_bins) - 1):
|
|
189
|
+
try:
|
|
190
|
+
theta_low, theta_high = self.theta_bins[i], self.theta_bins[i + 1]
|
|
191
|
+
|
|
192
|
+
prof = self.obtain_profile(theta_low, theta_high)
|
|
193
|
+
self.profiles.append(prof)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
self.calc_truncs()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_data_row(self, arcconv=1, kpc_per_arcsec=1):
|
|
201
|
+
"""
|
|
202
|
+
Add the measured data to the results dictionary.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"NAME": self.name,
|
|
207
|
+
"R_TRUNC": self.r_trunc,
|
|
208
|
+
"R_TRUNC_ARCSEC": self.r_trunc * arcconv,
|
|
209
|
+
"R_TRUNC_KPC": self.r_trunc * arcconv * kpc_per_arcsec,
|
|
210
|
+
"R_TRUNC_SCATTER": self.r_trunc_scatter,
|
|
211
|
+
"R_TRUNC_SCATTER_ARCSEC": self.r_trunc_scatter * arcconv,
|
|
212
|
+
"R_TRUNC_SCATTER_KPC": self.r_trunc_scatter * arcconv * kpc_per_arcsec,
|
|
213
|
+
"TAIL_PA": self.tail_angle,
|
|
214
|
+
"MAJOR_AXIS": self.pa,
|
|
215
|
+
"ELLIP": self.ellip,
|
|
216
|
+
"RA": float(self.ra),
|
|
217
|
+
"DEC": float(self.dec),
|
|
218
|
+
"CENT_X": float(self.cent_x),
|
|
219
|
+
"CENT_Y": float(self.cent_y),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def diagnostic_plot(self, pad=100, save=True, **kwargs):
|
|
224
|
+
"""
|
|
225
|
+
Create a diagnostic plot for the radii measurement.
|
|
226
|
+
"""
|
|
227
|
+
plotter = RadiiPlotter(self)
|
|
228
|
+
plotter.diagnostic_plot(pad=pad, save=save, **kwargs )
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def plot_single_profile(self, profile, ax=None, **kwargs):
|
|
232
|
+
"""
|
|
233
|
+
Plot a single profile on the given axis.
|
|
234
|
+
"""
|
|
235
|
+
plotter = RadiiPlotter(self)
|
|
236
|
+
plotter.plot_single_profile(profile, ax=ax, **kwargs)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class RadiiPlotter:
|
|
241
|
+
def __init__(self, rm):
|
|
242
|
+
self.rm = rm
|
|
243
|
+
|
|
244
|
+
def plot_tail_angle(self, axis, length=25, color='blue'):
|
|
245
|
+
"""
|
|
246
|
+
Plot the tail angle on the given axis.
|
|
247
|
+
"""
|
|
248
|
+
axis.plot([self.rm.cent_x, self.rm.cent_x + length * np.cos(self.rm.tail_angle)],
|
|
249
|
+
[self.rm.cent_y, self.rm.cent_y + length * np.sin(self.rm.tail_angle)],
|
|
250
|
+
color=color, lw=2, label='Tail Angle')
|
|
251
|
+
|
|
252
|
+
axis.plot([self.rm.cent_x, self.rm.cent_x + length * np.cos(self.rm.leading_side_angle)],
|
|
253
|
+
[self.rm.cent_y, self.rm.cent_y + length * np.sin(self.rm.leading_side_angle)],
|
|
254
|
+
color=color, lw=2, ls="dashed")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def plot_major_axis(self, axis, length=25):
|
|
258
|
+
"""
|
|
259
|
+
Plot the major axis on the given axis.
|
|
260
|
+
"""
|
|
261
|
+
axis.plot([self.rm.cent_x - length * np.cos(self.rm.pa),
|
|
262
|
+
self.rm.cent_x + length * np.cos(self.rm.pa)],
|
|
263
|
+
[self.rm.cent_y - length * np.sin(self.rm.pa),
|
|
264
|
+
self.rm.cent_y + length * np.sin(self.rm.pa)],
|
|
265
|
+
color='red', lw=1, label='Major Axis')
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def plot_angles(self, axis, length=25):
|
|
269
|
+
"""
|
|
270
|
+
Plot both the tail angle and the major axis on the given axis.
|
|
271
|
+
"""
|
|
272
|
+
self.plot_tail_angle(axis, length)
|
|
273
|
+
self.plot_major_axis(axis, length)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def plot_slices(self, axis, length=25, cmap="rainbow"):
|
|
277
|
+
"""
|
|
278
|
+
Plot the slices defined by the tail angle and the major axis.
|
|
279
|
+
"""
|
|
280
|
+
for pa in [self.rm.tmin, self.rm.tmax]:
|
|
281
|
+
axis.plot([self.rm.cent_x, self.rm.cent_x + length * np.cos(pa)],
|
|
282
|
+
[self.rm.cent_y, self.rm.cent_y + length * np.sin(pa)],
|
|
283
|
+
color='black', lw=3, ls="dotted", label='Slice Boundaries')
|
|
284
|
+
|
|
285
|
+
for i, pa in enumerate(self.rm.theta_avgs):
|
|
286
|
+
#pa += self.rm.tail_angle
|
|
287
|
+
tnorm = (i + 1) / len(self.rm.theta_avgs)
|
|
288
|
+
|
|
289
|
+
color = plt.get_cmap(cmap)(tnorm)
|
|
290
|
+
|
|
291
|
+
axis.plot([self.rm.cent_x, self.rm.cent_x + length * np.cos(pa)],
|
|
292
|
+
[self.rm.cent_y, self.rm.cent_y + length * np.sin(pa)],
|
|
293
|
+
color=color, lw=3, ls="dashed", alpha=0.5, label='Slice Averages')
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def plot_ellipse(self, ax, r, ellip, pa, alpha=1):
|
|
297
|
+
ell = EllipticalAperture((int(self.rm.cent_x), int(self.rm.cent_y)), r, r * (1 - ellip), theta=pa)
|
|
298
|
+
ell.plot(ax=ax, color='red', lw=2, alpha=alpha, zorder=6)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def plot_profiles(self, ax, arcconv=1, cmap="rainbow"):
|
|
302
|
+
"""
|
|
303
|
+
Plot the radial profiles on the given axis.
|
|
304
|
+
"""
|
|
305
|
+
for i, prof in enumerate(self.rm.profiles):
|
|
306
|
+
tnorm = (i + 1) / len(self.rm.profiles)
|
|
307
|
+
|
|
308
|
+
color = plt.get_cmap(cmap)(tnorm)
|
|
309
|
+
r_trunc = prof.r_trunc
|
|
310
|
+
ax.plot(prof.rs, prof.fluxes, color=color, lw=2, alpha=0.7)
|
|
311
|
+
ax.axvline(r_trunc * arcconv, color="black", lw=0.5, ls="dotted")
|
|
312
|
+
|
|
313
|
+
ax.axvline(self.rm.r_trunc * arcconv, color="black", lw=5, ls="dashed", label=r'$R_{CO}$')
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def diagnostic_plot(self, pad=100, save=True, **kwargs):
|
|
317
|
+
fig, ax = plt.subplots(1, 4, figsize=(12, 3.5), facecolor='white')
|
|
318
|
+
|
|
319
|
+
ax[0].imshow(self.rm.theta_map, cmap='rainbow', origin='lower', zorder=1, alpha=0.2)
|
|
320
|
+
ax[0].scatter(self.rm.cent_x, self.rm.cent_y, marker='x', color='red', s=100, zorder=2)
|
|
321
|
+
|
|
322
|
+
ax[1].imshow(self.rm.deviation_map, cmap="Blues_r", origin="lower", alpha=0.3, zorder=0)
|
|
323
|
+
|
|
324
|
+
for i in range(3):
|
|
325
|
+
ax[i].imshow(np.log10(self.rm.mom0), cmap='Greys', origin='lower',
|
|
326
|
+
vmin=np.log10(self.rm.truncation_value),
|
|
327
|
+
vmax=np.nanmax(np.log10(self.rm.mom0)))
|
|
328
|
+
ax[i].set(xlim=(self.rm.cent_x - pad, self.rm.cent_x + pad),
|
|
329
|
+
ylim=(self.rm.cent_y - pad, self.rm.cent_y + pad))
|
|
330
|
+
|
|
331
|
+
self.plot_angles(ax[0], length=pad / 1.5)
|
|
332
|
+
ax[0].legend(loc='upper left', fontsize=10, frameon=False)
|
|
333
|
+
|
|
334
|
+
self.plot_tail_angle(ax[1], length=pad, color="black")
|
|
335
|
+
self.plot_slices(ax[1], length=pad)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
for i, r in enumerate([self.rm.r_trunc,
|
|
340
|
+
self.rm.r_trunc - self.rm.r_trunc_scatter,
|
|
341
|
+
self.rm.r_trunc + self.rm.r_trunc_scatter]):
|
|
342
|
+
if r <= 0:
|
|
343
|
+
continue
|
|
344
|
+
alpha = 1 if i == 0 else 0.5
|
|
345
|
+
self.plot_ellipse(ax[2], r, self.rm.ellip, self.rm.pa, alpha=alpha)
|
|
346
|
+
|
|
347
|
+
ax[2].text(0.02, 0.98, f"$R_{{CO}} = {self.rm.r_trunc:.2f}$", transform=ax[2].transAxes,
|
|
348
|
+
ha='left', va='top', fontsize=10)
|
|
349
|
+
ax[2].text(0.02, 0.02, f"$sigma_{{R_{{CO}}}} = {self.rm.r_trunc_scatter:.2f}$", transform=ax[2].transAxes,
|
|
350
|
+
ha='left', va='bottom', fontsize=10)
|
|
351
|
+
|
|
352
|
+
self.plot_profiles(ax[3], arcconv=1)
|
|
353
|
+
|
|
354
|
+
ax[3].axhline(self.rm.truncation_value, color="blue", lw=2, alpha=0.5, ls="dotted", label=r"$I_{trunc}$")
|
|
355
|
+
|
|
356
|
+
if kwargs.get("log_y", True):
|
|
357
|
+
ax[3].set_yscale('log')
|
|
358
|
+
if kwargs.get("log_x", False):
|
|
359
|
+
ax[3].set_xscale('log')
|
|
360
|
+
|
|
361
|
+
ax[3].set_xlim(0, self.rm.r_trunc * 1.5)
|
|
362
|
+
|
|
363
|
+
ax[3].legend(loc="lower left", fontsize=10, frameon=False, ncols=1)
|
|
364
|
+
ax[0].set_title("Theta Map and\nTail/Major Axis Angles")
|
|
365
|
+
ax[1].set_title("Theta Slices")
|
|
366
|
+
ax[2].set_title("Overplotted Ellipse")
|
|
367
|
+
ax[3].set_title("Radial Profiles")
|
|
368
|
+
|
|
369
|
+
for axis in ax[:3]:
|
|
370
|
+
axis.set(xticks=[], yticks=[])
|
|
371
|
+
|
|
372
|
+
ylims = ax[3].get_ylim()
|
|
373
|
+
ax[3].fill_between([self.rm.r_trunc - 1 * self.rm.r_trunc_scatter,
|
|
374
|
+
self.rm.r_trunc + 1 * self.rm.r_trunc_scatter],
|
|
375
|
+
*ylims, color="grey", alpha=0.5)
|
|
376
|
+
ax[3].set_ylim(*ylims)
|
|
377
|
+
plt.tight_layout()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
if save:
|
|
381
|
+
outname = kwargs.get("outname", f"plots/TruncationRadii/{self.rm.name}_diagnostic.png")
|
|
382
|
+
os.makedirs(os.path.dirname(outname), exist_ok=True)
|
|
383
|
+
|
|
384
|
+
plt.savefig(outname, dpi=200, bbox_inches='tight')
|
|
385
|
+
plt.close(fig)
|
|
386
|
+
else:
|
|
387
|
+
plt.show()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def plot_single_profile(self, profile, ax=None, **kwargs):
|
|
391
|
+
if ax is None:
|
|
392
|
+
fig, ax = plt.subplots(1, 2, figsize=(6, 4))
|
|
393
|
+
|
|
394
|
+
color = kwargs.get("color", "blue")
|
|
395
|
+
|
|
396
|
+
theta_mask = self.rm.gen_theta_mask(profile.tmin, profile.tmax)
|
|
397
|
+
theta_mask[theta_mask == 1] = np.nan
|
|
398
|
+
|
|
399
|
+
ax[0].imshow(self.rm.mom0, cmap='Greys', origin='lower', vmin=-3, vmax=1, alpha=0.1)
|
|
400
|
+
ax[0].imshow(self.rm.mom0, cmap='Greys', origin='lower',
|
|
401
|
+
vmin=np.log10(self.rm.truncation_value), vmax=1)
|
|
402
|
+
|
|
403
|
+
ax[0].imshow(theta_mask, cmap='Reds', origin='lower', alpha=0.5)
|
|
404
|
+
|
|
405
|
+
size = kwargs.get("size", 100)
|
|
406
|
+
ax[0].scatter(self.rm.cent_x, self.rm.cent_y, marker='x', color='black', s=size, zorder=2)
|
|
407
|
+
self.plot_tail_angle(ax[0], length=size, color=kwargs.get("color", "blue"))
|
|
408
|
+
|
|
409
|
+
ax[0].plot([self.rm.cent_x, self.rm.cent_x + size * np.cos(profile.theta)],
|
|
410
|
+
[self.rm.cent_y, self.rm.cent_y + size * np.sin(profile.theta)],
|
|
411
|
+
color=color, lw=1, label='Major Axis')
|
|
412
|
+
|
|
413
|
+
ax[0].set(xlim=(self.rm.cent_x - size, self.rm.cent_x + size),
|
|
414
|
+
ylim=(self.rm.cent_y - size, self.rm.cent_y + size))
|
|
415
|
+
|
|
416
|
+
ax[1].plot(profile.rs, profile.fluxes, color=kwargs.get("color", "blue"), lw=2, alpha=0.7)
|
|
417
|
+
ax[1].axvline(profile.r_trunc, color="black", lw=2, ls="dashed")
|
|
418
|
+
ax[1].axhline(self.rm.truncation_value, color="blue", lw=2, alpha=0.5, ls="dotted")
|
|
419
|
+
|
|
420
|
+
ax[1].loglog()
|
|
421
|
+
|
|
422
|
+
if kwargs.get("grid", True):
|
|
423
|
+
ax[0].grid(True, which='both', linestyle='--', linewidth=0.5)
|
|
424
|
+
ax[1].grid(True, which='both', linestyle='--', linewidth=0.5)
|
|
425
|
+
|
|
426
|
+
self.plot_ellipse(ax[0], profile.r_trunc, self.rm.ellip, self.rm.pa)
|
|
427
|
+
|
|
428
|
+
if ax is None:
|
|
429
|
+
plt.tight_layout()
|
|
430
|
+
plt.show()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def animated_profile_plot(self, tmin, tmax, n_thetas=20, width = np.deg2rad(15), ax_width = 100, ylim=[1e-3, 30],
|
|
434
|
+
outname = "animated_profile.mp4"):
|
|
435
|
+
|
|
436
|
+
rm = self.rm
|
|
437
|
+
|
|
438
|
+
name = rm.name.replace(" ", "_")
|
|
439
|
+
|
|
440
|
+
fig, ax = plt.subplots(1, 2, figsize=(12, 6), facecolor='white')
|
|
441
|
+
ax[0].imshow(rm.mom0, cmap='Greys', origin='lower', vmin=-3, vmax=1, alpha=0.1)
|
|
442
|
+
ax[0].imshow(rm.mom0, cmap='Greys', origin='lower',
|
|
443
|
+
vmin=np.log10(rm.truncation_value), vmax=1)
|
|
444
|
+
ax[0].set(xlim=(rm.cent_x - ax_width, rm.cent_x + ax_width),
|
|
445
|
+
ylim=(rm.cent_y - ax_width, rm.cent_y + ax_width))
|
|
446
|
+
tmask = rm.gen_theta_mask(tmin - width / 2, tmin + width / 2)
|
|
447
|
+
# tmask[tmask > 1e-10] = np.nan
|
|
448
|
+
theta_mask = ax[0].imshow(tmask, cmap='rainbow',
|
|
449
|
+
origin='lower', zorder=1, alpha=0.2)
|
|
450
|
+
|
|
451
|
+
theta_bins = np.linspace(tmin, tmax, n_thetas)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
prof = rm.profile_at_angle(tmin, width=np.rad2deg(width))
|
|
455
|
+
|
|
456
|
+
line = ax[1].plot(prof.rs, prof.fluxes, color='blue', lw=2, alpha=0.7)[0]
|
|
457
|
+
|
|
458
|
+
hline = ax[1].axhline(y=rm.truncation_value, color='blue', lw=2, alpha=0.5, ls='dotted')
|
|
459
|
+
vline = ax[1].axvline(x=prof.r_trunc, color='blue', lw=2, ls='--')
|
|
460
|
+
|
|
461
|
+
xlim = 1, ax_width / 2
|
|
462
|
+
ax[1].set(xlim=xlim, ylim=ylim, xlabel='R [pix]', ylabel='Flux [Jy beam km s$^{-1}$]')
|
|
463
|
+
ax[1].loglog()
|
|
464
|
+
ax[1].grid(True, which='both', linestyle='--', linewidth=0.5, zorder=0, alpha=0.5)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
plotter = RadiiPlotter(rm)
|
|
468
|
+
|
|
469
|
+
thetas = np.linspace(tmin, tmax, n_thetas)
|
|
470
|
+
circle_xs = rm.cent_x + prof.r_trunc * np.cos(thetas)
|
|
471
|
+
circle_ys = rm.cent_y + prof.r_trunc * np.sin(thetas)
|
|
472
|
+
circle = ax[0].plot(circle_xs, circle_ys, color='red', lw=2, alpha=0.5)[0]
|
|
473
|
+
|
|
474
|
+
def update(frame):
|
|
475
|
+
theta = theta_bins[frame]
|
|
476
|
+
tmask = rm.gen_theta_mask(theta - width / 2, theta + width / 2)
|
|
477
|
+
|
|
478
|
+
theta_mask.set_data(tmask)
|
|
479
|
+
|
|
480
|
+
prof = rm.profile_at_angle(theta, width=np.rad2deg(width), rmax=40)
|
|
481
|
+
|
|
482
|
+
rs, fluxes = prof.oversampled_fluxes(dsub=0.2, smooth=True)
|
|
483
|
+
line.set_data(prof.rs, prof.fluxes)
|
|
484
|
+
|
|
485
|
+
vline.set_xdata(prof.r_trunc)
|
|
486
|
+
|
|
487
|
+
circle_xs = rm.cent_x + prof.r_trunc * np.cos(thetas)
|
|
488
|
+
circle_ys = rm.cent_y + prof.r_trunc * np.sin(thetas)
|
|
489
|
+
circle.set_data(circle_xs, circle_ys)
|
|
490
|
+
|
|
491
|
+
ax[1].plot(rs, fluxes, color='Grey', lw=1, alpha=0.2)
|
|
492
|
+
ax[1].set(xlim=xlim, ylim=ylim)
|
|
493
|
+
ax[1].loglog()
|
|
494
|
+
|
|
495
|
+
plt.tight_layout()
|
|
496
|
+
ani = FuncAnimation(fig, func=update, frames=len(theta_bins))
|
|
497
|
+
|
|
498
|
+
ani.save(outname, writer='ffmpeg', fps=10, dpi=200)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from astropy.io import fits
|
|
3
|
+
|
|
4
|
+
from ..utils import remap, norm
|
|
5
|
+
|
|
6
|
+
from astropy.cosmology import Planck18 as cosmo
|
|
7
|
+
from astropy.convolution import convolve, Gaussian2DKernel
|
|
8
|
+
|
|
9
|
+
from matplotlib import pyplot as plt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ViewingGeometry:
|
|
13
|
+
def __init__(self, d_obs, tail_angle_deg=78, north_vec=[0, 0, 1]):
|
|
14
|
+
self.tail_angle_deg = tail_angle_deg
|
|
15
|
+
self.tail_angle = np.radians(self.tail_angle_deg)
|
|
16
|
+
self.tail_vector = np.array([0.0, np.sin(self.tail_angle), np.cos(self.tail_angle)])
|
|
17
|
+
|
|
18
|
+
self.d_obs = -norm(np.array(d_obs))
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def from_header(header, tail_angle_deg=78, north_vec = [0, 0, 1]):
|
|
22
|
+
d_obs = -np.array([header[f"LOS_{n}"] for n in ["X", "Y", "Z"]])
|
|
23
|
+
return ViewingGeometry(d_obs, tail_angle_deg=tail_angle_deg, north_vec=north_vec)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MinorAxisFit:
|
|
27
|
+
def __init__(self, rs, ibins, means, stds, head = None):
|
|
28
|
+
self.rs = rs
|
|
29
|
+
self.ibins = ibins
|
|
30
|
+
self.means = means
|
|
31
|
+
self.stds = stds
|
|
32
|
+
|
|
33
|
+
self.head = head
|
|
34
|
+
|
|
35
|
+
def to_cartesian(self, x0=0, y0=0, mirror=True):
|
|
36
|
+
|
|
37
|
+
if mirror:
|
|
38
|
+
rs_full = np.concatenate([self.rs, self.rs])
|
|
39
|
+
means_full = np.concatenate([self.means, self.means + np.pi])
|
|
40
|
+
else:
|
|
41
|
+
rs_full = np.copy(self.rs)
|
|
42
|
+
means_full = np.copy(self.means)
|
|
43
|
+
|
|
44
|
+
xs = rs_full * np.cos(means_full) + x0
|
|
45
|
+
ys = rs_full * np.sin(means_full) + y0
|
|
46
|
+
|
|
47
|
+
return xs, ys
|
|
48
|
+
|
|
49
|
+
def bounds_cartesian(self, x0=0, y0=0, mirror=True):
|
|
50
|
+
|
|
51
|
+
lower_bounds = self.means - self.stds
|
|
52
|
+
upper_bounds = self.means + self.stds
|
|
53
|
+
|
|
54
|
+
if mirror:
|
|
55
|
+
rs_full = np.concatenate([self.rs, self.rs])
|
|
56
|
+
lower_bounds_full = np.concatenate([lower_bounds, lower_bounds + np.pi])
|
|
57
|
+
upper_bounds_full = np.concatenate([upper_bounds, upper_bounds + np.pi])
|
|
58
|
+
else:
|
|
59
|
+
rs_full = np.copy(self.rs)
|
|
60
|
+
lower_bounds_full = np.copy(lower_bounds)
|
|
61
|
+
upper_bounds_full = np.copy(upper_bounds)
|
|
62
|
+
|
|
63
|
+
lower_xs = rs_full * np.cos(lower_bounds_full) + x0
|
|
64
|
+
lower_ys = rs_full * np.sin(lower_bounds_full) + y0
|
|
65
|
+
|
|
66
|
+
upper_xs = rs_full * np.cos(upper_bounds_full) + x0
|
|
67
|
+
upper_ys = rs_full * np.sin(upper_bounds_full) + y0
|
|
68
|
+
|
|
69
|
+
return (lower_xs, lower_ys), (upper_xs, upper_ys)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def fit_minor_axis(mom0, mom1, vmax=10, imin=1e2, Rmin=1, Rmax=20, bin_width=2, extent=20,
|
|
73
|
+
tail_angle_deg = 90, which="both", cent=None, head=None):
|
|
74
|
+
|
|
75
|
+
if cent is None:
|
|
76
|
+
cent = np.array(mom0.shape) / 2
|
|
77
|
+
|
|
78
|
+
ys, xs = np.indices(mom0.shape)
|
|
79
|
+
ys = ys - cent[1]
|
|
80
|
+
xs = xs - cent[0]
|
|
81
|
+
|
|
82
|
+
tail_angle = np.radians(tail_angle_deg)
|
|
83
|
+
|
|
84
|
+
d_along = distance_along_tail(mom0.shape, cent, tail_angle)
|
|
85
|
+
|
|
86
|
+
ys, xs = np.mgrid[0:mom1.shape[0], 0:mom1.shape[1]]
|
|
87
|
+
ys = remap(ys, 0, mom1.shape[0], -extent, extent)
|
|
88
|
+
xs = remap(xs, 0, mom1.shape[1], -extent, extent)
|
|
89
|
+
|
|
90
|
+
rs = np.sqrt(xs ** 2 + ys ** 2)
|
|
91
|
+
thetas = np.arctan2(ys, xs) % (2 * np.pi)
|
|
92
|
+
|
|
93
|
+
mom1_mask = np.logical_and(np.abs(mom1) < vmax, np.abs(mom0) > imin)
|
|
94
|
+
mom1_mask = np.logical_and(mom1_mask, rs > Rmin)
|
|
95
|
+
|
|
96
|
+
if which == "lead" or which == "leading":
|
|
97
|
+
mom1_mask = np.logical_and(mom1_mask, leading_side(d_along))
|
|
98
|
+
if which == "trail" or which == "trailing":
|
|
99
|
+
mom1_mask = np.logical_and(mom1_mask, trailing_side(d_along))
|
|
100
|
+
|
|
101
|
+
zs = mom0[mom1_mask]
|
|
102
|
+
rs_masked = rs[mom1_mask]
|
|
103
|
+
thetas_masked = thetas[mom1_mask]
|
|
104
|
+
|
|
105
|
+
r_bins = np.arange(Rmin, Rmax + bin_width, bin_width)
|
|
106
|
+
|
|
107
|
+
res = []
|
|
108
|
+
|
|
109
|
+
for i in range(len(r_bins) - 1):
|
|
110
|
+
rmin, rmax = r_bins[i], r_bins[i + 1]
|
|
111
|
+
rmean = (rmin + rmax) / 2
|
|
112
|
+
|
|
113
|
+
bin_mask = (rs_masked > rmin) & (rs_masked <= rmax)
|
|
114
|
+
|
|
115
|
+
thetas_submask = thetas_masked[bin_mask]
|
|
116
|
+
z_submask = zs[bin_mask]
|
|
117
|
+
|
|
118
|
+
ibin = np.nansum(z_submask) / np.nansum(bin_mask)
|
|
119
|
+
if np.isnan(ibin):
|
|
120
|
+
ibin = 0.0
|
|
121
|
+
|
|
122
|
+
mean_theta = np.nanmedian(thetas_submask)
|
|
123
|
+
std_theta = np.nanstd(thetas_submask)
|
|
124
|
+
|
|
125
|
+
res.append((rmean, ibin, mean_theta, std_theta))
|
|
126
|
+
|
|
127
|
+
rmeans, ibins, tmeans, tstds = np.array(res).T
|
|
128
|
+
|
|
129
|
+
return MinorAxisFit(rs=rmeans, ibins=ibins, means=tmeans, stds=tstds, head=head)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def smooth_to_alma_beam(mom, extent, z=0.0231, beam=(1.1, 0.8, np.pi/4), plot=False):
|
|
133
|
+
|
|
134
|
+
# Convert to angular size
|
|
135
|
+
kpc_per_arcsec = 1/ cosmo.arcsec_per_kpc_proper(z).value
|
|
136
|
+
|
|
137
|
+
bmaj, bmin, bpa = beam
|
|
138
|
+
|
|
139
|
+
pix_per_kpc = mom.shape[0] / (extent[1] - extent[0])
|
|
140
|
+
|
|
141
|
+
x_stddev = bmaj / (2 * np.sqrt(2 * np.log(2))) * kpc_per_arcsec * pix_per_kpc
|
|
142
|
+
y_stddev = bmin / (2 * np.sqrt(2 * np.log(2))) * kpc_per_arcsec * pix_per_kpc
|
|
143
|
+
|
|
144
|
+
kernel = Gaussian2DKernel(x_stddev=x_stddev, y_stddev=y_stddev, theta=bpa)
|
|
145
|
+
|
|
146
|
+
convolved = convolve(mom, kernel)
|
|
147
|
+
|
|
148
|
+
return convolved
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def xy_map(shape, cent):
|
|
152
|
+
ys, xs = np.indices(shape)
|
|
153
|
+
ys = ys - cent[1]
|
|
154
|
+
xs = xs - cent[0]
|
|
155
|
+
|
|
156
|
+
return xs, ys
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def tail_angle_vector(angle_rad):
|
|
160
|
+
return np.array([np.cos(angle_rad), np.sin(angle_rad)])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def distance_along_tail(shape, cent, tail_angle_rad):
|
|
165
|
+
xs, ys = xy_map(shape, cent)
|
|
166
|
+
|
|
167
|
+
tail_vector = tail_angle_vector(tail_angle_rad)
|
|
168
|
+
|
|
169
|
+
return xs * tail_vector[0] + ys * tail_vector[1]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def leading_side(distances_along_tail, offset=0.0):
|
|
173
|
+
return distances_along_tail < offset
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def trailing_side(distances_along_tail, offset=0.0):
|
|
177
|
+
return distances_along_tail > offset
|