Simulating a multiple-screen system

This tutorial describes how to generate synthetic data corresponding to a single-dish observation of a pulsar whose radiation is scattered by multiple one-dimensional screens using the screens.screen module. It also explains how to set up Screen1D objects from patterns seen in a secondary spectrum, such that the synthetic data roughly match the observations.

For the basics of how to use the Screen1D class and the observe() method, please refer to the preceding tutorial.

The numerical values in the example explored in this tutorial correspond to a model for the scattering of pulsar PSR B0834+06, specifically Hengrui Zhu’s solution, which is based on that of Liu et al. (2016).

The combined codeblocks in this tutorial can be downloaded as a Python script and as a Jupyter notebook:

Python script:

two_screens.py

Jupyter notebook:

two_screens.ipynb

Preliminaries

Imports.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm

from astropy import units as u
from astropy import constants as const
from astropy.time import Time
from astropy.coordinates import (
    SkyCoord, SkyOffsetFrame, EarthLocation, CartesianRepresentation,
    CylindricalRepresentation, UnitSphericalRepresentation
)

from screens.screen import Source, Screen1D, Telescope
from screens.fields import phasor

Define a handy function to help create an extent for matplotlib.pyplot.imshow().

def axis_extent(x):
    x = x.ravel().value
    dx = x[1]-x[0]
    return x[0]-0.5*dx, x[-1]+0.5*dx

Define a matrix to transform between coordinate systems. This is needed because Astropy’s SkyOffsetFrame yields frames where the line of sight is along the x-axis, while the screens.screen module assumes the line of sight is along the z-axis.

xyz2yzx = np.array([
    [0, 1, 0],
    [0, 0, 1],
    [1, 0, 0],
])

The pulsar

Set the pulsar’s distance, sky coordinates, and proper motion. Then create a SkyOffsetFrame centered on the pulsar.

d_p = 0.620 * u.kpc

psr_coord = SkyCoord('08h37m5.644606s +06d10m15.4047s',
                     distance=d_p,
                     pm_ra_cosdec=2.16 * u.mas / u.yr,
                     pm_dec=51.64 * u.mas / u.yr)

psr_frame = SkyOffsetFrame(origin=psr_coord)

Get the pulsar’s velocity in the correct format.

vel_psr = (psr_coord
           .transform_to(psr_frame)
           .velocity
           .to_cartesian()
           .transform(xyz2yzx))

Create the Source object for the pulsar.

pulsar = Source(vel=vel_psr)

The telescope

Set the location of the telescope and the time of the observation. Use these together with the get_gcrs() method to get the telescope’s velocity.

tel_loc = EarthLocation('66°45′10″W', '18°20′48″N')

t_obs = Time(53712.29719907, format='mjd', scale='tai')

vel_tel = (tel_loc
           .get_gcrs(t_obs)
           .transform_to(psr_frame)
           .velocity
           .to_cartesian()
           .transform(xyz2yzx))

Create the Telescope object.

telescope = Telescope(vel=vel_tel)

The main screen

The screen properties

Set the distance of the screen from Earth \(d_\mathrm{s}\), the screen angle \(\xi\) (defined as the position angle of the line of lensed images, measured eastward from the celestial north), and the screen’s velocity along the line of lensed images \(v_\mathrm{lens,\parallel}\) (i.e., the component of the lens velocity in the direction defined by the angle \(\xi\)).

d_s1 = 0.389 * u.kpc
xi1 = 154.8 * u.deg
v_lens1 = 23.1 * u.km / u.s

The positions of main screen’s images

For screen 1 (the screen responsible for the main parabola in the secondary spectrum), we want to derive the positions of the images on the screen from the \(f_\mathrm{D}\) coordinates of the apices of the inverted arclets (measured in the secondary spectrum of the observation).

fd1 = [
    -15.93, -15.05, -14.47, -13.59, -13.00, -12.41, -11.83,  -9.78,
     -8.31,  -5.38,  -3.62,  -2.15,  -1.27,  -0.10,   1.08,   1.96,
      4.59,   5.47,   7.53,   9.29,  10.46,  15.15,
] * u.mHz

These could be converted to \(\theta\) angles using the main parabola’s curvature parameter \(\eta\), but since we have already set the screen’s distance and velocity, it’s better to do the conversion self-consistently using the screen’s effective velocity \(v_\mathrm{eff,\parallel}\), following

\[\theta = \lambda \frac{ f_\mathrm{D} }{ v_\mathrm{eff,\parallel} },\]

where \(\lambda\) is the observing wavelength.

First, get the component of the pulsar’s and the telescope’s (i.e., Earth’s) sky-plane velocity in the direction of the line of lensed images (see the tutorial on generating scintillation velocities for further explanation).

lens1_frame = SkyOffsetFrame(origin=psr_coord, rotation=xi1)

v_psr1 = psr_coord.transform_to(lens1_frame).velocity.d_z

v_tel1 = (tel_loc
          .get_gcrs(t_obs)
          .transform_to(lens1_frame)
          .velocity
          .d_z)

Then, compute effective velocity associated with the main screen.

s1 = 1. - d_s1 / d_p
v_eff1 = 1. / s1 * v_lens1 - (1. - s1) / s1 * v_psr1 - v_tel1

Finally, convert the listed \(f_\mathrm{D}\) coordinates to angles \(\theta\), and subsequently to positions on the screen (i.e., coordinates along the line of lensed images).

f_obs = 318.5 * u.MHz
lambda_obs = const.c / f_obs

theta1 = (lambda_obs * fd1 / v_eff1
         ).to(u.mas, equivalencies=u.dimensionless_angles())

pos1 = (theta1 * d_s1).to(u.au, equivalencies=u.dimensionless_angles())

Note

As a sanity check, we can verify that the curvature \(\eta\) corresponds to the value measured from the secondary spectrum.

d_eff1 = (1. - s1) / s1 * d_p

eta1 = lambda_obs**2 * d_eff1 / (2. * const.c * v_eff1**2)

eta1.to(u.s**3)
\[0.6039635 \; \mathrm{s^{3}}\]

The magnifications of main screen’s images

The magnifications of the images on the main screen will be derived from the normalized brightness of the points along the main parabola with the \(f_\mathrm{D}\) coordinates listed above. We set random angles for the unknown intrinsic phase due to the lens.

brightness1 = [
      1.20,  1.66,  1.60,  1.45,  1.37,  0.99,  1.22,  8.99,
      8.51,  6.48, 22.04, 26.32, 28.05, 27.78, 22.64, 21.20,
     40.38, 18.76, 10.80,  6.31,  5.02,  0.21,
] * u.dimensionless_unscaled

rng = np.random.default_rng(seed=12345)
phase1 = rng.random(len(brightness1)) * 2.*np.pi

magnification1 = brightness1 / brightness1.max() * np.exp(1j*phase1)

Constructing the main screen

Create the Screen1D object for the main screen.

normal1 = CylindricalRepresentation(1., 90.*u.deg - xi1, 0.).to_cartesian()

screen1 = Screen1D(normal=normal1,
                   p=pos1,
                   v=v_lens1,
                   magnification=magnification1)

The second screen

Set the second screen’s properties.

d_s2 = 0.415 * u.kpc
xi2 =  46.1 * u.deg
v_lens2 = -3.3 * u.km / u.s

For the second screen, we manually set the position and magnification of the single image. In principle, these can be calculated from the coordinates and brightness of the millisecond feature in the secondary spectrum.

pos2 = [9.1652957] * u.au
magnification2 = 0.1

Create the Screen1D object for the second screen.

normal2 = CylindricalRepresentation(1., 90.*u.deg - xi2, 0.).to_cartesian()

screen2 = Screen1D(normal=normal2,
                   p=pos2,
                   v=v_lens2,
                   magnification=magnification2)

Generating observations

Use the observe() method to generate two sets of optical paths: one for radiation scattered only by the main screen (resulting in the main parabola) and one for radiation scattered by both screens (yielding the millisecond feature).

obs1 = telescope.observe(
    source=screen1.observe(source=pulsar, distance=d_p-d_s1),
    distance=d_s1)

obs2 = telescope.observe(
    source=screen1.observe(
        source=screen2.observe(source=pulsar, distance=d_p-d_s2),
        distance=d_s2-d_s1),
    distance=d_s1)

The Screen1D class assumes that the linear features that cause the images on the lens continue indefinitely. The lens that causes the millisecond feature, however, is found to be limited in extent, not producing any scatterings beyond a certain point on the sky. To model this, we have to use a little hack: we can select optical paths in obs2 based on their positions at the main screen. For these positions to be available, they first need to be computed. This is triggered by the first line, which computes the geometric delays of the optical paths, for which the positions of the scattering points on both screens need to be calculated.

obs2.tau
bool_on_lens2 = obs2.source.pos.x.ravel() < 7. * u.au

Using the tau, taudot, and brightness attributes of obs1 and obs2, we can find the geometric delays of the optical paths (at the reference time), their time derivatives, and their complex magnifications. We combine the two sets of optical paths into a single list, using only the ones from obs2 that were selected above using bool_on_lens2.

tau0 = np.hstack([obs1.tau.ravel(),
                  obs2.tau.ravel()[bool_on_lens2]])
taudot = np.hstack([obs1.taudot.ravel(),
                    obs2.taudot.ravel()[bool_on_lens2]])
brightness = np.hstack([obs1.brightness.ravel(),
                        obs2.brightness.ravel()[bool_on_lens2]])

Plot the optical paths in conjugate variable space (the Doppler shift can be expressed as \(f_\mathrm{D} = f_\mathrm{obs} \dot{\tau}\), where \(f_\mathrm{obs}\) is the observing frequency). This figure should correspond to the norm of the conjugate wavefield, showing the arclet apices with their associated magnifications.

fd_all = f_obs * taudot

plt.figure(figsize=(12., 8.))

plt.scatter(fd_all.to(u.mHz), tau0.to(u.us),
            c=np.abs(brightness).value, s=5, cmap='Blues',
            norm=LogNorm(vmin=1.e-4, vmax=1.))

plt.xlim(-50., 50.)
plt.ylim(0., 1300.)
plt.xlabel(r"differential Doppler shift $f_\mathrm{{D}}$ (mHz)")
plt.ylabel(r"relative geometric delay $\tau$ ($\mathrm{\mu s}$)")

cbar = plt.colorbar()
cbar.set_label('magnification')

plt.show()
../_images/two_screens_22_0.png

Making the dynamic spectrum

Define the observing frequencies and times. Make one a column vector and the other a row vector, so they will be broadcast against one another correctly.

t = np.linspace(0, 45*u.min, 300)[:, np.newaxis]
f = np.linspace(318.*u.MHz, 319.*u.MHz, 3000)

Compute the geometric delays as a function of time, the associated geometric phases, then the dynamic wavefield, and finally the dynamic spectrum.

tau_t = (tau0[:, np.newaxis, np.newaxis]
        + taudot[:, np.newaxis, np.newaxis] * t)

ph = phasor(f, tau_t)

dynwave = ph * brightness[:, np.newaxis, np.newaxis]

dynspec = np.abs(dynwave.sum(0))**2

Plot the dynamic spectrum.

plt.figure(figsize=(12., 8.))

plt.imshow(dynspec.T,
           origin='lower', aspect='auto', interpolation='none',
           cmap='Greys', extent=axis_extent(t) + axis_extent(f), vmin=0.)
plt.xlabel(rf"time $t$ ({t.unit.to_string('latex')})")
plt.ylabel(rf"frequency $f$ ({f.unit.to_string('latex')})")

cbar = plt.colorbar()
cbar.set_label('normalized intensity')
../_images/two_screens_25_0.png

Making the secondary spectrum

Compute the conjugate spectrum, the conjugate variables, and then the secondary spectrum.

conjspec = np.fft.fft2(dynspec)
conjspec /= conjspec[0, 0]
conjspec = np.fft.fftshift(conjspec)

tau = np.fft.fftshift(np.fft.fftfreq(f.size, f[1]-f[0])).to(u.us)
fd = np.fft.fftshift(np.fft.fftfreq(t.size, t[1]-t[0])).to(u.mHz)

secspec = np.abs(conjspec)**2

Plot the secondary spectrum.

plt.figure(figsize=(12., 8.))

plt.imshow(secspec.T.value,
           origin='lower', aspect='auto', interpolation='none',
           cmap='Greys', extent=axis_extent(fd) + axis_extent(tau),
           norm=LogNorm(vmin=1.e-8, vmax=1.))

plt.xlim(-50., 50.)
plt.ylim(0., 1300.)
plt.xlabel(r"differential Doppler shift $f_\mathrm{{D}}$ "
           rf"({fd.unit.to_string('latex')})")
plt.ylabel(r"relative geometric delay $\tau$ "
           rf"({tau.unit.to_string('latex')})")

cbar = plt.colorbar()
cbar.set_label('normalized power')

plt.show()
../_images/two_screens_27_0.png

Overplot the arclet apices.

plt.figure(figsize=(12., 8.))

plt.imshow(secspec.T.value,
           origin='lower', aspect='auto', interpolation='none',
           cmap='Greys', extent=axis_extent(fd) + axis_extent(tau),
           norm=LogNorm(vmin=1.e-8, vmax=1.))

plt.xlim(-50., 50.)
plt.ylim(0., 1300.)
plt.xlabel(r"differential Doppler shift $f_\mathrm{{D}}$ "
           rf"({fd.unit.to_string('latex')})")
plt.ylabel(r"relative geometric delay $\tau$ "
           rf"({tau.unit.to_string('latex')})")

cbar = plt.colorbar()
cbar.set_label('normalized power')

plt.scatter(fd_all.to(u.mHz), tau0.to(u.us),
            c=np.abs(brightness).value, s=5, cmap='Blues',
            norm=LogNorm(vmin=1.e-4, vmax=1.))

cbar = plt.colorbar()
cbar.set_label('magnification')

plt.show()
../_images/two_screens_28_0.png