Source code for IMDgroup.pymatgen.transformations.symmetry_clone
# MIT License
#
# Copyright (c) 2024-2025 Inverse Materials Design Group
#
# Author: Ihor Radchenko <yantar92@posteo.net>
#
# This file is a part of IMDgroup-pymatgen package
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Generate all the symmetrically equivalent clones of a site in structure."""
import logging
# from multiprocessing import Pool, cpu_count
from pymatgen.transformations.transformation_abc import AbstractTransformation
from pymatgen.core import (SymmOp, Structure, get_el_sp)
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
from pymatgen.util.typing import SpeciesLike
from alive_progress import alive_bar
from IMDgroup.pymatgen.core.structure import structure_distance, get_matched_structure
__author__ = "Ihor Radchenko <yantar92@posteo.net>"
logger = logging.getLogger(__name__)
[docs]
class SymmetryFillTransformation(AbstractTransformation):
"""Clone selected sites according to symmetry operations.
Applies a list of symmetry operations to all sites of a given
element set, adding new sites when they are not too close to
existing ones.
Attributes:
- **sym_operations**: List of fractional SymmOp objects.
- **element_set**: Set of species to clone.
"""
[docs]
def __init__(
self,
sym_operations: list[SymmOp] | Structure,
element_list: list[SpeciesLike]):
"""Create structure with sites cloned according to symmetry.
Args:
sym_operations: List of SymmOp *fractional* operations to be
applied or a reference Structure to be used to generate the
operations.
element_list: List of species to be cloned.
"""
self.element_set = set(map(get_el_sp, element_list))
if isinstance(sym_operations, Structure):
analyzer = SpacegroupAnalyzer(sym_operations)
# Fractional
self.sym_operations = analyzer.get_symmetry_operations()
elif (isinstance(sym_operations, list) and
isinstance(sym_operations[0], SymmOp)):
self.sym_operations = sym_operations
else:
raise ValueError(
"sym_operations must be Structure of a list of SymmOp")
[docs]
def apply_transformation(
self,
structure: Structure | str,
_return_ranked_list: bool | int = False):
"""Apply the symmetry fill transformation.
Args:
structure: Structure to fill, or path to a structure file.
_return_ranked_list: Unused (one-to-one transformation).
Returns:
Structure: Filled structure. Each cloned site has a
``symop`` property set to the symmetry operation used.
"""
all_elements = map(get_el_sp, structure.species)
elements_to_remove = set(all_elements) - self.element_set
filled_structure = structure.copy()
identity_op = SymmOp.from_xyz_str('x,y,z')
for site in filled_structure:
site.properties.update({'symop': identity_op})
clean_structure = structure.copy()
clean_structure.remove_species(list(elements_to_remove))
for op in self.sym_operations:
tmp_structure = clean_structure.copy()
tmp_structure.apply_operation(op, fractional=True)
for site in tmp_structure:
props = site.properties
props.update({'symop': op})
try:
filled_structure.append(
site.species, site.coords,
coords_are_cartesian=True,
properties=props, validate_proximity=True)
except ValueError:
# Too close, skip.
pass
return filled_structure
[docs]
def apply_operation_keep_lattice(structure, op):
"""Apply a symmetry operation while preserving lattice vectors.
The modified structure will have atom-to-atom match and all
fractional coordinates normalized within 0..1 range.
Args:
structure: Structure to transform.
op: SymmOp to apply.
Returns:
Structure: Modified copy with unchanged lattice.
"""
tmp = structure.copy()
tmp.apply_operation(op, fractional=True)
# Operation might change the lattice vectors.
# Force them back into STRUCTURE by enforcing periodic
# conditions
result = structure.copy()
result.remove_sites(indices=range(len(result))) # empty
for site in tmp:
result.append(
site.species, site.coords,
coords_are_cartesian=True,
properties=site.properties)
for site in result:
site.to_unit_cell(in_place=True)
# Make life easier for the callees. Align sites
# between clone and structure
props = result.properties # preserve properties
result = get_matched_structure(structure, result)
result.properties = props
result.properties['symop'] = op
return result
def _structure_distance_wrapper(args):
return structure_distance(*args)
[docs]
class SymmetryCloneTransformation(AbstractTransformation):
"""Generate symmetrically equivalent clones of a structure.
Applies all symmetry operations to produce a list of distinct
configurations, filtering duplicates by structure distance.
Attributes:
sym_operations: List of fractional SymmOp objects.
tol: Distance threshold for considering two clones equivalent.
filter_cls: Optional filter with ``filter`` and ``final_filter``
methods.
"""
[docs]
def __init__(
self,
sym_operations: list[SymmOp] | Structure,
filter_cls=None,
tol: float = 0.5
):
"""Initialise symmetry clone transformation.
Args:
sym_operations: List of fractional SymmOp objects, or a
Structure used to derive them via
``SpacegroupAnalyzer``.
filter_cls: Optional filter object. Must implement
``filter(trial, clones) -> bool`` and may implement
``final_filter(clones) -> list``.
tol: Distance tolerance for equivalence. Two clones are
considered identical if the sum of site distances is
below ``tol``.
"""
self.tol = tol
self.filter_cls = filter_cls
if isinstance(sym_operations, Structure):
analyzer = SpacegroupAnalyzer(sym_operations)
# Fractional
self.sym_operations = analyzer.get_symmetry_operations()
elif (isinstance(sym_operations, list) and
isinstance(sym_operations[0], SymmOp)):
self.sym_operations = sym_operations
else:
raise ValueError(
"sym_operations must be Structure of a list of SymmOp")
[docs]
def get_all_clones(
self,
structure: Structure,
progress_bar: bool = True,
multithread: bool = False) -> list[Structure]:
"""Generate all distinct symmetry clones of a structure.
Clones are sorted by distance from the input structure.
Args:
structure: Structure to clone.
progress_bar: Whether to display a progress bar.
multithread: Whether to use multithreading for duplicate
detection.
Returns:
list[Structure]: Distinct clones with one-to-one site
matching and ``symop`` properties.
"""
clones = []
def _member(structure, clones):
"""Check whether a structure is equivalent to any in a list.
Returns:
bool: True if the structure is within ``self.tol`` of
any clone.
"""
# For some reason, Python sometimes hangs here when there
# are too few CLONES.
# FIXME: Multithreaded version appear to be slower in practice.
# if multithread and len(clones) > cpu_count():
# with Pool() as pool:
# distances = pool.imap_unordered(
# _structure_distance_wrapper,
# [(structure, clone, 0.1, False, self.tol)
# for clone in clones]
# )
# for dist in distances:
# if dist < self.tol:
# pool.terminate()
# return True
for clone in clones:
dist = structure_distance(
structure, clone, match_first=False, max_dist=self.tol)
if dist < self.tol:
return True
return False
with alive_bar(len(self.sym_operations),
title='Searching clones',
disable=not progress_bar) as progress_bar:
for op in self.sym_operations:
progress_bar() # pylint: disable=not-callable
clone = structure.copy()
clone = apply_operation_keep_lattice(clone, op)
if not _member(clone, clones)\
and (self.filter_cls is None
or self.filter_cls.filter(clone, clones)):
assert clone.is_valid(), "Given symmetry operations lead to invalid structure"
clones.append(clone)
# Apply additional filters
if self.filter_cls is not None and\
getattr(self.filter_cls, "final_filter", False):
clones = self.filter_cls.final_filter(clones)
# Sort structures by distance from reference STRUCTURE
clones = sorted(
clones,
key=lambda clone: structure_distance(clone, structure, match_first=False))
return clones
[docs]
def apply_transformation(
self,
structure: Structure | str,
return_ranked_list: bool | int = False):
"""Apply symmetry clone transformation.
Args:
structure: Structure to clone, or path to a structure file.
return_ranked_list: If an int, return that many structures
as ranked dictionaries.
Returns:
Structure or list[dict]: Single clone when
``return_ranked_list`` is False, otherwise a list of
``{'structure': ...}`` dictionaries.
"""
all_clones = self.get_all_clones(structure)
if not return_ranked_list:
return all_clones[0]
return [{"structure": structure}
for structure in all_clones[:return_ranked_list]]
@property
def is_one_to_many(self) -> bool:
"""Whether the transformation is one-to-many (always True)."""
return True