Source code for ccdc.morphology

#
# 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 dataclasses import dataclass
from enum import IntEnum
from typing import Tuple, Optional, List

from ccdc.molecule import Coordinates, Molecule
from ccdc.crystal import Crystal
from ccdc.utilities import bidirectional_dict, nested_class

from ccdc.utilities import _import_ccdc_modules

_import_ccdc_modules([
    'ChemistryLib',
    'MathsLib',
    'MorphologyLib',
    'UtilitiesLib'
])

# Type aliases for crystallographic vectors
IntegerTuple = Tuple[int, int, int]
FloatTuple = Tuple[float, float, float]


#################################################################################################
#   Morphology Base class
#################################################################################################

class MorphologyBase:
    '''The morphology of a crystal'''

    def __init__(self, crystal=None, morph=None):
        if crystal is None:
            self.identifier = 'DEFAULT'
        else:
            self.identifier = crystal.identifier

        self._morph = morph

        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 = MorphologyLib.ifstream(file_name)
        cif_morph = MorphologyLib.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 = MorphologyLib.Morphology(crystal._crystal.cell(), [
            MorphologyLib.MorphologicalImportance(mi._miller_indices, d / largest) for mi, d in
            growth_rates])
        morph.crystal = crystal
        morph.identifier = crystal.identifier
        return morph

    def check_morphology(self):
        '''Helper func to check that the morphology has been calculated.'''
        if self._morph is None:
            raise RuntimeError('No morphology has been calculated.')

    def write(self, file_name, keep_all_indices=False):
        '''Write this morphology to CIF file.'''
        self.check_morphology()

        stream = MorphologyLib.ofstream(file_name)
        MorphologyLib.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.
        '''
        self.check_morphology()

        return self._morph.relative_area(miller_indices._miller_indices)

    @property
    def facets(self):
        '''The facets making up the morphology.'''
        self.check_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.'''
        self.check_morphology()

        cs = MorphologyLib.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.
        '''
        self.check_morphology()

        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.
        '''
        self.check_morphology()

        return OrientedBoundingBox(self)

    @property
    def volume(self):
        '''The volume of the morphology.

        This is calculated stochastically, rather than analytically, so has some error.
        '''
        self.check_morphology()

        if not hasattr(self, '_volume'):
            volcalc = MorphologyLib.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.'''
        self.check_morphology()

        return self._morph.scale_factor()

    @scale_factor.setter
    def scale_factor(self, factor):
        '''Set the scale factor of the morphology.'''
        self.check_morphology()

        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): _import_ccdc_modules(['BFDHMorphologyLib']) _telemetry = 0 def __init__(self, crystal=None): if type(self)._telemetry == 0: UtilitiesLib.ccdc_bfdh_morphology_telemetry() type(self)._telemetry = 1 bfdh = BFDHMorphologyLib.BFDHMorphologyCalculator() morph = bfdh.calculate_morphology(crystal._crystal.cell()) super().__init__(crystal, morph)
################################################################################################# # 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. ''' _import_ccdc_modules(['HabitMorphologyLib'])
[docs] class Settings: ''' Settings for the VisualHabit runner. ''' _import_ccdc_modules(['HabitMorphologyLib']) _potentials = bidirectional_dict( dreidingII=HabitMorphologyLib.VisualHabitPotentialDreiding, gavezzotti=HabitMorphologyLib.VisualHabitPotentialGavezzotti, momany=HabitMorphologyLib.VisualHabitPotentialMomany, CLP=HabitMorphologyLib.VisualHabitPotentialCLP, CSDOPCS16=HabitMorphologyLib.VisualHabitPotentialCSDOPCS16, ) 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``, ``momany``, ``CLP`` or ``CSD-OPCS16``. ''' return self._settings.get_potential().information().name() @potential.setter def potential(self, value): pot = self._potentials.prefix_lookup(value.lower().replace('-', '')) 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: float): 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: float): 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. ''' _import_ccdc_modules(['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) -> 'VisualHabit.Results.LatticeEnergy': '''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: 'Crystal.MillerIndices | tuple'): '''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) -> float: '''The total energy.''' return self._energies.get_total() @property def electrostatic(self) -> float: '''The electrostatic energy.''' return self._energies.electrostatic() @property def h_bond_attraction(self) -> float: '''The attractive hydrogen bond energy.''' return self._energies.h_bond_attraction() @property def h_bond_repulsion(self) -> float: '''The repulsive hydrogen bond energy.''' return self._energies.h_bond_repulsion() @property def h_bond(self) -> float: '''The total hydrogen bond energy.''' return self.h_bond_attraction + self.h_bond_repulsion @property def vdw_attraction(self) -> float: '''The attractive Van der Waals energy.''' return self._energies.vdw_attraction() @property def vdw_repulsion(self) -> float: '''The repulsive Van der Waals energy.''' return self._energies.vdw_repulsion() @property def vdw(self) -> float: '''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: 'Crystal') -> 'VisualHabit.Results': '''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.type_atoms() if not checker.add_charges(self.settings._settings.get_potential()): raise RuntimeError('VisualHabit analysis can not assign charges to the atoms.') results = HabitMorphologyLib.VisualHabitRunner(self.settings._settings).run() return self.Results(crystal, results)
################################################################################################# # Symmetry Allowed morphology ################################################################################################# class SymmetryAllowedMorphology(MorphologyBase): _import_ccdc_modules(['SeedMorphologyLib']) _telemetry = 0 class Settings: """ Settings for the Symmetry Allowed Morphology calculation. """ def __init__(self, maximum_hkl_integer: int = 3, minimum_d_spacing: float = 0.5): self._maximum_hkl_integer = maximum_hkl_integer self._minimum_d_spacing = minimum_d_spacing @property def maximum_hkl_integer(self) -> int: """The maximum absolute value of the Miller indices to consider""" return self._maximum_hkl_integer @maximum_hkl_integer.setter def maximum_hkl_integer(self, value: int): if value < 1: raise ValueError('maximum_hkl_integer must be a positive integer') self._maximum_hkl_integer = int(value) @property def minimum_d_spacing(self) -> float: """The minimum d-spacing to consider""" return self._minimum_d_spacing @minimum_d_spacing.setter def minimum_d_spacing(self, value: float): if value <= 0.0: raise ValueError('minimum_d_spacing must be a positive number') self._minimum_d_spacing = float(value) def __init__(self, crystal, settings: 'SymmetryAllowedMorphology.Settings' = None): """ Initialize the Symmetry Allowed Morphology object with a crystal structure and settings outlined. """ if type(self)._telemetry == 0: UtilitiesLib.ccdc_seed_morphology_telemetry() type(self)._telemetry = 1 # TODO: These will be used later when I've implemented the hkl limits self.settings = settings if settings is None: self.settings = self.Settings() self._crystal = crystal self._default_growth_rate = 0.5 morph = MorphologyLib.Morphology(crystal._crystal.cell(), self._default_growth_rate, self.settings.maximum_hkl_integer) super().__init__(crystal, morph) ################################################################################################# # Seed morphology ################################################################################################# def _vector_to_tuple(vector) -> FloatTuple: """Convert a C++ vector to a Python tuple of floats.""" return (vector.x(), vector.y(), vector.z()) def _int_vector_to_tuple(vector) -> IntegerTuple: """Convert a C++ integer vector to a Python tuple of integers.""" return (int(vector.x()), int(vector.y()), int(vector.z())) @dataclass(frozen=True) class GrowthEnergies: """Energy breakdown for a growth element. All energy values are in kJ/mol. """ terrace: float = 0.000 reverse_terrace: float = 0.000 step_edge: float = 0.000 reverse_step_edge: float = 0.000 kink: float = 0.000 reverse_kink: float = 0.000 internal: float = 0.000 bulk: float = 0.000 interfacial: float = 0.000 class SeedMorphology(MorphologyBase): """ Morphology based on Hartman-Perdok theory using Periodic Bond Chains (PBCs). This class models crystal growth using a hierarchical structure: - **GrowthFace**: A crystal face defined by Miller indices (hkl) - **Layer**: A repeating growth unit perpendicular to the face - **StepEdge**: A growth step along a PBC direction - **EdgeRow**: Individual molecular rows within the step - **KinkSite**: Attachment sites at row terminations """ _import_ccdc_modules(['SeedMorphologyLib']) _telemetry = 0 @nested_class('SeedMorphology') class Settings(Crystal.PeriodicBondChain.Settings): """Settings for controlling the SeedMorphology calculation.""" def __init__(self): super().__init__() @nested_class('SeedMorphology') class GrowthFace: """ A crystal face defined by Miller indices. Represents a growth face containing layers, step edges, edge rows and kink sites. """ @nested_class('GrowthFace') class GrowthStatusState(IntEnum): """Status of a growth calculation element.""" GOOD = 0 BAD = 1 UNFINISHED = 2 def __str__(self) -> str: return {self.GOOD: "Good", self.BAD: "Bad", self.UNFINISHED: "Unfinished"}[self] @nested_class('GrowthFace') class GrowthStatus: """Status information for a growth calculation element.""" def __init__(self, status): """Initialize the GrowthStatus object.""" self._status = status def __str__(self) -> str: message = f"GrowthStatus: Status:{self.status}" if self.is_good: return message return f"{message} Reason:{self.reason})" @property def status(self) -> str: """Return the status as a human-readable string.""" state = SeedMorphology.GrowthFace.GrowthStatusState(self._status.status()) return str(state) @property def reason(self) -> str: """Return the reason for a bad status.""" return self._status.reason() @property def is_good(self) -> bool: """Return True if the status is good.""" return self._status.is_good() @property def is_bad(self) -> bool: """Return True if the status is bad.""" return self._status.is_bad() @nested_class('GrowthFace') class KinkSite: """ A kink site on an edge row. Kink sites are the primary attachment points for molecules during crystal growth. They represent positions where a molecule can attach with maximum coordination to the existing crystal surface. """ @nested_class("KinkSite") class _KinkSitePosition(IntEnum): """Relative position of a molecule to the kink site.""" AHEAD_OF_KINK = 0 AT_KINK = 1 BEHIND_KINK = 2 NOT_IN_KINK_PBC = 3 def __str__(self) -> str: return { self.AHEAD_OF_KINK: "Ahead of kink", self.AT_KINK: "At kink", self.BEHIND_KINK: "Behind kink", self.NOT_IN_KINK_PBC: "Not in kink PBC", }[self] class _KinkDirection(IntEnum): """Direction of kink propagation.""" EAST = 0 WEST = 1 def __str__(self) -> str: return {self.EAST: "East", self.WEST: "West"}[self] def __init__(self, _kink_site): """Initialize the KinkSite object.""" self._kink_site = _kink_site def __str__(self) -> str: return f"KinkSite: Direction:{self.kink_direction}, Status:{self.growth_status.status}" @property def growth_status(self) -> 'GrowthStatus': """Return the calculation status for this kink site.""" return SeedMorphology.GrowthFace.GrowthStatus(self._kink_site.status()) def _to_molecule(self, molecule_address) -> Optional[Molecule]: """Convert a molecule address to a Molecule object""" if molecule_address is None: return None mol = self._kink_site.interactions_calculation().addressing().obtain_molecule(molecule_address) return Molecule(_molecule=mol) @property def kink_vacancy_position(self) -> FloatTuple: """Return the kink vacancy centroid in orthogonal coordinates.""" vacancy = self._kink_site.kink_vacancy() centroid_point = self._kink_site.interactions_calculation().addressing().molecule_centroid_orth(vacancy) return _vector_to_tuple(centroid_point) @property def preceding_molecule(self) -> Optional[Molecule]: """Return the molecule preceding the kink vacancy in the growth direction.""" return self._to_molecule(self._kink_site.preceding_molecule()) @property def kink_direction_vector(self) -> IntegerTuple: """Return the kink growth direction relative to the unit cell. (UVW)""" return _int_vector_to_tuple(self._kink_site.kink_growth_direction()) @property def growing_row(self) -> 'Crystal.PeriodicBondChain': """Return the PBC of the growing edge row.""" return Crystal.PeriodicBondChain(self._kink_site.growing_row()) @property def parent_row(self) -> 'EdgeRow': """Return the parent edge row of the kink site""" return SeedMorphology.GrowthFace.EdgeRow(self._kink_site.parent_row()) @property def offset_kink_x(self) -> float: """Return the offset of the kink site in x direction""" return self._kink_site.offset_kink_x() def _relative_molecule_position(self, molecule: Molecule) -> str: """Return the KinkSitePosition enum as a string for the given molecule address.""" molecule_address = self._kink_site.interactions_calculation().addressing().molecule_address( molecule._molecule) pos = self._kink_site.relative_molecule_position(molecule_address) # SWIG may return an enum proxy or an int; normalise to int then wrap try: value = int(pos) except (TypeError, ValueError): value = pos position = SeedMorphology.GrowthFace.KinkSite._KinkSitePosition(value) return str(position) @property def kink_direction(self) -> str: """The direction of the kink site with respect ot the parent edge row""" return str(SeedMorphology.GrowthFace.KinkSite._KinkDirection(self._kink_site.spiral_direction())) @property def energies(self) -> GrowthEnergies: """ Return all energies for this kink site as a GrowthEnergies object. > Note - Internal energies are zero for Kink sites """ _ks = self._kink_site return GrowthEnergies( terrace=_ks.terrace_energy().value(), reverse_terrace=_ks.reverse_terrace_energy().value(), step_edge=_ks.step_edge_energy().value(), reverse_step_edge=_ks.reverse_step_edge_energy().value(), kink=_ks.kink_growth_energy().value(), reverse_kink=_ks.reverse_kink_growth_energy().value(), internal=0.0, # Not available at kink level bulk=_ks.bulk_energy().value(), interfacial=_ks.interfacial_energy().value() ) @nested_class('GrowthFace') class EdgeRow: """ An individual molecular row within a step edge. Edge rows represent the fundamental growth units that are completed by kink site attachment during spiral growth. """ @nested_class('EdgeRowPosition') class _EdgeRowPosition(IntEnum): """Relative position of a molecule to the edge row.""" BEHIND_CURRENT_ROW = 0 IN_CURRENT_ROW = 1 AHEAD_OF_CURRENT_ROW = 2 def __str__(self) -> str: return { self.BEHIND_CURRENT_ROW: "Behind current row", self.IN_CURRENT_ROW: "In current row", self.AHEAD_OF_CURRENT_ROW: "Ahead of current row", }[self] def __init__(self, _edge_row): """Initialize the EdgeRow object.""" self._edge_row = _edge_row def __str__(self) -> str: return f"EdgeRow: Edge direction ({self.step_direction}), Status: {self.growth_status.status})" @property def growth_status(self) -> 'GrowthStatus': """Return the calculation status for this edge row.""" return SeedMorphology.GrowthFace.GrowthStatus(self._edge_row.status()) @property def parent_step_edge(self) -> 'StepEdge': """Return the parent step edge of the edge row - Nice to have""" return SeedMorphology.GrowthFace.StepEdge(self._edge_row.parent_step_edge()) @property def step_direction(self) -> IntegerTuple: """Return the step direction of the edge row.""" return _int_vector_to_tuple(self._edge_row.step_direction()) @property def _current_row(self) -> 'Crystal.PeriodicBondChain': """Return the current row of the edge row""" return Crystal.PeriodicBondChain(self._edge_row.current_row(), self._edge_row.interactions_calculation()) @property def _next_row(self) -> 'Crystal.PeriodicBondChain': """Return the next row of the edge row""" return Crystal.PeriodicBondChain(self._edge_row.next_row(), self._edge_row.interactions_calculation()) @property def parent_face(self) -> 'GrowthFace': """Return the parent growth face of the edge row - Nice to have""" return SeedMorphology.GrowthFace(self._edge_row.parent_face()) @property def row_orth_normal(self) -> FloatTuple: """Return the orthogonal row normal of the edge row.""" return _vector_to_tuple(self._edge_row.row_orth_normal()) @property def row_frac_normal(self) -> FloatTuple: """Return the fractional row normal of the edge row.""" return _vector_to_tuple(self._edge_row.row_frac_normal()) @property def row_orth_vector(self) -> FloatTuple: """Return the vector representing one full growth cycle along the edge row in orthogonal coordinates.""" return _vector_to_tuple(self._edge_row.row_orth_vector()) @property def average_width_per_growth_unit(self) -> float: """ Return the average width per growth unit along the edge row. """ return self._edge_row.average_width_per_growth_unit() @property def _current_min_step_y(self) -> float: """Return the minimum y value of the current edge row""" return self._edge_row.current_min_step_y() @property def _current_max_step_y(self) -> float: """Return the maximum y value of the current edge row""" return self._edge_row.current_max_step_y() @property def _centroid_step_y(self) -> float: """Return the centroid y value of the edge row""" return self._edge_row.centroid_step_y() @property def kink_sites(self) -> Tuple['KinkSite', ...]: """Return the kink sites of the edge row""" return tuple( SeedMorphology.GrowthFace.KinkSite(ks) for ks in self._edge_row.kink_sites() ) def _next_kink_site(self, kink_site) -> 'KinkSite': """Return the next kink site of the edge row""" ks = self._edge_row.next_kink_site(kink_site._kink_site) if ks is None: return None return SeedMorphology.GrowthFace.KinkSite(ks) def _previous_kink_site(self, kink_site) -> 'KinkSite': """Return the previous kink site of the edge row""" ks = self._edge_row.previous_kink_site(kink_site._kink_site) if ks is None: return None return SeedMorphology.GrowthFace.KinkSite(ks) @property def kink_sites_by_direction(self) -> List[List['KinkSite']]: """Return a dictionary of kink sites by growth direction""" k_sites_by_direction = [] for kink_sites in self._edge_row.kink_sites_by_direction(): k_sites = [] for ks in kink_sites: k_sites.append(SeedMorphology.GrowthFace.KinkSite(ks)) k_sites_by_direction.append(k_sites) return k_sites_by_direction def _relative_molecule_position(self, molecule: Molecule) -> str: """Return the relative position of molecules to the edge row.""" molecule_address = self._edge_row.interactions_calculation().addressing().molecule_address( molecule._molecule) position = SeedMorphology.GrowthFace.EdgeRow._EdgeRowPosition( self._edge_row.relative_molecule_position(molecule_address)) return str(position) @property def energies(self) -> GrowthEnergies: """ Return all energies for this edge row as a GrowthEnergies object. > Note - Kink and Reverse Kink energies are zero for edge rows """ _er = self._edge_row return GrowthEnergies( terrace=_er.terrace_energy().value(), reverse_terrace=_er.reverse_terrace_energy().value(), step_edge=_er.step_edge_energy().value(), reverse_step_edge=_er.reverse_step_edge_energy().value(), internal=_er.internal_energy().value(), bulk=_er.bulk_energy().value(), interfacial=_er.interfacial_energy().value(), ) @nested_class('GrowthFace') class StepEdge: """ A growth step along a PBC direction. Step edges represent an incomplete layer. They contain edge rows which in turn contain kink sites. """ @nested_class('StepEdgePosition') class _StepEdgePosition(IntEnum): """The relative position of a molecule with respect to the step edge""" BEHIND_STEP = 0 IN_STEP = 1 AHEAD_OF_STEP = 2 NOT_IN_STEP = 3 def __str__(self) -> str: return { self.BEHIND_STEP: "Behind step", self.IN_STEP: "In step", self.AHEAD_OF_STEP: "Ahead of step", self.NOT_IN_STEP: "Not in step", }[self] def __init__(self, _step_edge): """Initialize the StepEdge object.""" self._step_edge = _step_edge def __str__(self) -> str: return f"StepEdge: Step Edge Direction {self.step_direction}, Status: {self.growth_status.status})" @property def growth_status(self) -> 'GrowthStatus': """Return the calculation status for this step edge.""" return SeedMorphology.GrowthFace.GrowthStatus(self._step_edge.status()) @property def parent_layer(self) -> 'Layer': """Return the parent edge row of the edge row - Nice to have""" return SeedMorphology.GrowthFace.Layer(self._step_edge.parent_layer()) @property def step_direction(self) -> IntegerTuple: """Return the step direction of the step edge.""" return _int_vector_to_tuple(self._step_edge.step_direction()) @property def edge_rows(self) -> Tuple['EdgeRow', ...]: """Return the edge rows of the step edge.""" return tuple(SeedMorphology.GrowthFace.EdgeRow(er) for er in self._step_edge.edge_rows()) @property def step_orth_normal(self) -> FloatTuple: """Return the orthogonal step normal of the step edge.""" return _vector_to_tuple(self._step_edge.step_orth_normal()) @property def step_frac_normal(self) -> FloatTuple: """Return the fractional step normal of the step edge.""" return _vector_to_tuple(self._step_edge.step_frac_normal()) @property def average_width_per_growth_unit(self) -> float: """ Return the average width per growth unit along the rows. """ return self._step_edge.average_width_per_growth_unit() @property def average_propagation_length(self) -> float: """ Return the average propagation length per row in the step edge direction. """ return self._step_edge.average_propagation_length() @property def energies(self) -> GrowthEnergies: """ Return all energies for this step edge as a GrowthEnergies object. > Note - Kink and Reverse Kink energies are zero for Step edges""" _se = self._step_edge return GrowthEnergies( terrace=_se.terrace_energy().value(), reverse_terrace=_se.reverse_terrace_energy().value(), step_edge=_se.step_edge_energy().value(), reverse_step_edge=_se.reverse_step_edge_energy().value(), internal=_se.internal_energy().value(), bulk=_se.bulk_energy().value(), interfacial=_se.interfacial_energy().value(), ) @nested_class('GrowthFace') class Layer: """ A single growth layer within a crystal face. Represents the fundamental repeating unit perpendicular to the face normal. Contains step edges that define the layer's growth directions. In the Hartman-Perdok theory, layers correspond to F-faces (flat faces) when they contain two or more non-parallel PBCs. """ def __init__(self, _layer): """Initialize the Layer object.""" self._layer = _layer if self._layer is None: raise ValueError('layer cannot be None') def __str__(self) -> str: return f"Layer: Status: {self.growth_status.status}" @property def growth_status(self) -> 'GrowthStatus': """Return the calculation status for this layer.""" return SeedMorphology.GrowthFace.GrowthStatus(self._layer.status()) @property def parent_face(self) -> 'GrowthFace': """Return the parent growth face of the layer""" return SeedMorphology.GrowthFace(self._layer.parent_face()) @property def periodic_bond_chains(self) -> List['Crystal.PeriodicBondChain']: """Return the periodic_bond_chains of the growth face""" return [Crystal.PeriodicBondChain(pbc, pbc.interactions_calculation()) for pbc in self._layer.pbcs()] @property def molecules(self) -> List[Molecule]: """Return the molecule addresses in the layer""" return [Molecule(_molecule=self._layer.interactions_calculation().addressing().obtain_molecule(addr)) for addr in self._layer.molecule_addresses()] @property def min_z_position(self) -> float: """Return the minimum z value of the layer - where z is perpendicular to the layer""" return self._layer.min_face_z() @property def max_z_position(self) -> float: """Return the maximum z value of the layer - where z is perpendicular to the layer""" return self._layer.max_face_z() @property def centroid_z_position(self) -> float: """Return the centroid of the layer - relative to the layer perpendicular""" return self._layer.centroid_face_z() @property def step_edges(self) -> Tuple['StepEdge', ...]: """Return the step edges of the layer""" return tuple(SeedMorphology.GrowthFace.StepEdge(er) for er in self._layer.step_edges()) @property def step_edge_directions(self) -> List[IntegerTuple]: """Return the step edge directions of the layer""" return [_int_vector_to_tuple(sed) for sed in self._layer.step_edge_directions()] def step_edge_by_direction(self, direction: Tuple[int, int, int]) -> 'StepEdge': """Return the step edge for the given direction""" try: se = self._layer.step_edge_by_direction(MathsLib.int_vector_3d(*direction)) return SeedMorphology.GrowthFace.StepEdge(se) except RuntimeError as e: if 'map' in str(e): raise KeyError(f'Step edge with direction {direction} not found.') raise @property def energies(self) -> GrowthEnergies: """ Return all energies for this layer as a GrowthEnergies object. Note - Step Edge, Reverse Step edge, Kink, Reverse Kink energies are zero for edge rows. """ return GrowthEnergies( terrace=self._layer.terrace_energy().value(), reverse_terrace=self._layer.reverse_terrace_energy().value(), internal=self._layer.internal_energy().value(), bulk=self._layer.reverse_terrace_energy().value(), interfacial=self._layer.terrace_energy().value(), ) def __init__(self, growth_face): """Initialize the GrowthFace object.""" self._growth_face = growth_face def __str__(self) -> str: return f"GrowthFace: MillerIndices{self.miller_indices}, Status={self.status.status}" @property def status(self) -> GrowthStatus: """Return the calculation status for this growth face.""" return SeedMorphology.GrowthFace.GrowthStatus(self._growth_face.status()) @property def layers(self) -> Tuple['Layer', ...]: """Return the layers of the growth face.""" return tuple( SeedMorphology.GrowthFace.Layer(layer) for layer in self._growth_face.layers() ) @property def periodic_bond_chains(self) -> Tuple[Crystal.PeriodicBondChain, ...]: """Return the periodic bond chains of the growth face.""" return tuple( Crystal.PeriodicBondChain(pbc, pbc.interactions_calculation()) for pbc in self._growth_face.pbcs() ) @property def face_orth_normal(self) -> FloatTuple: """Return the orthogonal face normal of the growth face.""" return _vector_to_tuple(self._growth_face.face_orth_normal()) @property def face_frac_normal(self) -> FloatTuple: """Return the fractional face normal of the growth face.""" return _vector_to_tuple(self._growth_face.face_frac_normal()) @property def num_symmetry_layers(self) -> int: """Return the number of layers implied by space group symmetry""" return self._growth_face.n_symmetry_layers() @property def interplanar_spacing(self) -> float: """Return the interplanar spacing of the growth face in Angstroms.""" return self._growth_face.interplanar_spacing() @property def average_height_per_layer(self) -> float: """ Return the average height per layer in Angstroms. In practice this may differ from the interplanar spacing. """ return self._growth_face.average_height_per_layer() def get_layer_containing_molecule(self, mol: Molecule) -> Optional['Layer']: """Return the layer containing the given molecule address""" mol_address = self._growth_face.interactions().addressing().molecule_address(mol._molecule) layer = self._growth_face.get_layer_containing_address(mol_address) if layer is None: return None return SeedMorphology.GrowthFace.Layer(layer) @property def miller_indices(self) -> IntegerTuple: """Return the Miller indices of the growth face.""" return _int_vector_to_tuple(self._growth_face.miller_indices().to_vector()) @nested_class('SeedMorphology') class GrowthFacet(Facet): """ A facet on a SeedMorphology with associated growth face information. Extends the base Facet class to include a reference to the GrowthFace which contains the detailed growth calculation results. """ def __init__(self, facet, perpendicular_distance, miller_indices, growth_face=None): """Initialize the GrowthFacet object with a morphology.Facet object.""" super().__init__(facet, perpendicular_distance, miller_indices) self._growth_face = growth_face def __str__(self) -> str: return f"GrowthFacet: MillerIndices:{self.miller_indices.hkl}" @property def growth_face(self) -> Optional['SeedMorphology.GrowthFace']: """Return the growth face associated with this facet.""" if self._growth_face is None: return None return SeedMorphology.GrowthFace(self._growth_face) def __init__(self, crystal, settings=None): """ Initialize the SeedMorphology object with a crystal structure and settings. """ if type(self)._telemetry == 0: UtilitiesLib.ccdc_seed_morphology_telemetry() type(self)._telemetry = 1 self._crystal = crystal self._settings = settings self._relative_growth_rate = 0.5 if self._settings is None: self._settings = SeedMorphology.Settings() morph = None pbcs = self._crystal.periodic_bond_chains(self._settings) if len(pbcs) == 0: warnings.warn( f"No periodic bond chains found for crystal '{crystal.identifier}'. " "Morphology properties will be unavailable.", UserWarning ) self._growth_faces = [] self._interaction_calculation = None else: self._interaction_calculation = pbcs[0]._interaction_calculation chains = [pbc._pbc for pbc in pbcs] calculator = SeedMorphologyLib.GrowthFaceCalculator(self._interaction_calculation, chains) self._growth_faces = calculator.calculate_growth_faces(None) morph = calculator.calculate_morphology(self._crystal._crystal.cell(), self._relative_growth_rate) super().__init__(crystal, morph) @property def facets(self): """ Overloaded function from the MorphologyBase - Return the facets of the seed - """ facets = self._morph.calculate() growth_rate_scaled = self._relative_growth_rate * self._morph.scale_factor() growth_faces_map = { (gf.miller_indices().h(), gf.miller_indices().k(), gf.miller_indices().l()): gf for gf in self._growth_faces } growth_facets = [] for facet in facets: hkl = (facet.miller_indices().h(), facet.miller_indices().k(), facet.miller_indices().l()) growth_facet = SeedMorphology.GrowthFacet( facet=facet, perpendicular_distance=growth_rate_scaled, miller_indices=Crystal.MillerIndices(*hkl, self.crystal, _cell=self._morph.unit_cell()), growth_face=growth_faces_map.get(hkl, None)) growth_facets.append(growth_facet) return tuple(growth_facets) def get_facet(self, hkl: IntegerTuple) -> Optional['GrowthFacet']: """Return the facet for the given Miller indices. :param hkl: Miller indices as (h, k, l) tuple. :returns: The matching GrowthFacet, or None if not found. """ for facet in self.facets: if facet.miller_indices.hkl == hkl: return facet return None @property def growth_faces(self) -> Tuple['GrowthFace', ...]: """Return all calculated growth faces.""" return tuple(SeedMorphology.GrowthFace(gf) for gf in self._growth_faces) def get_growth_face(self, hkl: IntegerTuple) -> Optional['GrowthFace']: """Return the growth face for the given Miller indices. :param hkl: Miller indices as (h, k, l) tuple. :returns: The matching GrowthFace, or None if not found. """ for gf in self._growth_faces: indices = gf.miller_indices() if (indices.h(), indices.k(), indices.l()) == hkl: return SeedMorphology.GrowthFace(gf) return None