cubespa 0.0.9__tar.gz

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-0.0.9/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.1
2
+ Name: cubespa
3
+ Version: 0.0.9
4
+ Summary: Cube SPectral Analysis
5
+ Home-page: https://github.com/HSouch/cubespa
6
+ Author: Harrison Souchereau
7
+ Author-email: harrison.souchereau@yale.edu
8
+ License: BSD-3-Clause
9
+ Keywords: galaxies datacubes spectra
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: Build Tools
13
+ Classifier: Programming Language :: Python :: 3.6
14
+ Classifier: Programming Language :: Python :: 3.7
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # CUBE SPectral Analysis
19
+
20
+ Fully-featured, aesthetically pleasing 2D and 3D analysis of astronomical datacubes.
@@ -0,0 +1,3 @@
1
+ # CUBE SPectral Analysis
2
+
3
+ Fully-featured, aesthetically pleasing 2D and 3D analysis of astronomical datacubes.
@@ -0,0 +1,15 @@
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 .moment_maps import * # Module for generating moment maps using masking
8
+ from .overlays import * # Module for reprojection and creating overlays
9
+ from .pvds import * # Module for position velocity diagrams (PVDs)
10
+ from .spectra import * # Module for generating and analyzing spectra
11
+ from .utils import * # All miscellaneous utility modules
12
+
13
+
14
+ __version__ = "0.0.9"
15
+
@@ -0,0 +1,2 @@
1
+
2
+ from .flux_analysis import *
@@ -0,0 +1,145 @@
1
+ import numpy as np
2
+ from .. import utils
3
+
4
+ from matplotlib import pyplot as plt
5
+
6
+ from photutils.aperture import EllipticalAnnulus
7
+
8
+
9
+ def quadrants(q1_angle, offset = 90, clockwise=False):
10
+ mult = -1 if clockwise else 1
11
+ return np.array([(q1_angle + i * 90 * mult + offset) for i in range(4)])
12
+
13
+
14
+ def halfline_points(cent, length, angle):
15
+
16
+ p1 = (cent[0] , cent[0] + length * np.cos(angle))
17
+ p2 = (cent[1], cent[1] + length * np.sin(angle))
18
+
19
+ return (p1, p2)
20
+
21
+
22
+ def line_points(cent, length, angle):
23
+ p1 = (cent[0] - length * np.cos(angle), cent[0] + length * np.cos(angle))
24
+ p2 = (cent[1] - length * np.sin(angle), cent[1] + length * np.sin(angle))
25
+
26
+
27
+ return (p1, p2)
28
+
29
+
30
+ def theta_list(theta_0, clockwise=False, n_samples=90, plot=False, **kwargs):
31
+ outname = utils.check_kwarg("outname", None, kwargs)
32
+
33
+ theta_max = theta_0 + 2*np.pi
34
+
35
+ if clockwise:
36
+ _ = theta_0
37
+ theta_0, theta_max = theta_max, _
38
+
39
+ print(theta_0, theta_max)
40
+
41
+ angles = np.linspace(theta_0, theta_max, n_samples) % (2 * np.pi)
42
+
43
+ if plot:
44
+ thetas_norm = np.linspace(0, 2*np.pi, n_samples)
45
+ xs, ys = np.cos(angles), np.sin(angles)
46
+ plt.scatter(xs, ys, c=np.rad2deg(thetas_norm))
47
+ plt.colorbar()
48
+ plt.tight_layout()
49
+ if outname is not None:
50
+ plt.savefig(outname, dpi=100)
51
+ plt.close()
52
+ else:
53
+ plt.show()
54
+
55
+ return angles
56
+
57
+
58
+ def theta_range(shape, x0, y0, theta, dtheta):
59
+ ys, xs = np.mgrid[:shape[0], :shape[1]].astype(float)
60
+ xs -= x0
61
+ ys -= y0
62
+
63
+ thetas = (np.arctan2(ys, xs) ) % (2 * np.pi)
64
+
65
+ theta_mask = np.ones(thetas.shape)
66
+
67
+ theta_min = (theta - dtheta / 2) % (2 * np.pi)
68
+ theta_max = (theta + dtheta / 2) % (2 * np.pi)
69
+
70
+ # Adjust for cases where theta_max < theta_min due to wrapping around 2pi
71
+ if theta_max < theta_min:
72
+ # In this case, we need to handle the wrap around.
73
+ theta_mask = np.logical_or(thetas >= theta_min, thetas <= theta_max)
74
+ else:
75
+ # Regular case where the range does not wrap around
76
+ theta_mask = np.logical_and(thetas >= theta_min, thetas <= theta_max)
77
+
78
+ return theta_mask.astype(int)
79
+
80
+
81
+ def process_azimuth(data, radius, pa, eps, thetas, stellar_x=0, stellar_y=0, annulus_width=50, trim_nans=False):
82
+
83
+
84
+ ell_annulus = EllipticalAnnulus((stellar_x, stellar_y), radius, radius + annulus_width,
85
+ (radius + annulus_width) * (1-eps), theta = pa)
86
+ mask = ell_annulus.to_mask(method='center').to_image(shape=data.shape)
87
+
88
+ data_init_mask = np.copy(data)
89
+ data_init_mask[mask == 0] = np.nan
90
+
91
+ fluxes, flux_stds = [], []
92
+
93
+ for i, theta in enumerate(thetas[:]):
94
+
95
+ theta_mask = theta_range(data.shape, stellar_x, stellar_y, theta, 5 * np.pi / 180)
96
+
97
+ this_data = np.copy(data_init_mask)
98
+ this_data[theta_mask == 0] = np.nan
99
+ try:
100
+ med, std = np.nanmedian(this_data), np.nanstd(this_data)
101
+ except RuntimeWarning:
102
+ med, std = np.nan, np.nan
103
+
104
+ fluxes.append(med)
105
+ flux_stds.append(std)
106
+
107
+ if trim_nans:
108
+ fluxes = np.array(fluxes)
109
+ fluxes[np.isnan(fluxes)] = 0
110
+
111
+ flux_stds = np.array(flux_stds)
112
+ flux_stds[np.isnan(flux_stds)] = 0
113
+
114
+ return (np.array(fluxes), np.array(flux_stds), ell_annulus)
115
+
116
+
117
+ def segment_sum(cubespa_obj, data, radius, annulus_width, theta_0, plot=False, clockwise=False):
118
+ stellar_x, stellar_y = cubespa_obj.center
119
+
120
+ eps, pa = cubespa_obj.eps, cubespa_obj.position_angle
121
+
122
+ # print(stellar_x, stellar_y, eps, pa)
123
+ ell_annulus = EllipticalAnnulus((stellar_x, stellar_y),
124
+ radius, radius + annulus_width,
125
+ (radius + annulus_width) * (1-eps),
126
+ theta = pa)
127
+ mask = ell_annulus.to_mask(method='center').to_image(shape=data.shape)
128
+
129
+ data_init_mask = np.copy(data)
130
+ data_init_mask[mask == 0] = np.nan
131
+
132
+ theta_mask = theta_range(data.shape, stellar_x, stellar_y, np.deg2rad(theta_0) + np.pi/4, np.pi / 2)
133
+
134
+ data_init_mask[theta_mask == 0] = np.nan
135
+ pixel_sum = np.nansum(data_init_mask)
136
+
137
+ if plot:
138
+ plt.imshow(data, origin="lower", cmap="Greys")
139
+ plt.imshow(data_init_mask, cmap="viridis", origin="lower", alpha=0.7)
140
+ plt.imshow(theta_mask, alpha=0.2, cmap="Greys", origin="lower")
141
+ ell_annulus.plot(color="black")
142
+ plt.title(f'{radius:.1f} {theta_0}\n{pixel_sum:.2f}', fontsize=10)
143
+ plt.show()
144
+
145
+ return pixel_sum
@@ -0,0 +1,184 @@
1
+ from . import data, utils, spectra
2
+
3
+ from . import plotting
4
+
5
+ import os, logging
6
+
7
+ import numpy as np
8
+
9
+
10
+ class CubeSPA:
11
+ """ Base input class for a CubeSPA object
12
+ """
13
+
14
+ def __init__(self, cube, data_index=0,
15
+ psf = None,
16
+ mom_maps=None, additional_maps = [],
17
+ center = None, position_angle = 0, eps=0,
18
+ vsys = 0,
19
+ limits = None,
20
+ plot_dir = None,
21
+ **kwargs) -> None:
22
+ """ CubeSPA object
23
+
24
+ Args:
25
+ cube (ndarray or str): Input cube or filename that points to cube.
26
+ data_index (int, optional): If loading cube, index of cube data. Defaults to 0.
27
+ mom_maps (str, optional): maskmoment-formatted moment map filename prefix. Defaults to None.
28
+ additional_maps (list, optional): Additional data.DataSet objects. Defaults to [].
29
+ center (tuple, optional): Central region, typically defined by stellar light isophotal
30
+ center. Defaults to None.
31
+ position_angle (float, optional): Position angle of disk. Defaults to None.
32
+ eps (float, optional): Ellipticity (1 - b/a) of disk. Defaults to None.
33
+ limits (array or str, optional): Bounding box containing relevant data. Defaults to None.
34
+ If "auto", will try to automatically generate from moment maps.
35
+ plot_dir (str, optional): Directory to place plots. Defaults to None.
36
+ """
37
+
38
+ self.cube = data.handle_data(cube, handler=data.load_data, data_index=data_index)
39
+
40
+ if self.cube is not None:
41
+ self.cube_noise_level, self.cube_rms = utils.estimate_rms(self.cube.data,
42
+ utils.check_kwarg("cmin", 5, kwargs),
43
+ utils.check_kwarg("cmax", 5, kwargs))
44
+
45
+ self.psf = data.handle_data(psf, handler=data.load_data, data_index=data_index) if psf is not None else psf
46
+
47
+ # TODO implement better handling of cx and cy. Right now assuming a 3D cube with Stokes. Can be better!
48
+ self.center = center
49
+ if self.center is not None:
50
+ center_x, center_y, center_type = center
51
+ if center_type == "radec":
52
+ cx, cy = self.cube.wcs.wcs_world2pix(center_x, center_y, 0, 0, 0)[:2]
53
+ self.center = (float(cx), float(cy))
54
+ else:
55
+ self.center = (center_x, center_y)
56
+
57
+ # Position angle and ellipticty (1 - b/a) of the stellar disk, for flux analysis
58
+ self.position_angle = np.deg2rad(position_angle)
59
+ self.eps = eps
60
+
61
+ self.velocities = self.velocities_from_wcs(vsys=vsys)
62
+ self.vsys = vsys
63
+
64
+ # TODO Add this to a separate function with error handling
65
+ try:
66
+ self.beam = [self.cube.header["BMIN"], self.cube.header["BMAJ"], self.cube.header["BPA"]]
67
+ self.beam_pix = self.get_beam_pix()
68
+
69
+ self.beam_area = self.get_beam_area()
70
+ self.beam_area_arcsec = self.get_beam_area(in_pixels=False)
71
+ except KeyError:
72
+ logging.warn(f"Unable to get beam for {cube}")
73
+ self.beam = self.beam_pix = self.beam_area_arcsec = None
74
+
75
+ try:
76
+ self.mom_maps = data.handle_data(mom_maps, handler=data.load_moment_maps, data_index=data_index)
77
+ self.additional_maps = additional_maps
78
+ except Exception as e:
79
+ logging.warn(f'Failed to load moment maps for {cube}\n{e}')
80
+
81
+ if limits == "auto":
82
+ if "padding" in kwargs.keys():
83
+ padding = kwargs["padding"]
84
+ else:
85
+ padding = 10
86
+
87
+ self.limits = utils.bounds_from_moment_map(self.mom_maps.mom0.data, padding = padding)
88
+
89
+ if plot_dir is not None:
90
+ self.plot_dir = plot_dir
91
+ self.load_dir(plot_dir)
92
+
93
+
94
+ def load_dir(path):
95
+ if not os.path.isdir(path):
96
+ os.makedirs(path, exist_ok=True)
97
+ else:
98
+ pass
99
+
100
+
101
+ def velocities_from_wcs(self, vsys=0):
102
+ vmin = self.cube.wcs.wcs.crval[2]
103
+ vdelt = self.cube.wcs.wcs.cdelt[2]
104
+
105
+ return (np.array([vmin + i * vdelt for i in range(len(self.cube.data))]) / 1000) - vsys
106
+
107
+
108
+ def get_beam_area(self, in_pixels=True):
109
+ try:
110
+ header, w = self.cube.header, self.cube.wcs
111
+
112
+ delt = np.mean(np.abs(w.wcs.cdelt[:2]))
113
+
114
+ if in_pixels:
115
+ bmin, bmaj = header["BMIN"] / delt , header["BMAJ"] / delt
116
+ else:
117
+ bmin, bmaj = header["BMIN"] * 3600 , header["BMAJ"] * 3600
118
+
119
+ area = np.pi * bmaj * bmin / (4 * np.log(2))
120
+ except Exception as e:
121
+ print("Failed to get beam area:", e)
122
+ area = 1
123
+
124
+ return area
125
+
126
+ def get_beam_pix(self):
127
+ try:
128
+ header, w = self.cube.header, self.cube.wcs
129
+
130
+ delt = np.mean(np.abs(w.wcs.cdelt[:2]))
131
+ bmaj, bmin, bpa = self.beam
132
+ bmaj /= delt
133
+ bmin /= delt
134
+ return bmaj, bmin, bpa
135
+ except:
136
+ return 1, 1
137
+
138
+ def get_beam_coords(self, x0=0, y0=0):
139
+ try:
140
+ header, w = self.cube.header, self.cube.wcs
141
+ delt = np.mean(np.abs(w.wcs.cdelt[:2]))
142
+
143
+ bmin, bmaj, bpa = header["BMIN"] / delt , header["BMAJ"] / delt, np.deg2rad(header["BPA"] + 90)
144
+ return utils.ellipse_coords(x0, y0, bmaj / 2, bmin / 2, bpa)
145
+
146
+ except Exception as e:
147
+ print("Failed to get beam coordinates", e)
148
+
149
+ # UTILITY FUNCTIONS
150
+ def plot_moment_maps(self, use_limits=True, **kwargs):
151
+ outname = utils.check_kwarg("outname", None, kwargs)
152
+
153
+ plotting.moment_map_plot(self, use_limits=use_limits, outname=outname, kwargs=kwargs)
154
+
155
+
156
+ def create_spectra(self, position, size, return_products=False, plot=False):
157
+ aper = spectra.create_aperture(self, position, size)
158
+ spectrum = spectra.get_spectra(self.cube.data, aper)
159
+ if plot:
160
+ plotting.spectra_plot(self, aper, spectrum)
161
+ if return_products:
162
+ return aper, spectrum
163
+
164
+
165
+ class CubeComparison:
166
+
167
+ def __init__(self, cube1: CubeSPA, cube2: CubeSPA) -> None:
168
+ self.cube1 = cube1
169
+ self.cube2 = cube2
170
+
171
+
172
+ def create_spectra(self, position, size, return_products=False, plot=False):
173
+ aper = spectra.create_aperture(self, position, size)
174
+ spectrum = spectra.get_spectra(self.cube1.data, aper)
175
+
176
+ if plot:
177
+ plotting.spectra_plot(self, aper, spectrum)
178
+ if return_products:
179
+ return aper, spectrum
180
+
181
+
182
+ def readout(self):
183
+ print(f'Cube 1: {self.cube1.cube.data.shape} Cube 2: {self.cube2.cube.data.shape}')
184
+
@@ -0,0 +1,173 @@
1
+ import numpy as np
2
+ import logging
3
+ import copy
4
+
5
+ from . import utils
6
+
7
+ from astropy.wcs import WCS
8
+ from astropy.convolution import Tophat2DKernel
9
+ from astropy.io import fits
10
+
11
+ from scipy.signal import convolve2d
12
+
13
+ from . import plotting
14
+
15
+
16
+ class DataSet:
17
+ def __init__(self, data=None, wcs=None, header=None, label=None, dtype=None):
18
+ self.data = data
19
+ self.wcs = wcs
20
+ self.header = header
21
+ self.label = label
22
+
23
+ self.dtype = dtype
24
+
25
+
26
+ class RGBImage(DataSet):
27
+ def __init__(self, data=None, wcs=None, header=None, label=None):
28
+ super().__init__(data, wcs, header, label)
29
+
30
+
31
+
32
+ class MomentMaps:
33
+ def __init__(self, mom0=None, mom1=None, mom2=None, data_index=0):
34
+ if type(mom0) == str:
35
+ self.mom0 = load_data(mom0, data_index=data_index)
36
+ if type(mom1) == str:
37
+ self.mom1 = load_data(mom1, data_index=data_index)
38
+ if type(mom2) == str:
39
+ self.mom2 = load_data(mom2, data_index=data_index)
40
+
41
+
42
+ def load_data(filename, data_index=0, rgb_index = None, label=None):
43
+ try:
44
+ with fits.open(filename) as HDUList:
45
+ hdu = HDUList[data_index]
46
+
47
+ if rgb_index is None:
48
+ data = hdu.data
49
+ if len(data.shape) == 4: # Drop Stokes parameter (could remove this later maybe)
50
+ data = data[0]
51
+ wcs = WCS(hdu.header)
52
+ else:
53
+ data = hdu.data[rgb_index]
54
+ wcs = WCS(hdu.header).dropaxis(2)
55
+
56
+ out = DataSet(data=data, wcs=wcs, header=hdu.header, label=label)
57
+ return out
58
+ except Exception as e:
59
+ print(f"Error loading data: {e}")
60
+ return None
61
+
62
+
63
+ def load_moment_maps(topdir, data_index=0):
64
+ try:
65
+ mom_0_fn = topdir + ".mom0.fits.gz"
66
+ mom_1_fn = topdir + ".mom1.fits.gz"
67
+ mom_2_fn = topdir + ".mom2.fits.gz"
68
+
69
+ moment_maps = MomentMaps(mom0=mom_0_fn, mom1=mom_1_fn, mom2=mom_2_fn, data_index=data_index)
70
+ return moment_maps
71
+ except Exception as e:
72
+ print(f"Error loading Moment Maps: {e}")
73
+ return None
74
+
75
+
76
+
77
+ def handle_data(data, handler, data_index=0):
78
+ """ Handle incoming data for the CubeSPA object.
79
+ If not a string, CubeSPA will return the data enclosed in a dataset without additional info.
80
+ Args:
81
+ data (str or cubespa.DataSet): Incoming data. If str, CubeSPA will automatically load it in.
82
+ handler (method): Method to handle data, either set to load_data or load_moment_maps
83
+ data_index (int, optional): Index to find data in. Defaults to 0.
84
+ Returns:
85
+ utils.DataSet : The output DataSet object.
86
+ """
87
+ if type(data) == str:
88
+ return handler(data, data_index=data_index)
89
+ elif type(data) == np.ndarray:
90
+ logging.warn("Naked data loaded. No WCS or header included!")
91
+ return utils.DataSet(data=data)
92
+
93
+
94
+ def gen_cutout(data, limits):
95
+ """ Generates a cutout from an image based on a set of limits.
96
+
97
+ Args:
98
+ data (ndarray): Input image data array
99
+ limits (arr): Limits, assuming [xmin, xmax, ymin, ymax] format
100
+
101
+ Returns:
102
+ _type_: _description_
103
+ """
104
+
105
+ xmin, xmax, ymin, ymax = limits
106
+
107
+ return data[ymin:ymax, xmin:xmax]
108
+
109
+
110
+ def gen_cutout(cubespa_obj, cent, size, show_bbox=False):
111
+
112
+ # TODO: change WCS coordinates to match new cutout location
113
+
114
+ cutout = copy.deepcopy(cubespa_obj)
115
+
116
+ if type(size) is int:
117
+ size = (size, size)
118
+
119
+ xmin, xmax = cent[1] - size[0], cent[1] + size[0]
120
+ ymin, ymax = cent[0] - size[1], cent[0] + size[1]
121
+
122
+ if show_bbox:
123
+ plotting.plot_bbox(cubespa_obj, [ymin, ymax, xmin, xmax])
124
+
125
+ new_cube = cubespa_obj.cube.data[:, xmin:xmax, ymin:ymax]
126
+ if cubespa_obj.mom_maps is not None:
127
+ new_mom0 = cubespa_obj.mom_maps.mom0.data[xmin:xmax, ymin:ymax]
128
+ new_mom1 = cubespa_obj.mom_maps.mom1.data[xmin:xmax, ymin:ymax]
129
+ new_mom2 = cubespa_obj.mom_maps.mom2.data[xmin:xmax, ymin:ymax]
130
+
131
+ for i, additional in enumerate(cubespa_obj.additional_maps):
132
+ cutout.additional_maps[i].data = additional.data[xmin:xmax, ymin:ymax]
133
+
134
+
135
+ cutout.cube.data = new_cube
136
+
137
+ cutout.limits = [ymin, ymax, xmin, xmax]
138
+
139
+ cutout.mom_maps.mom0.data = new_mom0
140
+ cutout.mom_maps.mom1.data = new_mom1
141
+ cutout.mom_maps.mom2.data = new_mom2
142
+
143
+ return cutout
144
+
145
+
146
+ def gen_bg_mask(a, thresh_min=None, dilate=None, dilate_thresh = 0.7):
147
+ """Generate mask from a map, assuming emission is either nan or below some threshold value.
148
+ Mask can be dilated using the dilate parameter, and be further adjusted with dilate_thresh
149
+
150
+ Args:
151
+ a (ndarray): Input data array
152
+ thresh_min (float, optional): Minimum threshold to keep unmasked. Defaults to None.
153
+ dilate (int, optional): Side of tophat kernel to dilate mask. Defaults to None.
154
+ dilate_thresh (float, optional): Threshold (between 0 and 1) to include "edge areas" in the dilation mask.
155
+ A higher value corresponds to a stronger dilation. Defaults to 0.7.
156
+
157
+ Returns:
158
+ ndarray: Boolean mask with same shape as input array.
159
+ """
160
+
161
+ mask = np.zeros(a.shape)
162
+ mask[np.isnan(a)] = 1
163
+
164
+ if thresh_min is not None:
165
+ mask[a < thresh_min] = 1
166
+
167
+ if dilate is not None:
168
+ kernel = Tophat2DKernel(dilate)
169
+ mask = convolve2d(mask, kernel, mode="same")
170
+ mask[mask < dilate_thresh] = 0
171
+ mask[mask >= dilate_thresh] = 1
172
+
173
+ return mask.astype(bool)
@@ -0,0 +1,3 @@
1
+
2
+ from .velocity_map import *
3
+
@@ -0,0 +1,102 @@
1
+ import numpy as np
2
+ from .. import utils
3
+
4
+ from astropy.io import fits
5
+
6
+
7
+ def brandt_curve(rs, kwargs):
8
+ Rmax = utils.check_kwarg("rmax", 50, kwargs)
9
+ Vmax = utils.check_kwarg("vmax", 50, kwargs)
10
+ n = utils.check_kwarg("n", 1, kwargs)
11
+
12
+ R_norm = rs/Rmax
13
+
14
+ return Vmax * R_norm * (1/3 + (2/3)*(R_norm ** n)) ** (-3 / (2 * n))
15
+
16
+
17
+ def df_to_of(x, y, alpha, i):
18
+ """ Convert the disk frame to the observer's frame.
19
+
20
+ Args:
21
+ x (_type_): _description_
22
+ y (_type_): _description_
23
+ alpha (_type_): _description_
24
+ i (_type_): _description_
25
+
26
+ Returns:
27
+ _type_: _description_
28
+ """
29
+ X = x * np.cos(alpha) - y * np.sin(alpha) * np.cos(i)
30
+ Y = x * np.sin(alpha) + y * np.cos(alpha) * np.cos(i)
31
+ return X, Y
32
+
33
+ def of_to_df(X, Y, alpha, i):
34
+ """ Convert the observer's frame to the disk frame.
35
+
36
+ Args:
37
+ X (_type_): _description_
38
+ Y (_type_): _description_
39
+ alpha (_type_): _description_
40
+ i (_type_): _description_
41
+
42
+ Returns:
43
+ _type_: _description_
44
+ """
45
+ x = X * np.cos(alpha) + Y * np.sin(alpha)
46
+ y = -X * np.sin(alpha) * np.cos(i) + Y * np.cos(alpha) * np.cos(i)
47
+
48
+ return x, y
49
+
50
+
51
+ class VelocityModel:
52
+ def __init__(self, shape, x0=None, y0=None, pa=None, inc=None, vsys=0, rmax=50):
53
+
54
+ if type(shape) == int:
55
+ shape = (shape, shape)
56
+
57
+ self.shape = shape
58
+
59
+ self.x0 = x0 if x0 is not None else int(shape[1] / 2)
60
+ self.y0 = y0 if y0 is not None else int(shape[0] / 2)
61
+ self.pa = np.deg2rad(pa) if pa is not None else 0
62
+ self.inc = np.deg2rad(inc) if inc is not None else np.deg2rad(30)
63
+ self.vsys = vsys if vsys is not None else 0
64
+ self.rmax = rmax
65
+
66
+ self.vr = None
67
+
68
+
69
+
70
+ def gen_model(self, velocity_model, fill_value=np.nan, **vmod_kwargs):
71
+ xs, ys = np.arange(0, self.shape[0]).astype(float), np.arange(0, self.shape[1]).astype(float)
72
+ Y, X = np.meshgrid(ys, xs)
73
+
74
+ X -= self.x0
75
+ Y -= self.y0
76
+
77
+ x, y = of_to_df(X, Y, self.pa, self.inc)
78
+
79
+ r = np.sqrt(x ** 2 + y ** 2)
80
+
81
+ vcirc = velocity_model(r, vmod_kwargs)
82
+ theta = np.arctan2(x, y)
83
+
84
+ vr = self.vsys + vcirc * np.sin(self.inc) * np.cos(theta)
85
+ vr[r > self.rmax * 2] = fill_value
86
+ self.vr = vr
87
+ return self.vr
88
+
89
+ def write_model(self, outname, to_fits=False, overwrite=True):
90
+ """ Save the velocity model to a FITS file or Pickle file, depending on user preferences
91
+
92
+ Args:
93
+ filename (str): Output filename
94
+ to_fits (bool, optional): Whether to save to FITS format. Defaults to False.
95
+ overwrite (bool, optional): If saving to fits format, whether to overwrite output if already saved. Defaults to True.
96
+ """
97
+ if to_fits:
98
+ hdul = fits.HDUList()
99
+ hdul.append(fits.ImageHDU(data=self.vr))
100
+ hdul.writeto(outname, overwrite=overwrite)
101
+ else:
102
+ utils.save_pickle(self.vr, outname)