Note
This tutorial was generated from an IPython notebook that can be downloaded here.
theano version: 1.0.4
pymc3 version: 3.7
exoplanet version: 0.2.0
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from astropy.time import Time
from astropy.io import ascii
from astropy import units as u
from astropy import constants
deg = np.pi/180.
yr = 365.25 # days / year
In the previous tutorial (Astrometric fitting) we showed how to fit an
orbit with exoplanet
where only astrometric information is
available. In this notebook we’ll extend that same example to also
include the available radial velocity information on the system.
As before we’ll use the astrometric and radial velocity measurements of HR 466 (HD 10009) as compiled by Pourbaix 1998. The speckle observations are from Hartkopf et al. 1996, and the radial velocities are from Tokovinin 1993.
# grab the formatted data and do some munging
dirname = "https://gist.github.com/iancze/262aba2429cb9aee3fd5b5e1a4582d4d/raw/c5fa5bc39fec90d2cc2e736eed479099e3e598e3/"
astro_data_full = ascii.read(dirname + "astro.txt", format="csv", fill_values=[(".", '0')])
rv1_data = ascii.read(dirname + "rv1.txt", format="csv")
rv2_data = ascii.read(dirname + "rv2.txt", format="csv")
# convert UT date to JD
astro_dates = Time(astro_data_full["date"].data, format="decimalyear")
# Following the Pourbaix et al. 1998 analysis, we'll limit ourselves to the highest quality data
# since the raw collection of data outside of these ranges has some ambiguities in swapping
# the primary and secondary star
ind = (astro_dates.value > 1975.) & (astro_dates.value < 1999.73) \
& (~astro_data_full["rho"].mask) & (~astro_data_full["PA"].mask) # eliminate entries with no measurements
astro_data = astro_data_full[ind]
astro_yrs = astro_data["date"]
astro_dates.format = 'jd'
astro_jds = astro_dates[ind].value
# adjust the errors as before
astro_data["rho_err"][astro_data["rho_err"].mask == True] = 0.01
astro_data["PA_err"][astro_data["PA_err"].mask == True] = 1.0
# convert all masked frames to be raw np arrays, since theano has issues with astropy masked columns
rho_data = np.ascontiguousarray(astro_data["rho"], dtype=float) # arcsec
rho_err = np.ascontiguousarray(astro_data["rho_err"], dtype=float)
# the position angle measurements come in degrees in the range [0, 360].
# we need to convert this to radians in the range [-pi, pi]
theta_data = np.ascontiguousarray(astro_data["PA"] * deg, dtype=float)
theta_data[theta_data > np.pi] -= 2 * np.pi
theta_err = np.ascontiguousarray(astro_data["PA_err"] * deg) # radians
WARNING: ErfaWarning: ERFA function "dtf2d" yielded 6 of "dubious year (Note 6)" [astropy._erfa.core]
WARNING: ErfaWarning: ERFA function "dtf2d" yielded 5 of "dubious year (Note 6)" [astropy._erfa.core]
WARNING: ErfaWarning: ERFA function "utctai" yielded 5 of "dubious year (Note 3)" [astropy._erfa.core]
WARNING: ErfaWarning: ERFA function "utctai" yielded 6 of "dubious year (Note 3)" [astropy._erfa.core]
WARNING: ErfaWarning: ERFA function "taiutc" yielded 6 of "dubious year (Note 4)" [astropy._erfa.core]
WARNING: ErfaWarning: ERFA function "d2dtf" yielded 6 of "dubious year (Note 5)" [astropy._erfa.core]
# take out the data as only numpy arrays
rv1 = rv1_data["rv"].data
rv1_err = rv1_data["err"].data
rv2 = rv2_data["rv"].data
rv2_err = rv2_data["err"].data
# adapt the dates of the RV series
rv1_dates = Time(rv1_data["date"] + 2400000, format="jd")
rv1_jds = rv1_dates.value
rv2_dates = Time(rv2_data["date"] + 2400000, format="jd")
rv2_jds = rv2_dates.value
rv1_yr = rv1_dates.decimalyear
rv2_yr = rv2_dates.decimalyear
As before, we’ll plot \(\rho\) vs. time and P.A. vs. time. We’ll also add the RV time series for the primary and secondary stars.
pkw = {"ls":"", "color":"k", "marker":"."}
ekw = {"ls":"", "color":"C1"}
fig, ax = plt.subplots(nrows=4, sharex=True, figsize=(5,8))
ax[0].plot(astro_yrs, rho_data, **pkw)
ax[0].errorbar(astro_yrs, rho_data, yerr=rho_err, ls="")
ax[0].set_ylabel(r'$\rho\,$ ["]')
ax[1].plot(astro_yrs, theta_data, **pkw)
ax[1].errorbar(astro_yrs, theta_data, yerr=theta_err, ls="")
ax[1].set_ylabel(r'P.A. [radians]');
ax[2].plot(rv1_yr, rv1, **pkw)
ax[2].errorbar(rv1_yr, rv1, yerr=rv1_err, **ekw)
ax[2].set_ylabel(r"$v_1$ [km/s]")
ax[3].plot(rv2_yr, rv2, **pkw)
ax[3].errorbar(rv2_yr, rv2, yerr=rv2_err, **ekw)
ax[3].set_ylabel(r"$v_2$ [km/s]");
/mnt/home/dforeman/miniconda3/envs/autoexoplanet/lib/python3.7/site-packages/matplotlib/font_manager.py:1241: UserWarning: findfont: Font family ['cursive'] not found. Falling back to DejaVu Sans.
(prop.get_family(), self.defaultFamily[fontext]))
# import the relevant packages
import pymc3 as pm
import theano.tensor as tt
import exoplanet as xo
import exoplanet.orbits
from exoplanet.distributions import Angle
First, let’s plot up a basic orbit with exoplanet to see if the best-fit parameters from Pourbaix et al. make sense. With the addition of radial velocity data, we can now constrain the masses of the stars.
def calc_Mtot(a, P):
'''
Calculate the total mass of the system using Kepler's third law.
Args:
a (au) semi-major axis
P (days) period
Returns:
Mtot (M_sun) total mass of system (M_primary + M_secondary)
'''
day_to_s = (1 * u.day).to(u.s).value
au_to_m = (1 * u.au).to(u.m).value
kg_to_M_sun = (1 * u.kg).to(u.M_sun).value
return 4 * np.pi**2 * (a * au_to_m)**3 / (constants.G.value * (P * day_to_s)**2) * kg_to_M_sun
# Orbital elements from Pourbaix et al. 1998
# conversion constant
au_to_km = constants.au.to(u.km).value
au_to_R_sun = (constants.au / constants.R_sun).value
a_ang = 0.324 # arcsec
mparallax = 27.0 # milliarcsec
parallax = 1e-3 * mparallax
a = a_ang / parallax # au
e = 0.798
i = 96.0 * deg # [rad]
omega = 251.6 * deg - np.pi # Pourbaix reports omega_2, but we want omega_1
Omega = 159.6 * deg
P = 28.8 * yr # days
T0 = Time(1989.92, format="decimalyear")
T0.format = "jd"
T0 = T0.value # [Julian Date]
gamma = 47.8 # km/s; systemic velocity
# kappa = a1 / (a1 + a2)
kappa = 0.45
# we parameterize exoplanet in terms of M2, so we'll need to
# calculate Mtot from a, P, then M2 from Mtot and kappa
Mtot = calc_Mtot(a, P)
M2 = kappa * Mtot
# n.b. that we include an extra conversion for a, because exoplanet expects a in R_sun
# note that we now include M2 in the KeplerianOrbit initialization
orbit = xo.orbits.KeplerianOrbit(a=a * au_to_R_sun, t_periastron=T0, period=P,
incl=i, ecc=e, omega=omega, Omega=Omega, m_planet=M2)
# make a theano function to get stuff from orbit
times = tt.vector("times")
ang = orbit.get_relative_angles(times, parallax) # the rho, theta measurements
# convert from R_sun / day to km/s
output_units = u.km / u.s
conv = -(1 * u.R_sun / u.day).to(output_units).value
rv1_model = conv * orbit.get_star_velocity(times)[2] + gamma
rv2_model = conv * orbit.get_planet_velocity(times)[2] + gamma
f_ang = theano.function([times], ang)
f_rv1 = theano.function([times], rv1_model)
f_rv2 = theano.function([times], rv2_model)
t = np.linspace(T0 - 0.5 * P, T0 + 0.5 * P, num=2000) # days
rho_model, theta_model = f_ang(t)
rv1s = f_rv1(t)
rv2s = f_rv2(t)
fig, ax = plt.subplots(nrows=4, sharex=True, figsize=(5,8))
ax[0].plot(t, rho_model)
ax[0].plot(astro_jds, rho_data, **pkw)
ax[0].errorbar(astro_jds, rho_data, yerr=rho_err, **ekw)
ax[0].set_ylabel(r'$\rho\,$ ["]')
ax[1].plot(t, theta_model)
ax[1].plot(astro_jds, theta_data, **pkw)
ax[1].errorbar(astro_jds, theta_data, yerr=theta_err, **ekw)
ax[1].set_ylabel(r'P.A. [radians]');
ax[2].plot(t, rv1s)
ax[2].plot(rv1_jds, rv1, **pkw)
ax[2].errorbar(rv1_jds, rv1, yerr=rv1_err, **ekw)
ax[2].set_ylabel(r"$v_1$ [km/s]")
ax[3].plot(t, rv2s)
ax[3].plot(rv2_jds, rv2, **pkw)
ax[3].errorbar(rv2_jds, rv2, yerr=rv2_err, **ekw)
ax[3].set_ylabel(r"$v_2$ [km/s]");
It looks like a pretty good starting point. So, let’s set up the model in PyMC3 for sampling.
# convert from R_sun / day to km/s
# and from v_r = - v_Z
output_units = u.km / u.s
conv = -(1 * u.R_sun / u.day).to(output_units).value
# for theta wrapping
zeros = np.zeros_like(astro_jds)
# for predicted orbits
t_fine = np.linspace(astro_jds.min(), astro_jds.max(), num=1000)
rv_times = np.linspace(rv1_jds.min(), rv1_jds.max(), num=1000)
# for predicted sky orbits, spanning a full period
t_sky = np.linspace(0, 1, num=500)
with pm.Model() as model:
# We'll include the parallax data as a prior on the parallax value
mparallax = pm.Normal("mparallax", mu=24.05, sd=0.45) # milliarcsec GAIA DR2
parallax = pm.Deterministic("parallax", 1e-3 * mparallax) # arcsec
a_ang = pm.Uniform("a_ang", 0.1, 1.0, testval=0.324) # arcsec
# the semi-major axis in au
a = pm.Deterministic("a", a_ang / parallax)
# we expect the period to be somewhere in the range of 25 years,
# so we'll set a broad prior on logP
logP = pm.Uniform("logP", lower=np.log(1 * yr), upper=np.log(100* yr), testval=np.log(28.8 * yr))
P = pm.Deterministic("P", tt.exp(logP)) # days
# Since we're doing an RV + astrometric fit, M2 now becomes a parameter of the model
M2 = pm.Normal("M2", mu=1.0, sd=0.5) # solar masses
gamma = pm.Normal("gamma", mu=47.8, sd=5.0) # km/s
omega = Angle("omega", testval=251.6 * deg - np.pi) # - pi to pi
Omega = Angle("Omega", testval=159.6 * deg) # - pi to pi
t_periastron = pm.Uniform("tperi", T0 - P, T0 + P)
# uniform on cos incl
cos_incl = pm.Uniform("cosIncl", lower=-1.0, upper=1.0, testval=np.cos(96.0 * deg)) # radians, 0 to 180 degrees
incl = pm.Deterministic("incl", tt.arccos(cos_incl))
e = pm.Uniform("e", lower=0.0, upper=1.0, testval=0.798)
# n.b. that we include an extra conversion for a, because exoplanet expects a in R_sun
orbit = xo.orbits.KeplerianOrbit(a=a * au_to_R_sun, t_periastron=t_periastron, period=P,
incl=incl, ecc=e, omega=omega, Omega=Omega, m_planet=M2)
# now that we have a physical scale defined, the total mass of the system makes sense
Mtot = pm.Deterministic("Mtot", orbit.m_total)
M1 = pm.Deterministic("M1", Mtot - M2)
# get the astrometric predictions
rho_model, theta_model = orbit.get_relative_angles(astro_jds, parallax) # the rho, theta model values
# add jitter terms to both separation and position angle
log_rho_s = pm.Normal("logRhoS", mu=np.log(np.median(rho_err)), sd=5.0)
log_theta_s = pm.Normal("logThetaS", mu=np.log(np.median(theta_err)), sd=5.0)
rho_tot_err = tt.sqrt(rho_err**2 + tt.exp(2*log_rho_s))
theta_tot_err = tt.sqrt(theta_err**2 + tt.exp(2*log_theta_s))
# evaluate the astrometric likelihood functions
pm.Normal("obs_rho", mu=rho_model, observed=rho_data, sd=rho_tot_err)
theta_diff = tt.arctan2(tt.sin(theta_model - theta_data), tt.cos(theta_model - theta_data))
pm.Normal("obs_theta", mu=theta_diff, observed=zeros, sd=theta_tot_err)
# get the radial velocity predictions
# get_star_velocity and get_planet_velocity return (v_x, v_y, v_z) tuples, so we only need the v_z vector
# but, note that since +Z points towards the observer, we actually want v_radial = -v_Z (see conv)
# this is handled naturally by exoplanets get_radial_velocity (of the star), but since we also want
# the "planet" velocity, or the velocity of the secondary, we queried both in this manner to be consistent
rv1_model = conv * orbit.get_star_velocity(rv1_jds)[2] + gamma
rv2_model = conv * orbit.get_planet_velocity(rv2_jds)[2] + gamma
log_rv1_s = pm.Normal("logRV1S", mu=np.log(np.median(rv1_err)), sd=5.0)
log_rv2_s = pm.Normal("logRV2S", mu=np.log(np.median(rv2_err)), sd=5.0)
rv1_tot_err = tt.sqrt(rv1_err**2 + tt.exp(2 * log_rv1_s))
rv2_tot_err = tt.sqrt(rv1_err**2 + tt.exp(2 * log_rv2_s))
pm.Normal("obs_rv1", mu=rv1, observed=rv1_model, sd=rv1_tot_err)
pm.Normal("obs_rv2", mu=rv2, observed=rv2_model, sd=rv2_tot_err)
# save for future sep, pa, and RV plots
rho_dense, theta_dense = orbit.get_relative_angles(t_fine, parallax)
rho_save = pm.Deterministic("rhoSave", rho_dense)
theta_save = pm.Deterministic("thetaSave", theta_dense)
rv1_dense = pm.Deterministic("rv1Save", conv * orbit.get_star_velocity(rv_times)[2] + gamma)
rv2_dense = pm.Deterministic("rv2Save", conv * orbit.get_planet_velocity(rv_times)[2] + gamma)
# sky plots
t_period = pm.Deterministic("tPeriod", t_sky * P + t_periastron)
# save some samples on a fine orbit for sky plotting purposes
rho, theta = orbit.get_relative_angles(t_period, parallax)
rho_save_sky = pm.Deterministic("rhoSaveSky", rho)
theta_save_sky = pm.Deterministic("thetaSaveSky", theta)
with model:
map_sol = xo.optimize()
optimizing logp for variables: ['logRV2S', 'logRV1S', 'logThetaS', 'logRhoS', 'e_interval__', 'cosIncl_interval__', 'tperi_interval__', 'Omega_angle__', 'omega_angle__', 'gamma', 'M2', 'logP_interval__', 'a_ang_interval__', 'mparallax']
message: Desired error not necessarily achieved due to precision loss.
logp: -181.75633483899398 -> 171.06970010730316
with model:
fig, ax = plt.subplots(nrows=4, sharex=True, figsize=(5,8))
ax[0].plot(t_fine, xo.eval_in_model(rho_save, map_sol))
ax[0].plot(astro_jds, rho_data, **pkw)
ax[0].errorbar(astro_jds, rho_data, yerr=rho_err, **ekw)
ax[0].set_ylabel(r'$\rho\,$ ["]')
ax[1].plot(t_fine, xo.eval_in_model(theta_save, map_sol))
ax[1].plot(astro_jds, theta_data, **pkw)
ax[1].errorbar(astro_jds, theta_data, yerr=theta_err, **ekw)
ax[1].set_ylabel(r'P.A. [radians]');
ax[2].plot(rv_times, xo.eval_in_model(rv1_dense, map_sol))
ax[2].plot(rv1_jds, rv1, **pkw)
ax[2].errorbar(rv1_jds, rv1, yerr=rv1_err, **ekw)
ax[2].set_ylabel(r"$v_1$ [km/s]")
ax[3].plot(rv_times, xo.eval_in_model(rv2_dense, map_sol))
ax[3].plot(rv2_jds, rv2, **pkw)
ax[3].errorbar(rv2_jds, rv2, yerr=rv2_err, **ekw)
ax[3].set_ylabel(r"$v_2$ [km/s]");
# now let's actually explore the posterior for real
sampler = xo.PyMC3Sampler(start=200, window=100, finish=300)
with model:
burnin = sampler.tune(tune=4000, start=map_sol,
step_kwargs=dict(target_accept=0.95))
trace = sampler.sample(draws=4000)
Sampling 4 chains: 100%|██████████| 808/808 [00:22<00:00, 36.45draws/s]
Sampling 4 chains: 100%|██████████| 408/408 [00:09<00:00, 41.37draws/s]
Sampling 4 chains: 100%|██████████| 808/808 [00:02<00:00, 286.18draws/s]
Sampling 4 chains: 100%|██████████| 1608/1608 [00:08<00:00, 182.38draws/s]
Sampling 4 chains: 100%|██████████| 3208/3208 [00:13<00:00, 241.40draws/s]
Sampling 4 chains: 100%|██████████| 9208/9208 [00:35<00:00, 262.36draws/s]
Sampling 4 chains: 100%|██████████| 1208/1208 [00:05<00:00, 231.25draws/s]
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [logRV2S, logRV1S, logThetaS, logRhoS, e, cosIncl, tperi, Omega, omega, gamma, M2, logP, a_ang, mparallax]
Sampling 4 chains: 100%|██████████| 16000/16000 [01:01<00:00, 259.15draws/s]
The number of effective samples is smaller than 25% for some parameters.
# let's examine the traces of the parameters we've sampled
pm.traceplot(trace, varnames=["a_ang", "logP", "omega", "Omega", "e", "cosIncl", "tperi",
"logRhoS", "logThetaS", "logRV1S", "logRV2S"])
/mnt/home/dforeman/miniconda3/envs/autoexoplanet/lib/python3.7/site-packages/pymc3/plots/__init__.py:40: UserWarning: Keyword argument varnames renamed to var_names, and will be removed in pymc3 3.8 warnings.warn('Keyword argument {old} renamed to {new}, and will be removed in pymc3 3.8'.format(old=old, new=new))
array([[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba453133c8>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fb9fd862cf8>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba3f250588>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba5ae6ce80>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba3ef99e10>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba34e5e978>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba35193ac8>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba351a4c18>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba25bcfcf8>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba4303eb70>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba602c2eb8>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba5c88eef0>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba3e786f28>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba3e731f60>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba3e735fd0>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba34651978>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba2659be10>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba472049e8>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba403a7e80>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba5a86fc88>],
[<matplotlib.axes._subplots.AxesSubplot object at 0x7fba4cb47cc0>,
<matplotlib.axes._subplots.AxesSubplot object at 0x7fba4cce0e10>]],
dtype=object)
import corner # https://corner.readthedocs.io
samples = pm.trace_to_dataframe(trace, varnames=["M1", "M2", "a", "P", "omega", "Omega", "e", "incl"])
samples["P"] /= yr
samples["incl"] /= deg
samples["omega"] /= deg
samples["Omega"] /= deg
corner.corner(samples);
# plot the orbits on the figure
# we can plot the maximum posterior solution to see
pkw = {'marker':".", "color":"k", 'ls':""}
ekw = {'color':"C1", 'ls':""}
fig, ax = plt.subplots(nrows=8, sharex=True, figsize=(8,16))
ax[0].set_ylabel(r'$\rho\,$ ["]')
ax[1].set_ylabel(r'$\rho$ residuals')
ax[2].set_ylabel(r'P.A. [radians]')
ax[3].set_ylabel(r'P.A. residuals')
nsamples = 50
choices = np.random.choice(len(trace), size=nsamples)
# get map sol for tot_rho_err
tot_rho_err = np.sqrt(rho_err**2 + np.exp(2 * np.median(trace["logRhoS"])))
tot_theta_err = np.sqrt(theta_err**2 + np.exp(2 * np.median(trace["logThetaS"])))
tot_rv1_err = np.sqrt(rv1_err**2 + np.exp(2 * np.median(trace["logRV1S"])))
tot_rv2_err = np.sqrt(rv2_err**2 + np.exp(2 * np.median(trace["logRV2S"])))
ax[0].set_ylabel(r'$\rho\,$ ["]')
ax[1].set_ylabel(r'$\rho$ residuals')
ax[2].set_ylabel(r'P.A. [radians]')
ax[3].set_ylabel(r'P.A. residuals')
ax[4].set_ylabel(r'$v_1$ [km/s]')
ax[5].set_ylabel(r'$v_1$ residuals [km/s]')
ax[6].set_ylabel(r'$v_2$ [km/s]')
ax[7].set_ylabel(r'$v_2$ residuals [km/s]')
ax[7].set_xlabel("JD [days]")
fig_sky, ax_sky = plt.subplots(nrows=1, figsize=(4,4))
with model:
# iterate through trace object
for i in choices:
pos = trace[i]
rho_pred = pos["rhoSaveSky"]
theta_pred = pos["thetaSaveSky"]
x_pred = rho_pred * np.cos(theta_pred) # X north
y_pred = rho_pred * np.sin(theta_pred) # Y east
ax[0].plot(t_fine, pos["rhoSave"], color="C0", lw=0.8, alpha=0.7)
ax[1].plot(astro_jds, rho_data - xo.eval_in_model(rho_model, pos), **pkw, alpha=0.4)
ax[2].plot(t_fine, pos["thetaSave"], color="C0", lw=0.8, alpha=0.7)
ax[3].plot(astro_jds, theta_data - xo.eval_in_model(theta_model, pos), **pkw, alpha=0.4)
ax[4].plot(rv_times, pos["rv1Save"], color="C0", lw=0.8, alpha=0.7)
ax[5].plot(rv1_jds, rv1 - xo.eval_in_model(rv1_model, pos), **pkw, alpha=0.4)
ax[6].plot(rv_times, pos["rv2Save"], color="C0", lw=0.8, alpha=0.7)
ax[7].plot(rv1_jds, rv2 - xo.eval_in_model(rv2_model, pos), **pkw, alpha=0.4)
ax_sky.plot(y_pred, x_pred, color="C0", lw=0.8, alpha=0.7)
ax[0].plot(astro_jds, rho_data, **pkw)
ax[0].errorbar(astro_jds, rho_data, yerr=tot_rho_err, **ekw)
ax[1].axhline(0.0, color="0.5")
ax[1].errorbar(astro_jds, np.zeros_like(astro_jds), yerr=tot_rho_err, **ekw)
ax[2].plot(astro_jds, theta_data, **pkw)
ax[2].errorbar(astro_jds, theta_data, yerr=tot_theta_err, **ekw)
ax[3].axhline(0.0, color="0.5")
ax[3].errorbar(astro_jds, np.zeros_like(astro_jds), yerr=tot_theta_err, **ekw)
ax[4].plot(rv1_jds, rv1, **pkw)
ax[5].axhline(0.0, color="0.5")
ax[5].errorbar(rv1_jds, np.zeros_like(rv1_jds), yerr=tot_rv1_err, **ekw)
ax[6].plot(rv2_jds, rv2, **pkw)
ax[7].axhline(0.0, color="0.5")
ax[7].errorbar(rv2_jds, np.zeros_like(rv2_jds), yerr=tot_rv2_err, **ekw)
xs = rho_data * np.cos(theta_data) # X is north
ys = rho_data * np.sin(theta_data) # Y is east
ax_sky.plot(ys, xs, "ko")
ax_sky.set_ylabel(r"$\Delta \delta$ ['']")
ax_sky.set_xlabel(r"$\Delta \alpha \cos \delta$ ['']")
ax_sky.invert_xaxis()
ax_sky.plot(0,0, "k*")
ax_sky.set_aspect("equal", "datalim")