Source code for ionize.Ion.BaseIon

"""Module containing the BaseIon class."""
import numpy as np
import contextlib
from math import sqrt
import json

from .fixed_state import fixed_state
from ..Solvent import Aqueous
from ..constants import reference_temperature, elementary_charge, lpm3, \
    boltzmann, faraday, kelvin
from ..serialize import _serialize


@fixed_state
class BaseIon(object):
    """BaseIon is the basic implementation of a ion.

    It represents a charged species in solution. All ion implementations in
    ionize are subclassed from BaseIon. The most common version is the
    :class:`Ion`.
    """

    _solvent = Aqueous

    _state = {'name': 'The name of the ion.',
              'reference_temperature': 'The temperature at which ion '
                                       'properties were measured.'}
    _name = 'BaseIon'
    _alias = None
    _reference_temperature = reference_temperature

    _context = None

    def __repr__(self):
        """Return an unambiguous string representation."""
        inner = []
        for prop in sorted(self._state):
            prop = str(prop)  # convert unicode to string
            value = getattr(self, prop)
            if isinstance(value, np.ndarray):
                value = value.tolist()
            elif isinstance(value, str):
                value = str(value)
            inner.append('{}={}'.format(str(prop), repr(value)))
        return '{}({})'.format(type(self).__name__, ', '.join(inner))

    def __str__(self):
        """Return a readable string representation."""
        return "{}('{}')".format(type(self).__name__, self.name)

    def __hash__(self):
        """Return the hash value for the object."""
        return hash(self.serialize())

    def __eq__(self, other):
        """Test equality between two ions."""
        try:
            assert self._state == other._state
            for prop in self._state:
                if isinstance(getattr(self, prop), np.ndarray):
                    assert getattr(other, prop) is not None
                    assert(np.all(getattr(self, prop) == getattr(other, prop)))
                else:
                    if isinstance(getattr(other, prop), np.ndarray):
                        assert getattr(self, prop) is not None
                    assert getattr(self, prop) == getattr(other, prop)
            return True
        except:
            return False

    def serialize(self, nested=False, compact=False):
        """Returns a JSON serialized string representation of the ion.
        This representation can be returned to an Ion object using deserialize.

        :param nested: If True, skips JSON serialization and returns a python
        dictionary.

        :param compact: If True, returns a JSON representation with minimal
        characters. Otherwise, whitespace is introduced for readability.
        """
        serial = {key: getattr(self, key) for key in self._state}
        serial['__ion__'] = type(self).__name__

        return _serialize(serial, nested, compact)

    def save(self, filename):
        """Save a serialized Ion to a file."""
        with open(filename, 'w') as file:
            file.write(self.serialize())

    def mobility(self):
        """The effective mobility of the ion, in meter^2/Volt/second.

        Mobility must be overridden by subclasses."""
        raise NotImplementedError

    def diffusivity(self, pH=None, ionic_strength=None, temperature=None):
        """The diffusivity of the ion, in meter^2/second."""
        pH, ionic_strength, temperature = \
            self._resolve_context(pH, ionic_strength, temperature)

        return (self.mobility(pH, ionic_strength, temperature) /
                self.charge(pH, ionic_strength, temperature) /
                elementary_charge * boltzmann * kelvin(temperature)
                )

    def molar_conductivity(self, pH=None, ionic_strength=None,
                           temperature=None):
        """The molar conductivity of the ion, in Seimens/meter/Molar."""
        pH, ionic_strength, temperature = \
            self._resolve_context(pH, ionic_strength, temperature)

        return (lpm3 * faraday *
                self.mobility(pH, ionic_strength, temperature) *
                self.charge(pH, ionic_strength, temperature)
                )

    def charge(self, pH=None, ionic_strength=None, temperature=None,
	       moment=1):
        """The average charge of the ion divided by the charge of an electron.

        Charge must be overridden by subclasses."""
        raise NotImplementedError

    def context(self, context=False):
        """Control the context in which ion properties are calculated.

        The context is used as a convenience to specify what values of pH,
        ionic strength, and temperature are used to compute ion properties.

        Example::
            ``water = ionize.Solution()

            my_ion.mobility()  # Raises without a pH for calculation

            with my_ion.context(water):
                my_ion.mobility()  # Uses water pH

            my_ion.context(water)
            my_ion.mobility()      # Uses water pH
            my_ion.context()       # Returns water
            my_ion.context(None)``

        There are multiple sources from which pH, ionic strength, and
        temperature can be drawn. They are, in order of priority::

            Method parameters. Manually setting a value when calling an ion
            method always has the highest priority.

            Context.

            Default values. The default temperature is the package reference
            temperature, 25 degrees C. This is assumed throughout the package
            if temperature is not specified. The default ionic strength is the
            solvent ionic strength. This ionic strength is calculated from the
            pH if available. Otherwise, it is calculated as the minimum ionic
            strength associated with the solvent dissociation constant. There
            is no default value for the pH.


        :param context: The new context to use for ion calculations. If context
        is False, the current context is returned. Context can be None (to
        erase the current context) or a Solution object.
        """
        if context is False:
            return self._context

        # Update to new context, save old_context
        old_context, self._context = self._context, context

        # Return a context manager to revert to old_context
        @contextlib.contextmanager
        def manager():
            yield
            self._context = old_context
        return manager()

    def _resolve_context(self, pH, ionic_strength, temperature):
        if pH is None:
            try:
                pH = self.context().pH
            except AttributeError:
                pH = None

        if temperature is None:
            try:
                temperature = self.context().temperature() or \
                              self.reference_temperature
            except AttributeError:
                temperature = self.reference_temperature

        if ionic_strength is None:
            try:
                ionic_strength = self.context().ionic_strength
            except AttributeError:
                if pH is not None:
                    ionic_strength = self._solvent.ionic_strength(pH,
                                                                  temperature)
                else:
                    ionic_strength = \
                        sqrt(self._solvent.dissociation(0., temperature))

        return pH, ionic_strength, temperature

    def separability(self, other, pH=None,
                     ionic_strength=None, temperature=None):
        """Return the separability between this ion and the other ion.

        Separabillity is a normalized difference between this ion and the other
        ion, defined as:
            abs((self.mobility - other.mobility)/ self.mobility)

        :param other: The other ion for comparison. Must have valid context
        and mobility methods.

        The context from this ion is used to override the context of the
        other ion.
        """
        with other.context(self.context()):
            mobilities = [ion.mobility(pH, ionic_strength, temperature)
                          for ion in (self, other)]

            # Self.mobility in denominator
            return abs((mobilities[0] - mobilities[1]) / mobilities[0])