# This code is Copyright (C) 2025 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.
#
'''
crystal_optimiser.py - an API for crystal structure optimisation.
The main class is :class:`ccdc.csp.crystal_optimiser.CrystalOptimiser`
Simplest operation is:
.. code-block:: python
>>> from ccdc import io
>>> from ccdc.csp.crystal_optimiser import CrystalOptimiser
>>> csd = io.EntryReader('csd')
>>> crystal = csd.entry('HXACAN').crystal
>>> optimiser = CrystalOptimiser()
>>> optimisation_results = optimiser.optimise(crystal)
>>> optimised_crystal = optimisation_results.optimised_crystal
>>> final_score = optimisation_results.final_score
CrystalOptimiser can also be used to directly score a crystal structure based on the current settings:
.. code-block:: python
>>> score = optimiser.score(crystal)
'''
###########################################################################
import os
from ccdc.crystal import Crystal
from ccdc.utilities import Decorator
from ccdc.utilities import _private_importer
with _private_importer() as pi:
pi.import_ccdc_module('CrystalOptimiserLib')
[docs]
class CrystalOptimiser(object):
[docs]
class Settings(object):
'''Settings pertaining to the optimiser.'''
def __init__(self, _settings=None):
if _settings is None:
_settings = CrystalOptimiserLib.CrystalOptimiserSettings()
self._settings = _settings
self._settings.set_optimiser_file_path(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templater'))
@property
def normalise_hydrogens(self):
'''If hydrogens are to be normalised before optimisation.'''
return self._settings.normalise_hydrogens()
@normalise_hydrogens.setter
@Decorator.value_true_or_false
def normalise_hydrogens(self, value):
self._settings.set_normalise_hydrogens(value)
@property
def force_field(self):
'''The force field choices for optimisation, including:
"CLP", "CSD-OPCS16", "Dreiding II", "Momany", "Gavezzotti".'''
return self._settings.force_field()
@force_field.setter
def force_field(self, value):
ff_types = ("CLP", "CSD-OPCS16", "Dreiding II", "Momany", "Gavezzotti")
if value not in ff_types:
raise ValueError("force field must be one of these values: %s - received %s" % (", ".join(ff_types), str(value)))
self._settings.set_force_field(value)
@property
def optimise_cell(self):
'''If unit cell parameters are allowed to change during optimisation.'''
return self._settings.optimise_cell()
@optimise_cell.setter
@Decorator.value_true_or_false
def optimise_cell(self, value):
self._settings.set_optimise_cell(value)
@property
def cell_limit(self):
'''The maximum % volume change allowed for the unit cell.'''
return self._settings.cell_limit()
@cell_limit.setter
def cell_limit(self, value):
if type(value) is not type(10) or value >= 100 or value <= 0:
raise ValueError(
"cell volume (%%) change limit must be an integer between (0, 100), received %s" % str(value))
self._settings.set_cell_limit(value)
@property
def optimise_molecule(self):
'''The choices for molecule optimisation: "Position" or "Geometry".'''
return self._settings.molecule_optimisation()
@optimise_molecule.setter
def optimise_molecule(self, value):
molecule_optimisation_types = ["Position", "Geometry"]
if value not in molecule_optimisation_types:
raise ValueError(
"Molecule optimisation must be one of these values: %s - received %s" % (", ".join(molecule_optimisation_types), str(value)))
self._settings.set_molecule_optimisation(value)
@property
def convergence_tolerance(self):
'''The convergence tolerance for optimisation.'''
return self._settings.crystal_convergence()
@convergence_tolerance.setter
def convergence_tolerance(self, value):
if type(value) is not type(0.5) or value >= 1.0 or value <= 0.0:
raise ValueError(
"Convergence tolerance must be a number between (0.0, 1.0), received %s" % str(value))
self._settings.set_crystal_convergence(value)
@property
def limiting_radius(self):
'''The limiting radius for optimisation.'''
return self._settings.limiting_radius()
@limiting_radius.setter
def limiting_radius(self, value):
if type(value) is not type(0.5) or value >= 100.00 or value <= 0.0:
raise ValueError(
"Limiting Radius (Angstrom) must be a number between (0.0, 100.0), received %s" % str(value))
self._settings.set_limiting_radius(value)
@property
def working_dir(self):
'''The directory to save the optimised structure file and log file.'''
return self._settings.job_directory()
@working_dir.setter
def working_dir(self, dir):
if dir != '' and not os.path.isdir(dir):
raise ValueError(
"working_dir must be an existing dir - received %s" % str(dir))
self._settings.set_job_directory(dir)
@property
def output_format(self):
'''The file format for the output optimised crystal, one of 'cif' or 'mol2'.'''
return self._settings.output_format()
@output_format.setter
def output_format(self, value):
formats = ('cif', 'mol2')
if value not in formats:
raise ValueError("format must be one of %s, received %s" % (", ".join(formats), str(value)))
self._settings.set_output_format(value)
[docs]
class Results:
'''Holds the results of a CrystalOptimiser calculation.'''
def __init__(self, results, crystal, original_crystal, optimised_crystal, optimised_name, starting_score,
final_score, n_steps):
self._optimised_name = optimised_name
self._original_crystal = original_crystal
self._optimised_crystal = optimised_crystal
self._starting_score = starting_score
self._final_score = final_score
self._n_steps = n_steps
self._crystal = crystal
self._results = results
@property
def optimised_name(self):
'''The name assigned to the optimised structure.'''
return self._optimised_name
@property
def original_crystal(self):
'''The original crystal structure used for the optimisation.'''
return self._original_crystal
@property
def optimised_crystal(self):
'''The optimised crystal structure.'''
return self._optimised_crystal
@property
def starting_score(self):
'''The score of the starting crystal structure.'''
return self._starting_score
@property
def final_score(self):
'''The score of the optimised crystal structure.'''
return self._final_score
@property
def n_steps(self):
'''The number of steps taken during optimisation.'''
return self._n_steps
def __init__(self, settings=None):
if settings is None:
settings = CrystalOptimiser.Settings()
self.settings = settings
self.optimiser = CrystalOptimiserLib.CrystalOptimiser()
[docs]
def minimiser_settings(self, crystal):
''' Return the minimiser settings for the optimiser.'''
self.optimiser.prepare_optimisation(crystal._csv)
self.optimiser.create_optimiser()
self.optimiser.prepare_optimiser_settings()
return self.optimiser.minimiser_settings()
[docs]
def optimise(self, crystal):
'''
Optimise the crystal based on the current settings.
:return: A :class:`ccdc.csp.crystal_optimiser.CrystalOptimiser.Results` instance.
'''
self.settings._settings.set_input_name(crystal.identifier)
self.optimiser.set_settings(self.settings._settings)
results = self.optimiser.optimise_synchronous(crystal._csv)
optimised_name = self.settings._settings.optimised_name()
original_crystal = Crystal(self.optimiser.original_structure(), crystal.identifier)
optimised_crystal = Crystal(results.crystal_structure(), optimised_name)
n_steps = self.optimiser.step_count()
starting_score = self.score(original_crystal)
final_score = results.absolute_score()
return self.Results(results, crystal, original_crystal, optimised_crystal, optimised_name, starting_score,
final_score, n_steps)
[docs]
def score(self, crystal):
'''Score the crystal based on the current settings.'''
self.optimiser.set_settings(self.settings._settings)
score = self.optimiser.score(crystal._csv)
return score