# -*- coding: utf-8 -*-
"""
===============================================================================
Base topology classes (:mod:`sknano.core.atoms.mixins.topology_base`)
===============================================================================
.. currentmodule:: sknano.core.atoms.mixins.topology_base
"""
from __future__ import absolute_import, division, print_function
from __future__ import unicode_literals
__docformat__ = 'restructuredtext en'
import numbers
from abc import ABCMeta, abstractmethod
from collections import Iterable, namedtuple
from functools import total_ordering
from operator import attrgetter
import numpy as np
# np.set_printoptions(edgeitems=20)
# np.set_printoptions(threshold=10000)
# import scipy as sp
from scipy import stats
# import pandas as pd
from sknano.core import BaseClass, UserList, TabulateMixin, rezero_array
from sknano.core.atoms import Atom, Atoms
# from sknano.core.math import vector as vec
__all__ = ['Topology', 'TopologyCollection', 'TopologyStats', 'check_operands',
'AngularTopology', 'AngularTopologyCollection']
operand_error_msg = 'Expected an `iterable` object containing {}'
atoms_operand_error_msg = operand_error_msg.format('`Atom` objects')
ids_operand_error_msg = operand_error_msg.format('`ints`')
TopologyStats = namedtuple('TopologyStats', ('nobs', 'min', 'max', 'minmax',
'mean', 'median', 'mode',
'variance', 'std',
'skewness', 'kurtosis'))
[docs]def check_operands(*atoms, size=None):
"""Check atom operands.
Parameters
----------
*atoms : {:class:`~python:list`, :class:`~sknano.core.atoms.Atoms`}
:class:`~python:list` of :class:`~sknano.core.atoms.Atom`\ s
or an :class:`~sknano.core.atoms.Atoms` object.
size : :class:`~python:int`
Returns
-------
:class:`~python:tuple`
Raises
------
:class:`~python:TypeError`
if `atoms` is not a list of :class:`~sknano.core.atoms.Atom` objects
or an :class:`~sknano.core.atoms.Atoms` object.
:class:`~python:ValueError`
if len(atoms) != `size`.
"""
if size is None:
raise ValueError('Expected `int` for size')
if not isinstance(atoms, Iterable):
raise TypeError(atoms_operand_error_msg)
if len(atoms) == 1:
if isinstance(atoms[0], Iterable):
return check_operands(*atoms[0], size=size)
else:
atoms = atoms[0]
if not isinstance(atoms, (Iterable, Atoms)) or not \
all([isinstance(atom, Atom) for atom in atoms]):
raise TypeError(atoms_operand_error_msg)
if len(atoms) != size:
raise ValueError('Expected {} atoms'.format(size))
return atoms
@total_ordering
[docs]class Topology(BaseClass, TabulateMixin, metaclass=ABCMeta):
"""Base :class:`~sknano.core.atoms.Atom` topology class.
Parameters
----------
*atoms : {:class:`~python:list`, :class:`~sknano.core.atoms.Atoms`}
:class:`~python:list` of :class:`~sknano.core.atoms.Atom`\ s
or an :class:`~sknano.core.atoms.Atoms` object.
size : :class:`~python:int`
parent : Parent :class:`~sknano.core.atoms.Molecule`, if any.
id : :class:`~python:int`
type : :class:`~python:int`
check_operands : :class:`~python:bool`, optional
Raises
------
:class:`~python:TypeError`
if `atoms` is not a list of :class:`~sknano.core.atoms.Atom` objects
or an :class:`~sknano.core.atoms.Atoms` object.
:class:`~python:ValueError`
if len(atoms) != `size`.
"""
def __init__(self, *atoms, size, id=0, type=0, parent=None,
check_operands=True):
if check_operands:
if not isinstance(atoms, Iterable):
raise TypeError(atoms_operand_error_msg)
if len(atoms) == 1:
atoms = atoms[0]
if not isinstance(atoms, (Iterable, Atoms)) or not \
all([isinstance(atom, Atom) for atom in atoms]):
raise TypeError(atoms_operand_error_msg)
if len(atoms) != size:
raise ValueError('Expected {} atoms'.format(size))
super().__init__()
from .. import StructureAtoms
self.atoms = StructureAtoms()
self.atoms.extend(list(atoms))
self.check_operands = check_operands
self.id = id
self.type = type
self.parent = parent
self.fmtstr = "id={id!r}, type={type!r}, parent={parent!r}"
def _is_valid_operand(self, other):
return isinstance(other, (numbers.Number, self.__class__))
def __eq__(self, other):
if not self._is_valid_operand(other):
return NotImplemented
if np.isscalar(other):
return np.allclose(self.measure, other)
return self is other or self.atoms == other.atoms
def __lt__(self, other):
if not self._is_valid_operand(other):
return NotImplemented
if np.isscalar(other):
return self.measure < other
return self.atoms < other.atoms
def __dir__(self):
return ['atoms', 'measure', 'id', 'type', 'parent']
# def __iter__(self):
# return iter(self.atoms)
@property
def atoms(self):
""":class:`~sknano.core.atoms.Atoms` in `TopologyCollection`."""
return self._atoms
@atoms.setter
def atoms(self, value):
# TODO: This should perform some sort of type check as well as update
# other attributes.
self._atoms = value
@property
def atom_ids(self):
""":attr:`Topology.atoms` :attr:`~sknano.core.atoms.IDAtoms.ids`."""
return tuple(self.atoms.ids)
@property
def centroid(self):
""":attr:`~sknano.core.atoms.XYZAtoms.centroid` of \
:attr:`Topology.atoms`."""
return self.atoms.centroid
@property
def measure(self):
"""Measure of topology."""
try:
return self._measure
except AttributeError:
self._update_measure()
return self._measure
@property
def strain(self):
"""Strain in measure."""
try:
return self._strain
except AttributeError:
return 0.0
@abstractmethod
[docs] def compute_measure(self):
"""Compute topological measure from :attr:`Topology.atoms`."""
raise NotImplementedError
[docs] def compute_strain(self, m0):
"""Compute topological strain in :attr:`Topology.measure`.
Parameters
----------
m0 : :class:`~python:float`
Returns
-------
:class:`~python:float`
"""
m = self.measure
self._strain = (m0 - m) / m
return self._strain
[docs] def rotate(self, **kwargs):
"""Rotate the `Topology` by rotating the \
:attr:`~Topology.atoms`."""
[atom.rotate(fix_anchor_point=True, **kwargs) for atom in self.atoms]
def _update_measure(self):
self._measure = self.compute_measure()
[docs] def todict(self):
"""Return :class:`~python:dict` of constructor parameters."""
return dict(id=self.id, type=self.type, parent=self.parent)
[docs]class TopologyCollection(TabulateMixin, UserList):
"""Base :class:`~sknano.core.atoms.Atoms` topology class.
Parameters
----------
topolist : {None, sequence, `Topology`}, optional
if not `None`, then a list of `Topology` objects
parent : Parent :class:`~sknano.core.atoms.Molecule`, if any.
"""
def __init__(self, topolist=None, parent=None):
super().__init__(initlist=topolist)
self.parent = parent
self.fmtstr = super().fmtstr + ", parent={parent!r}"
@property
def __item_class__(self):
return Topology
def sort(self, key=attrgetter('measure'), reverse=False):
super().sort(key=key, reverse=reverse)
@property
def Ntypes(self):
"""Number of unique :attr:`Topology.type`\ s."""
return len(set(self.types))
@property
def parent(self):
"""Parent :class:`~sknano.core.atoms.Molecule`, if any."""
return self._parent
@parent.setter
def parent(self, value):
self._parent = self.kwargs['parent'] = value
# @property
# def atoms(self):
# """`Atoms` :class:`python:set` in `TopologyCollection`."""
# atoms = []
# [atoms.extend(topology.atoms) for topology in self]
# atoms = \
# list(dedupe(list(flatten([topology.atoms for topology in self])),
# key=attrgetter('id')))
# return StructureAtoms(atoms)
@property
def measures(self):
""":class:`~numpy:numpy.ndarray` of \
:attr:`~Topology.measure`\ s."""
try:
return self._measures
except AttributeError:
self._update_measures()
return self._measures
@property
def strains(self):
""":class:`~numpy:numpy.ndarray` of \
:attr:`~Topology.strain`\ s."""
try:
return self._strains
except AttributeError:
return np.zeros(len(self), dtype=float)
@property
def mean_measure(self):
"""Mean measure."""
if np.all(self.measures == np.inf):
return np.inf
if np.any(self.measures == np.inf):
return np.ma.mean(np.ma.array(self.measures,
mask=self.measures == np.inf))
return np.mean(self.measures)
@property
def mean(self):
"""An alias for :attr:`TopologyCollection.mean_measure`."""
return self.mean_measure
@property
def atom_ids(self):
""":class:`~python:list` of :attr:`~Topology.atom_ids`."""
# return np.asarray([topology.atom_ids for topology in self])
return [topology.atom_ids for topology in self]
@property
def ids(self):
""":class:`~python:list` of :attr:`~Topology.id`\ s."""
# return np.asarray([topology.id for topology in self])
return [topology.id for topology in self]
@property
def types(self):
""":class:`~python:list` of :attr:`~Topology.type`\ s."""
# return np.asarray([topology.type for topology in self])
return [topology.type for topology in self]
@property
def unique(self):
"""Return new `TopologyCollection` object containing the set of \
unique `Topology`\ s."""
seen = set()
unique = []
for topology in self:
atom_ids = tuple(topology.atom_ids)
ratom_ids = tuple(reversed(atom_ids))
if atom_ids not in seen and ratom_ids not in seen:
unique.append(topology)
seen.add(atom_ids)
seen.add(ratom_ids)
return self.__class__(topolist=unique, **self.kwargs)
@property
def statistics(self):
""":class:`TopologyStats` of :attr:`TopologyCollection.measures` \
statistics."""
# measures = self.unique.measures
measures = self.measures
topostats = stats.describe(measures)._asdict()
topostats['min'] = np.min(measures)
topostats['max'] = np.max(measures)
topostats['median'] = np.median(measures)
topostats['mode'] = stats.mode(measures).mode[0]
topostats['std'] = np.std(measures)
return TopologyStats(**topostats)
def compute_strains(self, reference):
"""Return :class:`~numpy:numpy.ndarray` of topology measure strains.
Parameters
----------
reference : :class:`~python:float` or array_like
Reference/starting measure.
Returns
-------
:class:`~numpy:numpy.ndarray`
"""
if not (np.isscalar(reference) or
isinstance(reference, (list, np.ndarray))):
raise TypeError('Expected a `float` or `array_like` object.')
if np.isscalar(reference):
self._strains = \
np.asarray([topology.compute_strain(reference) for
topology in self])
else:
if len(reference) != len(self):
raise ValueError('`reference` must be same length as '
'topology list')
self._strains = \
np.asarray([topology.compute_strain(ref) for topology, ref in
zip(self, reference)])
return self._strains
def _update_measures(self):
[topology._update_measure() for topology in self]
self._measures = \
rezero_array(np.asarray([topology.measure for topology in self]))
def todict(self):
"""Return :class:`~python:dict` of constructor parameters."""
super_dict = super().todict()
super_dict.update(dict(parent=self.parent))
return super_dict
[docs]class AngularTopology(Topology):
"""`Topology` sub-class for topology with angular measure.
Parameters
----------
*atoms : {:class:`~python:list`, :class:`~sknano.core.atoms.Atoms`}
:class:`~python:list` of :class:`~sknano.core.atoms.Atom`\ s
or an :class:`~sknano.core.atoms.Atoms` object.
check_operands : :class:`~python:bool`, optional
parent : Parent :class:`~sknano.core.atoms.Molecule`, if any.
id : :class:`~python:int`
degrees : :class:`~python:bool`, optional
Raises
------
:class:`~python:TypeError`
if `atoms` is not a list of :class:`~sknano.core.atoms.Atom` objects
or an :class:`~sknano.core.atoms.Atoms` object.
"""
def __init__(self, *args, degrees=False, **kwargs):
super().__init__(*args, **kwargs)
self.degrees = degrees
self.fmtstr = super().fmtstr + ", degrees={degrees!r}"
@property
def angle(self):
"""An alias for :attr:`Topology.measure`."""
return self.measure
[docs] def todict(self):
"""Return :class:`~python:dict` of constructor parameters."""
super_dict = super().todict()
super_dict.update(dict(degrees=self.degrees))
return super_dict
[docs]class AngularTopologyCollection(TopologyCollection):
"""`TopologyCollection` sub-class for collection of angular topologies.
Parameters
----------
topolist : {None, sequence, `AngularTopology`}, optional
if not `None`, then a list of `AngularTopology` objects
parent : Parent :class:`~sknano.core.atoms.Molecule`, if any.
degrees : :class:`~python:bool`, optional
"""
def __init__(self, *args, degrees=False, **kwargs):
super().__init__(*args, **kwargs)
self.degrees = degrees
self.fmtstr = super().fmtstr + ", degrees={degrees!r}"
@property
def __item_class__(self):
return AngularTopology
@property
def degrees(self):
""":class:`~python:bool` setting for returning angles in degrees."""
return self._degrees
@degrees.setter
def degrees(self, value):
if not isinstance(value, bool):
raise ValueError('Expected a boolean value.')
self._degrees = self.kwargs['degrees'] = value
[setattr(topoobj, 'degrees', value) for topoobj in self]
super()._update_measures()
@property
def angles(self):
""":class:`~numpy:numpy.ndarray` of \
:attr:`~AngularTopology.angle`\ s."""
return self.measures
@property
def mean_angle(self):
"""Mean angle."""
return self.mean_measure
def todict(self):
"""Return :class:`~python:dict` of constructor parameters."""
super_dict = super().todict()
super_dict.update(dict(degrees=self.degrees))
return super_dict