Fit Proper Motion Anomaly

Octofitter.jl supports fitting orbit models to astrometric motion in the form of GAIA-Hipparcos proper motion anomaly (HGCA; https://arxiv.org/abs/2105.11662). These data points are calculated by finding the difference between a long term proper motion of a star between the Hipparcos and GAIA catalogs, and their proper motion calculated within the windows of each catalog. This gives four data points that can constrain the dynamical mass & orbits of planetary companions (assuming we subtract out the net trend).

If your star of interest is in the HGCA, all you need is it's GAIA DR3 ID number. You can find this number by searching for your target on SIMBAD.

For this tutorial, we will examine the star and companion HD 91312 A & B discovered by SCExAO. We will use their published astrometry and proper motion anomaly extracted from the HGCA.

We will also perform a model comparison: we will fit the same model to four different subsets of data to see how each dataset are impacting the final constraints. This is an important consistency check, especially with proper motion / absolute astrometry data which be susceptible to systematic errors.

The first step is to find the GAIA source ID for your object. For HD 91312, SIMBAD tells us the GAIA DR3 ID is 756291174721509376.

Model: PMA Only

Initial setup:

using Octofitter, Distributions, Random

We begin by finding orbits that are consistent with the astrometric motion. Later, we will add in relative astrometry to the fit from direct imaging to further constrain the planet's orbit and mass.

Compared to previous tutorials, we will now have to add a few additional variables to our model. The first is a prior on the mass of the companion, called mass. The units used on this variable are Jupiter masses, in contrast to M, the primary's mass, in solar masses. A reasonable uninformative prior for mass is Uniform(0,1000) or LogUniform(1,1000) depending on the situation.

For this model, we also want to place a prior on the host star mass rather than system total mass. For exoplanets there is litte difference between these two values, but in this example we have a reasonably informative prior on the host mass, and know from the paper that the companion is has a non-neglible effect on the total system mass.

To make this parameterization change, we specify priors on both masses in the @system block, and connect it to the planet.

Retrieving the HGCA

To start, we retrieve the HGCA data for this object.

hgca_like = HGCALikelihood(gaia_id=756291174721509376)

For this example, we will speed things up by treating the HGCA measurements as instantaenous. The default is to average measurements 25 times over the duration of each mission. For real inference, you'll want to increase this or leave it at the default value, unless you have reason to believe the companion has an orbital period much greater than 20 years or so.

hgca_like = HGCALikelihood(gaia_id=756291174721509376, N_ave=1)
HGCALikelihood Table with 3 columns and 4 rows:
     epoch    meas  inst
   ┌────────────────────
 1 │ 48345.7  ra    hip
 2 │ 48500.8  dec   hip
 3 │ 57423.6  ra    gaia
 4 │ 57503.4  dec   gaia

Planet Model

@planet b Visual{KepOrbit} begin
    a ~ LogUniform(0.1,20)
    e ~ Uniform(0,0.999)
    ω ~ Uniform(0, 2pi)
    i ~ Sine() # The Sine() distribution is defined by Octofitter
    Ω ~ Uniform(0, 2pi)

    mass = system.M_sec

    θ ~ Uniform(0, 2pi)
    tp = θ_at_epoch_to_tperi(system,b,57423.0) # epoch of GAIA measurement
end
Planet b
Derived:
  mass, tp, 
Priors:
  a	Distributions.LogUniform{Float64}(a=0.1, b=20.0)
  e	Distributions.Uniform{Float64}(a=0.0, b=0.999)
  ω	Distributions.Uniform{Float64}(a=0.0, b=6.283185307179586)
  i	Sine()
  Ω	Distributions.Uniform{Float64}(a=0.0, b=6.283185307179586)
  θ	Distributions.Uniform{Float64}(a=0.0, b=6.283185307179586)


System Model & Specifying Proper Motion Anomaly

Now that we have our planet model, we create a system model to contain it.

We specify priors on plx as usual, but here we use the gaia_plx helper function to read the parallax and uncertainty directly from the HGCA catalog using its source ID.

We also add parameters for the star's long term proper motion. This is usually close to the long term trend between the Hipparcos and GAIA measurements. If you're not sure what to use here, try Normal(0, 1000); that is, assume a long-term proper motion of 0 +- 1000 milliarcseconds / year.

@system HD91312_pma begin

    M_pri ~ truncated(Normal(1.61, 0.1), lower=0.1) # Msol
    M_sec ~ LogUniform(0.5, 1000) # MJup
    M = system.M_pri + system.M_sec*Octofitter.mjup2msol # Msol

    plx ~ gaia_plx(gaia_id=756291174721509376)

    # Priors on the center of mass proper motion
    pmra ~ Normal(-137, 10)
    pmdec ~ Normal(2,  10)
end hgca_like b

model_pma = Octofitter.LogDensityModel(HD91312_pma)
LogDensityModel for System HD91312_pma of dimension 11 and 4 epochs with fields .ℓπcallback and .∇ℓπcallback

After the priors, we add the proper motion anomaly measurements from the HGCA. If this is your first time running this code, you will be prompted to automatically download and cache the catalog which may take around 30 seconds.

Sampling from the posterior (PMA only)

Because proper motion anomaly data is quite sparse, it can often produce multi-modal posteriors. If your orbit already has several relative astrometry or RV data points, this is less of an issue. But in many cases it is recommended to use the Pigeons.jl sampler instead of Octofitter's default. This sampler is less efficient for unimodal distributions, but is more robust at exploring posteriors with distinct, widely separated peaks.

To install and use Pigeons.jl with Octofitter, type using Pigeons at in the terminal and accept the prompt to install the package. You may have to restart Julia.

Note

octofit_pigeons scales very well across multiple cores. Start julia with julia --threads=auto to make sure you have multiple threads available for sampling.

We now sample from our model using Pigeons:

using Pigeons
chain_pma, pt = octofit_pigeons(model_pma, n_rounds=13, explorer=SliceSampler())
display(chain_pma)
┌ Warning: Module Octofitter with build ID fafbfcfd-a27b-b7ca-0000-006fbc8fe2e9 is missing from the cache.
│ This may mean Octofitter [daf3887e-d01a-44a1-9d7e-98f15c5d69c9] does not support precompilation but is imported by a module that does.
└ @ Base loading.jl:2011
┌ Warning: Module ForwardDiff with build ID fafbfcfd-dc20-ae10-0000-0075d95f115c is missing from the cache.
│ This may mean ForwardDiff [f6369f11-7733-5829-9624-2563aa707210] does not support precompilation but is imported by a module that does.
└ @ Base loading.jl:2011
[ Info: Sampler running with multiple threads     : true
[ Info: Likelihood evaluated with multiple threads: false
[ Info: Determining initial positions using pathfinder, around that location.
┌ Warning: ErrorException("Could not find starting point within 0.001 - 0.999 quantiles of priors.")
└ @ Octofitter ~/work/Octofitter.jl/Octofitter.jl/src/initialization.jl:302
┌ Info: Found a sample of initial positions
└   initial_logpost_range = (-14.466201297627096, -14.466201297627096)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  scans     restarts      Λ        Λ_var      time(s)    allc(B)  log(Z₁/Z₀)   min(α)     mean(α)    min(αₑ)   mean(αₑ)
────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ──────────
        2          0       4.02       1.29      0.892   2.92e+07  -2.76e+04          0      0.829          1          1
        4          0       4.95       7.61     0.0908    1.3e+05      -25.5          0      0.595          1          1
        8          0        6.8       8.51       0.17   2.16e+05      -20.4     0.0662      0.506      0.989      0.999
       16          0       7.06       7.67      0.335   3.71e+05      -24.8   6.01e-08      0.525      0.998          1
       32          0       7.65       8.39       0.68   6.22e+05      -21.2      0.037      0.483          1          1
       64          2       8.18       5.29       1.59   5.38e+07        -23     0.0545      0.566      0.999          1
      128          5       7.95       5.41        2.7   9.07e+07      -20.3      0.326      0.569          1          1
      256         10       8.62       5.41       5.41   1.79e+08      -21.2      0.359      0.547          1          1
      512         21       8.24       5.62       10.6    3.5e+08        -21      0.395      0.553          1          1
 1.02e+03         46       8.33       5.87       21.1   7.02e+08      -21.1      0.371      0.542          1          1
 2.05e+03        106       8.32        5.7       41.7   1.38e+09      -21.2      0.393      0.548          1          1
  4.1e+03        223       8.33       5.55         84   2.78e+09      -21.2      0.417      0.552          1          1
 8.19e+03        462       8.29       5.75        168   5.54e+09      -21.1      0.427      0.547          1          1
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note that octofit_pigeons took somewhat longer to run than octofit typically does; however, as we will see, it sampled successfully from severally completely disconnected modes in the posterior. That makes it a good fit for sampling from proper motion anomaly and relative astrometry with limited orbital coverage.

Pair Plot

If we wish to examine the covariance between parameters in more detail, we can construct a pair-plot (aka. corner plot).

# Create a corner plot / pair plot.
# We can access any property from the chain specified in Variables
using CairoMakie
using PairPlots
octocorner(model_pma, chain_pma, small=true)
Example block output

Notice how there are completely separated peaks? The default Octofitter sample (Hamiltonian Monte Carlo) is capabale of jumping 2-3σ gaps between modes, but such widely separated peaks can cause issues (hence why we used Pigeons in this example).

Model: PMA & Relative Astrometry

The first orbit fit to only Hipparcos/GAIA data was very unconstrained. We will now add six epochs of relative astrometry (measured from direct images) gathered from the discovery paper.

astrom_like = PlanetRelAstromLikelihood(
    (epoch=mjd("2016-12-15"), ra=133., dec=-174., σ_ra=07.0, σ_dec=07., cor=0.2),
    (epoch=mjd("2017-03-12"), ra=126., dec=-176., σ_ra=04.0, σ_dec=04., cor=0.3),
    (epoch=mjd("2017-03-13"), ra=127., dec=-172., σ_ra=04.0, σ_dec=04., cor=0.1),
    (epoch=mjd("2018-02-08"), ra=083., dec=-133., σ_ra=10.0, σ_dec=10., cor=0.4),
    (epoch=mjd("2018-11-28"), ra=058., dec=-122., σ_ra=10.0, σ_dec=20., cor=0.3),
    (epoch=mjd("2018-12-15"), ra=056., dec=-104., σ_ra=08.0, σ_dec=08., cor=0.2),
)
scatter(astrom_like.table.ra, astrom_like.table.dec)
Example block output

We use the same model as before, but now condition the planet model B on the astrometry data by adding astrom_like to the end of the @planet defintion.

using OctofitterRadialVelocity

rvlike = PlanetRelativeRVLikelihood(
    (epoch=mjd("2008-05-01"), rv=1300, σ_rv=150, inst_idx=1),
    (epoch=mjd("2010-02-15"), rv=700, σ_rv=150, inst_idx=1),
    (epoch=mjd("2016-03-01"), rv=-2700, σ_rv=150, inst_idx=1),
    jitter=:jitter_1,
    instrument_name=""
)

@planet b Visual{KepOrbit} begin
    a ~ LogUniform(0.1,400)
    e ~ Uniform(0,0.999)
    ω ~ Uniform(0, 2pi)
    i ~ Sine()
    Ω ~ Uniform(0, 2pi)

    mass = system.M_sec

    θ ~ Uniform(0, 2pi)
    tp = θ_at_epoch_to_tperi(system,b,57737.0) # epoch of astrometry

    jitter_1 = 0.0

end astrom_like # Note the relative astrometry added here!

@system HD91312_pma_astrom begin

    M_pri ~ truncated(Normal(1.61, 0.1), lower=0.1)
    M_sec ~ LogUniform(0.5, 1000) # MJup
    M = system.M_pri + system.M_sec*Octofitter.mjup2msol

    plx ~ gaia_plx(gaia_id=756291174721509376)

    # Priors on the centre of mass proper motion
    pmra ~ Normal(-137, 10)
    pmdec ~ Normal(2,  10)

end hgca_like b

model_pma_astrom = Octofitter.LogDensityModel(HD91312_pma_astrom,autodiff=:ForwardDiff,verbosity=4)

using Pigeons
chain_pma_astrom, pt = octofit_pigeons(model_pma_astrom, n_rounds=12, explorer=SliceSampler())
[ Info: Preparing model
┌ Info: Determined number of free variables
└   D = 11
ℓπcallback(θ, args...): 0.000025 seconds (9 allocations: 1.688 KiB)
┌ Info: Tuning autodiff
│   chunk_size = 11
└   t = 1.035e-5
┌ Info: Selected auto-diff chunk size
└   ideal_chunk_size = 11
∇ℓπcallback(θ): 0.000055 seconds (1 allocation: 144 bytes)
[ Info: Sampler running with multiple threads     : true
[ Info: Likelihood evaluated with multiple threads: false
[ Info: Determining initial positions using pathfinder, around that location.
┌ Info: Found a sample of initial positions
└   initial_logpost_range = (-65.47425181523383, -55.24407487145618)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  scans     restarts      Λ        Λ_var      time(s)    allc(B)  log(Z₁/Z₀)   min(α)     mean(α)    min(αₑ)   mean(αₑ)
────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ──────────
        2          0       5.07       2.53      0.322   6.35e+06  -1.89e+04          0      0.755          1          1
        4          0       5.02       4.23      0.186   1.45e+05       -256          0      0.702      0.992          1
        8          0       5.73       6.47      0.342   2.34e+05       -131          0      0.606      0.996          1
       16          0       7.61       7.42       0.66   3.57e+05       -181   2.95e-98      0.515      0.998          1
       32          0       8.56       8.73       1.32   5.47e+05      -76.6   0.000521      0.442      0.999          1
       64          1        8.7       5.05       2.77   5.32e+07      -64.4   2.25e-10      0.557      0.997      0.999
      128          1        8.9        6.5       5.17    9.3e+07      -77.8    0.00167      0.503      0.999          1
      256          6       9.58       6.72       10.2   1.85e+08      -73.7     0.0158      0.474          1          1
      512         16       9.52       6.71       20.4   3.68e+08      -71.9     0.0848      0.476          1          1
 1.02e+03         24        9.8       6.93       39.9    7.2e+08      -71.9      0.122       0.46          1          1
 2.05e+03         71       9.78       6.65       79.3   1.44e+09      -72.2       0.21       0.47          1          1
  4.1e+03        167       9.84       6.51        160    2.9e+09      -72.1      0.309      0.472          1          1
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
octoplot(model_pma_astrom, chain_pma_astrom)
Example block output

Model: PMA & Relative Astrometry & RVs

We now add in three additional epochs of stellar RVs.

using OctofitterRadialVelocity

rvlike = MarginalizedStarAbsoluteRVLikelihood(
    (epoch=mjd("2008-05-01"), rv=1300, σ_rv=150),
    (epoch=mjd("2010-02-15"), rv=700, σ_rv=150),
    (epoch=mjd("2016-03-01"), rv=-2700, σ_rv=150),
    instrument_name="SOPHIE",
    jitter=:jitter_1
)

@planet b Visual{KepOrbit} begin
    a ~ LogUniform(0.1,400)
    e ~ Uniform(0,0.999)
    ω ~ Uniform(0, 2pi)
    i ~ Sine()
    Ω ~ Uniform(0, 2pi)

    mass = system.M_sec

    θ ~ Uniform(0, 2pi)
    tp = θ_at_epoch_to_tperi(system,b,57737.0) # epoch of astrometry

end astrom_like  ObsPriorAstromONeil2019(astrom_like) # Note the relative astrometry added here!

@system HD91312_pma_rv_astrom begin

    M_pri ~ truncated(Normal(1.61, 0.1), lower=0.1)
    M_sec ~ LogUniform(0.5, 1000) # MJup
    M = system.M_pri + system.M_sec*Octofitter.mjup2msol

    plx ~ gaia_plx(gaia_id=756291174721509376)

    # Priors on the centre of mass proper motion
    pmra ~ Normal(-137, 10)
    pmdec ~ Normal(2,  10)

    jitter_1 = 0.0
end hgca_like rvlike b

model_pma_rv_astrom = Octofitter.LogDensityModel(HD91312_pma_rv_astrom,autodiff=:ForwardDiff,verbosity=4)
chain_pma_rv_astrom, pt = octofit_pigeons(model_pma_rv_astrom, n_rounds=12, explorer=SliceSampler())
display(chain_pma_rv_astrom)
[ Info: Preparing model
┌ Info: Determined number of free variables
└   D = 11
ℓπcallback(θ, args...): 0.000036 seconds (9 allocations: 1.969 KiB)
┌ Info: Tuning autodiff
│   chunk_size = 11
└   t = 1.4216e-5
┌ Info: Selected auto-diff chunk size
└   ideal_chunk_size = 11
∇ℓπcallback(θ): 0.000060 seconds (1 allocation: 144 bytes)
┌ Warning: This model has priors that cannot be sampled IID.
└ @ OctofitterPigeonsExt ~/work/Octofitter.jl/Octofitter.jl/ext/OctofitterPigeonsExt/OctofitterPigeonsExt.jl:104
[ Info: Sampler running with multiple threads     : true
[ Info: Likelihood evaluated with multiple threads: false
[ Info: Determining initial positions using pathfinder, around that location.
┌ Info: Found a sample of initial positions
└   initial_logpost_range = (-68.77372786169532, -63.30752129965552)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  scans     restarts      Λ        Λ_var      time(s)    allc(B)  log(Z₁/Z₀)   min(α)     mean(α)    min(αₑ)   mean(αₑ)
────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ──────────
        2          0       3.49       2.11      0.346   3.83e+06  -2.48e+04          0      0.819          1          1
        4          0       5.48       6.61      0.306   1.43e+05       -279          0       0.61          1          1
        8          0        6.2       9.19      0.556    2.1e+05      -99.3          0      0.504          1          1
       16          0       7.33       9.83       1.09   3.38e+05       -102  2.22e-118      0.446          1          1
       32          0       8.02       10.3       2.12   5.18e+05       -104  4.39e-102      0.409          1          1
       64          1       9.05       3.54       3.99   5.42e+07       -126   2.98e-13      0.594          1          1
      128          8        9.4       3.81       7.92   9.91e+07      -98.4    3.1e-13      0.574          1          1
      256         16       9.94       3.89       15.8   1.99e+08      -98.5   0.000982      0.554      0.998          1
      512         35       10.1       3.75       31.3   3.94e+08      -98.6     0.0104      0.555          1          1
 1.02e+03         52       10.3       5.24       61.7   7.76e+08      -98.2     0.0227        0.5          1          1
 2.05e+03         98       10.4       5.39        122   1.53e+09      -98.5     0.0774      0.491          1          1
  4.1e+03        197       10.7       5.33        241   3.04e+09      -98.5      0.163      0.484          1          1
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

The mass vs. semi-major axis posterior is now much more constrained:

using CairoMakie, PairPlots
pairplot(
    (; a=chain_pma_rv_astrom["b_a"][:], mass=chain_pma_rv_astrom["b_mass"][:]) =>
        (
            PairPlots.Scatter(color=:red),
            PairPlots.MarginHist(),
            PairPlots.MarginConfidenceLimits()
        ),
    labels=Dict(:mass=>"mass [Mⱼᵤₚ]", :a=>"sma. [au]"),
)
Example block output

It is now useful to display the orbits projected onto the plane of the sky using octoplot. This function produces a nine-panel figure showing posterior predictive distributions for velocity (in three dimensions), projected positions vs. time in the plane of the sky, and various other two and three-dimensional views.

octoplot(model_pma_rv_astrom, chain_pma_rv_astrom, show_mass=true)
Example block output

Model: Relative Astrometry & RVs (no PMA)

There is a final model we should consider: one using the RV and astrometry data, but not the proper motion anomaly:

using OctofitterRadialVelocity

@planet b Visual{KepOrbit} begin
    a ~ LogUniform(0.1,400)
    e ~ Uniform(0,0.999)
    ω ~ Uniform(0, 2pi)
    i ~ Sine()
    Ω ~ Uniform(0, 2pi)

    mass = system.M_sec

    θ ~ Uniform(0, 2pi)
    tp = θ_at_epoch_to_tperi(system,b,57737.0) # epoch of astrometry

end astrom_like # Note the relative astrometry added here!

@system HD91312_rv_astrom begin

    M_pri ~ truncated(Normal(1.61, 0.1), lower=0.1)
    M_sec ~ LogUniform(0.5, 1000) # MJup
    M = system.M_pri + system.M_sec*Octofitter.mjup2msol

    plx ~ gaia_plx(gaia_id=756291174721509376)
    jitter_1 = 0.0
end rvlike b

model_rv_astrom = Octofitter.LogDensityModel(HD91312_rv_astrom,autodiff=:ForwardDiff,verbosity=4)

chain_rv_astrom, pt = octofit_pigeons(model_rv_astrom, n_rounds=12)
[ Info: Preparing model
┌ Info: Determined number of free variables
└   D = 9
ℓπcallback(θ, args...): 0.000029 seconds (9 allocations: 608 bytes)
┌ Info: Tuning autodiff
│   chunk_size = 9
└   t = 8.235e-6
┌ Info: Selected auto-diff chunk size
└   ideal_chunk_size = 9
∇ℓπcallback(θ): 0.000043 seconds (1 allocation: 128 bytes)
[ Info: Sampler running with multiple threads     : true
[ Info: Likelihood evaluated with multiple threads: false
[ Info: Determining initial positions using pathfinder, around that location.
┌ Info: Found a sample of initial positions
└   initial_logpost_range = (-75.95577756347852, -68.34018577641103)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  scans     restarts      Λ        Λ_var      time(s)    allc(B)  log(Z₁/Z₀)   min(α)     mean(α)    min(αₑ)   mean(αₑ)
────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ────────── ──────────
        2          0       2.61        2.2      0.354   8.64e+06  -1.36e+04          0      0.845          1          1
        4          0       5.36       4.64      0.143   1.43e+05       -103   7.04e-41      0.677          1          1
        8          0        4.9       5.53      0.263   2.45e+05        -85   4.24e-10      0.664          1          1
       16          0       6.17       7.08      0.495   3.79e+05      -87.8     0.0354      0.573          1          1
       32          0       7.24       6.84      0.954   6.42e+05        -88      0.119      0.546          1          1
       64          2       7.36       4.16       2.07   4.35e+07      -89.4      0.242      0.628      0.999          1
      128          4       7.38        4.8       3.76    7.4e+07      -90.2      0.378      0.607      0.999          1
      256         14        7.3       4.61       7.51   1.48e+08      -89.9      0.407      0.616      0.999          1
      512         40       7.25        4.4       14.8   2.93e+08      -90.3      0.474      0.624      0.999          1
 1.02e+03         80       7.11       4.12       29.7   5.86e+08      -90.2      0.493      0.638          1          1
 2.05e+03        160       7.22       4.48       59.5   1.17e+09        -90      0.482      0.623          1          1
  4.1e+03        329       7.22        4.5        119   2.34e+09        -90      0.503      0.622          1          1
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
octoplot(model_rv_astrom, chain_rv_astrom)
Example block output

Model Comparison

Let's now display the constraints provided by each data set in a single corner plot

# Create a corner plot / pair plot.
using CairoMakie: Makie
using PairPlots
octocorner(
    model_pma,
    chain_pma,
    chain_pma_astrom,
    chain_rv_astrom,
    chain_pma_rv_astrom,
    small=false,
    axis=(;
        b_a = (;lims=(low=0, high=25))
    ),
    viz=(
        PairPlots.MarginDensity(),
        PairPlots.Scatter()
    )
)
Example block output

We see that the constraints provided by the PMA, the astrometry, and the radial velocity data all individually overlap, and agree with the joint model constraint. This is means that none of the datasets are in tension with each other, which might suggest an issue with the data or with the modelling assumptions (e.g. single planet).