Skip to content

Commit 8c84d72

Browse files
authored
Merge pull request #251 from lukas-neumann-astro/master
Add shuffled cubes and flat maps scripts
2 parents 90bea4d + 2a76479 commit 8c84d72

File tree

4 files changed

+2072
-0
lines changed

4 files changed

+2072
-0
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
2+
Scripts in this folder produce shuffled cubes and flat maps of the PHANGS-ALMA CO cubes,
3+
using an external velocity field and a velocity-integration mask that combines a fixed
4+
velocity window with a CO-based line emission mask.
5+
6+
### WORKFLOW FOR MOST USERS
7+
8+
If you want to produce shuffled cubes and flat maps of PHANSG-ALMA CO line cubes, you either produce a velocity field using `prepare_velocity_field.py` or input your on velocity field and directly run `shuffled_cubes_pipeline.py`.
9+
10+
0. If no velocity field exists beforehand: Execute `prepare_velocity_field.py` from the terminal with python3 to produce a velocity field by providing several additional velocity fields, e.g. CO, Halpha, HI moment-1 maps, or modelled velocity fields. The script can combine various velocity maps into a master velocity map.
11+
1. Execute `shuffled_cubes_pipeline.py` from the terminal with python3 to produce shuffled cubes and flat maps.
12+
13+
### PIPELINE PRODUCTS
14+
15+
The pipeline produces shuffled cubes, noise cubes, a set of flat maps with corresponding uncertainties and velocity-integration masks:
16+
17+
Products:
18+
19+
* cubes:
20+
1. shuffled cube
21+
2. shuffled noise cube
22+
3. shuffled masks (two versions: narrow_strict, wide_broad)
23+
4. re-shuffled masks (same as shuffled masks but shuffled back to the original velocity field)
24+
* maps:
25+
1. narrow (+-50 km/s) fixed window flat maps
26+
2. wide (+-100 km/s) fixed window flat maps
27+
3. narrow (+-20-200 km/s; adapted to each galaxy) fixed window combined with a strict CO line emission mask
28+
4. narrow (+-20-200 km/s; adapted to each galaxy) fixed window combined with a broad CO line emission mask
29+
5. wide (+-100 km/s) fixed window combined with a strict CO line emission mask
30+
6. wide (+-100 km/s) fixed window combined with a broad CO line emission mask
31+
32+
### PYTHON PACKAGES
33+
34+
* Python 3.9 or later
35+
* [numpy](https://numpy.org)
36+
* [astropy](https://www.astropy.org)
37+
* [matplotlib](https://matplotlib.org)
38+
* [pandas](https://pandas.pydata.org)
39+
* [reproject](https://reproject.readthedocs.io)
40+
41+
* Run with:
42+
* python 3.9
43+
* numpy 1.26.4
44+
* astropy 5.2.2
45+
* matplotlib 3.5.3
46+
* pandas 2.2.3
47+
* reproject 0.13.0
48+
49+
### SCRIPTS
50+
51+
- `prepare_velocity_field.py` is used to create a master velocity field from various tracers (e.g. CO, Halpha, HI, model) that are combined in a hirarchhical order
52+
- `shuffled_cubes_pipeline.py` produces the shuffled cubes and flat maps
53+
- `ancillary_functions.py` contains ancillary functions used in the main pipeline script
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
######################################################################################################
2+
# PHANGS-ALMA (DR5) shuffled cubes pipeline, ancillary scripts
3+
# Date: Jan 23, 2025
4+
# Authors: Lukas Neumann, lukas.neumann@eso.org
5+
######################################################################################################
6+
7+
import numpy as np
8+
from astropy.io import fits # to load fits files
9+
from astropy.wcs import WCS # for coordinates
10+
from matplotlib.path import Path # create paths
11+
12+
13+
def shuffle_cube(cube, vaxis, vfield):
14+
15+
"""
16+
Takes a cube, its velocity axis and a velocity field and shuffles the cube in
17+
integer channels according to the velocity field.
18+
"""
19+
20+
# copy cube
21+
cube_shuffled = np.copy(cube)
22+
23+
# define reference channel = the zero-velocity channel
24+
n_ch = len(vaxis) # number of channels
25+
ch_med = n_ch//2 # reference channel
26+
v_med = vaxis[ch_med] # reference velocity
27+
ch_axis = np.arange(n_ch) - ch_med
28+
29+
# create cube with velocity values
30+
vaxis_cube = np.zeros_like(cube, dtype=float)
31+
for i in range(len(vaxis)):
32+
vaxis_cube[i,:,:] = vaxis[i]
33+
34+
# resample velocity field to velocity axis
35+
vfield_ch = np.argmin(np.abs(vfield - vaxis_cube), axis=0) # channel indeces of velocity field
36+
vfield_ch_delta = vfield_ch - ch_med # shift by reference channel
37+
38+
# loop over velocity shuffling array
39+
for ch_roll in ch_axis:
40+
41+
# get spaxels where map matches shuffling value
42+
idx_x = np.where(vfield_ch_delta == ch_roll)[0]
43+
idx_y = np.where(vfield_ch_delta == ch_roll)[1]
44+
45+
# shuffle cube
46+
cube_shuffled[:,idx_x, idx_y] = np.roll(cube[:,idx_x, idx_y], -ch_roll, axis=0)
47+
48+
return cube_shuffled
49+
50+
51+
def get_major_axis_bins(ctr_ra, ctr_dec, posang, header, bin_width_major, bin_width_minor, rgal_axis_length):
52+
53+
"""
54+
Takes a galaxy's fits file and its coordinates and return a PV diagram array.
55+
"""
56+
57+
# make 2D header
58+
del header['*3*']
59+
header['NAXIS'] = 2
60+
header['WCSAXES'] = 2
61+
62+
# get coordinatesspectrum
63+
wcs = WCS(header)
64+
65+
# get map dimensions
66+
n_ra = header['NAXIS1']
67+
n_dec = header['NAXIS2']
68+
69+
# convert centre to pixel coordinates
70+
ctr_ra_pix, ctr_dec_pix = wcs.wcs_world2pix([[ctr_ra, ctr_dec]], 1)[0]
71+
72+
# convert axis length from degree to pixel coordinates
73+
_, axis_max_pix = wcs.wcs_world2pix([[ctr_ra, ctr_dec]], 1)[0]
74+
_, axis_ctr_pix = wcs.wcs_world2pix([[ctr_ra, ctr_dec + rgal_axis_length]], 1)[0]
75+
rgal_axis_length_pix = np.abs(axis_max_pix - axis_ctr_pix)
76+
77+
# make axis oriented with north-south axis
78+
dec_min = wcs.wcs_pix2world([[ctr_ra_pix, ctr_dec_pix - rgal_axis_length_pix]], 1)[0][1]
79+
dec_max = wcs.wcs_pix2world([[ctr_ra_pix, ctr_dec_pix + rgal_axis_length_pix]], 1)[0][1]
80+
major_dec = np.concatenate((np.arange(ctr_dec-bin_width_major/2, dec_min, -bin_width_major), np.arange(ctr_dec+bin_width_major/2, dec_max, bin_width_major)))
81+
major_dec.sort()
82+
major_ra = np.full_like(major_dec, ctr_ra)
83+
axis = np.column_stack((major_ra, major_dec))
84+
85+
# get major axis position (relative to centre)
86+
bin_position_list = major_dec - ctr_dec
87+
88+
# conver to relative coordinates
89+
axis[:,0] -= ctr_ra
90+
axis[:,1] -= ctr_dec
91+
92+
# rotate according to position angle
93+
theta = - np.deg2rad(posang)
94+
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)],
95+
[np.sin(theta), np.cos(theta)]])
96+
major_axis = np.full_like(axis, np.nan)
97+
for i in range(axis.shape[0]):
98+
major_axis[i,:] = np.dot(rotation_matrix, axis[i,:])
99+
100+
# convert back to absolute coordinates
101+
major_axis[:,0] += ctr_ra
102+
major_axis[:,1] += ctr_dec
103+
104+
# convert to world coordinates
105+
major_axis_pix = wcs.wcs_world2pix(major_axis, 1)
106+
107+
########################################
108+
# define size of bins
109+
size_x = bin_width_minor
110+
size_y = bin_width_major
111+
112+
# make list for bins
113+
bin_pixels_list = []
114+
115+
# create bins and select pixel inside bin
116+
for x, y in zip(major_axis[:,0], major_axis[:,1]):
117+
118+
# create vertices of the bin
119+
verts = np.array([
120+
[x - size_x/2, y - size_y/2], # bottom left
121+
[x - size_x/2, y + size_y/2], # top left
122+
[x + size_x/2, y + size_y/2], # top right
123+
[x + size_x/2, y - size_y/2], # bottom right
124+
[x - size_x/2, y - size_y/2], # bottom left
125+
])
126+
127+
# conver to relative coordinates
128+
verts[:,0] -= x
129+
verts[:,1] -= y
130+
131+
# rotate according to position angle
132+
verts_rot = np.full_like(verts, np.nan)
133+
for i in range(verts.shape[0]):
134+
verts_rot[i,:] = np.dot(rotation_matrix, verts[i,:])
135+
136+
# convert back to absolute coordinates
137+
verts_rot[:,0] += x
138+
verts_rot[:,1] += y
139+
140+
# convert to pixel coordinates
141+
verts_world = wcs.wcs_world2pix(verts_rot, 1)
142+
143+
# define code to link vertices
144+
codes = [
145+
Path.MOVETO,
146+
Path.LINETO,
147+
Path.LINETO,
148+
Path.LINETO,
149+
Path.CLOSEPOLY,
150+
]
151+
152+
# build hexagon path
153+
path = Path(verts_world, codes)
154+
155+
# get pixels inside bin
156+
ra_v, dec_v = np.meshgrid(np.arange(n_ra), np.arange(n_dec)) # create meshgrid for pixel indeces in x and y
157+
pixels = np.column_stack((ra_v.flatten(), dec_v.flatten())) # create list of pixels (x,y)
158+
bin_mask = path.contains_points(pixels) # create mask to select pixels inside bin
159+
bin_pixels = pixels[bin_mask] # select pixels of map inside bin
160+
161+
# append to mask
162+
bin_pixels_list.append(bin_pixels)
163+
164+
return bin_position_list, bin_pixels_list
165+
166+
167+
168+
def get_bin_spectra(cube, major_axis_pixels):
169+
170+
"""
171+
Takes a cube and its major axis and returns binned spectra along the major axis
172+
of the galaxy.
173+
"""
174+
175+
# make list for average bin spectra
176+
bin_spectra = []
177+
178+
# loop over bins
179+
for bin_pixels in major_axis_pixels:
180+
181+
try:
182+
# select spectra inside bin
183+
spectra = cube[:, bin_pixels[:,0], bin_pixels[:,1]]
184+
185+
# compute average spectrum inside bin
186+
spectrum = np.nanmean(spectra, axis=1)
187+
188+
except:
189+
spectrum = np.full_like(cube[:,0,0], np.nan)
190+
191+
# append to list
192+
bin_spectra.append(spectrum)
193+
194+
return bin_spectra
195+
196+
197+
198+
def get_pv_data(fits_cube, ctr_ra, ctr_dec, posang, bin_width_major=None, bin_width_minor=None, bin_width_velocity=None, rgal_axis_length=None, fits_cube_mask=None):
199+
200+
"""
201+
Takes a galaxy's fits cube and its coordinates and return a PV diagram array.
202+
"""
203+
204+
# get data and header
205+
cube, header = fits.getdata(fits_cube, header=True)
206+
207+
if fits_cube_mask != None:
208+
cube_mask = fits.getdata(fits_cube_mask)
209+
cube[cube_mask == 0] = np.nan
210+
211+
# build velocity axis
212+
n_ch = header['NAXIS3'] # number of channels
213+
v_ch = header['CDELT3'] # channel width (m/s, with sign)
214+
v_ref = header['CRVAL3'] # referecne channel (m/s)
215+
ch_ref = header['CRPIX3'] # reference channel
216+
v_ch0 = v_ref - (ch_ref-1)*v_ch # velocity of first channel
217+
vaxis_kms = np.linspace(v_ch0, v_ch0+(n_ch-1)*v_ch, n_ch) * 1e-3 # in km/s
218+
# vaxis_kms = np.arange(v_ch0, v_ch0+n_ch*v_ch, v_ch) * 1e-3 # in km/s
219+
220+
# position bin width
221+
if bin_width_major == None:
222+
bin_width_major = header['BMAJ']
223+
if bin_width_minor == None:
224+
bin_width_minor = min(header['CDELT1']*header['NAXIS1'], header['CDELT2']*header['NAXIS2'])/2
225+
226+
# major axis length
227+
if rgal_axis_length == None:
228+
229+
# make 2D header
230+
del header['*3*']
231+
header['NAXIS'] = 2
232+
header['WCSAXES'] = 2
233+
234+
# get coordinates
235+
wcs = WCS(header)
236+
237+
# get map dimensions
238+
n_ra = header['NAXIS1']
239+
n_dec = header['NAXIS2']
240+
n_max = max(n_ra, n_dec) # extend of the map in any direction
241+
242+
# convert centre to pixel coordinatesx
243+
ctr_ra_pix, ctr_dec_pix = wcs.wcs_world2pix([[ctr_ra, ctr_dec]], 1)[0]
244+
245+
# compute radial extend of major axis
246+
dec_max = wcs.wcs_pix2world([[ctr_ra_pix, n_max]], 1)[0][1]
247+
rgal_axis_length = np.abs(dec_max - ctr_dec) # degrees
248+
249+
250+
251+
# get major axis bins
252+
position, major_axis_pixels = get_major_axis_bins(ctr_ra, ctr_dec, posang, header, bin_width_major, bin_width_minor, rgal_axis_length)
253+
254+
# get major axis spectra
255+
spectra = get_bin_spectra(cube, major_axis_pixels)
256+
257+
if bin_width_velocity == None:
258+
# velocity resolution is native, so skip velocity binning
259+
pv_data = np.transpose(np.array(spectra))
260+
velocity = vaxis_kms
261+
262+
else:
263+
# compute velocity bins
264+
vaxis_sign = 1 if vaxis_kms[0] < vaxis_kms[-1] else -1
265+
vaxis_bin_edges = np.arange(vaxis_kms[0]-0.5*bin_width_velocity, vaxis_kms[-1]+vaxis_sign*2*bin_width_velocity, vaxis_sign*bin_width_velocity)
266+
267+
# compute velocity bin centres
268+
velocity = vaxis_bin_edges[:-1] + (vaxis_bin_edges[2]-vaxis_bin_edges[1])/2
269+
270+
# get bin indeces
271+
bin_indeces = np.digitize(vaxis_kms, bins=vaxis_bin_edges, right=True)
272+
273+
# get dimensions of pv plot
274+
n_pos = len(position)
275+
n_vel = len(velocity)
276+
277+
# creata pv-data array
278+
pv_data = np.ones([n_vel, n_pos]) * np.nan
279+
280+
# loop over spectra
281+
id_spec = 0
282+
for spec in spectra:
283+
284+
# make lists to store binned data
285+
vaxis_bin_list = [] # velocity bin mean
286+
spec_bin_list = [] # intensity bin mean
287+
288+
# loop over bin indeces
289+
for bin_idx in range(n_vel):
290+
291+
# select data in bin
292+
bin_mask = bin_indeces == bin_idx
293+
294+
# compute bin mean
295+
vaxis_bin = np.nanmean(vaxis_kms[bin_mask])
296+
297+
# check if spectrum is empty
298+
if sum(np.isnan(spec)) == len(spec):
299+
spec_bin = np.full_like(vaxis_bin, np.nan)
300+
else:
301+
spec_bin = np.nanmean(spec[bin_mask])
302+
303+
# append to list
304+
vaxis_bin_list.append(vaxis_bin)
305+
spec_bin_list.append(spec_bin)
306+
307+
# cast list to numpy array
308+
vaxis_bin = np.array(vaxis_bin_list)
309+
spec_bin = np.array(spec_bin_list)
310+
311+
# add spectrum to pv array
312+
pv_data[:, id_spec] = spec_bin
313+
id_spec += 1
314+
315+
# find position indices that contain only nans
316+
id_del = [] # position indeces that contain only nan values
317+
id_spec = 0
318+
for spec in pv_data.T:
319+
if sum(np.isnan(spec)) == len(spec):
320+
id_del.append(id_spec)
321+
id_spec += 1
322+
# trim data
323+
pv_data = np.delete(pv_data, id_del, axis=1)
324+
position = np.delete(position, id_del)
325+
326+
return pv_data, position, velocity

0 commit comments

Comments
 (0)