diff --git a/spatialpy/core/__init__.py b/spatialpy/core/__init__.py index 29087774..196f29e1 100644 --- a/spatialpy/core/__init__.py +++ b/spatialpy/core/__init__.py @@ -16,18 +16,33 @@ import logging from spatialpy.__version__ import __version__ -from .boundarycondition import * -from .cleanup import * -from .datafunction import * -from .domain import * -from .geometry import * -from .initialcondition import * -from .model import Model -from .parameter import * -from .reaction import * -from .result import * +from .boundarycondition import BoundaryCondition +from .cleanup import ( + cleanup_tempfiles, + cleanup_core_files, + cleanup_build_files, + cleanup_result_files +) +from .datafunction import DataFunction +from .domain import Domain +from .geometry import ( + Geometry, + GeometryAll, + GeometryExterior, + GeometryInterior +) +from .initialcondition import ( + InitialCondition, + PlaceInitialCondition, + UniformInitialCondition, + ScatterInitialCondition +) +from .model import Model, export_StochSS +from .parameter import Parameter +from .reaction import Reaction +from .result import Result from .spatialpyerror import * -from .species import * +from .species import Species from .timespan import TimeSpan from .visualization import Visualization from .vtkreader import * diff --git a/spatialpy/core/model.py b/spatialpy/core/model.py index b35b6345..d3ae5f65 100644 --- a/spatialpy/core/model.py +++ b/spatialpy/core/model.py @@ -17,14 +17,26 @@ #This module defines a model that simulates a discrete, stoachastic, mixed biochemical reaction network in python. import math +from typing import Set, Type from collections import OrderedDict import numpy import scipy +from spatialpy.core.domain import Domain +from spatialpy.core.species import Species +from spatialpy.core.initialcondition import ( + InitialCondition, + PlaceInitialCondition, + ScatterInitialCondition, + UniformInitialCondition +) +from spatialpy.core.parameter import Parameter +from spatialpy.core.reaction import Reaction +from spatialpy.core.boundarycondition import BoundaryCondition +from spatialpy.core.datafunction import DataFunction from spatialpy.core.timespan import TimeSpan from spatialpy.solvers.build_expression import BuildExpression - from spatialpy.core.spatialpyerror import ModelError def export_StochSS(spatialpy_model, filename=None, return_stochss_model=False): @@ -71,6 +83,15 @@ def __init__(self, name="spatialpy"): self.listOfSpecies = OrderedDict() self.listOfReactions = OrderedDict() + # Dictionaries with model element objects. + # Model element names are used as keys, and values are + # sanitized versions of the names/formulas. + # These dictionaries contain sanitized values and are for + # Internal use only + self._listOfParameters = OrderedDict() + self._listOfSpecies = OrderedDict() + self._listOfReactions = OrderedDict() + ###################### # Dict that holds flattended parameters and species for # evaluation of expressions in the scope of the model. @@ -79,8 +100,8 @@ def __init__(self, name="spatialpy"): ###################### self.domain = None - self.listOfDiffusionRestrictions = {} - self.listOfDataFunctions = [] + self.listOfDiffusionRestrictions = OrderedDict([]) + self.listOfDataFunctions = OrderedDict([]) self.listOfInitialConditions = [] self.listOfBoundaryConditions = [] @@ -103,7 +124,7 @@ def __str__(self): self.__update_diffusion_restrictions() except Exception: pass - self.__resolve_parameters() + self._resolve_all_parameters() divider = f"\n{'*'*10}\n" def decorate(header): @@ -147,22 +168,12 @@ def __eq__(self, other): self.listOfReactions == other.listOfReactions and \ self.name == other.name - def __problem_with_name(self, name): - if name in Model.reserved_names: - errmsg = f'Name "{name}" is unavailable. It is reserved for internal SpatialPy use. ' - errmsg += f'Reserved Names: ({Model.reserved_names}).' - raise ModelError(errmsg) - if name in self.listOfSpecies: - raise ModelError(f'Name "{name}" is unavailable. A species with that name exists.') - if name in self.listOfParameters: - raise ModelError(f'Name "{name}" is unavailable. A parameter with that name exists.') - if name.isdigit(): - raise ModelError(f'Name "{name}" is unavailable. Names must not be numeric strings.') - for special_character in Model.special_characters: - if special_character in name: - errmsg = f'Name "{name}" is unavailable. ' - errmsg += f'Names must not contain special characters: {Model.special_characters}.' - raise ModelError(errmsg) + def __getitem__(self, key): + if isinstance(key, str): + return self.get_element(key) + if hasattr(self.__class__, "__missing__"): + return self.__class__.__missing__(self, key) + raise KeyError(f"{key} is an invalid key.") def __apply_initial_conditions(self): # initalize @@ -266,107 +277,270 @@ def __get_expression_utility(self): } self.expr = BuildExpression(namespace=base_namespace, blacklist=["="], sanitize=True) + def __update_diffusion_restrictions(self): + for species in self.listOfSpecies.values(): + if isinstance(species.restrict_to, list): + self.listOfDiffusionRestrictions[species] = species.restrict_to + elif isinstance(species.restrict_to, str): + self.listOfDiffusionRestrictions[species] = [species.restrict_to] + def _ipython_display_(self, use_matplotlib=False): if self.domain is None: print(self) else: self.domain.plot_types(width="auto", height="auto", use_matplotlib=use_matplotlib) - def __resolve_parameters(self): - self.update_namespace() + def _problem_with_name(self, name): + names = Model.reserved_names + if name in Model.reserved_names: + raise ModelError( + f'Name "{name}" is unavailable. It is reserved for internal GillesPy use. Reserved Names: ({names}).' + ) + if name in self.listOfSpecies: + raise ModelError(f'Name "{name}" is unavailable. A species with that name exists.') + if name in self.listOfParameters: + raise ModelError(f'Name "{name}" is unavailable. A parameter with that name exists.') + if name in self.listOfReactions: + raise ModelError(f'Name "{name}" is unavailable. A reaction with that name exists.') + if name.isdigit(): + raise ModelError(f'Name "{name}" is unavailable. Names must not be numeric strings.') + for special_character in Model.special_characters: + if special_character in name: + chars = Model.special_characters + raise ModelError( + f'Name "{name}" is unavailable. Names must not contain special characters: {chars}.' + ) + + def _resolve_parameter(self, parameter): + try: + parameter.validate() + self.update_namespace() + parameter._evaluate(self.namespace) # pylint: disable=protected-access + except ModelError as err: + raise ModelError( + f"Could not add/resolve parameter: {parameter.name}, Reason given: {err}" + ) from err + + def _resolve_all_parameters(self): + for _, parameter in self.listOfParameters.items(): + self._resolve_parameter(parameter) + + def _resolve_reaction(self, reaction): + try: + reaction.validate() + + # If the rate parameter exists in the reaction, confirm that it is a part of the model + if reaction.marate is not None: + name = reaction.marate if isinstance(reaction.marate, str) else reaction.marate.name + if isinstance(reaction.marate, str) and not reaction.marate.replace(".", "", 1).isdigit(): + reaction.marate = self.get_parameter(name) + + # Confirm that all species in reactants are part of the model + for species in list(reaction.reactants.keys()): + stoichiometry = reaction.reactants[species] + name = species if isinstance(species, str) else species.name + stoich_spec = self.get_species(name) + if stoich_spec not in reaction.reactants: + reaction.reactants[stoich_spec] = stoichiometry + del reaction.reactants[species] + + # Confirm that all species in products are part of the model + for species in list(reaction.products.keys()): + stoichiometry = reaction.products[species] + name = species if isinstance(species, str) else species.name + stoich_spec = self.get_species(name) + if stoich_spec not in reaction.products: + reaction.products[stoich_spec] = stoichiometry + del reaction.products[species] + except ModelError as err: + raise ModelError(f"Could not add/resolve reaction: {reaction.name}, Reason given: {err}") from err + + def _resolve_all_reactions(self): + for _, reaction in self.listOfReactions.items(): + self._resolve_reaction(reaction) + + def update_namespace(self): + """ + Create a dict with flattened parameter and species objects. + """ + self.namespace = OrderedDict([]) for param in self.listOfParameters: - self.listOfParameters[param]._evaluate(self.namespace) # pylint: disable=protected-access + self.namespace[param] = self.listOfParameters[param].value - def __update_diffusion_restrictions(self): - for species in self.listOfSpecies.values(): - if isinstance(species.restrict_to, list): - self.listOfDiffusionRestrictions[species] = species.restrict_to - elif isinstance(species.restrict_to, str): - self.listOfDiffusionRestrictions[species] = [species.restrict_to] + def add(self, components): + """ + Adds a component, or list of components to the model. If a list is provided, Species + and Parameters are added before other components. Lists may contain any combination + of accepted types other than lists and do not need to be in any particular order. - def compile_prep(self): + :param components: The component or list of components to be added the the model. + :type components: Species, Parameters, Reactions, Domain, Data Function, \ + Initial Conditions, Boundary Conditions, and TimeSpan or list + + :returns: The components that were added to the model. + :rtype: Species, Parameters, Reactions, Domain, Data Function, \ + Initial Conditions, Boundary Conditions, TimeSpan, or list + + :raises ModelError: Component is invalid. """ - Make sure all paramters are evaluated to scalars, update the models diffusion restrictions, - create the models expression utility, and generate the domain list of type ids in preperation - of compiling the simulation files. + initialcondition_names = [ + PlaceInitialCondition.__name__, + ScatterInitialCondition.__name__, + UniformInitialCondition.__name__ + ] + if isinstance(components, list): + params = [] + others = [] + for component in components: + if isinstance(component, Species) or type(component).__name__ in Species.__name__: + self.add_species(component) + elif isinstance(component, Parameter) or type(component).__name__ in Parameter.__name__: + params.append(component) + else: + others.append(component) - :returns: The stoichiometric and dependency_graph - :rtype: tuple + for param in params: + self.add_parameter(param) + for component in others: + self.add(component) + elif isinstance(components, BoundaryCondition) or type(components).__name__ == BoundaryCondition.__name__: + self.add_boundary_condition(components) + elif isinstance(components, DataFunction) or type(components).__name__ == DataFunction.__name__: + self.add_data_function(components) + elif isinstance(components, Domain) or type(components).__name__ == Domain.__name__: + self.add_domain(components) + elif isinstance(components, InitialCondition) or type(components).__name__ in initialcondition_names: + self.add_initial_condition(components) + elif isinstance(components, Parameter) or type(components).__name__ == Parameter.__name__: + self.add_parameter(components) + elif isinstance(components, Reaction) or type(components).__name__ == Reaction.__name__: + self.add_reaction(components) + elif isinstance(components, Species) or type(components).__name__ == Species.__name__: + self.add_species(components) + elif isinstance(components, TimeSpan) or type(components).__name__ == TimeSpan.__name__: + self.timespan(components) + else: + raise ModelError(f"Unsupported component: {type(components)} is not a valid component.") + return components - :raises ModelError: Timestep size exceeds output frequency or Model is missing a domain + def get_element(self, name): """ - try: - self.tspan.validate(coverage="all") - except TimespanError as err: - raise ModelError(f"Failed to validate timespan. Reason given: {err}") from err + Get a model element specified by name. - if self.domain is None: - raise ModelError("The model's domain is not set. Use 'add_domain()'.") - self.domain.compile_prep() - - self.__update_diffusion_restrictions() - self.__apply_initial_conditions() - self.__resolve_parameters() + :param name: Name of the element to be returned. + :type name: str - sanitized_params = self.sanitized_parameter_names() - for species in self.listOfSpecies.values(): - diff_coeff = species.diffusion_coefficient - if isinstance(diff_coeff, str): - if diff_coeff not in sanitized_params: - raise ModelError(f"Parameterm {diff_coeff} doesn't exist.") - species.diffusion_coefficient = sanitized_params[diff_coeff] + :returns: The specified spatialpy.Model element. + :rtype: Species, Parameters, Reactions, Domain, Data Function, or TimeSpan + """ + if name in ("tspan", "timespan"): + return self.tspan + if name == "domain": + return self.domain + if name in self.listOfSpecies: + return self.get_species(name) + if name in self.listOfParameters: + return self.get_parameter(name) + if name in self.listOfReactions: + return self.get_reaction(name) + if name in self.listOfDataFunctions: + return self.get_data_function(name) + raise ModelError(f"{self.name} does not contain an element named {name}.") - self.__get_expression_utility() - stoich_matrix = self.__create_stoichiometric_matrix() - dep_graph = self.__create_dependency_graph() + def add_domain(self, domain): + """ + Add a spatial domain to the model - return stoich_matrix, dep_graph + :param domain: The Domain object to be added to the model + :type domain: spatialpy.core.domain.Domain - def add_species(self, obj): + :raises ModelError: Invalid Domain object + """ + if not isinstance(domain, Domain) and type(domain).__name__ != Domain.__name__: + raise ModelError( + "Unexpected parameter for add_domain. Parameter must be of type SpatialPy.Domain." + ) + + self.domain = domain + return domain + + def add_species(self, species): """ - Adds a species, or list of species to the model. Will return the added object upon success. + Adds a species, or list of species to the model. - :param obj: The species or list of species to be added to the model object. - :type obj: spatialpy.core.species.Species | list(spatialpy.core.species.Species + :param species: The species or list of species to be added to the model object. + :type species: spatialpy.core.species.Species | list(spatialpy.core.species.Species - :returns: Species object which was added to the model. + :returns: The species or list of species that were added to the model. :rtype: spatialpy.core.species.Species | list(spatialpy.core.species.Species) - :raises ModelError: If obj is not a spatialpy.core.species.Species + :raises ModelError: If an invalid species is provided or if Species.validate fails. """ - from spatialpy.core.species import Species # pylint: disable=import-outside-toplevel - if isinstance(obj, list): - for species in obj: - self.add_species(species) - elif isinstance(obj, Species) or type(obj).__name__ == 'Species': - self.__problem_with_name(obj.name) - self.species_map[obj] = len(self.listOfSpecies) - self.listOfSpecies[obj.name] = obj + if isinstance(species, list): + for spec in species: + self.add_species(spec) + elif isinstance(species, Species) or type(species).__name__ == "Species": + try: + species.validate() + self._problem_with_name(species.name) + self.species_map[species] = self.get_num_species() + self.listOfSpecies[species.name] = species + self._listOfSpecies[species.name] = f'S{len(self._listOfSpecies)}' + except ModelError as err: + errmsg = f"Could not add species: {species.name}, Reason given: {err}" + raise ModelError(errmsg) from err else: - raise ModelError("Unexpected parameter for add_species. Parameter must be Species or list of Species.") - return obj + errmsg = f"species must be of type Species or list of Species not {type(species)}" + raise ModelError(errmsg) + return species + + def delete_species(self, name): + """ + Removes a species object by name. + + :param name: Name of the species object to be removed + :type name: str + + :raises ModelError: If species is not part of the model. + """ + try: + self.listOfSpecies.pop(name) + if name in self._listOfSpecies: + self._listOfSpecies.pop(name) + except KeyError as err: + raise ModelError( + f"{self.name} does not contain a species named {name}." + ) from err def delete_all_species(self): """ - Remove all species from model.listOfSpecies. + Removes all species from the model object. """ self.listOfSpecies.clear() + self._listOfSpecies.clear() - def delete_species(self, obj): + def get_species(self, name): """ - Remove a Species from model.listOfSpecies. + Returns a species object by name. + + :param name: Name of the species object to be returned. + :type name: str - :param obj: Species object to be removed - :type obj: spatialpy.core.species.Species + :returns: The specified species object. + :rtype: spatialpy.core.species.Species + + :raises ModelError: If the species is not part of the model. """ - self.listOfSpecies.pop(obj) # raises key error if param is missing + if name not in self.listOfSpecies: + raise ModelError(f"Species {name} could not be found in the model.") + return self.listOfSpecies[name] def get_all_species(self): """ Returns a dictionary of all species in the model using names as keys. - :returns: A dictionary of all species in the form of {"species_name":Species_object} - :rtype: dict + :returns: A dict of all species in the model, in the form: {name : species object}. + :rtype: OrderedDict """ return self.listOfSpecies @@ -379,103 +553,151 @@ def get_num_species(self): """ return len(self.listOfSpecies) - def get_species(self, sname): - """ - Returns target species from model as object. - - :param sname: name of species to be returned. - :type sname: str - - :returns: The Species objected represented by given 'sname' - :rtype: spatialpy.core.species.Species - - :raises ModelError: if the model does not contain the requested species - """ - try: - return self.listOfSpecies[sname] - except KeyError as err: - raise ModelError(f"No species named {sname}") from err - def sanitized_species_names(self): """ Generate a dictionary mapping user chosen species names to simplified formats which will be used later on by SpatialPySolvers evaluating reaction propensity functions. :returns: the dictionary mapping user species names to their internal SpatialPy notation. - :rtype: dict + :rtype: OrderedDict """ species_name_mapping = OrderedDict([]) for i, name in enumerate(self.listOfSpecies.keys()): species_name_mapping[name] = f'x[{i}]' return species_name_mapping - def add_parameter(self,params): + def add_initial_condition(self, init_cond): """ - Add Parameter(s) to model.listOfParameters. Input can be either a single - Parameter object or a list of Parameter objects. + Add an initial condition object to the initialization of the model. - :param params: Parameter object or list of Parameters to be added. - :type params: spatialpy.core.parameter.Parameter | list(spatialpy.core.parameter.Parameter) + :param init_cond: Initial condition to be added. + :type init_cond: spatialpy.core.initialcondition.InitialCondition - :returns: Parameter object which has been added to the model. - :rtype: spatialpy.core.parameter.Parameter | list(spatialpy.core.parameter.Parameter) + :returns: The initial condition or list of initial conditions that were added to the model. + :rtype: spatialpy.core.initialcondition.InitialCondition | \ + list(spatialpy.core.initialcondition.InitialCondition) - :raises ModelError: if obj is not a spatialpy.core.parameter.Parameter + :raises ModelError: If an invalid initial condition is provided. """ - from spatialpy.core.parameter import Parameter # pylint: disable=import-outside-toplevel - if isinstance(params,list): - for param in params: - self.add_parameter(param) - elif isinstance(params, Parameter) or type(params).__name__ == 'Parameter': - self.__problem_with_name(params.name) - self.update_namespace() - params._evaluate(self.namespace) # pylint: disable=protected-access - self.listOfParameters[params.name] = params + names = [ + PlaceInitialCondition.__name__, + ScatterInitialCondition.__name__, + UniformInitialCondition.__name__ + ] + if isinstance(init_cond, list): + for initial_condition in init_cond: + self.add_initial_condition(initial_condition) + elif isinstance(init_cond, InitialCondition) or type(init_cond).__name__ in names: + self.listOfInitialConditions.append(init_cond) else: - errmsg = f"Parameter '{params.name}' needs to be of type '{Parameter}', it is of type '{params}'" + errmsg = f"init_cond must be of type InitialCondition or list of InitialCondition not {type(init_cond)}" raise ModelError(errmsg) - return params + return init_cond - def delete_all_parameters(self): + def delete_initial_condition(self, init_cond): """ - Remove all parameters from model.listOfParameters. + Removes an initial condition object from the model object. + + :param init_cond: initial condition object to be removed. + :type init_cond: spatialpy.core.InitialCondition + + :raises ModelError: If the initial condition is not part of the model. """ - self.listOfParameters.clear() + try: + index = self.listOfInitialConditions.index(init_cond) + self.listOfInitialConditions.pop(index) + except ValueError as err: + raise ModelError( + f"{self.name} does not contain this initial condition." + ) from err + + def delete_all_initial_conditions(self): + """ + Removes all initial conditions from the model object. + """ + self.listOfInitialConditions.clear() - def delete_parameter(self, obj): + def get_all_initial_conditions(self): """ - Remove a Parameter from model.listOfParameters. + Returns a list of all initial conditions in the model. - :param obj: Parameter object to be removed - :type obj: spatialpy.core.parameter.Parameter + :returns: A list of all initial conditions in the model. + :rtype: list """ - self.listOfParameters.pop(obj) + return self.listOfInitialConditions - def get_all_parameters(self): + def add_parameter(self, parameters): """ - Return a dictionary of all model parameters, indexed by name. + Adds a parameter, or list of parameters to the model. + + :param parameters: The parameter or list of parameters to be added to the model object. + :type parameters: spatialpy.core.parameter.Parameter | list(spatialpy.core.parameter.Parameter) + + :returns: A parameter or list of Parameters that were added to the model. + :rtype: spatialpy.core.parameter.Parameter | list(spatialpy.core.parameter.Parameter) - :returns: A dictionary of all model parameters in the form {'param_name':param_obj} - :rtype: dict + :raises ModelError: If an invalid parameter is provided or if Parameter.validate fails. """ - return self.listOfParameters + if isinstance(parameters, list): + for param in parameters: + self.add_parameter(param) + elif isinstance(parameters, Parameter) or type(parameters).__name__ == 'Parameter': + self._problem_with_name(parameters.name) + self._resolve_parameter(parameters) + self.listOfParameters[parameters.name] = parameters + self._listOfParameters[parameters.name] = f'P{len(self._listOfParameters)}' + else: + errmsg = f"parameters must be of type Parameter or list of Parameter not {type(parameters)}" + raise ModelError(errmsg) + return parameters + + def delete_parameter(self, name): + """ + Removes a parameter object by name. + + :param name: Name of the parameter object to be removed. + :type name: str + """ + try: + self.listOfParameters.pop(name) + if name in self._listOfParameters: + self._listOfParameters.pop(name) + except KeyError as err: + raise ModelError( + f"{self.name} does not contain a parameter named {name}" + ) from err + + def delete_all_parameters(self): + """ + Removes all parameters from model object. + """ + self.listOfParameters.clear() + self._listOfParameters.clear() - def get_parameter(self, pname): + def get_parameter(self, name): """ - Return the Parameter object from model associated with 'pname' + Returns a parameter object by name. - :param pname: Name of parameter to be returned - :type pname: str + :param name: Name of the parameter object to be returned + :type name: str - :returns: The Parameter object represented in the model by 'pname' + :returns: The specified parameter object. :rtype: Spatialpy.core.parameter.Parameter - :raises ModelError: No parameter named {pname} + :raises ModelError: If the parameter is not part of the model. """ - try: - return self.listOfParameters[pname] - except KeyError as err: - raise ModelError(f"No parameter named {pname}") from err + if name not in self.listOfParameters: + raise ModelError(f"Parameter {name} could not be found in the model.") + return self.listOfParameters[name] + + def get_all_parameters(self): + """ + Return a dictionary of all model parameters, indexed by name. + + :returns: A dict of all parameters in the model, in the form: {name : parameter object} + :rtype: OrderedDict + """ + return self.listOfParameters def sanitized_parameter_names(self): """ @@ -483,7 +705,7 @@ def sanitized_parameter_names(self): later on by SpatialPySolvers evaluating reaction propensity functions. :returns: the dictionary mapping user parameter names to their internal SpatialPy notation. - :rtype: dict + :rtype: OrderedDict """ parameter_name_mapping = OrderedDict() for i, name in enumerate(self.listOfParameters.keys()): @@ -491,52 +713,80 @@ def sanitized_parameter_names(self): parameter_name_mapping[name] = f'P{i}' return parameter_name_mapping - def add_reaction(self, reacs): + def add_reaction(self, reactions): """ - Add Reaction(s) to the model. Input can be single instance, a list of instances - or a dict with name, instance pairs. + Adds a reaction, or list of reactions to the model. - :param reacs: Reaction or list of Reactions to be added. - :type reacs: spatialpy.core.reaction.Reaction | list(spatialpy.core.reaction.Reaction) + :param reactions: The reaction or list of reactions to be added to the model object + :type reactions: spatialpy.core.reaction.Reaction | list(spatialpy.core.reaction.Reaction) - :returns: The Reaction object(s) added to the model + :returns: The reaction or list of reactions that were added to the model. :rtype: spatialpy.core.reaction.Reaction | list(spatialpy.core.reaction.Reaction) - :raises ModelError: if obj is not a spatialpy.core.reaction.Reaction + :raises ModelError: If an invalid reaction is provided or if Reaction.validate fails. """ - from spatialpy.core.reaction import Reaction # pylint: disable=import-outside-toplevel - if isinstance(reacs, list): - for reaction in reacs: + if isinstance(reactions, list): + for reaction in reactions: self.add_reaction(reaction) - elif isinstance(reacs, Reaction) or type(reacs).__name__ == "Reaction": - self.__problem_with_name(reacs.name) - reacs.initialize(self) - self.listOfReactions[reacs.name] = reacs + elif isinstance(reactions, Reaction) or type(reactions).__name__ == "Reaction": + self._problem_with_name(reactions.name) + self._resolve_reaction(reactions) + self.listOfReactions[reactions.name] = reactions + # Build Sanitized reaction as well + sanitized_reaction = reactions._create_sanitized_reaction( + len(self.listOfReactions), self._listOfSpecies, self._listOfParameters + ) + self._listOfReactions[reactions.name] = sanitized_reaction else: - raise ModelError("add_reaction() takes a spatialpy.Reaction object or list of objects") - return reacs + errmsg = f"reactions must be of type Reaction or list of Reaction not {type(reactions)}" + raise ModelError(errmsg) + return reactions + + def delete_reaction(self, name): + """ + Removes a reaction object by name. + + :param name: Name of the reaction object to be removed. + :type name: str + """ + try: + self.listOfReactions.pop(name) + if name in self._listOfReactions: + self._listOfReactions.pop(name) + except KeyError as err: + raise ModelError( + f"{self.name} does not contain a reaction named {name}" + ) from err def delete_all_reactions(self): """ - Remove all reactions from model.listOfReactions. + Removes all reactions from the model object. """ self.listOfReactions.clear() + self._listOfReactions.clear() - def delete_reaction(self, obj): + def get_reaction(self, rname): """ - Remove reaction from model.listOfReactions + Returns a reaction object by name. + + :param name: Name of the reaction object to be returned + :type name: str - :param obj: Reaction to be removed. - :type obj: spatialpy.core.reaction.Reaction + :returns: The specified reaction object. + :rtype: spatialpy.core.reaction.Reaction + + :raises ModelError: If the reaction is not part of the model. """ - self.listOfReactions.pop(obj) + if name not in self.listOfReactions: + raise ModelError(f"Reaction {name} could not be found in the model.") + return self.listOfReactions[name] def get_all_reactions(self): """ Returns a dictionary of all model reactions using names as keys. - :returns: A dictionary of reactions in the form of {'react_name':react_obj} - :rtype: dict + :returns: A dict of all reaction in the model, in the form: {name : reaction object}. + :rtype: OrderedDict """ return self.listOfReactions @@ -549,61 +799,147 @@ def get_num_reactions(self): """ return len(self.listOfReactions) - def get_reaction(self, rname): + def add_boundary_condition(self, bound_cond): """ - Retrieve a reaction object from the model by name + Add an boundary condition object to the model. - :param rname: name of the reaction to be returned - :type rname: str + :param bound_cond: Boundary condition to be added + :type bound_cond: spatialpy.core.boundarycondition.BoundaryCondition - :returns: The Reaction Object in the model represented by 'rname' - :rtype: spatialpy.core.reaction.Reaction + :returns: The boundary condition or list of boundary conditions that were added to the model. + :rtype: spatialpy.core.boundarycondition.BoundaryCondition | \ + list(spatialpy.core.boundarycondition.BoundaryCondition) + + :raises ModelError: If an invalid boundary conidition is provided. + """ + if isinstance(bound_cond, list): + for boundary_condition in bound_cond: + self.add_boundary_condition(boundary_condition) + elif isinstance(bound_cond, BoundaryCondition) or type(bound_cond).__name__ in "BoundaryCondition": + bound_cond.model = self + self.listOfBoundaryConditions.append(bound_cond) + else: + errmsg = f"bound_cond must be of type BoundaryCondition or list of BoundaryCondition not {type(bound_cond)}" + raise ModelError(errmsg) + return bound_cond + + def delete_boundary_condition(self, bound_cond): + """ + Removes an boundary condition object from the model object. + + :param bound_cond: boundary condition object to be removed. + :type bound_cond: spatialpy.core.BoundaryCondition - :raises ModelError: Could not find reaction + :raises ModelError: If the boundary condition is not part of the model. """ try: - return self.listOfReactions[rname] - except KeyError as err: - raise ModelError(f"No reaction named {rname}") from err + index = self.listOfBoundaryConditions.index(bound_cond) + self.listOfBoundaryConditions.pop(index) + except ValueError as err: + raise ModelError( + f"{self.name} does not contain this boundary condition." + ) from err + + def delete_all_boundary_conditions(self): + """ + Removes all boundary conditions from the model object. + """ + self.listOfBoundaryConditions.clear() - def run(self, number_of_trajectories=1, seed=None, timeout=None, - number_of_threads=None, debug_level=0, debug=False, profile=False): + def get_all_boundary_conditions(self): """ - Simulate the model. Returns a result object containing simulation results. + Returns a list of all boundary conditions in the model. - :param number_of_trajectories: How many trajectories should be run. - :type number_of_trajectories: int + :returns: A list of all boundary conditions in the model. + :rtype: list + """ + return self.listOfBoundaryConditions - :param seed: The random seed given to the solver. - :type seed: int + def add_data_function(self, data_function): + """ + Add a scalar spatial function to the simulation. This is useful if you have a + spatially varying input to your model. Argument is a instances of subclass of the + spatialpy.DataFunction class. It must implement a function 'map(point)' which takes a + the spatial positon 'point' as an array, and it returns a float value. - :param timeout: Number of seconds for simulation to run. Simulation will be - killed upon reaching timeout. - :type timeout: int + :param data_function: Data function to be added. + :type data_function: spatialpy.DataFunction - :param number_of_threads: The number threads the solver will use. - :type number_of_threads: int + :returns: DataFunction object(s) added tothe model. + :rtype: spatialpy.core.datafunction.DataFunction | list(spatialpy.core.datafunction.DataFunction) - :param debug_level: Level of output from the solver: 0, 1, or 2. Default: 0. - :type debug_level: int + :raises ModelError: Invalid DataFunction + """ + if isinstance(data_function, list): + for data_fn in data_function: + self.add_data_function(data_fn) + elif isinstance(data_function, DataFunction) or type(data_function).__name__ == 'DataFunction': + self._problem_with_name(data_function.name) + self.listOfDataFunctions[data_function.name] = data_function + else: + errmsg = f"data_function must be of type DataFunction or list of DataFunction not {type(data_function)}" + raise ModelError(errmsg) + return data_function - :param debug: Optional flag to print out additional debug info during simulation. - :type debug: bool + def delete_data_function(self, name): + """ + Removes an data function object from the model object. - :param profile: Optional flag to print out addtional performance profiling for simulation. - :type profile: bool + :param name: data function object to be removed. + :type name: spatialpy.core.DataFunction - :returns: A SpatialPy Result object containing simulation data. - :rtype: spatialpy.core.result.Result + :raises ModelError: If the data function is not part of the model. """ - from spatialpy.solvers.solver import Solver # pylint: disable=import-outside-toplevel + try: + self.listOfDataFunctions.pop(name) + except ValueError as err: + raise ModelError( + f"{self.name} does not contain a data function named {name}." + ) from err - sol = Solver(self, debug_level=debug_level) + def delete_all_data_functions(self): + """ + Removes all data functions from the model object. + """ + self.listOfDataFunctions.clear() - return sol.run(number_of_trajectories=number_of_trajectories, seed=seed, timeout=timeout, - number_of_threads=number_of_threads, debug=debug, profile=profile) + def get_data_function(self, name): + """ + Returns a data function object by name. + + :param name: Name of the data function object to be returned + :type name: str + + :returns: The specified data function object. + :rtype: spatialpy.core.datafunction.DataFunction + + :raises ModelError: If the data function is not part of the model. + """ + if name not in self.listOfDataFunctions: + raise ModelError(f"Data function {name} could not be found in the model.") + return self.listOfDataFunctions[name] + def get_all_data_functions(self): + """ + Returns a dict of all data functions in the model. + + :returns: A dict of all data functions in the model. + :rtype: OrderedDict + """ + return self.listOfDataFunctions + + def sanitized_data_function_names(self): + """ + Generate a dictionary mapping user chosen data function names to simplified formats which will be used + later on by SpatialPySolvers evaluating reaction propensity functions. + :returns: the dictionary mapping user data function names to their internal SpatialPy notation. + :rtype: OrderedDict + """ + data_fn_name_mapping = OrderedDict([]) + for i, name in enumerate(self.listOfDataFunctions.keys()): + data_fn_name_mapping[name] = f'data_fn[{i}]' + return data_fn_name_mapping def set_timesteps(self, output_interval, num_steps, timestep_size=None): """ @@ -642,84 +978,78 @@ def timespan(self, time_span, timestep_size=None): else: self.tspan = TimeSpan(time_span, timestep_size=timestep_size) - def add_domain(self, domain): + def compile_prep(self): """ - Add a spatial domain to the model + Make sure all paramters are evaluated to scalars, update the models diffusion restrictions, + create the models expression utility, and generate the domain list of type ids in preperation + of compiling the simulation files. - :param domain: The Domain object to be added to the model - :type domain: spatialpy.core.domain.Domain + :returns: The stoichiometric and dependency_graph + :rtype: tuple - :raises ModelError: Invalid Domain object + :raises ModelError: Timestep size exceeds output frequency or Model is missing a domain """ - from spatialpy.core.domain import Domain # pylint: disable=import-outside-toplevel - if not isinstance(domain,Domain) and type(domain).__name__ != 'Domain': - raise ModelError("Unexpected parameter for add_domain. Parameter must be a Domain.") + try: + self.tspan.validate(coverage="all") + except TimespanError as err: + raise ModelError(f"Failed to validate timespan. Reason given: {err}") from err - self.domain = domain + if self.domain is None: + raise ModelError("The model's domain is not set. Use 'add_domain()'.") + self.domain.compile_prep() + + self.__update_diffusion_restrictions() + self.__apply_initial_conditions() + self._resolve_all_parameters() + self._resolve_all_reactions() - def add_data_function(self, data_function): - """ - Add a scalar spatial function to the simulation. This is useful if you have a - spatially varying input to your model. Argument is a instances of subclass of the - spatialpy.DataFunction class. It must implement a function 'map(point)' which takes a - the spatial positon 'point' as an array, and it returns a float value. + sanitized_params = self.sanitized_parameter_names() + for species in self.listOfSpecies.values(): + diff_coeff = species.diffusion_coefficient + if isinstance(diff_coeff, str): + if diff_coeff not in sanitized_params: + raise ModelError(f"Parameterm {diff_coeff} doesn't exist.") + species.diffusion_coefficient = sanitized_params[diff_coeff] - :param data_function: Data function to be added. - :type data_function: spatialpy.DataFunction + self.__get_expression_utility() + stoich_matrix = self.__create_stoichiometric_matrix() + dep_graph = self.__create_dependency_graph() - :returns: DataFunction object(s) added tothe model. - :rtype: spatialpy.core.datafunction.DataFunction | list(spatialpy.core.datafunction.DataFunction) + return stoich_matrix, dep_graph - :raises ModelError: Invalid DataFunction + def run(self, number_of_trajectories=1, seed=None, timeout=None, + number_of_threads=None, debug_level=0, debug=False, profile=False): """ - from spatialpy.core.datafunction import DataFunction # pylint: disable=import-outside-toplevel - if isinstance(data_function, list): - for data_fn in data_function: - self.add_data_function(data_fn) - elif isinstance(data_function, DataFunction) or type(data_function).__name__ == 'DataFunction': - self.__problem_with_name(data_function.name) - self.listOfDataFunctions.append(data_function) - else: - errmsg = "Unexpected parameter for add_data_function. " - errmsg += "Parameter must be DataFunction or list of DataFunctions." - raise ModelError(errmsg) - return data_function + Simulate the model. Returns a result object containing simulation results. - def add_initial_condition(self, init_cond): - """ - Add an initial condition object to the initialization of the model. + :param number_of_trajectories: How many trajectories should be run. + :type number_of_trajectories: int - :param init_cond: Initial condition to be added - :type init_cond: spatialpy.core.initialcondition.InitialCondition - """ - self.listOfInitialConditions.append(init_cond) + :param seed: The random seed given to the solver. + :type seed: int - def add_boundary_condition(self, bound_cond): - """ - Add an boundary condition object to the model. + :param timeout: Number of seconds for simulation to run. Simulation will be + killed upon reaching timeout. + :type timeout: int - :param bound_cond: Boundary condition to be added - :type bound_cond: spatialpy.core.boundarycondition.BoundaryCondition - """ - bound_cond.model = self - self.listOfBoundaryConditions.append(bound_cond) + :param number_of_threads: The number threads the solver will use. + :type number_of_threads: int - def update_namespace(self): - """ - Create a dict with flattened parameter and species objects. - """ - for param in self.listOfParameters: - self.namespace[param]=self.listOfParameters[param].value + :param debug_level: Level of output from the solver: 0, 1, or 2. Default: 0. + :type debug_level: int - def sanitized_data_function_names(self): - """ - Generate a dictionary mapping user chosen data function names to simplified formats which will be used - later on by SpatialPySolvers evaluating reaction propensity functions. + :param debug: Optional flag to print out additional debug info during simulation. + :type debug: bool - :returns: the dictionary mapping user data function names to their internal SpatialPy notation. - :rtype: dict + :param profile: Optional flag to print out addtional performance profiling for simulation. + :type profile: bool + + :returns: A SpatialPy Result object containing simulation data. + :rtype: spatialpy.core.result.Result """ - data_fn_name_mapping = OrderedDict([]) - for i, data_fn in enumerate(self.listOfDataFunctions): - data_fn_name_mapping[data_fn.name] = f'data_fn[{i}]' - return data_fn_name_mapping + from spatialpy.solvers.solver import Solver # pylint: disable=import-outside-toplevel + + sol = Solver(self, debug_level=debug_level) + + return sol.run(number_of_trajectories=number_of_trajectories, seed=seed, timeout=timeout, + number_of_threads=number_of_threads, debug=debug, profile=profile) diff --git a/spatialpy/core/reaction.py b/spatialpy/core/reaction.py index 1e3f8a7b..d0404734 100644 --- a/spatialpy/core/reaction.py +++ b/spatialpy/core/reaction.py @@ -13,7 +13,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import ast import uuid +from json.encoder import JSONEncoder + +import numpy as np from spatialpy.core.species import Species from spatialpy.core.parameter import Parameter @@ -21,145 +25,280 @@ class Reaction(): """ - Models a biochemical reaction. A reaction conatains dictionaries of species (reactants and products) \ - and parameters. The reaction's propensity function needs to be evaluable and result in a \ - non-negative scalar value in the namespace defined by the union of its Reactant, Product and \ - Parameter dictionaries. For mass-action, zeroth, first \ - and second order reactions are supported, attempting to used higher orders will result in an error. - - :param name: String that the model is referenced by. + Models a biochemical reaction. A reaction conatains dictionaries of species + (reactants and products) and a parameters. The reaction's propensity functions + needs to be evaluable (and result in a non-negative scalar value) in the + namespace defined by the union of its Reactant and Product dictionaries and + its Parameter. For mass-action, zeroth, first and second order reactions are + supported, attempting to used higher orders will result in an error. + + :param name: The name by which the reaction is called (optional). :type name: str - :param reactants: Dictionary of {species:stoichiometry} of reaction reactants + :param reactants: The reactants that are consumed in the reaction, with stoichiometry. An + example would be {R1 : 1, R2 : 2} if the reaction consumes two of R1 and + one of R2, where R1 and R2 are Species objects. :type reactants: dict - - :param products: Dictionary of {species:stoichiometry} of reaction products + + :param products: The species that are created by the reaction event, with stoichiometry. Same format as reactants. :type products: dict - - :param propensity_function: String with the expression for the reaction's propensity + + :param propensity_function: The custom propensity function for the reaction. Must be evaluable in the + namespace of the reaction using C operations. :type propensity_function: str - - :param rate: if mass action, rate is a reference to a parameter instance. - :type rate: spatialpy.core.parameter.Parameter - - :param annotation: Description of the reaction (meta) + + :param ode_propensity_function: The custom ode propensity function for the reaction. Must be evaluable in the + namespace of the reaction using C operations. + :type ode_propensity_function: str + + :param rate: The rate of the mass-action reaction, take care to note the units. + :type rate: float + + :param annotation: An optional note about the reaction. :type annotation: str :param restrict_to: Restrict reaction execution to a type or list of types within the domain. :type restrict_to: int, str, list of ints or list of strs + + Notes + ---------- + For a species that is NOT consumed in the reaction but is part of a mass + action reaction, add it as both a reactant and a product. + + Mass-action reactions must also have a rate term added. Note that the input + rate represents the mass-action constant rate independent of volume. + + if a name is not provided for reactions, the name will be populated by the + model based on the order it was added. This could impact seeded simulation + results if the order of addition is not preserved. """ - def __init__(self, name="", reactants=None, products=None, propensity_function=None, - rate=None, annotation=None, restrict_to=None): + def __init__(self, name=None, reactants=None, products=None, propensity_function=None, + ode_propensity_function=None, rate=None, annotation=None, restrict_to=None): - if not isinstance(name, str) and name is not None: - raise ReactionError("Reaction name must be of type str.") if name is None or name == "": - self.name = f'rxn{uuid.uuid4()}'.replace('-', '_') - else: - self.name = name - - if not (reactants is None or isinstance(reactants, dict)): - raise ReactionError("Reaction reactants must be of type dict.") - self.reactants = {} if reactants is None else reactants + name = f'rxn{uuid.uuid4()}'.replace('-', '_') + if isinstance(propensity_function, (int, float)): + propensity_function = str(propensity_function) + if isinstance(ode_propensity_function, (int, float)): + ode_propensity_function = str(ode_propensity_function) + if isinstance(rate, (int, float)): + rate = str(rate) + if not (restrict_to is None or isinstance(restrict_to, list)): + restrict_to = [restrict_to] - if not (products is None or isinstance(products, dict)): - raise ReactionError("Reaction products must be of type dict.") - self.products = {} if products is None else products + self.name = name + self.reactants = {} + self.products = {} + self.marate = rate + self.annotation = annotation + self.propensity_function = propensity_function + self.ode_propensity_function = ode_propensity_function + self.restrict_to = restrict_to if restrict_to is None else [] - if not (isinstance(propensity_function, (str, int, float)) or propensity_function is None): - message = "Reaction propensity_function must be one of the following types: str, int, float, or None." - raise ReactionError(message) - if isinstance(propensity_function, (int, float)): - self.propensity_function = str(propensity_function) - else: - self.propensity_function = propensity_function + self.validate(reactants=reactants, products=products, restrict_to=restrict_to) - if not (isinstance(rate, (Parameter, str, int, float)) or rate is None or type(rate).__name__ == 'Parameter'): - message = "Reaction rate must be one of the following types: spatialpy.Parameter, str, int, float, or None." - raise ReactionError(message) - if isinstance(rate, (int, float)): - self.rate = str(rate) - else: - self.rate = rate + if reactants is not None: + for r in reactants: + rtype = type(r).__name__ + if rtype == 'Species': + self.reactants[r.name] = reactants[r] + else: + self.reactants[r] = reactants[r] - if not (restrict_to is None or isinstance(restrict_to, (int, str, list))): - errmsg = f"Reaction {self.name}: restrict_to must be an int, str, list of ints or list of strs." - raise ReactionError(errmsg) - elif restrict_to is not None and isinstance(restrict_to, (int, str)): - restrict_to = [restrict_to] + if products is not None: + for p in products: + rtype = type(p).__name__ + if rtype == 'Species': + self.products[p.name] = products[p] + else: + self.products[p] = products[p] - if restrict_to is None: - self.restrict_to = restrict_to + if self.marate is not None: + rtype = type(self.marate).__name__ + if rtype == 'Parameter': + self.marate = self.marate.name + self.massaction = True + self.type = "mass-action" + self._create_mass_action() else: - self.restrict_to = [] + self.massaction = False + self.type = "customized" + if self.propensity_function is None: + self.propensity_function = self.ode_propensity_function + if self.ode_propensity_function is None: + self.ode_propensity_function = self.propensity_function + propensity = self._create_custom_propensity(self.propensity_function) + self.propensity_function = propensity + ode_propensity = self._create_custom_propensity(self.ode_propensity_function) + self.ode_propensity_function = ode_propensity + + if restrict_to is not None: for type_id in restrict_to: self.restrict_to.append(f"type_{type_id}") - self.type = None - self.marate = None - self.massaction = None - self.ode_propensity_function = None - self.annotation = annotation + self.validate(coverage="initialized") def __str__(self): - print_string = f"{self.name}, Active in: {str(self.restrict_to)}" + print_string = self.name if len(self.reactants): - print_string += "\n\tReactants" - for species, stoichiometry in self.reactants.items(): - name = species if isinstance(species, str) else species.name - print_string += f"\n\t\t{name}: {stoichiometry}" + print_string += '\n\tReactants' + for r, stoich in self.reactants.items(): + try: + if isinstance(r, str): + print_string += '\n\t\t' + r + ': ' + str(stoich) + else: + print_string += '\n\t\t' + r.name + ': ' + str(stoich) + except Exception as e: + print_string += '\n\t\t' + r + ': ' + 'INVALID - ' + str(e) if len(self.products): - print_string += "\n\tProducts" - for species, stoichiometry in self.products.items(): - name = species if isinstance(species, str) else species.name - print_string += f"\n\t\t{name}: {stoichiometry}" - print_string += f"\n\tPropensity Function: {self.propensity_function}" + print_string += '\n\tProducts' + for p, stoich in self.products.items(): + try: + if isinstance(p, str): + print_string += '\n\t\t' + p + ': ' + str(stoich) + else: + print_string += '\n\t\t' + p.name + ': ' + str(stoich) + except Exception as e: + print_string += '\n\t\t' + p + ': ' + 'INVALID - ' + str(e) + print_string += '\n\tPropensity Function: ' + self.propensity_function return print_string + class __ExpressionParser(ast.NodeTransformer): + def visit_BinOp(self, node): + node.left = self.visit(node.left) + node.right = self.visit(node.right) + if isinstance(node.op, (ast.BitXor, ast.Pow)): + # ast.Call calls defined function, args include which nodes + # are effected by function call + pow_func = ast.parse("pow", mode="eval").body + call = ast.Call(func=pow_func, + args=[node.left, node.right], + keywords=[]) + # Copy_location copies lineno and coloffset attributes + # from old node to new node. ast.copy_location(new_node,old_node) + call = ast.copy_location(call, node) + # Return changed node + return call + # No modification to node, classes extending NodeTransformer methods + # Always return node or value + else: + return node + + def visit_Name(self, node): + # Visits Name nodes, if the name nodes "id" value is 'e', replace with numerical constant + if node.id == 'e': + nameToConstant = ast.copy_location(ast.Num(float(np.e), ctx=node.ctx), node) + return nameToConstant + return node + + class __ToString(ast.NodeVisitor): + substitutions = { + "ln": "log", + } + + def __init__(self): + self.string = '' + + def _string_changer(self, addition): + self.string += addition + + def visit_BinOp(self, node): + self._string_changer('(') + self.visit(node.left) + self.visit(node.op) + self.visit(node.right) + self._string_changer(')') + + def visit_Name(self, node): + self._string_changer(node.id) + self.generic_visit(node) + + def visit_Num(self, node): + self._string_changer(str(node.n)) + self.generic_visit(node) + + def visit_Call(self, node): + func_name = self.substitutions.get(node.func.id) \ + if node.func.id in self.substitutions \ + else node.func.id + self._string_changer(func_name + '(') + counter = 0 + for arg in node.args: + if counter > 0: + self._string_changer(',') + self.visit(arg) + counter += 1 + self._string_changer(')') + + def visit_Add(self, node): + self._string_changer('+') + self.generic_visit(node) + + def visit_Div(self, node): + self._string_changer('/') + self.generic_visit(node) + + def visit_Mult(self, node): + self._string_changer('*') + self.generic_visit(node) + + def visit_UnaryOp(self, node): + self._string_changer('(') + self.visit_Usub(node) + self._string_changer(')') + + def visit_Sub(self, node): + self._string_changer('-') + self.generic_visit(node) + + def visit_Usub(self, node): + self._string_changer('-') + self.generic_visit(node) def _create_mass_action(self): """ - Create a mass action propensity function given self.reactants and a single parameter value. - - We support zeroth, first and second order propensities only. - There is no theoretical justification for higher order propensities. - Users can still create such propensities if they really want to, - but should then use a custom propensity. + Initializes the mass action propensity function given + self.reactants and a single parameter value. """ + # We support zeroth, first and second order propensities only. + # There is no theoretical justification for higher order propensities. + # Users can still create such propensities if they really want to, + # but should then use a custom propensity. total_stoch = 0 - for reactant in self.reactants: + for reactant in sorted(self.reactants): total_stoch += self.reactants[reactant] if total_stoch > 2: raise ReactionError( - """Reaction: A mass-action reaction cannot involve more than two of one species or one " - of two species (no more than 2 total reactants). - SpatialPy support zeroth, first and second order propensities only. - There is no theoretical justification for higher order propensities. - Users can still create such propensities using a 'custom propensity'.""") + """ + Reaction: A mass-action reaction cannot involve more than two of + one species or one of two species. To declare a custom propensity, + replace 'rate' with 'propensity_function'. + """ + ) + # Case EmptySet -> Y - self.type = "mass-action" - rtype = type(self.rate).__name__ - if rtype == 'Parameter': - self.marate = self.rate.name - elif rtype in ('int', 'float'): - self.marate = str(self.rate) + if isinstance(self.marate, Parameter) or type(self.marate).__name__ == "Parameter": + propensity_function = self.marate.name + ode_propensity_function = self.marate.name else: - self.marate = self.rate - - propensity_function = self.marate - ode_propensity_function = self.marate + if isinstance(self.marate, (int, float)): + self.marate = str(self.marate) + propensity_function = self.marate + ode_propensity_function = self.marate # There are only three ways to get 'total_stoch==2': - for reactant in self.reactants: + for reactant, stoichiometry in self.reactants.items(): + if isinstance(reactant, Species) or type(reactant).__name__ == "Species": + reactant = reactant.name # Case 1: 2X -> Y - if self.reactants[reactant] == 2: - propensity_function = f"0.5 * {propensity_function} * {reactant.name} * ({reactant.name} - 1) / vol" - ode_propensity_function += f" * {reactant.name} * {reactant.name}" + if stoichiometry == 2: + propensity_function = f"0.5 * {propensity_function} * {reactant} * ({reactant} - 1) / vol" + ode_propensity_function += f" * {reactant} * {reactant}" else: # Case 3: X1, X2 -> Y; - propensity_function += f" * {reactant.name}" - ode_propensity_function += f" * {reactant.name}" + propensity_function += f" * {reactant}" + ode_propensity_function += f" * {reactant}" # Set the volume dependency based on order. order = len(self.reactants) @@ -168,8 +307,24 @@ def _create_mass_action(self): elif order == 0: propensity_function += " * vol" - self.propensity_function = propensity_function - self.ode_propensity_function = ode_propensity_function + self.propensity_function = self._create_custom_propensity(propensity_function=propensity_function) + self.ode_propensity_function = self._create_custom_propensity(propensity_function=ode_propensity_function) + + def _create_custom_propensity(self, propensity_function): + expr = propensity_function.replace('^', '**') + expr = ast.parse(expr, mode='eval') + expr = self.__ExpressionParser().visit(expr) + + newFunc = self.__ToString() + newFunc.visit(expr) + return newFunc.string + + def _create_sanitized_reaction(self, n_ndx, species_mappings, parameter_mappings): + name = f"R{n_ndx}" + reactants = {species_mappings[species.name]: self.reactants[species] for species in self.reactants} + products = {species_mappings[species.name]: self.products[species] for species in self.products} + propensity_function = self.sanitized_propensity_function(species_mappings, parameter_mappings) + return Reaction(name=name, reactants=reactants, products=products, propensity_function=propensity_function) def add_product(self, species, stoichiometry): """ @@ -181,11 +336,13 @@ def add_product(self, species, stoichiometry): :param stoichiometry: Stoichiometry of this product. :type stoichiometry: int """ - if not (isinstance(species, (str, Species)) or type(species).__name__ == 'Species'): - raise ReactionError("species must be of type string or SpatialPy.Species. ") - if not isinstance(stoichiometry, int) or stoichiometry <= 0: - raise ReactionError("Stoichiometry must be a positive integer.") - name = species if isinstance(species, str) else species.name + name = species.name if type(species).__name__ == 'Species' else species + + try: + self.validate(products={name: stoichiometry}, coverage="products") + except TypeError as err: + raise ReactionError(f"Failed to validate product. Reason given: {err}") from err + self.products[name] = stoichiometry def add_reactant(self, species, stoichiometry): @@ -198,77 +355,328 @@ def add_reactant(self, species, stoichiometry): :param stoichiometry: Stoichiometry of this participant reactant :type stoichiometry: int """ - if not (isinstance(species, (str, Species)) or type(species).__name__ == 'Species'): - raise ReactionError("Species must be of type string or spatialpy.Species. ") - if not isinstance(stoichiometry, int) or stoichiometry <= 0: - raise ReactionError("Stoichiometry must be a positive integer.") - name = species if isinstance(species, str) else species.name + name = species.name if type(species).__name__ == 'Species' else species + + try: + self.validate(reactants={name: stoichiometry}, coverage="reactants") + except TypeError as err: + raise ReactionError(f"Failed to validate reactant. Reason given: {err}") from err + self.reactants[name] = stoichiometry + if self.massaction and self.type == "mass-action": + self._create_mass_action() + + self.validate(coverage="initialized") + + @classmethod + def from_json(cls, json_object): + new = Reaction.__new__(Reaction) + new.__dict__ = json_object - def annotate(self, annotation): + # Same as in to_dict(), but we need to reverse it back into its original representation. + new.products = { x["key"]: x["value"] for x in json_object["products"] } + new.reactants = { x["key"]: x["value"] for x in json_object["reactants"] } + + if new.massaction and new.type == "mass-action" and new.marate is not None: + new._create_mass_action() + + new.validate(coverage="all") + + return new + + def sanitized_propensity_function(self, species_mappings, parameter_mappings, ode=False): + names = sorted(list(species_mappings.keys()) + list(parameter_mappings.keys()), key=lambda x: len(x), + reverse=True) + replacements = [parameter_mappings[name] if name in parameter_mappings else species_mappings[name] + for name in names] + sanitized_propensity = self.ode_propensity_function if ode else self.propensity_function + for id, name in enumerate(names): + sanitized_propensity = sanitized_propensity.replace(name, "{" + str(id) + "}") + return sanitized_propensity.format(*replacements) + + def set_annotation(self, annotation): """ Add an annotation to this reaction. - :param annotation: Annotation note to be added to reaction :type annotation: str """ if annotation is None: - raise ReactionError("Annotation cannot be None.") + raise ReactionError("annotation can't be None type.") + + self.validate(coverage="annotation", annotation=annotation) + self.annotation = annotation - def initialize(self, model): + def set_propensities(self, propensity_function=None, ode_propensity_function=None): """ - Deferred object initialization, called by model.add_reaction(). - - :param model: Target SpatialPy Model for annotation. - :type model: spatialpy.core.model.Model + Change the reaction to a customized reaction and set the propensities. + :param propensity_function: The custom propensity function for the reaction. Must be evaluable in the + namespace of the reaction using C operations. + :type propensity_function: str + :param ode_propensity_function: The custom ode propensity function for the reaction. Must be evaluable in the + namespace of the reaction using C operations. + :type ode_propensity_function: str """ - self.ode_propensity_function = self.propensity_function + if isinstance(propensity_function, (int, float)): + propensity_function = str(propensity_function) + if isinstance(ode_propensity_function, (int, float)): + ode_propensity_function = str(ode_propensity_function) + + self.validate(propensity_function=propensity_function, ode_propensity_function=ode_propensity_function, coverage="propensities") + + self.propensity_function = propensity_function + self.ode_propensity_function = ode_propensity_function + self.marate = None + self.massaction = False + self.type = "customized" if self.propensity_function is None: - if self.rate is None: - errmsg = f"Reaction {self.name}: You must either set the reaction to be " - errmsg += "mass-action or specifiy a propensity function." - raise ReactionError(errmsg) - self.massaction = True - else: - if self.rate is not None: - errmsg = f"Reaction {self.name}: You cannot set the propensity type to mass-action " - errmsg += "and simultaneously set a propensity function." - raise ReactionError(errmsg) - # If they don't give us a propensity function and do give a rate, assume mass-action. - self.massaction = False - self.marate = None + self.propensity_function = self.ode_propensity_function + if self.ode_propensity_function is None: + self.ode_propensity_function = self.propensity_function + propensity = self._create_custom_propensity(self.propensity_function) + self.propensity_function = propensity + ode_propensity = self._create_custom_propensity(self.ode_propensity_function) + self.ode_propensity_function = ode_propensity - reactants = self.reactants - self.reactants = {} - if reactants is not None: - for reactant in reactants: - rtype = type(reactant).__name__ - if rtype=='Species': - if reactant.name not in model.listOfSpecies: - raise ReactionError(f"Could not find species '{reactant.name}' in model.") - self.reactants[reactant] = reactants[reactant] - elif rtype=='str': - if reactant not in model.listOfSpecies: - raise ReactionError(f"Could not find species '{reactant}' in model.") - self.reactants[model.listOfSpecies[reactant]] = reactants[reactant] - - products = self.products - self.products = {} - if products is not None: - for product in products: - rtype = type(product).__name__ - if rtype=='Species': - if product.name not in model.listOfSpecies: - raise ReactionError(f"Could not find species '{product.name}' in model.") - self.products[product] = products[product] - else: - if product not in model.listOfSpecies: - raise ReactionError(f"Could not find species '{product}' in model.") - self.products[model.listOfSpecies[product]] = products[product] + self.validate(coverage="initialized") - if self.massaction: - self._create_mass_action() - else: - self.type = "customized" + def set_rate(self, rate): + """ + Change the reaction to a mass-action reaction and set the rate. + :param rate: The rate of the mass-action reaction, take care to note the units. + :type rate: int | float | str | Parameter + """ + if rate is None: + raise ReactionError("rate can't be None type") + + if isinstance(rate, Parameter) or type(rate).__name__ == "Parameter": + rate = rate.name + elif isinstance(rate, (int, float)): + rate = str(rate) + + self.validate(marate=rate, coverage="marate") + + self.marate = rate + self.massaction = True + self.type = "mass-action" + self._create_mass_action() + + self.validate(coverage="initialized") + + def to_dict(self): + temp = vars(self).copy() + + # This to_dict overload is needed because, while Species is hashable (thanks to its inheretence), + # objects are not valid key values in the JSON spec. To fix this, we set the Species equal to some key 'key', + # and it's value equal to some key 'value'. + + temp["products"] = list({ "key": k, "value": v} for k, v in self.products.items() ) + temp["reactants"] = list( {"key": k, "value": v} for k, v in self.reactants.items() ) + + return temp + + def validate(self, coverage="build", reactants=None, products=None, propensity_function=None, + ode_propensity_function=None, marate=None, annotation=None, restrict_to=None): + """ + Validate the reaction. + + :param reactants: The reactants that are consumed in the reaction, with stoichiometry. An + example would be {R1 : 1, R2 : 2} if the reaction consumes two of R1 and + one of R2, where R1 and R2 are Species objects. + :type reactants: dict + + :param products: The species that are created by the reaction event, with stoichiometry. Same format as reactants. + :type products: dict + + :param propensity_function: The custom propensity function for the reaction. Must be evaluable in the + namespace of the reaction using C operations. + :type propensity_function: str + + :param ode_propensity_function: The custom ode propensity function for the reaction. Must be evaluable in the + namespace of the reaction using C operations. + :type ode_propensity_function: str + + :param marate: The rate of the mass-action reaction, take care to note the units. + :type marate: float + + :param annotation: An optional note about the reaction. + :type annotation: str + + :param restrict_to: Restrict reaction execution to a type or list of types within the domain. + :type restrict_to: int, str, list of ints or list of strs + """ + if coverage in ("all", "build", "name"): + if self.name is None: + raise ReactionError("name can't be None type.") + if not isinstance(self.name, str): + raise ReactionError("name must be of type str.") + if self.name == "": + raise ReactionError("name can't be an empty string.") + + if coverage in ("all", "build", "reactants"): + if self.reactants is None: + raise ReactionError("recants can't be None type.") + + if reactants is None: + reactants = self.reactants + + if reactants is not None: + if not isinstance(reactants, dict): + raise ReactionError("reactants must be of type dict.") + + for species, stoichiometry in reactants.items(): + if species is None: + raise ReactionError("species in reactants can't be None type.") + if not (isinstance(species, (str, Species)) or type(species).__name__ == 'Species'): + raise ReactionError("species in reactants must be of type str or SpatialPy.Species.") + if species == "": + raise ReactionError("species in reactants can't be an empty string.") + + if stoichiometry is None: + raise ReactionError("stoichiometry in reactants can't be None type.") + if not isinstance(stoichiometry, int) or stoichiometry <= 0: + raise ReactionError("stoichiometry in reactants must greater than 0 and of type int.") + + if coverage in ("all", "build", "products"): + if self.products is None: + raise ReactionError("products can't be None type.") + + if products is None: + products = self.products + + if products is not None: + if not isinstance(products, dict): + raise ReactionError("products must be of type dict.") + + for species, stoichiometry in products.items(): + if species is None: + raise ReactionError("species in products can't be None Type.") + if not (isinstance(species, (str, Species)) or type(species).__name__ == 'Species'): + raise ReactionError("species in products must be of type str or SpatialPy.Species.") + if species == "": + raise ReactionError("species in products can't be an empty string.") + + if stoichiometry is None: + raise ReactionError("stoichiometry in products can't be None type.") + if not isinstance(stoichiometry, int) or stoichiometry <= 0: + raise ReactionError("stoichiometry in products must greater than 0 and of type int.") + + if coverage in ("all", "build", "propensities"): + if propensity_function is None: + propensity_function = self.propensity_function + + if propensity_function is not None: + if not isinstance(propensity_function, str): + raise ReactionError("propensity_function must be of type str.") + if propensity_function == "": + raise ReactionError("propensity_function can't be an empty string.") + + if coverage in ("all", "build", "propensities"): + if ode_propensity_function is None: + ode_propensity_function = self.ode_propensity_function + + if ode_propensity_function is not None: + if not isinstance(ode_propensity_function, str): + raise ReactionError("ode_propensity_function must be of type str.") + if ode_propensity_function == "": + raise ReactionError("ode_propensity_function can't be an empty string.") + + if coverage in ("all", "build", "marate"): + if marate is None: + marate = self.marate + + if marate is not None: + if not (isinstance(marate, (str, Parameter)) or type(marate).__name__ == 'Parameter'): + raise ReactionError("rate must be of type str or SpatialPy.Parameter.") + if marate == "": + raise ReactionError("rate can't be an empty string.") + + if coverage == "build" and not hasattr(self, "massaction"): + has_prop = propensity_function is not None or ode_propensity_function is not None + if marate is not None and has_prop: + raise ReactionError("You cannot set the rate and simultaneously set propensity functions.") + if marate is None and not has_prop: + raise ReactionError("You must specify either a mass-action rate or propensity functions.") + + if coverage in ("all", "build", "annotation"): + if annotation is None: + annotation = self.annotation + + if annotation is not None and not isinstance(annotation, str): + raise ReactionError("annotation must be of type str.") + + if coverage in ("all", "build", "initialized", "restrict_to"): + if restrict_to is None: + restrict_to = self.restrict_to + + if restrict_to is not None: + if not isinstance(restrict_to, list): + raise ReactionError("restrict_to must be None type or of type list.") + if len(restrict_to) == 0: + raise ReactionError("restrict_to can't be an empty list.") + + for type_id in restrict_to: + if type_id is None: + raise ReactionError("Type ids in restrict_to can't be None type.") + if not isinstance(type_id, (int, str)): + raise ReactionError("Type ids in restrict_to must be of type int or str.") + if type_id == "": + raise ReactionError("Type ids in restrict_to can't be an empty string.") + if coverage in ("all", "initialized"): + if not isinstance(type_id, str): + raise ReactionError("Type ids in restrict_to must be of type str.") + if not type_id.startswith("type_"): + raise ReactionError("Type ids in restrict_to must start with 'type_'.") + + if coverage in ("all", "initialized"): + if len(self.reactants) == 0 and len(self.products) == 0: + raise ReactionError("You must have a non-zero number of reactants or products.") + if self.propensity_function is None: + raise ReactionError("propensity_function can't be None type.") + if self.ode_propensity_function is None: + raise ReactionError("ode_propensity_function can't be None type.") + if self.marate is None: + if self.massaction and self.type == "mass-action": + raise ReactionError("You must specify either a mass-action rate or propensity functions") + if self.massaction or self.type == "mass-action": + errmsg = "Invalid customized reaction. Customized reactions require massaction=False and type='customized'" + raise ReactionError(errmsg) + else: + if not self.massaction and self.type == "customized": + raise ReactionError("You cannot set the rate and simultaneously set propensity functions.") + if not self.massaction or self.type == "customized": + errmsg = "Invalid mass-action reaction. Mass-action reactions require massaction=True and type='mass-action'" + raise ReactionError(errmsg) + + def annotate(self, *args, **kwargs): + """ + Add an annotation to this reaction (deprecated). + :param annotation: Annotation note to be added to reaction + :type annotation: str + """ + from spatialpy.core import log + log.warning( + """ + Reaction.Annotate has been deprecated. Future releases of SpatialPy may + not support this feature. Use Reaction.set_annotation instead. + """ + ) + + self.set_annotation(*args, **kwargs) + + def initialize(self, model): + """ + Deferred object initialization, called by model.add_reaction() (deprecated). + + :param model: Target SpatialPy Model for annotation. + :type model: spatialpy.core.model.Model + """ + from spatialpy.core import log + log.warning( + """ + Reaction.initialize has been deprecated. Future releases of SpatialPy may + not support this feature. Initialization of reactions is now completed in + the constructor. + """ + ) diff --git a/spatialpy/solvers/solver.py b/spatialpy/solvers/solver.py index 87629349..5d46bfe9 100644 --- a/spatialpy/solvers/solver.py +++ b/spatialpy/solvers/solver.py @@ -239,7 +239,7 @@ def __get_input_constants(self, nspecies, ncells, stoich_matrix, dep_graph): outstr = f"static double input_data_fn[{len(self.model.listOfDataFunctions) * ncells}] = " outstr += "{" coords = self.model.domain.coordinates() - for ndf, data_fn in enumerate(self.model.listOfDataFunctions): + for ndf, data_fn in enumerate(self.model.listOfDataFunctions.values()): for i in range(ncells): if i > 0 and ndf == 0: outstr += ',' diff --git a/test/unit_tests/test_reaction.py b/test/unit_tests/test_reaction.py index 116dcc3c..d32e51b5 100644 --- a/test/unit_tests/test_reaction.py +++ b/test/unit_tests/test_reaction.py @@ -26,676 +26,1010 @@ class TestReaction(unittest.TestCase): Unit tests for spatialpy.Reaction. ################################################################################################ ''' + def setUp(self): + self.valid_ma_reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate="k1" + ) + self.valid_cp_reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + propensity_function="k1 * A" + ) + def test_constructor__mass_action(self): """ Test the Reaction constructor for a mass action reaction. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = "k1" - reaction = Reaction(name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate) + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate="k1" + ) self.assertEqual(reaction.name, "test_reaction") - self.assertEqual(reaction.reactants, test_reactants) - self.assertEqual(reaction.products, test_products) - self.assertEqual(reaction.rate, test_rate) - + self.assertEqual(reaction.reactants, {"A": 1}) + self.assertEqual(reaction.products, {"B": 1}) + self.assertEqual(reaction.marate, "k1") + self.assertEqual(reaction.propensity_function, "(k1*A)") + self.assertEqual(reaction.ode_propensity_function, "(k1*A)") def test_constructor__custom_propensity(self): """ Test the Reaction constructor for a custom propensity reaction. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_propensity = "k1 * A * B" reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, propensity_function=test_propensity + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + propensity_function="k1 * A * B" + ) + self.assertEqual(reaction.name, "test_reaction") + self.assertEqual(reaction.reactants, {"A": 1}) + self.assertEqual(reaction.products, {"B": 1}) + self.assertEqual(reaction.propensity_function, "((k1*A)*B)") + self.assertEqual(reaction.ode_propensity_function, "((k1*A)*B)") + + def test_constructor__custom_ode_propensity(self): + """ Test the Reaction constructor for a custom ode propensity reaction. """ + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + ode_propensity_function="k1 * A * B" ) self.assertEqual(reaction.name, "test_reaction") - self.assertEqual(reaction.reactants, test_reactants) - self.assertEqual(reaction.products, test_products) - self.assertEqual(reaction.propensity_function, test_propensity) + self.assertEqual(reaction.reactants, {"A": 1}) + self.assertEqual(reaction.products, {"B": 1}) + self.assertEqual(reaction.propensity_function, "((k1*A)*B)") + self.assertEqual(reaction.ode_propensity_function, "((k1*A)*B)") + def test_constructor__different_custom_propensity(self): + """ Test the Reaction constructor for a custom propensity reaction with different propensities. """ + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + propensity_function="k1 * A * B", + ode_propensity_function="k1 * A * B / vol" + ) + self.assertEqual(reaction.name, "test_reaction") + self.assertEqual(reaction.reactants, {"A": 1}) + self.assertEqual(reaction.products, {"B": 1}) + self.assertEqual(reaction.propensity_function, "((k1*A)*B)") + self.assertEqual(reaction.ode_propensity_function, "(((k1*A)*B)/vol)") def test_constructor__no_name(self): """ Test the Reaction constructor with no name provided. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_propensity = "k1 * A * B" reaction = Reaction( - reactants=test_reactants, products=test_products, propensity_function=test_propensity + reactants={"A": 1}, products={"B": 1}, propensity_function="k1 * A * B" ) self.assertIsNotNone(re.search("rxn.*", reaction.name)) + def test_constructor__name_is_none_or_empty(self): + """ Test the Reaction constructor with None or empty string as name. """ + test_names = [None, ""] + for test_name in test_names: + with self.subTest(name=test_name): + reaction = Reaction( + name=test_name, reactants={"A": 1}, products={"B": 1}, + propensity_function="k1 * A * B" + ) + self.assertIsNotNone(re.search("rxn.*", reaction.name)) + + def test_constructor__invalid_name(self): + """ Test the Reaction constructor with non-str name. """ + with self.assertRaises(ReactionError): + Reaction(name=0, reactants={"A": 1}, products={"B": 1}, rate="k1") - def test_constructor__name_is_none(self): - """ Test the Reaction constructor with None as name. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_propensity = "k1 * A * B" + def test_constructor__no_reactants_or_products(self): + """ Test the Reaction constructor with reactants and products not set. """ + with self.assertRaises(ReactionError): + Reaction(name="test_reaction", rate="k1") + + def test_constructor__invalid_reactants_and_products(self): + """ Test the Reaction constructor with reactants and products both set to None or empty. """ + test_reacs = [None, {}] + test_prods = [None, {}] + for test_reac in test_reacs: + for test_prod in test_prods: + with self.subTest(reactants=test_reac, products=test_prod): + with self.assertRaises(ReactionError): + Reaction( + name="test_reaction", reactants=test_reac, + products=test_prod, rate="k1" + ) + + def test_constructor__reactants_is_none(self): + """ Test the Reaction constructor with None as reactants. """ reaction = Reaction( - name=None, reactants=test_reactants, products=test_products, propensity_function=test_propensity + name="test_reaction", reactants=None, products={"B": 1}, rate="k2" ) - self.assertIsNotNone(re.search("rxn.*", reaction.name)) + self.assertEqual(reaction.reactants, {}) + def test_constructor__reactants_keyed_by_species_obj(self): + """ Test the Reaction constructor with reactants keyed by species objs. """ + test_species = Species(name="A", diffusion_coefficient=0.1) + reaction = Reaction( + name="test_reaction", reactants={test_species: 1}, products={"B": 1}, + rate="k2" + ) + self.assertEqual(reaction.reactants, {"A": 1}) - def test_constructor__name_not_str_or_None(self): - """ Test the Reaction constructor with non-str name. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = "k1" + def test_constructor__reactants_invalid_keys(self): + """ Test the Reaction constructor with reactants keyed with invalid keys. """ with self.assertRaises(ReactionError): - reaction = Reaction(name=0, reactants=test_reactants, products=test_products, rate=test_rate) + Reaction( + name="test_reaction", reactants={1: 1}, products={"B": 1}, + rate="k2" + ) + def test_constructor__reactants_invalid_values(self): + """ Test the Reaction constructor with reactants containing invalid stoichiometry. """ + with self.assertRaises(ReactionError): + Reaction( + name="test_reaction", reactants={"A": "1"}, products={"B": 1}, + rate="k2" + ) - def test_constructor__reactants_not_dict(self): + def test_constructor__invalid_reactants(self): """ Test the Reaction constructor with non-dict reactants. """ - test_reactants = [["C", 1]] - test_products = {"A": 1, "B": 1} - test_rate = "k2" with self.assertRaises(ReactionError): - reaction = Reaction(name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate) + Reaction( + name="test_reaction", reactants=[["A", 1]], products={"B": 1}, + rate="k2" + ) - def test_constructor__products_not_dict(self): - """ Test the Reaction constructor with non-dict products. """ - test_reactants = {"A": 1, "B": 1} - test_products = [["C", 1]] - test_rate = "k1" + def test_constructor__products_is_none(self): + """ Test the Reaction constructor with None as products. """ + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products=None, rate="k2" + ) + self.assertEqual(reaction.products, {}) + + def test_constructor__products_keyed_by_species_obj(self): + """ Test the Reaction constructor with products keyed by species objs. """ + test_species = Species(name="B", diffusion_coefficient=0.1) + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={test_species: 1}, + rate="k2" + ) + self.assertEqual(reaction.products, {"B": 1}) + + def test_constructor__products_invalid_keys(self): + """ Test the Reaction constructor with products keyed with invalid keys. """ with self.assertRaises(ReactionError): - reaction = Reaction(name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate) + Reaction( + name="test_reaction", reactants={"A": 1}, products={1: 1}, + rate="k2" + ) + def test_constructor__products_invalid_values(self): + """ Test the Reaction constructor with products containing invalid stoichiometry. """ + with self.assertRaises(ReactionError): + Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": "1"}, + rate="k2" + ) - def test_constructor__propensity_function_not_accepted_type(self): - """ Test the Reaction constructor with a propensity function that is not of the proper type. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_propensity = ["k1 * A * B"] + def test_constructor__invalid_products(self): + """ Test the Reaction constructor with non-dict products. """ with self.assertRaises(ReactionError): - reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, propensity_function=test_propensity + Reaction( + name="test_reaction", reactants={"A": 1}, products=[["B", 1]], + rate="k2" ) + def test_constructor__rate_and_propensity_functions_are_none(self): + """ Test the Reaction constructor with rate and propensity functions set to None. """ + with self.assertRaises(ReactionError): + Reaction(name="test_reaction", reactants={"A": 1}, products={"B": 1}) + + def test_constructor__rate_and_propensity_function_are_not_none(self): + """ Test the Reaction constructor with rate and propensity function set. """ + with self.assertRaises(ReactionError): + Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + rate="k1", propensity_function="A**2 + B**2" + ) def test_constructor__int_propensity_function(self): """ Test the Reaction constructor with an int propensity function. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_propensity = 20 reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, propensity_function=test_propensity + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + propensity_function=20 ) self.assertIsInstance(reaction.propensity_function, str) self.assertEqual(reaction.propensity_function, "20") - + self.assertIsInstance(reaction.ode_propensity_function, str) + self.assertEqual(reaction.ode_propensity_function, "20") def test_constructor__float_propensity_function(self): """ Test the Reaction constructor with a float propensity function. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_propensity = 0.5 reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, propensity_function=test_propensity + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + propensity_function=0.5 ) self.assertIsInstance(reaction.propensity_function, str) self.assertEqual(reaction.propensity_function, "0.5") + self.assertIsInstance(reaction.ode_propensity_function, str) + self.assertEqual(reaction.ode_propensity_function, "0.5") - - def test_constructor__rate_not_accepted_type(self): - """ Test the Reaction constructor with a rate that is not of the proper type. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = ["k1"] + def test_constructor__invalid_propensity_function(self): + """ Test the Reaction constructor with a propensity function that is not of the proper type. """ + test_pfs = ["", ["k1 * A * B"]] + for test_pf in test_pfs: + with self.subTest(propensity_function=test_pf): + with self.assertRaises(ReactionError): + Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + propensity_function=test_pf + ) + + def test_constructor__rate_and_ode_propensity_function_are_not_none(self): + """ Test the Reaction constructor with rate and propensity function set. """ with self.assertRaises(ReactionError): - reaction = Reaction(name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate) + Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + rate="k1", ode_propensity_function="A**2 + B**2" + ) + def test_constructor__int_ode_propensity_function(self): + """ Test the Reaction constructor with an int ode propensity function. """ + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + ode_propensity_function=20 + ) + self.assertIsInstance(reaction.propensity_function, str) + self.assertEqual(reaction.propensity_function, "20") + self.assertIsInstance(reaction.ode_propensity_function, str) + self.assertEqual(reaction.ode_propensity_function, "20") + + def test_constructor__float_ode_propensity_function(self): + """ Test the Reaction constructor with a float ode propensity function. """ + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + ode_propensity_function=0.5 + ) + self.assertIsInstance(reaction.propensity_function, str) + self.assertEqual(reaction.propensity_function, "0.5") + self.assertIsInstance(reaction.ode_propensity_function, str) + self.assertEqual(reaction.ode_propensity_function, "0.5") + + def test_constructor__invalid_ode_propensity_function(self): + """ Test the Reaction constructor with a ode propensity function that is not of the proper type. """ + test_opfs = ["", ["k1 * A * B"]] + for test_opf in test_opfs: + with self.subTest(ode_propensity_function=test_opf): + with self.assertRaises(ReactionError): + Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, + ode_propensity_function=test_opf + ) + + def test_constructor__parameter_rate(self): + """ Test the Reaction constructor with an parameter object as rate. """ + k1 = Parameter(name="k1", expression="20") + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate=k1 + ) + self.assertIsInstance(reaction.marate, str) + self.assertEqual(reaction.marate, "k1") def test_constructor__int_rate(self): """ Test the Reaction constructor with an int rate. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = 20 - reaction = Reaction(name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate) - self.assertIsInstance(reaction.rate, str) - self.assertEqual(reaction.rate, "20") - + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate=20 + ) + self.assertIsInstance(reaction.marate, str) + self.assertEqual(reaction.marate, "20") def test_constructor__float_rate(self): """ Test the Reaction constructor with a float rate. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = 0.5 - reaction = Reaction(name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate) - self.assertIsInstance(reaction.rate, str) - self.assertEqual(reaction.rate, "0.5") - - - def test_constructor__restrict_to_not_accepted_type(self): - """ Test the Reaction constructor with a restrict_to that is not the proper type. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = "k1" - test_restrict_to = 1.5 - with self.assertRaises(ReactionError): - reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate, restrict_to=test_restrict_to - ) + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate=0.5 + ) + self.assertIsInstance(reaction.marate, str) + self.assertEqual(reaction.marate, "0.5") + def test_constructor__rate_not_accepted_type(self): + """ Test the Reaction constructor with a rate that is an invalid type. """ + test_rates = ["", ["k1"]] + for test_rate in test_rates: + with self.subTest(rate=test_rate): + with self.assertRaises(ReactionError): + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate=test_rate + ) + + def test_constructor__annotation_invalid_type(self): + """ Test the Reaction construct with an annotation of invalid type. """ + test_annotations = [5, 0.1, ["g"]] + for test_annotation in test_annotations: + with self.subTest(annotation=test_annotation): + with self.assertRaises(ReactionError): + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate="k1", + annotation=test_annotation + ) def test_constructor__int_restrict_to(self): """ Test the Reaction constructor with a int restrict_to. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = "k1" - test_restrict_to = 1 reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate, restrict_to=test_restrict_to + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate="k1", restrict_to=1 ) self.assertIsInstance(reaction.restrict_to, list) self.assertEqual(reaction.restrict_to, ["type_1"]) - def test_constructor__str_restrict_to(self): """ Test the Reaction constructor with a string restrict_to. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = "k1" - test_restrict_to = "Walls" reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate, restrict_to=test_restrict_to + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate="k1", restrict_to="Walls" ) self.assertIsInstance(reaction.restrict_to, list) self.assertEqual(reaction.restrict_to, ["type_Walls"]) - - def test___str__(self): - """ Test Reaction.__str__ method. """ - test_reactants = {"A": 1, "B": 1} - test_products = {"C": 1} - test_rate = "k1" - test_restrict_to = 1 + def test_constructor__list_restrict_to(self): + """ Test the Reaction constructor with a list restrict_to. """ reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate, restrict_to=test_restrict_to + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate="k1", restrict_to=["Walls", "Fluid", "Beam"] ) - self.assertIsInstance(str(reaction), str) + self.assertIsInstance(reaction.restrict_to, list) + self.assertEqual(reaction.restrict_to, ["type_Walls", "type_Fluid", "type_Beam"]) + + def test_constructor__invalid_restrict_to(self): + """ Test the Reaction constructor with a restrict_to that is not the proper type. """ + test_rts = ["", 1.5, {"Walls": 1}, ("Walls", "Fluid")] + for test_rt in test_rts: + with self.subTest(restrict_to=test_rt): + with self.assertRaises(ReactionError): + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate="k1", restrict_to=test_rt + ) + + def test_constructor__invalid_restrict_to_value(self): + """ Test the Reaction constructor with a restrict_to that is not the proper value. """ + test_rts = [[], [None], [""], [1.5], [[]], ["Walls", None], ["Walls", ""], ["Walls", 1.5], ["Walls", []]] + for test_rt in test_rts: + with self.subTest(restrict_to=test_rt): + with self.assertRaises(ReactionError): + reaction = Reaction( + name="test_reaction", reactants={"A": 1}, products={"B": 1}, rate="k1", restrict_to=test_rt + ) + def test___str__(self): + """ Test Reaction.__str__ method. """ + self.assertIsInstance(str(self.valid_ma_reaction), str) def test__create_mass_action__total_stoch_3(self): """ Test Reaction._create_mass_action total stochiometry > 2. """ - test_reactants = {"A": 1, "B": 2} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) + self.valid_ma_reaction.reactants = {"A": 1, "B": 2} + self.valid_ma_reaction.products = {"C": 1} with self.assertRaises(ReactionError): - test_reaction._create_mass_action() - + self.valid_ma_reaction._create_mass_action() def test__create_mass_action__marate_type_as_string(self): """ Test Reaction._create_mass_action marate as string. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction._create_mass_action() - self.assertEqual(test_reaction.propensity_function, "k1 * vol") - self.assertEqual(test_reaction.ode_propensity_function, "k1") - + self.valid_ma_reaction.reactants = {} + self.valid_ma_reaction.products = {"C": 1} + self.valid_ma_reaction._create_mass_action() + self.assertEqual(self.valid_ma_reaction.propensity_function, "(k1*vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "k1") def test__create_mass_action__marate_type_as_int(self): """ Test Reaction._create_mass_action marate as int. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = 1 - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction._create_mass_action() - self.assertEqual(test_reaction.propensity_function, "1 * vol") - self.assertEqual(test_reaction.ode_propensity_function, "1") - + self.valid_ma_reaction.reactants = {} + self.valid_ma_reaction.products = {"C": 1} + self.valid_ma_reaction.marate = 1 + self.valid_ma_reaction._create_mass_action() + self.assertEqual(self.valid_ma_reaction.propensity_function, "(1*vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "1") def test__create_mass_action__marate_type_as_float(self): """ Test Reaction._create_mass_action marate as float. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = 0.5 - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction._create_mass_action() - self.assertEqual(test_reaction.propensity_function, "0.5 * vol") - self.assertEqual(test_reaction.ode_propensity_function, "0.5") - + self.valid_ma_reaction.reactants = {} + self.valid_ma_reaction.products = {"C": 1} + self.valid_ma_reaction.marate = 0.5 + self.valid_ma_reaction._create_mass_action() + self.assertEqual(self.valid_ma_reaction.propensity_function, "(0.5*vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "0.5") def test__create_mass_action__marate_type_as_parameter(self): """ Test Reaction._create_mass_action marate as parameter. """ test_parameter = Parameter("k1", expression=0.1) - test_reactants = {} - test_products = {"C": 1} - test_rate = test_parameter - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction._create_mass_action() - self.assertEqual(test_reaction.propensity_function, "k1 * vol") - self.assertEqual(test_reaction.ode_propensity_function, "k1") - + self.valid_ma_reaction.reactants = {} + self.valid_ma_reaction.products = {"C": 1} + self.valid_ma_reaction.marate = test_parameter + self.valid_ma_reaction._create_mass_action() + self.assertEqual(self.valid_ma_reaction.propensity_function, "(k1*vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "k1") def test__create_mass_action__X_to_Y(self): """ Test Reaction._create_mass_action X -> Y. """ - test_species_x = Species(name="X", diffusion_coefficient=0.1) - test_reactants = {test_species_x: 1} - test_products = {"Y": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction._create_mass_action() - self.assertEqual(test_reaction.propensity_function, "k1 * X") - self.assertEqual(test_reaction.ode_propensity_function, "k1 * X") - + self.valid_ma_reaction.reactants = {"X": 1} + self.valid_ma_reaction.products = {"Y": 1} + self.valid_ma_reaction._create_mass_action() + self.assertEqual(self.valid_ma_reaction.propensity_function, "(k1*X)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "(k1*X)") def test__create_mass_action__X_plus_Y_to_Z(self): """ Test Reaction._create_mass_action X + Y -> Z. """ - test_species_x = Species(name="X", diffusion_coefficient=0.1) - test_species_y = Species(name="Y", diffusion_coefficient=0.1) - test_reactants = {test_species_x: 1, test_species_y: 1} - test_products = {"Z": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction._create_mass_action() - self.assertEqual(test_reaction.propensity_function, "k1 * X * Y / vol") - self.assertEqual(test_reaction.ode_propensity_function, "k1 * X * Y") - + self.valid_ma_reaction.reactants = {"X": 1, "Y": 1} + self.valid_ma_reaction.products = {"Z": 1} + self.valid_ma_reaction._create_mass_action() + self.assertEqual(self.valid_ma_reaction.propensity_function, "(((k1*X)*Y)/vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "((k1*X)*Y)") def test__create_mass_action__2X_to_Y(self): """ Test Reaction._create_mass_action 2X -> Y. """ - test_species_x = Species(name="X", diffusion_coefficient=0.1) - test_reactants = {test_species_x: 2} - test_products = {"Y": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction._create_mass_action() - self.assertEqual(test_reaction.propensity_function, "0.5 * k1 * X * (X - 1) / vol") - self.assertEqual(test_reaction.ode_propensity_function, "k1 * X * X") + self.valid_ma_reaction.reactants = {"X": 2} + self.valid_ma_reaction.products = {"Y": 1} + self.valid_ma_reaction._create_mass_action() + self.assertEqual(self.valid_ma_reaction.propensity_function, "((((0.5*k1)*X)*(X-1))/vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "((k1*X)*X)") + + def test__create_mass_action__species_obj_reactant(self): + """ Test Reaction._create_mass_action when reactants is keyed by species object. """ + test_species = Species(name="A", diffusion_coefficient=0.1) + self.valid_ma_reaction.reactants = {test_species: 1} + self.valid_ma_reaction._create_mass_action() + self.assertEqual(self.valid_ma_reaction.propensity_function, "(k1*A)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "(k1*A)") + + def test__create_custom_propensity__exponent(self): + """ Test Reaction._create_custom_propensity with a propensity containing exponents. """ + test_propensities = ["1/A^2", "1/A**2"] + for test_propensity in test_propensities: + with self.subTest(propensity_function=test_propensity): + expr = self.valid_cp_reaction._create_custom_propensity(test_propensity) + self.assertEqual(expr, "(1/pow(A,2))") + + def test__create_custom_propensity__ln(self): + """ Test Reaction._create_custom_propensity with a propensity containing ln. """ + expr = self.valid_cp_reaction._create_custom_propensity("ln(A)") + self.assertEqual(expr, "log(A)") + + def test__create_custom_propensity__e(self): + """ Test Reaction._create_custom_propensity with a propensity containing e. """ + expr = self.valid_cp_reaction._create_custom_propensity("A*e") + self.assertEqual(expr, "(A*2.718281828459045)") + + def test__create_custom_propensity__fake_e(self): + """ Test Reaction._create_custom_propensity with a propensity containing e. """ + expr = self.valid_cp_reaction._create_custom_propensity("A*ex") + self.assertEqual(expr, "(A*ex)") + + def test__create_custom_propensity__propensity_parsing(self): + """ Test Reaction._create_custom_propensity for parsing accuracy. """ + test_propensities = [ + "5*x^2+e*b+6", "5*x**2+e*b+6", "1*alpha/2+5^beta", "1*alpha/2+5**beta", + "2.78*x+3^(4*x)", "2.78*x+3**(4*x)", "-5*-x^2", "-5*-x**2", + "(alpha/beta + delta**gamma)/(atlas-zeta)", "(alpha/beta + delta^gamma)/(atlas-zeta)" + ] + expected_results = [ + "(((5*pow(x,2))+(2.718281828459045*b))+6)", "(((5*pow(x,2))+(2.718281828459045*b))+6)", + "(((1*alpha)/2)+pow(5,beta))", "(((1*alpha)/2)+pow(5,beta))", "((2.78*x)+pow(3,(4*x)))", + "((2.78*x)+pow(3,(4*x)))", "((-5)*(-pow(x,2)))", "((-5)*(-pow(x,2)))", + "(((alpha/beta)+pow(delta,gamma))/(atlas-zeta))", "(((alpha/beta)+pow(delta,gamma))/(atlas-zeta))" + ] + for i, test_propensity in enumerate(test_propensities): + expected_result = expected_results[i] + with self.subTest(propensity_function=test_propensity, expected_propensity_function=expected_result): + expr = self.valid_cp_reaction._create_custom_propensity(test_propensity) + self.assertEqual(expr, expected_result) + def test_add_product__species_string(self): + """ Test Reaction.add_product when species is string. """ + self.valid_ma_reaction.add_product("X", 1) + self.assertIn("X", self.valid_ma_reaction.products) + self.assertEqual(self.valid_ma_reaction.products["X"], 1) def test_add_product__species_object(self): - """ Test Reaction.add_product species is SpatialPy.Species. """ + """ Test Reaction.add_product when species is SpatialPy.Species. """ test_species = Species(name="X", diffusion_coefficient=0.1) - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.add_product(test_species, 1) - self.assertIn(test_species.name, test_reaction.products) - self.assertEqual(test_reaction.products[test_species.name], 1) - - - def test_add_product__species_string(self): - """ Test Reaction.add_product species is string. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.add_product("X", 1) - self.assertIn("X", test_reaction.products) - self.assertEqual(test_reaction.products["X"], 1) - - - def test_add_product__species_not_accepted_type(self): - """ Test Reaction.add_product species not string or SpatialPy.Species. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - with self.assertRaises(ReactionError): - test_reaction.add_product(["X"], 1) - - - def test_add_product__stochiometry_not_int(self): - """ Test Reaction.add_product stochiometry not integer. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - with self.assertRaises(ReactionError): - test_reaction.add_product("X", 0.1) - - - def test_add_product__stochiometry_less_than_zero(self): - """ Test Reaction.add_product stochiometry <= 0. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - with self.assertRaises(ReactionError): - test_reaction.add_product("X", 0) - - - def test_add_reactant__species_object(self): - """ Test Reaction.add_reactant species is SpatialPy.Species. """ + self.valid_ma_reaction.add_product(test_species, 1) + self.assertIn(test_species.name, self.valid_ma_reaction.products) + self.assertEqual(self.valid_ma_reaction.products[test_species.name], 1) + + def test_add_product__invalid_species(self): + """ Test Reaction.add_product with an invalid species. """ + test_species = [None, "", 5, 0.5, ["A"]] + for test_spec in test_species: + with self.subTest(species=test_spec): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.add_product(test_spec, 1) + + def test_add_product__invalid_stochiometry(self): + """ Test Reaction.add_product with an invalid stochiometry. """ + test_stoichs = [None, "1", -5, 0, 0.5, [1]] + for test_stoich in test_stoichs: + with self.subTest(stoichiometry=test_stoich): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.add_product("X", test_stoich) + + def test_add_reactant__massaction_reaction_species_string(self): + """ Test Reaction.add_reactant when reaction is mass-action and species is string. """ + self.valid_ma_reaction.add_reactant("X", 1) + self.assertIn("X", self.valid_ma_reaction.reactants) + self.assertEqual(self.valid_ma_reaction.reactants["X"], 1) + self.assertEqual(self.valid_ma_reaction.propensity_function, "(((k1*A)*X)/vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "((k1*A)*X)") + + def test_add_reactant__customized_reaction_species_string(self): + """ Test Reaction.add_reactant when reaction is customized and species is string. """ + self.valid_cp_reaction.add_reactant("X", 1) + self.assertIn("X", self.valid_cp_reaction.reactants) + self.assertEqual(self.valid_cp_reaction.reactants["X"], 1) + self.assertEqual(self.valid_cp_reaction.propensity_function, "(k1*A)") + self.assertEqual(self.valid_cp_reaction.ode_propensity_function, "(k1*A)") + + def test_add_reactant__massaction_reaction_species_object(self): + """ Test Reaction.add_reactant when reaction is mass-action and species is SpatialPy.Species. """ test_species = Species(name="X", diffusion_coefficient=0.1) - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.add_reactant(test_species, 1) - self.assertIn(test_species.name, test_reaction.reactants) - self.assertEqual(test_reaction.reactants[test_species.name], 1) - - - def test_add_reactant__species_string(self): - """ Test Reaction.add_reactant species is string. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.add_reactant("X", 1) - self.assertIn("X", test_reaction.reactants) - self.assertEqual(test_reaction.reactants["X"], 1) - - - def test_add_reactant__species_not_accepted_type(self): - """ Test Reaction.add_reactant species not string or SpatialPy.Species. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - with self.assertRaises(ReactionError): - test_reaction.add_reactant(["X"], 1) - - - def test_add_reactant__stochiometry_not_int(self): - """ Test Reaction.add_reactant stochiometry not integer. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - with self.assertRaises(ReactionError): - test_reaction.add_reactant("X", 0.1) - - - def test_add_reactant__stochiometry_less_than_zero(self): - """ Test Reaction.add_reactant stochiometry <= 0. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) + self.valid_ma_reaction.add_reactant(test_species, 1) + self.assertIn(test_species.name, self.valid_ma_reaction.reactants) + self.assertEqual(self.valid_ma_reaction.reactants[test_species.name], 1) + self.assertEqual(self.valid_ma_reaction.propensity_function, f"(((k1*A)*{test_species.name})/vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, f"((k1*A)*{test_species.name})") + + def test_add_reactant__customized_reaction_species_object(self): + """ Test Reaction.add_reactant when reaction is customized and species is SpatialPy.Species. """ + test_species = Species(name="X", diffusion_coefficient=0.1) + self.valid_cp_reaction.add_reactant(test_species, 1) + self.assertIn(test_species.name, self.valid_cp_reaction.reactants) + self.assertEqual(self.valid_cp_reaction.reactants[test_species.name], 1) + self.assertEqual(self.valid_cp_reaction.propensity_function, "(k1*A)") + self.assertEqual(self.valid_cp_reaction.ode_propensity_function, "(k1*A)") + + def test_add_reactant__massaction_reaction_stoich_0_to_1(self): + """ Test Reaction.add_reactant when reaction is mass-action and stoichiometry goes from 0 to 1. """ + self.valid_ma_reaction.reactants = {} + self.valid_ma_reaction.add_reactant("X", 1) + self.assertIn("X", self.valid_ma_reaction.reactants) + self.assertEqual(self.valid_ma_reaction.reactants["X"], 1) + self.assertEqual(self.valid_ma_reaction.propensity_function, "(k1*X)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "(k1*X)") + + def test_add_reactant__massaction_reaction_stoich_0_to_2(self): + """ Test Reaction.add_reactant when reaction is mass-action and stoichiometry goes from 0 to 2. """ + self.valid_ma_reaction.reactants = {} + self.valid_ma_reaction.add_reactant("X", 2) + self.assertIn("X", self.valid_ma_reaction.reactants) + self.assertEqual(self.valid_ma_reaction.reactants["X"], 2) + self.assertEqual(self.valid_ma_reaction.propensity_function, "((((0.5*k1)*X)*(X-1))/vol)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "((k1*X)*X)") + + def test_add_reactant__invalid_species(self): + """ Test Reaction.add_reactant with an invalid species. """ + test_species = [None, "", 5, 0.5, {}] + for test_spec in test_species: + with self.subTest(species=test_spec): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.add_reactant(test_spec, 1) + + def test_add_reactant__invalid_stochiometry(self): + """ Test Reaction.add_reactant with an invalid stochiometry. """ + test_stoichs = [None, "1", -5, 0, 0.5, [1]] + for test_stoich in test_stoichs: + with self.subTest(stoichiometry=test_stoich): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.add_reactant("X", test_stoich) + + def test_from_json__massaction(self): + """ Test Reaction.from_json with a mass-action reaction. """ + test_json = { + 'name': 'test_reaction', + 'reactants': [{'key': 'A', 'value': 1}], + 'products': [{'key': 'B', 'value': 1}], + 'marate': 'k1', + 'annotation': None, + 'propensity_function': '(k1*A)', + 'ode_propensity_function': '(k1*A)', + 'restrict_to': None, + 'massaction': True, + 'type': 'mass-action' + } + reaction = Reaction.from_json(test_json) + self.assertIsInstance(reaction, Reaction) + self.assertEqual(reaction.name, 'test_reaction') + self.assertEqual(reaction.reactants, {'A': 1}) + self.assertEqual(reaction.products, {'B': 1}) + self.assertEqual(reaction.propensity_function, '(k1*A)') + self.assertEqual(reaction.ode_propensity_function, '(k1*A)') + self.assertEqual(reaction.marate, 'k1') + self.assertIsNone(reaction.annotation) + self.assertTrue(reaction.massaction) + self.assertEqual(reaction.type, 'mass-action') + + def test_from_json__customized(self): + """ Test Reaction.from_json with a customized reaction. """ + test_json = { + 'name': 'test_reaction', + 'reactants': [{'key': 'A', 'value': 1}], + 'products': [{'key': 'B', 'value': 1}], + 'marate': None, + 'annotation': None, + 'propensity_function': '(k1*A)', + 'ode_propensity_function': '(k1*A)', + 'restrict_to': None, + 'massaction': False, + 'type': 'customized' + } + reaction = Reaction.from_json(test_json) + self.assertIsInstance(reaction, Reaction) + self.assertEqual(reaction.name, 'test_reaction') + self.assertEqual(reaction.reactants, {'A': 1}) + self.assertEqual(reaction.products, {'B': 1}) + self.assertEqual(reaction.propensity_function, '(k1*A)') + self.assertEqual(reaction.ode_propensity_function, '(k1*A)') + self.assertIsNone(reaction.marate) + self.assertIsNone(reaction.annotation) + self.assertFalse(reaction.massaction) + self.assertEqual(reaction.type, 'customized') + + def test_from_json__massaction_json_changed(self): + """ Test Reaction.from_json with a mass-action reaction and modified json. Ensure propensities are updated.""" + test_json = { + 'name': 'test_reaction', + 'reactants': [{'key': 'B', 'value': 1}], + 'products': [{'key': 'A', 'value': 1}], + 'marate': 'k1', + 'annotation': None, + 'propensity_function': '(k1*A)', + 'ode_propensity_function': '(k1*A)', + 'restrict_to': None, + 'massaction': True, + 'type': 'mass-action' + } + reaction = Reaction.from_json(test_json) + self.assertEqual(reaction.name, 'test_reaction') + self.assertEqual(reaction.reactants, {'B': 1}) + self.assertEqual(reaction.products, {'A': 1}) + self.assertEqual(reaction.propensity_function, '(k1*B)') + self.assertEqual(reaction.ode_propensity_function, '(k1*B)') + self.assertEqual(reaction.marate, 'k1') + self.assertIsNone(reaction.annotation) + self.assertTrue(reaction.massaction) + self.assertEqual(reaction.type, 'mass-action') + + def test_from_json__customized_json_changed(self): + """ Test Reaction.from_json with a customized reaction and modified json. Ensure propensities don't change. """ + test_json = { + 'name': 'test_reaction', + 'reactants': [{'key': 'B', 'value': 1}], + 'products': [{'key': 'A', 'value': 1}], + 'marate': None, + 'annotation': None, + 'propensity_function': '(k1*A)', + 'ode_propensity_function': '(k1*A)', + 'restrict_to': None, + 'massaction': False, + 'type': 'customized' + } + reaction = Reaction.from_json(test_json) + self.assertEqual(reaction.name, 'test_reaction') + self.assertEqual(reaction.reactants, {'B': 1}) + self.assertEqual(reaction.products, {'A': 1}) + self.assertEqual(reaction.propensity_function, '(k1*A)') + self.assertEqual(reaction.ode_propensity_function, '(k1*A)') + self.assertIsNone(reaction.marate) + self.assertIsNone(reaction.annotation) + self.assertFalse(reaction.massaction) + self.assertEqual(reaction.type, 'customized') + + def test_from_json__invalid_json(self): + """ Test Reaction.from_json with a reaction that fails validation. """ + test_json = { + 'name': 'test_reaction', + 'reactants': [{'key': 'A', 'value': 1}], + 'products': [{'key': 'B', 'value': 1}], + 'marate': None, + 'annotation': None, + 'propensity_function': '(k1*A)', + 'ode_propensity_function': '(k1*A)', + 'restrict_to': None, + 'massaction': True, + 'type': 'mass-action' + } with self.assertRaises(ReactionError): - test_reaction.add_reactant("X", 0) + reaction = Reaction.from_json(test_json) - - def test_annotate(self): - """ Test Reaction.annotate. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) + def test_set_annotation(self): + """ Test Reaction.annotation. """ test_annotation = "Testing the reaction annotation message." - test_reaction.annotate(test_annotation) - self.assertEqual(test_reaction.annotation, test_annotation) - - - def test_annotate__annotation_is_None(self): - """ Test Reaction.annotate annotation is None. """ - test_reactants = {} - test_products = {"C": 1} - test_rate = "k1" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate + self.valid_ma_reaction.set_annotation(test_annotation) + self.assertEqual(self.valid_ma_reaction.annotation, test_annotation) + + def test_set_annotation__invalid_annotation(self): + """ Test Reaction.set_annotation with an invalid annotation. """ + test_annotations = [None, 5, 0.1, ["g"]] + for test_annotation in test_annotations: + with self.subTest(annotation=test_annotation): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.set_annotation(test_annotation) + + def test_set_propensities__propensity_function_only(self): + """ Test Reaction.set_propensities with propensity_function only. """ + self.valid_ma_reaction.set_propensities(propensity_function="k1*A/2") + expected_result = "((k1*A)/2)" + self.assertEqual(self.valid_ma_reaction.propensity_function, expected_result) + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, expected_result) + self.assertIsNone(self.valid_ma_reaction.marate) + self.assertFalse(self.valid_ma_reaction.massaction) + self.assertEqual(self.valid_ma_reaction.type, "customized") + + def test_set_propensities__ode_propensity_function_only(self): + """ Test Reaction.set_propensities with ode_propensity_function only. """ + self.valid_ma_reaction.set_propensities(ode_propensity_function="k1*A/2") + expected_result = "((k1*A)/2)" + self.assertEqual(self.valid_ma_reaction.propensity_function, expected_result) + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, expected_result) + self.assertIsNone(self.valid_ma_reaction.marate) + self.assertFalse(self.valid_ma_reaction.massaction) + self.assertEqual(self.valid_ma_reaction.type, "customized") + + def test_set_propensities__different_propensities(self): + """ Test Reaction.set_propensities with different propensity functions. """ + self.valid_ma_reaction.set_propensities( + propensity_function="k1*A/2", ode_propensity_function="k1*A/3" ) + self.assertEqual(self.valid_ma_reaction.propensity_function, "((k1*A)/2)") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "((k1*A)/3)") + self.assertIsNone(self.valid_ma_reaction.marate) + self.assertFalse(self.valid_ma_reaction.massaction) + self.assertEqual(self.valid_ma_reaction.type, "customized") + + def test_set_propensities__int_propensity_function(self): + """ Test Reaction.set_propensities with propensity_function of type int. """ + self.valid_ma_reaction.set_propensities(propensity_function=5) + self.assertEqual(self.valid_ma_reaction.propensity_function, "5") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "5") + + def test_set_propensities__float_propensity_function(self): + """ Test Reaction.set_propensities with propensity_function of type float. """ + self.valid_ma_reaction.set_propensities(propensity_function=0.5) + self.assertEqual(self.valid_ma_reaction.propensity_function, "0.5") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "0.5") + + def test_set_propensities__int_ode_propensity_function(self): + """ Test Reaction.set_propensities with ode_propensity_function of type int. """ + self.valid_ma_reaction.set_propensities(ode_propensity_function=5) + self.assertEqual(self.valid_ma_reaction.propensity_function, "5") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "5") + + def test_set_propensities__float_ode_propensity_function(self): + """ Test Reaction.set_propensities with ode_propensity_function of type float. """ + self.valid_ma_reaction.set_propensities(propensity_function=0.5) + self.assertEqual(self.valid_ma_reaction.propensity_function, "0.5") + self.assertEqual(self.valid_ma_reaction.ode_propensity_function, "0.5") + + def test_set_propensities__invalid_propensity_function(self): + """ Test Reaction.set_propensities with an invalid propensity_function. """ + test_pfs = ["", ["k1 * A * B"]] + for test_pf in test_pfs: + with self.subTest(propensity_function=test_pf): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.set_propensities(propensity_function=test_pf) + + def test_set_propensities__invalid_ode_propensity_function(self): + """ Test Reaction.set_propensities with an invalid ode_propensity_function. """ + test_pfs = ["", ["k1 * A * B"]] + for test_pf in test_pfs: + with self.subTest(ode_propensity_function=test_pf): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.set_propensities(ode_propensity_function=test_pf) + + def test_set_rate(self): + """ Test Reaction.set_rate. """ + self.valid_cp_reaction.set_rate("k2") + self.assertEqual(self.valid_cp_reaction.propensity_function, "(k2*A)") + self.assertEqual(self.valid_cp_reaction.ode_propensity_function, "(k2*A)") + self.assertEqual(self.valid_cp_reaction.marate, "k2") + self.assertTrue(self.valid_cp_reaction.massaction) + self.assertEqual(self.valid_cp_reaction.type, "mass-action") + + def test_set_rate__parameter_rate(self): + """ Test Reaction.set_rate with rate of type SpatialPy.Parameter. """ + test_parameter = Parameter(name="k2", expression="0.75") + self.valid_cp_reaction.set_rate(test_parameter) + self.assertEqual(self.valid_cp_reaction.marate, "k2") + + def test_set_rate__int_rate(self): + """ Test Reaction.set_rate with rate of type int. """ + self.valid_cp_reaction.set_rate(5) + self.assertEqual(self.valid_cp_reaction.marate, "5") + + def test_set_rate__float_rate(self): + """ Test Reaction.set_rate with rate of type float. """ + self.valid_cp_reaction.set_rate(0.5) + self.assertEqual(self.valid_cp_reaction.marate, "0.5") + + def test_set_rate__invalid_rate(self): + """ Test Reaction.set_rate with an invalid rate. """ + test_rates = [None, "", ["k1"]] + for test_rate in test_rates: + with self.subTest(rate=test_rate): + with self.assertRaises(ReactionError): + self.valid_cp_reaction.set_rate(test_rate) + + def test_to_dict__massaction(self): + """ Test Reaction.to_dict with a mass-action reaction. """ + test_json = { + 'name': 'test_reaction', + 'reactants': [{'key': 'A', 'value': 1}], + 'products': [{'key': 'B', 'value': 1}], + 'marate': 'k1', + 'annotation': None, + 'propensity_function': '(k1*A)', + 'ode_propensity_function': '(k1*A)', + 'restrict_to': None, + 'massaction': True, + 'type': 'mass-action' + } + test_dict = self.valid_ma_reaction.to_dict() + self.assertIsInstance(test_dict, dict) + self.assertEqual(test_dict, test_json) + + def test_to_dict__customized(self): + """ Test Reaction.to_dict with a customized reaction. """ + test_json = { + 'name': 'test_reaction', + 'reactants': [{'key': 'A', 'value': 1}], + 'products': [{'key': 'B', 'value': 1}], + 'marate': None, + 'annotation': None, + 'propensity_function': '(k1*A)', + 'ode_propensity_function': '(k1*A)', + 'restrict_to': None, + 'massaction': False, + 'type': 'customized' + } + test_dict = self.valid_cp_reaction.to_dict() + self.assertIsInstance(test_dict, dict) + self.assertEqual(test_dict, test_json) + + def test_validate__mass_action(self): + """ Test Reaction.validate for mass-action reactions. """ + test_types = ["mass-action", "customized"] + test_massactions = [True, False] + for test_type in test_types: + for test_massaction in test_massactions: + with self.subTest(reaction_type=test_type, massaction=test_massaction): + if test_type == "mass-action" and test_massaction: + continue + with self.assertRaises(ReactionError): + self.valid_ma_reaction.type = test_type + self.valid_ma_reaction.massaction = test_massaction + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__customized(self): + """ Test Reaction.validate for customized reactions. """ + test_types = ["mass-action", "customized"] + test_massactions = [True, False] + for test_type in test_types: + for test_massaction in test_massactions: + with self.subTest(reaction_type=test_type, massaction=test_massaction): + if test_type == "customized" and not test_massaction: + continue + with self.assertRaises(ReactionError): + self.valid_cp_reaction.type = test_type + self.valid_cp_reaction.massaction = test_massaction + self.valid_cp_reaction.validate(coverage="all") + + def test_validate__invalid_name(self): + """ Test Reaction.validate with an invalid name. """ + test_names = [None, "", 0] + for test_name in test_names: + with self.subTest(name=test_name): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.name = test_name + self.valid_ma_reaction.validate(coverage="all") + + def test_valdiate__invalid_reactants_and_products(self): + """ Test Reaction.validate with reactants and products both set to None or empty. """ + test_reacs = [None, {}] + test_prods = [None, {}] + for test_reac in test_reacs: + for test_prod in test_prods: + with self.subTest(reactants=test_reac, products=test_prod): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.reactants = test_reac + self.valid_ma_reaction.products = test_prod + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__reactants_invalid_keys(self): + """ Test Reaction.validate with reactants keyed with invalid keys. """ with self.assertRaises(ReactionError): - test_reaction.annotate(None) - - - def test_initialize__propensity_function(self): - """ Test Reaction.initialize with propensity function only. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species([test_species_x, test_species_y]) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {"test_species_x": 1} - test_products = {"test_species_y": 1} - test_propensity = "test_parameter * test_species_x" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, propensity_function=test_propensity - ) - test_reaction.initialize(model=test_model) - self.assertFalse(test_reaction.massaction) - self.assertEqual(test_reaction.type, "customized") - self.assertIsNone(test_reaction.marate) - self.assertEqual(test_reaction.ode_propensity_function, test_propensity) - - - def test_initialize__propensity_function(self): - """ Test Reaction.initialize with rate parameter only. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species([test_species_x, test_species_y]) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {"test_species_x": 1} - test_products = {"test_species_y": 1} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.initialize(model=test_model) - self.assertTrue(test_reaction.massaction) - self.assertEqual(test_reaction.type, "mass-action") - self.assertEqual(test_reaction.marate, "test_parameter") - self.assertEqual(test_reaction.ode_propensity_function, "test_parameter * test_species_x") - - - def test_initialize__no_propensity_or_rate(self): - """ Test Reaction.initialize without providing a propensity function or rate parameter. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species([test_species_x, test_species_y]) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {"test_species_x": 1} - test_products = {"test_species_y": 1} - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products - ) - with self.assertRaises(ReactionError): - test_reaction.initialize(model=test_model) - - - def test_initialize__no_propensity_or_rate(self): - """ Test Reaction.initialize without providing a propensity function or rate parameter. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species([test_species_x, test_species_y]) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {"test_species_x": 1} - test_products = {"test_species_y": 1} - test_rate = "test_parameter" - test_propensity = "test_parameter * test_species_x" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate, propensity_function=test_propensity - ) - with self.assertRaises(ReactionError): - test_reaction.initialize(model=test_model) - - - def test_initialize__reactant_species_object_not_in_model(self): - """ Test Reaction.initialize with reactant species object not in the Model. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species(test_species_y) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {test_species_x: 1} - test_products = {"test_species_y": 1} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - with self.assertRaises(ReactionError): - test_reaction.initialize(model=test_model) - - - def test_initialize__reactant_species_str_not_in_model(self): - """ Test Reaction.initialize with reactant species string not in the Model. """ - test_model = Model(name="test_model") - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species(test_species_y) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {"test_species_x": 1} - test_products = {"test_species_y": 1} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) + self.valid_ma_reaction.reactants = {1: 1} + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__reactants_invalid_values(self): + """ Test Reaction.validate with reactants containing invalid stoichiometry. """ with self.assertRaises(ReactionError): - test_reaction.initialize(model=test_model) - - - def test_initialize__product_species_object_not_in_model(self): - """ Test Reaction.initialize with product species object not in the Model. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species(test_species_x) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {"test_species_x": 1} - test_products = {test_species_y: 1} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) + self.valid_ma_reaction.reactants = {"A": "1"} + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__invalid_reactants(self): + """ Test Reaction.validate with non-dict reactants. """ + test_reactants = [None, ["A", 1]] + for test_reactant in test_reactants: + with self.subTest(reactants=test_reactant): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.reactants = test_reactant + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__prodects_invalid_keys(self): + """ Test Reaction.validate with products keyed with invalid keys. """ with self.assertRaises(ReactionError): - test_reaction.initialize(model=test_model) - - - def test_initialize__product_species_str_not_in_model(self): - """ Test Reaction.initialize with product species string not in the Model. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_model.add_species(test_species_x) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {"test_species_x": 1} - test_products = {"test_species_y": 1} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) + self.valid_ma_reaction.products = {1: 1} + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__products_invalid_values(self): + """ Test Reaction.validate with products containing invalid stoichiometry. """ with self.assertRaises(ReactionError): - test_reaction.initialize(model=test_model) - - - def test_initialize__reactant_object_as_object(self): - """ Test that all species objects in Reaction.reactants are stored correctly after Reaction.initialize call. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species([test_species_x, test_species_y]) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {test_species_x: 1, test_species_y: 1} - test_products = {} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.initialize(model=test_model) - for reactant in test_reaction.reactants: - with self.subTest(reactant=str(reactant)): - self.assertIsInstance(reactant, Species) - - - def test_initialize__reactant_string_as_object(self): - """ Test that all species strings in Reaction.reactants are stored correctly after Reaction.initialize call. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species([test_species_x, test_species_y]) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {"test_species_x": 1, "test_species_y": 1} - test_products = {} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.initialize(model=test_model) - for reactant in test_reaction.reactants: - with self.subTest(reactant=str(reactant)): - self.assertIsInstance(reactant, Species) - - - def test_initialize__product_object_as_object(self): - """ Test that all species objects in Reaction.products are stored correctly after Reaction.initialize call. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species([test_species_x, test_species_y]) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {} - test_products = {test_species_x: 1, test_species_y: 1} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.initialize(model=test_model) - for product in test_reaction.products: - with self.subTest(product=str(product)): - self.assertIsInstance(product, Species) - - - def test_initialize__reactant_object_as_object(self): - """ Test that all species string in Reaction.products are stored correctly after Reaction.initialize call. """ - test_model = Model(name="test_model") - test_species_x = Species(name="test_species_x", diffusion_coefficient=0.5) - test_species_y = Species(name="test_species_y", diffusion_coefficient=0.5) - test_model.add_species([test_species_x, test_species_y]) - test_parameter = Parameter(name="test_parameter", expression=0.1) - test_model.add_parameter(test_parameter) - test_reactants = {} - test_products = {"test_species_x": 1, "test_species_y": 1} - test_rate = "test_parameter" - test_reaction = Reaction( - name="test_reaction", reactants=test_reactants, products=test_products, rate=test_rate - ) - test_reaction.initialize(model=test_model) - for product in test_reaction.products: - with self.subTest(product=str(product)): - self.assertIsInstance(product, Species) + self.valid_ma_reaction.products = {"B": "1"} + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__invalid_products(self): + """ Test Reaction.validate with non-dict products. """ + test_products = [None, ["B", 1]] + for test_product in test_products: + with self.subTest(products=test_product): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.products = test_product + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__invalid_propensity_function(self): + """ Test Reaction.validate with an invalid propensity function. """ + test_pfs = [None, 20, 0.5, "", ["k1 * A * B"]] + for test_pf in test_pfs: + with self.subTest(propensity_function=test_pf): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.propensity_function = test_pf + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__invalid_ode_propensity_function(self): + """ Test Reaction.validate with an invalid ode propensity function. """ + test_opfs = [None, 20, 0.5, "", ["k1 * A * B"]] + for test_opf in test_opfs: + with self.subTest(ode_propensity_function=test_opf): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.ode_propensity_function = test_opf + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__invalid_rate(self): + """ Test Reaction.validate with a rate that is an invalid type. """ + test_rates = [20, 0.5, "", ["k1"]] + for test_rate in test_rates: + with self.subTest(rate=test_rate): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.marate = test_rate + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__annotation_invalid_type(self): + """ Test Reaction.validate with an annotation of invalid type. """ + test_annotations = [5, 0.1, ["g"]] + for test_annotation in test_annotations: + with self.subTest(annotation=test_annotation): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.annotation = test_annotation + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__invalid_restrict_to(self): + """ Test Reaction.validate with a restrict_to that is not the proper type. """ + test_rts = ["Walls", 1, 1.5, {"Walls": 1}, ("Walls", "Fluid")] + for test_rt in test_rts: + with self.subTest(restrict_to=test_rt): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.restrict_to = test_rt + self.valid_ma_reaction.validate(coverage="all") + + def test_validate__invalid_restrict_to_value(self): + """ Test Reaction.validate with a restrict_to that is not the proper value. """ + test_rts = [ + [], [None], [""], ["Walls"], [1], [1.5], [[]], ["type_1", None], + ["type_1", ""], ["type_1", "Walls"], ["type_1", 1], ["type_1", 1.5], ["type_1", []] + ] + for test_rt in test_rts: + with self.subTest(restrict_to=test_rt): + with self.assertRaises(ReactionError): + self.valid_ma_reaction.restrict_to = test_rt + self.valid_ma_reaction.validate(coverage="all") + + def test_comp_time_of_validate(self): + """ Check the computation time of validate. """ + import time + from datetime import datetime + start = time.time() + self.valid_ma_reaction.validate(coverage="all") + tic = datetime.utcfromtimestamp(time.time() - start) + print(f"Total time to run validate on a mass-action reaction: {tic.strftime('%M mins %S secs %f msecs')}") + start = time.time() + self.valid_cp_reaction.validate(coverage="all") + tic = datetime.utcfromtimestamp(time.time() - start) + print(f"Total time to run validate on a customized reaction: {tic.strftime('%M mins %S secs %f msecs')}")