Primary beam patterns¶
The UVBeam wrapper inside WODEN is designed to read in RA,Dec coords from a sky catalogue, so let’s make a grid of az,za and convert them to RA,Dec coords at the HERA location. We’ll grab that from pyuvdata:
from pyuvdata.telescopes import known_telescope_location
##this is an astropy location object
location = known_telescope_location("HERA")
latitude = location.lat.value
longitude = location.lon.value
height = location.height.value
print(f"Latitude: {latitude} degrees")
print(f"Longitude: {longitude} degrees")
print(f"Height: {height} m")
Latitude: -30.72152612068926 degrees
Longitude: 21.42830382686301 degrees
Height: 1051.6900000007606 m
Make the az/za grid:
import numpy as np
import matplotlib.pyplot as plt
nside = 129
xrange = np.linspace(-1, 1, nside)
yrange = np.linspace(-1, 1, nside)
xcoords, ycoords = np.meshgrid(xrange, yrange)
za = np.sqrt(xcoords**2 + ycoords**2)*np.pi/2
az = np.pi/2 - np.arctan2(ycoords, xcoords)
az[az < 0] += 2*np.pi
below_horizon = za > np.pi/2
az[below_horizon] = np.nan
za[below_horizon] = np.nan
fig, axs = plt.subplots(1,2, figsize=(12, 5), layout='constrained')
im = axs[0].imshow(np.degrees(az), origin='lower')
plt.colorbar(im, ax=axs[0])
im = axs[1].imshow(np.degrees(za), origin='lower')
plt.colorbar(im, ax=axs[1])
axs[0].set_title('Azimuth (deg)')
axs[1].set_title('Zenith Angle (deg)')
plt.show()
Now convert to RA,Dec coords. For that we need an observing time. We’ll be using the HERA phase 1 array layout, so let’s pick a date in between Oct 2017 and April 2018, when phase 1 was being used (Abdurashidova et al.). They also used three field centres. Let’s pick F1 which is centred at RA=2h:
from astropy.time import Time
obstime = Time("2017-11-11T21:09:30", scale='utc', location=location)
lst_deg = obstime.sidereal_time('apparent').to_value('deg')
print(f"LST: {lst_deg} degrees")
LST: 30.001385332537474 degrees
An LST of 30 deg (two hours) is what we want so this is close enough. Righto, now we can convert the az,za grid to RA,Dec coords:
import erfa
has, decs = erfa.ae2hd(az, np.pi/2-za, np.radians(latitude))
ras = np.radians(lst_deg) - has
ras[ras < 0] += 2*np.pi # wrap RA to [0, 2pi]
fig, axs = plt.subplots(1,2, figsize=(12, 5), layout='constrained')
im = axs[0].imshow(np.degrees(ras), origin='lower')
plt.colorbar(im, ax=axs[0])
im = axs[1].imshow(np.degrees(decs), origin='lower')
plt.colorbar(im, ax=axs[1])
axs[0].set_title('RA (deg)')
axs[1].set_title('Dec (deg)')
plt.show()
CST text files¶
After some online sleuthing, you can find some old CST files in Nicolas Fagnoni’s Simulations repo. Let’s download and unpack the 100 to 150 MHz files.
from glob import glob
files = glob("hera_beam/*.txt")
files.sort()
with open("hera_cst_4.9m_pattern_list.txt", "w") as f:
for file in files:
freq = file.split("_")[-1].split("MHz")[0]
f.write(f"{file},{freq}\n")
%%bash
# Download the HERA E-field pattern
mkdir -p hera_beam
wget "https://github.com/Nicolas-Fagnoni/Simulations/raw/refs/heads/master/Radiation%20patterns/Archive/E-field%20pattern%20-%20Rigging%20height%204.9%20m/HERA_4.9m_E-pattern_100-150MHz.zip" -O hera_beam/HERA_4.9m_E-pattern_100-150MHz.zip
unzip hera_beam/HERA_4.9m_E-pattern_100-150MHz.zip -d hera_beam
Beam pattern names come out with spaces in them, annoying for Linux systems, so I’m going to rename them here
from subprocess import call
from glob import glob
files = glob("hera_beam/*.txt")
for ind in range(len(files)):
cmd = f"mv '{files[ind]}' {''.join(files[ind].split(' '))}"
# print(cmd)
call(cmd, shell=True)
First of all, we make a UVBeam object from the CST text files. To do that, need a list of all the filenames, and an array of associated frequencies.
from wodenpy.primary_beam.use_uvbeam import setup_HERA_uvbeams_from_CST
cst_files = sorted(glob("hera_beam/*.txt"))
cst_freqs = [float(file.split("_")[-1].split("MHz")[0])*1e+6 for file in cst_files]
uvbeam_objs = setup_HERA_uvbeams_from_CST(cst_files, cst_freqs)
Finally let calculate the beam pattern. We’ll do two time steps and two frequencies. The HERA beam is zenith-locked. As we’re feeding in RA,Dec coords, we should see the beam pattern centred in the first time step, and then shifted away from centred in the second time step.
from wodenpy.primary_beam.use_uvbeam import run_uvbeam
##no need to do precession for simple test here
j2000_latitudes = np.radians([latitude]*2)
##add an extra 2 hours to lst for second time step
j2000_lsts = np.radians([lst_deg, lst_deg + 60.0])
freqs = np.array([100e+6, 150e+6])
all_jones_cst = run_uvbeam(uvbeam_objs, ras.flatten(), decs.flatten(),
j2000_latitudes, j2000_lsts,
freqs, iau_order=True, parallactic_rotate=True)
invalid value encountered in hd2ae
Let’s make some beam plots. I’m going to log the amplitudes to make the pattern more visible. run_uvbeam has been written to make outputs that fit into the wodenpy beam framework, which can handle unique beam models per station. The beam models I’m using here are only for one station, so if you see me setting station=0, that’s why.
def plot_jones(all_jones, time_ind, lsts, freq_ind, freqs, title, nside):
fig, axs = plt.subplots(4, 4, figsize=(12, 10), layout='constrained')
station_ind=0
gx = all_jones[0, time_ind, freq_ind, :, 0, 0]
Dx = all_jones[0, time_ind, freq_ind, :, 1, 0]
Dy = all_jones[0, time_ind, freq_ind, :, 0, 1]
gy = all_jones[0, time_ind, freq_ind, :, 1, 1]
ax = 0
for pol, label in zip([gx, Dx, Dy, gy], ['gx', 'Dx', 'Dy', 'gy']):
pol = pol.reshape((nside, nside))
im = axs[ax, 0].imshow(np.real(pol), origin='lower')
axs[ax, 0].set_title(f'Real {label}')
plt.colorbar(im, ax=axs[ax, 0])
im = axs[ax, 1].imshow(np.imag(pol), origin='lower')
axs[ax, 1].set_title(f'Imag {label}')
plt.colorbar(im, ax=axs[ax, 1])
im = axs[ax, 2].imshow(np.log10(np.abs(pol)), origin='lower')
axs[ax, 2].set_title(f'log 10 abs {label}')
plt.colorbar(im, ax=axs[ax, 2])
im = axs[ax, 3].imshow(np.angle(pol), origin='lower')
axs[ax, 3].set_title(f'Phase {label}')
plt.colorbar(im, ax=axs[ax, 3])
ax += 1
for ax in axs.flat:
ax.set_xticks([])
ax.set_yticks([])
plt.suptitle(f'{title} {freqs[freq_ind]/1e+6:.1f} MHz, LST {np.degrees(lsts[time_ind]):.1f} deg')
plt.show()
plot_jones(all_jones_cst, 0, j2000_lsts, 0, freqs, 'CST', nside)
Looks pretty single dipole-like. I’ve set iau_order=True as internally WODEN uses the IAU conventions. This means gx in above is the gain term for the north-south dipole. That means gx should be more sensitive to the east-west. The beam pattern does look elongated in the east-west direction, and vice-versa for gy, so the polarisations are behaving as expected. Let’s check the beam gets smaller at higher frequencies, and moves with time.
plot_jones(all_jones_cst, 0, j2000_lsts, 1, freqs, 'CST', nside)
plot_jones(all_jones_cst, 1, j2000_lsts, 0, freqs, 'CST', nside)
Indeed it does, so things are looking good. Now let’s try the FITS format.
FITS beam file¶
We’ll do the same again but with a beam FITS format. Again, I’ve stalked HERA-repos, and found a FITS beam file in mativs which we can download.
%%bash
wget "https://github.com/HERA-Team/matvis/raw/refs/heads/main/src/matvis/data/NF_HERA_Dipole_small.fits" -O NF_HERA_Dipole_small.fits
from wodenpy.primary_beam.use_uvbeam import setup_HERA_uvbeams_from_single_file
uvbeam_fits_objs = setup_HERA_uvbeams_from_single_file("NF_HERA_Dipole_small.fits")
print(uvbeam_fits_objs[0].freq_array)
[1.00e+08 1.01e+08]
Unforntunately, this only contains two frequencies. By default, UVBeam wants to interpolate over frequencies, and the default interp needs three at a minimum. The WODEN wrapper will just switch interpolation off if there’s <3 frequencies. At least we can compare the beam patterns at 100MHz.
all_jones_fits = run_uvbeam(uvbeam_fits_objs, ras.flatten(), decs.flatten(),
j2000_latitudes, j2000_lsts,
freqs, iau_order=True, parallactic_rotate=True)
2025-06-02 11:15:17 - WARNING - Number of frequencies in the UVBeam object is less than 3.
WODEN will proceed, but will switch off frequency interpolation.
UVBeam needs at least three frequencies to perform default interpolation.
Further warnings of this type will be suppressed.
2025-06-02 11:15:17 - WARNING - Number of frequencies in the UVBeam object is less than 3.
WODEN will proceed, but will switch off frequency interpolation.
UVBeam needs at least three frequencies to perform default interpolation.
Further warnings of this type will be suppressed.
2025-06-02 11:15:17 - WARNING - Number of frequencies in the UVBeam object is less than 3.
WODEN will proceed, but will switch off frequency interpolation.
UVBeam needs at least three frequencies to perform default interpolation.
Further warnings of this type will be suppressed.
2025-06-02 11:15:17 - WARNING - Number of frequencies in the UVBeam object is less than 3.
WODEN will proceed, but will switch off frequency interpolation.
UVBeam needs at least three frequencies to perform default interpolation.
Further warnings of this type will be suppressed.
invalid value encountered in hd2ae
We can at least check that the beam moves with time; obviously it won’t change with frequency.
plot_jones(all_jones_fits, 0, j2000_lsts, 0, freqs, 'FITS', nside)
plot_jones(all_jones_fits, 1, j2000_lsts, 0, freqs, 'FITS', nside)
Let’s compare the gains of the two models at 100MHz to convince ourselves we’re plottings two different models.
fig, axs = plt.subplots(2, 3, figsize=(14, 10), layout='constrained')
station_ind=0
time_ind = 0
freq_ind = 0
gx_cst = all_jones_cst[station_ind, time_ind, freq_ind, :, 0, 0]
gx_fits = all_jones_fits[station_ind, time_ind, freq_ind, :, 0, 0]
gy_cst = all_jones_cst[station_ind, time_ind, freq_ind, :, 1, 1]
gy_fits = all_jones_fits[station_ind, time_ind, freq_ind, :, 1, 1]
for ind, pol, label in zip(range(6), [gx_cst, gx_fits, np.abs(gx_fits) - np.abs(gx_cst),
gy_cst, gy_fits, np.abs(gy_fits) - np.abs(gy_cst)],
['gx CST', 'gx FITS', 'gx FITS - gx CST',
'gy CST', 'gy FITS', 'gy FITS - gy CST']):
pol = pol.reshape((nside, nside))
col = ind % 3
row = ind // 3
im = axs[row, col].imshow(np.log10(np.abs(pol)), origin='lower')
# im = axs[row, col].imshow(np.abs(pol), origin='lower')
axs[row, col].set_title(f'{label}')
plt.colorbar(im, ax=axs[row, col])
for ax in axs.flat:
ax.set_xticks([])
ax.set_yticks([])
plt.suptitle(f'Beam amplitude comparison (everything in log10 scale)')
plt.show()
Huh well maybe these are actually the same model, just stored in different formats. There is a slight difference, so at least we’re defo reading in two different things, and they both work.