# File: kineticmodel.py
# Project: models
# Author: Jan Range
# License: BSD-2 clause
# Copyright (c) 2022 Institute of Biochemistry and Technical Biochemistry Stuttgart
import ast
import re
import numexpr
from typing import Any, List, TYPE_CHECKING, Optional
from pydantic import Field
from dataclasses import dataclass
from pydantic.fields import PrivateAttr
from pyenzyme.enzymeml.core.enzymemlbase import EnzymeMLBase
from pyenzyme.enzymeml.core.ontology import SBOTerm
from pyenzyme.enzymeml.core.utils import type_checking, deprecated_getter
if TYPE_CHECKING: # pragma: no cover
static_check_init_args = dataclass
else:
static_check_init_args = type_checking
[docs]@static_check_init_args
class KineticParameter(EnzymeMLBase):
name: str = Field(
...,
description="Name of the estimated parameter.",
)
value: Optional[float] = Field(
None,
description="Numerical value of the estimated parameter.",
)
unit: Optional[str] = Field(
None,
description="Unit of the estimated parameter.",
)
initial_value: Optional[float] = Field(
None, description="Initial value that was used for the parameter estimation."
)
upper: Optional[float] = Field(
None, description="Upper bound of the estimated parameter."
)
lower: Optional[float] = Field(
None, description="Lower bound of the estimated parameter."
)
is_global: bool = Field(
False, description="Specifies if this parameter is a global parameter."
)
stdev: Optional[float] = Field(
None, description="Standard deviation of the estimated parameter."
)
constant: bool = Field(False, description="Specifies if this parameter is constant")
ontology: Optional[SBOTerm] = Field(
None,
description="Type of the estimated parameter.",
)
# * Private attributes
_unit_id: Optional[str] = PrivateAttr(None)
_enzmldoc = PrivateAttr(default=None)
[docs] def get_id(self):
"""For logging. Dont bother."""
return self.name
# ! Utilities
[docs] def update(self, **kwargs):
"""Adds attributes to this parameter based in kwargs"""
self.__dict__.update(kwargs)
# ! Getters
[docs] def unitdef(self):
"""Returns the appropriate unitdef if an enzmldoc is given"""
if not self._enzmldoc:
return None
return self._enzmldoc._unit_dict[self._unit_id]
[docs]@static_check_init_args
class KineticModel(EnzymeMLBase):
name: str = Field(
...,
description="Name of the kinetic law.",
)
equation: str = Field(
...,
description="Equation for the kinetic law.",
)
parameters: List[KineticParameter] = Field(
default_factory=list,
description="List of estimated parameters.",
)
ontology: Optional[SBOTerm] = Field(
None,
description="Type of the estimated parameter.",
)
# ! Add methods
[docs] def addParameter(
self,
name: str,
value: Optional[float] = None,
unit: Optional[str] = None,
initial_value: Optional[float] = None,
upper: Optional[float] = None,
lower: Optional[float] = None,
is_global: bool = False,
stdev: Optional[float] = None,
constant: bool = False,
ontology: Optional[SBOTerm] = None,
):
"""Adds a parameter to the KineticModel object
Args:
name (str): Name of the estimated parameter.
value (Optional[float], optional): Numerical value of the estimated parameter.. Defaults to None.
unit (Optional[str], optional): Unit of the estimated parameter.. Defaults to None.
initial_value (Optional[float], optional): Initial value that was used for the parameter estimation. Defaults to None.
upper (Optional[float], optional): Upper bound of the estimated parameter.. Defaults to None.
lower (Optional[float], optional): Lower bound of the estimated parameter.. Defaults to None.
is_global (bool, optional): Specifies if this parameter is a global parameter.. Defaults to False.
stdev (Optional[float], optional): Standard deviation of the estimated parameter.. Defaults to None.
constant (bool, optional): Specifies if this parameter is constant. Defaults to False.
ontology (Optional[SBOTerm], optional): Type of the estimated parameter.. Defaults to None.
"""
self.parameters.append(
KineticParameter(
name=name,
value=value,
unit=unit,
initial_value=initial_value,
upper=upper,
lower=lower,
is_global=is_global,
stdev=stdev,
constant=constant,
ontology=ontology,
)
)
self.__dict__["_" + self.parameters[-1].name] = self.parameters[-1]
# ! Initializers
[docs] @staticmethod
def createGenerator(name: str, equation: str, **parameters):
"""Creates an abstract model generator to generated specific models.
Args:
name (str): Name of the model.
equation (str): Equation
Returns:
[type]: [description]
"""
return ModelFactory(name=name, equation=equation, **parameters)
[docs] @classmethod
def fromEquation(cls, name: str, equation: str, enzmldoc: Optional[Any] = None):
"""Creates a Kinetic Model instance from an equation
Args:
equation (str): Mathematical equation decribing the model.
Returns:
KineticModel: Resulting kinetic model
"""
if enzmldoc:
# Convert an equation with names to one with IDs
class_name = enzmldoc.__class__.__name__
if class_name != "EnzymeMLDocument":
# Guard clause
raise TypeError(
f"Expected type 'EnzymeMLDocument' for argument 'enzmldoc'. Got '{class_name}' instead."
)
# Now perform the actual conversion
equation = cls._convert_names_to_ids(enzmldoc, equation)
# Create a new instance
cls = cls(name=name, equation=equation)
# Parse equation and add parameters
used_species = []
for node in ast.walk(ast.parse(equation)):
if isinstance(node, ast.Name):
name = node.id
regex = re.compile(r"[s|p|c]\d*")
if not bool(regex.match(name)) and "_" + name not in cls.__dict__:
cls.addParameter(name=name)
else:
used_species.append(name)
if not used_species:
raise TypeError(
"It seems like you have included no species (Protein, Reactant, Complex) in your equation. "
"Are you using names? Please set your 'EnzymeMLDocument' to the argument 'enzmldoc' in order to proceed"
)
return cls
@staticmethod
def _convert_names_to_ids(enzmldoc, equation: str):
"""Converts names in an equation to appropriate IDs given in an EnzymeMLDocument"""
all_species = {
**enzmldoc.protein_dict,
**enzmldoc.reactant_dict,
**enzmldoc.complex_dict,
}
for id, species in all_species.items():
equation = equation.replace(species.name, id)
return equation
# ! Utilities
[docs] def get_id(self) -> str:
return self.name
[docs] def evaluate(self, **kwargs):
"""Calculates the the reaction velocity given the internal parameters and variable concentrations handed as keyword arguments.
Examples:
model = KineticModel(...) <- Lets assume this is a Menten Model with already estimated parameters
print(model.evaluate(protein=10.0, substrate=1.0))
>> 1.002 <- This is the resulting velocity
Returns:
float: Corresponding reaction velocity given the internal parameters and variables.
"""
# Initialize a dictionary which will take care to create an eval string
params = {}
# Get the values for the parameters
for parameter in self.parameters:
if parameter.value:
params.update({parameter.name: parameter.value})
# Now replace the strings in the equation to evaluate it using numexpr
eval_string = self.equation
for key, value in {**kwargs, **params}.items():
eval_string = eval_string.replace(key, str(value))
return numexpr.evaluate(eval_string).tolist()
# ! Getters
[docs] def getParameter(self, name: str) -> KineticParameter:
"""Returns a parameter of choice from the model.
Args:
name (str): Name of the parameter.
Raises:
KeyError: If the parameter does not exist.
Returns:
KineticParameter: The desired parameter.
"""
try:
return next(filter(lambda parm: parm.name == name, self.parameters))
except StopIteration:
raise KeyError(f"Parameter {name} not found in the model.")
[docs] @deprecated_getter("equation")
def getEquation(self):
return self.equation
[docs] @deprecated_getter("parameters")
def getParameters(self):
return self.parameters
[docs] @deprecated_getter("name")
def getName(self):
return self.name
[docs]class ModelFactory:
equation: str
parameters: List[str]
name: str
def __init__(
self, name: str, equation: str, ontology: Optional[SBOTerm] = None, **parameters
) -> None:
# Parse the eqation and get all names and variables
self.variables = self.parse_equation(equation)
# Initialize the model
self.model = KineticModel(name=name, equation=equation, ontology=None)
for name, options in parameters.items():
# Remove parameter from variables
self.variables.remove(name)
# Get all teh options for the parameter
init_value = options.get("init_value")
value = options.get("value")
unit = options.get("unit")
ontology = options.get("ontology")
stdev = options.get("stdev")
upper = options.get("upper")
lower = options.get("lower")
constant = options.get("constant")
# Convert constant to bool if not set
if not constant:
constant = False
parameter = KineticParameter(
name=name,
value=value,
unit=unit,
initial_value=init_value,
stdev=stdev,
ontology=ontology,
upper=upper,
lower=lower,
is_global=False,
constant=constant,
)
self.model.parameters.append(parameter)
def __call__(self, mapping: dict = {}, **variables) -> KineticModel:
"""Returns a KineticModel that is suited for the given parameters."""
# Copy the internal object and modify it to the needs
model = KineticModel(**self.model.dict())
# Replace everything
for stock_variable in self.variables:
try:
identifier = variables[stock_variable]
if isinstance(identifier, list):
# Allow multiple species
identifier = [repr(name) for name in identifier]
identifier = f"({' * '.join(identifier)})"
else:
identifier = repr(identifier)
model.equation = model.equation.replace(stock_variable, identifier)
except KeyError:
raise KeyError(
f"Variable {stock_variable} has not been given. Please make sure to cover all variables: [{repr(self.variables)}]"
)
# Apply mapping
if mapping:
self._apply_mapping(mapping, model)
return model
def _apply_mapping(self, mapping: dict, model: KineticModel):
"""Applies a mapping that has been given to the model."""
for param_old, param_new in mapping.items():
model.equation = model.equation.replace(param_old, param_new)
# Variable to control when a parameter has found
found = False
for index, parameter in enumerate(model.parameters):
if parameter.name == param_old:
# Copy old and add new parameter
nu_param = parameter.copy()
nu_param.name = param_new
model.parameters.append(nu_param)
found = True
# Remove old one
del model.parameters[index]
continue
if not found:
raise KeyError(f"Parameter {param_old} is not part of the model.")
[docs] @staticmethod
def parse_equation(equation: str):
return list(
set(
[
node.id
for node in ast.walk(ast.parse(equation))
if isinstance(node, ast.Name)
]
)
)