KarrLab/obj_tables

View on GitHub
obj_tables/chem/core.py

Summary

Maintainability
F
2 wks
Test Coverage
A
99%
""" Chemistry attributes

:Author: Jonathan Karr <karr@mssm.edu>
:Date: 2017-05-10
:Copyright: 2017, Karr Lab
:License: MIT
"""

from .. import core
from wc_utils.util import chem
from wc_utils.util.enumerate import CaseInsensitiveEnum
import wc_utils.workbook.io
import bcforms
import bpforms
import bpforms.util
import lark
import math
import openbabel
import os.path
import pkg_resources

__all__ = [
    'ChemicalFormulaAttribute',
    'ChemicalStructure',
    'ChemicalStructureFormat',
    'ChemicalStructureAttribute',
    'ReactionEquation',
    'ReactionParticipant',
    'ReactionEquationAttribute',
]


class ChemicalFormulaAttribute(core.LiteralAttribute):
    """ Chemical formula attribute """

    def __init__(self, default=None, none_value=None, verbose_name='', description="A chemical formula (e.g. 'H2O', 'CO2', or 'NaCl')",
                 primary=False, unique=False):
        """
        Args:
            default (:obj:`chem.EmpiricalFormula`, :obj:`dict`, :obj:`str`, or :obj:`None`, optional): default value
            none_value (:obj:`object`, optional): none value
            verbose_name (:obj:`str`, optional): verbose name
            description (:obj:`str`, optional): description
            primary (:obj:`bool`, optional): indicate if attribute is primary attribute
            unique (:obj:`bool`, optional): indicate if attribute value must be unique
        """
        if not isinstance(default, chem.EmpiricalFormula) and default is not None:
            default = chem.EmpiricalFormula(default)

        super(ChemicalFormulaAttribute, self).__init__(default=default, none_value=none_value,
                                                       verbose_name=verbose_name,
                                                       description=description,
                                                       primary=primary, unique=unique)
        if primary:
            self.type = chem.EmpiricalFormula
        else:
            self.type = (chem.EmpiricalFormula, None.__class__)

    def deserialize(self, value):
        """ Deserialize value

        Args:
            value (:obj:`str`): semantically equivalent representation

        Returns:
            :obj:`tuple`:

                * :obj:`chem.EmpiricalFormula`: cleaned value
                * :obj:`core.InvalidAttribute`: cleaning error
        """
        if value:
            try:
                return (chem.EmpiricalFormula(value), None)
            except ValueError as error:
                return (None, core.InvalidAttribute(self, [str(error)]))
        return (None, None)

    def validate(self, obj, value):
        """ Determine if :obj:`value` is a valid value

        Args:
            obj (:obj:`Model`): class being validated
            value (:obj:`chem.EmpiricalFormula`): value of attribute to validate

        Returns:
            :obj:`core.InvalidAttribute` or None: None if attribute is valid, other return
                list of errors as an instance of :obj:`core.InvalidAttribute`
        """
        errors = []

        if value is not None and not isinstance(value, chem.EmpiricalFormula):
            errors.append('Value must be an instance of `chem.EmpiricalFormula`')

        if self.primary and (not value or len(value) == 0):
            errors.append('{} value for primary attribute cannot be empty'.format(
                self.__class__.__name__))

        if errors:
            return core.InvalidAttribute(self, errors)
        return None

    def validate_unique(self, objects, values):
        """ Determine if the attribute values are unique

        Args:
            objects (:obj:`list` of :obj:`Model`): list of :obj:`Model` objects
            values (:obj:`list` of :obj:`chem.EmpiricalFormula`): list of values

        Returns:
            :obj:`core.InvalidAttribute` or None: None if values are unique, otherwise return a
                list of errors as an instance of :obj:`core.InvalidAttribute`
        """
        str_values = []
        for v in values:
            str_values.append(self.serialize(v))
        return super(ChemicalFormulaAttribute, self).validate_unique(objects, str_values)

    def serialize(self, value):
        """ Serialize string

        Args:
            value (:obj:`chem.EmpiricalFormula`): Python representation

        Returns:
            :obj:`str`: simple Python representation
        """
        if value is None:
            return ''
        return str(value)

    def to_builtin(self, value):
        """ Encode a value of the attribute using a simple Python representation (dict, list, str, float, bool, None)
        that is compatible with JSON and YAML

        Args:
            value (:obj:`chem.EmpiricalFormula`): value of the attribute

        Returns:
            :obj:`dict`: simple Python representation of a value of the attribute
        """
        if value:
            return dict(value)
        return None

    def from_builtin(self, json):
        """ Decode a simple Python representation (dict, list, str, float, bool, None) of a value of the attribute
        that is compatible with JSON and YAML

        Args:
            json (:obj:`dict`): simple Python representation of a value of the attribute

        Returns:
            :obj:`chem.EmpiricalFormula`: decoded value of the attribute
        """
        if json:
            return chem.EmpiricalFormula(json)
        return None

    def get_xlsx_validation(self, sheet_models=None, doc_metadata_model=None):
        """ Get XLSX validation

        Args:
            sheet_models (:obj:`list` of :obj:`Model`, optional): models encoded as separate sheets
            doc_metadata_model (:obj:`type`): model whose worksheet contains the document metadata

        Returns:
            :obj:`wc_utils.workbook.io.FieldValidation`: validation
        """
        validation = super(ChemicalFormulaAttribute, self).get_xlsx_validation(sheet_models=sheet_models,
                                                                                doc_metadata_model=doc_metadata_model)

        validation.type = wc_utils.workbook.io.FieldValidationType.any

        input_message = ['Enter an chemical formula (e.g. "H2O").']
        error_message = ['Value must be an chemical formula (e.g. "H2O").']

        if self.unique:
            input_message.append('Value must be unique.')
            error_message.append('Value must be unique.')

        if validation.input_message:
            validation.input_message += '\n\n'
        if input_message:
            if not validation.input_message:
                validation.input_message = ""
            validation.input_message += '\n\n'.join(input_message)

        if validation.error_message:
            validation.error_message += '\n\n'
        if error_message:
            if not validation.error_message:
                validation.error_message = ""
            validation.error_message += '\n\n'.join(error_message)

        return validation


class ChemicalStructureFormat(int, CaseInsensitiveEnum):
    """ Format of a chemical structure """
    inchi = 1
    smiles = 2
    bpforms = 3
    bcforms = 4


class ChemicalStructure(object):
    """ A chemical structure

    Attributes
        value (:obj:`openbabel.OBMol`, :obj:`bpforms.BpForm`, :obj:`bcforms.BcForm`): value
        serialized_value (:obj:`str`): serialized value
        serialized_format (:obj:`ChemicalStructureFormat`): serialized format

        _value (:obj:`openbabel.OBMol`, :obj:`bpforms.BpForm`, :obj:`bcforms.BcForm`): value
        _serialized_value (:obj:`str`): serialized value
        _serialized_format (:obj:`ChemicalStructureFormat`): serialized format
    """

    def __init__(self, value=None, serialized_format=None):
        """
        Args:
            value (:obj:`str`, :obj:`openbabel.OBMol`, :obj:`bpforms.BpForm`, :obj:`bcforms.BcForm`, optional): value
            serialized_format (:obj:`ChemicalStructureFormat`, openbabel): serialized format
        """
        self._value = None
        self._serialized_format = None
        self._serialized_value = None

        self.value = value
        self.serialized_format = serialized_format or self.serialized_format

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, value):
        if value is None:
            self._value = None
            self._serialized_value = None
            self._serialized_format = None

        elif isinstance(value, str):
            self.deserialize(value)

        elif isinstance(value, openbabel.OBMol):
            self._value = value
            self._serialized_value = None
            if self.serialized_format not in [ChemicalStructureFormat.inchi, ChemicalStructureFormat.smiles]:
                self._serialized_format = ChemicalStructureFormat.smiles

        elif isinstance(value, bpforms.BpForm):
            if value.alphabet not in bpforms.util.get_alphabets().values():
                raise ValueError('BpForms must use one of the defined alphabets')
            self._value = value
            self._serialized_value = None
            self._serialized_format = ChemicalStructureFormat.bpforms

        elif isinstance(value, bcforms.BcForm):
            self._value = value
            self._serialized_value = None
            self._serialized_format = ChemicalStructureFormat.bcforms

        else:
            raise ValueError('Unable to set `value` to an instance of {}'.format(
                value.__class__.__name__))

    @property
    def serialized_format(self):
        return self._serialized_format

    @serialized_format.setter
    def serialized_format(self, value):
        if value in [ChemicalStructureFormat.inchi, ChemicalStructureFormat.smiles]:
            if self._serialized_format in [ChemicalStructureFormat.inchi, ChemicalStructureFormat.smiles]:
                if value != self._serialized_format:
                    self._serialized_format = value
                    self._serialized_value = None
            else:
                raise ValueError('`serialized_format` must be consistent with `value`')
        else:
            if value != self._serialized_format:
                raise ValueError('`serialized_format` must be consistent with `value`')

    @property
    def serialized_value(self):
        return self._serialized_value

    def to_dict(self):
        """ Get a dictionary representation

        Returns:
            :obj:`dict`: dictionary representation
        """
        serialized_value = self.serialized_value
        if serialized_value is None and self.value is not None:
            if isinstance(self.value, openbabel.OBMol):
                conversion = openbabel.OBConversion()
                assert conversion.SetOutFormat(self.serialized_format.name)
                conversion.SetOptions('c', conversion.OUTOPTIONS)
                serialized_value = conversion.WriteString(self.value, True)
            else:
                serialized_value = str(self.value)
            self._serialized_value = serialized_value

        if self.serialized_format:
            serialized_format = self.serialized_format.name
            if self.serialized_format == ChemicalStructureFormat.bpforms:
                serialized_format += '/' + self.value.alphabet.id
        else:
            serialized_format = None

        return {
            "format": serialized_format,
            "value": serialized_value
        }

    def from_dict(self, dict_value):
        """ Set value from a dictionary representation

        Args:
            dict_value (:obj:`dict`): dictionary representation

        Returns:
            :obj:`ChemicalStructure`: self
        """
        format = dict_value.get('format', None)
        if format:
            serialized_format, _, serialized_alphabet = format.partition('/')
            self._serialized_format = ChemicalStructureFormat[serialized_format.strip()]
            serialized_alphabet = serialized_alphabet.strip().lower()
        else:
            self._serialized_format = None

        value = dict_value.get('value', None)
        if self.serialized_format in [ChemicalStructureFormat.inchi, ChemicalStructureFormat.smiles]:
            self._value = openbabel.OBMol()
            conversion = openbabel.OBConversion()
            assert conversion.SetInFormat(self.serialized_format.name)
            conversion.ReadString(self.value, value or '')
        elif self.serialized_format == ChemicalStructureFormat.bpforms:
            alphabet = bpforms.util.get_alphabet(serialized_alphabet)
            self._value = bpforms.BpForm(alphabet=alphabet).from_str(value or '')
        elif self.serialized_format == ChemicalStructureFormat.bcforms:
            self._value = bcforms.BcForm().from_str(value or '')
        elif self.serialized_format is None:
            if value:
                raise ValueError('`format` key must be defined')
            else:
                value = None

        self._serialized_value = value

        return self

    def serialize(self):
        """ Generate a string representation

        Returns:
            :obj:`str`: string representation
        """
        dict_value = self.to_dict()
        return '{}: {}'.format(dict_value['format'], dict_value['value'])

    def deserialize(self, serialized_value):
        """ Set value from a string representation

        Args:
            serialized_value (:obj:`str`): string representation

        Returns:
            :obj:`ChemicalStructure`: self
        """
        if serialized_value:
            serialized_format, _, serialized_value = serialized_value.partition(':')
            serialized_format = serialized_format.strip()
            serialized_value = serialized_value.strip()
        else:
            serialized_format = None
            serialized_value = None

        self.from_dict({
            'format': serialized_format,
            'value': serialized_value,
        })

        return self


class ChemicalStructureAttribute(core.LiteralAttribute):
    """ Attribute for the structures of chemical compounds """

    def __init__(self, verbose_name='',
                 description=("The InChI, SMILES-, BpForms, BcForms-encoded structure of a compound."
                              "\n"
                              "\nExamples:"
                              "\n  Small molecules (SMILES): C([N+])C([O-])=O"
                              "\n  DNA (BpForms/dna): A{m2C}GT"
                              "\n  RNA (BpForms/rna): AC{02G}U"
                              "\n  Protein (BpForms/protein): RNC{AA0037}E"
                              "\n  Complex (BcForms): 2 * subunit-A + subunit-B"),
                 primary=False, unique=False):
        """
        Args:
            verbose_name (:obj:`str`, optional): verbose name
            description (:obj:`str`, optional): description
            primary (:obj:`bool`, optional): indicate if attribute is primary attribute
            unique (:obj:`bool`, optional): indicate if attribute value must be unique
        """
        super(ChemicalStructureAttribute, self).__init__(default=None, none_value=None,
                                                         verbose_name=verbose_name,
                                                         description=description,
                                                         primary=primary, unique=unique)
        if primary:
            self.type = ChemicalStructure
        else:
            self.type = (ChemicalStructure, None.__class__)

    def deserialize(self, value):
        """ Deserialize value

        Args:
            value (:obj:`str`): string representation of structure

        Returns:
            :obj:`tuple`:

                * :obj:`str`: cleaned value
                * :obj:`core.InvalidAttribute`: cleaning error
        """
        if value:
            if isinstance(value, str):
                try:
                    return (ChemicalStructure().deserialize(value), None)
                except Exception as error:
                    return (None, core.InvalidAttribute(self, [str(error)]))

            else:
                return (None, core.InvalidAttribute(self, ['Value must be a string']))
        return (None, None)

    def validate(self, obj, value):
        """ Determine if :obj:`value` is a valid value

        Args:
            obj (:obj:`Model`): class being validated
            value (:obj:`ChemicalStructure`): value of attribute to validate

        Returns:
            :obj:`core.InvalidAttribute` or None: None if attribute is valid, other return list of
                errors as an instance of :obj:`core.InvalidAttribute`
        """
        errors = []

        if value is not None and not isinstance(value, ChemicalStructure):
            errors.append('Value must be an instance of `ChemicalStructure` or `None`')

        if self.primary and value is None:
            errors.append('{} value for primary attribute cannot be `None`'.format(
                self.__class__.__name__))

        if errors:
            return core.InvalidAttribute(self, errors)
        return None

    def validate_unique(self, objects, values):
        """ Determine if the attribute values are unique

        Args:
            objects (:obj:`list` of :obj:`Model`): list of :obj:`Model` objects
            values (:obj:`list` of :obj:`ChemicalStructure`): list of values

        Returns:
            :obj:`core.InvalidAttribute` or None: None if values are unique, otherwise return a
                list of errors as an instance of :obj:`core.InvalidAttribute`
        """
        str_values = []
        for v in values:
            str_values.append(self.serialize(v))
        return super(ChemicalStructureAttribute, self).validate_unique(objects, str_values)

    def serialize(self, value):
        """ Serialize chemical structure

        Args:
            value (:obj:`ChemicalStructure`): structure

        Returns:
            :obj:`str`: string representation
        """
        if value:
            return value.serialize()
        return ''

    def to_builtin(self, value):
        """ Encode a value of the attribute using a simple Python representation (dict, list, str, float, bool, None)
        that is compatible with JSON and YAML

        Args:
            value (:obj:`ChemicalStructure`): chemical structure

        Returns:
            :obj:`dict`: simple Python representation of a value of the attribute
        """
        if value:
            return value.to_dict()
        return None

    def from_builtin(self, json):
        """ Decode a simple Python representation (dict, list, str, float, bool, None) of a value of the attribute
        that is compatible with JSON and YAML

        Args:
            json (:obj:`dict`): simple Python representation of a value of the attribute

        Returns:
            :obj:`ChemicalStructure`: chemical structure
        """
        if json:
            return ChemicalStructure().from_dict(json)
        return None

    def get_xlsx_validation(self, sheet_models=None, doc_metadata_model=None):
        """ Get XLSX validation

        Args:
            sheet_models (:obj:`list` of :obj:`Model`, optional): models encoded as separate sheets
            doc_metadata_model (:obj:`type`): model whose worksheet contains the document metadata

        Returns:
            :obj:`wc_utils.workbook.io.FieldValidation`: validation
        """
        validation = super(ChemicalStructureAttribute, self).get_xlsx_validation(sheet_models=sheet_models,
                                                                                  doc_metadata_model=doc_metadata_model)

        validation.type = wc_utils.workbook.io.FieldValidationType.any

        input_message = [
            ('Enter an InChI, SMILES-, BpForms, or BcForms-encoded structure '
             '(e.g. "[OH2]" for small molecules, "A{m2C}GT" for DNA).')]
        error_message = [
            ('Value must be an InChI, SMILES-, BpForms, or BcForms-encoded structure '
             '(e.g. "[OH2]" for small molecules, "A{m2C}GT" for DNA).')]

        if self.unique:
            input_message.append('Value must be unique.')
            error_message.append('Value must be unique.')

        if validation.input_message:
            validation.input_message += '\n\n'
        validation.input_message += '\n\n'.join(input_message)

        if validation.error_message:
            validation.error_message += '\n\n'
        validation.error_message += '\n\n'.join(error_message)

        return validation


class ReactionEquation(list):
    """ Reaction equation
    """

    with open(pkg_resources.resource_filename('obj_tables', os.path.join('chem', 'reaction_equation.lark')), 'r') as file:
        parser = lark.Lark(file.read())

    def is_equal(self, other):
        """ Determine if two reaction equations are semantically equivalent

        Args:
            other (:obj:`ReactionEquation`): other reaction equation

        Returns:
            :obj:`bool`: :obj:`True` if the objects are semantically equivalent
        """
        if self.__class__ != other.__class__:
            return False

        if len(self) != len(other):
            return False

        other_participants = list(other)
        for part in self:
            part_in_other = False
            for other_part in list(other_participants):
                if part.is_equal(other_part):
                    part_in_other = True
                    other_participants.remove(other_part)
                    break
            if not part_in_other:
                return False

        return True

    def to_dict(self):
        """ Get a simple Python representation compatible with JSON

        Returns:
            :obj:`list`: simple Python representation
        """
        return [part.to_dict() for part in self]

    def serialize(self):
        """ Generate a string representation

        Returns:
            :obj:`str`: string representation
        """
        compartments = set()
        for part in self:
            if isinstance(part.compartment, core.Model):
                compartments.add(part.compartment.serialize())
            else:
                compartments.add(part.compartment)

        lhs = []
        rhs = []
        for part in self:
            if part.stoichiometry < 0:
                lhs.append(part.serialize(include_compartment=len(compartments) > 1))
            elif part.stoichiometry > 0:
                rhs.append(part.serialize(include_compartment=len(compartments) > 1))

        serialized_value = '{} <=> {}'.format(' + '.join(sorted(lhs)), ' + '.join(sorted(rhs))).strip()

        if len(compartments) == 1:
            serialized_value = '[{}]: '.format(list(compartments)[0]) + serialized_value

        return serialized_value

    def deserialize(self, value, species=None, compartments=None):
        """ Set the participants from a string representation

        Args:
            value (:obj:`str`): string representation
            species (:obj:`dict`, optional): dictionary that maps species ids to instances
            compartments (:obj:`dict`, optional): dictionary that maps compartment ids to instances
        """
        tree = self.parser.parse(value)
        transformer = self.ParseTreeTransformer(species=species, compartments=compartments)
        parts = transformer.transform(tree)
        self.clear()
        self.extend(parts)
        return self

    class ParseTreeTransformer(lark.Transformer):
        """ Transforms parse trees into an instance of :obj:`ReactionEquation`

        Attributes:
            species (:obj:`dict`): dictionary that maps species ids to instances
            compartments (:obj:`dict`): dictionary that maps compartment ids to instances
        """

        def __init__(self, species=None, compartments=None):
            """
            Args:
                species (:obj:`dict`, optional): dictionary that maps species ids to instances
                compartments (:obj:`dict`, optional): dictionary that maps compartment ids to instances
            """
            self.species = species
            self.compartments = compartments

        @lark.v_args(inline=True)
        def start(self, parts):
            if len(set([part.serialize() for part in parts])) < len(parts):
                raise ValueError('Reaction participants cannot be repeated')

            if self.species:
                for part in parts:
                    species = self.species.get(part.species, None)
                    if not species:
                        raise ValueError('Species "{}" must be defined'.format(part.species))
                    part.species = species

            if self.compartments:
                for part in parts:
                    compartment = self.compartments.get(part.compartment, None)
                    if not compartment:
                        raise ValueError('Compartment "{}" must be defined'.format(part.compartment))
                    part.compartment = compartment

            return parts

        @lark.v_args(inline=True)
        def gbl(self, *args):
            parts = []
            for arg in args:
                if isinstance(arg, lark.lexer.Token) and \
                        arg.type == 'SPECIES_STOICHIOMETRY__SPECIES__COMPARTMENT__ID':
                    compartment = arg.value

                elif isinstance(arg, lark.tree.Tree):
                    if arg.data == 'gbl_reactants':
                        sign = -1
                    else:
                        sign = 1

                    for part in arg.children[0]:
                        part.stoichiometry *= sign
                        part.compartment = compartment
                        parts.append(part)
            return parts

        @lark.v_args(inline=True)
        def gbl_parts(self, *args):
            val = []
            for arg in args:
                if isinstance(arg, ReactionParticipant):
                    val.append(arg)
            return val

        @lark.v_args(inline=True)
        def gbl_part(self, *args):
            stoichiometry = 1.
            for arg in args:
                if arg.type == 'SPECIES_STOICHIOMETRY__SPECIES__SPECIES_TYPE__ID':
                    species = arg.value
                elif arg.type == 'SPECIES_STOICHIOMETRY__STOICHIOMETRY':
                    stoichiometry = float(arg.value)
            return ReactionParticipant(species, None, stoichiometry)

        @lark.v_args(inline=True)
        def lcl(self, *args):
            parts = []
            for arg in args:
                if isinstance(arg, lark.tree.Tree):
                    if arg.data == 'lcl_reactants':
                        sign = -1
                    else:
                        sign = 1
                    for part in arg.children[0]:
                        part.stoichiometry *= sign
                        parts.append(part)
            return parts

        @lark.v_args(inline=True)
        def lcl_parts(self, *args):
            val = []
            for arg in args:
                if isinstance(arg, ReactionParticipant):
                    val.append(arg)
            return val

        @lark.v_args(inline=True)
        def lcl_part(self, *args):
            stoichiometry = 1.
            for arg in args:
                if arg.type == 'SPECIES_STOICHIOMETRY__SPECIES__SPECIES_TYPE__ID':
                    species = arg.value
                elif arg.type == 'SPECIES_STOICHIOMETRY__SPECIES__COMPARTMENT__ID':
                    compartment = arg.value
                elif arg.type == 'SPECIES_STOICHIOMETRY__STOICHIOMETRY':
                    stoichiometry = float(arg.value)

            return ReactionParticipant(species, compartment, stoichiometry)


class ReactionParticipant(object):
    """ Participant in a reaction

    Attributes:
        species (:obj:`str` or :obj:`core.Model`): species
        compartment (:obj:`str` or :obj:`core.Model`): compartment
        stoichiometry (:obj:`float`): stoichiometry
    """

    def __init__(self, species=None, compartment=None, stoichiometry=None):
        """
        Args:
            species (:obj:`str` or :obj:`core.Model`, optional): species
            compartment (:obj:`str` or :obj:`core.Model`, optional): compartment
            stoichiometry (:obj:`float`, optional): stoichiometry
        """
        self.species = species
        self.compartment = compartment
        self.stoichiometry = stoichiometry

    def is_equal(self, other):
        """ Determine if two reaction participants are semantically equivalent

        Args:
            other (:obj:`ReactionParticipant`): other reaction equation

        Returns:
            :obj:`bool`: :obj:`True` if the objects are semantically equivalent
        """
        return (
            self.__class__ == other.__class__
            and (
                self.species == other.species or (
                    isinstance(self.species, core.Model) and self.species.is_equal(other.species)
                )
            )
            and (
                self.compartment == other.compartment or (
                    isinstance(self.compartment, core.Model) and self.compartment.is_equal(other.compartment)
                )
            )
            and abs(self.stoichiometry - other.stoichiometry) < 1e-8
        )

    def to_dict(self):
        """ Get a simple Python representation compatible with JSON

        Returns:
            :obj:`dict`: simple Python representation
        """
        if isinstance(self.species, core.Model):
            species = self.species.serialize()
        else:
            species = self.species

        if isinstance(self.compartment, core.Model):
            compartment = self.compartment.serialize()
        else:
            compartment = self.compartment

        return {
            'species': species,
            'compartment': compartment,
            'stoichiometry': self.stoichiometry,
        }

    def serialize(self, include_compartment=True):
        """ Generate a string representation

        Args:
            include_compartment (:obj:`bool`, optional): if :obj:`True`, include compartment in string representation

        Returns:
            :obj:`str`: string representation
        """
        if abs(self.stoichiometry) == 1:
            stoichiometry = ''
        elif self.stoichiometry == math.ceil(self.stoichiometry):
            stoichiometry = '({}) '.format(int(abs(self.stoichiometry)))
        else:
            stoichiometry = '({}) '.format(abs(self.stoichiometry))

        if isinstance(self.species, core.Model):
            species = self.species.serialize()
        else:
            species = self.species

        serialized_value = '{}{}'.format(stoichiometry, species)

        if include_compartment:
            if isinstance(self.compartment, core.Model):
                compartment = self.compartment.serialize()
            else:
                compartment = self.compartment
            serialized_value += '[{}]'.format(compartment)

        return serialized_value


class ReactionEquationAttribute(core.BaseRelatedAttribute, core.LiteralAttribute):
    """ Reaction equation attribute

    Attributes:
        none (:obj:`bool`): if :obj:`False`, the attribute is invalid if its value is :obj:`None`
        species_cls (:obj:`type` or :obj:`str`): class which represents species
            compartment_cls (:obj:`type` or :obj:`str`): class which represents compartments
    """

    def __init__(self, verbose_name='', description="A reaction equation (e.g. 'A[c] <=> B[e]', '[c]: A <=> B')",
                 none=True, unique=False, species_cls=None, compartment_cls=None):
        """
        Args:
            verbose_name (:obj:`str`, optional): verbose name
            description (:obj:`str`, optional): description
            none (:obj:`bool`, optional): if :obj:`False`, the attribute is invalid if its value is :obj:`None`
            unique (:obj:`bool`, optional): indicate if attribute value must be unique
            species_cls (:obj:`type` or :obj:`str`, optional): class which represents species
            compartment_cls (:obj:`type` or :obj:`str`, optional): class which represents compartments
        """
        super(ReactionEquationAttribute, self).__init__(default=None, none_value=None,
                                                        verbose_name=verbose_name,
                                                        description=description,
                                                        primary=False, unique=unique)
        self.none = none
        self.species_cls = species_cls
        self.compartment_cls = compartment_cls

    def deserialize(self, value, objects=None, decoded=None):
        """ Deserialize value

        Args:
            value (:obj:`str`): semantically equivalent representation
            objects (:obj:`dict`, optional): dictionary of objects, grouped by model
            decoded (:obj:`dict`, optional): dictionary of objects that have already been decoded

        Returns:
            :obj:`tuple`:

                * :obj:`ReactionEquation`: cleaned value
                * :obj:`core.InvalidAttribute`: cleaning error
        """
        if not value:
            return (None, None)

        if objects and self.species_cls:
            if not isinstance(self.species_cls, type):
                for cls in objects.keys():
                    if '.' in self.species_cls:
                        if cls.__module__ + '.' + cls.__name__ == self.species_cls:
                            self.species_cls = cls
                            break
                    else:
                        if cls.__name__ == self.species_cls:
                            self.species_cls = cls
                            break

            if not isinstance(self.species_cls, type):
                raise ValueError('Unable to resolve class {}'.format(self.species_cls))

            if objects:
                species = objects.get(self.species_cls, {})
            else:
                species = None
        else:
            species = None

        if objects and self.compartment_cls:
            if not isinstance(self.compartment_cls, type):
                for cls in objects.keys():
                    if '.' in self.compartment_cls:
                        if cls.__module__ + '.' + cls.__name__ == self.compartment_cls:
                            self.compartment_cls = cls
                            break
                    else:
                        if cls.__name__ == self.compartment_cls:
                            self.compartment_cls = cls
                            break

            if not isinstance(self.compartment_cls, type):
                raise ValueError('Unable to resolve class {}'.format(self.compartment_cls))

            if objects:
                compartments = objects.get(self.compartment_cls, {})
            else:
                compartments = None
        else:
            compartments = None

        try:
            deserialized_value = ReactionEquation().deserialize(value, species=species, compartments=compartments)
            return (deserialized_value, None)
        except Exception as error:
            return (None, core.InvalidAttribute(self, [str(error)]))

    def validate(self, obj, value):
        """ Determine if :obj:`value` is a valid value

        Args:
            obj (:obj:`Model`): class being validated
            value (:obj:`ReactionEquation`): value of attribute to validate

        Returns:
            :obj:`core.InvalidAttribute` or None: None if attribute is valid, other return
                list of errors as an instance of :obj:`core.InvalidAttribute`
        """
        errors = []

        if self.none:
            if value is not None and not isinstance(value, ReactionEquation):
                errors.append('Value must be an instance of `ReactionEquation` or `None`')
        else:
            if not isinstance(value, ReactionEquation):
                errors.append('Value must be an instance of `ReactionEquation`')

        if errors:
            return core.InvalidAttribute(self, errors)
        return None

    def validate_unique(self, objects, values):
        """ Determine if the attribute values are unique

        Args:
            objects (:obj:`list` of :obj:`Model`): list of :obj:`Model` objects
            values (:obj:`list` of :obj:`ReactionEquation`): list of values

        Returns:
            :obj:`core.InvalidAttribute` or None: None if values are unique, otherwise return a
                list of errors as an instance of :obj:`core.InvalidAttribute`
        """
        str_values = []
        for v in values:
            str_values.append(self.serialize(v))
        return super(ReactionEquationAttribute, self).validate_unique(objects, str_values)

    def serialize(self, value):
        """ Serialize string

        Args:
            value (:obj:`ReactionEquation`): Python representation

        Returns:
            :obj:`str`: simple Python representation
        """
        if value is None:
            return ''
        return value.serialize()

    def to_builtin(self, value):
        """ Encode a value of the attribute using a simple Python representation (dict, list, str, float, bool, None)
        that is compatible with JSON and YAML

        Args:
            value (:obj:`ReactionEquation`): value of the attribute

        Returns:
            :obj:`dict`: simple Python representation of a value of the attribute
        """
        if value:
            return value.to_dict()
        return None

    def from_builtin(self, json):
        """ Decode a simple Python representation (dict, list, str, float, bool, None) of a value of the attribute
        that is compatible with JSON and YAML

        Args:
            json (:obj:`dict`): simple Python representation of a value of the attribute

        Returns:
            :obj:`ReactionEquation`: decoded value of the attribute
        """
        raise NotImplementedError('Cannot be converted from JSON')

    def get_xlsx_validation(self, sheet_models=None, doc_metadata_model=None):
        """ Get XLSX validation

        Args:
            sheet_models (:obj:`list` of :obj:`Model`, optional): models encoded as separate sheets
            doc_metadata_model (:obj:`type`): model whose worksheet contains the document metadata

        Returns:
            :obj:`wc_utils.workbook.io.FieldValidation`: validation
        """
        validation = super(ReactionEquationAttribute, self).get_xlsx_validation(sheet_models=sheet_models,
                                                                                 doc_metadata_model=doc_metadata_model)

        validation.type = wc_utils.workbook.io.FieldValidationType.any

        input_message = ['Enter a reaction equation (e.g. "A[c] <=> B[e]", "[c]: A <=> B").']
        error_message = ['Value must be a reaction equation (e.g. "A[c] <=> B[e]", "[c]: A <=> B").']

        if self.unique:
            input_message.append('Value must be unique.')
            error_message.append('Value must be unique.')

        if validation.input_message:
            validation.input_message += '\n\n'
        if input_message:
            if not validation.input_message:
                validation.input_message = ""
            validation.input_message += '\n\n'.join(input_message)

        if validation.error_message:
            validation.error_message += '\n\n'
        if error_message:
            if not validation.error_message:
                validation.error_message = ""
            validation.error_message += '\n\n'.join(error_message)

        return validation