"""The binary object describes current and past state of the binary.

The binary object is composed of two star objects and contains the current and
past states of the binary. Only parameters in the BINARYPROPERTIES list are
stored in the history.

The current parameter value of the star object is accessed as, e.g.
`binary.orbital_period` while its past history with
`binary.orbital_period_history`.

The two stars are accessed as, e.g. `binary.star_1.mass`
while their past history with `binary.star_1.mass_history`.

"""


__authors__ = [
    "Konstantinos Kovlakas <Konstantinos.Kovlakas@unige.ch>",
    "Kyle Akira Rocha <kylerocha2024@u.northwestern.edu>",
    "Simone Bavera <Simone.Bavera@unige.ch>",
    "Jeffrey Andrews <jeffrey.andrews@northwestern.edu>",
    "Nam Tran <tranhn03@gmail.com>",
    "Philipp Moura Srivastava <philipp.msrivastava@gmail.com>",
    "Devina Misra <devina.misra@unige.ch>",
    "Scott Coughlin <scottcoughlin2014@u.northwestern.edu>",
]


import signal
import warnings
import copy
import numpy as np
import pandas as pd
from posydon.binary_evol.simulationproperties import SimulationProperties
from posydon.binary_evol.singlestar import SingleStar, STARPROPERTIES
from posydon.utils.common_functions import (
    check_state_of_star, orbital_period_from_separation,
    orbital_separation_from_period, get_binary_state_and_event_and_mt_case)
from posydon.popsyn.io import (clean_binary_history_df, clean_binary_oneline_df)


# star property: column names in binary history for star 1 and star 2
STAR_ATTRIBUTES_FROM_BINARY_HISTORY = {
    "mass": ["star_1_mass", "star_2_mass"],
    "lg_mdot": ["lg_mstar_dot_1", "lg_mstar_dot_2"],
    "lg_system_mdot": ["lg_system_mdot_1", "lg_system_mdot_2"],
    "lg_wind_mdot": ["lg_wind_mdot_1", "lg_wind_mdot_2"],
}

# only mention those with names different from the column names in history data
STAR_ATTRIBUTES_FROM_STAR_HISTORY = {
    'state': None,                      # to be computed after loading
    'metallicity': None,                # from initial values
    'mass': None,                       # from binary history
    'lg_mdot': None,                    # from binary history
    'lg_system_mdot': None,             # from binary history
    'lg_wind_mdot': None,               # from binary history
    'spin': 'spin_parameter',
    'profile': None
}


BINARY_ATTRIBUTES_FROM_HISTORY = {
    'state': None,
    'event': None,
    'time': 'age',
    'separation': 'binary_separation',
    'orbital_period': 'period_days',
    'eccentricity': None,
    'V_sys': None,
    'mass_transfer_case': None,
    'nearest_neighbour_distance': None
}


BINARYPROPERTIES = [
    # The state and event of the system. For more information, see
    # `posydon.utils.common_functions.get_binary_state_and_event_and_mt_case()
    'state',                    #
    'event',
    'time',                     # age of the system (yr)
    'separation',               # binary orbital separation (solar radii)
    'orbital_period',           # binary orbital period (days)
    'eccentricity',             # binary eccentricity
    'V_sys',                    # list of the 3 systemic velocity coordinates
    # (R_{star} - R_{Roche_lobe}) / R_{Roche_lobe}...
    'rl_relative_overflow_1',   # ...for star 1
    'rl_relative_overflow_2',   # ...for star 2
    'lg_mtransfer_rate',        # log10 of mass lost from the donor (Msun/yr)
    'mass_transfer_case',       # current mass transfer case of the system.
                                # See `get_binary_state_and_event_and_mt_case`
                                # in `posydon.utils.common_functions`.
    'trap_radius',
    'acc_radius',
    't_sync_rad_1',
    't_sync_conv_1',
    't_sync_rad_2',
    't_sync_conv_2',
    'nearest_neighbour_distance',   # the distance of system from its nearest
                                    # neighbour of MESA binary system  in case
                                    # of interpolation during the the end of
                                    # the previous step including MESA psygrid.
                                    # The distance is normalized in the
                                    # parameter space and limits at which it
                                    # was calculated. See `mesa_step` for more.
]


MAXIMUM_STEP_TIME = 120


def signal_handler(signum, frame):
    """React to a maximum time signal."""
    raise Exception("Binary Step Exceeded Alloted Time: {}".
                    format(MAXIMUM_STEP_TIME))


signal.signal(signal.SIGALRM, signal_handler)


class BinaryStar:
    """A class containing the state and history of a stellar binary."""

    def __init__(self, star_1=None, star_2=None, index=None, properties=None,
                 **binary_kwargs):
        """Initialize a binary star.

        Arguments
        ---------
        properties : SimulationProperties
            Instance of the SimulationProperties class (default: None)
        star_1 : SingleStar
            The first star of the binary.
        star_2 : Star
            The second star of the binary.
        **binary_kwargs : dictionary
            List of initialization parameters for a binary
        """
        # Binary Index
        self.index = index

        # Create the two stars
        self.star_1 = star_1 if star_1 is not None else SingleStar()
        self.star_2 = star_2 if star_2 is not None else SingleStar()

        # Set the initial binary properties
        for item in BINARYPROPERTIES:
            if item == 'V_sys':
                setattr(self, item, binary_kwargs.pop(item, [0,0,0]))
            elif item == 'mass_transfer_case':
                setattr(self, item, binary_kwargs.pop(item, 'None'))
            elif item == 'nearest_neighbour_distance':
                setattr(self, item, binary_kwargs.pop(item, ['None',
                                                             'None',
                                                             'None']))
            else:
                setattr(self, item, binary_kwargs.pop(item, None))
            setattr(self, item + '_history', [getattr(self, item)])
        for key, val in binary_kwargs.items():
            setattr(self, key, val)
        if not hasattr(self, 'inspiral_time'):
            self.inspiral_time = None
        if not hasattr(self, 'mass_transfer_case'):
            self.mass_transfer_case = 'None'
        # if not hasattr(self, 'V_sys'):
        #     self.V_sys = [0, 0, 0]
        
        # store innterpolation_class for each step_MESA
        for grid_type in ['HMS_HMS','CO_HMS_RLO','CO_HeMS']:
            if not hasattr(self, f'interp_class_{grid_type}'):
                setattr(self, f'interp_class_{grid_type}', None)

        # SimulationProperties object - parameters & parameterizations
        if isinstance(properties, SimulationProperties):
            self.properties = properties
        else:
            self.properties = SimulationProperties()

    def evolve(self):
        """Evolve a binary from start to finish."""
        self.properties.pre_evolve(self)

        # Code to make sure start time is less than max_simulation_time
        if self.time > self.properties.max_simulation_time:
            raise ValueError(
                "The binary's birth time ({0}) is greater than "
                "`max_simulation_time` ({1}).".format(
                    self.time, self.properties.max_simulation_time))

        max_n_steps = self.properties.max_n_steps_per_binary
        n_steps = 0
        try:
            while (self.event != 'END' and self.event != 'FAILED'
                   and self.event not in self.properties.end_events
                   and self.state not in self.properties.end_states):
                signal.alarm(MAXIMUM_STEP_TIME)
                self.run_step()

                n_steps += 1
                if max_n_steps is not None:
                    if n_steps > max_n_steps:
                        raise Exception("Exceeded maximum number of steps ({})"
                                        .format(max_n_steps))
        finally:
            signal.alarm(0)     # turning off alarm
            self.properties.post_evolve(self)

    def run_step(self):
        """Evolve the binary through one evolutionary step."""
        try:
            total_state = (self.star_1.state, self.star_2.state, self.state,
                           self.event)
            next_step_name = self.properties.flow.get(total_state)
            
            if next_step_name is None:
                warnings.warn("Undefined next step given stars/binary states "
                              "{}.".format(total_state))
                self.event = 'END'
                return

            next_step = getattr(self.properties, next_step_name, None)
            if next_step is None:
                raise ValueError(
                    "Next step name '{}' does not correspond to a function in "
                    "SimulationProperties.".format(next_step_name))

            self.properties.pre_step(self, next_step_name)
            next_step(self)
        finally:
            self.append_state()
            self.properties.post_step(self, next_step_name)

    def append_state(self):
        """Update the history of the binaries' properties."""
        # Append to the binary history lists
        for item in BINARYPROPERTIES:
            getattr(self, item + '_history').append(getattr(self, item))

        # Append to the individual star history lists
        self.star_1.append_state()
        self.star_2.append_state()

    def switch_star(self):
        """Switch stars."""
        self.star_1, self.star_2 = self.star_2, self.star_1

    def restore(self, i=0, delete_history=True):
        """Restore the object to the i-th state.

        Parameters
        ----------
        i : int
            The index of the binary object history to reset the binary to.
            By default 0, i.e. the star will be restored to its initial state.

        """
        # Move current binary properties to the ith step, using its history
        for p in BINARYPROPERTIES:
            setattr(self, p, getattr(self, '{}_history'.format(p))[i])
        for star in (self.star_1, self.star_2):
            star.restore(i)

        # Remove the obsolete history data
        if delete_history:
            for p in BINARYPROPERTIES:
                setattr(self, p + '_history',
                        getattr(self, p + '_history')[0:i + 1])

    def reset(self, properties=None):
        """Reset the binary to its ZAMS state.

        Parameters
        ----------
        properties : SimulationProperties
            Instance of the SimulationProperties class (default: None)

        """
        # If provided, update the simulation properties class
        if properties is not None:
            self.properties = SimulationProperties(properties)

        # Use the restore function to move the binary back to its initial state
        self.restore(i=0)

    def update_star_states(self):
        """Update the states of the two stars in the binary."""
        self.star_1.state = check_state_of_star(
            self.star_1, star_CO=self.star_1.state in ["WD", "BH", "NS"])
        self.star_2.state = check_state_of_star(
            self.star_2, star_CO=self.star_2.state in ["WD", "BH", "NS"])

    def to_df(self, **kwargs):
        """Return history parameters from the binary in a DataFrame.

        Includes star 1 and 2 (S1, S2) data and an extra column 'binary_index'.

        Parameters
        ----------
        extra_columns : dict( 'name':dtype, .... )
            Extra binary parameters to return in DataFrame that are not
            included in BINARYPROPERTIES. All columns must have an
            associated pandas data type.
            Can be used in combination with `only_select_columns`.
            Assumes names have no suffix.
        ignore_columns : list
            Names of binary parameters to ignore.
            Assumes names have `_history` suffix.
        only_select_columns : list
            Names of the only columns to include.
            Assumes names have `_history` suffix.
            Can be used in combination with `extra_columns`.
        null_value : float
            Replace all None values with something else (for saving).
            Default is np.NAN.
        include_S1, include_S2 : bool
            Choose to include star 1 or 2 data to the DataFrame.
            The default is to include both.
        S1_kwargs, S2_kwargs : dict
            kwargs to pass to each star's 'to_df' method (extra/ignore columns)

        Returns
        -------
        pandas DataFrame

        """
        extra_binary_cols_dict = kwargs.get('extra_columns', {})
        extra_columns = list(extra_binary_cols_dict.keys())
        extra_columns_dtypes_user = list(extra_binary_cols_dict.values())
        
        all_keys = (["binary_index"]
                    + [key+'_history' for key in BINARYPROPERTIES]
                    + extra_columns)

        ignore_cols = list(kwargs.get('ignore_columns', []))
        keys_to_save = [i for i in all_keys if not (
            (i.split('_history')[0] in ignore_cols) or (i in ignore_cols))]

        if bool(kwargs.get('only_select_columns')):
            user_keys_to_save = list(kwargs.get('only_select_columns'))
            keys_to_save = (["binary_index"]
                            + [key+'_history' for key in user_keys_to_save]
                            + extra_columns)

        try:
            data_to_save = [getattr(self, key) for key in keys_to_save[1:]]
            col_lengths = [len(x) for x in data_to_save]
            max_col_length = np.max(col_lengths)

            # binary_index
            data_to_save.insert(0, [self.index]*max_col_length)

            where_none = np.array([[True if var is None else False
                                    for var in column]
                                   for column in data_to_save], dtype=bool)

            # If a binary fails, usually history cols have diff lengths.
            # This should append NAN to create even columns.
            all_equal_length_cols = len(set(col_lengths)) == 1
            if not all_equal_length_cols:
                for col in data_to_save:
                    col.extend(['NAN'] * abs(max_col_length - len(col)))

        except AttributeError as err:
            raise AttributeError(
                str(err) + "\n\nAvailable attributes in BinaryStar: \n{}".
                format(self.__dict__.keys()))

        # Convert None to np.NAN by default
        bin_data = np.array(data_to_save, dtype=object)
        bin_data[where_none] = kwargs.get('null_value', np.NAN)

        bin_data = np.transpose(bin_data)

        # remove the _history at the end of all column names
        column_names = [name.split('_history')[0] for name in keys_to_save]
        bin_df = pd.DataFrame(bin_data, columns=column_names)

        # Add 3 columns for V_sys
        if 'V_sys' in column_names:
            V_sys_x = np.zeros(len(bin_df))
            V_sys_y = np.zeros(len(bin_df))
            V_sys_z = np.zeros(len(bin_df))
            for i in range(len(bin_df)):
                V_sys_x[i] = bin_df.iloc[i]['V_sys'][0]
                V_sys_y[i] = bin_df.iloc[i]['V_sys'][1]
                V_sys_z[i] = bin_df.iloc[i]['V_sys'][2]

            bin_df['V_sys_x'] = copy.deepcopy(V_sys_x)
            bin_df['V_sys_y'] = copy.deepcopy(V_sys_y)
            bin_df['V_sys_z'] = copy.deepcopy(V_sys_z)

            # Lose the V_sys list
            bin_df = bin_df.drop(['V_sys'], axis=1)

        frames = [bin_df]
        if kwargs.get('include_S1', True):
            # we are hard coding the prefix
            frames.append(self.star_1.to_df(
                prefix='S1_', null_value=kwargs.get('null_value', np.NAN),
                **kwargs.get('S1_kwargs', {})))
        if kwargs.get('include_S2', True):
            frames.append(self.star_2.to_df(
                prefix='S2_', null_value=kwargs.get('null_value', np.NAN),
                **kwargs.get('S2_kwargs', {})))
        binary_df = pd.concat(frames, axis=1)

        binary_df.set_index('binary_index', inplace=True)

        extra_s1_cols_dict = kwargs.get('S1_kwargs', {}).get('extra_columns', {})
        extra_s2_cols_dict = kwargs.get('S2_kwargs', {}).get('extra_columns', {})
        binary_df = clean_binary_history_df(binary_df,
                                            extra_binary_dtypes_user=extra_binary_cols_dict,
                                            extra_S1_dtypes_user=extra_s1_cols_dict,
                                            extra_S2_dtypes_user=extra_s2_cols_dict)
        return binary_df

    @classmethod
    def from_df(cls, dataframe, **kwargs):
        """Convert a binary from a pandas DataFrame to BinaryStar instance.

        Parameters
        ----------
        dataframe : Pandas DataFrame
            data to turn into a BinaryStar instance.
        index : int, optional
            Sets the binary index.
        extra_columns : dict, optional
            Column names to be added directly to binary
            not in BINARYPROPERTIES.

        Returns
        -------
            New instance of BinaryStar

        """
        if isinstance(dataframe, pd.Series):
            dataframe = pd.DataFrame(dataframe.to_dict(), index=[0])

        # split input dataframe into kwargs dicts
        binary_params, star1_params, star2_params = dict(), dict(), dict()
        extra_params = dict()
        extra_columns = kwargs.get('extra_columns', {})
        hist_lengths = []
        for name in list(dataframe.columns):
            if 'S1' in name:
                corr_name = name.split('S1_')[-1] + '_history'
                star1_params[corr_name] = list(dataframe[name])
                hist_lengths.append(len(star1_params[corr_name]))
            elif 'S2' in name:
                corr_name = name.split('S2_')[-1] + '_history'
                star2_params[corr_name] = list(dataframe[name])
                hist_lengths.append(len(star2_params[corr_name]))
            elif name in extra_columns:
                # this assumes extra cols in binary, not star 1 or 2
                extra_params[name] = list(dataframe[name])
                hist_lengths.append(len(extra_params[name]))
            else:
                corr_name = name + '_history'
                binary_params[corr_name] = list(dataframe[name])
                hist_lengths.append(len(binary_params[corr_name]))

        # make sure all history columns have equal length
        assert len(set(hist_lengths)) == 1
        history_length = set(hist_lengths).pop()

        if ('binary_index' in dataframe.index.name
                and not kwargs.get('index', False)):
            binary_index = set(dataframe.index).pop()
        else:
            binary_index = kwargs.get('index', None)

        binary = cls(index=binary_index,
                     star_1=SingleStar(**star1_params),
                     star_2=SingleStar(**star2_params),
                     **binary_params)

        # set extra history columns directly
        for key, val in extra_params.items():
            setattr(binary, key, val)

        # set some orbital parameters that should exist by hand
        bp_keys = binary_params.keys()
        if 'eccentricity_history' not in bp_keys:
            setattr(binary, 'eccentricity_history', [0]*history_length)
            setattr(binary, 'eccentricity', 0)
        if ('separation_history' not in bp_keys
                and 'orbital_period_history' in bp_keys):
            separation = orbital_separation_from_period(
                            np.array(binary.orbital_period_history),
                            np.array(binary.star_1.mass_history),
                            np.array(binary.star_2.mass_history),)
            setattr(binary, 'separation_history', list(separation))
            setattr(binary, 'separation', list(separation)[-1])
        if ('orbital_period_history' not in bp_keys
                and 'seperation_history' in bp_keys):
            period = orbital_period_from_separation(
                            np.array(binary.seperation_history),
                            np.array(binary.star_1.mass_history),
                            np.array(binary.star_2.mass_history),)
            setattr(binary, 'orbital_period_history', list(period))
            setattr(binary, 'orbital_period', list(period)[-1])

        # set the binary, star1, star2 parameters to last history value in df
        for params, pointer in zip(
                [star1_params,  star2_params,  binary_params],
                [binary.star_1, binary.star_2, binary]):
            for key, val in params.items():
                setattr(pointer, key.split('_history')[0], val[-1])

        # make BINARYPROPERTIES, history columns same length if not given
        already_included_cols = [name.split('_history')[0]
                                 for name in binary_params.keys()]
        valid_binaryprop_keys = [p for p in BINARYPROPERTIES
                                 if (p not in already_included_cols)]
        for prop_key in valid_binaryprop_keys:
            last_default_value = getattr(binary, prop_key+'_history')[-1]
            len_default = len(getattr(binary, prop_key+'_history'))
            diff = history_length - len_default
            getattr(binary, prop_key+'_history').extend([last_default_value]
                                                        * diff)

        # make STARPROPERTIES, history columns same length if not given
        for params, star in zip(
                [star1_params, star2_params], [binary.star_1, binary.star_2]):
            already_included_cols = [name.split('_history')[0]
                                     for name in params.keys()]
            valid_starprop_keys = [p for p in STARPROPERTIES
                                   if not(p in already_included_cols)]
            for prop_key in valid_starprop_keys:
                last_default_value = getattr(star, prop_key+'_history')[-1]
                len_default = len(getattr(star, prop_key+'_history'))
                diff = history_length - len_default
                getattr(star, prop_key+'_history').extend([last_default_value]
                                                          * diff)

        return binary

    def to_oneline_df(self, scalar_names=[], history=True, **kwargs):
        """Convert binary into a single row DataFrame."""
        if history:
            bin_kwargs = kwargs.copy()
            bin_kwargs['include_S1'] = False
            bin_kwargs['include_S2'] = False
            output_df = self.to_df(**bin_kwargs)
            initial_final_data = output_df.values[[0, -1], :]  # first/last row
            col_names = list(output_df.columns)

            oneline_names = (['binary_index']
                             + [s + '_i' for s in col_names]
                             + [s + '_f' for s in col_names])

            oneline_data = [self.index] + [d for d in
                                           initial_final_data.flatten()]

            bin_df = pd.DataFrame(data=[oneline_data], columns=oneline_names)
        else:
            bin_df = pd.DataFrame()

        s1_kwargs = kwargs.get('S1_kwargs', {})
        if bool(s1_kwargs):
            s1_df = self.star_1.to_oneline_df(prefix='S1_', **s1_kwargs)
        else:
            s1_df = pd.DataFrame()

        s2_kwargs = kwargs.get('S2_kwargs', {})
        if bool(s2_kwargs):
            s2_df = self.star_2.to_oneline_df(prefix='S2_', **s2_kwargs)
        else:
            s2_df = pd.DataFrame()

        oneline_df = pd.concat([bin_df, s1_df, s2_df], axis=1)

        for name in scalar_names:
            if hasattr(self, name):
                oneline_df[name] = [getattr(self, name)]

        # check for variables set in BinaryPopulation handling safe evolution
        if hasattr(self, 'traceback'):
            oneline_df['FAILED'] = [1]
        else:
            oneline_df['FAILED'] = [0]
        if hasattr(self, 'warning_message'):
            oneline_df['WARNING'] = [1]
        else:
            oneline_df['WARNING'] = [0]

        oneline_df.set_index('binary_index', inplace=True)
        
        # try to coerce data types automatically
        oneline_df = oneline_df.infer_objects()

        # Set data types for all columns explicitly
        # we are assuming you may pass the same kwargs to both to_df and oneline
        extra_binary_cols_dict = kwargs.get('extra_columns', {})
        extra_s1_cols_dict = kwargs.get('S1_kwargs', {}).get('extra_columns', {})
        extra_s2_cols_dict = kwargs.get('S2_kwargs', {}).get('extra_columns', {})
        oneline_df = clean_binary_oneline_df(oneline_df,
                                            extra_binary_dtypes_user=extra_binary_cols_dict,
                                            extra_S1_dtypes_user=extra_s1_cols_dict,
                                            extra_S2_dtypes_user=extra_s2_cols_dict)
        
        return oneline_df

    @classmethod
    def from_oneline_df(cls, oneline_df, **kwargs):
        """Convert a oneline DataFrame into a BinaryStar.

        The oneline DataFrame is expected to have initial-final
        values from history and any individual values that don't have
        histories.

        Parameters
        ----------
        oneline_df : DataFrame
            A oneline DataFrame describing a binary.
        index : int, None
            Binary index
        extra_columns : dict
            Names of any extra history columns not inlcuded
            in BINARYPROPERTIES

        Returns
        -------
            A new BinaryStar instance.

        """
        if isinstance(oneline_df, pd.Series):
            oneline_df = pd.DataFrame(oneline_df.to_dict(), index=[0])

        binary_params, star1_params, star2_params = dict(), dict(), dict()
        extra_params = dict()
        extra_columns = kwargs.get('extra_columns', {})
        hist_lengths = []
        for name in list(oneline_df.columns):
            if '_f' in name[-2:]:
                continue    # ignore final values
            param_name = name.split('_i')[0]

            special_cases = ['natal_kick_array']
            if any([i in param_name for i in special_cases]):
                continue    # deal with special cases later

            # ignore error and warning
            if name in ['FAILED', 'WARNING']:
                continue

            if 'S1' in name:
                param_name = param_name.split('S1_')[-1]
                ending_str = '_history' if param_name in STARPROPERTIES else ''
                star1_params[param_name + ending_str] = list(oneline_df[name])
                hist_lengths.append(len(list(oneline_df[name])))
            elif 'S2' in name:
                param_name = param_name.split('S2_')[-1]
                ending_str = '_history' if param_name in STARPROPERTIES else ''
                star2_params[param_name + ending_str] = list(oneline_df[name])
                hist_lengths.append(len(list(oneline_df[name])))
            elif param_name in extra_columns:
                # this assumes extra cols in binary, not star 1 or 2
                extra_params[param_name] = list(oneline_df[name])
                hist_lengths.append(len(list(oneline_df[name])))
            else:
                # binary
                ending_str = ('_history'
                              if param_name in BINARYPROPERTIES else '')
                binary_params[param_name + ending_str] = list(oneline_df[name])
                hist_lengths.append(len(list(oneline_df[name])))

        # make sure all history columns have equal length
        assert len(set(hist_lengths)) == 1
        history_length = set(hist_lengths).pop()

        if any(['S1_natal_kick_array' in name for name in oneline_df.columns]):
            natalkick_names = ['S1_natal_kick_array_{}'.format(i)
                               for i in range(4)]
            star1_params['natal_kick_array'] = \
                oneline_df[natalkick_names].values

        if any(['S2_natal_kick_array' in name for name in oneline_df.columns]):
            natalkick_names = ['S2_natal_kick_array_{}'.format(i)
                               for i in range(4)]
            star2_params['natal_kick_array'] = \
                oneline_df[natalkick_names].values

        if ('binary_index' in oneline_df.index.name
                and not kwargs.get('index', None)):
            binary_index = set(oneline_df.index).pop()
        else:
            binary_index = kwargs.get('index', None)

        binary = cls(index=binary_index,
                     star_1=SingleStar(**star1_params),
                     star_2=SingleStar(**star2_params),
                     **binary_params)

        # set extra history columns directly
        for key, val in extra_params.items():
            setattr(binary, key, val)

        # set some orbital parameters that should exist by hand
        bp_keys = binary_params.keys()
        if 'eccentricity_history' not in bp_keys:
            setattr(binary, 'eccentricity_history', [0])
            setattr(binary, 'eccentricity', 0)
        if ('separation_history' not in bp_keys
                and 'orbital_period_history' in bp_keys):
            separation = orbital_separation_from_period(
                            np.array(binary.orbital_period_history),
                            np.array(binary.star_1.mass_history),
                            np.array(binary.star_2.mass_history),)
            setattr(binary, 'separation_history', list(separation))
            setattr(binary, 'separation', list(separation)[-1])
        if ('orbital_period_history' not in bp_keys
                and 'seperation_history' in bp_keys):
            period = orbital_period_from_separation(
                            np.array(binary.seperation_history),
                            np.array(binary.star_1.mass_history),
                            np.array(binary.star_2.mass_history),)
            setattr(binary, 'orbital_period_history', list(period))
            setattr(binary, 'orbital_period', list(period)[-1])

        # set the binary, star1, star2 parameters to last history value in df
        for params, pointer in zip(
                [star1_params,  star2_params,  binary_params],
                [binary.star_1, binary.star_2, binary]):
            for key, val in params.items():
                setattr(pointer, key.split('_history')[0], val[-1])

        # make BINARYPROPERTIES, history columns same length if not given
        already_included_cols = [name.split('_history')[0]
                                 for name in binary_params.keys()]
        valid_binaryprop_keys = [p for p in BINARYPROPERTIES
                                 if not(p in already_included_cols)]
        for prop_key in valid_binaryprop_keys:
            last_default_value = getattr(binary, prop_key+'_history')[-1]
            len_default = len(getattr(binary, prop_key+'_history'))
            diff = history_length - len_default
            getattr(binary, prop_key+'_history').extend([last_default_value]
                                                        * diff)

        # if the binary errored, then set the final event
        if bool(oneline_df['FAILED'].values[-1]):
            setattr(binary, 'event', 'FAILED')
        return binary

    def __repr__(self):
        """Return the object representation when print is called."""
        s = '<{}.{} at {}>\n'.format(self.__class__.__module__,
                                     self.__class__.__name__, hex(id(self)))
        if hasattr(self, "error_message"):
            s += "BINARY FAILED: {}\n".format(self.error_message)
        if hasattr(self, "warning_message"):
            s += "WARNING FOUND: {}\n".format(self.warning_message)
        for p in BINARYPROPERTIES:
            s += '{}: {}\n'.format(p, getattr(self, p))
        for star in (self.star_1, self.star_2):
            s += '\n{}\n'.format(star)
        return s[:-1]

    def __str__(self):
        """Get a printable description of the binary star."""
        s = ''
        gap = ', '
        for name in ['state', 'event']:
            s += str(getattr(self, name)) + gap

        def nan_if_not_int_or_float(value):
            """Return nan if `value` is neither int nor float."""
            if isinstance(value, (float, int)):
                return value
            return np.nan

        orb_p = nan_if_not_int_or_float(self.orbital_period)
        m1 = nan_if_not_int_or_float(self.star_1.mass)
        m2 = nan_if_not_int_or_float(self.star_2.mass)

        s += 'p={0:.2f}'.format(orb_p) + gap
        s += 'S1=({0},M={1:.2f})'.format(self.star_1.state, m1) + gap
        s += 'S2=({0},M={1:.2f})'.format(self.star_2.state, m2)
        return 'BinaryStar(' + s + ')'

    @staticmethod
    def from_run(run, history=False, profiles=False):
        """Create a BinaryStar object from a PSyGrid run."""
        binary = BinaryStar()

        # get the data for the binary
        if run.binary_history is not None:
            n_steps = len(run.binary_history["age"]) if history else 1
            bh_colnames = run.binary_history.dtype.names
            for attr in BINARYPROPERTIES:
                colname = BINARY_ATTRIBUTES_FROM_HISTORY.get(attr, attr)
                if colname is not None and colname in bh_colnames:
                    final_value = run.final_values[colname]
                    if history:
                        col_history = list(run.binary_history[colname])
                    else:
                        col_history = [final_value]
                else:
                    final_value = None
                    col_history = [None] * n_steps
                assert n_steps == len(col_history)
                setattr(binary, attr + "_history", col_history)
                setattr(binary, attr, final_value)
        else:
            return binary    # if no binary history, return defaults

        # get the data for each companion star
        for star_history, star, prefix in zip([run.history1, run.history2],
                                              [binary.star_1, binary.star_2],
                                              ["S1", "S2"]):
            if star_history is not None:
                h_colnames = star_history.dtype.names
                for attr in STARPROPERTIES:
                    # get the corresponding column name (default=same name)
                    colname = STAR_ATTRIBUTES_FROM_STAR_HISTORY.get(attr, attr)
                    if colname is not None and colname in h_colnames:
                        final_value = run.final_values[prefix + "_" + colname]
                        if history:
                            col_history = list(star_history[colname])
                        else:
                            col_history = [final_value]
                    else:
                        final_value = None
                        col_history = [None] * n_steps
                    assert n_steps == len(col_history)
                    setattr(star, attr + "_history", col_history)
                    setattr(star, attr, final_value)

        # set metallicities (if defined in the track)...
        try:
            metallicity = run.initial_values["Z"]
        except AttributeError:
            metallicity = None
        # ...and other star parameters taken from the binary history
        for star_index, star in enumerate([binary.star_1, binary.star_2]):
            star.metallicity = metallicity
            star.metallicity_history = [metallicity] * n_steps
            for attr, colnames in STAR_ATTRIBUTES_FROM_BINARY_HISTORY.items():
                colname = colnames[star_index]
                final_value = run.final_values[colname]
                if history:
                    col_history = list(run.binary_history[colname])
                else:
                    col_history = [final_value]
                assert n_steps == len(col_history)
                setattr(star, attr, final_value)
                setattr(star, attr + "_history", col_history)

        # update eccentricity
        binary.eccentricity = 0.0
        binary.eccentricity_history = [0.0] * n_steps

        # update star states
        n_history = len(binary.time_history)
        for star, track in zip([binary.star_1, binary.star_2],
                               [run.history1, run.history2]):
            is_CO = track is None
            state_history = [check_state_of_star(star, i=i, star_CO=is_CO)
                             for i in range(n_history)]
            star.state_history = state_history
            star.state = state_history[-1]

        # update binary state, event and MT case
        binary.state_history = []
        binary.event_history = []
        binary.mass_transfer_case_history = []
        for i in range(n_history):  # step-by-step: previous states matter!
            result = get_binary_state_and_event_and_mt_case(binary, i=i)
            binary.state, binary.event, binary.mass_transfer_case = result
            binary.state_history.append(binary.state)
            binary.event_history.append(binary.event)
            binary.mass_transfer_case_history.append(binary.mass_transfer_case)

        if profiles:
            binary.star_1.profile = run.final_profile1
            binary.star_2.profile = run.final_profile2

        return binary
