Source code for posydon.binary_evol.DT.step_detached

"""Detached evolution step."""


__authors__ = [
    "Devina Misra <devina.misra@unige.ch>",
    "Zepei Xing <Zepei.Xing@unige.ch>",
    "Emmanouil Zapartas <ezapartas@gmail.com>",
    "Nam Tran <tranhn03@gmail.com>",
    "Simone Bavera <Simone.Bavera@unige.ch>",
    "Konstantinos Kovlakas <Konstantinos.Kovlakas@unige.ch>",
    "Kyle Akira Rocha <kylerocha2024@u.northwestern.edu>",
    "Jeffrey Andrews <jeffrey.andrews@northwestern.edu>",
    "Camille Liotine <cliotine@u.northwestern.edu>",
    "Seth Gossage <seth.gossage@northwestern.edu>"
]

import numpy as np
import pandas as pd
import time
from scipy.integrate import solve_ivp
from scipy.interpolate import PchipInterpolator
from posydon.utils.interpolators import PchipInterpolator2
from posydon.config import PATH_TO_POSYDON_DATA
from posydon.binary_evol.binarystar import BINARYPROPERTIES
from posydon.binary_evol.singlestar import STARPROPERTIES
#from posydon.interpolation.data_scaling import DataScaler
from posydon.utils.common_functions import (bondi_hoyle,
                                            orbital_period_from_separation,
                                            roche_lobe_radius,
                                            check_state_of_star,
                                            set_binary_to_failed,
                                            zero_negative_values)
from posydon.binary_evol.flow_chart import (STAR_STATES_CC, 
                                            STAR_STATES_H_RICH_EVOLVABLE,
                                            STAR_STATES_HE_RICH_EVOLVABLE,
                                            UNDEFINED_STATES)
import posydon.utils.constants as const
from posydon.utils.posydonerror import (NumericalError, POSYDONError, 
                                        FlowError, ClassificationError)
from posydon.utils.posydonwarning import Pwarn

from posydon.binary_evol.DT.track_match import TrackMatcher
from posydon.binary_evol.DT.key_library import (DEFAULT_TRANSLATION,
                                                DEFAULT_TRANSLATED_KEYS)

from posydon.binary_evol.DT.tides.default_tides import default_tides
from posydon.binary_evol.DT.winds.default_winds import (default_spin_from_winds,
                                                        default_sep_from_winds)
from posydon.binary_evol.DT.gravitational_radiation.default_gravrad import default_gravrad
from posydon.binary_evol.DT.magnetic_braking.prescriptions  import (RVJ83_braking,
                                                                    M15_braking,
                                                                    G18_braking,
                                                                    CARB_braking)

[docs] def event(terminal, direction=0): """Return a helper function to set attributes for solve_ivp events.""" def dec(f): f.terminal = terminal f.direction = direction return f return dec
[docs] class detached_step: """ Evolve a detached binary. The binary will be evolved until Roche-lobe overflow, core-collapse or maximum simulation time, using the standard equations that govern the orbital evolution. Parameters ---------- path : str Path to the directory that contains POSYDON data HDF5 files. Defaults to the PATH_TO_POSYDON_DATA environment variable. Used for track matching. metallicity : float The metallicity of the grid. This should be one of the eight supported metallicities: [2e+00, 1e+00, 4.5e-01, 2e-01, 1e-01, 1e-02, 1e-03, 1e-04] and this will be converted to a corresponding string (e.g., 1e+00 --> "1e+00_Zsun"). Used for track matching. matching_method : str Method to find the best match between a star from a previous step and a point in a single star evolution track. Options: "root": Tries to find a root of two matching quantities. It is possible to not find one, causing the evolution to fail. "minimize": Minimizes the sum of squares of differences of various quantities between the previous evolution step and a stellar evolution track. Used for track matching. grid_name_Hrich : str Name of the single star H-rich grid h5 file, including its parent directory. This is set to (for example): grid_name_Hrich = 'single_HMS/1e+00_Zsun.h5' by default if not specified. Used for track matching. grid_name_strippedHe : str Name of the single star He-rich grid h5 file. This is set to (for example): grid_name_strippedHe = 'single_HeMS/1e+00_Zsun.h5' by default if not specified. Used for track matching. list_for_matching_HMS : list A list of mixed type that specifies properties of the matching process for HMS stars. Used for track matching. list_for_matching_postMS : list A list of mixed type that specifies properties of the matching process for postMS stars. Used for track matching. list_for_matching_HeStar : list A list of mixed type that specifies properties of the matching process for He stars. Used for track matching. record_matching : bool Whether properties of the matched star(s) should be recorded in the binary evolution history. Used for track matching. Attributes ---------- KEYS : list[str] Contains keywords corresponding to MESA data column names which are used to extract quantities from the single star evolution grids. dt : float The timestep size, in years, to be appended to the history of the binary. None means only the final step. Note: do not select very small timesteps because it may mess with the solving of the ODE. n_o_steps_history : int Alternatively, we can define the number of timesteps to be appended to the history of the binary. None means only the final step. If both `dt` and `n_o_steps_history` are different than None, `dt` has priority. do_wind_loss : bool If True, take into account change of separation due to mass loss from the star. do_tides : bool If True, take into account change of separation, eccentricity and star spin due to tidal forces. do_gravitational_radiation : bool If True, take into account change of separation and eccentricity due to gravitational wave radiation. do_magnetic_braking : bool If True, take into account change of star spin due to magnetic braking. magnetic_braking_mode : str A string corresponding to the desired magnetic braking prescription. -- RVJ83: Rappaport, Verbunt, & Joss 1983 (Default) -- M15: Matt et al. 2015 -- G18: Garraffo et al. 2018 -- CARB: Van & Ivanova 2019 do_stellar_evolution_and_spin_from_winds : bool If True, take into account change of star spin due to change of its moment of inertia during its evolution and due to spin angular momentum loss due to winds. RLO_orbit_at_orbit_with_same_am : bool Binaries are circularized instaneously when RLO occurs and this option dictates how that is handled. If False (default), place the binary in an orbit with separation equal to the binary's separation at periastron. If True, circularize the orbit assuming that angular momentum is conserved w.r.t. the previously (possibly) eccentric orbit. In the latter case, the star may no longer fill its Roche lobe after circularization, and may be further evolved until RLO commences once again, but without changing the orbit. translate : dict Dictionary containing data column name (key) translations between POSYDON h5 file PSyGrid data names (items) and MESA data names (keys). track_matcher : TrackMatcher object The TrackMatcher object performs functions related to matching binary stellar evolution components to single star evolution models. verbose : bool True if we want to print stuff. """ def __init__( self, dt=None, n_o_steps_history=None, do_wind_loss=True, do_tides=True, do_gravitational_radiation=True, do_magnetic_braking=True, magnetic_braking_mode="RVJ83", do_stellar_evolution_and_spin_from_winds=True, RLO_orbit_at_orbit_with_same_am=False, record_matching=False, verbose=False, grid_name_Hrich=None, grid_name_strippedHe=None, metallicity=None, path=PATH_TO_POSYDON_DATA, matching_method="minimize", list_for_matching_HMS=None, list_for_matching_postMS=None, list_for_matching_HeStar=None ): """Initialize the step. See class documentation for details.""" self.dt = dt self.n_o_steps_history = n_o_steps_history self.do_wind_loss = do_wind_loss self.do_tides = do_tides self.do_gravitational_radiation = do_gravitational_radiation self.do_magnetic_braking = do_magnetic_braking self.magnetic_braking_mode = magnetic_braking_mode self.do_stellar_evolution_and_spin_from_winds = ( do_stellar_evolution_and_spin_from_winds ) self.RLO_orbit_at_orbit_with_same_am = RLO_orbit_at_orbit_with_same_am self.verbose = verbose if self.verbose: print( dt, n_o_steps_history, matching_method, do_wind_loss, do_tides, do_gravitational_radiation, do_magnetic_braking, magnetic_braking_mode, do_stellar_evolution_and_spin_from_winds) self.translate = DEFAULT_TRANSLATION # these are the KEYS read from POSYDON h5 grid files (after translating # them to the appropriate columns) self.KEYS = DEFAULT_TRANSLATED_KEYS # creating a track matching object self.track_matcher = TrackMatcher(grid_name_Hrich = grid_name_Hrich, grid_name_strippedHe = grid_name_strippedHe, path=path, metallicity = metallicity, matching_method = matching_method, list_for_matching_HMS = list_for_matching_HMS, list_for_matching_HeStar = list_for_matching_HeStar, list_for_matching_postMS = list_for_matching_postMS, record_matching = record_matching, verbose = self.verbose) # create evolution handler object self.init_evo_kwargs() self.evo = detached_evolution(**self.evo_kwargs) return
[docs] def init_evo_kwargs(self): """Store keyword args required to initialize detached evolution, based on step's kwargs.""" self.evo_kwargs = { "primary": None, "secondary": None, "do_wind_loss": self.do_wind_loss, "do_tides": self.do_tides, "do_magnetic_braking": self.do_magnetic_braking, "magnetic_braking_mode": self.magnetic_braking_mode, "do_stellar_evolution_and_spin_from_winds": self.do_stellar_evolution_and_spin_from_winds, "do_gravitational_radiation": self.do_gravitational_radiation }
def __repr__(self): """Return the name of evolution step.""" return "Detached Step." def __call__(self, binary): """ Evolve the binary through detached evolution until RLO or compact object formation. Parameters ---------- binary : BinaryStar object A BinaryStar object containing a binary system's properties. This binary will be evolved through detached evolution here. Raises ------ ValueError If the max time is exceeded by the current time of evolution. NumericalError If numerical integration fails for the binary during the calculation of its detached evolution. We mark the binary as failed in this case. FlowError If the evolution of H-rich/He-rich stars in RLO onto H-rich/He-rich stars after HMS-HMS is detected. This evolution pathway is not yet supported by our grids. The binary evolution is marked as failed at this point. ClassificationError If stable RLO between two HMS-HMS stars is determined as a result of detached evolution. We mark these binaries as failed. POSYDONError If both stars are calculated to be ready for collapse as a result of detached evolution, but the two stars differ in mass. """ # Get simulation properties and step names binary_sim_prop = getattr(binary, "properties") all_step_names = getattr(binary_sim_prop, "all_step_names") # get the next step's name to display for match recording in data frame # (in the event that the total_state is not in the flow, this will be None, # and the binary will be set to fail in BinaryStar().run_step()). next_step_name = binary.get_next_step_name() # match stars to single star models for detached evolution primary, secondary, only_CO = self.track_matcher.do_matching(binary, next_step_name) if only_CO: if self.verbose: print("Binary system only contains compact objects." "Exiting step_detached, nothing left to do here.") return secondary.t_max = secondary.interp1d["t_max"] primary.t_max = primary.interp1d["t_max"] # set the age offset on the matched track to be the time span # from the start of the track to the current age # (for these interp1d, x = time) secondary.t_offset = binary.time - secondary.interp1d["t0"] for item in secondary.interp1d.values(): if type(item) == PchipInterpolator2: item.offset = secondary.t_offset #secondary.interp1d.x_offset = secondary.t_offset primary.t_offset = binary.time - primary.interp1d["t0"] for item in primary.interp1d.values(): if type(item) == PchipInterpolator2: item.offset = primary.t_offset #primary.interp1d.x_offset = primary.t_offset max_time = secondary.interp1d["max_time"] if (self.evo.ev_rlo1(binary.time, [binary.separation, binary.eccentricity], primary, secondary) >= 0 or self.evo.ev_rlo2(binary.time, [binary.separation, binary.eccentricity], primary, secondary) >= 0): binary.state = "initial_RLOF" return else: if not (max_time - binary.time > 0.0): raise ValueError("max_time is lower than the current time. " "Evolution of the detached binary will go to " "lower times.") with np.errstate(all="ignore"): t_before_ODEsolution = time.time() try: res = solve_ivp(self.evo, events=[self.evo.ev_rlo1, self.evo.ev_rlo2, self.evo.ev_max_time1, self.evo.ev_max_time2], method="Radau", t_span=(binary.time, max_time), y0=[binary.separation, binary.eccentricity, secondary.omega0, primary.omega0], args = (primary, secondary), dense_output=True) except Exception: res = solve_ivp(self.evo, events=[self.evo.ev_rlo1, self.evo.ev_rlo2, self.evo.ev_max_time1, self.evo.ev_max_time2], method="RK45", t_span=(binary.time, max_time), y0=[binary.separation, binary.eccentricity, secondary.omega0, primary.omega0], args=(primary, secondary), dense_output=True) t_after_ODEsolution = time.time() if self.verbose: ivp_tspan = t_after_ODEsolution - t_before_ODEsolution print(f"\nODE solver duration: {ivp_tspan:.6g} sec") print("solution of ODE", res) if res.status == -1: failed_state = binary.state set_binary_to_failed(binary) raise NumericalError(f"Integration failed for {failed_state} binary.") # update binary/star properties after detached evolution t = self.get_time_after_evo(res, binary) self.update_after_evo(res, t, binary, primary, secondary) # check primary/secondary star states secondary.state = check_state_of_star(secondary, star_CO=False) for timestep in range(-len(t[:-1]), 0): secondary.state_history[timestep] = check_state_of_star(secondary, i=timestep, star_CO=False) if primary.state == "massless_remnant": pass elif primary.co: mdot_acc = np.atleast_1d(bondi_hoyle( binary, primary, secondary, slice(-len(t), None), wind_disk_criteria=True, scheme='Kudritzki+2000')) primary.lg_mdot = np.log10(mdot_acc.item(-1)) primary.lg_mdot_history[len(primary.lg_mdot_history) - len(t) + 1:] = np.log10(mdot_acc[:-1]) else: primary.state = check_state_of_star(primary, star_CO=False) for timestep in range(-len(t[:-1]), 0): primary.state_history[timestep] = check_state_of_star(primary, i=timestep, star_CO=False) ## CHECK IF THE BINARY IS IN RLO if res.t_events[0] or res.t_events[1]: if self.RLO_orbit_at_orbit_with_same_am: # final circular orbit conserves angular momentum # compared to the eccentric orbit binary.separation *= (1 - res.y[1][-1]**2) binary.orbital_period *= (1 - res.y[1][-1]**2) ** 1.5 else: # final circular orbit is at periastron of the ecc. orbit binary.separation *= (1 - res.y[1][-1]) binary.orbital_period *= (1 - res.y[1][-1]) ** 1.5 abs_diff_porb = np.abs(binary.orbital_period - orbital_period_from_separation( binary.separation, secondary.mass, primary.mass)) / binary.orbital_period assert abs_diff_porb < 10 ** (-2), \ f"\nabs_diff_porb = {abs_diff_porb:.4f}" + \ f"\nbinary.orbital_period = {binary.orbital_period:.4f}" +\ "\norbital_period_from_separation(binary.separation, secondary.mass, primary.mass) =" + \ f"{orbital_period_from_separation(binary.separation, secondary.mass, primary.mass):.4f}" # instantly circularize at RLO binary.eccentricity = 0 if res.t_events[0]: if secondary == binary.star_1: binary.state = "RLO1" binary.event = "oRLO1" else: binary.state = "RLO2" binary.event = "oRLO2" elif res.t_events[1]: if secondary == binary.star_1: binary.state = "RLO2" binary.event = "oRLO2" else: binary.state = "RLO1" binary.event = "oRLO1" if ('step_HMS_HMS_RLO' not in all_step_names): if ((binary.star_1.state in STAR_STATES_HE_RICH_EVOLVABLE and binary.star_2.state in STAR_STATES_H_RICH_EVOLVABLE) or (binary.star_1.state in STAR_STATES_H_RICH_EVOLVABLE and binary.star_2.state in STAR_STATES_HE_RICH_EVOLVABLE)): set_binary_to_failed(binary) raise FlowError("Evolution of H-rich/He-rich stars in RLO onto H-rich/He-rich stars after " "HMS-HMS not yet supported.") elif (binary.star_1.state in STAR_STATES_H_RICH_EVOLVABLE and binary.star_2.state in STAR_STATES_H_RICH_EVOLVABLE): set_binary_to_failed(binary) raise ClassificationError("Binary is in the detached step but has stable RLO with two HMS stars - " "should it have undergone CE (was its HMS-HMS interpolation class unstable MT?)") ## CHECK IF STARS WILL UNDERGO CC elif res.t_events[2]: # reached t_max of track. End of life (possible collapse) of secondary if secondary == binary.star_1: binary.event = "CC1" else: binary.event = "CC2" self.track_matcher.get_star_final_values(secondary) self.track_matcher.get_star_profile(secondary) if not primary.co and primary.state in STAR_STATES_CC: # simultaneous core-collapse of the other star as well primary_time = primary.t_max + primary.t_offset - t[-1] secondary_time = secondary.t_max + secondary.t_offset - t[-1] if primary_time == secondary_time: # we manually check if s.t_events[3] should also be happening simultaneously self.track_matcher.get_star_final_values(primary) self.track_matcher.get_star_profile(primary) if primary.mass != secondary.mass: raise POSYDONError( "Both stars are found to be ready for collapse " "(i.e. end of their life) during the detached " "step, but do not have the same mass") elif res.t_events[3]: # reached t_max of track. End of life (possible collapse) of primary if secondary == binary.star_1: binary.event = "CC2" else: binary.event = "CC1" self.track_matcher.get_star_final_values(primary) self.track_matcher.get_star_profile(primary) else: # Reached max_time asked. if binary.properties.max_simulation_time - binary.time < 0.0: binary.event = "MaxTime_exceeded" else: binary.event = "maxtime"
[docs] def get_time_after_evo(self, res, binary): """ After detached evolution, this uses the ODESolver result to determine what the current time is. Parameters ---------- res : ODESolver object This is the ODESolver object produced by SciPy's solve_ivp function that contains calculated values of the stars evolution through the detached step. binary: BinaryStar object A binary star object, containing the binary system's properties. Returns ------- t : float or array[float] This is the time elapsed as a result of detached evolution in years. This is a float unless the user specifies a timestep (see `n_o_steps_history` or `dt`) to use via the simulation properties ini file, in which case it is an array. """ if self.dt is not None and self.dt > 0: t = np.arange(binary.time, res.t[-1] + self.dt/2.0, self.dt)[1:] if t[-1] < res.t[-1]: t = np.hstack([t, res.t[-1]]) elif (self.n_o_steps_history is not None and self.n_o_steps_history > 0): t_step = (res.t[-1] - binary.time) / self.n_o_steps_history t = np.arange(binary.time, res.t[-1] + t_step / 2.0, t_step)[1:] if t[-1] < res.t[-1]: t = np.hstack([t, res.t[-1]]) else: # self.dt is None and self.n_o_steps_history is None t = np.array([res.t[-1]]) return t
[docs] def update_after_evo(self, res, t, binary, primary, secondary): """ Update star and binary properties and interpolators with ODESolver result from detached evolution. This update gives the binary/stars their appropriate values, according to the interpolation after detached evolution. Parameters ---------- res : ODESolver object This is the ODESolver object produced by SciPy's solve_ivp function that contains calculated values of the stars evolution through the detached step. t : float or array[float] This is the time elapsed as a result of detached evolution in years. This is a float unless the user specifies a timestep to use via the simulation properties ini file, in which case it is an array. binary : BinaryStar object A binary star object, containing the binary system's properties. primary : SingleStar object A single star object, representing the primary (more evolved) star in the binary and containing its properties. secondary : SingleStar object A single star object, representing the secondary (less evolved) star in the binary and containing its properties. Warns ----- InappropriateValueWarning If trying to compute log angular momentum for object with no spin. """ sep_interp, ecc_interp, omega_interp_sec, omega_interp_pri = res.sol(t) mass_interp_sec = secondary.interp1d[self.translate["mass"]] mass_interp_pri = primary.interp1d[self.translate["mass"]] secondary.interp1d["sep"] = sep_interp secondary.interp1d["ecc"] = ecc_interp secondary.interp1d["time"] = t secondary.interp1d["omega"] = omega_interp_sec primary.interp1d["sep"] = sep_interp primary.interp1d["ecc"] = ecc_interp primary.interp1d["time"] = t primary.interp1d["omega"] = omega_interp_pri secondary.interp1d["porb"] = orbital_period_from_separation( sep_interp, mass_interp_sec(t), mass_interp_pri(t)) primary.interp1d["porb"] = orbital_period_from_separation( sep_interp, mass_interp_pri(t), mass_interp_sec(t)) secondary.interp1d["time"] = t primary.interp1d["time"] = t for obj, prop in zip([secondary, primary, binary], [STARPROPERTIES, STARPROPERTIES, BINARYPROPERTIES]): interp1d = primary.interp1d if obj == primary else secondary.interp1d t_offset = primary.t_offset if obj == primary else secondary.t_offset for key in prop: if key in ["event", "mass_transfer_case", "nearest_neighbour_distance", "state", "metallicity", "V_sys"]: current = getattr(obj, key) # For star objects, the state is calculated further below history = [current] * len(t[:-1]) # replace the actual surf_avg_w with the effective omega, # which takes into account the whole star # key = 'effective_omega' # in rad/sec # current = s.y[2][-1] / 3.1558149984e7 # history_of_attribute = s.y[2][:-1] / 3.1558149984e7 elif (key in ["surf_avg_omega_div_omega_crit"] and obj != binary):#primary): if obj.co: #primary.co: current = None history = [current] * len(t[:-1]) else: # TODO: change `item()` to 0 omega_crit_current = np.sqrt(const.standard_cgrav * interp1d[self.translate["mass"]](t[-1]).item() * const.msol / (interp1d[self.translate["R"]](t[-1]).item() * const.rsol)**3) omega_crit_hist = np.sqrt(const.standard_cgrav * interp1d[self.translate["mass"]](t[:-1]) * const.msol / (interp1d[self.translate["R"]](t[:-1]) * const.rsol)**3) current = (interp1d["omega"][-1] / const.secyer / omega_crit_current) history = (interp1d["omega"][:-1] / const.secyer / omega_crit_hist) # ensure positive rotation values current = zero_negative_values([current], key)[0] history = zero_negative_values(history, key) elif (key in ["surf_avg_omega"] and obj != binary): if obj.co: current = None history = [current] * len(t[:-1]) else: current = interp1d["omega"][-1] / const.secyer history = interp1d["omega"][:-1] / const.secyer current = zero_negative_values([current], key)[0] history = zero_negative_values(history, key) elif ("rl_relative_overflow_" in key and obj == binary): s = binary.star_1 if "_1" in key[-2:] else binary.star_2 s_alt = binary.star_2 if "_1" in key[-2:] else binary.star_1 if s.state in ("BH", "NS", "WD","massless_remnant"): current = None history = [current] * len(t[:-1]) elif secondary == s: current = self.evo.ev_rel_rlo1(t[-1], [interp1d["sep"][-1], interp1d["ecc"][-1]], primary, secondary) history = self.evo.ev_rel_rlo1(t[:-1], [interp1d["sep"][:-1], interp1d["ecc"][:-1]], primary, secondary) elif secondary == s_alt: current = self.evo.ev_rel_rlo2(t[-1], [interp1d["sep"][-1], interp1d["ecc"][-1]], primary, secondary) history = self.evo.ev_rel_rlo2(t[:-1], [interp1d["sep"][:-1], interp1d["ecc"][:-1]], primary, secondary) elif key in ["separation", "orbital_period", "eccentricity", "time"]: current = interp1d[self.translate[key]][-1].item() history = interp1d[self.translate[key]][:-1] current = zero_negative_values([current], key)[0] history = zero_negative_values(history, key) elif (key in ["total_moment_of_inertia"] and obj != binary): if obj.co: current = getattr(obj, key) history = [current] * len(t[:-1]) else: current = interp1d[self.translate[key]](t[-1]).item() * (const.msol * const.rsol**2) history = interp1d[self.translate[key]](t[:-1]) * (const.msol * const.rsol**2) current = zero_negative_values([current], key)[0] history = zero_negative_values(history, key) elif (key in ["log_total_angular_momentum"] and obj != binary): if obj.co: current = getattr(obj, key) history = [current] * len(t[:-1]) else: tot_j = (interp1d["omega"][-1] / const.secyer) \ * (interp1d[self.translate["total_moment_of_inertia"]](t[-1]).item() \ * (const.msol * const.rsol**2)) current = np.log10(tot_j) if tot_j > 0.0 else -99 tot_j_hist = (interp1d["omega"][:-1] / const.secyer) \ * (interp1d[self.translate["total_moment_of_inertia"]](t[:-1]) \ * (const.msol * const.rsol**2)) history = np.where(tot_j_hist > 0, np.log10(tot_j_hist), -99) current = zero_negative_values([current], key)[0] history = zero_negative_values(history, key) elif (key in ["spin"] and obj != binary): if obj.co: current = getattr(obj, key) history = [current] * len(t[:-1]) else: current = (const.clight * (interp1d["omega"][-1] / const.secyer) * interp1d[self.translate["total_moment_of_inertia"]](t[-1]).item() \ * (const.msol * const.rsol**2) / (const.standard_cgrav * (interp1d[self.translate["mass"]](t[-1]).item() \ * const.msol)**2)) history = (const.clight * (interp1d["omega"][:-1] / const.secyer) \ * interp1d[self.translate["total_moment_of_inertia"]](t[:-1]) \ * (const.msol * const.rsol**2) \ / (const.standard_cgrav * (interp1d[self.translate["mass"]](t[:-1]) \ * const.msol)**2)) current = zero_negative_values([current], key)[0] history = zero_negative_values(history, key) elif (key in ["lg_mdot", "lg_wind_mdot"] and obj != binary): if obj.co: current = None history = [current] * len(t[:-1]) else: if interp1d[self.translate[key]](t[-1]) == 0: current = -98.99 else: current = np.log10(np.abs(interp1d[self.translate[key]]( t[-1]))).item() history = np.ones_like(t[:-1]) for i in range(len(t)-1): if (interp1d[self.translate[key]](t[i]) == 0): history[i] = -98.99 else: history[i] = np.log10(np.abs(interp1d[self.translate[key]](t[i]))) elif (self.translate[key] in interp1d and obj != binary): if obj.co: current = getattr(obj, key) history = [current] * len(t[:-1]) else: current = interp1d[self.translate[key]](t[-1]).item() history = interp1d[self.translate[key]](t[:-1]) elif key in ["profile"]: current = None history = [current] * len(t[:-1]) else: current = np.nan history = np.ones_like(t[:-1]) * current setattr(obj, key, current) getattr(obj, key + "_history").extend(history)
[docs] class detached_evolution: def __init__(self, primary=None, secondary=None, do_wind_loss=True, do_tides=True, do_magnetic_braking=True, magnetic_braking_mode="RVJ83", do_stellar_evolution_and_spin_from_winds=True, do_gravitational_radiation=True, verbose=False): self.verbose = verbose self.phys_keys = ["R", "L", "mass", "mdot", "inertia", "conv_mx1_top_r", "conv_mx1_bot_r", "surface_h1", "center_h1", "mass_conv_reg_fortides", "thickness_conv_reg_fortides", "radius_conv_reg_fortides", "Idot", "conv_env_turnover_time_l_b"] # initialize physical properties # of stars... if primary is not None: self.primary = {k:primary.interp1d[k](0) for k in self.phys_keys} else: self.primary = {k:np.nan for k in self.phys_keys} if secondary is not None: self.secondary = {k:secondary.interp1d[k](0) for k in self.phys_keys} else: self.secondary = {k:np.nan for k in self.phys_keys} self.primary['omega'] = np.nan self.secondary['omega'] = np.nan # also separation and eccentricity self.a = np.nan self.e = np.nan # detached evolution options self.do_wind_loss = do_wind_loss self.do_tides = do_tides self.do_gravitational_radiation = do_gravitational_radiation self.do_magnetic_braking = do_magnetic_braking self.magnetic_braking_mode = magnetic_braking_mode self.do_stellar_evolution_and_spin_from_winds = do_stellar_evolution_and_spin_from_winds self.verbose = False # timing events for solve_ivp... # detects secondary RLO
[docs] @event(True, 1) def ev_rlo1(self, t, y, primary, secondary): """ Difference between radius and Roche lobe at a given time. Used to check if there is RLOF mass transfer during the detached binary evolution interpolation. Calculated for the secondary. Parameters ---------- t : float Time of the evolution, in years. y : tuple(float) [separation, eccentricity] at that time. Separation should be in solar radii. primary : SingleStar object A single star object, representing the primary (more evolved) star in the binary and containing its properties. secondary : SingleStar object A single star object, representing the secondary (less evolved) star in the binary and containing its properties. Returns ------- RL_diff : float Difference between stellar radius and 95% of the Roche lobe radius in solar radii. """ pri_mass = primary.interp1d["mass"](t) sec_mass = secondary.interp1d["mass"](t) sep = y[0] ecc = y[1] RL = roche_lobe_radius(sec_mass, pri_mass, (1 - ecc) * sep) # 95% filling of the RL is enough to assume beginning of RLO, # as we do in CO-HMS_RLO grid RL_diff = secondary.interp1d["R"](t) - 0.95*RL return RL_diff
# detects primary RLO
[docs] @event(True, 1) def ev_rlo2(self, t, y, primary, secondary): """ Difference between radius and Roche lobe at a given time. Used to check if there is RLOF mass transfer during the detached binary evolution interpolation. Calculated for the primary. Parameters ---------- t : float Time of the evolution, in years y : tuple(float) [separation, eccentricity] at that time. Separation should be in solar radii. primary : SingleStar object A single star object, representing the primary (more evolved) star in the binary and containing its properties. secondary : SingleStar object A single star object, representing the secondary (less evolved) star in the binary and containing its properties. Returns ------- RL_diff : float Difference between stellar radius and 95% of the Roche lobe radius in solar radii. """ pri_mass = primary.interp1d["mass"](t) sec_mass = secondary.interp1d["mass"](t) sep = y[0] ecc = y[1] RL = roche_lobe_radius(pri_mass, sec_mass, (1 - ecc) * sep) RL_diff = primary.interp1d["R"](t) - 0.95*RL return RL_diff
# detects secondary RLO via relative difference btwn. R and R_RL
[docs] @event(True, 1) def ev_rel_rlo1(self, t, y, primary, secondary): """ Relative difference between radius and Roche lobe. Used to check if there is RLOF mass transfer during the detached binary evolution interpolation. Calculated for the secondary. Parameters ---------- t : float Time of the evolution, in years. y : tuple(float) [separation, eccentricity] at that time. Separation should be in solar radii. primary : SingleStar object A single star object, representing the primary (more evolved) star in the binary and containing its properties. secondary : SingleStar object A single star object, representing the secondary (less evolved) star in the binary and containing its properties. Returns ------- RL_rel_diff : float Relative difference between stellar radius and Roche lobe radius. """ pri_mass = primary.interp1d["mass"](t) sec_mass = secondary.interp1d["mass"](t) sep = y[0] ecc = y[1] RL = roche_lobe_radius(sec_mass, pri_mass, (1 - ecc) * sep) RL_rel_diff = (secondary.interp1d["R"](t) - RL) / RL return RL_rel_diff
# detects primary RLO via relative difference btwn. R and R_RL
[docs] @event(True, 1) def ev_rel_rlo2(self, t, y, primary, secondary): """ Relative difference between radius and Roche lobe. Used to check if there is RLOF mass transfer during the detached binary evolution interpolation. Calculated for the primary. Parameters ---------- t : float Time of the evolution, in years. y : tuple(float) [separation, eccentricity] at that time. Separation should be in solar radii. primary : SingleStar object A single star object, representing the primary (more evolved) star in the binary and containing its properties. secondary : SingleStar object A single star object, representing the secondary (less evolved) star in the binary and containing its properties. Returns ------- RL_rel_diff : float Relative difference between stellar radius and Roche lobe radius. """ pri_mass = primary.interp1d["mass"](t) sec_mass = secondary.interp1d["mass"](t) sep = y[0] ecc = y[1] RL = roche_lobe_radius(pri_mass, sec_mass, (1 - ecc) * sep) RL_rel_diff = (primary.interp1d["R"](t) - RL) / RL return RL_rel_diff
# detects if the max age in track of secondary is reached
[docs] @event(True, -1) def ev_max_time1(self, t, y, primary, secondary): return secondary.t_max - t + secondary.t_offset
# detects if the max age in track of primary is reached
[docs] @event(True, -1) def ev_max_time2(self, t, y, primary, secondary): return primary.t_max - t + primary.t_offset
[docs] def update_props(self, t, y, primary, secondary): """ Update properties of stars w/ current age during detached evolution.""" for k in self.phys_keys: self.primary[k] = primary.interp1d[k](t) self.secondary[k] = secondary.interp1d[k](t) # update omega, a, e, based on current diffeq solution y[0] = np.max([y[0], 0]) # We limit separation to non-negative values self.a = y[0] y[1] = np.max([y[1], 0]) # We limit eccentricity to non-negative values self.e = y[1] if self.e > 0 and self.e < 10.0 ** (-3): # we force a negligible eccentricity to become 0 # for computational stability self.e = 0.0 if self.verbose and self.verbose != 1: print("Negligible eccentricity became 0 for " "computational stability") y[2] = np.max([y[2], 0]) # We limit omega spin to non-negative values self.secondary['omega'] = y[2] # in rad/yr y[3] = np.max([y[3], 0]) self.primary['omega'] = y[3]
def __call__(self, t, y, primary, secondary): """ Diff. equation describing the orbital evolution of a detached binary. The equation handles wind mass-loss [1]_, tidal [2]_, gravational [3]_ effects and magnetic braking [4]_, [5]_, [6]_, [7]_, [8]_. It also handles the change of the secondary's stellar spin due to its change of moment of intertia and due to mass-loss from its spinning surface. It is assumed that the mass loss is fully non-conservative. Magnetic braking is fully applied to secondary stars with mass less than 1.3 Msun and fully off for stars with mass larger then 1.5 Msun. The effect of magnetic braking falls linearly for stars with mass between 1.3 Msun and 1.5 Msun. TODO: explain new features (e.g., double COs) Parameters ---------- t : float The age of the system in years y : list[float] Contains the separation, eccentricity and angular velocity, in Rsolar, dimensionless and rad/year units, respectively. primary : SingleStar object A single star object, representing the primary (more evolved) star in the binary and containing its properties. secondary : SingleStar object A single star object, representing the secondary (less evolved) star in the binary and containing its properties. Warns ----- UnsupportedModelWarning If an unsupported model or model is unspecified is determined from `magnetic_braking_mode`. In this case, magnetic braking will not be calculated during the detached step. Returns ------- result : list[float] Contains the change of the separation, eccentricity and angular velocity, in Rsolar, dimensionless and rad/year units, respectively. References ---------- .. [1] Tauris, T. M., & van den Heuvel, E. 2006, Compact stellar X-ray sources, 1, 623 .. [2] Hut, P. 1981, A&A, 99, 126 .. [3] Junker, W., & Schafer, G. 1992, MNRAS, 254, 146 .. [4] Rappaport, S., Joss, P. C., & Verbunt, F. 1983, ApJ, 275, 713 .. [5] Matt et al. 2015, ApJ, 799, L23 .. [6] Garraffo et al. 2018, ApJ, 862, 90 .. [7] Van & Ivanova 2019, ApJ, 886, L31 .. [8] Gossage et al. 2021, ApJ, 912, 65 """ # update star/orbital props w/ current time during integration self.update_props(t, y, primary, secondary) # initialize deltas for this timestep self.da = 0.0 self.de = 0.0 self.dOmega_sec = 0.0 self.dOmega_pri = 0.0 # Tidal forces affecting orbit and stellar spins if self.do_tides: self.tides() # Gravitional radiation affecting the orbit if self.do_gravitational_radiation: self.gravitational_radiation() # Magnetic braking affecting stellar spins if self.do_magnetic_braking: self.magnetic_braking() # Mass Loss affecting orbital separation if self.do_wind_loss: self.sep_from_winds() # Mass loss affecting stellar spins if self.do_stellar_evolution_and_spin_from_winds: self.spin_from_winds() result = [self.da, self.de, self.dOmega_sec, self.dOmega_pri] return result
[docs] def spin_from_winds(self): dOmega_sec_winds, dOmega_pri_winds = default_spin_from_winds(self.a, self.e, self.primary, self.secondary, self.verbose) # update spins self.dOmega_sec += dOmega_sec_winds self.dOmega_pri += dOmega_pri_winds
[docs] def sep_from_winds(self): da_winds = default_sep_from_winds(self.a, self.e, self.primary, self.secondary, self.verbose) # update separation self.da += da_winds
[docs] def tides(self): da_tides, de_tides, dOmega_sec_tides, dOmega_pri_tides = default_tides(self.a, self.e, self.primary, self.secondary, self.verbose) # update orbital params and spin self.da += da_tides self.de += de_tides self.dOmega_sec += dOmega_sec_tides self.dOmega_pri += dOmega_pri_tides
[docs] def gravitational_radiation(self): da_gr, de_gr = default_gravrad(self.a, self.e, self.primary, self.secondary, self.verbose) # update orbital params self.da += da_gr self.de += de_gr
[docs] def magnetic_braking(self): # domega_mb / dt = torque_mb / I is calculated below. # All results are in units of [yr^-2], i.e., the amount of change # in Omega over 1 year. if self.magnetic_braking_mode == "RVJ83": dOmega_mb_sec, dOmega_mb_pri = RVJ83_braking(self.primary, self.secondary, self.verbose) elif self.magnetic_braking_mode == "M15": dOmega_mb_sec, dOmega_mb_pri = M15_braking(self.primary, self.secondary, self.verbose) elif self.magnetic_braking_mode == "G18": dOmega_mb_sec, dOmega_mb_pri = G18_braking(self.primary, self.secondary, self.verbose) elif self.magnetic_braking_mode == "CARB": dOmega_mb_sec, dOmega_mb_pri = CARB_braking(self.primary, self.secondary, self.verbose) else: Pwarn("WARNING: Magnetic braking is not being calculated in the " "detached step. The given magnetic_braking_mode string ", f"'{self.magnetic_braking_mode}' does not match the available " "built-in cases. To enable magnetic braking, please set " "magnetc_braking_mode to one of the following strings: " "'RVJ83' for Rappaport, Verbunt, & Joss 1983" "'G18' for Garraffo et al. 2018" "'M15' for Matt et al. 2015" "'CARB' for Van & Ivanova 2019", "UnsupportedModelWarning") if self.verbose: print("magnetic_braking_mode = ", self.magnetic_braking_mode) print("dOmega_mb = ", dOmega_mb_sec, dOmega_mb_pri) # update spins self.dOmega_sec += dOmega_mb_sec self.dOmega_pri += dOmega_mb_pri