# -*- python -*-
#
# This file is part of the CNO package
#
# Copyright (c) 2012-2014 - EMBL-EBI
#
# File author(s): Thomas Cokelaer (cokelaer@ebi.ac.uk)
#
# Distributed under the GLPv3 License.
# See accompanying file LICENSE.txt or copy at
# http://www.gnu.org/licenses/gpl-3.0.html
#
# website: http://github.com/cellnopt/cellnopt
#
##############################################################################
"""This module contains a base class to manipulate reactions
.. testsetup:: reactions
from cno import Reaction
from cno.io.reactions import Reactions
r = Reactions()
"""
from __future__ import print_function
import re
from cno.misc import CNOError
__all__ = ["Reaction", "Reactions"]
class ReactionBase(object):
valid_symbols = ["+", "!", "&", "^"]
and_symbol = "^"
[docs]class Reaction(str, ReactionBase):
"""Logical Reaction
A Reaction can encode logical ANDs and ORs as well as NOT::
>>> from cno import Reaction
>>> r = Reaction("A+B=C") # a OR reaction
>>> r = Reaction("A^B=C") # an AND reaction
>>> r = Reaction("A&B=C") # an AND reaction
>>> r = Reaction("C=D") # an activation
>>> r = Reaction("!D=E") # a NOT reaction
The syntax is as follows:
#. The **!** sign indicates a logical NOT.
#. The **+** sign indicates a logical OR.
#. The **=** sign indicates a relation (edge).
#. The **^** or **&** signs indicate a logical AND. Note that **&** signs
will be replaced by **^**.
Internally, reactions are checked for validity (e.g., !=C is invalid).
You can reset the name::
>>> r.name = "A+B+C=D"
or create an instance from another instance::
>>> newr = Reaction(r)
Sorting can be done inplace (default) or not. ::
>>> r = Reaction("F+D^!B+!A=Z")
>>> r.sort(inplace=False)
'!A+!B^D+F=Z'
Simple operator (e.g., equality) are available. Note that equality will sort the species
internally so A+B=C would be equal to B+A=C and there is no need to call :meth:`sort`::
>>> r = Reaction("F+D^!B+!A=Z")
>>> r == '!A+!B^D+F=Z'
True
If a reaction **A+A=B** is provided, it can be simplified by calling :meth:`simplify`.
ANDs operator are not simplified. More sophisticated simplifications using Truth
Table could be used but will not be implemented in this class for now.
"""
# use __new__ to inherit from str class.
def __new__(cls, reaction=None, strict_rules=True):
"""
:param str reaction: a valid reaction (e.g., A=B, A+B=C, !B=D, C^D=F, ...),
or an instance of :class:`Reaction`.
:param bool strict_rules: if True, reactions cannot start with =, ^ or +
signs (default to True).
"""
# Since __init__ is called after the object is constructed,
# it is too late to modify the value for immutable types.
# Note that __new__ is a classmethod,
self = str.__new__(cls, reaction)
self._strict_rules = strict_rules
# since strings are immutable, we use this attribute to play around
# with the name
self._name = None
if reaction is not None:
# could be a Reaction instance
if hasattr(reaction, "name"):
self.name = reaction.name[:]
# or a string
# no unicode to be python3 compatible.
elif isinstance(reaction, (str)):
self.name = reaction[:]
else:
raise CNOError("neither a string nor a Reaction instance")
return self
def _set_name(self, reaction):
if reaction is not None:
reaction = self._valid_reaction(reaction)
self._name = reaction[:]
def _get_name(self):
return self._name
name = property(_get_name, _set_name,
doc="Getter/Setter for the reaction name")
def _get_species(self, reac=None):
"""
.. doctest:: reactions
>>> r = Reaction("!a+c^d^e^f+!h=b")
>>> r.species
['a', 'c', 'd', 'e', 'f', 'h', 'b']
"""
if reac is None:
reac = self.name[:]
species = re.split("[+|=|^|!]", reac)
species = [x for x in species if x]
return species
species = property(_get_species)
[docs] def get_signed_lhs_species(self):
lhs = self.lhs[:]
species = re.split("[+|^]", lhs)
pos = [x for x in species if x.startswith("!") is False]
neg = [x[1:] for x in species if x.startswith("!") is True]
return {'-': neg, '+': pos}
def _get_lhs(self):
return self.name.split("=")[0]
lhs = property(_get_lhs,
doc="Getter for the left hand side of the = character")
def _get_lhs_species(self):
lhs = self.name.split("=")[0]
species = self._get_species(reac=lhs)
return species
lhs_species = property(_get_lhs_species,
doc="Getter for the list of species on the left hand side of the = character")
def _get_rhs(self):
return self.name.split("=")[1]
rhs = property(_get_rhs,
doc="Getter for the right hand side of the = character")
# FIXME does not make sense if A^!B^C=D ??
def _get_sign(self):
# if we have an AND gate, for instance A^B=C
if "!" in self.name and self.and_symbol not in self.name:
return "-1"
else:
return "1"
sign = property(_get_sign, doc="return sign of the reaction")
def _valid_reaction(self, reaction):
reaction = reaction.strip()
reaction = reaction.replace("&", self.and_symbol)
# = sign is compulsory
N = reaction.count("=")
if N != 1:
raise CNOError("Invalid reaction name (only one = character expected. found {0})".format(N))
#
if self._strict_rules:
if reaction[0] in ["=", "^", "+"]:
raise CNOError("Reaction (%s) cannot start with %s" %
(reaction, "=, ^, +"))
#
lhs, rhs = reaction.split("=")
for this in self.valid_symbols:
if this in rhs:
raise CNOError("Found an unexpected character (%s) in the LHS of reactions %s" %
(reaction, self.valid_symbols))
if reaction.startswith("="):
pass
else:
species = re.split("[+|^]", reaction.split("=")[0])
if "" in species:
raise CNOError("Reaction (%s) has two many + or ^ signs" % reaction)
# Finally, a ! must be preceded by either nothing or a special sign but not another species
# e.g. A!B does not make sense.
for i, x in enumerate(species):
if x == "!" and i!=0:
if species[i-1] not in self.valid_symbols:
raise CNOError("Reaction (%s) may be ill-formed with incorrect ! not preceded by another reaction ")
return reaction
[docs] def ands2ors(self, reaction):
reaction = Reaction(reaction)
lhs = reaction.get_signed_lhs_species()
rhs = reaction.rhs
reactions = [x + "=" + rhs for x in lhs['+']]
reactions += ["!" + x + "=" + rhs for x in lhs['-']]
return reactions
[docs] def sort(self, inplace=True):
"""Rearrange species in alphabetical order
:param bool inplace: defaults to True
::
>>> r = Reaction("F+D^!B+!A=Z")
>>> r.sort()
>>> r
'!A+!B^D+F=Z'
"""
# if only one lhs, nothing to do
if len(self.lhs_species) == 1:
return
# we first need to split + and then ^
splitted_ors = [x for x in self.lhs.split("+")] # left species keeping ! sign
# loop over split list searching for ANDs
species = []
for this in splitted_ors:
species_ands = this.split("^")
# sort the species within the ANDs
species_ands = sorted(species_ands, key=lambda x: x.replace("!", ""))
species_ands = "^".join(species_ands)
species.append(species_ands)
# now sort the ORs
species = sorted(species, key=lambda x: x.replace("!", ""))
# and finally rejoin them
species = "+".join(species)
new_reac = "=".join([species, self.rhs])
if inplace is True:
self.name = new_reac
else:
return new_reac
[docs] def simplify(self, inplace=True):
"""Simplifies reaction if possible.
::
>>> r = Reaction("A+A=B")
>>> r.simplify()
>>> r
"A=B"
Other cases (with ANDs) are not simplified. Even though **A+A^B=C** truth table
could be simplified to **A=C** but we will not simplified it for now.
"""
lhs = "+".join(set(self.lhs.split("+")))
name = "=".join([lhs, self.rhs])
if inplace:
self.name = name
else:
return name
def _rename_one_species(self, lhs, k, v):
symbols = ['+', '^', '!', '=']
new_name = ''
current_species = ''
# LHS
for x in lhs:
# each time there is a symbol found, we will read a new species
if x in symbols:
# the current species should now be added to the new_name
if current_species == k:
new_name += v
else:
new_name += current_species
current_species = ''
new_name += x
else:
current_species += x
# RHS: in principle current_species should be the RHS
if current_species == k:
new_name += v
else:
new_name += current_species
return new_name
[docs] def rename_species(self, mapping={}):
for k, v in mapping.items():
self.name = self._rename_one_species(self.name, k, v)
def __repr__(self):
# str needs to be overwritten otherwise, _name is not used but the default __repr__
# if one call sort(), then, calling the variable name raise the wrong values,
# _name but the internal attribute from the str object.
return self._name
def __eq__(self, other):
# The reaction may not be sorted and user may not want it to be sorted,
# so we create a new instance and sort it
r1 = Reaction(self)
r1.sort()
# we also sort the input reaction creating an instance as well so that the input reaction
# (if it is an object) will not be sorted inplace either
r2 = Reaction(other)
r2.sort()
if r1.name == r2.name:
return True
else:
return False
[docs]class Reactions(ReactionBase):
"""Data structure to handle list of :class:`Reaction` instances
For the syntax of a reaction, see :class:`Reaction`. You can
use the **=**, **!**, **+** and **^** characters.
Reactions can be added using either string or instances of :class:`Reaction`::
>>> from cno import Reaction, Reactions
>>> r = Reactions()
>>> r.add_reaction("A+B=C") # a OR reaction
>>> r.add_reaction("A^B=C") # an AND reaction
>>> r.add_reaction("A&B=C") # an AND reaction
>>> r.add_reaction("C=D") # an activation
>>> r.add_reaction("!D=E") # a NOT reaction
>>> r.add_reaction(Reaction("F=G")) # a NOT reaction
Now, we can get the species::
>>> r.species
['A', 'B', 'C', 'D', 'E']
Remove one::
>>> r.remove_species("A")
>>> r.reactions
["B=C", "C=D", "!D=E"]
.. note:: there is no simplifications made on reactions. For instance, if you add A=B and then
A+B=C, A=B is redundant but will be kept.
.. seealso:: :class:`cno.io.reactions.Reaction` and :class:`cno.io.sif.SIF`
"""
def __init__(self, reactions=[], strict_rules=True, verbose=False):
super(Reactions, self).__init__()
self.strict_rules = strict_rules
# !! use a copy
self._reactions = []
self.add_reactions(reactions)
self.verbose = verbose
[docs] def to_list(self):
"""Return list of reaction names"""
return [x.name for x in self._reactions]
def _get_species(self):
"""Extract the specID out of reacID"""
# extract species from all reactions and add to a set
species = [this for reaction in self._reactions for this in reaction.species]
species = set(species)
# sort (transformed to a list)
species = sorted(species)
return species
species = property(_get_species, doc="return list of unique species")
def _get_reaction_names(self):
return [reaction.name for reaction in self._reactions]
reactions = property(fget=_get_reaction_names, doc="return list of reaction names")
def __str__(self):
_str = "Reactions() instance:\n"
_str += "- %s reactions\n" % len(self.reactions)
_str += "- %s species\n" % len(self.species)
return _str
[docs] def remove_species(self, species_to_remove):
"""Removes species from the list of reactions
:param str,list species_to_remove:
.. note:: If a reaction is "a+b=c" and you remove specy "a",
then the reaction is not enterely removed but replace by "b=c"
"""
# make sure we have a **list** of species to remove
if isinstance(species_to_remove, list):
pass
elif isinstance(species_to_remove, str):
species_to_remove = [species_to_remove]
else:
raise TypeError("species_to_remove must be a list or string")
reacIDs_toremove = []
reacIDs_toadd = []
for reac in self._reactions:
lhs = reac.lhs_species # lhs without ! sign
rhs = reac.rhs
# if RHS contains a species to remove, the entire reaction can be removed
if rhs in species_to_remove:
reacIDs_toremove.append(reac.name)
continue
# otherwise, we need to look at the LHS. If the LHS is of length 1,
# we are in the first case (a=b) and it LHS contains specy to
# remove, we do not want to keep it.
if len(lhs) == 1:
if lhs[0] in species_to_remove:
reacIDs_toremove.append(reac.name)
continue
# Finally, if LHS contains 2 species or more, separated by + sign,
# we do no want to remove the entire reaction but only the
# relevant species. So to remove a in "a+b=c", we should return "b=c"
# taking care of ! signs as well.
for symbol in ["+", "^"]:
if symbol not in reac.name:
continue
else:
lhs_with_neg = [x for x in reac.name.split("=")[0].split(symbol)]
new_lhs = symbol.join([x for x in lhs_with_neg if x.replace("!", "") not in species_to_remove])
if len(new_lhs):
new_reac = new_lhs + "=" + rhs
reacIDs_toremove.append(reac.name)
reacIDs_toadd.append(new_reac)
#
for reac in reacIDs_toremove:
self.remove_reaction(reac)
for reac in reacIDs_toadd:
self.add_reaction(reac)
[docs] def rename_species(self, mapping={}):
"""Rename species in all reactions
:param dict mapping: The mapping between old and new names
"""
for r in self._reactions:
r.rename_species(mapping)
[docs] def add_reactions(self, reactions):
"""Add a list of reactions
:param list reactions: list of reactions or strings
"""
for reac in reactions:
self.add_reaction(Reaction(reac))
[docs] def add_reaction(self, reaction):
"""Adds a reaction in the list of reactions
See documentation of the :class:`Reaction` class for details. Here are
some valid reactions::
a=b
a+c=d
a^b=e # same as above
!a=e
Example:
.. doctest::
>>> from cno import Reactions
>>> c = Reactions()
>>> c.add_reaction("a=b")
>>> assert len(c.reactions) == 1
"""
reac = Reaction(reaction, strict_rules=self.strict_rules)
reac.sort()
if reac.name not in self.to_list():
self._reactions.append(reac)
else:
print("Reaction %s already in the list of reactions" % reaction)
[docs] def remove_reaction(self, reaction_name):
"""Remove a reaction from the reacID list
>>> c = Reactions()
>>> c.add_reaction("a=b")
>>> assert len(c.reactions) == 1
>>> c.remove_reaction("a=b")
>>> assert len(c.reactions) == 0
"""
names = [x.name for x in self._reactions]
if reaction_name in names:
index2remove = names.index(reaction_name)
del self._reactions[index2remove]
else:
if self.verbose:
print("Reaction {0} not found. Nothing done".format(reaction_name))
[docs] def search(self, species, strict=False):
"""Prints and returns reactions that contain the species name
:param str species: name to look for
:param bool strict: decompose reactions to search for the species
:return: a Reactions instance with reactions containing the species to search for
"""
r = Reactions()
for x in self._reactions:
list_species = x.lhs_species
if strict == True:
for this in list_species:
if species.lower() == this.lower():
if self.verbose:
print("Adding {0}".format(x.name))
r.add_reaction(x.name)
else:
for this in list_species:
if species.lower() in this.lower():
if self.verbose:
print("Adding {0}".format(x.name))
r.add_reaction(x.name)
continue
return r
def __len__(self):
return len(self._reactions)