#
# This code is Copyright (C) 2022 The Cambridge Crystallographic Data Centre
# (CCDC) of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC.
# This code may not be used, reproduced, translated, modified, disassembled or
# copied, except in accordance with a valid licence agreement with CCDC and may
# not be disclosed or redistributed in any form, either in whole or in part, to
# any third party. All copies of this code made in accordance with a valid
# licence agreement as referred to above must contain this copyright notice.
#
# No representations, warranties, or liabilities are expressed or implied in the
# supply of this code by CCDC, its servants or agents, except where such
# exclusion or limitation is prohibited, void or unenforceable under governing
# law.
#
'''
The :mod:`ccdc.morphology` module contains classes for crystal morphology.
The main classes in the :mod:`ccdc.morphology` module are:
- :class:`ccdc.morphology.BFDHMorphology`.
- :class:`ccdc.morphology.VisualHabitMorphology`.
'''
import os
import warnings
from ccdc.molecule import Coordinates, Molecule
from ccdc.crystal import Crystal
from ccdc.utilities import bidirectional_dict
from ccdc.utilities import _private_importer
with _private_importer() as pi:
pi.import_ccdc_module('ChemistryLib')
pi.import_ccdc_module('MathsLib')
pi.import_ccdc_module('BFDHMorphologyLib')
pi.import_ccdc_module('UtilitiesLib')
#################################################################################################
# Morphology Base class
#################################################################################################
class MorphologyBase:
'''The morphology of a crystal'''
def __init__(self, crystal=None):
if crystal is None:
self._morph = BFDHMorphologyLib.Morphology()
self.identifier = 'DEFAULT'
else:
bfdh = BFDHMorphologyLib.BFDHMorphologyCalculator()
self._morph = bfdh.calculate_morphology(crystal._crystal.cell())
self.identifier = crystal.identifier
self.crystal = crystal
@staticmethod
def from_file(file_name):
'''Creates a Morphology instance from a CIF file.
The CIF file should be those written by this class or Mercury, which
includes a scaling for each of the perpendicular distances.
'''
morph = MorphologyBase()
stream = BFDHMorphologyLib.ifstream(file_name)
cif_morph = BFDHMorphologyLib.CifMorphology()
morph._morph = cif_morph.read_cif_file(stream)
morph.identifier = os.path.splitext(os.path.basename(file_name))[0]
stream.close()
return morph
@staticmethod
def from_growth_rates(crystal, growth_rates):
'''Creates a morphology from an iterable of growth rates.
:param crystal: an instance of :class:`ccdc.crystal.Crystal`.
:param growth_rates: an iterable of pairs, :class:`ccdc.crystal.Crystal.MillerIndices` and perpendicular distance, otherwise known as
morphological importance.
'''
morph = MorphologyBase()
largest = max(d for mi, d in growth_rates)
morph._morph = BFDHMorphologyLib.Morphology(crystal._crystal.cell(), [
BFDHMorphologyLib.MorphologicalImportance(mi._miller_indices, d / largest) for mi, d in
growth_rates])
morph.crystal = crystal
morph.identifier = crystal.identifier
return morph
def write(self, file_name, keep_all_indices=False):
'''Write this morphology to CIF file.'''
stream = BFDHMorphologyLib.ofstream(file_name)
BFDHMorphologyLib.CifMorphology().write_cif_file(
stream, self._morph, self.identifier, keep_all_indices)
def relative_area(self, miller_indices):
'''The relative area of the facet.
This is what is usually called the Morphological Importance of a facet.
'''
return self._morph.relative_area(miller_indices._miller_indices)
@property
def facets(self):
'''The facets making up the morphology.'''
facets = self._morph.calculate()
importances = self._morph.morphological_importance_list()
perps = []
for f in facets:
for x in importances:
if x.miller_indices() == f.miller_indices():
perps.append(x.perpendicular_distance() * self._morph.scale_factor())
break
return tuple(
Facet(
f,
p,
Crystal.MillerIndices(
f.miller_indices().h(), f.miller_indices().k(), f.miller_indices().l(),
self.crystal, _cell=self._morph.unit_cell()
)
)
for f, p in zip(facets, perps)
)
@property
def centre_of_geometry(self):
'''The centroid of the morphology.'''
cs = BFDHMorphologyLib.SolidAsConvexPolygonSolid(self._morph.solid())
p = cs.centroid()
return Coordinates(p.x(), p.y(), p.z())
@property
def bounding_box(self):
'''The bounding box of the morphology.
A pair of :class:`ccdc.molecule.Coordinates` representing the minimum and maximum corners of the box.
'''
box = self._morph.solid().bounding_box()
return (
Coordinates(box.xmin(), box.ymin(), box.zmin()),
Coordinates(box.xmax(), box.ymax(), box.zmax())
)
@property
def oriented_bounding_box(self):
'''The minimum volume box of the morphology.
This will not necessarily be aligned to the orthonormal cartesian axes.
'''
return OrientedBoundingBox(self)
@property
def volume(self):
'''The volume of the morphology.
This is calculated stochastically, rather than analytically, so has some error.
'''
if not hasattr(self, '_volume'):
volcalc = BFDHMorphologyLib.StochasticVolumeCalculator(self._morph.solid())
self._volume = volcalc.solid_volume()
return self._volume
@property
def scale_factor(self):
'''The factor by which the morphology is scaled.'''
return self._morph.scale_factor()
@scale_factor.setter
def scale_factor(self, factor):
self._morph.set_scale_factor(factor)
if hasattr(self, '_volume'):
delattr(self, '_volume')
class OrientedBoundingBox:
'''The bounding box of the morphology.
This box is not necessarily axis-aligned.
'''
def __init__(self, morphology):
self.morphology = morphology
all_pts = [p for f in self.morphology.facets for p in f.coordinates]
points = [MathsLib.Point(c[0], c[1], c[2]) for c in all_pts]
try:
# Fast but may fail with an exception
self._bbox = MathsLib.MinimumVolumeBox(points, MathsLib.MinimumVolumeBox.CALC_REAL)
except RuntimeError:
# Slow and requires lots of stack space
self._bbox = MathsLib.MinimumVolumeBox(points, MathsLib.MinimumVolumeBox.CALC_RATIONAL)
corners = self.corners
from ccdc import descriptors
self.lengths = [
descriptors.GeometricDescriptors.Vector.from_points(corners[0], corners[1]).length,
descriptors.GeometricDescriptors.Vector.from_points(corners[0], corners[2]).length,
descriptors.GeometricDescriptors.Vector.from_points(corners[0], corners[4]).length
]
self.lengths.sort(reverse=True)
@property
def major_length(self):
'''The length of the major axis of the bounding box.'''
return self.lengths[0]
@property
def median_length(self):
'''The length of the middle axis of the bounding box.'''
return self.lengths[1]
@property
def minor_length(self):
'''The length of the minor axis of the bounding box.'''
return self.lengths[2]
@property
def volume(self):
'''The volume of the bounding box.'''
return self._bbox.volume()
@property
def corners(self):
'''The eight points forming the corners of the bounding box.'''
return tuple(
Coordinates(c.x(), c.y(), c.z()) for c in self._bbox.corners()
)
class Facet:
'''One of the facets of a morphology.'''
def __init__(self, _facet, _perpendicular_distance, _miller_indices):
self._facet = _facet
self._miller_indices = _miller_indices
self._perpendicular_distance = _perpendicular_distance
@property
def centre_of_geometry(self):
'''The centre of geometry of the facet.'''
if self._facet is None:
return None
p = self._facet.convex_polygon().center_of_mass_3D()
return Coordinates(p.x(), p.y(), p.z())
@property
def coordinates(self):
'''The coordinates of the vertices of the facet.'''
if self._facet is None:
return None
coords = self._facet.convex_polygon().vertices3D()
return tuple(
Coordinates(*x) for x in coords
)
@property
def edges(self):
'''The edges making up the facet.'''
if self._facet is None:
return None
edges = self._facet.convex_polygon().edges()
return tuple(
(
Coordinates(e.point1().x(), e.point1().y(), e.point1().z()),
Coordinates(e.point2().x(), e.point2().y(), e.point2().z())
)
for e in edges
)
@property
def plane(self):
'''The plane of the facet.
This is a :class:`ccdc.descriptors.GeometricDescriptors.Plane` instance.
'''
if self._facet is None:
return None
from ccdc import descriptors
return descriptors.GeometricDescriptors.Plane(None, None,
_plane=self._facet.convex_polygon().plane())
@property
def miller_indices(self):
'''The Miller indices of the facet.'''
return self._miller_indices
@property
def perpendicular_distance(self):
'''The perpendicular distance from the origin.'''
return self._perpendicular_distance
@property
def area(self):
'''The area of the polygon.'''
if self._facet is None:
return None
return self._facet.convex_polygon().area()
#################################################################################################
# BFDHMorphology
#################################################################################################
[docs]class BFDHMorphology(MorphologyBase):
_telemetry = 0
def __init__(self, crystal=None):
super().__init__(crystal)
if type(self)._telemetry == 0:
UtilitiesLib.ccdc_bfdh_morphology_telemetry()
type(self)._telemetry = 1
#################################################################################################
# VisualHabitMorphology
#################################################################################################
[docs]class VisualHabitMorphology(MorphologyBase):
'''The morphological information of the VisualHabit calculation'''
def __init__(self, _results, crystal):
self._results = _results
self._morph = self._results.get_morphology()
self.crystal = crystal
self._face_properties = {}
for fp in self._results.get_face_properties():
miller_indices = Crystal.MillerIndices(
fp.miller_indices().h(),
fp.miller_indices().k(),
fp.miller_indices().l(),
self.crystal
)
self._face_properties[miller_indices.hkl] = fp
[docs] class VisualHabitFacet(Facet):
'''One of the facets of a VisualHabit morphology.'''
def __init__(self, _facet, _perpendicular_distance, _miller_indices, _face_property):
super().__init__(_facet, _perpendicular_distance, _miller_indices)
self._face_property = _face_property
@property
def attachment_energy(self):
'''Attachment energy of the facet in kJ/mol'''
return self._face_property.attachment_energy().get_total()
@property
def surface_energy(self):
'''Surface energy of the facet in mJ/m^2'''
return self._face_property.surface_energy().get_total()
@property
def d_spacing(self):
'''d-spacing of the facet in Angstroms.'''
return self._face_property.d_spacing()
@property
def percentage_area(self):
'''Percentage of the total surface area accounted for by this facet'''
return self._face_property.get_fractional_surface_area() * 100
@property
def offset(self):
'''The offset at which the energies are measured, in Angstroms.'''
return self._face_property.slice_shift()
@property
def facets(self):
'''The facets making up the morphology
:return: A tuple of :class:`ccdc.morphology.VisualHabitMorphology.VisualHabitFacet` instances
'''
facets = self._morph.calculate()
importances = self._morph.morphological_importance_list()
perps = []
for f in facets:
for x in importances:
if x.miller_indices() == f.miller_indices():
perps.append(x.perpendicular_distance() * self._morph.scale_factor())
break
vh_facets = []
for f, p in zip(facets, perps):
miller_indices = Crystal.MillerIndices(
f.miller_indices().h(), f.miller_indices().k(), f.miller_indices().l(),
self.crystal, _cell=self._morph.unit_cell()
)
vh_facets.append(
VisualHabitMorphology.VisualHabitFacet(
f,
p,
miller_indices,
self._face_properties[miller_indices.hkl]
)
)
return tuple(vh_facets)
#################################################################################################
# VisualHabit
#################################################################################################
[docs]class VisualHabit:
'''
Descriptors for VisualHabit.
'''
with _private_importer() as pi:
pi.import_ccdc_module('HabitMorphologyLib')
[docs] class Settings:
'''
Settings for the VisualHabit runner.
'''
with _private_importer() as pi:
pi.import_ccdc_module('HabitMorphologyLib')
_potentials = bidirectional_dict(
dreidingII = HabitMorphologyLib.VisualHabitPotentialDreiding,
gavezzotti = HabitMorphologyLib.VisualHabitPotentialGavezzotti,
momany = HabitMorphologyLib.VisualHabitPotentialMomany,
)
def __init__(self):
self._settings = HabitMorphologyLib.VisualHabitRunParameters()
# Default values
_pot = self._potentials.prefix_lookup('dreidingII')
self._settings.set_potential(_pot())
self._settings.set_limiting_radius(30.0)
@property
def potential(self):
'''The potential forcefield used for the calculation.
Set this to one of ``dreidingII`` (default), ``gavezzotti``,
or ``momany``.
'''
return self._settings.get_potential().information_.name_
@potential.setter
def potential(self, value):
pot = self._potentials.prefix_lookup(value.lower())
if pot is None:
raise TypeError('Potential was not recognised.')
self._settings.set_potential(pot())
@property
def electrostatic_correction(self):
'''The electrostatic correction mode.
Set to ``None`` to turn this off. Default to ``Evjen``.
'''
return self._settings.get_electrostatic_correction_mode()
@electrostatic_correction.setter
def electrostatic_correction(self, value):
self._settings.set_electrostatic_correction_mode(value)
@property
def convergence_limiting_radius(self):
'''The convergence limiting radius for the calculation.
The default is 30.0 Angstroms.
Setting it higher will significantly increase the time of calculation.
'''
return self._settings.get_limiting_radius()
@convergence_limiting_radius.setter
def convergence_limiting_radius(self, value):
if value % HabitMorphologyLib.VisualHabitRunParameters.RADIUS_INCREMENT > 0.0:
warnings.warn(
f'The supplied limiting radius of {value} is not a multiple of 2 Å, and will be rounded up.',
UserWarning)
self._settings.set_limiting_radius(float(value))
[docs] class Results:
'''Holds the results of a VisualHabit calculation.
All energy terms are given in kJ/mol.
'''
with _private_importer() as pi:
pi.import_ccdc_module('HabitMorphologyLib')
def __init__(self, crystal, _results):
self.crystal = crystal
self._results = _results
self._breakdown = _results.lattice_energies_by_value()
@property
def morphology(self):
'''The calculated morphology
:return: A :class:`ccdc.morphology.VisualHabitMorphology` instance
'''
return VisualHabitMorphology(self._results, self.crystal)
@property
def lattice_energy(self):
'''The energies associated with the lattice.
:return: A :class:`ccdc.descriptors.CrystalDescriptors.VisualHabit.Results.LatticeEnergy` instance
'''
return self.LatticeEnergy(
HabitMorphologyLib.VisualHabitEnergyBreakdown(
self._results.total_vdw_attractive_lattice_energy(),
self._results.total_vdw_repulsive_lattice_energy(),
self._results.total_electrostatic_lattice_energy(),
self._results.total_h_bond_attractive_lattice_energy(),
self._results.total_h_bond_repulsive_lattice_energy()
)
)
[docs] def facet_energy(self, miller_indices):
'''Return the facet for the miller indices with energy information, but no morphology geometry information.
:param miller_indices: a :class:`ccdc.crystal.Crystal.MillerIndices` instance, or a tuple of (h,k,l).
:return: A :class:`ccdc.morphology.VisualHabitMorphology.VisualHabitFacet` instance.
'''
if isinstance(miller_indices, Crystal.MillerIndices):
mi = miller_indices._miller_indices
else:
mi = ChemistryLib.MillerIndices(*miller_indices)
miller_indices = Crystal.MillerIndices(mi.h(), mi.k(), mi.l(), self.crystal)
props = self._results.get_face_properties(mi)
return VisualHabitMorphology.VisualHabitFacet(None, None, miller_indices, props)
@property
def synthons(self):
'''The synthons, or molecular interactions within the crystal.
:return: A tuple of :class:`ccdc.descriptors.CrystalDescriptors.VisualHabit.Results.Synthon` instances.
'''
synthon_data_store = self._results.synthon_data_store()
synthons = synthon_data_store.get_synthons()
return tuple(
self.Synthon(s, self._results) for s in synthons
)
[docs] class LatticeEnergy:
'''The lattice energy associated with a facet of the lattice.'''
def __init__(self, _energies):
self._energies = _energies
@property
def total(self):
'''The total energy.'''
return self._energies.get_total()
@property
def electrostatic(self):
'''The electrostatic energy.'''
return self._energies.electrostatic()
@property
def h_bond_attraction(self):
'''The attractive hydrogen bond energy.'''
return self._energies.h_bond_attraction()
@property
def h_bond_repulsion(self):
'''The repulsive hydrogen bond energy.'''
return self._energies.h_bond_repulsion()
@property
def h_bond(self):
'''The total hydrogen bond energy.'''
return self.h_bond_attraction + self.h_bond_repulsion
@property
def vdw_attraction(self):
'''The attractive Van der Waals energy.'''
return self._energies.vdw_attraction()
@property
def vdw_repulsion(self):
'''The repulsive Van der Waals energy.'''
return self._energies.vdw_repulsion()
@property
def vdw(self):
'''The total Van der Waals energy.'''
return self.vdw_attraction + self.vdw_repulsion
[docs] class Synthon:
'''A synthon or molecular interaction within the crystal lattice.'''
def __init__(self, _synthon, _results):
self._synthon = _synthon
self._energies = _synthon.energies()
self._results = _results
self._molecule1 = None
self._molecule2 = None
@property
def distance(self) -> float:
'''The distance between the two molecules's centroids in Angstroms.'''
return self._synthon.separation_ctr_geometry()
@property
def electrostatic(self) -> float:
'''The electrostatic energy in kJ/mol.'''
return self._energies.electrostatic()
@property
def vdw(self) -> float:
'''The Van der Waals energy in kJ/mol.'''
return self._energies.get_vdw_total()
@property
def h_bond(self) -> float:
'''The hydrogen bond energy in kJ/mol.'''
return self._energies.get_h_bonding_total()
@property
def total(self) -> float:
'''The total energy in kJ/mol.'''
return self._energies.get_total()
@property
def symmetry_operator(self) -> str:
'''Return a string representing the symmetry operator relating the two molecules.'''
return self._synthon.distant_transformation().to_string()
@property
def translation_vector(self):
'''Return the unit cell translation vector between the two molecules.'''
offset = self._synthon.distant_cell_offset()
return (offset.x(), offset.y(), offset.z())
@property
def molecule1(self) -> Molecule:
'''Return the first molecule in the synthon.'''
if self._molecule1 is None:
mol = self._results.origin_molecule(self._synthon)
mapping = self._synthon.origin_molecule_index_mapping()
self._molecule1 = Molecule(mapping.label, _molecule=mol)
return self._molecule1
@property
def molecule2(self) -> Molecule:
'''Return the second molecule in the synthon.'''
if self._molecule2 is None:
mol = self._results.distant_molecule(self._synthon)
mapping = self._synthon.distant_molecule_index_mapping()
self._molecule2 = Molecule(mapping.label, _molecule=mol)
return self._molecule2
def __init__(self, settings=None):
if settings is None:
settings = VisualHabit.Settings()
self.settings = settings
[docs] def calculate(self, crystal):
'''Calculate the habit of the crystal.
:param crystal: a :class:`ccdc.crystal.Crystal` instance.
:returns: a :class:`ccdc.descriptors.CrystalDescriptors.VisualHabit.Results` instance.
The calculation will not be possible if the crystal contains
significant disorder or if some heavy atoms of the crystal have no
coordinates.
'''
csv = crystal._csv
self.settings._settings.set_crystal_view(csv)
if HabitMorphologyLib.check_unknown_bonds(csv):
raise RuntimeError('VisualHabit analysis can not be performed on this structure due to unknown bonds.')
if HabitMorphologyLib.check_disorder_assemblies(csv):
raise RuntimeError('VisualHabit analysis can not be performed on this structure due to all or suppressed disorder assemblies selected.')
checker = HabitMorphologyLib.VisualHabitDueDiligence(self.settings._settings)
if not checker.check(csv):
raise RuntimeError(checker.errors_description())
checker.gasteiger_charges()
checker.type_atoms()
results = HabitMorphologyLib.VisualHabitRunner(self.settings._settings).run()
return self.Results(crystal, results)