Making a mock cluster

In this notebook, we will run through the utility available in kinesis to quickly make mock cluster observations. Using this, we will examine the perspective effect of cluster mean velocity.

[1]:
%load_ext autoreload
%autoreload 2
[3]:
%matplotlib inline
import matplotlib.pyplot as plt
from matplotlib import colors
import pandas as pd
import numpy as np
import astropy.units as u
import astropy.coordinates as coords
import arviz as az

# project dependencies
import gapipes as gp
import kinesis as kn
[19]:
plt.style.use(['science','high-contrast','no-latex','notebook'])
[6]:
np.random.seed(18324)

Making and observing a mock cluster

First, let’s make a cluster with some fiducial parameters.

[7]:
N = 150  # number of sources
b0 = np.array([17.7, 41.2, 13.3])    # pc
v0 = np.array([-6.32, 45.24, 5.30])  # [vx, vy, vz] in km/s
sigv = 1.  # dispersion, km/s

cl = kn.Cluster(v0, sigv, b0=b0)
cl
[7]:
Cluster(b0=[17.7 41.2 13.3], v0=[-6.32 45.24  5.3 ], sigmav=1.0)

The cluster currently has no members. We need to ‘sample’ the cluster in order to populate it. There are two methods for sampling a cluster:

  • sample_sphere: sampling uniformly within a maximum radius

  • sample_at: sample at exactly the positions you want by giving it astropy coordinates.

Let’s do uniform sphere for now. All normal methods of Cluster will return itself with modified attributes for method chaining. Sampling will modify its members from None to an instance of ClusterMembers.

[8]:
cl.sample_sphere(N=N, Rmax=5)
cl.members
[8]:
<kinesis.mock.ClusterMembers at 0x7f7f3d8dc050>

There are two Gaia-like DataFrame associated with ClusterMembers: - truth: this is the noise-free Gaia data consistent with the Cluster’s internal motions - observed: this is the noise-added Gaia data. If the ClusterMembers has not been observed, it will be None.

Now let’s observe the cluster. In order to observe, you need to specify the 3x3 covariance matrix for (parallax, pmra, pmdec) with cov keyword. If cov is (3,3) array, this will be assumed for all stars. You can also specify different covariance matrix for different stars by specifying cov to be (N, 3, 3) array. The noise model is assumed to be Gaussian.

It is also possible to specify covariance by giving another Gaia-like data frame using error_from keyword. The following columns needed to construct to covariance matrix are expected: parallax_error, pmra_error, pmdec_error, parallax_pmra_corr, parallax_pmdec_corr, pmra_pmdec_corr. This is useful, for example, if you want to simulate data as noisy as the actual Gaia data you want to model.

Radial velocity errors should be specified separately with rv_error keyword, which should be 1-d array with N elements, each specifying the Gaussian standard deviation in km/s. If any element in rv_error is np.nan, it is assumed that the star does not have RV measurement.

This will add noise to the astrometry and radial velocities of the cluster members, and the noise-added Gaia-like data frame is now accessible with cl.members.observed.

Let’s specify simply 0.01 mas or mas/yr errors for parallax and pmra/pmdec and no available RVs.

[9]:
cl.observe(cov=np.eye(3)*0.01)
[9]:
Cluster(b0=[17.7 41.2 13.3], v0=[-6.32 45.24  5.3 ], sigmav=1.0)

Now we have a Gaia-like data in pandas DataFrame in cl.members.observed:

[10]:
cl.members.observed.head()
[10]:
ra dec parallax pmra pmdec parallax_pmra_corr parallax_pmdec_corr pmra_pmdec_corr parallax_error pmra_error pmdec_error
0 69.827908 15.519434 21.594203 100.148848 -26.160099 0.0 0.0 0.0 0.1 0.1 0.1
1 63.449065 18.338080 20.866021 116.734043 -26.047054 0.0 0.0 0.0 0.1 0.1 0.1
2 67.364160 20.868653 21.474773 97.400167 -43.817789 0.0 0.0 0.0 0.1 0.1 0.1
3 62.220135 16.061914 20.065537 116.387176 -26.138049 0.0 0.0 0.0 0.1 0.1 0.1
4 61.149763 17.653502 22.384763 125.056344 -28.292826 0.0 0.0 0.0 0.1 0.1 0.1

Perspective effect

We can use this mock cluster class to quickly verify the perspective effect which arises from the fact that proper motions are motion reflected onto a sphere (the celectial sphere). Since velocity dispersion only adds noise to this effect, let’s make another cluster with zero dispersion but still sampling the exactly same star positions as the one we just made using sample_at:

[35]:
cl0 = kn.Cluster([-6.32, 45.24, 5.30], 0., b0=b0).sample_at(cl.members.truth.g.icrs)
df = cl0.members.truth

fig, (ax1,ax2) = plt.subplots(1, 2, figsize=(8,4))
ax1.quiver(df['ra'],df['dec'],df['pmra'],df['pmdec'])
dpmra,dpmdec = df[['pmra','pmdec']].apply(lambda x:x-np.mean(x)).values.T
ra,dec = df[['ra','dec']].values.T
ax2.quiver(ra,dec,dpmra,dpmdec)
for cax in [ax1,ax2]:
    cax.set(xlabel='RA',ylabel='Dec')
ax1.set(title="V=(pmra, pmdec)")
ax2.set(title="V=($\Delta$pmra, $\Delta$pmdec)")
fig.tight_layout()
_images/tutorial-mock_16_0.png

While the overall proper motion is the same (left plot above) because all stars have the same velocity, in detail, they are projections of the velocity at different positions of the fictitious celestial sphere. So if you take out the mean and look at the residual proper motions (right plot above), the pattern is such that they are converging to a certain direction (ra, dec). This converging point is where the velocity vector becomes exactly radial, and the converging pattern is due to the fact that the cluster is receding from us.

At the specified position (b0), the radial direction is more or less aligned with the \(y\)-axis. So if we reverse the sign of our \(y\) velocity and make the cluster to be approaching towards us, the residual proper motion now shows diverging pattern as we see below

[36]:
cl0 = kn.Cluster([-6.32, -45.24, 5.30], 0., b0=b0).sample_at(cl.members.truth.g.icrs)
df = cl0.members.truth

fig, (ax1,ax2) = plt.subplots(1, 2, figsize=(8,4))
ax1.quiver(df['ra'],df['dec'],df['pmra'],df['pmdec'])
dpmra,dpmdec = df[['pmra','pmdec']].apply(lambda x:x-np.mean(x)).values.T
ra,dec = df[['ra','dec']].values.T
ax2.quiver(ra,dec,dpmra,dpmdec)
for cax in [ax1,ax2]:
    cax.set(xlabel='RA',ylabel='Dec')
ax1.set(title="V=(pmra, pmdec)")
ax2.set(title="V=($\Delta$pmra, $\Delta$pmdec)")
fig.tight_layout()
_images/tutorial-mock_19_0.png

This figure should also make you wonder, what if the cluster is contracting or expanding?

Degeneracy between perspective effect and isotropic contraction/expansion

Indeed, if we only have proper motions, we cannot tell the difference between the (istoropic) contractiong/expansion of the cluster and the perspective effect due to radial velocity. We show this below by making another mock cluster that looks the same in terms of their (residual) proper motions but that is actually stationary in the radial direction and instead contracting.

[165]:
def make_equivalent_cluster(cl):
    '''
    Make a cluster that has zero RV (not receding nor approaching)
    but is either expanding or contracting, resulting it to appear equivalent
    in terms of observed proper motions.
    '''
    import astropy.coordinates as coord
    b0, v0 = cl.b0, cl.v0
    c = coord.ICRS(*(b0*u.pc), *(v0*u.km/u.s),
                   representation_type='cartesian',differential_type='cartesian').spherical
    print(c)
    print(c.differentials['s'])

    v0_rv0 = coord.ICRS(
        c.lon,c.lat,c.distance,c.differentials['s'].d_lon*np.cos(c.lat),
        c.differentials['s'].d_lat).velocity.d_xyz.to(u.km/u.s)

    rv = c.differentials['s'].d_distance
    kappa = rv.to(u.km/u.s).value / c.distance.to(u.pc).value * 1000.0
    print(kappa)


    cl_rv0 = (
        kn.Cluster(v0_rv0.value, 0.,
                   b0=b0,T=np.diag([-kappa,-kappa,-kappa]))
        .sample_at(cl.members.truth.g.icrs))
    return cl_rv0
[170]:
cl0 = kn.Cluster([-6.32, 45.24, 5.30], 0., b0=b0).sample_at(cl.members.truth.g.icrs)
df0 = cl0.members.truth
cl_contracting =make_equivalent_cluster(cl0)
df = cl_contracting.members.truth

fig, ((ax11,ax12),(ax21,ax22)) = plt.subplots(2, 2, figsize=(8,8),sharex=True,sharey=True)

ax11.quiver(df0['ra'],df0['dec'],df0['pmra'],df0['pmdec'])
dpmra,dpmdec = df0[['pmra','pmdec']].apply(lambda x:x-np.mean(x)).values.T
ra,dec = df0[['ra','dec']].values.T
ax12.quiver(ra,dec,dpmra,dpmdec, )


ax21.quiver(df['ra'],df['dec'],df['pmra'],df['pmdec'])
dpmra,dpmdec = df[['pmra','pmdec']].apply(lambda x:x-np.mean(x)).values.T
ra,dec = df[['ra','dec']].values.T
ax22.quiver(ra,dec,dpmra,dpmdec, )
ax11.set(title="V=(pmra, pmdec)")
ax12.set(title="V=($\Delta$pmra, $\Delta$pmdec)")
ax11.set(ylabel="recedeing cluster")
ax21.set(ylabel="contracting cluster");
(66.75107646, 16.52048667, 46.77200017) (deg, deg, pc)
(111.32538582, -27.19257586, 38.96591964) (mas / yr, mas / yr, km / s)
833.1035554621004
_images/tutorial-mock_24_1.png
[175]:
# check the individual values of stars at the same position are actually the same
assert np.allclose(df['pmra'].values,df0['pmra'].values)
assert np.allclose(df['pmdec'].values,df0['pmdec'].values)

As you can see from comparing plots on the top row (the default receding cluster) and the bottom row (stationary in distance but contracting), in terms of astrometric parameters, the two are indistinguishable. In other words, there is a degeneracy between radial component of the mean velocity and the isotropic expansion/contraction.

However, if we have precise radial velocities even for some of these stars, we may hope to break that degeneracy and constrain the internal motion of the cluster.

On the other hand, if we are willing to assume that there is no expansion or contraction, we can back out the radial component of the cluster’s mean velocity from proper motions. This astrometric radial velocity is purely from geomeric projection effect as opposed to spectroscopic ones that are derived from the Doppler shift of spectral lines.

Effect of internal velocity gradient

So what if the cluster is rotating around \(z\)-axis? What effect would it introduce to the observables?

We can again make another mock cluster sampling the same positions but this time giving it a little rotation of 300 m/s/pc.

[195]:
cl0 = kn.Cluster([-6.32, 45.24, 5.30], 0., b0=b0).sample_at(cl.members.truth.g.icrs)
df0 = cl0.members.truth
clrot = kn.Cluster([-6.32, 45.24, 5.30], 0., b0=b0, omegas=[0,0,300.]).sample_at(cl.members.truth.g.icrs)
df = clrot.members.truth

fig, (ax1,ax2,ax3) = plt.subplots(1, 3, figsize=(12,4))
ax1.quiver(df['ra'],df['dec'],df['pmra'],df['pmdec'])
dpmra0,dpmdec0 = df0[['pmra','pmdec']].apply(lambda x:x-np.mean(x)).values.T
dpmrarot,dpmdecrot = df[['pmra','pmdec']].apply(lambda x:x-np.mean(x)).values.T
# take out perspective effect exactly
dpmrarot1,dpmdecrot1 = (df[['pmra','pmdec']] - df0[['pmra','pmdec']]).values.T

ra,dec = df[['ra','dec']].values.T

ax2.quiver(ra,dec,dpmra0,dpmdec0,scale_units='inches',scale=60, color='gray')
ax2.quiver(ra,dec,dpmrarot,dpmdecrot,scale_units='inches',scale=60, color='k')

ax2.quiver(ra,dec,dpmrarot1,dpmdecrot1,scale_units='inches',scale=60, color='C2')
ax3.quiver(ra,dec,dpmrarot1,dpmdecrot1,scale_units='inches',scale=20, color='C2')

for cax in [ax1,ax2,ax3]:
    cax.set(xlabel='RA',ylabel='Dec')
ax1.set(title="V=(pmra, pmdec)")
ax2.set(title="V=($\Delta$pmra, $\Delta$pmdec)")
ax3.set(title="V=($\Delta$pmra$_{-0}$, $\Delta$pmdec$_{-0}}$)")
fig.tight_layout()
_images/tutorial-mock_29_0.png

In the middle plot, we see that such small rotation will only deflect the residual proper motion slightly from the one without the rotation (perspective effect only; gray arrows) by the little red arrows. In other words, the vector some of gray and red arrows give our observed proper motion (black arrows).

If we separate out the perspective effect and only look at the effect of rotation (the right plot above), we can see the anti-symmetric pattern distinctive of rotation more clearly. The right quiver plot is made with 1/3 scale length of the middle to highlight these small vectors.

It’s clear that the relative importance of the perspective effect of the mean velocity versus any internal velocity gradient (shear or rotation) depends on the cluster parameters.