abstochkin._simulation_methods API documentation

# abstochkin._simulation_methods

View Source

       1#  Copyright (c) 2024-2025, Alex Plakantonakis.
       2#
       3#  This program is free software: you can redistribute it and/or modify
       4#  it under the terms of the GNU General Public License as published by
       5#  the Free Software Foundation, either version 3 of the License, or
       6#  (at your option) any later version.
       7#
       8#  This program is distributed in the hope that it will be useful,
       9#  but WITHOUT ANY WARRANTY; without even the implied warranty of
      10#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
      11#  GNU General Public License for more details.
      12#
      13#  You should have received a copy of the GNU General Public License
      14#  along with this program.  If not, see <http://www.gnu.org/licenses/>.
      15
      16import contextlib
      17from concurrent.futures import ThreadPoolExecutor
      18from copy import deepcopy
      19from functools import partial
      20
      21import numpy as np
      22
      23from .agentstatedata import AgentStateData
      24from .het_calcs import idx_het
      25from .process import Process, MichaelisMentenProcess, ReversibleProcess, \
      26    RegulatedProcess, RegulatedMichaelisMentenProcess
      27from .utils import r_squared
      28
      29
      30class SimulationMethodsMixin:
      31    """ Mixin class with methods necessary for running an AbStochKin simulation. """
      32
      33    def _validate_p0(self):
      34        """ A couple of assertions regarding initial population sizes. """
      35        assert len(self.p0) == len(self.all_species), \
      36            f"Specification of initial population sizes does not match number of species.\n" \
      37            f"len(p0)={len(self.p0)} , len(all_species)={len(self.all_species)}"
      38        assert all([True if y0 >= 0 else False for y0 in self.p0.values()]), \
      39            "An initial population size cannot be negative."
      40
      41    def _setup_data(self):
      42        """
      43        Initialize runtime data and `results` dictionary for all species.
      44        Also initialize process-specific `k` values (`k_vals`),
      45        transition probabilities (`trans_p`), and metrics of heterogeneity
      46        (`k_het_metrics`) dictionaries.
      47        """
      48        self._setup_runtime_data()
      49
      50        for sp in self.all_species:
      51            self.results[sp] = {
      52                'N': np.empty((self.t_steps + 1, self.n), dtype=np.uint64),
      53                'N_avg': np.empty(self.t_steps + 1, dtype=np.float64),
      54                'N_std': np.empty(self.t_steps + 1, dtype=np.float64),
      55                'eta': np.empty(self.t_steps + 1, dtype=np.float64),  # CoV
      56                'eta_p': np.empty(self.t_steps + 1, dtype=np.float64),  # Poisson CoV
      57                'R^2': None
      58            }
      59            self.results[sp]['N'][0] = self.p0[sp]
      60
      61        """ Construct the time-independent PHM.
      62            Calculate k and transition probability values (done just once). """
      63        for proc in self._algo_processes:
      64            if proc.order == 0:
      65                self.trans_p[proc] = self._get_o0_trans_p(proc)
      66            elif proc.order == 1:
      67                self.k_vals[proc], self.trans_p[proc] = self._init_o1_vals(proc)
      68
      69                if isinstance(proc, (MichaelisMentenProcess, RegulatedMichaelisMentenProcess)):
      70                    self.Km_vals[proc] = self._init_o1mm_Km_vals(proc)
      71                if isinstance(proc, (RegulatedProcess, RegulatedMichaelisMentenProcess)):
      72                    self.K50_vals[proc] = self._init_o1reg_K50_vals(proc)
      73            else:  # order == 2
      74                self.k_vals[proc], self.trans_p[proc] = self._init_o2_vals(proc)
      75
      76                if isinstance(proc, RegulatedProcess):
      77                    self.K50_vals[proc] = self._init_o2reg_K50_vals(proc)
      78
      79        self._init_het_metrics()
      80
      81    def _setup_runtime_data(self):
      82        """ Initialize runtime data (`rtd`) dictionaries for all species. """
      83
      84        """ The following is an arbitrary multiplier to account for
      85            population size fluctuations and should be sufficient for most
      86            cases. The multiplier may need to be adjusted in some cases. """
      87        if len(self.max_agents) == 0:
      88            """ Strategy 1: Set the number of possible agents for each
      89                species based on the maximum value of the ODE solution for
      90                the species times the multiplier. """
      91            for i, sp in enumerate(self.de_calcs.odes.keys()):
      92                if sp in self._procs_by_reactant.keys() or sp in self._procs_by_product.keys():
      93                    sp_max_agents = np.ceil(
      94                        self.max_agents_multiplier * np.max(self.de_calcs.odes_sol.y[i]))
      95                else:  # for species whose population size does not change (eg, a catalyst)
      96                    sp_max_agents = np.ceil(np.max(self.de_calcs.odes_sol.y[i]))
      97
      98                self.rtd[sp] = AgentStateData(self.p0[sp], int(sp_max_agents), self.n,
      99                                              fill_state=self._get_fill_state(sp))
     100                self.max_agents[sp] = int(sp_max_agents)
     101
     102            """ Strategy 2: Find the maximum from the ODE trajectories of all
     103                species and set the number of possible agents for all species
     104                to that value times the multiplier. This is an arguably
     105                simplistic approach that may result in unnecessary memory (and cpu)
     106                usage, however it should be sufficient for most simple systems. """
     107            # num_agents = int(np.ceil(np.max(self.de_calcs.odes_sol.y))) * max_agents_multiplier
     108            # for sp in self.all_species:
     109            #     self.rtd[sp] = AgentStateData(self.p0[sp], num_agents, self.n,
     110            #                                   fill_state=self._get_fill_state(sp))
     111            #     self.max_agents[sp] = num_agents
     112        else:
     113            """ Strategy 3: Let the user specify the number of possible agents
     114                for each species. After examination of the deterministic
     115                trajectories and underlying dynamics, it may sometimes
     116                be preferred to simply specify the max agents for each species. """
     117            assert len(self.max_agents) == len(self.all_species), \
     118                "Must specify the maximum number of agents for all species."
     119            for sp in self.all_species:
     120                self.rtd[sp] = AgentStateData(self.p0[sp], self.max_agents[sp], self.n,
     121                                              fill_state=self._get_fill_state(sp))
     122
     123    def _get_fill_state(self, species_name):
     124        """
     125        When setting up the initial agent-state vector (`asv`) of a
     126        species, agents that are part of the initial population
     127        have a state of 1 (they are 'alive'). The remaining
     128        members of the `asv` array are filled with state 0, or -1
     129        if the species is the product of any 0th order processes.
     130
     131        Returns
     132        -------
     133        fill_state
     134            0 or -1
     135
     136        Notes
     137        -----
     138        There was a need in an earlier version of the algorithm
     139        to distinguish between agents that have never been created
     140        through a 0th order process (or born), and those that have
     141        been previously born but converted to another species.
     142        The convention we adopted then is to assign the former
     143        a state of -1 and the latter a state of 0. We are keeping
     144        this convention here, although it's not strictly necessary
     145        for the latest version of the algorithm.
     146        """
     147        f_state = 0
     148        if species_name in self._procs_by_product.keys():
     149            if 0 in [proc.order for proc in self._procs_by_product[species_name]]:
     150                f_state = -1
     151        return f_state
     152
     153    def _init_het_metrics(self):
     154        """ Initialize heterogeneity metrics as a list of tuples
     155        to be converted to a dictionary for each process. """
     156        # Initialize heterogeneity metrics (for k values) as a list of tuples ...
     157        # to be converted to a dictionary for each process of a species.
     158        k_metrics_init = [('k_avg', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     159                          ('k_std', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     160                          ('psi', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     161                          ('<k_avg>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     162                          ('<k_std>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     163                          ('psi_avg', np.zeros(self.t_steps + 1, dtype=np.float64)),
     164                          ('psi_std', np.zeros(self.t_steps + 1, dtype=np.float64))]
     165
     166        Km_metrics_init = [('Km_avg', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     167                           ('Km_std', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     168                           ('psi', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     169                           ('<Km_avg>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     170                           ('<Km_std>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     171                           ('psi_avg', np.zeros(self.t_steps + 1, dtype=np.float64)),
     172                           ('psi_std', np.zeros(self.t_steps + 1, dtype=np.float64))]
     173
     174        K50_metrics_init = [
     175            ('K50_avg', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     176            ('K50_std', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     177            ('psi', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     178            ('<K50_avg>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     179            ('<K50_std>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     180            ('psi_avg', np.zeros(self.t_steps + 1, dtype=np.float64)),
     181            ('psi_std', np.zeros(self.t_steps + 1, dtype=np.float64))]
     182
     183        for procs in self._procs_by_reactant.values():
     184            # Compute heterogeneity metrics at t=0
     185            for proc in procs:
     186                if proc.is_heterogeneous:
     187                    k_metrics = deepcopy(k_metrics_init)
     188
     189                    if proc.order == 1:
     190                        temp_k_vals = self.k_vals[proc] * (
     191                                self.rtd[proc.reacts_[0]].asv_ini[0, :] > 0)
     192                    else:  # order == 2
     193                        nonzero_rows = (self.rtd[proc.reacts_[0]].asv_ini[1, :] > 0).reshape(-1, 1)
     194                        nonzero_cols = self.rtd[proc.reacts_[1]].asv_ini[1, :] > 0
     195                        temp_k_vals = self.k_vals[proc] * nonzero_rows * nonzero_cols
     196
     197                    nonzero_k_vals = temp_k_vals[np.nonzero(temp_k_vals)]
     198
     199                    if nonzero_k_vals.size != 0:
     200                        for i, stat_fcn in enumerate([np.mean, np.std, idx_het]):
     201                            k_metrics[i][1][0, :] = stat_fcn(nonzero_k_vals)
     202
     203                    self.k_het_metrics[proc] = dict(k_metrics)
     204
     205                if isinstance(proc, (MichaelisMentenProcess, RegulatedMichaelisMentenProcess)):
     206                    if proc.is_heterogeneous_Km:
     207                        Km_metrics = deepcopy(Km_metrics_init)
     208
     209                        # Only 1st order Michaelis-Menten processes are currently defined
     210                        temp_Km_vals = self.Km_vals[proc] * (
     211                                self.rtd[proc.reacts_[0]].asv_ini[0, :] > 0)
     212
     213                        nonzero_Km_vals = temp_Km_vals[np.nonzero(temp_Km_vals)]
     214
     215                        if nonzero_Km_vals.size != 0:
     216                            for i, stat_fcn in enumerate([np.mean, np.std, idx_het]):
     217                                Km_metrics[i][1][0, :] = stat_fcn(nonzero_Km_vals)
     218
     219                        self.Km_het_metrics[proc] = dict(Km_metrics)
     220
     221                if isinstance(proc, (RegulatedProcess, RegulatedMichaelisMentenProcess)):
     222                    if isinstance(proc.regulating_species, list):  # multiple regulating species
     223                        self.K50_het_metrics[proc] = list()
     224
     225                        for w, Kval in enumerate(proc.K50):
     226                            if proc.is_heterogeneous_K50[w]:
     227                                K50_metrics = deepcopy(K50_metrics_init)
     228                                if proc.order == 1:
     229                                    temp_K50_vals = self.K50_vals[proc][w] * (
     230                                            self.rtd[proc.reacts_[0]].asv_ini[0, :] > 0)
     231                                else:  # order == 2
     232                                    nonzero_rows = (self.rtd[proc.reacts_[0]].asv_ini[1, :] > 0).reshape(-1, 1)
     233                                    nonzero_cols = self.rtd[proc.reacts_[1]].asv_ini[1, :] > 0
     234                                    temp_K50_vals = self.K50_vals[proc][w] * nonzero_rows * nonzero_cols
     235
     236                                nonzero_K50_vals = temp_K50_vals[np.nonzero(temp_K50_vals)]
     237
     238                                if nonzero_K50_vals.size != 0:
     239                                    for i, stat_fcn in enumerate([np.mean, np.std, idx_het]):
     240                                        K50_metrics[i][1][0, :] = stat_fcn(nonzero_K50_vals)
     241
     242                                self.K50_het_metrics[proc].append(dict(K50_metrics))
     243                            else:
     244                                self.K50_het_metrics[proc].append(None)
     245
     246                    else:  # only one regulating species
     247                        if proc.is_heterogeneous_K50:
     248                            K50_metrics = deepcopy(K50_metrics_init)
     249
     250                            if proc.order == 1:
     251                                temp_K50_vals = self.K50_vals[proc] * (
     252                                        self.rtd[proc.reacts_[0]].asv_ini[0, :] > 0)
     253                            else:  # order == 2
     254                                nonzero_rows = (self.rtd[proc.reacts_[0]].asv_ini[1, :] > 0).reshape(-1, 1)
     255                                nonzero_cols = self.rtd[proc.reacts_[1]].asv_ini[1, :] > 0
     256                                temp_K50_vals = self.K50_vals[proc] * nonzero_rows * nonzero_cols
     257
     258                            nonzero_K50_vals = temp_K50_vals[np.nonzero(temp_K50_vals)]
     259
     260                            if nonzero_K50_vals.size != 0:
     261                                for i, stat_fcn in enumerate([np.mean, np.std, idx_het]):
     262                                    K50_metrics[i][1][0, :] = stat_fcn(nonzero_K50_vals)
     263
     264                            self.K50_het_metrics[proc] = dict(K50_metrics)
     265
     266    def _get_o0_trans_p(self, proc: Process | RegulatedProcess):
     267        """ Get the transition probability for a 0th order process. """
     268        return proc.k * self.dt
     269
     270    def _init_o1_vals(self, proc: Process):
     271        """
     272        Initialize the arrays for the rate constant and
     273        transition probability values for a 1st order process.
     274        The latter array is calculated from the former.
     275        The contents of the arrays depend on whether the process
     276        is homogeneous or heterogeneous.
     277
     278        - Homogeneous: The arrays consist of a single repeated value.
     279        - Heterogeneous: If the population is stratified with a distinct
     280          number of subspecies, then the array contents are partitioned
     281          in equal parts according to that number. Each part has the
     282          value that corresponds to each subspecies. Note that the
     283          order of the array contents is randomized.
     284          On the other hand, if the population is normally distributed
     285          with respect to its rate constant value, then this
     286          distribution is reflected in the array contents.
     287
     288        Notes
     289        -----
     290        Note that the **sample** mean and standard deviation of the
     291        generated values in the array will not be exactly the same as
     292        specified in `proc.k` in the case of normally-distributed
     293        heterogeneous populations.
     294
     295        Parameters
     296        ----------
     297        proc: Process
     298            Object of class `Process` whose attribute `order`
     299            is equal to `1` (i.e., a 1st order process).
     300
     301        Returns
     302        -------
     303        tuple
     304            k_vals : numpy array
     305                Contains the values of the rate constant for
     306                `num_agents` number of agents in a 1st order process.
     307            p_vals: numpy array
     308                Contains the values of the transition probabilities
     309                given a time interval `dt` for a 1st order process.
     310        """
     311        num_agents = self.max_agents[proc.reacts_[0]]
     312        if not proc.is_heterogeneous:  # homogeneous process: one k value
     313            k_vals = np.full(num_agents, proc.k)
     314        else:  # heterogeneous process
     315            if isinstance(proc.k, list):  # distinct subspecies
     316                k_vals = self._gen_o1_distinct_subspecies_vals(proc, num_agents)
     317            else:  # proc.k is a tuple: mean, std of normal distribution
     318                k_vals = self._gen_o1_normal_vals(proc, num_agents)
     319
     320        p_vals = 1 - np.exp(-1 * k_vals * self.dt)
     321
     322        return k_vals, p_vals
     323
     324    def _init_o1mm_Km_vals(self, proc: MichaelisMentenProcess):
     325        """
     326        Initialize the array for the Km values for a
     327        1st order Michaelis-Menten process.
     328        The contents of the array depends on whether the process
     329        is homogeneous or heterogeneous.
     330
     331        - Homogeneous: The array consists of a single repeated value.
     332        - Heterogeneous: If the population is stratified with a distinct
     333          number of subspecies, then the array contents are partitioned
     334          in equal parts according to that number. Each part has the
     335          value that corresponds to each subspecies. Note that the
     336          order of the array contents is randomized.
     337          On the other hand, if the population is normally distributed
     338          with respect to its Km value, then this
     339          distribution is reflected in the array contents.
     340        """
     341        num_agents = self.max_agents[proc.reacts_[0]]
     342        if not proc.is_heterogeneous_Km:  # homogeneous process: one Km value
     343            Km_vals = np.full(num_agents, proc.Km)
     344        else:  # heterogeneous process wrt Km
     345            if isinstance(proc.Km, list):  # distinct subspecies wrt Km
     346                Km_vals = self._gen_o1_distinct_subspecies_vals(proc, num_agents, het_attr='Km')
     347            else:
     348                # Assuming Km can only have integer values (output_type).
     349                Km_vals = self._gen_o1_normal_vals(proc, num_agents, het_attr='Km',
     350                                                   output_type=int)
     351
     352        return Km_vals
     353
     354    def _init_o1reg_K50_vals(self, proc: RegulatedProcess):
     355        """
     356        Initialize the array for the K50 values for a regulated
     357        1st order process.
     358        The contents of the array depends on whether the process
     359        is homogeneous or heterogeneous.
     360
     361        - Homogeneous: The array consists of a single repeated value.
     362        - Heterogeneous: If the population is stratified with a distinct
     363          number of subspecies, then the array contents are partitioned
     364          in equal parts according to that number. Each part has the
     365          value that corresponds to each subspecies. Note that the
     366          order of the array contents is randomized.
     367          On the other hand, if the population is normally distributed
     368          with respect to its Km value, then this
     369          distribution is reflected in the array contents.
     370        """
     371        num_agents = self.max_agents[proc.reacts_[0]]
     372
     373        if isinstance(proc.regulating_species, list):  # multiple regulating species
     374            K50_vals = list()
     375            for i, Kval in enumerate(proc.K50):
     376                if not proc.is_heterogeneous_K50[i]:  # homogeneous process: one K50 value
     377                    K50_vals.append(np.full(num_agents, Kval))
     378                else:  # heterogeneous process wrt K50
     379                    if isinstance(Kval, list):  # distinct subspecies wrt K50
     380                        K50_vals.append(self._gen_o1_distinct_subspecies_vals(proc, num_agents,
     381                                                                              het_attr='K50',
     382                                                                              het_attr_idx=i))
     383                    else:  # K50 is a 2-tuple: normally-distributed
     384                        # Assuming K50 can only have integer values (output_type).
     385                        K50_vals.append(self._gen_o1_normal_vals(proc, num_agents,
     386                                                                 het_attr='K50',
     387                                                                 het_attr_idx=i,
     388                                                                 output_type=int))
     389
     390        else:  # only one regulating species
     391            if not proc.is_heterogeneous_K50:  # homogeneous process: one K50 value
     392                K50_vals = np.full(num_agents, proc.K50)
     393            else:  # heterogeneous process wrt K50
     394                if isinstance(proc.K50, list):  # distinct subspecies wrt K50
     395                    K50_vals = self._gen_o1_distinct_subspecies_vals(proc, num_agents,
     396                                                                     het_attr='K50')
     397                else:  # K50 is a 2-tuple: normally-distributed
     398                    # Assuming K50 can only have integer values (output_type).
     399                    K50_vals = self._gen_o1_normal_vals(proc, num_agents,
     400                                                        het_attr='K50',
     401                                                        output_type=int)
     402
     403        return K50_vals
     404
     405    def _gen_o1_distinct_subspecies_vals(self,
     406                                         proc: Process | MichaelisMentenProcess | RegulatedProcess,
     407                                         num_agents: int,
     408                                         *,
     409                                         het_attr: str = 'k',
     410                                         het_attr_idx: int = None):
     411        """
     412        For a heterogeneous population, if the population is stratified
     413        with a distinct number of subspecies, then the array contents
     414        are partitioned in equal parts according to that number.
     415        Each part has the value that corresponds to each subspecies.
     416        Note that the order of the array contents is randomized.
     417
     418        Parameters
     419        ----------
     420        proc: Process or MichaelisMentenProcess or RegulatedProcess
     421            Object of class `Process` (or of a class whose parent
     422            class is `Parent`) whose attribute `order` is equal to
     423            `1` (i.e., a 1st order process).
     424        num_agents : int
     425            The number of elements in the array of values to be
     426            constructed.
     427        het_attr : str, default: 'k'
     428            Specification of the attribute of a process that exhibits
     429            heterogeneity. The default is the rate constant `k`.
     430        het_attr_idx : int, default: None
     431            For cases where the attribute of a process that exhibits
     432            heterogeneity is a list, this is the index of the element
     433            within the list that is desired.
     434
     435        Returns
     436        -------
     437        vals : numpy array
     438            Contains the values of `het_attr`. The size of the
     439            array is `num_agents`.
     440        """
     441        if het_attr != 'k':
     442            assert hasattr(proc, het_attr), f"{proc} does not have attribute {het_attr}."
     443
     444        attr = getattr(proc, het_attr)
     445        if isinstance(attr, list) and het_attr_idx is not None:
     446            attr = attr[het_attr_idx]
     447
     448        num_subspecies = len(attr)
     449        assert num_subspecies <= num_agents, \
     450            "The number of subspecies cannot be greater than the population size."
     451
     452        """ `vals` has shape (num_agents,). Note that `num_agents`
     453        is the same as `max_agents` when setting up the data. So, this
     454        sets up the `het_attr` values for all agents that may become 'alive'
     455        during a simulation. """
     456        vals = np.array([attr[h] for h in range(num_subspecies) for _ in
     457                         range(int(num_agents / num_subspecies))])
     458
     459        """ When the number of agents is not evenly divisible by
     460        the number of subspecies, `k_vals` has a shorter length than
     461        the required number of agents. That's because
     462        `int(num_agents / num_subspecies)` in the above comprehension
     463        returns the floor of the ratio. """
     464        if len(vals) != num_agents:
     465            num_missing = num_agents - len(vals)
     466            vals = np.append(vals, [attr[i] for i in range(num_missing)])
     467
     468        """ Agents in the initial population of the species are
     469        assigned `k_vals` sequentially within the array (starting from
     470        the beginning). This would over-represent the first (or more)
     471        subspecies at the expense of the subsequent ones.
     472        Thus, we must shuffle `k_vals` so that the collection of agents
     473        in the initial population *approximate* the desired composition
     474        of distinct subspecies. """
     475        np.random.default_rng(seed=self.random_state).shuffle(vals)
     476
     477        return vals
     478
     479    def _gen_o1_normal_vals(self,
     480                            proc: Process | MichaelisMentenProcess | RegulatedProcess,
     481                            num_agents: int,
     482                            *,
     483                            het_attr: str = 'k',
     484                            het_attr_idx: int = None,
     485                            output_type: type[float | int] = float,
     486                            max_tries: int = 1000):
     487        """
     488        Generate an array of values sampled from a normal distribution.
     489        Make sure there are no negative values in generated array.
     490
     491        Parameters
     492        ----------
     493        *
     494        proc: Process or MichaelisMentenProcess or RegulatedProcess
     495            Object of class `Process` whose attribute `order`
     496            is equal to `1` (i.e., a 1st order process).
     497        num_agents: int
     498            Defines the length of the desired array.
     499            Effectively represents the number of agents that
     500            *could* participate in process `proc` during the
     501            simulation.
     502        het_attr : str, default: 'k'
     503            Specification of the process attribute that is
     504            normally-distributed. The default, `k`, is the
     505            rate constant of the process.
     506        het_attr_idx : int, default: None
     507            For cases where the attribute of a process that exhibits
     508            heterogeneity is a list, this is the index of the element
     509            within the list that is desired.
     510        output_type : type, default: float
     511            The type of the numbers that will populate the output array.
     512            The default is `float`, but `int` may sometimes be desired.
     513        max_tries : int, default: 1000
     514            Maximum number of tries to generate the array
     515            without any negative values. If this number is
     516            exceeded, an exception is raised.
     517
     518        Returns
     519        -------
     520        vals : numpy array
     521            Contains the normally-distributed values of `het_attr` for
     522            `num_agents` number of agents in a 1st order process.
     523            If the array could not be generated after the maximum number
     524            of tries, then an exception is raised.
     525
     526        """
     527        if het_attr != 'k':
     528            assert hasattr(proc, het_attr), f"{proc} does not have attribute {het_attr}."
     529
     530        attr = getattr(proc, het_attr)
     531        if isinstance(attr, list) and het_attr_idx is not None:
     532            attr = attr[het_attr_idx]
     533
     534        rng = np.random.default_rng(seed=self.random_state)
     535
     536        tries = 0
     537        while tries <= max_tries:
     538            vals = rng.normal(*attr, num_agents).astype(output_type)
     539            if not np.any(vals <= 0):
     540                return vals
     541            else:
     542                tries += 1
     543        else:
     544            err_msg = f"Maximum number of tries ({max_tries}) to get non-negative " \
     545                      f"{het_attr} values in simulated process {proc.__str__()} " \
     546                      f"has been exceeded. Please reconsider the " \
     547                      f"distribution of {het_attr} values for this process."
     548            raise AssertionError(err_msg)
     549
     550    def _init_o2_vals(self, proc: Process):
     551        """
     552        Initialize the arrays for the rate constant and
     553        transition probability values for a 2nd order process.
     554        The latter array is calculated from the former.
     555        The contents of the arrays depend on whether the process
     556        is homogeneous or heterogeneous.
     557
     558        - Homogeneous: The arrays consist of a single repeated value.
     559        - Heterogeneous: If the population is stratified with a distinct
     560          number of subspecies, then the array contents are partitioned
     561          in equal parts according to that number. Each part has the
     562          value that corresponds to each subspecies. Note that the
     563          order of the array contents is randomized.
     564          On the other hand, if the population is normally distributed
     565          with respect to its rate constant value, then this
     566          distribution is reflected in the array contents.
     567
     568        Notes
     569        -----
     570        Note that the **sample** mean and standard deviation of the
     571        generated values in the array will not be exactly the same as
     572        specified in `proc.k` in the case of normally-distributed
     573        heterogeneous populations.
     574
     575        Parameters
     576        ----------
     577        proc: Process
     578            Object of class `Process` whose attribute `order`
     579            is equal to `2` (i.e., a 2nd order process).
     580
     581        Returns
     582        -------
     583        tuple
     584            k_vals : numpy array
     585                Contains the values of the rate constant for
     586                `num_agents` number of agents in a 1st order process.
     587            p_vals: numpy array
     588                Contains the values of the transition probabilities
     589                given a time interval `dt` for a 1st order process.
     590        """
     591
     592        """ phm_shape is a tuple of integers that
     593        defines the shape of the population heterogeneity matrix
     594        (i.e., the `k_vals` and, by extension, `p_vals` arrays).
     595        Effectively represents the number of agents that
     596        *could* participate in process `proc` during the
     597        simulation. For example, for the process A + B -> C,
     598        the number of rows and columns correspond to the number
     599        of agents of species A and B respectively. """
     600        phm_shape = (self.max_agents[proc.reacts_[0]],
     601                     self.max_agents[proc.reacts_[1]])
     602
     603        if not proc.is_heterogeneous:  # homogeneous process: one k value
     604            k_vals = np.full(phm_shape, proc.k)
     605        else:  # heterogeneous process
     606            if isinstance(proc.k, list):  # distinct subinteractions
     607                k_vals = self._gen_o2_distinct_subinteractions_vals(proc, phm_shape)
     608            else:  # proc.k is a tuple: mean, std of normal distribution
     609                k_vals = self._gen_o2_normal_vals(proc, phm_shape)
     610
     611        # If reactants are the same (eg: A + A -> C, a homologous 2nd order
     612        # process), then make the interaction of an agent with itself impossible.
     613        if len(set(proc.reacts_)) == 1:
     614            np.fill_diagonal(k_vals, 0)  # make diagonal entries zero
     615
     616        p_vals = 1 - np.exp(-1 * k_vals * self.dt)
     617
     618        return k_vals, p_vals
     619
     620    def _init_o2reg_K50_vals(self, proc: RegulatedProcess):
     621        """
     622        Initialize the array for the K50 values for a regulated
     623        2nd order process.
     624        The contents of the array depends on whether the process
     625        is homogeneous or heterogeneous.
     626
     627        - Homogeneous: The array consists of a single repeated value.
     628        - Heterogeneous: If the population is stratified with a distinct
     629          number of subspecies, then the array contents are partitioned
     630          in equal parts according to that number. Each part has the
     631          value that corresponds to each subspecies. Note that the
     632          order of the array contents is randomized.
     633          On the other hand, if the population is normally distributed
     634          with respect to its K50 value, then this
     635          distribution is reflected in the array contents.
     636        """
     637        phm_shape = (self.max_agents[proc.reacts_[0]],
     638                     self.max_agents[proc.reacts_[1]])
     639
     640        if isinstance(proc.regulating_species, list):  # multiple regulating species
     641            K50_vals = list()
     642            for i, Kval in enumerate(proc.K50):
     643                if not proc.is_heterogeneous_K50[i]:  # homogeneous process: one K50 value
     644                    K50_vals.append(np.full(phm_shape, Kval))
     645                else:  # heterogeneous process wrt K50
     646                    if isinstance(Kval, list):  # distinct subspecies wrt K50
     647                        K50_vals.append(self._gen_o2_distinct_subinteractions_vals(proc,
     648                                                                                   phm_shape,
     649                                                                                   het_attr='K50',
     650                                                                                   het_attr_idx=i))
     651                    else:  # K50 is a 2-tuple: normally-distributed
     652                        # Assuming K50 can only have integer values (output_type).
     653                        K50_vals.append(self._gen_o2_normal_vals(proc,
     654                                                                 phm_shape,
     655                                                                 het_attr='K50',
     656                                                                 het_attr_idx=i,
     657                                                                 output_type=int))
     658
     659        else:  # only one regulating species
     660            if not proc.is_heterogeneous_K50:  # homogeneous process: one K50 value
     661                K50_vals = np.full(phm_shape, proc.K50)
     662            else:  # heterogeneous process wrt K50
     663                if isinstance(proc.K50, list):  # distinct subspecies wrt K50
     664                    K50_vals = self._gen_o2_distinct_subinteractions_vals(proc, phm_shape,
     665                                                                          het_attr='K50')
     666                else:  # K50 is a 2-tuple: normally-distributed
     667                    # Assuming K50 can only have integer values (output_type).
     668                    K50_vals = self._gen_o2_normal_vals(proc, phm_shape,
     669                                                        het_attr='K50', output_type=int)
     670
     671        return K50_vals
     672
     673    def _gen_o2_distinct_subinteractions_vals(self, proc: Process | RegulatedProcess,
     674                                              phm_shape: tuple[int, int],
     675                                              *,
     676                                              het_attr: str = 'k',
     677                                              het_attr_idx: int = None):
     678        """
     679        For a 2nd order process with distinct subinteractions between
     680        the reactant agents, as determined by the parameter `het_attr`,
     681        this function generates a 2-dimensional array of parameter values
     682        with the desired subinteraction parameter values.
     683
     684        Parameters
     685        ----------
     686        *
     687        proc : Process or RegulatedProcess
     688            The 2nd order process whose subinteractions are considered.
     689        phm_shape : tuple of int, int
     690            The shape of the population heterogeneity matrix (phm) or
     691            array to be generated in this function.
     692        het_attr : str, default: 'k'
     693            The heterogeneity attribute whose values are to be generated.
     694            The default is the rate constant `k`. Other possible attributes
     695            include `K50` for a regulated process.
     696        het_attr_idx : int, default: None
     697            For cases where the attribute of a process that exhibits
     698            heterogeneity is a list, this is the index of the element
     699            within the list that is desired.
     700
     701        Notes
     702        -----
     703        Note that the **sample** mean and standard deviation of the
     704        generated values in the array will not be exactly the same as
     705        expected based on the discrete number of subinteractions.
     706        """
     707        if het_attr != 'k':
     708            assert hasattr(proc, het_attr), f"{proc} does not have attribute {het_attr}."
     709
     710        attr = getattr(proc, het_attr)
     711        if isinstance(attr, list) and het_attr_idx is not None:
     712            attr = attr[het_attr_idx]
     713
     714        num_subinteractions = len(attr)
     715
     716        k_overflow = False
     717        if len(set(proc.reacts_)) == 1:  # If reactants are the same (eg: A + A -> C)
     718            if num_subinteractions > phm_shape[0] * phm_shape[1] - np.min(phm_shape):
     719                k_overflow = True
     720        else:
     721            if num_subinteractions > phm_shape[0] * phm_shape[1]:
     722                k_overflow = True
     723
     724        if k_overflow:
     725            raise AssertionError(
     726                "The number of subinteractions cannot be greater than the "
     727                "total number of possible inter-agent interactions.")
     728
     729        """ `k_vals` has shape (num_agents_1, num_agents_2).
     730        Note that `num_agents_*` is the same as `max_agents` when setting
     731        up the data for a given species. So, this sets up the `het_attr` values
     732        for all interactions that may become available during a
     733        simulation when agents become 'alive'. """
     734        rng = np.random.default_rng(seed=self.random_state)
     735        vals = rng.choice(attr, size=phm_shape, replace=True)
     736        return vals
     737
     738    def _gen_o2_normal_vals(self, proc: Process | RegulatedProcess,
     739                            phm_shape: tuple[int, int],
     740                            *,
     741                            het_attr: str = 'k',
     742                            het_attr_idx: int = None,
     743                            output_type: type[float | int] = float,
     744                            max_tries: int = 1000):
     745        """
     746        For a 2nd order process where the subinteractions between
     747        the reactant agents are normally distributed
     748        with respect to a kinetic parameter value, this function
     749        generates the 2-dimensional array of parameter values with
     750        the desired population mean and standard deviation.
     751
     752        Parameters
     753        ----------
     754        proc : Process or RegulatedProcess
     755            The 2nd order process whose subinteractions are considered.
     756        phm_shape : tuple of int, int
     757            The shape of the population heterogeneity matrix (phm) or
     758            array to be generated in this function.
     759        het_attr : str, default: 'k'
     760            The heterogeneity attribute whose values are to be generated.
     761            The default is the rate constant `k`. Other possible attributes
     762            include `K50` for a regulated process.
     763        het_attr_idx : int, default: None
     764            For cases where the attribute of a process that exhibits
     765            heterogeneity is a list, this is the index of the element
     766            within the list that is desired.
     767        output_type : type
     768            The type of the numbers that will populate the generated
     769            array. The default is `float`. For integer values, the
     770            specified type should be `int`.
     771        max_tries : int, default: 1000
     772            The maximum number of times to try generating the array while
     773            checking that no negative values are in the array given the
     774            population mean and standard deviation. If this number is
     775            exceeded, an exception is raised.
     776
     777        Notes
     778        -----
     779        Note that the **sample** mean and standard deviation of the
     780        generated values in the array will not be exactly the same as
     781        specified in the `het_attr` process parameter.
     782        """
     783        if het_attr != 'k':
     784            assert hasattr(proc, het_attr), f"{proc} does not have attribute {het_attr}."
     785
     786        attr = getattr(proc, het_attr)
     787        if isinstance(attr, list) and het_attr_idx is not None:
     788            attr = attr[het_attr_idx]
     789
     790        rng = np.random.default_rng(seed=self.random_state)
     791
     792        tries = 0
     793        while tries <= max_tries:
     794            vals = rng.normal(*attr, size=phm_shape).astype(output_type)
     795            if not np.any(vals.flatten() <= 0):
     796                return vals
     797            else:
     798                tries += 1
     799        else:
     800            err_msg = f"Maximum number of tries ({max_tries}) to get non-negative " \
     801                      f"{het_attr} values in simulated process {proc.__str__()} " \
     802                      f"has been exceeded. Please reconsider the " \
     803                      f"distribution of {het_attr} values for this process."
     804            raise AssertionError(err_msg)
     805
     806    def _gen_algo_processes(self, procs):
     807        """
     808        Generate the list of processes that the algorithm will use
     809        when running the simulation.
     810
     811        Notes
     812        -----
     813        Convert a reversible process to two separate processes
     814        representing the forward and reverse reactions. Otherwise,
     815        keep all irreversible processes unchanged.
     816        """
     817        for proc in procs:
     818            if isinstance(proc, ReversibleProcess):
     819                forward_proc = Process(proc.reactants, proc.products, proc.k)
     820                reverse_proc = Process(proc.products, proc.reactants, proc.k_rev)
     821                self._algo_processes.extend([forward_proc, reverse_proc])
     822            else:
     823                self._algo_processes.append(proc)
     824
     825    def _gen_algo_sequence(self):
     826        """
     827        Generate the sequence of functions to be called for the
     828        algorithm to perform the simulation.
     829        """
     830        for proc in self._algo_processes:
     831            if proc.order == 0:
     832                if isinstance(proc, RegulatedProcess):
     833                    if isinstance(proc.regulating_species, list):
     834                        self.algo_sequence.append(partial(self._order_0_reg_gt1, proc))
     835                    else:
     836                        self.algo_sequence.append(partial(self._order_0_reg, proc))
     837                else:
     838                    self.algo_sequence.append(partial(self._order_0, proc))
     839            elif proc.order == 1:
     840                if isinstance(proc, MichaelisMentenProcess):
     841                    self.algo_sequence.append(partial(self._order_1_mm, proc))
     842                elif isinstance(proc, RegulatedMichaelisMentenProcess):
     843                    if isinstance(proc.regulating_species, list):
     844                        self.algo_sequence.append(partial(self._order_1_reg_mm_gt1, proc))
     845                    else:
     846                        self.algo_sequence.append(partial(self._order_1_reg_mm, proc))
     847                elif isinstance(proc, RegulatedProcess):
     848                    if isinstance(proc.regulating_species, list):
     849                        self.algo_sequence.append(partial(self._order_1_reg_gt1, proc))
     850                    else:
     851                        self.algo_sequence.append(partial(self._order_1_reg, proc))
     852                else:
     853                    self.algo_sequence.append(partial(self._order_1, proc))
     854            else:  # order 2
     855                if isinstance(proc, RegulatedProcess):
     856                    if isinstance(proc.regulating_species, list):
     857                        self.algo_sequence.append(partial(self._order_2_reg_gt1, proc))
     858                    else:
     859                        self.algo_sequence.append(partial(self._order_2_reg, proc))
     860                else:
     861                    self.algo_sequence.append(partial(self._order_2, proc))
     862
     863    def _parallel_run(self, num_workers=None):
     864        """
     865        Run the simulation `n` times in parallel through multithreading.
     866        Multithreading was chosen instead of multiprocessing because numpy
     867        functions typically release the GIL while performing their
     868        computations. In this case, multi-threading allows for parallelism
     869        without incurring any of the often significant costs in speed related
     870        to initializing and sharing memory/data between multiple processes.
     871
     872        From documentation of `concurrent.futures` module at
     873        https://docs.python.org/3/library/concurrent.futures.html :
     874        'Default value of max_workers is `min(32, os.cpu_count() + 4)`:
     875        This default value preserves at least 5 workers for I/O bound tasks.'
     876        """
     877        with ThreadPoolExecutor(max_workers=num_workers) as executor:
     878            executor.map(self._repeat_sim, range(self.n))
     879
     880    def _sequential_run(self):
     881        """
     882        Run the simulation sequentially `n` times
     883        (without parallelization), i.e., using a simple `for` loop.
     884        """
     885        for i in range(self.n):
     886            self._repeat_sim(i)
     887
     888    def _repeat_sim(self, r: int):
     889        """ Run a repetition (indexed `r`) of the simulation. """
     890        for t in range(1, self.t_steps + 1):
     891            for algo_fcn in self.algo_sequence:
     892                algo_fcn(r, t)
     893
     894            for sp in self.all_species:
     895                # Sum up population sizes at end of time step
     896                self.results[sp]['N'][t, r] = np.sum(self.rtd[sp].asv[r][1, :] > 0)
     897
     898                # Replace old with new agent-state-vector
     899                self.rtd[sp].apply_markov_property(r)
     900
     901        self.progress_bar.update(1)
     902
     903    def _order_0(self, proc: Process, r: int, t: int):
     904        """
     905        Determine the transition probability for a 0th order process `proc`
     906        in a single time step of an AbStochKin simulation and assess if a
     907        transition event occurs. Then update the agent-state vector
     908        `asv` accordingly.
     909        """
     910        if self.streams[r].random() < self.trans_p[proc]:
     911            available_agent = np.argmin(self.rtd[proc.prods_[0]].asv[r][0])
     912            self.rtd[proc.prods_[0]].asv[r][1, available_agent] = 1
     913
     914    def _order_0_reg(self, proc: RegulatedProcess, r: int, t: int):
     915        """
     916        Determine the transition probability for a 0th order process
     917        regulated by a single species
     918        in a single time step of an AbStochKin simulation.
     919        """
     920        ratio = self.results[proc.regulating_species]['N'][t - 1, r] / proc.K50
     921        mult = (1 + proc.alpha * ratio ** proc.nH) / (1 + ratio ** proc.nH)
     922
     923        self.trans_p[proc] = proc.k * mult * self.dt
     924
     925        self._order_0(proc, r, t)
     926
     927    def _order_0_reg_gt1(self, proc: RegulatedProcess, r: int, t: int):
     928        """
     929        Determine the transition probability for a regulated 0th order process
     930        where the number of regulating species in greater than 1,
     931        in a single time step of an AbStochKin simulation.
     932        """
     933        mult = 1
     934        for i, sp in enumerate(proc.regulating_species):
     935            ratio = self.results[sp]['N'][t - 1, r] / proc.K50[i]
     936            mult *= (1 + proc.alpha[i] * ratio ** proc.nH[i]) / (1 + ratio ** proc.nH[i])
     937
     938        self.trans_p[proc] = proc.k * mult * self.dt
     939
     940        self._order_0(proc, r, t)
     941
     942    def _order_1(self, proc: Process, r: int, t: int):
     943        """
     944        Determine the transition events for a 1st order process `proc`
     945        in a single time step of an AbStochKin simulation. Then update the
     946        agent-state vector `asv` accordingly.
     947        """
     948        # Get random numbers (rn) and transition probabilities (tp)
     949        rn, tp = self.rtd[proc.reacts_[0]].get_vals_o1(r,
     950                                                       self.streams[r],
     951                                                       self.trans_p[proc])
     952
     953        transition_events = rn < tp  # Determine all transition events in this time step
     954
     955        # Mark transitions in the ASV of the reactant species
     956        self.rtd[proc.reacts_[0]].asv[r][1, :] = np.where(transition_events,
     957                                                          0,
     958                                                          self.rtd[proc.reacts_[0]].asv[r][1, :])
     959
     960        num_events = np.sum(transition_events)
     961        # Mark transitions in the ASV of the product species
     962        for prod in proc.prods_:
     963            available_agents = np.nonzero(np.all(self.rtd[prod].asv[r] == 0, axis=0))[0]
     964            self.rtd[prod].asv[r][1, available_agents[:num_events]] = 1
     965
     966        if proc.is_heterogeneous:  # Now compute metrics of heterogeneity (k)
     967            new_k_vals = self.k_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
     968            self._compute_k_het_metrics(proc, new_k_vals, t, r)
     969
     970    def _order_1_mm(self, proc: MichaelisMentenProcess, r: int, t: int):
     971        """
     972        Determine the transition events for a 1st order Michaelis-Menten
     973        process `proc` in a single time step of an AbStochKin simulation.
     974        """
     975        mult = self.results[proc.catalyst]['N'][t - 1, r] / (
     976                self.results[proc.reacts_[0]]['N'][t - 1, r] + self.Km_vals[proc])
     977        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
     978
     979        self._order_1(proc, r, t)
     980
     981        if proc.is_heterogeneous_Km:  # Now compute metrics of heterogeneity (Km)
     982            new_Km_vals = self.Km_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
     983            self._compute_Km_het_metrics(proc, new_Km_vals, t, r)
     984
     985    def _order_1_reg(self, proc: RegulatedProcess, r: int, t: int):
     986        """
     987        Determine the transition events for a 1st order
     988        process `proc`, regulated by a single species,
     989        in a single time step of an AbStochKin simulation.
     990        """
     991        ratio = self.results[proc.regulating_species]['N'][t - 1, r] / self.K50_vals[proc]
     992        mult = (1 + proc.alpha * ratio ** proc.nH) / (1 + ratio ** proc.nH)
     993        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
     994        self._order_1(proc, r, t)
     995
     996        if proc.is_heterogeneous_K50:  # Now compute metrics of heterogeneity (K50)
     997            new_K50_vals = self.K50_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
     998            self._compute_K50_het_metrics(proc, new_K50_vals, t, r)
     999
    1000    def _order_1_reg_gt1(self, proc: RegulatedProcess, r: int, t: int):
    1001        """
    1002        Determine the transition events for a 1st order
    1003        process `proc`, regulated by more than 1 species,
    1004        in a single time step of an AbStochKin simulation.
    1005        """
    1006        mult = 1
    1007        for i, sp in enumerate(proc.regulating_species):
    1008            ratio = self.results[sp]['N'][t - 1, r] / self.K50_vals[proc][i]
    1009            mult *= (1 + proc.alpha[i] * ratio ** proc.nH[i]) / (1 + ratio ** proc.nH[i])
    1010        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1011        self._order_1(proc, r, t)
    1012
    1013        for i in range(len(proc.regulating_species)):
    1014            if proc.is_heterogeneous_K50[i]:  # Now compute metrics of heterogeneity (K50)
    1015                new_K50_vals = self.K50_vals[proc][i] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1016                self._compute_K50_het_metrics(proc, new_K50_vals, t, r, idx=i)
    1017
    1018    def _order_1_reg_mm(self, proc: RegulatedMichaelisMentenProcess, r: int, t: int):
    1019        """
    1020        Determine the transition events for a 1st order
    1021        process `proc`, regulated by a single species and
    1022        obeying Michaelis-Menten kinetics,
    1023        in a single time step of an AbStochKin simulation.
    1024        """
    1025        ratio = self.results[proc.regulating_species]['N'][t - 1, r] / self.K50_vals[proc]
    1026        mult_reg = (1 + proc.alpha * ratio ** proc.nH) / (1 + ratio ** proc.nH)
    1027        mult_mm = self.results[proc.catalyst]['N'][t - 1, r] / (
    1028                self.results[proc.reacts_[0]]['N'][t - 1, r] + self.Km_vals[proc])
    1029        mult = mult_reg * mult_mm
    1030        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1031        self._order_1(proc, r, t)
    1032
    1033        if proc.is_heterogeneous_K50:  # Now compute metrics of heterogeneity (K50)
    1034            new_K50_vals = self.K50_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1035            self._compute_K50_het_metrics(proc, new_K50_vals, t, r)
    1036
    1037        if proc.is_heterogeneous_Km:  # Now compute metrics of heterogeneity (Km)
    1038            new_Km_vals = self.Km_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1039            self._compute_Km_het_metrics(proc, new_Km_vals, t, r)
    1040
    1041    def _order_1_reg_mm_gt1(self, proc: RegulatedMichaelisMentenProcess, r: int, t: int):
    1042        """
    1043        Determine the transition events for a 1st order
    1044        process `proc`, regulated by more than one species and
    1045        obeying Michaelis-Menten kinetics,
    1046        in a single time step of an AbStochKin simulation.
    1047        """
    1048        mult_reg = 1
    1049        for i, sp in enumerate(proc.regulating_species):
    1050            ratio = self.results[sp]['N'][t - 1, r] / self.K50_vals[proc][i]
    1051            mult_reg *= (1 + proc.alpha[i] * ratio ** proc.nH[i]) / (1 + ratio ** proc.nH[i])
    1052        mult_mm = self.results[proc.catalyst]['N'][t - 1, r] / (
    1053                self.results[proc.reacts_[0]]['N'][t - 1, r] + self.Km_vals[proc])
    1054        mult = mult_reg * mult_mm
    1055        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1056        self._order_1(proc, r, t)
    1057
    1058        for i in range(len(proc.regulating_species)):
    1059            if proc.is_heterogeneous_K50[i]:  # Now compute metrics of heterogeneity (K50)
    1060                new_K50_vals = self.K50_vals[proc][i] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1061                self._compute_K50_het_metrics(proc, new_K50_vals, t, r, idx=i)
    1062
    1063        if proc.is_heterogeneous_Km:  # Now compute metrics of heterogeneity (Km)
    1064            new_Km_vals = self.Km_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1065            self._compute_Km_het_metrics(proc, new_Km_vals, t, r)
    1066
    1067    def _order_2(self, proc: Process, r: int, t: int):
    1068        """
    1069        Determine the transition events for a 2nd order process `proc`
    1070        in a single time step of an AbStochKin simulation. Then update the
    1071        agent-state vector(s) `asv` accordingly.
    1072        """
    1073        # Get random numbers (rn) and transition probabilities (tp)
    1074        rn, tp = self.rtd[proc.reacts_[0]].get_vals_o2(self.rtd[proc.reacts_[1]],
    1075                                                       r,
    1076                                                       self.streams[r],
    1077                                                       self.trans_p[proc])
    1078
    1079        transition_events = rn < tp  # Determine all transition events in this time step
    1080
    1081        if np.sum(transition_events) > 1:  # IF multiple transition events
    1082            pairs = self._o2_get_unique_pairs(np.argwhere(transition_events))
    1083        else:  # only one or zero transition events
    1084            pairs = np.argwhere(transition_events)
    1085
    1086        # Mark transitions in the ASV of each species (or of the same species
    1087        # in the case of a homologous 2nd order process, 2A -> C).
    1088        for pair in pairs:
    1089            self.rtd[proc.reacts_[0]].asv[r][1, pair[0]] = 0
    1090            self.rtd[proc.reacts_[1]].asv[r][1, pair[1]] = 0
    1091
    1092        num_events = len(pairs)
    1093        # Mark transitions in the ASV of the product species
    1094        for prod in proc.prods_:
    1095            available_agents = np.nonzero(np.all(self.rtd[prod].asv[r] == 0, axis=0))[0]
    1096            self.rtd[prod].asv[r][1, available_agents[:num_events]] = 1
    1097
    1098        # Now compute metrics of heterogeneity for this time step (if applicable)
    1099        if proc.is_heterogeneous:
    1100            new_k_vals = self.k_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0).reshape(
    1101                -1, 1) * (self.rtd[proc.reacts_[1]].asv[r][1, :] > 0)
    1102            self._compute_k_het_metrics(proc, new_k_vals, t, r)
    1103
    1104    def _order_2_reg(self, proc: RegulatedProcess, r: int, t: int):
    1105        """
    1106        Determine the transition events for a 2nd order
    1107        process `proc`, regulated by a single species,
    1108        in a single time step of an AbStochKin simulation.
    1109        """
    1110        ratio = self.results[proc.regulating_species]['N'][t - 1, r] / self.K50_vals[proc]
    1111        mult = (1 + proc.alpha * ratio ** proc.nH) / (1 + ratio ** proc.nH)
    1112        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1113
    1114        self._order_2(proc, r, t)
    1115
    1116        if proc.is_heterogeneous_K50:  # Now compute metrics of heterogeneity (K50)
    1117            new_K50_vals = self.K50_vals[proc] * (
    1118                    self.rtd[proc.reacts_[0]].asv[r][1, :] > 0).reshape(-1, 1) * (
    1119                                   self.rtd[proc.reacts_[1]].asv[r][1, :] > 0)
    1120            self._compute_K50_het_metrics(proc, new_K50_vals, t, r)
    1121
    1122    def _order_2_reg_gt1(self, proc: RegulatedProcess, r: int, t: int):
    1123        """
    1124        Determine the transition events for a 2nd order
    1125        process `proc`, regulated by more than 1 species,
    1126        in a single time step of an AbStochKin simulation.
    1127        """
    1128        mult = 1
    1129        for i, sp in enumerate(proc.regulating_species):
    1130            ratio = self.results[sp]['N'][t - 1, r] / self.K50_vals[proc][i]
    1131            mult *= (1 + proc.alpha[i] * ratio ** proc.nH[i]) / (1 + ratio ** proc.nH[i])
    1132        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1133
    1134        self._order_2(proc, r, t)
    1135
    1136        for i in range(len(proc.regulating_species)):
    1137            if proc.is_heterogeneous_K50[i]:  # Now compute metrics of heterogeneity (K50)
    1138                new_K50_vals = self.K50_vals[proc][i] * (
    1139                        self.rtd[proc.reacts_[0]].asv[r][1, :] > 0).reshape(-1, 1) * (
    1140                                       self.rtd[proc.reacts_[1]].asv[r][1, :] > 0)
    1141                self._compute_K50_het_metrics(proc, new_K50_vals, t, r, idx=i)
    1142
    1143    def _compute_k_het_metrics(self,
    1144                               proc: Process | MichaelisMentenProcess |
    1145                                     RegulatedProcess | RegulatedMichaelisMentenProcess,
    1146                               all_k_vals: np.array,
    1147                               t: int,
    1148                               r: int):
    1149        """
    1150        Calculate metrics of heterogeneity for a 1st or 2nd order process `proc`
    1151        with an array of agent-specific `k` values `all_k_vals`,
    1152        corresponding to time step `t` of repetition `r` of the simulation.
    1153        """
    1154        nonzero_k = all_k_vals[np.nonzero(all_k_vals)]
    1155
    1156        if nonzero_k.size != 0:
    1157            self.k_het_metrics[proc]['k_avg'][t, r] = np.mean(nonzero_k)
    1158            self.k_het_metrics[proc]['k_std'][t, r] = np.std(nonzero_k)
    1159            self.k_het_metrics[proc]['psi'][t, r] = idx_het(nonzero_k)
    1160
    1161    def _compute_Km_het_metrics(self,
    1162                                proc: MichaelisMentenProcess | RegulatedMichaelisMentenProcess,
    1163                                all_Km_vals: np.array,
    1164                                t: int,
    1165                                r: int):
    1166        """
    1167        Calculate metrics of heterogeneity for a 1st or 2nd order process `proc`
    1168        obeying Michaelis-Menten kinetics with an array of
    1169        agent-specific `Km` values `all_Km_vals`,
    1170        corresponding to time step `t` of repetition `r` of the simulation.
    1171        """
    1172        nonzero_Km = all_Km_vals[np.nonzero(all_Km_vals)]
    1173
    1174        if nonzero_Km.size != 0:
    1175            self.Km_het_metrics[proc]['Km_avg'][t, r] = np.mean(nonzero_Km)
    1176            self.Km_het_metrics[proc]['Km_std'][t, r] = np.std(nonzero_Km)
    1177            self.Km_het_metrics[proc]['psi'][t, r] = idx_het(nonzero_Km)
    1178
    1179    def _compute_K50_het_metrics(self,
    1180                                 proc: RegulatedProcess | RegulatedMichaelisMentenProcess,
    1181                                 all_K50_vals: np.array,
    1182                                 t: int,
    1183                                 r: int,
    1184                                 *,
    1185                                 idx: int = None):
    1186        """
    1187        Calculate metrics of heterogeneity for a regulated 1st or ***** order process `proc`
    1188        with an array of agent-specific `K50` values `all_K50_vals`,
    1189        corresponding to time step `t` of repetition `r` of the simulation.
    1190        """
    1191        nonzero_K50 = all_K50_vals[np.nonzero(all_K50_vals)]
    1192
    1193        if nonzero_K50.size != 0:
    1194            if idx is not None:
    1195                self.K50_het_metrics[proc][idx]['K50_avg'][t, r] = np.mean(nonzero_K50)
    1196                self.K50_het_metrics[proc][idx]['K50_std'][t, r] = np.std(nonzero_K50)
    1197                self.K50_het_metrics[proc][idx]['psi'][t, r] = idx_het(nonzero_K50)
    1198            else:
    1199                self.K50_het_metrics[proc]['K50_avg'][t, r] = np.mean(nonzero_K50)
    1200                self.K50_het_metrics[proc]['K50_std'][t, r] = np.std(nonzero_K50)
    1201                self.K50_het_metrics[proc]['psi'][t, r] = idx_het(nonzero_K50)
    1202
    1203    @staticmethod
    1204    def _o2_get_unique_pairs(i_pairs: np.array):
    1205        """
    1206        An agent of a species can only participate in one transition
    1207        event per time step, so this function ensures that multiple
    1208        transition events are not recorded for a given agent.
    1209        For instance, if agent 1 of species A was reported as
    1210        transitioning with agents 5 **and** 9 of species B as partners,
    1211        then only one of those interactions is kept.
    1212
    1213        Parameters
    1214        ----------
    1215        i_pairs : numpy.array, shape: (n x 2)
    1216            All interacting pairs of agents that have been reported as
    1217            transitioning in a time step.
    1218
    1219        Returns
    1220        -------
    1221        unique_pairs : numpy.array of numpy.array(s) of shape (2, )
    1222            Unique pairs of interacting agents.
    1223        """
    1224        agent_0 = set(i_pairs[:, 0])  # set of number of first agent in pairs
    1225        seen_agent_1 = set()  # set of second agent that is already paired up
    1226        unique_pairs = []
    1227
    1228        for a in agent_0:
    1229            for p in i_pairs:
    1230                if p[0] == a:
    1231                    if p[1] not in seen_agent_1:
    1232                        seen_agent_1.add(p[1])  # this agent_1 has been used
    1233                        unique_pairs.append(p)  # add to final list of pairs
    1234                        break
    1235                    else:
    1236                        continue
    1237
    1238        return np.array(unique_pairs)
    1239
    1240    def _compute_trajectory_stats(self):
    1241        """
    1242        Compute statistics on all the species trajectories
    1243        obtained through an ensemble of `n` simulations.
    1244        """
    1245
    1246        for i, sp in enumerate(list(self.de_calcs.odes.keys())):
    1247            self.results[sp]['N_avg'] = np.mean(self.results[sp]['N'], axis=1)
    1248            self.results[sp]['N_std'] = np.std(self.results[sp]['N'], axis=1)
    1249
    1250            # Avoid warning about division by zero:
    1251            n_avg_nozeros = np.where(self.results[sp]['N_avg'] == 0,
    1252                                     np.nan, self.results[sp]['N_avg'])
    1253
    1254            # Calculate the coefficient of variation, η:
    1255            self.results[sp]['eta'] = self.results[sp]['N_std'] / n_avg_nozeros
    1256            # Calculate η for a Poisson process:
    1257            self.results[sp]['eta_p'] = 1 / np.sqrt(n_avg_nozeros)
    1258
    1259            with contextlib.suppress(AttributeError):
    1260                # If the ODEs have been solved, then calculate R^2.
    1261                self.results[sp]['R^2'] = r_squared(self.results[sp]['N_avg'],
    1262                                                    self.de_calcs.odes_sol.sol(self.time).T[:, i])
    1263
    1264    def _compute_het_stats(self):
    1265        """ Compute statistics on process-specific metrics of heterogeneity. """
    1266        het_params = [self.k_het_metrics, self.Km_het_metrics, self.K50_het_metrics]
    1267        het_attrs = ['k', 'Km', 'K50']
    1268
    1269        for het_param, het_attr in zip(het_params, het_attrs):
    1270            for proc, data in het_param.items():
    1271                if isinstance(data, list):
    1272                    for i, datum in enumerate(data):
    1273                        if datum is not None:
    1274                            het_param[proc][i][f'<{het_attr}_avg>'] = np.mean(datum[f'{het_attr}_avg'], axis=1)
    1275                            het_param[proc][i][f'<{het_attr}_std>'] = np.mean(datum[f'{het_attr}_std'], axis=1)
    1276                            het_param[proc][i]['psi_avg'] = np.mean(datum['psi'], axis=1)
    1277                            het_param[proc][i]['psi_std'] = np.std(datum['psi'], axis=1)
    1278                else:
    1279                    het_param[proc][f'<{het_attr}_avg>'] = np.mean(data[f'{het_attr}_avg'], axis=1)
    1280                    het_param[proc][f'<{het_attr}_std>'] = np.mean(data[f'{het_attr}_std'], axis=1)
    1281                    het_param[proc]['psi_avg'] = np.mean(data['psi'], axis=1)
    1282                    het_param[proc]['psi_std'] = np.std(data['psi'], axis=1)
    1283
    1284    def _post_run_cleanup(self):
    1285        """ Delete `asv` for all species after the simulation is done
    1286        to free up the memory associated with this attribute. """
    1287        for sp in self.all_species:
    1288            self.rtd[sp].cleanup_asv()

class SimulationMethodsMixin: View Source

      31class SimulationMethodsMixin:
      32    """ Mixin class with methods necessary for running an AbStochKin simulation. """
      33
      34    def _validate_p0(self):
      35        """ A couple of assertions regarding initial population sizes. """
      36        assert len(self.p0) == len(self.all_species), \
      37            f"Specification of initial population sizes does not match number of species.\n" \
      38            f"len(p0)={len(self.p0)} , len(all_species)={len(self.all_species)}"
      39        assert all([True if y0 >= 0 else False for y0 in self.p0.values()]), \
      40            "An initial population size cannot be negative."
      41
      42    def _setup_data(self):
      43        """
      44        Initialize runtime data and `results` dictionary for all species.
      45        Also initialize process-specific `k` values (`k_vals`),
      46        transition probabilities (`trans_p`), and metrics of heterogeneity
      47        (`k_het_metrics`) dictionaries.
      48        """
      49        self._setup_runtime_data()
      50
      51        for sp in self.all_species:
      52            self.results[sp] = {
      53                'N': np.empty((self.t_steps + 1, self.n), dtype=np.uint64),
      54                'N_avg': np.empty(self.t_steps + 1, dtype=np.float64),
      55                'N_std': np.empty(self.t_steps + 1, dtype=np.float64),
      56                'eta': np.empty(self.t_steps + 1, dtype=np.float64),  # CoV
      57                'eta_p': np.empty(self.t_steps + 1, dtype=np.float64),  # Poisson CoV
      58                'R^2': None
      59            }
      60            self.results[sp]['N'][0] = self.p0[sp]
      61
      62        """ Construct the time-independent PHM.
      63            Calculate k and transition probability values (done just once). """
      64        for proc in self._algo_processes:
      65            if proc.order == 0:
      66                self.trans_p[proc] = self._get_o0_trans_p(proc)
      67            elif proc.order == 1:
      68                self.k_vals[proc], self.trans_p[proc] = self._init_o1_vals(proc)
      69
      70                if isinstance(proc, (MichaelisMentenProcess, RegulatedMichaelisMentenProcess)):
      71                    self.Km_vals[proc] = self._init_o1mm_Km_vals(proc)
      72                if isinstance(proc, (RegulatedProcess, RegulatedMichaelisMentenProcess)):
      73                    self.K50_vals[proc] = self._init_o1reg_K50_vals(proc)
      74            else:  # order == 2
      75                self.k_vals[proc], self.trans_p[proc] = self._init_o2_vals(proc)
      76
      77                if isinstance(proc, RegulatedProcess):
      78                    self.K50_vals[proc] = self._init_o2reg_K50_vals(proc)
      79
      80        self._init_het_metrics()
      81
      82    def _setup_runtime_data(self):
      83        """ Initialize runtime data (`rtd`) dictionaries for all species. """
      84
      85        """ The following is an arbitrary multiplier to account for
      86            population size fluctuations and should be sufficient for most
      87            cases. The multiplier may need to be adjusted in some cases. """
      88        if len(self.max_agents) == 0:
      89            """ Strategy 1: Set the number of possible agents for each
      90                species based on the maximum value of the ODE solution for
      91                the species times the multiplier. """
      92            for i, sp in enumerate(self.de_calcs.odes.keys()):
      93                if sp in self._procs_by_reactant.keys() or sp in self._procs_by_product.keys():
      94                    sp_max_agents = np.ceil(
      95                        self.max_agents_multiplier * np.max(self.de_calcs.odes_sol.y[i]))
      96                else:  # for species whose population size does not change (eg, a catalyst)
      97                    sp_max_agents = np.ceil(np.max(self.de_calcs.odes_sol.y[i]))
      98
      99                self.rtd[sp] = AgentStateData(self.p0[sp], int(sp_max_agents), self.n,
     100                                              fill_state=self._get_fill_state(sp))
     101                self.max_agents[sp] = int(sp_max_agents)
     102
     103            """ Strategy 2: Find the maximum from the ODE trajectories of all
     104                species and set the number of possible agents for all species
     105                to that value times the multiplier. This is an arguably
     106                simplistic approach that may result in unnecessary memory (and cpu)
     107                usage, however it should be sufficient for most simple systems. """
     108            # num_agents = int(np.ceil(np.max(self.de_calcs.odes_sol.y))) * max_agents_multiplier
     109            # for sp in self.all_species:
     110            #     self.rtd[sp] = AgentStateData(self.p0[sp], num_agents, self.n,
     111            #                                   fill_state=self._get_fill_state(sp))
     112            #     self.max_agents[sp] = num_agents
     113        else:
     114            """ Strategy 3: Let the user specify the number of possible agents
     115                for each species. After examination of the deterministic
     116                trajectories and underlying dynamics, it may sometimes
     117                be preferred to simply specify the max agents for each species. """
     118            assert len(self.max_agents) == len(self.all_species), \
     119                "Must specify the maximum number of agents for all species."
     120            for sp in self.all_species:
     121                self.rtd[sp] = AgentStateData(self.p0[sp], self.max_agents[sp], self.n,
     122                                              fill_state=self._get_fill_state(sp))
     123
     124    def _get_fill_state(self, species_name):
     125        """
     126        When setting up the initial agent-state vector (`asv`) of a
     127        species, agents that are part of the initial population
     128        have a state of 1 (they are 'alive'). The remaining
     129        members of the `asv` array are filled with state 0, or -1
     130        if the species is the product of any 0th order processes.
     131
     132        Returns
     133        -------
     134        fill_state
     135            0 or -1
     136
     137        Notes
     138        -----
     139        There was a need in an earlier version of the algorithm
     140        to distinguish between agents that have never been created
     141        through a 0th order process (or born), and those that have
     142        been previously born but converted to another species.
     143        The convention we adopted then is to assign the former
     144        a state of -1 and the latter a state of 0. We are keeping
     145        this convention here, although it's not strictly necessary
     146        for the latest version of the algorithm.
     147        """
     148        f_state = 0
     149        if species_name in self._procs_by_product.keys():
     150            if 0 in [proc.order for proc in self._procs_by_product[species_name]]:
     151                f_state = -1
     152        return f_state
     153
     154    def _init_het_metrics(self):
     155        """ Initialize heterogeneity metrics as a list of tuples
     156        to be converted to a dictionary for each process. """
     157        # Initialize heterogeneity metrics (for k values) as a list of tuples ...
     158        # to be converted to a dictionary for each process of a species.
     159        k_metrics_init = [('k_avg', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     160                          ('k_std', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     161                          ('psi', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     162                          ('<k_avg>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     163                          ('<k_std>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     164                          ('psi_avg', np.zeros(self.t_steps + 1, dtype=np.float64)),
     165                          ('psi_std', np.zeros(self.t_steps + 1, dtype=np.float64))]
     166
     167        Km_metrics_init = [('Km_avg', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     168                           ('Km_std', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     169                           ('psi', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     170                           ('<Km_avg>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     171                           ('<Km_std>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     172                           ('psi_avg', np.zeros(self.t_steps + 1, dtype=np.float64)),
     173                           ('psi_std', np.zeros(self.t_steps + 1, dtype=np.float64))]
     174
     175        K50_metrics_init = [
     176            ('K50_avg', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     177            ('K50_std', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     178            ('psi', np.zeros((self.t_steps + 1, self.n), dtype=np.float64)),
     179            ('<K50_avg>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     180            ('<K50_std>', np.zeros(self.t_steps + 1, dtype=np.float64)),
     181            ('psi_avg', np.zeros(self.t_steps + 1, dtype=np.float64)),
     182            ('psi_std', np.zeros(self.t_steps + 1, dtype=np.float64))]
     183
     184        for procs in self._procs_by_reactant.values():
     185            # Compute heterogeneity metrics at t=0
     186            for proc in procs:
     187                if proc.is_heterogeneous:
     188                    k_metrics = deepcopy(k_metrics_init)
     189
     190                    if proc.order == 1:
     191                        temp_k_vals = self.k_vals[proc] * (
     192                                self.rtd[proc.reacts_[0]].asv_ini[0, :] > 0)
     193                    else:  # order == 2
     194                        nonzero_rows = (self.rtd[proc.reacts_[0]].asv_ini[1, :] > 0).reshape(-1, 1)
     195                        nonzero_cols = self.rtd[proc.reacts_[1]].asv_ini[1, :] > 0
     196                        temp_k_vals = self.k_vals[proc] * nonzero_rows * nonzero_cols
     197
     198                    nonzero_k_vals = temp_k_vals[np.nonzero(temp_k_vals)]
     199
     200                    if nonzero_k_vals.size != 0:
     201                        for i, stat_fcn in enumerate([np.mean, np.std, idx_het]):
     202                            k_metrics[i][1][0, :] = stat_fcn(nonzero_k_vals)
     203
     204                    self.k_het_metrics[proc] = dict(k_metrics)
     205
     206                if isinstance(proc, (MichaelisMentenProcess, RegulatedMichaelisMentenProcess)):
     207                    if proc.is_heterogeneous_Km:
     208                        Km_metrics = deepcopy(Km_metrics_init)
     209
     210                        # Only 1st order Michaelis-Menten processes are currently defined
     211                        temp_Km_vals = self.Km_vals[proc] * (
     212                                self.rtd[proc.reacts_[0]].asv_ini[0, :] > 0)
     213
     214                        nonzero_Km_vals = temp_Km_vals[np.nonzero(temp_Km_vals)]
     215
     216                        if nonzero_Km_vals.size != 0:
     217                            for i, stat_fcn in enumerate([np.mean, np.std, idx_het]):
     218                                Km_metrics[i][1][0, :] = stat_fcn(nonzero_Km_vals)
     219
     220                        self.Km_het_metrics[proc] = dict(Km_metrics)
     221
     222                if isinstance(proc, (RegulatedProcess, RegulatedMichaelisMentenProcess)):
     223                    if isinstance(proc.regulating_species, list):  # multiple regulating species
     224                        self.K50_het_metrics[proc] = list()
     225
     226                        for w, Kval in enumerate(proc.K50):
     227                            if proc.is_heterogeneous_K50[w]:
     228                                K50_metrics = deepcopy(K50_metrics_init)
     229                                if proc.order == 1:
     230                                    temp_K50_vals = self.K50_vals[proc][w] * (
     231                                            self.rtd[proc.reacts_[0]].asv_ini[0, :] > 0)
     232                                else:  # order == 2
     233                                    nonzero_rows = (self.rtd[proc.reacts_[0]].asv_ini[1, :] > 0).reshape(-1, 1)
     234                                    nonzero_cols = self.rtd[proc.reacts_[1]].asv_ini[1, :] > 0
     235                                    temp_K50_vals = self.K50_vals[proc][w] * nonzero_rows * nonzero_cols
     236
     237                                nonzero_K50_vals = temp_K50_vals[np.nonzero(temp_K50_vals)]
     238
     239                                if nonzero_K50_vals.size != 0:
     240                                    for i, stat_fcn in enumerate([np.mean, np.std, idx_het]):
     241                                        K50_metrics[i][1][0, :] = stat_fcn(nonzero_K50_vals)
     242
     243                                self.K50_het_metrics[proc].append(dict(K50_metrics))
     244                            else:
     245                                self.K50_het_metrics[proc].append(None)
     246
     247                    else:  # only one regulating species
     248                        if proc.is_heterogeneous_K50:
     249                            K50_metrics = deepcopy(K50_metrics_init)
     250
     251                            if proc.order == 1:
     252                                temp_K50_vals = self.K50_vals[proc] * (
     253                                        self.rtd[proc.reacts_[0]].asv_ini[0, :] > 0)
     254                            else:  # order == 2
     255                                nonzero_rows = (self.rtd[proc.reacts_[0]].asv_ini[1, :] > 0).reshape(-1, 1)
     256                                nonzero_cols = self.rtd[proc.reacts_[1]].asv_ini[1, :] > 0
     257                                temp_K50_vals = self.K50_vals[proc] * nonzero_rows * nonzero_cols
     258
     259                            nonzero_K50_vals = temp_K50_vals[np.nonzero(temp_K50_vals)]
     260
     261                            if nonzero_K50_vals.size != 0:
     262                                for i, stat_fcn in enumerate([np.mean, np.std, idx_het]):
     263                                    K50_metrics[i][1][0, :] = stat_fcn(nonzero_K50_vals)
     264
     265                            self.K50_het_metrics[proc] = dict(K50_metrics)
     266
     267    def _get_o0_trans_p(self, proc: Process | RegulatedProcess):
     268        """ Get the transition probability for a 0th order process. """
     269        return proc.k * self.dt
     270
     271    def _init_o1_vals(self, proc: Process):
     272        """
     273        Initialize the arrays for the rate constant and
     274        transition probability values for a 1st order process.
     275        The latter array is calculated from the former.
     276        The contents of the arrays depend on whether the process
     277        is homogeneous or heterogeneous.
     278
     279        - Homogeneous: The arrays consist of a single repeated value.
     280        - Heterogeneous: If the population is stratified with a distinct
     281          number of subspecies, then the array contents are partitioned
     282          in equal parts according to that number. Each part has the
     283          value that corresponds to each subspecies. Note that the
     284          order of the array contents is randomized.
     285          On the other hand, if the population is normally distributed
     286          with respect to its rate constant value, then this
     287          distribution is reflected in the array contents.
     288
     289        Notes
     290        -----
     291        Note that the **sample** mean and standard deviation of the
     292        generated values in the array will not be exactly the same as
     293        specified in `proc.k` in the case of normally-distributed
     294        heterogeneous populations.
     295
     296        Parameters
     297        ----------
     298        proc: Process
     299            Object of class `Process` whose attribute `order`
     300            is equal to `1` (i.e., a 1st order process).
     301
     302        Returns
     303        -------
     304        tuple
     305            k_vals : numpy array
     306                Contains the values of the rate constant for
     307                `num_agents` number of agents in a 1st order process.
     308            p_vals: numpy array
     309                Contains the values of the transition probabilities
     310                given a time interval `dt` for a 1st order process.
     311        """
     312        num_agents = self.max_agents[proc.reacts_[0]]
     313        if not proc.is_heterogeneous:  # homogeneous process: one k value
     314            k_vals = np.full(num_agents, proc.k)
     315        else:  # heterogeneous process
     316            if isinstance(proc.k, list):  # distinct subspecies
     317                k_vals = self._gen_o1_distinct_subspecies_vals(proc, num_agents)
     318            else:  # proc.k is a tuple: mean, std of normal distribution
     319                k_vals = self._gen_o1_normal_vals(proc, num_agents)
     320
     321        p_vals = 1 - np.exp(-1 * k_vals * self.dt)
     322
     323        return k_vals, p_vals
     324
     325    def _init_o1mm_Km_vals(self, proc: MichaelisMentenProcess):
     326        """
     327        Initialize the array for the Km values for a
     328        1st order Michaelis-Menten process.
     329        The contents of the array depends on whether the process
     330        is homogeneous or heterogeneous.
     331
     332        - Homogeneous: The array consists of a single repeated value.
     333        - Heterogeneous: If the population is stratified with a distinct
     334          number of subspecies, then the array contents are partitioned
     335          in equal parts according to that number. Each part has the
     336          value that corresponds to each subspecies. Note that the
     337          order of the array contents is randomized.
     338          On the other hand, if the population is normally distributed
     339          with respect to its Km value, then this
     340          distribution is reflected in the array contents.
     341        """
     342        num_agents = self.max_agents[proc.reacts_[0]]
     343        if not proc.is_heterogeneous_Km:  # homogeneous process: one Km value
     344            Km_vals = np.full(num_agents, proc.Km)
     345        else:  # heterogeneous process wrt Km
     346            if isinstance(proc.Km, list):  # distinct subspecies wrt Km
     347                Km_vals = self._gen_o1_distinct_subspecies_vals(proc, num_agents, het_attr='Km')
     348            else:
     349                # Assuming Km can only have integer values (output_type).
     350                Km_vals = self._gen_o1_normal_vals(proc, num_agents, het_attr='Km',
     351                                                   output_type=int)
     352
     353        return Km_vals
     354
     355    def _init_o1reg_K50_vals(self, proc: RegulatedProcess):
     356        """
     357        Initialize the array for the K50 values for a regulated
     358        1st order process.
     359        The contents of the array depends on whether the process
     360        is homogeneous or heterogeneous.
     361
     362        - Homogeneous: The array consists of a single repeated value.
     363        - Heterogeneous: If the population is stratified with a distinct
     364          number of subspecies, then the array contents are partitioned
     365          in equal parts according to that number. Each part has the
     366          value that corresponds to each subspecies. Note that the
     367          order of the array contents is randomized.
     368          On the other hand, if the population is normally distributed
     369          with respect to its Km value, then this
     370          distribution is reflected in the array contents.
     371        """
     372        num_agents = self.max_agents[proc.reacts_[0]]
     373
     374        if isinstance(proc.regulating_species, list):  # multiple regulating species
     375            K50_vals = list()
     376            for i, Kval in enumerate(proc.K50):
     377                if not proc.is_heterogeneous_K50[i]:  # homogeneous process: one K50 value
     378                    K50_vals.append(np.full(num_agents, Kval))
     379                else:  # heterogeneous process wrt K50
     380                    if isinstance(Kval, list):  # distinct subspecies wrt K50
     381                        K50_vals.append(self._gen_o1_distinct_subspecies_vals(proc, num_agents,
     382                                                                              het_attr='K50',
     383                                                                              het_attr_idx=i))
     384                    else:  # K50 is a 2-tuple: normally-distributed
     385                        # Assuming K50 can only have integer values (output_type).
     386                        K50_vals.append(self._gen_o1_normal_vals(proc, num_agents,
     387                                                                 het_attr='K50',
     388                                                                 het_attr_idx=i,
     389                                                                 output_type=int))
     390
     391        else:  # only one regulating species
     392            if not proc.is_heterogeneous_K50:  # homogeneous process: one K50 value
     393                K50_vals = np.full(num_agents, proc.K50)
     394            else:  # heterogeneous process wrt K50
     395                if isinstance(proc.K50, list):  # distinct subspecies wrt K50
     396                    K50_vals = self._gen_o1_distinct_subspecies_vals(proc, num_agents,
     397                                                                     het_attr='K50')
     398                else:  # K50 is a 2-tuple: normally-distributed
     399                    # Assuming K50 can only have integer values (output_type).
     400                    K50_vals = self._gen_o1_normal_vals(proc, num_agents,
     401                                                        het_attr='K50',
     402                                                        output_type=int)
     403
     404        return K50_vals
     405
     406    def _gen_o1_distinct_subspecies_vals(self,
     407                                         proc: Process | MichaelisMentenProcess | RegulatedProcess,
     408                                         num_agents: int,
     409                                         *,
     410                                         het_attr: str = 'k',
     411                                         het_attr_idx: int = None):
     412        """
     413        For a heterogeneous population, if the population is stratified
     414        with a distinct number of subspecies, then the array contents
     415        are partitioned in equal parts according to that number.
     416        Each part has the value that corresponds to each subspecies.
     417        Note that the order of the array contents is randomized.
     418
     419        Parameters
     420        ----------
     421        proc: Process or MichaelisMentenProcess or RegulatedProcess
     422            Object of class `Process` (or of a class whose parent
     423            class is `Parent`) whose attribute `order` is equal to
     424            `1` (i.e., a 1st order process).
     425        num_agents : int
     426            The number of elements in the array of values to be
     427            constructed.
     428        het_attr : str, default: 'k'
     429            Specification of the attribute of a process that exhibits
     430            heterogeneity. The default is the rate constant `k`.
     431        het_attr_idx : int, default: None
     432            For cases where the attribute of a process that exhibits
     433            heterogeneity is a list, this is the index of the element
     434            within the list that is desired.
     435
     436        Returns
     437        -------
     438        vals : numpy array
     439            Contains the values of `het_attr`. The size of the
     440            array is `num_agents`.
     441        """
     442        if het_attr != 'k':
     443            assert hasattr(proc, het_attr), f"{proc} does not have attribute {het_attr}."
     444
     445        attr = getattr(proc, het_attr)
     446        if isinstance(attr, list) and het_attr_idx is not None:
     447            attr = attr[het_attr_idx]
     448
     449        num_subspecies = len(attr)
     450        assert num_subspecies <= num_agents, \
     451            "The number of subspecies cannot be greater than the population size."
     452
     453        """ `vals` has shape (num_agents,). Note that `num_agents`
     454        is the same as `max_agents` when setting up the data. So, this
     455        sets up the `het_attr` values for all agents that may become 'alive'
     456        during a simulation. """
     457        vals = np.array([attr[h] for h in range(num_subspecies) for _ in
     458                         range(int(num_agents / num_subspecies))])
     459
     460        """ When the number of agents is not evenly divisible by
     461        the number of subspecies, `k_vals` has a shorter length than
     462        the required number of agents. That's because
     463        `int(num_agents / num_subspecies)` in the above comprehension
     464        returns the floor of the ratio. """
     465        if len(vals) != num_agents:
     466            num_missing = num_agents - len(vals)
     467            vals = np.append(vals, [attr[i] for i in range(num_missing)])
     468
     469        """ Agents in the initial population of the species are
     470        assigned `k_vals` sequentially within the array (starting from
     471        the beginning). This would over-represent the first (or more)
     472        subspecies at the expense of the subsequent ones.
     473        Thus, we must shuffle `k_vals` so that the collection of agents
     474        in the initial population *approximate* the desired composition
     475        of distinct subspecies. """
     476        np.random.default_rng(seed=self.random_state).shuffle(vals)
     477
     478        return vals
     479
     480    def _gen_o1_normal_vals(self,
     481                            proc: Process | MichaelisMentenProcess | RegulatedProcess,
     482                            num_agents: int,
     483                            *,
     484                            het_attr: str = 'k',
     485                            het_attr_idx: int = None,
     486                            output_type: type[float | int] = float,
     487                            max_tries: int = 1000):
     488        """
     489        Generate an array of values sampled from a normal distribution.
     490        Make sure there are no negative values in generated array.
     491
     492        Parameters
     493        ----------
     494        *
     495        proc: Process or MichaelisMentenProcess or RegulatedProcess
     496            Object of class `Process` whose attribute `order`
     497            is equal to `1` (i.e., a 1st order process).
     498        num_agents: int
     499            Defines the length of the desired array.
     500            Effectively represents the number of agents that
     501            *could* participate in process `proc` during the
     502            simulation.
     503        het_attr : str, default: 'k'
     504            Specification of the process attribute that is
     505            normally-distributed. The default, `k`, is the
     506            rate constant of the process.
     507        het_attr_idx : int, default: None
     508            For cases where the attribute of a process that exhibits
     509            heterogeneity is a list, this is the index of the element
     510            within the list that is desired.
     511        output_type : type, default: float
     512            The type of the numbers that will populate the output array.
     513            The default is `float`, but `int` may sometimes be desired.
     514        max_tries : int, default: 1000
     515            Maximum number of tries to generate the array
     516            without any negative values. If this number is
     517            exceeded, an exception is raised.
     518
     519        Returns
     520        -------
     521        vals : numpy array
     522            Contains the normally-distributed values of `het_attr` for
     523            `num_agents` number of agents in a 1st order process.
     524            If the array could not be generated after the maximum number
     525            of tries, then an exception is raised.
     526
     527        """
     528        if het_attr != 'k':
     529            assert hasattr(proc, het_attr), f"{proc} does not have attribute {het_attr}."
     530
     531        attr = getattr(proc, het_attr)
     532        if isinstance(attr, list) and het_attr_idx is not None:
     533            attr = attr[het_attr_idx]
     534
     535        rng = np.random.default_rng(seed=self.random_state)
     536
     537        tries = 0
     538        while tries <= max_tries:
     539            vals = rng.normal(*attr, num_agents).astype(output_type)
     540            if not np.any(vals <= 0):
     541                return vals
     542            else:
     543                tries += 1
     544        else:
     545            err_msg = f"Maximum number of tries ({max_tries}) to get non-negative " \
     546                      f"{het_attr} values in simulated process {proc.__str__()} " \
     547                      f"has been exceeded. Please reconsider the " \
     548                      f"distribution of {het_attr} values for this process."
     549            raise AssertionError(err_msg)
     550
     551    def _init_o2_vals(self, proc: Process):
     552        """
     553        Initialize the arrays for the rate constant and
     554        transition probability values for a 2nd order process.
     555        The latter array is calculated from the former.
     556        The contents of the arrays depend on whether the process
     557        is homogeneous or heterogeneous.
     558
     559        - Homogeneous: The arrays consist of a single repeated value.
     560        - Heterogeneous: If the population is stratified with a distinct
     561          number of subspecies, then the array contents are partitioned
     562          in equal parts according to that number. Each part has the
     563          value that corresponds to each subspecies. Note that the
     564          order of the array contents is randomized.
     565          On the other hand, if the population is normally distributed
     566          with respect to its rate constant value, then this
     567          distribution is reflected in the array contents.
     568
     569        Notes
     570        -----
     571        Note that the **sample** mean and standard deviation of the
     572        generated values in the array will not be exactly the same as
     573        specified in `proc.k` in the case of normally-distributed
     574        heterogeneous populations.
     575
     576        Parameters
     577        ----------
     578        proc: Process
     579            Object of class `Process` whose attribute `order`
     580            is equal to `2` (i.e., a 2nd order process).
     581
     582        Returns
     583        -------
     584        tuple
     585            k_vals : numpy array
     586                Contains the values of the rate constant for
     587                `num_agents` number of agents in a 1st order process.
     588            p_vals: numpy array
     589                Contains the values of the transition probabilities
     590                given a time interval `dt` for a 1st order process.
     591        """
     592
     593        """ phm_shape is a tuple of integers that
     594        defines the shape of the population heterogeneity matrix
     595        (i.e., the `k_vals` and, by extension, `p_vals` arrays).
     596        Effectively represents the number of agents that
     597        *could* participate in process `proc` during the
     598        simulation. For example, for the process A + B -> C,
     599        the number of rows and columns correspond to the number
     600        of agents of species A and B respectively. """
     601        phm_shape = (self.max_agents[proc.reacts_[0]],
     602                     self.max_agents[proc.reacts_[1]])
     603
     604        if not proc.is_heterogeneous:  # homogeneous process: one k value
     605            k_vals = np.full(phm_shape, proc.k)
     606        else:  # heterogeneous process
     607            if isinstance(proc.k, list):  # distinct subinteractions
     608                k_vals = self._gen_o2_distinct_subinteractions_vals(proc, phm_shape)
     609            else:  # proc.k is a tuple: mean, std of normal distribution
     610                k_vals = self._gen_o2_normal_vals(proc, phm_shape)
     611
     612        # If reactants are the same (eg: A + A -> C, a homologous 2nd order
     613        # process), then make the interaction of an agent with itself impossible.
     614        if len(set(proc.reacts_)) == 1:
     615            np.fill_diagonal(k_vals, 0)  # make diagonal entries zero
     616
     617        p_vals = 1 - np.exp(-1 * k_vals * self.dt)
     618
     619        return k_vals, p_vals
     620
     621    def _init_o2reg_K50_vals(self, proc: RegulatedProcess):
     622        """
     623        Initialize the array for the K50 values for a regulated
     624        2nd order process.
     625        The contents of the array depends on whether the process
     626        is homogeneous or heterogeneous.
     627
     628        - Homogeneous: The array consists of a single repeated value.
     629        - Heterogeneous: If the population is stratified with a distinct
     630          number of subspecies, then the array contents are partitioned
     631          in equal parts according to that number. Each part has the
     632          value that corresponds to each subspecies. Note that the
     633          order of the array contents is randomized.
     634          On the other hand, if the population is normally distributed
     635          with respect to its K50 value, then this
     636          distribution is reflected in the array contents.
     637        """
     638        phm_shape = (self.max_agents[proc.reacts_[0]],
     639                     self.max_agents[proc.reacts_[1]])
     640
     641        if isinstance(proc.regulating_species, list):  # multiple regulating species
     642            K50_vals = list()
     643            for i, Kval in enumerate(proc.K50):
     644                if not proc.is_heterogeneous_K50[i]:  # homogeneous process: one K50 value
     645                    K50_vals.append(np.full(phm_shape, Kval))
     646                else:  # heterogeneous process wrt K50
     647                    if isinstance(Kval, list):  # distinct subspecies wrt K50
     648                        K50_vals.append(self._gen_o2_distinct_subinteractions_vals(proc,
     649                                                                                   phm_shape,
     650                                                                                   het_attr='K50',
     651                                                                                   het_attr_idx=i))
     652                    else:  # K50 is a 2-tuple: normally-distributed
     653                        # Assuming K50 can only have integer values (output_type).
     654                        K50_vals.append(self._gen_o2_normal_vals(proc,
     655                                                                 phm_shape,
     656                                                                 het_attr='K50',
     657                                                                 het_attr_idx=i,
     658                                                                 output_type=int))
     659
     660        else:  # only one regulating species
     661            if not proc.is_heterogeneous_K50:  # homogeneous process: one K50 value
     662                K50_vals = np.full(phm_shape, proc.K50)
     663            else:  # heterogeneous process wrt K50
     664                if isinstance(proc.K50, list):  # distinct subspecies wrt K50
     665                    K50_vals = self._gen_o2_distinct_subinteractions_vals(proc, phm_shape,
     666                                                                          het_attr='K50')
     667                else:  # K50 is a 2-tuple: normally-distributed
     668                    # Assuming K50 can only have integer values (output_type).
     669                    K50_vals = self._gen_o2_normal_vals(proc, phm_shape,
     670                                                        het_attr='K50', output_type=int)
     671
     672        return K50_vals
     673
     674    def _gen_o2_distinct_subinteractions_vals(self, proc: Process | RegulatedProcess,
     675                                              phm_shape: tuple[int, int],
     676                                              *,
     677                                              het_attr: str = 'k',
     678                                              het_attr_idx: int = None):
     679        """
     680        For a 2nd order process with distinct subinteractions between
     681        the reactant agents, as determined by the parameter `het_attr`,
     682        this function generates a 2-dimensional array of parameter values
     683        with the desired subinteraction parameter values.
     684
     685        Parameters
     686        ----------
     687        *
     688        proc : Process or RegulatedProcess
     689            The 2nd order process whose subinteractions are considered.
     690        phm_shape : tuple of int, int
     691            The shape of the population heterogeneity matrix (phm) or
     692            array to be generated in this function.
     693        het_attr : str, default: 'k'
     694            The heterogeneity attribute whose values are to be generated.
     695            The default is the rate constant `k`. Other possible attributes
     696            include `K50` for a regulated process.
     697        het_attr_idx : int, default: None
     698            For cases where the attribute of a process that exhibits
     699            heterogeneity is a list, this is the index of the element
     700            within the list that is desired.
     701
     702        Notes
     703        -----
     704        Note that the **sample** mean and standard deviation of the
     705        generated values in the array will not be exactly the same as
     706        expected based on the discrete number of subinteractions.
     707        """
     708        if het_attr != 'k':
     709            assert hasattr(proc, het_attr), f"{proc} does not have attribute {het_attr}."
     710
     711        attr = getattr(proc, het_attr)
     712        if isinstance(attr, list) and het_attr_idx is not None:
     713            attr = attr[het_attr_idx]
     714
     715        num_subinteractions = len(attr)
     716
     717        k_overflow = False
     718        if len(set(proc.reacts_)) == 1:  # If reactants are the same (eg: A + A -> C)
     719            if num_subinteractions > phm_shape[0] * phm_shape[1] - np.min(phm_shape):
     720                k_overflow = True
     721        else:
     722            if num_subinteractions > phm_shape[0] * phm_shape[1]:
     723                k_overflow = True
     724
     725        if k_overflow:
     726            raise AssertionError(
     727                "The number of subinteractions cannot be greater than the "
     728                "total number of possible inter-agent interactions.")
     729
     730        """ `k_vals` has shape (num_agents_1, num_agents_2).
     731        Note that `num_agents_*` is the same as `max_agents` when setting
     732        up the data for a given species. So, this sets up the `het_attr` values
     733        for all interactions that may become available during a
     734        simulation when agents become 'alive'. """
     735        rng = np.random.default_rng(seed=self.random_state)
     736        vals = rng.choice(attr, size=phm_shape, replace=True)
     737        return vals
     738
     739    def _gen_o2_normal_vals(self, proc: Process | RegulatedProcess,
     740                            phm_shape: tuple[int, int],
     741                            *,
     742                            het_attr: str = 'k',
     743                            het_attr_idx: int = None,
     744                            output_type: type[float | int] = float,
     745                            max_tries: int = 1000):
     746        """
     747        For a 2nd order process where the subinteractions between
     748        the reactant agents are normally distributed
     749        with respect to a kinetic parameter value, this function
     750        generates the 2-dimensional array of parameter values with
     751        the desired population mean and standard deviation.
     752
     753        Parameters
     754        ----------
     755        proc : Process or RegulatedProcess
     756            The 2nd order process whose subinteractions are considered.
     757        phm_shape : tuple of int, int
     758            The shape of the population heterogeneity matrix (phm) or
     759            array to be generated in this function.
     760        het_attr : str, default: 'k'
     761            The heterogeneity attribute whose values are to be generated.
     762            The default is the rate constant `k`. Other possible attributes
     763            include `K50` for a regulated process.
     764        het_attr_idx : int, default: None
     765            For cases where the attribute of a process that exhibits
     766            heterogeneity is a list, this is the index of the element
     767            within the list that is desired.
     768        output_type : type
     769            The type of the numbers that will populate the generated
     770            array. The default is `float`. For integer values, the
     771            specified type should be `int`.
     772        max_tries : int, default: 1000
     773            The maximum number of times to try generating the array while
     774            checking that no negative values are in the array given the
     775            population mean and standard deviation. If this number is
     776            exceeded, an exception is raised.
     777
     778        Notes
     779        -----
     780        Note that the **sample** mean and standard deviation of the
     781        generated values in the array will not be exactly the same as
     782        specified in the `het_attr` process parameter.
     783        """
     784        if het_attr != 'k':
     785            assert hasattr(proc, het_attr), f"{proc} does not have attribute {het_attr}."
     786
     787        attr = getattr(proc, het_attr)
     788        if isinstance(attr, list) and het_attr_idx is not None:
     789            attr = attr[het_attr_idx]
     790
     791        rng = np.random.default_rng(seed=self.random_state)
     792
     793        tries = 0
     794        while tries <= max_tries:
     795            vals = rng.normal(*attr, size=phm_shape).astype(output_type)
     796            if not np.any(vals.flatten() <= 0):
     797                return vals
     798            else:
     799                tries += 1
     800        else:
     801            err_msg = f"Maximum number of tries ({max_tries}) to get non-negative " \
     802                      f"{het_attr} values in simulated process {proc.__str__()} " \
     803                      f"has been exceeded. Please reconsider the " \
     804                      f"distribution of {het_attr} values for this process."
     805            raise AssertionError(err_msg)
     806
     807    def _gen_algo_processes(self, procs):
     808        """
     809        Generate the list of processes that the algorithm will use
     810        when running the simulation.
     811
     812        Notes
     813        -----
     814        Convert a reversible process to two separate processes
     815        representing the forward and reverse reactions. Otherwise,
     816        keep all irreversible processes unchanged.
     817        """
     818        for proc in procs:
     819            if isinstance(proc, ReversibleProcess):
     820                forward_proc = Process(proc.reactants, proc.products, proc.k)
     821                reverse_proc = Process(proc.products, proc.reactants, proc.k_rev)
     822                self._algo_processes.extend([forward_proc, reverse_proc])
     823            else:
     824                self._algo_processes.append(proc)
     825
     826    def _gen_algo_sequence(self):
     827        """
     828        Generate the sequence of functions to be called for the
     829        algorithm to perform the simulation.
     830        """
     831        for proc in self._algo_processes:
     832            if proc.order == 0:
     833                if isinstance(proc, RegulatedProcess):
     834                    if isinstance(proc.regulating_species, list):
     835                        self.algo_sequence.append(partial(self._order_0_reg_gt1, proc))
     836                    else:
     837                        self.algo_sequence.append(partial(self._order_0_reg, proc))
     838                else:
     839                    self.algo_sequence.append(partial(self._order_0, proc))
     840            elif proc.order == 1:
     841                if isinstance(proc, MichaelisMentenProcess):
     842                    self.algo_sequence.append(partial(self._order_1_mm, proc))
     843                elif isinstance(proc, RegulatedMichaelisMentenProcess):
     844                    if isinstance(proc.regulating_species, list):
     845                        self.algo_sequence.append(partial(self._order_1_reg_mm_gt1, proc))
     846                    else:
     847                        self.algo_sequence.append(partial(self._order_1_reg_mm, proc))
     848                elif isinstance(proc, RegulatedProcess):
     849                    if isinstance(proc.regulating_species, list):
     850                        self.algo_sequence.append(partial(self._order_1_reg_gt1, proc))
     851                    else:
     852                        self.algo_sequence.append(partial(self._order_1_reg, proc))
     853                else:
     854                    self.algo_sequence.append(partial(self._order_1, proc))
     855            else:  # order 2
     856                if isinstance(proc, RegulatedProcess):
     857                    if isinstance(proc.regulating_species, list):
     858                        self.algo_sequence.append(partial(self._order_2_reg_gt1, proc))
     859                    else:
     860                        self.algo_sequence.append(partial(self._order_2_reg, proc))
     861                else:
     862                    self.algo_sequence.append(partial(self._order_2, proc))
     863
     864    def _parallel_run(self, num_workers=None):
     865        """
     866        Run the simulation `n` times in parallel through multithreading.
     867        Multithreading was chosen instead of multiprocessing because numpy
     868        functions typically release the GIL while performing their
     869        computations. In this case, multi-threading allows for parallelism
     870        without incurring any of the often significant costs in speed related
     871        to initializing and sharing memory/data between multiple processes.
     872
     873        From documentation of `concurrent.futures` module at
     874        https://docs.python.org/3/library/concurrent.futures.html :
     875        'Default value of max_workers is `min(32, os.cpu_count() + 4)`:
     876        This default value preserves at least 5 workers for I/O bound tasks.'
     877        """
     878        with ThreadPoolExecutor(max_workers=num_workers) as executor:
     879            executor.map(self._repeat_sim, range(self.n))
     880
     881    def _sequential_run(self):
     882        """
     883        Run the simulation sequentially `n` times
     884        (without parallelization), i.e., using a simple `for` loop.
     885        """
     886        for i in range(self.n):
     887            self._repeat_sim(i)
     888
     889    def _repeat_sim(self, r: int):
     890        """ Run a repetition (indexed `r`) of the simulation. """
     891        for t in range(1, self.t_steps + 1):
     892            for algo_fcn in self.algo_sequence:
     893                algo_fcn(r, t)
     894
     895            for sp in self.all_species:
     896                # Sum up population sizes at end of time step
     897                self.results[sp]['N'][t, r] = np.sum(self.rtd[sp].asv[r][1, :] > 0)
     898
     899                # Replace old with new agent-state-vector
     900                self.rtd[sp].apply_markov_property(r)
     901
     902        self.progress_bar.update(1)
     903
     904    def _order_0(self, proc: Process, r: int, t: int):
     905        """
     906        Determine the transition probability for a 0th order process `proc`
     907        in a single time step of an AbStochKin simulation and assess if a
     908        transition event occurs. Then update the agent-state vector
     909        `asv` accordingly.
     910        """
     911        if self.streams[r].random() < self.trans_p[proc]:
     912            available_agent = np.argmin(self.rtd[proc.prods_[0]].asv[r][0])
     913            self.rtd[proc.prods_[0]].asv[r][1, available_agent] = 1
     914
     915    def _order_0_reg(self, proc: RegulatedProcess, r: int, t: int):
     916        """
     917        Determine the transition probability for a 0th order process
     918        regulated by a single species
     919        in a single time step of an AbStochKin simulation.
     920        """
     921        ratio = self.results[proc.regulating_species]['N'][t - 1, r] / proc.K50
     922        mult = (1 + proc.alpha * ratio ** proc.nH) / (1 + ratio ** proc.nH)
     923
     924        self.trans_p[proc] = proc.k * mult * self.dt
     925
     926        self._order_0(proc, r, t)
     927
     928    def _order_0_reg_gt1(self, proc: RegulatedProcess, r: int, t: int):
     929        """
     930        Determine the transition probability for a regulated 0th order process
     931        where the number of regulating species in greater than 1,
     932        in a single time step of an AbStochKin simulation.
     933        """
     934        mult = 1
     935        for i, sp in enumerate(proc.regulating_species):
     936            ratio = self.results[sp]['N'][t - 1, r] / proc.K50[i]
     937            mult *= (1 + proc.alpha[i] * ratio ** proc.nH[i]) / (1 + ratio ** proc.nH[i])
     938
     939        self.trans_p[proc] = proc.k * mult * self.dt
     940
     941        self._order_0(proc, r, t)
     942
     943    def _order_1(self, proc: Process, r: int, t: int):
     944        """
     945        Determine the transition events for a 1st order process `proc`
     946        in a single time step of an AbStochKin simulation. Then update the
     947        agent-state vector `asv` accordingly.
     948        """
     949        # Get random numbers (rn) and transition probabilities (tp)
     950        rn, tp = self.rtd[proc.reacts_[0]].get_vals_o1(r,
     951                                                       self.streams[r],
     952                                                       self.trans_p[proc])
     953
     954        transition_events = rn < tp  # Determine all transition events in this time step
     955
     956        # Mark transitions in the ASV of the reactant species
     957        self.rtd[proc.reacts_[0]].asv[r][1, :] = np.where(transition_events,
     958                                                          0,
     959                                                          self.rtd[proc.reacts_[0]].asv[r][1, :])
     960
     961        num_events = np.sum(transition_events)
     962        # Mark transitions in the ASV of the product species
     963        for prod in proc.prods_:
     964            available_agents = np.nonzero(np.all(self.rtd[prod].asv[r] == 0, axis=0))[0]
     965            self.rtd[prod].asv[r][1, available_agents[:num_events]] = 1
     966
     967        if proc.is_heterogeneous:  # Now compute metrics of heterogeneity (k)
     968            new_k_vals = self.k_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
     969            self._compute_k_het_metrics(proc, new_k_vals, t, r)
     970
     971    def _order_1_mm(self, proc: MichaelisMentenProcess, r: int, t: int):
     972        """
     973        Determine the transition events for a 1st order Michaelis-Menten
     974        process `proc` in a single time step of an AbStochKin simulation.
     975        """
     976        mult = self.results[proc.catalyst]['N'][t - 1, r] / (
     977                self.results[proc.reacts_[0]]['N'][t - 1, r] + self.Km_vals[proc])
     978        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
     979
     980        self._order_1(proc, r, t)
     981
     982        if proc.is_heterogeneous_Km:  # Now compute metrics of heterogeneity (Km)
     983            new_Km_vals = self.Km_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
     984            self._compute_Km_het_metrics(proc, new_Km_vals, t, r)
     985
     986    def _order_1_reg(self, proc: RegulatedProcess, r: int, t: int):
     987        """
     988        Determine the transition events for a 1st order
     989        process `proc`, regulated by a single species,
     990        in a single time step of an AbStochKin simulation.
     991        """
     992        ratio = self.results[proc.regulating_species]['N'][t - 1, r] / self.K50_vals[proc]
     993        mult = (1 + proc.alpha * ratio ** proc.nH) / (1 + ratio ** proc.nH)
     994        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
     995        self._order_1(proc, r, t)
     996
     997        if proc.is_heterogeneous_K50:  # Now compute metrics of heterogeneity (K50)
     998            new_K50_vals = self.K50_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
     999            self._compute_K50_het_metrics(proc, new_K50_vals, t, r)
    1000
    1001    def _order_1_reg_gt1(self, proc: RegulatedProcess, r: int, t: int):
    1002        """
    1003        Determine the transition events for a 1st order
    1004        process `proc`, regulated by more than 1 species,
    1005        in a single time step of an AbStochKin simulation.
    1006        """
    1007        mult = 1
    1008        for i, sp in enumerate(proc.regulating_species):
    1009            ratio = self.results[sp]['N'][t - 1, r] / self.K50_vals[proc][i]
    1010            mult *= (1 + proc.alpha[i] * ratio ** proc.nH[i]) / (1 + ratio ** proc.nH[i])
    1011        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1012        self._order_1(proc, r, t)
    1013
    1014        for i in range(len(proc.regulating_species)):
    1015            if proc.is_heterogeneous_K50[i]:  # Now compute metrics of heterogeneity (K50)
    1016                new_K50_vals = self.K50_vals[proc][i] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1017                self._compute_K50_het_metrics(proc, new_K50_vals, t, r, idx=i)
    1018
    1019    def _order_1_reg_mm(self, proc: RegulatedMichaelisMentenProcess, r: int, t: int):
    1020        """
    1021        Determine the transition events for a 1st order
    1022        process `proc`, regulated by a single species and
    1023        obeying Michaelis-Menten kinetics,
    1024        in a single time step of an AbStochKin simulation.
    1025        """
    1026        ratio = self.results[proc.regulating_species]['N'][t - 1, r] / self.K50_vals[proc]
    1027        mult_reg = (1 + proc.alpha * ratio ** proc.nH) / (1 + ratio ** proc.nH)
    1028        mult_mm = self.results[proc.catalyst]['N'][t - 1, r] / (
    1029                self.results[proc.reacts_[0]]['N'][t - 1, r] + self.Km_vals[proc])
    1030        mult = mult_reg * mult_mm
    1031        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1032        self._order_1(proc, r, t)
    1033
    1034        if proc.is_heterogeneous_K50:  # Now compute metrics of heterogeneity (K50)
    1035            new_K50_vals = self.K50_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1036            self._compute_K50_het_metrics(proc, new_K50_vals, t, r)
    1037
    1038        if proc.is_heterogeneous_Km:  # Now compute metrics of heterogeneity (Km)
    1039            new_Km_vals = self.Km_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1040            self._compute_Km_het_metrics(proc, new_Km_vals, t, r)
    1041
    1042    def _order_1_reg_mm_gt1(self, proc: RegulatedMichaelisMentenProcess, r: int, t: int):
    1043        """
    1044        Determine the transition events for a 1st order
    1045        process `proc`, regulated by more than one species and
    1046        obeying Michaelis-Menten kinetics,
    1047        in a single time step of an AbStochKin simulation.
    1048        """
    1049        mult_reg = 1
    1050        for i, sp in enumerate(proc.regulating_species):
    1051            ratio = self.results[sp]['N'][t - 1, r] / self.K50_vals[proc][i]
    1052            mult_reg *= (1 + proc.alpha[i] * ratio ** proc.nH[i]) / (1 + ratio ** proc.nH[i])
    1053        mult_mm = self.results[proc.catalyst]['N'][t - 1, r] / (
    1054                self.results[proc.reacts_[0]]['N'][t - 1, r] + self.Km_vals[proc])
    1055        mult = mult_reg * mult_mm
    1056        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1057        self._order_1(proc, r, t)
    1058
    1059        for i in range(len(proc.regulating_species)):
    1060            if proc.is_heterogeneous_K50[i]:  # Now compute metrics of heterogeneity (K50)
    1061                new_K50_vals = self.K50_vals[proc][i] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1062                self._compute_K50_het_metrics(proc, new_K50_vals, t, r, idx=i)
    1063
    1064        if proc.is_heterogeneous_Km:  # Now compute metrics of heterogeneity (Km)
    1065            new_Km_vals = self.Km_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0)
    1066            self._compute_Km_het_metrics(proc, new_Km_vals, t, r)
    1067
    1068    def _order_2(self, proc: Process, r: int, t: int):
    1069        """
    1070        Determine the transition events for a 2nd order process `proc`
    1071        in a single time step of an AbStochKin simulation. Then update the
    1072        agent-state vector(s) `asv` accordingly.
    1073        """
    1074        # Get random numbers (rn) and transition probabilities (tp)
    1075        rn, tp = self.rtd[proc.reacts_[0]].get_vals_o2(self.rtd[proc.reacts_[1]],
    1076                                                       r,
    1077                                                       self.streams[r],
    1078                                                       self.trans_p[proc])
    1079
    1080        transition_events = rn < tp  # Determine all transition events in this time step
    1081
    1082        if np.sum(transition_events) > 1:  # IF multiple transition events
    1083            pairs = self._o2_get_unique_pairs(np.argwhere(transition_events))
    1084        else:  # only one or zero transition events
    1085            pairs = np.argwhere(transition_events)
    1086
    1087        # Mark transitions in the ASV of each species (or of the same species
    1088        # in the case of a homologous 2nd order process, 2A -> C).
    1089        for pair in pairs:
    1090            self.rtd[proc.reacts_[0]].asv[r][1, pair[0]] = 0
    1091            self.rtd[proc.reacts_[1]].asv[r][1, pair[1]] = 0
    1092
    1093        num_events = len(pairs)
    1094        # Mark transitions in the ASV of the product species
    1095        for prod in proc.prods_:
    1096            available_agents = np.nonzero(np.all(self.rtd[prod].asv[r] == 0, axis=0))[0]
    1097            self.rtd[prod].asv[r][1, available_agents[:num_events]] = 1
    1098
    1099        # Now compute metrics of heterogeneity for this time step (if applicable)
    1100        if proc.is_heterogeneous:
    1101            new_k_vals = self.k_vals[proc] * (self.rtd[proc.reacts_[0]].asv[r][1, :] > 0).reshape(
    1102                -1, 1) * (self.rtd[proc.reacts_[1]].asv[r][1, :] > 0)
    1103            self._compute_k_het_metrics(proc, new_k_vals, t, r)
    1104
    1105    def _order_2_reg(self, proc: RegulatedProcess, r: int, t: int):
    1106        """
    1107        Determine the transition events for a 2nd order
    1108        process `proc`, regulated by a single species,
    1109        in a single time step of an AbStochKin simulation.
    1110        """
    1111        ratio = self.results[proc.regulating_species]['N'][t - 1, r] / self.K50_vals[proc]
    1112        mult = (1 + proc.alpha * ratio ** proc.nH) / (1 + ratio ** proc.nH)
    1113        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1114
    1115        self._order_2(proc, r, t)
    1116
    1117        if proc.is_heterogeneous_K50:  # Now compute metrics of heterogeneity (K50)
    1118            new_K50_vals = self.K50_vals[proc] * (
    1119                    self.rtd[proc.reacts_[0]].asv[r][1, :] > 0).reshape(-1, 1) * (
    1120                                   self.rtd[proc.reacts_[1]].asv[r][1, :] > 0)
    1121            self._compute_K50_het_metrics(proc, new_K50_vals, t, r)
    1122
    1123    def _order_2_reg_gt1(self, proc: RegulatedProcess, r: int, t: int):
    1124        """
    1125        Determine the transition events for a 2nd order
    1126        process `proc`, regulated by more than 1 species,
    1127        in a single time step of an AbStochKin simulation.
    1128        """
    1129        mult = 1
    1130        for i, sp in enumerate(proc.regulating_species):
    1131            ratio = self.results[sp]['N'][t - 1, r] / self.K50_vals[proc][i]
    1132            mult *= (1 + proc.alpha[i] * ratio ** proc.nH[i]) / (1 + ratio ** proc.nH[i])
    1133        self.trans_p[proc] = 1 - np.exp(-1 * self.k_vals[proc] * mult * self.dt)
    1134
    1135        self._order_2(proc, r, t)
    1136
    1137        for i in range(len(proc.regulating_species)):
    1138            if proc.is_heterogeneous_K50[i]:  # Now compute metrics of heterogeneity (K50)
    1139                new_K50_vals = self.K50_vals[proc][i] * (
    1140                        self.rtd[proc.reacts_[0]].asv[r][1, :] > 0).reshape(-1, 1) * (
    1141                                       self.rtd[proc.reacts_[1]].asv[r][1, :] > 0)
    1142                self._compute_K50_het_metrics(proc, new_K50_vals, t, r, idx=i)
    1143
    1144    def _compute_k_het_metrics(self,
    1145                               proc: Process | MichaelisMentenProcess |
    1146                                     RegulatedProcess | RegulatedMichaelisMentenProcess,
    1147                               all_k_vals: np.array,
    1148                               t: int,
    1149                               r: int):
    1150        """
    1151        Calculate metrics of heterogeneity for a 1st or 2nd order process `proc`
    1152        with an array of agent-specific `k` values `all_k_vals`,
    1153        corresponding to time step `t` of repetition `r` of the simulation.
    1154        """
    1155        nonzero_k = all_k_vals[np.nonzero(all_k_vals)]
    1156
    1157        if nonzero_k.size != 0:
    1158            self.k_het_metrics[proc]['k_avg'][t, r] = np.mean(nonzero_k)
    1159            self.k_het_metrics[proc]['k_std'][t, r] = np.std(nonzero_k)
    1160            self.k_het_metrics[proc]['psi'][t, r] = idx_het(nonzero_k)
    1161
    1162    def _compute_Km_het_metrics(self,
    1163                                proc: MichaelisMentenProcess | RegulatedMichaelisMentenProcess,
    1164                                all_Km_vals: np.array,
    1165                                t: int,
    1166                                r: int):
    1167        """
    1168        Calculate metrics of heterogeneity for a 1st or 2nd order process `proc`
    1169        obeying Michaelis-Menten kinetics with an array of
    1170        agent-specific `Km` values `all_Km_vals`,
    1171        corresponding to time step `t` of repetition `r` of the simulation.
    1172        """
    1173        nonzero_Km = all_Km_vals[np.nonzero(all_Km_vals)]
    1174
    1175        if nonzero_Km.size != 0:
    1176            self.Km_het_metrics[proc]['Km_avg'][t, r] = np.mean(nonzero_Km)
    1177            self.Km_het_metrics[proc]['Km_std'][t, r] = np.std(nonzero_Km)
    1178            self.Km_het_metrics[proc]['psi'][t, r] = idx_het(nonzero_Km)
    1179
    1180    def _compute_K50_het_metrics(self,
    1181                                 proc: RegulatedProcess | RegulatedMichaelisMentenProcess,
    1182                                 all_K50_vals: np.array,
    1183                                 t: int,
    1184                                 r: int,
    1185                                 *,
    1186                                 idx: int = None):
    1187        """
    1188        Calculate metrics of heterogeneity for a regulated 1st or ***** order process `proc`
    1189        with an array of agent-specific `K50` values `all_K50_vals`,
    1190        corresponding to time step `t` of repetition `r` of the simulation.
    1191        """
    1192        nonzero_K50 = all_K50_vals[np.nonzero(all_K50_vals)]
    1193
    1194        if nonzero_K50.size != 0:
    1195            if idx is not None:
    1196                self.K50_het_metrics[proc][idx]['K50_avg'][t, r] = np.mean(nonzero_K50)
    1197                self.K50_het_metrics[proc][idx]['K50_std'][t, r] = np.std(nonzero_K50)
    1198                self.K50_het_metrics[proc][idx]['psi'][t, r] = idx_het(nonzero_K50)
    1199            else:
    1200                self.K50_het_metrics[proc]['K50_avg'][t, r] = np.mean(nonzero_K50)
    1201                self.K50_het_metrics[proc]['K50_std'][t, r] = np.std(nonzero_K50)
    1202                self.K50_het_metrics[proc]['psi'][t, r] = idx_het(nonzero_K50)
    1203
    1204    @staticmethod
    1205    def _o2_get_unique_pairs(i_pairs: np.array):
    1206        """
    1207        An agent of a species can only participate in one transition
    1208        event per time step, so this function ensures that multiple
    1209        transition events are not recorded for a given agent.
    1210        For instance, if agent 1 of species A was reported as
    1211        transitioning with agents 5 **and** 9 of species B as partners,
    1212        then only one of those interactions is kept.
    1213
    1214        Parameters
    1215        ----------
    1216        i_pairs : numpy.array, shape: (n x 2)
    1217            All interacting pairs of agents that have been reported as
    1218            transitioning in a time step.
    1219
    1220        Returns
    1221        -------
    1222        unique_pairs : numpy.array of numpy.array(s) of shape (2, )
    1223            Unique pairs of interacting agents.
    1224        """
    1225        agent_0 = set(i_pairs[:, 0])  # set of number of first agent in pairs
    1226        seen_agent_1 = set()  # set of second agent that is already paired up
    1227        unique_pairs = []
    1228
    1229        for a in agent_0:
    1230            for p in i_pairs:
    1231                if p[0] == a:
    1232                    if p[1] not in seen_agent_1:
    1233                        seen_agent_1.add(p[1])  # this agent_1 has been used
    1234                        unique_pairs.append(p)  # add to final list of pairs
    1235                        break
    1236                    else:
    1237                        continue
    1238
    1239        return np.array(unique_pairs)
    1240
    1241    def _compute_trajectory_stats(self):
    1242        """
    1243        Compute statistics on all the species trajectories
    1244        obtained through an ensemble of `n` simulations.
    1245        """
    1246
    1247        for i, sp in enumerate(list(self.de_calcs.odes.keys())):
    1248            self.results[sp]['N_avg'] = np.mean(self.results[sp]['N'], axis=1)
    1249            self.results[sp]['N_std'] = np.std(self.results[sp]['N'], axis=1)
    1250
    1251            # Avoid warning about division by zero:
    1252            n_avg_nozeros = np.where(self.results[sp]['N_avg'] == 0,
    1253                                     np.nan, self.results[sp]['N_avg'])
    1254
    1255            # Calculate the coefficient of variation, η:
    1256            self.results[sp]['eta'] = self.results[sp]['N_std'] / n_avg_nozeros
    1257            # Calculate η for a Poisson process:
    1258            self.results[sp]['eta_p'] = 1 / np.sqrt(n_avg_nozeros)
    1259
    1260            with contextlib.suppress(AttributeError):
    1261                # If the ODEs have been solved, then calculate R^2.
    1262                self.results[sp]['R^2'] = r_squared(self.results[sp]['N_avg'],
    1263                                                    self.de_calcs.odes_sol.sol(self.time).T[:, i])
    1264
    1265    def _compute_het_stats(self):
    1266        """ Compute statistics on process-specific metrics of heterogeneity. """
    1267        het_params = [self.k_het_metrics, self.Km_het_metrics, self.K50_het_metrics]
    1268        het_attrs = ['k', 'Km', 'K50']
    1269
    1270        for het_param, het_attr in zip(het_params, het_attrs):
    1271            for proc, data in het_param.items():
    1272                if isinstance(data, list):
    1273                    for i, datum in enumerate(data):
    1274                        if datum is not None:
    1275                            het_param[proc][i][f'<{het_attr}_avg>'] = np.mean(datum[f'{het_attr}_avg'], axis=1)
    1276                            het_param[proc][i][f'<{het_attr}_std>'] = np.mean(datum[f'{het_attr}_std'], axis=1)
    1277                            het_param[proc][i]['psi_avg'] = np.mean(datum['psi'], axis=1)
    1278                            het_param[proc][i]['psi_std'] = np.std(datum['psi'], axis=1)
    1279                else:
    1280                    het_param[proc][f'<{het_attr}_avg>'] = np.mean(data[f'{het_attr}_avg'], axis=1)
    1281                    het_param[proc][f'<{het_attr}_std>'] = np.mean(data[f'{het_attr}_std'], axis=1)
    1282                    het_param[proc]['psi_avg'] = np.mean(data['psi'], axis=1)
    1283                    het_param[proc]['psi_std'] = np.std(data['psi'], axis=1)
    1284
    1285    def _post_run_cleanup(self):
    1286        """ Delete `asv` for all species after the simulation is done
    1287        to free up the memory associated with this attribute. """
    1288        for sp in self.all_species:
    1289            self.rtd[sp].cleanup_asv()

Mixin class with methods necessary for running an AbStochKin simulation.