import copy
import json
import typing as t
import numpy as np
import itertools
from aiida import orm
from aiida.common.constants import elements
from aiida_atomistic.data.structure.site import Site
from aiida_atomistic.data.structure.models import MutableStructureModel
from aiida_atomistic.data.structure.hubbard_mixin import (
HubbardGetterMixin,
)
from aiida_atomistic.data.structure.utils import classify_site_kinds, check_kinds_match, efficient_copy
try:
import ase # noqa: F401
from ase import io as ase_io
ASE_ATOMS_TYPE = ase.Atoms
except ImportError:
has_ase = False
ASE_ATOMS_TYPE = t.Any
try:
import pymatgen.core as core # noqa: F401
PYMATGEN_MOLECULE = core.structure.Molecule
PYMATGEN_STRUCTURE = core.structure.Structure
except ImportError:
has_pymatgen = False
PYMATGEN_MOLECULE = t.Any
PYMATGEN_STRUCTURE = t.Any
[docs]
_MASS_THRESHOLD = 1.0e-3
# Threshold to check if the sum is one or not
[docs]
_SUM_THRESHOLD = 1.0e-6
# Default cell
[docs]
_DEFAULT_CELL = ((0, 0, 0),) * 3
[docs]
_valid_symbols = tuple(i["symbol"] for i in elements.values())
[docs]
_atomic_masses = {el["symbol"]: el["mass"] for el in elements.values()}
[docs]
_atomic_numbers = {data["symbol"]: num for num, data in elements.items()}
from . import _GLOBAL_PROPERTIES, _COMPUTED_PROPERTIES
[docs]
_DEFAULT_THRESHOLDS = {
"charges": 0.1,
"masses": 1e-4,
"magmoms": 1e-4, # _MAGMOM_THRESHOLD
}
[docs]
class GetterMixin(HubbardGetterMixin):
# Start redundant properties: This is mainly for make easier migration of plugins.
@property
[docs]
def cell(self):
return self.properties.cell
@property
[docs]
def pbc(self):
return self.properties.pbc
@property
[docs]
def sites(self):
return self.properties.sites
@property
[docs]
def kinds(self):
return self.properties.kinds
@property
[docs]
def is_alloy(self):
return self.properties.is_alloy
@property
[docs]
def has_vacancies(self):
return self.properties.has_vacancies
@property
# End redundant properties
@staticmethod
[docs]
def get_supported_properties():
"""
Get a dictionary of global and site properties that can be set
for this structure.
"""
structure_fields = set(MutableStructureModel.model_fields.keys())
site_fields = set(Site.model_fields.keys())
return {
'global': structure_fields,
'site': site_fields
}
[docs]
def get_defined_properties(self):
"""
Retrieve the defined properties of the structure, categorized into direct, computed, and site-specific properties.
Args:
exclude_computed (bool): If False, all properties will be returned, including those computed after the initialization (the pydantic computed fields).
exclude_defaults (bool): If True, properties with default values will be excluded from the result.
"""
return set(self.properties.model_dump(exclude_unset=True, exclude_none=True, warnings=False).keys()).difference(_COMPUTED_PROPERTIES)
[docs]
def get_kind_names(self):
"""Return a list of the kind names defined in this structure."""
return list(set(self.properties.kind_names))
[docs]
def get_kind(self, kind_name: str = None):
"""Return a given kind."""
for kind in self.kinds:
if kind.kind_name == kind_name:
return kind
@property
[docs]
def is_collinear(self):
# if not magmoms, is can be collinear if magnetizations are provided (just quantum number)
# if magmoms, we check that the rank of the magmoms matrix is one (if not, it is not collinear)
if self.properties.magmoms is None:
return False
if self.properties.magnetizations is not None:
return True
return np.linalg.matrix_rank(self.properties.magmoms) == 1
# initialization methods
@classmethod
[docs]
def from_ase(
cls,
aseatoms: ASE_ATOMS_TYPE,
detect_kinds: bool = False):
"""Load the structure from a ASE object"""
if not has_ase:
raise ImportError("The ASE package cannot be imported.")
# Read the ase structure
data = {}
data["cell"] = aseatoms.cell.array.tolist()
data["pbc"] = aseatoms.pbc.tolist()
data["sites"] = []
# self.clear_kinds() # This also calls clear_sites
for atom in aseatoms:
new_site = Site.from_ase_atom(aseatom=atom)
data["sites"].append(new_site.model_dump(exclude={"kind_name"} if not detect_kinds else None))
structure = cls(**data)
return structure
@classmethod
[docs]
def from_file(
cls,
filename,
format="cif",
detect_kinds: bool = False,
**kwargs):
"""Load the structure from a file."""
if format == 'mcif' or '.mcif' in filename:
# in this case, we use pymatgen parser, because the ase one does not work properly for now.
from pymatgen.io.cif import CifParser
parser = CifParser(filename)
mcif_structure = parser.get_structures(**kwargs)[0]
return cls.from_pymatgen(pymatgen_obj=mcif_structure, detect_kinds=detect_kinds)
else:
ase_read = ase_io.read(filename, format=format, **kwargs)
return cls.from_ase(aseatoms=ase_read, detect_kinds=detect_kinds)
@classmethod
[docs]
def from_pymatgen(
cls,
pymatgen_obj: t.Union[PYMATGEN_MOLECULE, PYMATGEN_STRUCTURE],
detect_kinds: bool = False,
**kwargs,
):
"""Load the structure from a pymatgen object.
.. note:: Requires the pymatgen module (version >= 3.0.13, usage
of earlier versions may cause errors).
"""
if not has_pymatgen:
raise ImportError("The pymatgen package cannot be imported.")
if isinstance(pymatgen_obj, PYMATGEN_MOLECULE):
structure = cls._from_pymatgen_molecule(pymatgen_obj, detect_kinds=detect_kinds)
else:
structure = cls._from_pymatgen_structure(pymatgen_obj, detect_kinds=detect_kinds)
return structure
@classmethod
[docs]
def _from_pymatgen_molecule(
cls,
mol: PYMATGEN_MOLECULE,
margin=5,
detect_kinds: bool = False,
):
"""Load the structure from a pymatgen Molecule object.
:param margin: the margin to be added in all directions of the
bounding box of the molecule.
.. note:: Requires the pymatgen module (version >= 3.0.13, usage
of earlier versions may cause errors).
"""
box = [
max(x.coords.tolist()[0] for x in mol.properties.sites)
- min(x.coords.tolist()[0] for x in mol.properties.sites)
+ 2 * margin,
max(x.coords.tolist()[1] for x in mol.properties.sites)
- min(x.coords.tolist()[1] for x in mol.properties.sites)
+ 2 * margin,
max(x.coords.tolist()[2] for x in mol.properties.sites)
- min(x.coords.tolist()[2] for x in mol.properties.sites)
+ 2 * margin,
]
structure = cls._from_pymatgen_structure(mol.get_boxed_structure(*box), detect_kinds=detect_kinds)
structure.properties.pbc = [False, False, False]
return structure
@classmethod
[docs]
def _from_pymatgen_structure(
cls,
struct: PYMATGEN_STRUCTURE,
detect_kinds: bool = False,
):
"""Load the structure from a pymatgen Structure object.
.. note:: periodic boundary conditions are set to True in all
three directions.
.. note:: Requires the pymatgen module (version >= 3.3.5, usage
of earlier versions may cause errors).
:raise ValueError: if there are partial occupancies together with spins.
"""
def build_kind_name(species_and_occu):
"""Build a kind name from a pymatgen Composition, including an additional ordinal if spin is included,
e.g. it returns '<specie>1' for an atom with spin < 0 and '<specie>2' for an atom with spin > 0,
otherwise (no spin) it returns None
:param species_and_occu: a pymatgen species and occupations dictionary
:return: a string representing the kind name or None
"""
species = list(species_and_occu.keys())
occupations = list(species_and_occu.values())
# As of v2023.9.2, the ``properties`` argument is removed and the ``spin`` argument should be used.
# See: https://github.com/materialsproject/pymatgen/commit/118c245d6082fe0b13e19d348fc1db9c0d512019
# The ``spin`` argument was introduced in v2023.6.28.
# See: https://github.com/materialsproject/pymatgen/commit/9f2b3939af45d5129e0778d371d814811924aeb6
has_spin_attribute = hasattr(species[0], "_spin")
if has_spin_attribute:
has_spin = any(specie.spin != 0 for specie in species)
else:
has_spin = any(
specie.as_dict().get("properties", {}).get("spin", 0) != 0
for specie in species
)
has_partial_occupancies = len(occupations) != 1 or occupations[0] != 1.0
if has_partial_occupancies and has_spin:
raise ValueError(
"Cannot set partial occupancies and spins at the same time"
)
if has_spin:
from aiida_atomistic.data.structure.utils import create_automatic_kind_name
symbols = [specie.symbol for specie in species]
kind_name = create_automatic_kind_name(symbols, occupations)
# If there is spin, we can only have a single specie, otherwise we would have raised above
specie = species[0]
if has_spin_attribute:
spin = specie.spin
else:
spin = specie.as_dict().get("properties", {}).get("spin", 0)
if spin < 0:
kind_name += "1"
else:
kind_name += "2"
return kind_name
return None
inputs = {}
inputs["cell"] = struct.lattice.matrix.tolist()
inputs["pbc"] = [True, True, True]
# self.clear_kinds()
inputs["sites"] = []
sites_collection = struct.properties["sites"] if "sites" in struct.properties.keys() else struct.sites
for site in sites_collection:
site_info = {
"symbol": site.specie.symbol,
"mass": site.species.weight,
"position": site.coords.tolist(),
'magmom': site.properties.get("magmom").moment if "magmom" in site.properties.keys() else None
}
if site.properties.get('kinds', None) is not None:
site_info["kind_name"] = site.properties.get('kinds').replace("+", "").replace("-", "")
if bool(site.properties.get('charge', None)):
site_info["charge"] = site.properties.get("charge")
if site.properties.get('magmom', None) is not None:
magmom = site.properties.get("magmom").moment
if isinstance(magmom, (int, float)):
if magmom != 0:
site_info['magnetization'] = magmom
elif isinstance(magmom, (list, np.ndarray)):
if np.linalg.norm(magmom) > 0:
site_info['magmom'] = magmom
inputs["sites"].append(site_info)
structure = cls(**inputs)
return structure
# method for the kinds generation and validation
[docs]
def generate_kinds(self, tolerance:t.Union[dict, float]=1e-3):
sites = self.to_dict()['sites']
groups = classify_site_kinds(sites, tolerance=tolerance)
kinds = []
kind_names = []
for i, (key, group) in enumerate(groups.items()):
for l in range(i+1):
kind_name = f"{group['properties']['symbol']}{l+1}"
if kind_name not in kind_names:
kind_names.append(kind_name)
break
else:
continue
site_indices = group['sites']
properties = group['properties']
positions = group['positions']
properties['kind_name'] = kind_name
kind = {
'site_indices': site_indices,
'positions': positions,
**properties
}
kinds.append(kind)
return kinds
[docs]
def validate_kinds(self,):
if not self.kinds:
raise ValueError("No kinds defined in the structure.")
generated_kinds = self.generate_kinds()
check_kinds = check_kinds_match(self, generated_kinds)
if not check_kinds:
raise ValueError("The kinds defined in the structure do not match the generated kinds from the sites. Please run the 'generate_kinds' method to see the expected kinds.")
# TO methods:
[docs]
def to_dict(self, exclude_kinds=False):
"""
Convert the structure to a dictionary representation.
:param detect_kinds: Whether to detect and include the kinds of the structure.
:type detect_kinds: bool, optional
:return: The structure as a dictionary.
:rtype: dict
"""
dict_repr = efficient_copy(self.properties.model_dump(exclude_unset=True, exclude_none=True, warnings=False, exclude={'kinds'} if exclude_kinds else {}))
return dict_repr
[docs]
def to_kinds_based(self, tolerance:t.Union[dict, float]=1e-3):
"""
Convert the structure to a kinds-based representation.
:param tolerance: Tolerance for grouping sites into kinds. Can be a float or a dictionary specifying tolerances for specific properties.
:type tolerance: float or dict, optional
:return: The structure as a dictionary with kinds.
:rtype: dict
"""
dict_repr = self.to_dict(exclude_kinds=True)
dict_repr['kinds'] = self.generate_kinds(tolerance=tolerance)
dict_repr.pop('sites', None)
return self.__class__(**dict_repr)
[docs]
def get_cif(self, converter="ase", store=False, **kwargs):
"""Creates :py:class:`aiida.orm.nodes.data.cif.CifData`.
:param converter: specify the converter. Default 'ase'.
:param store: If True, intermediate calculation gets stored in the
AiiDA database for record. Default False.
:return: :py:class:`aiida.orm.nodes.data.cif.CifData` node.
"""
from aiida.tools.data import structure as structure_tools
param = orm.Dict(kwargs)
try:
conv_f = getattr(structure_tools, f"_get_cif_{converter}_inline")
except AttributeError:
raise ValueError(f"No such converter '{converter}' available")
ret_dict = conv_f(
struct=self, parameters=param, metadata={"store_provenance": store}
)
return ret_dict["cif"]
[docs]
def get_description(self):
"""Returns a string with infos retrieved from StructureData node's properties
:param self: the StructureData node
:return: retsrt: the description string
"""
return self.get_formula(mode="hill_compact")
[docs]
def get_composition(self, mode="full"):
"""Returns the chemical composition of this structure as a dictionary,
where each key is the kind symbol (e.g. H, Li, Ba),
and each value is the number of occurences of that element in this
structure.
:param mode: Specify the mode of the composition to return. Choose from ``full``, ``reduced`` or ``fractional``.
For example, given the structure with formula Ba2Zr2O6, the various modes operate as follows.
``full``: The default, the counts are left unnnormalized.
``reduced``: The counts are renormalized to the greatest common denominator.
``fractional``: The counts are renormalized such that the sum equals 1.
:returns: a dictionary with the composition
"""
import numpy as np
symbols_list = self.properties.symbols
symbols_set = set(symbols_list)
if mode == "full":
return {symbol: symbols_list.count(symbol) for symbol in symbols_set}
if mode == "reduced":
gcd = np.gcd.reduce([symbols_list.count(symbol) for symbol in symbols_set])
return {
symbol: (symbols_list.count(symbol) / gcd) for symbol in symbols_set
}
if mode == "fractional":
sum_comp = sum(symbols_list.count(symbol) for symbol in symbols_set)
return {
symbol: symbols_list.count(symbol) / sum_comp for symbol in symbols_set
}
raise ValueError(
f"mode `{mode}` is invalid, choose from `full`, `reduced` or `fractional`."
)
[docs]
def to_ase(self):
"""Get the ASE object.
Requires to be able to import ase.
:return: an ASE object corresponding to this
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
object.
.. note:: If any site is an alloy or has vacancies, a ValueError
is raised (from the site.to_ase() routine).
"""
if not has_ase:
raise ImportError("The ASE package cannot be imported.")
return self._get_object_ase()
[docs]
def to_pymatgen(self, **kwargs):
"""Get pymatgen object. Returns pymatgen Structure for structures with periodic boundary conditions
(in 1D, 2D, 3D) and Molecule otherwise.
:param add_spin: True to add the spins to the pymatgen structure.
Default is False (no spin added).
.. note:: The spins are set according to the following rule:
* if the kind name ends with 1 -> spin=+1
* if the kind name ends with 2 -> spin=-1
.. note:: Requires the pymatgen module (version >= 3.0.13, usage
of earlier versions may cause errors).
"""
if not has_pymatgen:
raise ImportError("The pymatgen package cannot be imported.")
return self._get_object_pymatgen(**kwargs)
[docs]
def to_file(self, filename=None, format="cif"):
"""Writes the structure to a file.
Args:
filename (_type_, optional): defaults to None.
format (str, optional): defaults to "cif".
Raises:
ValueError: should provide a filename different from None.
"""
if not has_ase:
raise ImportError("The ASE package cannot be imported.")
if not filename:
raise ValueError("Please provide a valid filename.")
aseatoms = self.to_ase()
ase_io.write(filename, aseatoms, format=format)
return
'''def to_legacy(self) -> LegacyStructureData:
"""
Returns: orm.StructureData object, used for backward compatibility.
"""
if not has_ase:
raise ImportError("The ASE package cannot be imported.")
aseatoms = self.to_ase()
return LegacyStructureData(ase=aseatoms)
'''
[docs]
def get_pymatgen_structure(self, **kwargs):
"""Get the pymatgen Structure object with any PBC, provided the cell is not singular.
:param add_spin: True to add the spins to the pymatgen structure.
Default is False (no spin added).
.. note:: The spins are set according to the following rule:
* if the kind name ends with 1 -> spin=+1
* if the kind name ends with 2 -> spin=-1
.. note:: Requires the pymatgen module (version >= 3.0.13, usage
of earlier versions may cause errors).
:return: a pymatgen Structure object corresponding to this
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
object.
:raise ValueError: if the cell is singular, e.g. when it has not been set.
Use `get_pymatgen_molecule` instead, or set a proper cell.
"""
return self._get_object_pymatgen_structure(**kwargs)
[docs]
def get_pymatgen_molecule(self):
"""Get the pymatgen Molecule object.
.. note:: Requires the pymatgen module (version >= 3.0.13, usage
of earlier versions may cause errors).
:return: a pymatgen Molecule object corresponding to this
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
object.
"""
return self._get_object_pymatgen_molecule()
[docs]
def _prepare_xsf(self, main_file_name=""):
"""Write the given structure to a string of format XSF (for XCrySDen)."""
if self.is_alloy or self.has_vacancies:
raise NotImplementedError(
"XSF for alloys or systems with vacancies not implemented."
)
sites = self.properties.sites
return_string = "CRYSTAL\nPRIMVEC 1\n"
for cell_vector in self.properties.cell:
return_string += " ".join([f"{i:18.10f}" for i in cell_vector])
return_string += "\n"
return_string += "PRIMCOORD 1\n"
return_string += f"{int(len(sites))} 1\n"
for site in sites:
# I checked above that it is not an alloy, therefore I take the
# first symbol
return_string += (
f"{_atomic_numbers[site.symbols]} "
)
return_string += "%18.10f %18.10f %18.10f\n" % tuple(site.position)
return return_string.encode("utf-8"), {}
[docs]
def _prepare_cif(self, main_file_name=""):
"""Write the given structure to a string of format CIF."""
from aiida.orm import CifData
cif = CifData(ase=self.to_ase())
return cif._prepare_cif()
[docs]
def _prepare_chemdoodle(self, main_file_name=""):
"""Write the given structure to a string of format required by ChemDoodle."""
from itertools import product
from aiida_atomistic.data.structure.utils import atom_kinds_to_html
import numpy as np
supercell_factors = [1, 1, 1]
# Get cell vectors and atomic position
lattice_vectors = np.array(self.base.attributes.get("cell"))
base_sites = self.sites
start1 = -int(supercell_factors[0] / 2)
start2 = -int(supercell_factors[1] / 2)
start3 = -int(supercell_factors[2] / 2)
stop1 = start1 + supercell_factors[0]
stop2 = start2 + supercell_factors[1]
stop3 = start3 + supercell_factors[2]
grid1 = range(start1, stop1)
grid2 = range(start2, stop2)
grid3 = range(start3, stop3)
atoms_json = []
# Manual recenter of the structure
center = (lattice_vectors[0] + lattice_vectors[1] + lattice_vectors[2]) / 2.0
for ix, iy, iz in product(grid1, grid2, grid3):
for base_site in base_sites:
shift = (
ix * lattice_vectors[0]
+ iy * lattice_vectors[1]
+ iz * lattice_vectors[2]
- center
).tolist()
kind_name = base_site.kinds
kind_string = base_site.symbols
atoms_json.append(
{
"l": kind_string,
"x": np.array(base_site.positions[0]) + shift[0],
"y": np.array(base_site.positions[1]) + shift[1],
"z": np.array(base_site.positions[2]) + shift[2],
"atomic_elements_html": atom_kinds_to_html(kind_string),
}
)
cell_json = {
"t": "UnitCell",
"i": "s0",
"o": (-center).tolist(),
"x": (lattice_vectors[0] - center).tolist(),
"y": (lattice_vectors[1] - center).tolist(),
"z": (lattice_vectors[2] - center).tolist(),
"xy": (lattice_vectors[0] + lattice_vectors[1] - center).tolist(),
"xz": (lattice_vectors[0] + lattice_vectors[2] - center).tolist(),
"yz": (lattice_vectors[1] + lattice_vectors[2] - center).tolist(),
"xyz": (
lattice_vectors[0] + lattice_vectors[1] + lattice_vectors[2] - center
).tolist(),
}
return_dict = {"s": [cell_json], "m": [{"a": atoms_json}], "units": "Å"}
return json.dumps(return_dict).encode("utf-8"), {}
[docs]
def _prepare_xyz(self, main_file_name=""):
"""Write the given structure to a string of format XYZ."""
if self.is_alloy or self.has_vacancies:
raise NotImplementedError(
"XYZ for alloys or systems with vacancies not implemented."
)
sites = self.properties.sites
cell = self.properties.cell
return_list = [f"{len(sites)}"]
return_list.append(
'Lattice="{} {} {} {} {} {} {} {} {}" pbc="{} {} {}"'.format(
cell[0][0],
cell[0][1],
cell[0][2],
cell[1][0],
cell[1][1],
cell[1][2],
cell[2][0],
cell[2][1],
cell[2][2],
self.properties.pbc[0],
self.properties.pbc[1],
self.properties.pbc[2],
)
)
for site in sites:
# I checked above that it is not an alloy, therefore I take the
# first symbol
return_list.append(
"{:6s} {:18.10f} {:18.10f} {:18.10f}".format(
site.symbols,
site.position[0],
site.position[1],
site.position[2],
)
)
return_string = "\n".join(return_list)
return return_string.encode("utf-8"), {}
[docs]
def _parse_xyz(self, inputstring):
"""Read the structure from a string of format XYZ."""
from aiida.tools.data.structure import xyz_parser_iterator
# idiom to get to the last block
atoms = None
for _, _, atoms in xyz_parser_iterator(inputstring):
pass
if atoms is None:
raise TypeError("The data does not contain any XYZ data")
#self.clear_kinds()
self.properties.pbc = (False, False, False)
for sym, position in atoms:
self.add_atom(atom_info={'symbols':sym, 'positions':position})
[docs]
def _adjust_default_cell(
self, vacuum_factor=1.0, vacuum_addition=10.0, pbc=(False, False, False)
):
"""If the structure was imported from an xyz file, it lacks a cell.
This method will adjust the cell
"""
import numpy as np
def get_extremas_from_positions(positions):
"""Returns the minimum and maximum value for each dimension in the positions given"""
return list(
zip(*[(min(values), max(values)) for values in zip(*positions)])
)
# Calculating the minimal cell:
positions = np.array([site.positions for site in self.properties.sites])
position_min, _ = get_extremas_from_positions(positions)
# Translate the structure to the origin, such that the minimal values in each dimension
# amount to (0,0,0)
positions -= position_min
for index, site in enumerate(self.sites):
site.positions = list(positions[index])
# The orthorhombic cell that (just) accomodates the whole structure is now given by the
# extremas of position in each dimension:
minimal_orthorhombic_cell_dimensions = np.array(
get_extremas_from_positions(positions)[1]
)
minimal_orthorhombic_cell_dimensions = np.dot(
vacuum_factor, minimal_orthorhombic_cell_dimensions
)
minimal_orthorhombic_cell_dimensions += vacuum_addition
# Transform the vector (a, b, c ) to [[a,0,0], [0,b,0], [0,0,c]]
newcell = np.diag(minimal_orthorhombic_cell_dimensions)
self.set_cell(newcell.tolist())
# Now set PBC (checks are done in set_pbc, no need to check anything here)
self.set_pbc(pbc)
return self
[docs]
def _get_object_phonopyatoms(self):
"""Converts StructureData to PhonopyAtoms
:return: a PhonopyAtoms object
"""
from phonopy.structure.atoms import PhonopyAtoms
atoms = PhonopyAtoms(
symbols = self.properties.symbols,
masses = self.properties.masses,
magnetic_moments = self.properties.magmoms,
positions = self.properties.positions,
cell = self.cell,
pbc = self.pbc,
)
return atoms
[docs]
def _get_object_ase(self):
"""Converts
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
to ase.Atoms
:return: an ase.Atoms object
"""
import ase
asecell = ase.Atoms(
cell=self.properties.cell,
pbc=self.properties.pbc,
)
for site in self.properties.sites:
asecell.append(site.to_ase())
# asecell.set_initial_charges(self.get_site_property("charge"))
return asecell
[docs]
def _get_object_pymatgen(self, **kwargs):
"""Converts
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
to pymatgen object
:return: a pymatgen Structure for structures with periodic boundary
conditions (in three dimensions) and Molecule otherwise
.. note:: Requires the pymatgen module (version >= 3.0.13, usage
of earlier versions may cause errors).
"""
if any(self.properties.pbc):
return self._get_object_pymatgen_structure(**kwargs)
return self._get_object_pymatgen_molecule(**kwargs)
[docs]
def _get_object_pymatgen_structure(self, **kwargs):
"""Converts
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
to pymatgen Structure object
:param add_spin: True to add the spins to the pymatgen structure.
Default is False (no spin added).
.. note:: The spins are set according to the following rule:
* if the kind name ends with 1 -> spin=+1
* if the kind name ends with 2 -> spin=-1
:return: a pymatgen Structure object corresponding to this
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
object
:raise ValueError: if the cell is not set (i.e. is the default one);
if there are partial occupancies together with spins
(defined by kind names ending with '1' or '2').
.. note:: Requires the pymatgen module (version >= 3.0.13, usage
of earlier versions may cause errors)
"""
from pymatgen.core.lattice import Lattice
from pymatgen.core.periodic_table import Specie
from pymatgen.core.structure import Structure
species = []
additional_kwargs = {}
lattice = Lattice(matrix=self.properties.cell, pbc=self.properties.pbc)
if kwargs.pop("add_spin", False) and any(
n.endswith("1") or n.endswith("2") for n in self.get_kind_names()
):
# case when spins are defined -> no partial occupancy allowed
oxidation_state = 0 # now I always set the oxidation_state to zero
for site in self.properties.sites:
kind = site.kinds
if len(kind.symbols) != 1 or (
len(kind.weights) != 1 or sum(kind.weights) < 1.0
):
raise ValueError(
"Cannot set partial occupancies and spins at the same time"
)
spin = (
-1
if site.kinds.endswith("1")
else 1
if site.kinds.endswith("2")
else 0
)
try:
specie = Specie(
kind.symbols[0], oxidation_state, properties={"spin": spin}
)
except TypeError:
# As of v2023.9.2, the ``properties`` argument is removed and the ``spin`` argument should be used.
# See: https://github.com/materialsproject/pymatgen/commit/118c245d6082fe0b13e19d348fc1db9c0d512019
# The ``spin`` argument was introduced in v2023.6.28.
# See: https://github.com/materialsproject/pymatgen/commit/9f2b3939af45d5129e0778d371d814811924aeb6
specie = Specie(kind.symbols[0], oxidation_state, spin=spin)
species.append(specie)
else:
# case when no spin are defined
for site in self.properties.sites:
kind = site.kind_name
specie = Specie(
site.symbol,
site.charge,
) # spin)
species.append(specie)
# if any(
# create_automatic_kind_name(self.get_kind(name).symbols, self.get_kind(name).weights) != name
# for name in self.get_site_property("kinds")
# ):
# add "kinds" as a properties to each site, whenever
# the kinds cannot be automatically obtained from the symbols
additional_kwargs["site_properties"] = {
"kinds": self.properties.kind_names,
"charge": self.properties.charges,
"magmom": self.properties.magmoms
}
if kwargs:
raise ValueError(
f"Unrecognized parameters passed to pymatgen converter: {kwargs.keys()}"
)
positions = [list(site.position) for site in self.properties.sites]
try:
return Structure(
lattice,
species,
positions,
coords_are_cartesian=True,
**additional_kwargs,
)
except ValueError as err:
raise ValueError(
"Singular cell detected. Probably the cell was not set?"
) from err
[docs]
def _get_object_pymatgen_molecule(self, **kwargs):
"""Converts
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
to pymatgen Molecule object
:return: a pymatgen Molecule object corresponding to this
:py:class:`StructureData <aiida.orm.nodes.data.structure.StructureData>`
object.
.. note:: Requires the pymatgen module (version >= 3.0.13, usage
of earlier versions may cause errors)
"""
from pymatgen.core.structure import Molecule
if kwargs:
raise ValueError(
f"Unrecognized parameters passed to pymatgen converter: {kwargs.keys()}"
)
species = []
additional_kwargs = {}
for site in self.properties.sites:
if hasattr(site, "weights"):
weight = site.weights
else:
weight = 1
species.append({site.symbols: weight})
positions = [list(site.positions) for site in self.properties.sites]
mol = Molecule(species, positions)
additional_kwargs["site_properties"] = {
"kinds": self.properties.kind_names,
"charge": self.properties.charges,
"magmom": self.properties.magmoms
}
for prop,value in additional_kwargs.items():
mol.add_site_property(prop, value)
[docs]
def _get_dimensionality(
self,
):
"""Return the dimensionality of the structure and its length/surface/volume.
Zero-dimensional structures are assigned "volume" 0.
:return: returns a dictionary with keys "dim" (dimensionality integer), "label" (dimensionality label)
and "value" (numerical length/surface/volume).
"""
import numpy as np
retdict = {}
pbc = np.array(self.properties.pbc)
cell = np.array(self.properties.cell)
dim = len(pbc[pbc])
retdict["dim"] = dim
retdict["label"] = self._dimensionality_label[dim]
if dim not in (0, 1, 2, 3):
raise ValueError(f"Dimensionality {dim} must be one of 0, 1, 2, 3")
if dim == 0:
# We have no concept of 0d volume. Let's return a value of 0 for a consistent output dictionary
retdict["value"] = 0
elif dim == 1:
retdict["value"] = np.linalg.norm(cell[pbc])
elif dim == 2:
vectors = cell[pbc]
retdict["value"] = np.linalg.norm(np.cross(vectors[0], vectors[1]))
elif dim == 3:
from aiida_atomistic.data.structure.utils import calc_cell_volume
retdict["value"] = calc_cell_volume(cell)
return retdict
[docs]
def _validate_dimensionality(
self,
):
"""Check whether the given pbc and cell vectors are consistent."""
dim = self._get_dimensionality()
# 0-d structures put no constraints on the cell
if dim["dim"] == 0:
return
# finite-d structures should have a cell with finite volume
if dim["value"] == 0:
raise ValueError(
f'Structure has periodicity {self.properties.pbc} but {dim["dim"]}-d volume 0.'
)
return
[docs]
def get_symbols_set(self):
"""Return the set of unique chemical symbols in the structure."""
return set(self.properties.symbols)
[docs]
def __len__(
self,
):
return len(self.properties.sites)