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 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,5 @@
1
+
2
+ # from .flux_analysis import *
3
+ from .radii_measurements import *
4
+
5
+ from .velocityanalysis import *
@@ -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