Running populations using POSYDON

1. Run a basic population

To run the most basic population with all the pre-set POSYDON defaults, you only need the following few lines of code. By default the code runs 100 initial binaries of a constant star formation for a duration of Hubble time (13.7e9 years).

All the required POSYDON imports:

from posydon.popsyn.binarypopulation import BinaryPopulation
from posydon.binary_evol.simulationproperties import SimulationProperties
from posydon.binary_evol.flow_chart import flow_chart
from posydon.binary_evol.CE.step_CEE import StepCEE
from posydon.binary_evol.SN.step_SN import StepSN
from posydon.binary_evol.step_end import step_end
from posydon.binary_evol.MESA.step_mesa import CO_HeMS_step, MS_MS_step, CO_HMS_RLO_step
from posydon.binary_evol.DT.step_detached import detached_step
from posydon.binary_evol.DT.double_CO import DoubleCO

Define the POSYDON evolutionary steps

We should define all the evolutionary steps involved. By default, the parameters for these steps are pre-defined. Then, SimulationProperties(**sim_kwargs) initializes the population object with the steps defined, in sim_prop. This initialization step can take a few seconds as it requires loading large data files.

sim_kwargs = dict(
    flow = (flow_chart, {}),
    step_HMS_HMS = (MS_MS_step, {}),
    step_CO_HeMS = (CO_HeMS_step, {}),
    step_CO_HMS_RLO = (CO_HMS_RLO_step, {}),
    step_detached = (detached_step, {}),
    step_CE = (StepCEE, {}),
    step_SN = (StepSN, {}),
    step_dco = (DoubleCO, {}),
    step_end = (step_end, {})
)

sim_prop = SimulationProperties(**sim_kwargs)

Initializing the population object

We invoke the population class BinaryPopulation, which is the population class with the function evolve that does the work of evolving the binaries. The population class takes in the simulation properties to initialize the population.

pop = BinaryPopulation(population_properties=sim_prop)

pop.evolve()

This evolved population can be converted to a pandas dataframe. The dataframe helps us in studying the evolved population by utilizing the benefits of the functionalities available for managing dataframes.

DF = pop.to_df()

2. Customize the script to run more complicated populations

Now, for most science cases, the default POSYDON parameters would need to be changed. Below we show an example of how to run a population of 1000 initial binaries with constant star-formation for a duration of Hubble time.

Define the POSYDON steps and their parameters

If there is a need to change a parameter in the POSYDON steps that would be done in the simulation properties. For example, to change the interpolation_method for the steps that evolve the binaries according to the MESA grids, we introduce a new string interp_str = 'nearest_neighbour' and add this string to the parameter interpolation_method for the steps step_HMS_HMS, step_CO_HeMS, and step_CO_HMS_RLO.

interp_str = 'nearest_neighbour'

sim_kwargs = dict(
    flow = (flow_chart, {}),
    step_HMS_HMS = (MS_MS_step, dict(interpolation_method=interp_str)),
    step_CO_HeMS = (CO_HeMS_step, dict(interpolation_method=interp_str)),
    step_CO_HMS_RLO = (CO_HMS_RLO_step, dict(interpolation_method=interp_str)),
    step_detached = (detached_step, {}),
    step_CE = (StepCEE, {}),
    step_SN = (StepSN, {}),
    step_dco = (DoubleCO, {}),
    step_end = (step_end, {}),
)

sim_prop = SimulationProperties(**sim_kwargs)

Define the properties of the initial generated population

If some parameters of the initial population are required to be changed, they are changed as follows in kwargs. Here we change the number_of_binaries.

kwargs = {'number_of_binaries' : 1000
         }

Initializing the population object

As before, we invoke the population class, this time with the parameters of the initial population that we want to include in kwargs.

pop = BinaryPopulation(population_properties=sim_prop,
                       **kwargs)

Evolve it! And covert to pandas dataframe

The boolean parameter tqdm shows the progress bar.

pop.evolve(breakdown_to_df=False, tqdm=True)
DF = pop.to_df()

To save the population as an HDF5 file use the save function.

pop.save('population_1000.h5', **kwargs )

3. Identify double compact objects

Now, the evolved population has the complete evolution of all binaries. We only require the last row from all the binaries as we want to study the “current” properties of the double compact objects. The example is to search for binaries not disrupted in the end, in which star_1 is a BH and star_2 is an NS.

output_cols = ['state','time','event','S1_state','S2_state','S1_mass','S2_mass','orbital_period','eccentricity']
DF.loc[(DF['S1_state'] == 'BH')&(DF['S2_state'] == 'NS')&(DF['event'] == 'END')&(DF['state'] != 'disrupted')
       ,output_cols]

To look at the evolution of one individual binary, choose proper quantities to display:

index_BHNS = DF.loc[(DF['S1_state'] == 'BH')&(DF['S2_state'] == 'NS')&
                    (DF['event'] == 'END')&
                    (DF['state'] != 'disrupted')].index
DF.loc[index_BHNS[0], output_cols]

To identify the BHNS binaries that specifically went through a common envelope phase with a BH and a donor:

DF_BHNS = DF.loc[index_BHNS]
index_BHNS_CE = DF_BHNS.loc[(DF_BHNS["event"]=="oCE2")].index
DF_BHNS.loc[index_BHNS_CE[0], output_cols]

4. Identify the X-ray Binaries

In order to investigate the XRBs from this population, we need to look into the originally run population again. Again, we only require the last row from all the binaries as we want to study the “current” properties of the XRBs. Also, we want to pick out binaries where only one of the two stars at the end is a compact object (NS or BH), and the other star is definitively not a compact object (not even a white dwarf).

DF_end = DF[DF['event']=='END']
star1_is_CO = ( (DF_end["S1_state"] == "NS") | (DF_end["S1_state"] == "BH") ) & (DF_end["S2_state"] != "WD")
star2_is_CO = ( (DF_end["S2_state"] == "NS") | (DF_end["S2_state"] == "BH") ) & (DF_end["S2_state"] != "WD")
exactly_one_CO = (star1_is_CO & ~star2_is_CO) | (~star1_is_CO & star2_is_CO)
only_one_CO = DF_end[exactly_one_CO]

Looking at the final state of all the binaries, we can figure out the ones we want to keep. For XRBs, we are interested in states detached (corresponding to wind accretion) and RLO1/RLO2 (corresponding to Roche-lobe overflow).

only_one_CO['state'].unique()

Most of our XRB RLO states would be RLO2 (mass-transferred from star_2 to star_1), because the primary (star_1) is always the initially more massive star which would evolve first and form a compact object. However, there might a rare case where star_1 transferred mass to star_2 making it more massive than star_1, leading to a compact object for star_2. Therefore, to be sure, we look for both RLO1 (mass-transferred from star_1 to star_2) and RLO2.

DF_XRB = only_one_CO[(only_one_CO['state']=='detached') |
                     (only_one_CO['state']=='RLO1') |
                     (only_one_CO['state']=='RLO2')]

Now we have our subset of binaries from the total population. To see them as XRBs, we need some information about their X-ray luminosities. We can use some simple functions to calculate the corresponding accretion efficiencies and luminosities.

Importing some packages needed for these calculations.

from posydon.utils.common_functions import CO_radius
import posydon.utils.constants as const
import numpy as np

Since the accretor can be star_1 or star_2, we need to do a check to see which star is the compact object. We calculate the accretion luminosity (Lacc) using the relation eta * mass_accretion_rate * light_speed^2, where eta is the efficiency at which gravitational potential mass is converted to luminosity.

def Lx(S1_state, S1_mass, S1_lg_mdot,S2_state, S2_mass, S2_lg_mdot, lg_mtransfer_rate):

    if np.isnan(lg_mtransfer_rate):
        if S1_state == 'NS':
            eta = 0.1
            acc_lg_mdot = S1_lg_mdot
        elif S1_state == 'BH':
            eta = 0.057
            acc_lg_mdot = S1_lg_mdot
        else:
            print("CO??", S1_state, S2_state)
    else:
        acc_lg_mdot = lg_mtransfer_rate
        if S1_state == 'NS':
            eta = 0.1
        elif S1_state == 'BH':
            eta = 0.057
        else:
            print("CO??", S1_state, S2_state)

    return eta * (10.0**acc_lg_mdot * const.Msun / const.secyer) * const.clight**2

We define a new column Lacc which stands for the accretion luminosity and calculate it using the function defined above.

DF_XRB['Lacc']=DF_XRB[['S1_state', 'S1_mass', 'S1_lg_mdot',
                 'S2_state', 'S2_mass','S2_lg_mdot',
                 'lg_mtransfer_rate']].apply(lambda x: Lx(x['S1_state'], x['S1_mass'],
                                                          x['S1_lg_mdot'],x['S2_state'],
                                                          x['S2_mass'], x['S2_lg_mdot'],
                                                          x['lg_mtransfer_rate']), axis=1)

Let’s look at the properties of the XRBs, we can use pd.set_option('display.max_columns', None) to expand the columns.

import pandas as pd

#pd.set_option('display.max_columns', None)

DF_XRB[['state','time','event','S1_state','S2_state','S1_mass','S2_mass','orbital_period']]