Module abstochkin.base

Base class, AbStochKin, for initializing and storing all data for performing stochastic simulations using the Agent-based Kinetics method. A simulation project can be initialized and run as follows:

Example

>>> from abstochkin import AbStochKin
>>> sim = AbStochKin()
>>> sim.add_process_from_str('A -> ', 0.2)  # degradation process
>>> sim.simulate(p0={'A': 100}, t_max=20)
>>> # All data for the above simulation is stored in <code>sim.sims\[0]</code>.
>>>
>>> # Now set up a new simulation without actually running it.
>>> sim.simulate(p0={'A': 10}, t_max=10, n=50, run=False)
>>> # All data for the new simulation is stored in <code>sim.sims\[1]</code>.
>>> # The simulation can then be manually run using methods
>>> # documented in the class <code>Simulation</code>.
Expand source code
""" Base class, AbStochKin, for initializing and storing all data for
performing stochastic simulations using the Agent-based Kinetics
method. A simulation project can be initialized and run as follows:

Example
-------
>>> from abstochkin import AbStochKin
>>> sim = AbStochKin()
>>> sim.add_process_from_str('A -> ', 0.2)  # degradation process
>>> sim.simulate(p0={'A': 100}, t_max=20)
>>> # All data for the above simulation is stored in `sim.sims[0]`.
>>>
>>> # Now set up a new simulation without actually running it.
>>> sim.simulate(p0={'A': 10}, t_max=10, n=50, run=False)
>>> # All data for the new simulation is stored in `sim.sims[1]`.
>>> # The simulation can then be manually run using methods
>>> # documented in the class `Simulation`.

"""
#  Copyright (c) 2023, Alex Plakantonakis.
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <http://www.gnu.org/licenses/>.

import re
from ast import literal_eval
from concurrent.futures import ProcessPoolExecutor
from typing import Any

from .het_calcs import get_het_processes
from .process import Process, MichaelisMentenProcess, ReversibleProcess, \
    RegulatedProcess, RegulatedMichaelisMentenProcess
from .simulation import Simulation


class AbStochKin:
    """ Base class for Agent-based Kinetics (AbStochKin) simulator.

    Attributes
    ----------
    time_unit : str, default : sec, optional
        A string of the time unit to be used for describing the kinetics
        of the given processes.
    processes : list
        A list of the processes that the AbStochKin object has.
    het_processes : list
        A list of the processes where population heterogeneity in one
        of the parameters is to be modeled. This list is a subset of
        the `processes` attribute.
    sims : list
        A list of all simulations performed for the given set of processes.
        Each member of the list is an object of the `Simulation` class and
        contains all data for that simulation.
    """

    def __init__(self, time_unit='sec'):
        self.time_unit = time_unit
        self.processes = list()
        self.het_processes = list()
        self.sims = list()

    def add_processes_from_file(self, filename: str):
        """ Add a batch of processes from a text file. """
        with open(filename) as f:
            lines = f.readlines()

        for line in lines:
            self.extract_process_from_str(line)

    def extract_process_from_str(self, process_str):
        """
        Extract a process and all of its specified parameters from a string.

        This functions parses a string specifying all values and parameters
        needed to define a process. It then creates a Process object
        based on the extracted data.
        """
        process_str = process_str.replace(' ', '').replace('"', '\'')

        proc_str = process_str.split(',')[0]

        # Extract string parameters first
        patt_str_params = r"(\w+)=('[\w\s,]+')"
        str_params = re.findall(patt_str_params, process_str)

        process_str_remain = re.sub(r"\w+='[\w\s,]+'", '', process_str)

        patt_num_params = r"(\w+)=([\[\(,.\s\d\)\]]+)"
        num_params = re.findall(patt_num_params, process_str_remain)

        all_params = list()
        for name, val_str in num_params + str_params:
            while val_str.endswith(','):
                val_str = val_str[:-1]
            all_params.append((name, literal_eval(val_str)))

        self.add_process_from_str(proc_str, **dict(all_params))

    def add_process_from_str(self,
                             process_str: str,
                             /,
                             k: float | int | list[float | int, ...] |
                                tuple[float | int, float | int],
                             **kwargs):
        """
        Add a process by specifying a string: 'reactants -> products'.
        Additional arguments determine if a specialized process
        (such as a reversible, regulated, or Michaelis-Menten process)
        is to be defined.
        """
        if '<->' in process_str or 'k_rev' in kwargs:  # reversible process
            self.processes.append(ReversibleProcess.from_string(process_str, k, **kwargs))
        elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
            self.processes.append(MichaelisMentenProcess.from_string(process_str, k, **kwargs))
        elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
            if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                self.processes.append(RegulatedMichaelisMentenProcess.from_string(process_str,
                                                                                  k, **kwargs))
            else:  # Regulated process
                self.processes.append(RegulatedProcess.from_string(process_str, k, **kwargs))
        else:  # simple unidirectional process
            self.processes.append(Process.from_string(process_str, k, **kwargs))

    def add_process(self,
                    reactants: dict,
                    products: dict,
                    /,
                    k: float | int | list[float | int, ...] | tuple[float | int, float | int],
                    **kwargs):
        """
        Add a process by using a dictionary for the reactants and products.
        Additional arguments determine if a specialized process
        (such as a reversible, regulated, or Michaelis-Menten process)
        is to be defined.
        """
        if 'k_rev' in kwargs:  # reversible process
            self.processes.append(ReversibleProcess(reactants, products, k, **kwargs))
        elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
            self.processes.append(MichaelisMentenProcess(reactants, products, k, **kwargs))
        elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
            if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                self.processes.append(
                    RegulatedMichaelisMentenProcess(reactants, products, k, **kwargs))
            else:  # Regulated process
                self.processes.append(RegulatedProcess(reactants, products, k, **kwargs))
        else:  # simple unidirectional process
            self.processes.append(Process(reactants, products, k, **kwargs))

    def del_process_from_str(self,
                             process_str: str,
                             /,
                             k: float | int | list[float | int, ...] | tuple[float | int],
                             **kwargs):
        """ Delete a process by specifying a string: 'reactants -> products'. """
        try:
            if '<->' in process_str or 'k_rev' in kwargs:  # reversible process
                self.processes.remove(ReversibleProcess.from_string(process_str, k, **kwargs))
            elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
                self.processes.remove(MichaelisMentenProcess.from_string(process_str, k, **kwargs))
            elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
                if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                    self.processes.remove(
                        RegulatedMichaelisMentenProcess.from_string(process_str, k, **kwargs))
                else:  # Regulated process
                    self.processes.remove(RegulatedProcess.from_string(process_str, k, **kwargs))
            else:  # simple unidirectional process
                self.processes.remove(Process.from_string(process_str, k, **kwargs))
        except ValueError:
            print("Process to be removed was not found.")
        else:
            print(f"Removed: {process_str}, k = {k}, kwargs = {kwargs}")

    def del_process(self,
                    reactants: dict,
                    products: dict,
                    /,
                    k: float | int | list[float | int, ...] | tuple[float | int, float | int],
                    **kwargs):
        """ Delete a process by using a dictionary for the reactants and products. """
        try:
            if 'k_rev' in kwargs:  # reversible process
                self.processes.remove(ReversibleProcess(reactants, products, k, **kwargs))
            elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
                self.processes.remove(MichaelisMentenProcess(reactants, products, k, **kwargs))
            elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
                if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                    self.processes.remove(
                        RegulatedMichaelisMentenProcess(reactants, products, k, **kwargs))
                else:  # Regulated process
                    self.processes.remove(RegulatedProcess(reactants, products, k, **kwargs))
            else:  # simple unidirectional process
                self.processes.remove(Process(reactants, products, k, **kwargs))
        except ValueError:
            print("Process to be removed was not found.")
        else:
            lhs, rhs = Process(reactants, products, k)._reconstruct_string()
            print(f"Removed: " + " -> ".join([lhs, rhs]) + f", k = {k}, kwargs = {kwargs}")

    def simulate(self,
                 /,
                 p0: dict,
                 t_max: float | int,
                 dt: float = 0.01,
                 n: int = 100,
                 *,
                 random_seed: int = 19,
                 solve_odes: bool = True,
                 ode_method: str = 'RK45',
                 run: bool = True,
                 show_plots: bool = True,
                 multithreading: bool = True,
                 max_agents_by_species: dict = None,
                 max_agents_multiplier: int = 2,
                 _return_simulation: bool = False):
        """
        Start an AbStochKin simulation by creating an instance of the class
        `Simulation`. The resulting object is appended to the list
        in the class attribute `AbStochKin.sims`.

        Parameters
        ----------
        p0 : dict[str: int]
            Dictionary specifying the initial population sizes of all
            species in the given processes.
        t_max : float or int
            Numerical value of the end of simulated time in the units
            specified in the class attribute `AbStochKin.time_unit`.
        dt : float, default: 0.1, optional
            The duration of the time interval that the simulation's
            algorithm considers. The current implementation only
            supports a fixed time step interval whose value is `dt`.
        n : int, default: 100, optional
            The number of repetitions of the simulation to be performed.
        random_seed : int, default: 19, optional
            A number used to seed the random number generator.
        solve_odes : bool, default: True, optional
            Specify whether to numerically solve the system of
            ODEs defined from the given set of processes.
        ode_method : str, default: RK45, optional
            Available ODE methods: RK45, RK23, DOP853, Radau, BDF, LSODA.
        run : bool, default: True, optional
            Specify whether to run an AbStochKin simulation.
        show_plots : bool, default: True, optional
            Specify whether to graph the results of the AbStochKin simulation.
        multithreading : bool, default: True, optional
            Specify whether to parallelize the simulation
            using multithreading. If `False`, the ensemble
            of simulations is run sequentially.
        max_agents_by_species : None or dict, default: dict
            Specification of the maximum number of agents that each
            species should have when running the simulation.
            If `None`, that a default approach will
            be taken by the class `Simulation` and the number
            for each species will be automatically determined
            (see method `Simulation._setup_runtime_data()` for details).
            The entries in the dictionary should be
            `species name (string): number (int)`.
        max_agents_multiplier : float or int, default: 2
            This parameter is used to calculate the maximum number of
            agents of each species that the simulation engine allocates
            memory for. This be determined by multiplying the maximum value
            of the ODE time trajectory for this species by the
            multiplier value specified here.
        _return_simulation : bool
            Determines if the `self.simulate` method returns a `Simulation`
            object or appends it to the list `self.sims`.
            Returning a `Simulation` object is needed when calling the method
            `simulate_series_in_parallel`.
        """

        if max_agents_by_species is None:
            max_agents_by_species = dict()

        self.het_processes = get_het_processes(self.processes)

        sim = Simulation(p0,
                         t_max,
                         dt,
                         n,
                         self.processes,
                         random_state=random_seed,
                         do_solve_ODEs=solve_odes,
                         ODE_method=ode_method,
                         do_run=run,
                         show_graphs=show_plots,
                         use_multithreading=multithreading,
                         max_agents=max_agents_by_species,
                         max_agents_multiplier=max_agents_multiplier,
                         time_unit=self.time_unit)

        if _return_simulation:
            assert run, "Must run individual simulations if a series of " \
                        "simulations is to be run with multiprocessing."

            # Set un-pickleable objects to None for data serialization to work
            sim.algo_sequence = None
            sim.progress_bar = None

            return sim
        else:
            self.sims.append(sim)

    def simulate_series_in_parallel(self,
                                    series_kwargs: list[dict[str, Any], ...],
                                    *,
                                    max_workers: int = None):
        """
        Perform a series of simulations in parallel by initializing
        separate processes. Each process runs a simulation and appends
        a `Simulation` object in the list `self.sims`.

        Parameters
        ----------
        series_kwargs : list of dict
            A list containing dictionaries of the keyword arguments for
            performing each simulation in the series. The number of elements
            in the list is the number of simulations that will be run.
        max_workers : int, default: None
            The maximum number of processes to be used for performing
            the given series of simulations. If None, then as many worker
            processes will be created as the machine has processors.

        Examples
        --------
        - Run a series of simulations by varying the initial population size of A.
        >>>  from abstochkin import AbStochKin
        >>>  sim = AbStochKin()
        >>>  sim.add_process_from_str("A -> B", 0.3, catalyst='E', Km=10)
        >>>  series_kwargs = [{"p0": {'A': i, 'B': 0, 'E': 10}, "t_max": 10} for i in range(40, 51)]
        >>> sim.simulate_series_in_parallel(series_kwargs)
        """
        extra_opts = {"show_plots": False, "_return_simulation": True}
        with ProcessPoolExecutor(max_workers=max_workers) as executor:
            futures = [executor.submit(self.simulate, **(kwargs | extra_opts)) for kwargs in
                       series_kwargs]
            for future in futures:
                self.sims.append(future.result())

Classes

class AbStochKin (time_unit='sec')

Base class for Agent-based Kinetics (AbStochKin) simulator.

Attributes

time_unit : str, default : sec, optional
A string of the time unit to be used for describing the kinetics of the given processes.
processes : list
A list of the processes that the AbStochKin object has.
het_processes : list
A list of the processes where population heterogeneity in one of the parameters is to be modeled. This list is a subset of the processes attribute.
sims : list
A list of all simulations performed for the given set of processes. Each member of the list is an object of the Simulation class and contains all data for that simulation.
Expand source code
class AbStochKin:
    """ Base class for Agent-based Kinetics (AbStochKin) simulator.

    Attributes
    ----------
    time_unit : str, default : sec, optional
        A string of the time unit to be used for describing the kinetics
        of the given processes.
    processes : list
        A list of the processes that the AbStochKin object has.
    het_processes : list
        A list of the processes where population heterogeneity in one
        of the parameters is to be modeled. This list is a subset of
        the `processes` attribute.
    sims : list
        A list of all simulations performed for the given set of processes.
        Each member of the list is an object of the `Simulation` class and
        contains all data for that simulation.
    """

    def __init__(self, time_unit='sec'):
        self.time_unit = time_unit
        self.processes = list()
        self.het_processes = list()
        self.sims = list()

    def add_processes_from_file(self, filename: str):
        """ Add a batch of processes from a text file. """
        with open(filename) as f:
            lines = f.readlines()

        for line in lines:
            self.extract_process_from_str(line)

    def extract_process_from_str(self, process_str):
        """
        Extract a process and all of its specified parameters from a string.

        This functions parses a string specifying all values and parameters
        needed to define a process. It then creates a Process object
        based on the extracted data.
        """
        process_str = process_str.replace(' ', '').replace('"', '\'')

        proc_str = process_str.split(',')[0]

        # Extract string parameters first
        patt_str_params = r"(\w+)=('[\w\s,]+')"
        str_params = re.findall(patt_str_params, process_str)

        process_str_remain = re.sub(r"\w+='[\w\s,]+'", '', process_str)

        patt_num_params = r"(\w+)=([\[\(,.\s\d\)\]]+)"
        num_params = re.findall(patt_num_params, process_str_remain)

        all_params = list()
        for name, val_str in num_params + str_params:
            while val_str.endswith(','):
                val_str = val_str[:-1]
            all_params.append((name, literal_eval(val_str)))

        self.add_process_from_str(proc_str, **dict(all_params))

    def add_process_from_str(self,
                             process_str: str,
                             /,
                             k: float | int | list[float | int, ...] |
                                tuple[float | int, float | int],
                             **kwargs):
        """
        Add a process by specifying a string: 'reactants -> products'.
        Additional arguments determine if a specialized process
        (such as a reversible, regulated, or Michaelis-Menten process)
        is to be defined.
        """
        if '<->' in process_str or 'k_rev' in kwargs:  # reversible process
            self.processes.append(ReversibleProcess.from_string(process_str, k, **kwargs))
        elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
            self.processes.append(MichaelisMentenProcess.from_string(process_str, k, **kwargs))
        elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
            if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                self.processes.append(RegulatedMichaelisMentenProcess.from_string(process_str,
                                                                                  k, **kwargs))
            else:  # Regulated process
                self.processes.append(RegulatedProcess.from_string(process_str, k, **kwargs))
        else:  # simple unidirectional process
            self.processes.append(Process.from_string(process_str, k, **kwargs))

    def add_process(self,
                    reactants: dict,
                    products: dict,
                    /,
                    k: float | int | list[float | int, ...] | tuple[float | int, float | int],
                    **kwargs):
        """
        Add a process by using a dictionary for the reactants and products.
        Additional arguments determine if a specialized process
        (such as a reversible, regulated, or Michaelis-Menten process)
        is to be defined.
        """
        if 'k_rev' in kwargs:  # reversible process
            self.processes.append(ReversibleProcess(reactants, products, k, **kwargs))
        elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
            self.processes.append(MichaelisMentenProcess(reactants, products, k, **kwargs))
        elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
            if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                self.processes.append(
                    RegulatedMichaelisMentenProcess(reactants, products, k, **kwargs))
            else:  # Regulated process
                self.processes.append(RegulatedProcess(reactants, products, k, **kwargs))
        else:  # simple unidirectional process
            self.processes.append(Process(reactants, products, k, **kwargs))

    def del_process_from_str(self,
                             process_str: str,
                             /,
                             k: float | int | list[float | int, ...] | tuple[float | int],
                             **kwargs):
        """ Delete a process by specifying a string: 'reactants -> products'. """
        try:
            if '<->' in process_str or 'k_rev' in kwargs:  # reversible process
                self.processes.remove(ReversibleProcess.from_string(process_str, k, **kwargs))
            elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
                self.processes.remove(MichaelisMentenProcess.from_string(process_str, k, **kwargs))
            elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
                if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                    self.processes.remove(
                        RegulatedMichaelisMentenProcess.from_string(process_str, k, **kwargs))
                else:  # Regulated process
                    self.processes.remove(RegulatedProcess.from_string(process_str, k, **kwargs))
            else:  # simple unidirectional process
                self.processes.remove(Process.from_string(process_str, k, **kwargs))
        except ValueError:
            print("Process to be removed was not found.")
        else:
            print(f"Removed: {process_str}, k = {k}, kwargs = {kwargs}")

    def del_process(self,
                    reactants: dict,
                    products: dict,
                    /,
                    k: float | int | list[float | int, ...] | tuple[float | int, float | int],
                    **kwargs):
        """ Delete a process by using a dictionary for the reactants and products. """
        try:
            if 'k_rev' in kwargs:  # reversible process
                self.processes.remove(ReversibleProcess(reactants, products, k, **kwargs))
            elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
                self.processes.remove(MichaelisMentenProcess(reactants, products, k, **kwargs))
            elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
                if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                    self.processes.remove(
                        RegulatedMichaelisMentenProcess(reactants, products, k, **kwargs))
                else:  # Regulated process
                    self.processes.remove(RegulatedProcess(reactants, products, k, **kwargs))
            else:  # simple unidirectional process
                self.processes.remove(Process(reactants, products, k, **kwargs))
        except ValueError:
            print("Process to be removed was not found.")
        else:
            lhs, rhs = Process(reactants, products, k)._reconstruct_string()
            print(f"Removed: " + " -> ".join([lhs, rhs]) + f", k = {k}, kwargs = {kwargs}")

    def simulate(self,
                 /,
                 p0: dict,
                 t_max: float | int,
                 dt: float = 0.01,
                 n: int = 100,
                 *,
                 random_seed: int = 19,
                 solve_odes: bool = True,
                 ode_method: str = 'RK45',
                 run: bool = True,
                 show_plots: bool = True,
                 multithreading: bool = True,
                 max_agents_by_species: dict = None,
                 max_agents_multiplier: int = 2,
                 _return_simulation: bool = False):
        """
        Start an AbStochKin simulation by creating an instance of the class
        `Simulation`. The resulting object is appended to the list
        in the class attribute `AbStochKin.sims`.

        Parameters
        ----------
        p0 : dict[str: int]
            Dictionary specifying the initial population sizes of all
            species in the given processes.
        t_max : float or int
            Numerical value of the end of simulated time in the units
            specified in the class attribute `AbStochKin.time_unit`.
        dt : float, default: 0.1, optional
            The duration of the time interval that the simulation's
            algorithm considers. The current implementation only
            supports a fixed time step interval whose value is `dt`.
        n : int, default: 100, optional
            The number of repetitions of the simulation to be performed.
        random_seed : int, default: 19, optional
            A number used to seed the random number generator.
        solve_odes : bool, default: True, optional
            Specify whether to numerically solve the system of
            ODEs defined from the given set of processes.
        ode_method : str, default: RK45, optional
            Available ODE methods: RK45, RK23, DOP853, Radau, BDF, LSODA.
        run : bool, default: True, optional
            Specify whether to run an AbStochKin simulation.
        show_plots : bool, default: True, optional
            Specify whether to graph the results of the AbStochKin simulation.
        multithreading : bool, default: True, optional
            Specify whether to parallelize the simulation
            using multithreading. If `False`, the ensemble
            of simulations is run sequentially.
        max_agents_by_species : None or dict, default: dict
            Specification of the maximum number of agents that each
            species should have when running the simulation.
            If `None`, that a default approach will
            be taken by the class `Simulation` and the number
            for each species will be automatically determined
            (see method `Simulation._setup_runtime_data()` for details).
            The entries in the dictionary should be
            `species name (string): number (int)`.
        max_agents_multiplier : float or int, default: 2
            This parameter is used to calculate the maximum number of
            agents of each species that the simulation engine allocates
            memory for. This be determined by multiplying the maximum value
            of the ODE time trajectory for this species by the
            multiplier value specified here.
        _return_simulation : bool
            Determines if the `self.simulate` method returns a `Simulation`
            object or appends it to the list `self.sims`.
            Returning a `Simulation` object is needed when calling the method
            `simulate_series_in_parallel`.
        """

        if max_agents_by_species is None:
            max_agents_by_species = dict()

        self.het_processes = get_het_processes(self.processes)

        sim = Simulation(p0,
                         t_max,
                         dt,
                         n,
                         self.processes,
                         random_state=random_seed,
                         do_solve_ODEs=solve_odes,
                         ODE_method=ode_method,
                         do_run=run,
                         show_graphs=show_plots,
                         use_multithreading=multithreading,
                         max_agents=max_agents_by_species,
                         max_agents_multiplier=max_agents_multiplier,
                         time_unit=self.time_unit)

        if _return_simulation:
            assert run, "Must run individual simulations if a series of " \
                        "simulations is to be run with multiprocessing."

            # Set un-pickleable objects to None for data serialization to work
            sim.algo_sequence = None
            sim.progress_bar = None

            return sim
        else:
            self.sims.append(sim)

    def simulate_series_in_parallel(self,
                                    series_kwargs: list[dict[str, Any], ...],
                                    *,
                                    max_workers: int = None):
        """
        Perform a series of simulations in parallel by initializing
        separate processes. Each process runs a simulation and appends
        a `Simulation` object in the list `self.sims`.

        Parameters
        ----------
        series_kwargs : list of dict
            A list containing dictionaries of the keyword arguments for
            performing each simulation in the series. The number of elements
            in the list is the number of simulations that will be run.
        max_workers : int, default: None
            The maximum number of processes to be used for performing
            the given series of simulations. If None, then as many worker
            processes will be created as the machine has processors.

        Examples
        --------
        - Run a series of simulations by varying the initial population size of A.
        >>>  from abstochkin import AbStochKin
        >>>  sim = AbStochKin()
        >>>  sim.add_process_from_str("A -> B", 0.3, catalyst='E', Km=10)
        >>>  series_kwargs = [{"p0": {'A': i, 'B': 0, 'E': 10}, "t_max": 10} for i in range(40, 51)]
        >>> sim.simulate_series_in_parallel(series_kwargs)
        """
        extra_opts = {"show_plots": False, "_return_simulation": True}
        with ProcessPoolExecutor(max_workers=max_workers) as executor:
            futures = [executor.submit(self.simulate, **(kwargs | extra_opts)) for kwargs in
                       series_kwargs]
            for future in futures:
                self.sims.append(future.result())

Methods

def add_process(self, reactants: dict, products: dict, /, k: float | int | list[float | int, ...] | tuple[float | int, float | int], **kwargs)

Add a process by using a dictionary for the reactants and products. Additional arguments determine if a specialized process (such as a reversible, regulated, or Michaelis-Menten process) is to be defined.

Expand source code
def add_process(self,
                reactants: dict,
                products: dict,
                /,
                k: float | int | list[float | int, ...] | tuple[float | int, float | int],
                **kwargs):
    """
    Add a process by using a dictionary for the reactants and products.
    Additional arguments determine if a specialized process
    (such as a reversible, regulated, or Michaelis-Menten process)
    is to be defined.
    """
    if 'k_rev' in kwargs:  # reversible process
        self.processes.append(ReversibleProcess(reactants, products, k, **kwargs))
    elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
        self.processes.append(MichaelisMentenProcess(reactants, products, k, **kwargs))
    elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
        if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
            self.processes.append(
                RegulatedMichaelisMentenProcess(reactants, products, k, **kwargs))
        else:  # Regulated process
            self.processes.append(RegulatedProcess(reactants, products, k, **kwargs))
    else:  # simple unidirectional process
        self.processes.append(Process(reactants, products, k, **kwargs))
def add_process_from_str(self, process_str: str, /, k: float | int | list[float | int, ...] | tuple[float | int, float | int], **kwargs)

Add a process by specifying a string: 'reactants -> products'. Additional arguments determine if a specialized process (such as a reversible, regulated, or Michaelis-Menten process) is to be defined.

Expand source code
def add_process_from_str(self,
                         process_str: str,
                         /,
                         k: float | int | list[float | int, ...] |
                            tuple[float | int, float | int],
                         **kwargs):
    """
    Add a process by specifying a string: 'reactants -> products'.
    Additional arguments determine if a specialized process
    (such as a reversible, regulated, or Michaelis-Menten process)
    is to be defined.
    """
    if '<->' in process_str or 'k_rev' in kwargs:  # reversible process
        self.processes.append(ReversibleProcess.from_string(process_str, k, **kwargs))
    elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
        self.processes.append(MichaelisMentenProcess.from_string(process_str, k, **kwargs))
    elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
        if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
            self.processes.append(RegulatedMichaelisMentenProcess.from_string(process_str,
                                                                              k, **kwargs))
        else:  # Regulated process
            self.processes.append(RegulatedProcess.from_string(process_str, k, **kwargs))
    else:  # simple unidirectional process
        self.processes.append(Process.from_string(process_str, k, **kwargs))
def add_processes_from_file(self, filename: str)

Add a batch of processes from a text file.

Expand source code
def add_processes_from_file(self, filename: str):
    """ Add a batch of processes from a text file. """
    with open(filename) as f:
        lines = f.readlines()

    for line in lines:
        self.extract_process_from_str(line)
def del_process(self, reactants: dict, products: dict, /, k: float | int | list[float | int, ...] | tuple[float | int, float | int], **kwargs)

Delete a process by using a dictionary for the reactants and products.

Expand source code
def del_process(self,
                reactants: dict,
                products: dict,
                /,
                k: float | int | list[float | int, ...] | tuple[float | int, float | int],
                **kwargs):
    """ Delete a process by using a dictionary for the reactants and products. """
    try:
        if 'k_rev' in kwargs:  # reversible process
            self.processes.remove(ReversibleProcess(reactants, products, k, **kwargs))
        elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
            self.processes.remove(MichaelisMentenProcess(reactants, products, k, **kwargs))
        elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
            if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                self.processes.remove(
                    RegulatedMichaelisMentenProcess(reactants, products, k, **kwargs))
            else:  # Regulated process
                self.processes.remove(RegulatedProcess(reactants, products, k, **kwargs))
        else:  # simple unidirectional process
            self.processes.remove(Process(reactants, products, k, **kwargs))
    except ValueError:
        print("Process to be removed was not found.")
    else:
        lhs, rhs = Process(reactants, products, k)._reconstruct_string()
        print(f"Removed: " + " -> ".join([lhs, rhs]) + f", k = {k}, kwargs = {kwargs}")
def del_process_from_str(self, process_str: str, /, k: float | int | list[float | int, ...] | tuple[float | int], **kwargs)

Delete a process by specifying a string: 'reactants -> products'.

Expand source code
def del_process_from_str(self,
                         process_str: str,
                         /,
                         k: float | int | list[float | int, ...] | tuple[float | int],
                         **kwargs):
    """ Delete a process by specifying a string: 'reactants -> products'. """
    try:
        if '<->' in process_str or 'k_rev' in kwargs:  # reversible process
            self.processes.remove(ReversibleProcess.from_string(process_str, k, **kwargs))
        elif 'catalyst' in kwargs and 'Km' in kwargs and 'regulating_species' not in kwargs:
            self.processes.remove(MichaelisMentenProcess.from_string(process_str, k, **kwargs))
        elif 'regulating_species' in kwargs and 'alpha' in kwargs and 'K50' in kwargs and 'nH' in kwargs:
            if 'catalyst' in kwargs and 'Km' in kwargs:  # Regulated MM process
                self.processes.remove(
                    RegulatedMichaelisMentenProcess.from_string(process_str, k, **kwargs))
            else:  # Regulated process
                self.processes.remove(RegulatedProcess.from_string(process_str, k, **kwargs))
        else:  # simple unidirectional process
            self.processes.remove(Process.from_string(process_str, k, **kwargs))
    except ValueError:
        print("Process to be removed was not found.")
    else:
        print(f"Removed: {process_str}, k = {k}, kwargs = {kwargs}")
def extract_process_from_str(self, process_str)

Extract a process and all of its specified parameters from a string.

This functions parses a string specifying all values and parameters needed to define a process. It then creates a Process object based on the extracted data.

Expand source code
def extract_process_from_str(self, process_str):
    """
    Extract a process and all of its specified parameters from a string.

    This functions parses a string specifying all values and parameters
    needed to define a process. It then creates a Process object
    based on the extracted data.
    """
    process_str = process_str.replace(' ', '').replace('"', '\'')

    proc_str = process_str.split(',')[0]

    # Extract string parameters first
    patt_str_params = r"(\w+)=('[\w\s,]+')"
    str_params = re.findall(patt_str_params, process_str)

    process_str_remain = re.sub(r"\w+='[\w\s,]+'", '', process_str)

    patt_num_params = r"(\w+)=([\[\(,.\s\d\)\]]+)"
    num_params = re.findall(patt_num_params, process_str_remain)

    all_params = list()
    for name, val_str in num_params + str_params:
        while val_str.endswith(','):
            val_str = val_str[:-1]
        all_params.append((name, literal_eval(val_str)))

    self.add_process_from_str(proc_str, **dict(all_params))
def simulate(self, /, p0: dict, t_max: float | int, dt: float = 0.01, n: int = 100, *, random_seed: int = 19, solve_odes: bool = True, ode_method: str = 'RK45', run: bool = True, show_plots: bool = True, multithreading: bool = True, max_agents_by_species: dict = None, max_agents_multiplier: int = 2)

Start an AbStochKin simulation by creating an instance of the class Simulation. The resulting object is appended to the list in the class attribute AbStochKin.sims.

Parameters

p0 : dict[str: int]
Dictionary specifying the initial population sizes of all species in the given processes.
t_max : float or int
Numerical value of the end of simulated time in the units specified in the class attribute AbStochKin.time_unit.
dt : float, default: 0.1, optional
The duration of the time interval that the simulation's algorithm considers. The current implementation only supports a fixed time step interval whose value is dt.
n : int, default: 100, optional
The number of repetitions of the simulation to be performed.
random_seed : int, default: 19, optional
A number used to seed the random number generator.
solve_odes : bool, default: True, optional
Specify whether to numerically solve the system of ODEs defined from the given set of processes.
ode_method : str, default: RK45, optional
Available ODE methods: RK45, RK23, DOP853, Radau, BDF, LSODA.
run : bool, default: True, optional
Specify whether to run an AbStochKin simulation.
show_plots : bool, default: True, optional
Specify whether to graph the results of the AbStochKin simulation.
multithreading : bool, default: True, optional
Specify whether to parallelize the simulation using multithreading. If False, the ensemble of simulations is run sequentially.
max_agents_by_species : None or dict, default: dict
Specification of the maximum number of agents that each species should have when running the simulation. If None, that a default approach will be taken by the class Simulation and the number for each species will be automatically determined (see method Simulation._setup_runtime_data() for details). The entries in the dictionary should be species name (string): number (int).
max_agents_multiplier : float or int, default: 2
This parameter is used to calculate the maximum number of agents of each species that the simulation engine allocates memory for. This be determined by multiplying the maximum value of the ODE time trajectory for this species by the multiplier value specified here.
_return_simulation : bool
Determines if the self.simulate method returns a Simulation object or appends it to the list self.sims. Returning a Simulation object is needed when calling the method simulate_series_in_parallel.
Expand source code
def simulate(self,
             /,
             p0: dict,
             t_max: float | int,
             dt: float = 0.01,
             n: int = 100,
             *,
             random_seed: int = 19,
             solve_odes: bool = True,
             ode_method: str = 'RK45',
             run: bool = True,
             show_plots: bool = True,
             multithreading: bool = True,
             max_agents_by_species: dict = None,
             max_agents_multiplier: int = 2,
             _return_simulation: bool = False):
    """
    Start an AbStochKin simulation by creating an instance of the class
    `Simulation`. The resulting object is appended to the list
    in the class attribute `AbStochKin.sims`.

    Parameters
    ----------
    p0 : dict[str: int]
        Dictionary specifying the initial population sizes of all
        species in the given processes.
    t_max : float or int
        Numerical value of the end of simulated time in the units
        specified in the class attribute `AbStochKin.time_unit`.
    dt : float, default: 0.1, optional
        The duration of the time interval that the simulation's
        algorithm considers. The current implementation only
        supports a fixed time step interval whose value is `dt`.
    n : int, default: 100, optional
        The number of repetitions of the simulation to be performed.
    random_seed : int, default: 19, optional
        A number used to seed the random number generator.
    solve_odes : bool, default: True, optional
        Specify whether to numerically solve the system of
        ODEs defined from the given set of processes.
    ode_method : str, default: RK45, optional
        Available ODE methods: RK45, RK23, DOP853, Radau, BDF, LSODA.
    run : bool, default: True, optional
        Specify whether to run an AbStochKin simulation.
    show_plots : bool, default: True, optional
        Specify whether to graph the results of the AbStochKin simulation.
    multithreading : bool, default: True, optional
        Specify whether to parallelize the simulation
        using multithreading. If `False`, the ensemble
        of simulations is run sequentially.
    max_agents_by_species : None or dict, default: dict
        Specification of the maximum number of agents that each
        species should have when running the simulation.
        If `None`, that a default approach will
        be taken by the class `Simulation` and the number
        for each species will be automatically determined
        (see method `Simulation._setup_runtime_data()` for details).
        The entries in the dictionary should be
        `species name (string): number (int)`.
    max_agents_multiplier : float or int, default: 2
        This parameter is used to calculate the maximum number of
        agents of each species that the simulation engine allocates
        memory for. This be determined by multiplying the maximum value
        of the ODE time trajectory for this species by the
        multiplier value specified here.
    _return_simulation : bool
        Determines if the `self.simulate` method returns a `Simulation`
        object or appends it to the list `self.sims`.
        Returning a `Simulation` object is needed when calling the method
        `simulate_series_in_parallel`.
    """

    if max_agents_by_species is None:
        max_agents_by_species = dict()

    self.het_processes = get_het_processes(self.processes)

    sim = Simulation(p0,
                     t_max,
                     dt,
                     n,
                     self.processes,
                     random_state=random_seed,
                     do_solve_ODEs=solve_odes,
                     ODE_method=ode_method,
                     do_run=run,
                     show_graphs=show_plots,
                     use_multithreading=multithreading,
                     max_agents=max_agents_by_species,
                     max_agents_multiplier=max_agents_multiplier,
                     time_unit=self.time_unit)

    if _return_simulation:
        assert run, "Must run individual simulations if a series of " \
                    "simulations is to be run with multiprocessing."

        # Set un-pickleable objects to None for data serialization to work
        sim.algo_sequence = None
        sim.progress_bar = None

        return sim
    else:
        self.sims.append(sim)
def simulate_series_in_parallel(self, series_kwargs: list[dict[str, typing.Any], ...], *, max_workers: int = None)

Perform a series of simulations in parallel by initializing separate processes. Each process runs a simulation and appends a Simulation object in the list self.sims.

Parameters

series_kwargs : list of dict
A list containing dictionaries of the keyword arguments for performing each simulation in the series. The number of elements in the list is the number of simulations that will be run.
max_workers : int, default: None
The maximum number of processes to be used for performing the given series of simulations. If None, then as many worker processes will be created as the machine has processors.

Examples

  • Run a series of simulations by varying the initial population size of A.
>>>  from abstochkin import AbStochKin
>>>  sim = AbStochKin()
>>>  sim.add_process_from_str("A -> B", 0.3, catalyst='E', Km=10)
>>>  series_kwargs = [{"p0": {'A': i, 'B': 0, 'E': 10}, "t_max": 10} for i in range(40, 51)]
>>> sim.simulate_series_in_parallel(series_kwargs)
Expand source code
def simulate_series_in_parallel(self,
                                series_kwargs: list[dict[str, Any], ...],
                                *,
                                max_workers: int = None):
    """
    Perform a series of simulations in parallel by initializing
    separate processes. Each process runs a simulation and appends
    a `Simulation` object in the list `self.sims`.

    Parameters
    ----------
    series_kwargs : list of dict
        A list containing dictionaries of the keyword arguments for
        performing each simulation in the series. The number of elements
        in the list is the number of simulations that will be run.
    max_workers : int, default: None
        The maximum number of processes to be used for performing
        the given series of simulations. If None, then as many worker
        processes will be created as the machine has processors.

    Examples
    --------
    - Run a series of simulations by varying the initial population size of A.
    >>>  from abstochkin import AbStochKin
    >>>  sim = AbStochKin()
    >>>  sim.add_process_from_str("A -> B", 0.3, catalyst='E', Km=10)
    >>>  series_kwargs = [{"p0": {'A': i, 'B': 0, 'E': 10}, "t_max": 10} for i in range(40, 51)]
    >>> sim.simulate_series_in_parallel(series_kwargs)
    """
    extra_opts = {"show_plots": False, "_return_simulation": True}
    with ProcessPoolExecutor(max_workers=max_workers) as executor:
        futures = [executor.submit(self.simulate, **(kwargs | extra_opts)) for kwargs in
                   series_kwargs]
        for future in futures:
            self.sims.append(future.result())