"""This module provides a class to extract geometry and neighbor information.
Todo:
* distortion of geometry e.g. elongated along an axis
"""
from __future__ import annotations
from collections import defaultdict
from typing import Any
import numpy as np
from pymatgen.analysis.graphs import StructureGraph
from pymatgen.core.composition import Composition
from pymatgen.core.periodic_table import get_el_sp
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
from pymatgen.util.coord import get_angle
from pymatgen.util.string import formula_double_format
from robocrys.condense.fingerprint import get_site_fingerprints
from robocrys.util import connected_geometries, defaultdict_to_dict, get_el
[docs]class SiteAnalyzer:
"""Class to extract information on site geometry and bonding.
Attributes:
symmetry_labels: A :obj:`dict` mapping the site indices to the symmetry
label for that site. If two sites are symmetrically equivalent they
share the same symmetry label. The numbering begins at 1 for each
element in the structure.
equivalent_sites: A :obj:`list` of indices mapping each site in
the structure to a symmetrically or structurally equivalent site,
depending on the value of ``use_symmetry_equivalent_sites``.
Args:
bonded_structure: A bonded structure with nearest neighbor data
included. For example generated using
:class:`pymatgen.analysis.local_env.CrystalNN` or
:class:`pymatgen.analysis.local_env.VoronoiNN`.
use_symmetry_equivalent_sites: Whether to use symmetry to determine if
sites are inequivalent. If ``False``, the site geometry and (next)
nearest neighbor information will be used.
symprec: The tolerance used when determining the symmetry of
the structure. The symmetry can used both to determine if multiple
sites are symmetrically equivalent and to obtain the symmetry labels
for each site.
minimum_geometry_op: The minimum geometrical order parameter for a
geometry match to be returned.
use_iupac_formula (bool, optional): Whether to order formulas
by the iupac "electronegativity" series, defined in
Table VI of "Nomenclature of Inorganic Chemistry (IUPAC
Recommendations 2005)". This ordering effectively follows
the groups and rows of the periodic table, except the
Lanthanides, Actanides and hydrogen. If set to ``False``, the
elements will be ordered according to the electronegativity values.
"""
def __init__(
self,
bonded_structure: StructureGraph,
use_symmetry_equivalent_sites: bool = False,
symprec: float = 0.01,
minimum_geometry_op: float = 0.4,
use_iupac_formula: bool = True,
):
self.bonded_structure = bonded_structure
self.use_iupac_formula = use_iupac_formula
self.minimum_geometry_op = minimum_geometry_op
self.site_fingerprints = get_site_fingerprints(bonded_structure.structure)
sga = SpacegroupAnalyzer(bonded_structure.structure, symprec=symprec)
equivalent_sites = sga.get_symmetry_dataset().equivalent_atoms
if use_symmetry_equivalent_sites:
self.equivalent_sites = list(equivalent_sites)
else:
self.equivalent_sites = self._calculate_equivalent_sites()
self.symmetry_labels = self._calculate_symmetry_labels(equivalent_sites)
[docs] def get_site_geometry(self, site_index: int) -> dict[str, str | float]:
"""Gets the bonding geometry of a site.
For example, "octahedral" or "square-planar".
Args:
site_index: The site index (zero based).
Returns:
The site geometry information formatted at as::
{'type': geometry_type, 'likeness': order_parameter}
Where ``geometry_type`` is a :obj:`str` corresponding to the
geometry type (e.g. octahedral) and ``order_parameter`` is a
:obj:`float` indicating whether how close the geometry is to the
perfect geometry. If the largest geometrical order parameter falls
beneath :attr:`robocrys.site.SiteAnalyzer.minimum_geometry_op`, the
geometry type will be returned as "X-coordinate", where X is the
coordination number.
"""
# get fingerprint as a list of tuples, e.g. [("op name", val), ...]
site_fingerprint: list[tuple[str, int]] = list(
self.site_fingerprints[site_index].items()
)
# get coordination number with largest weight, ignore op names with
# just the coordination number weight (e.g. containing "wt")
parameter = max(site_fingerprint, key=lambda x: x[1] if "wt" not in x[0] else 0)
if parameter[1] < self.minimum_geometry_op:
# the largest filtered weight is less than the tolerance for determining the
# coordination geometry (i.e., we can no longer say the site is octahedral
# or tetrahedral etc). Now we don't care about the actual geometry, we just
# want the most likely coordination number; take this from the largest
# of all weights
parameter = max(site_fingerprint, key=lambda x: x[1])
cn = parameter[0].split()[-1].split("_")[-1]
geometry = f"{cn}-coordinate"
likeness = 1.0
else:
# return the geometry type without the CN at the end, e.g.
# "square co-planar CN_4" -> "square co-planar"
geometry = " ".join(parameter[0].split()[:-1])
geometry = "single-bond" if geometry == "sgl_bd" else geometry
likeness = parameter[1]
return {"type": geometry, "likeness": likeness}
[docs] def get_nearest_neighbors(
self, site_index: int, inc_inequivalent_site_index: bool = True
) -> list[dict[str, Any]]:
"""Gets information about the bonded nearest neighbors.
Args:
site_index: The site index (zero based).
inc_inequivalent_site_index: Whether to include the inequivalent
site indices in the nearest neighbor information.
Returns:
For each site bonded to ``site_index``, returns a :obj:`dict`
with the format::
{'element': el, 'dist': distance}
If ``inc_inequivalent_site_index=True``, the data will have an
additional key ``'inequiv_index'`` corresponding to the inequivalent
site index. E.g. if two sites are structurally/symmetrically
equivalent (depending on the value of ``self.use_symmetry_equivalent_sites``
then they will have the same ``inequiv_index``.
"""
nn_sites = self.bonded_structure.get_connected_sites(site_index)
if inc_inequivalent_site_index:
return [
{
"element": str(site.site.specie),
"inequiv_index": self.equivalent_sites[site.index],
"dist": site.dist,
}
for site in nn_sites
]
return [
{"element": str(site.site.specie), "dist": site.dist} for site in nn_sites
]
[docs] def get_next_nearest_neighbors(
self, site_index: int, inc_inequivalent_site_index: bool = True
) -> list[dict[str, Any]]:
"""Gets information about the bonded next nearest neighbors.
Args:
site_index: The site index (zero based).
inc_inequivalent_site_index: Whether to include the inequivalent
site indices.
Returns:
A list of the next nearest neighbor information. For each next
nearest neighbor site, returns a :obj:`dict` with the format::
{'element': el, 'connectivity': con, 'geometry': geom,
'angles': angles, 'distance': distance}
The ``connectivity`` property is the connectivity type to the
next nearest neighbor, e.g. "face", "corner", or
"edge". The ``geometry`` property gives the geometry of the
next nearest neighbor site. See the ``get_site_geometry`` method for
the format of this data. The ``angles`` property gives the bond
angles between the site and the next nearest neighbour. Returned as
a :obj:`list` of :obj:`int`. Multiple bond angles are given when
the two sites share more than nearest neighbor (e.g. if they are
face-sharing or edge-sharing). The ``distance`` property gives the
distance between the site and the next nearest neighbor.
If ``inc_inequivalent_site_index=True``, the data will have an
additional key ``'inequiv_index'`` corresponding to the inequivalent
site index. E.g. if two sites are structurally/symmetrically
equivalent (depending on the value of ``self.use_symmetry_equivalent_sites``
then they will have the same ``inequiv_index``.
"""
def get_coords(a_site_index, a_site_image):
return np.asarray(
self.bonded_structure.structure.lattice.get_cartesian_coords(
self.bonded_structure.structure.frac_coords[a_site_index]
+ a_site_image
)
)
nn_sites = self.bonded_structure.get_connected_sites(site_index)
next_nn_sites = [
site
for nn_site in nn_sites
for site in self.bonded_structure.get_connected_sites(
nn_site.index, jimage=nn_site.jimage
)
]
nn_sites_set = {(site.index, site.jimage) for site in nn_sites}
seen_nnn_sites = set()
next_nn_summary = []
for nnn_site in next_nn_sites:
if (
nnn_site.index == site_index
and nnn_site.jimage == (0, 0, 0)
or (nnn_site.index, nnn_site.jimage) in seen_nnn_sites
):
# skip the nnn site if it is the original atom of interest
continue
seen_nnn_sites.add((nnn_site.index, nnn_site.jimage))
sites = {
(site.index, site.jimage)
for site in self.bonded_structure.get_connected_sites(
nnn_site.index, jimage=nnn_site.jimage
)
}
shared_sites = nn_sites_set.intersection(sites)
n_shared_atoms = len(shared_sites)
if n_shared_atoms == 1:
connectivity = "corner"
elif n_shared_atoms == 2:
connectivity = "edge"
else:
connectivity = "face"
site_coords = get_coords(site_index, (0, 0, 0))
nnn_site_coords = get_coords(nnn_site.index, nnn_site.jimage)
nn_site_coords = [
get_coords(nn_site_index, nn_site_image)
for nn_site_index, nn_site_image in shared_sites
]
# can't just use Structure.get_angles to calculate angles as it
# doesn't take into account the site image
angles = [
get_angle(site_coords - x, nnn_site_coords - x) for x in nn_site_coords
]
distance = np.linalg.norm(site_coords - nnn_site_coords)
geometry = self.get_site_geometry(nnn_site.index)
summary = {
"element": str(nnn_site.site.specie),
"connectivity": connectivity,
"geometry": geometry,
"angles": angles,
"distance": distance,
}
if inc_inequivalent_site_index:
summary["inequiv_index"] = self.equivalent_sites[nnn_site.index]
next_nn_summary.append(summary)
return next_nn_summary
[docs] def get_site_summary(self, site_index: int) -> dict[str, Any]:
"""Gets a summary of the site information.
Args:
site_index: The site index (zero based).
Returns:
A summary of the site information, formatted as::
{
'element': 'Mo4+',
'geometry': {
'likesness': 0.5544,
'type': 'pentagonal pyramidal'
},
'nn': [2, 2, 2, 2, 2, 2],
'nnn': {'edge': [0, 0, 0, 0, 0, 0]},
'poly_formula': 'S6',
'sym_labels': (1,)
}
Where ``element`` is the species string (if the species has
oxidation states, these will be included in the string). The
``geometry`` key is the geometry information as produced by
:meth:`SiteAnalyzer.get_site_geometry`. The `nn` key lists
the site indices of the nearest neighbor bonding sites. Note the
inequivalent site index is given for each site. The `nnn` key gives
the next nearest neighbor information, broken up by the connectivity
to that neighbor. The ``poly_formula`` key gives the formula of the
bonded nearest neighbors. ``poly_formula`` will be ``None`` if the
site geometry is not in :data:`robocrys.util.connected_geometries`.
The ``sym_labels`` key gives the symmetry labels of the site. If
two sites are symmetrically equivalent they share the same symmetry
label. The numbering begins at 1 for each element in the structure.
If :attr:`SiteAnalyzer.use_symmetry_inequivalnt_sites` is ``False``,
each site may have more than one symmetry label, as structural
features have instead been used to determine the site equivalences,
i.e. two sites are symmetrically distinct but share the same
geometry, nearest neighbor and next nearest neighbor properties.
"""
element = str(self.bonded_structure.structure[site_index].specie)
geometry = self.get_site_geometry(site_index)
nn_sites = self.get_nearest_neighbors(
site_index, inc_inequivalent_site_index=True
)
nn_indices = [nn_site["inequiv_index"] for nn_site in nn_sites]
nnn_sites = self.get_next_nearest_neighbors(
site_index, inc_inequivalent_site_index=True
)
nnn = defaultdict(list)
for nnn_site in nnn_sites:
nnn[nnn_site["connectivity"]].append(nnn_site["inequiv_index"])
nnn = dict(nnn)
equiv_sites = [
i
for i in range(len(self.equivalent_sites))
if self.equivalent_sites[i] == self.equivalent_sites[site_index]
]
sym_labels = tuple({self.symmetry_labels[x] for x in equiv_sites})
poly_formula = self._get_poly_formula(geometry, nn_sites, nnn_sites)
return {
"element": element,
"geometry": geometry,
"nn": nn_indices,
"nnn": nnn,
"poly_formula": poly_formula,
"sym_labels": sym_labels,
}
[docs] def get_bond_distance_summary(self, site_index: int) -> dict[int, list[float]]:
"""Gets the bond distance summary for a site.
Args:
site_index: The site index (zero based).
Returns:
The bonding data for the site, formatted as::
{to_site: [dist_1, dist_2, dist_3, ...]}
Where ``to_site`` is the index of a nearest neighbor site
and ``dist_1`` etc are the bond distances as :obj:`float`.
"""
bonds = defaultdict(list)
for nn_site in self.get_nearest_neighbors(site_index):
to_site = nn_site["inequiv_index"]
bonds[to_site].append(nn_site["dist"])
return defaultdict_to_dict(bonds)
[docs] def get_connectivity_angle_summary(
self, site_index: int
) -> dict[int, dict[str, list[float]]]:
"""Gets the connectivity angle summary for a site.
The connectivity angles are the angles between a site and its
next nearest neighbors.
Args:
site_index: The site index (zero based).
Returns:
The connectivity angle data for the site, formatted as::
{
to_site: {
connectivity_a: [angle_1, angle_2, ...]
connectivity_b: [angle_1, angle_2, ...]
}
}
Where ``to_site`` is the index of a next nearest neighbor site,
``connectivity_a`` etc are the bonding connectivity type, e.g.
``'edge'`` or ``'corner'`` (for edge-sharing and corner-sharing
connectivity), and ``angle_1`` etc are the bond angles as
:obj:`float`.
"""
connectivities = defaultdict(lambda: defaultdict(list))
for nnn_site in self.get_next_nearest_neighbors(
site_index, inc_inequivalent_site_index=True
):
to_site = nnn_site["inequiv_index"]
connectivity = nnn_site["connectivity"]
connectivities[to_site][connectivity].extend(nnn_site["angles"])
return defaultdict_to_dict(connectivities)
[docs] def get_nnn_distance_summary(
self, site_index: int
) -> dict[int, dict[str, list[float]]]:
"""Gets the next nearest neighbor distance summary for a site.
Args:
site_index: The site index (zero based).
Returns:
The connectivity distance data for the site, formatted as::
{
to_site: {
connectivity_a: [distance_1, distance_2, ...]
connectivity_b: [distance_1, distance_2, ...]
}
}
Where ``to_site`` is the index of a next nearest neighbor site,
``connectivity_a`` etc are the bonding connectivity type, e.g.
``'edge'`` or ``'corner'`` (for edge-sharing and corner-sharing
connectivity), and ``distance_1`` etc are the bond angles as
:obj:`float`.
"""
connectivities = defaultdict(lambda: defaultdict(list))
for nnn_site in self.get_next_nearest_neighbors(
site_index, inc_inequivalent_site_index=True
):
to_site = nnn_site["inequiv_index"]
connectivity = nnn_site["connectivity"]
connectivities[to_site][connectivity].append(nnn_site["distance"])
return defaultdict_to_dict(connectivities)
[docs] def get_all_site_summaries(self):
"""Gets the site summaries for all sites.
Returns:
The site summaries for all sites, formatted as::
{
site_index: site_summary
}
Where ``site_summary`` has the same format as produced by
:meth:`SiteAnalyzer.get_site_summary`.
"""
return {
site: self.get_site_summary(site) for site in set(self.equivalent_sites)
}
[docs] def get_all_bond_distance_summaries(self) -> dict[int, dict[int, list[float]]]:
"""Gets the bond distance summaries for all sites.
Returns:
The bond distance summaries for all sites, formatted as::
{
from_site: {
to_site: distances
}
}
Where ``from_site`` and ``to_site`` are site indices and
``distances`` is a :obj:`list` of :obj:`float` of bond distances.
"""
return {
from_site: self.get_bond_distance_summary(from_site)
for from_site in set(self.equivalent_sites)
}
[docs] def get_all_connectivity_angle_summaries(
self,
) -> dict[int, dict[int, dict[str, list[float]]]]:
"""Gets the connectivity angle summaries for all sites.
The connectivity angles are the angles between a site and its
next nearest neighbors.
Returns:
The connectivity angle summaries for all sites, formatted as::
{
from_site: {
to_site: {
connectivity: angles
}
}
}
Where ``from_site`` and ``to_site`` are the site indices of
two sites, ``connectivity`` is the connectivity type (e.g.
``'edge'`` or ``'face'``) and ``angles`` is a :obj:`list` of
:obj:`float` of connectivity angles.
"""
return {
from_site: self.get_connectivity_angle_summary(from_site)
for from_site in set(self.equivalent_sites)
}
[docs] def get_all_nnn_distance_summaries(
self,
) -> dict[int, dict[int, dict[str, list[float]]]]:
"""Gets the next nearest neighbor distance summaries for all sites.
Returns:
The next nearest neighbor distance summaries for all sites,
formatted as::
{
from_site: {
to_site: {
connectivity: distances
}
}
}
Where ``from_site`` and ``to_site`` are the site indices of
two sites, ``connectivity`` is the connectivity type (e.g.
``'edge'`` or ``'face'``) and ``distances`` is a :obj:`list` of
:obj:`float` of distances.
"""
return {
from_site: self.get_nnn_distance_summary(from_site)
for from_site in set(self.equivalent_sites)
}
[docs] def get_inequivalent_site_indices(self, site_indices: list[int]) -> list[int]:
"""Gets the inequivalent site indices from a list of site indices.
Args:
site_indices: The site indices.
Returns:
The inequivalent site indices. For example, if a structure has 4
sites where the first two are equivalent and the last two are
inequivalent. If ``site_indices=[0, 1, 2, 3]`` the output will be::
[0, 0, 2, 3]
"""
return [self.equivalent_sites[i] for i in site_indices]
def _calculate_equivalent_sites(
self,
likeness_tol: float = 0.001,
bond_dist_tol: float = 0.01,
bond_angle_tol: float = 0.1,
) -> list[int]:
"""Determines the indices of the structurally inequivalent sites.
Args:
likeness_tol: The tolerance used to determine if two likeness
parameters are the same.
bond_dist_tol: The tolerance used to determine if two bond lengths
are the same.
bond_angle_tol: The tolerance used to determine if two bond angles
are the same.
Two sites are considered equivalent if they are the same element, and
have the same geometry and (next) nearest neighbors.
Returns:
A :obj:`list` of indices mapping each site in the structure to a
structurally equivalent site. For example, if the first two sites
are equivalent and the last two are both inequivalent, the data will
be formatted as::
[0, 0, 2, 3]
"""
# TODO: Use site fingerprint rather than geometry type.
inequiv_sites = {}
equivalent_sites = []
for site_index, site in enumerate(self.bonded_structure.structure):
element = get_el_sp(site.specie)
geometry = self.get_site_geometry(site_index)
nn_sites = self.get_nearest_neighbors(
site_index, inc_inequivalent_site_index=False
)
nnn_sites = self.get_next_nearest_neighbors(
site_index, inc_inequivalent_site_index=False
)
matched = False
for inequiv_index, inequiv_site in inequiv_sites.items():
elem_match = element == inequiv_site["element"]
geom_match = geometries_match(
geometry, inequiv_site["geometry"], likeness_tol=likeness_tol
)
nn_match = nn_summaries_match(
nn_sites, inequiv_site["nn_sites"], bond_dist_tol=bond_dist_tol
)
nnn_match = nnn_summaries_match(
nnn_sites, inequiv_site["nnn_sites"], bond_angle_tol=bond_angle_tol
)
if elem_match and geom_match and nn_match and nnn_match:
equivalent_sites.append(inequiv_index)
matched = True
break
if not matched:
# no matches therefore store original site index
equivalent_sites.append(site_index)
site_data = {
"element": element,
"geometry": geometry,
"nn_sites": nn_sites,
"nnn_sites": nnn_sites,
}
inequiv_sites[site_index] = site_data
return equivalent_sites
def _calculate_symmetry_labels(self, sym_equivalent_atoms: list[int]) -> list[int]:
"""Calculates the symmetry labels for all sites in the structure.
The symmetry labels number the sites in the structure. If two sites
are symmetrically equivalent they share the same symmetry label. The
numbering begins at 1 for each element in the structure.
Args:
sym_equivalent_atoms: A :obj:`list` of indices mapping each site in
the structure to a symmetrically equivalent site. The data
should be formatted as given by the ``equivalent_atoms`` key in
:meth`SpacegroupAnalyzer.get_symmetry_dataset()`.
Returns:
A mapping between the site index and symmetry label for that site.
"""
symmetry_labels = dict()
# this way is a little long winded but works if the sites aren't
# grouped together by element
for specie in self.bonded_structure.structure.species:
el_indices = self.bonded_structure.structure.indices_from_symbol(
get_el(specie)
)
equiv_indices = [sym_equivalent_atoms[x] for x in el_indices]
count = 1
equiv_index_to_sym_label = {}
for el_index, equiv_index in zip(el_indices, equiv_indices):
if equiv_index in equiv_index_to_sym_label:
symmetry_labels[el_index] = equiv_index_to_sym_label[equiv_index]
else:
equiv_index_to_sym_label[equiv_index] = count
symmetry_labels[el_index] = count
count += 1
return [symmetry_labels[i] for i in sorted(symmetry_labels.keys())]
def _get_poly_formula(
self,
geometry: dict[str, Any],
nn_sites: list[dict[str, Any]],
nnn_sites: list[dict[str, Any]],
) -> str | None:
"""Gets the polyhedra formula of the nearest neighbor atoms.
The polyhedral formula is effectively the sorted nearest neighbor
atoms in a reduced format. For example, if the nearest neighbors are
3 I atoms, 2 Br atoms and 1 Cl atom, the polyhedral formula will be
"I3Br2Cl". The polyhedral formula will be ``None`` if the site geometry
is not in :data:`robocrys.util.connected_geometries`.
Args:
geometry: The site geometry as produced by
:meth:`SiteAnalyzer.get_site_geometry`.
nn_sites: The nearest neighbor sites as produced by
:meth:`SiteAnalyzer.get_nearest_neighbors`.
nnn_sites: The next nearest neighbor sites as produced by
:meth:`SiteAnalyzer.get_next_nearest_neighbors`.
Returns:
The polyhedral formula if the site geometry is in
:data:`robocrys.util.connected_geometries` else ``None``.
"""
def order_elements(el):
if self.use_iupac_formula:
return [get_el_sp(el).X, el]
return [get_el_sp(el).iupac_ordering, el]
nnn_geometries = [nnn_site["geometry"] for nnn_site in nnn_sites]
poly_formula = None
if geometry["type"] in connected_geometries and any(
nnn_geometry["type"] in connected_geometries
for nnn_geometry in nnn_geometries
):
nn_els = [get_el(nn_site["element"]) for nn_site in nn_sites]
comp = Composition("".join(nn_els))
el_amt_dict = comp.get_el_amt_dict()
poly_formula = ""
for e in sorted(el_amt_dict.keys(), key=order_elements):
poly_formula += e
poly_formula += str(formula_double_format(el_amt_dict[e]))
return poly_formula
[docs]def geometries_match(
geometry_a: dict[str, Any], geometry_b: dict[str, Any], likeness_tol: float = 0.001
) -> bool:
"""Determine whether two site geometries match.
Geometry data should be formatted the same as produced by
:meth:`robocrys.site.SiteAnalyzer.get_site_geometry`.
Args:
geometry_a: The first set of geometry data.
geometry_b: The second set of geometry data.
likeness_tol: The tolerance used to determine if two likeness parameters
are the same.
Returns:
Whether the two geometries are the same.
"""
return (
geometry_a["type"] == geometry_b["type"]
and abs(geometry_a["likeness"] - geometry_b["likeness"]) < likeness_tol
)
[docs]def nn_summaries_match(
nn_sites_a: list[dict[str, int | str]],
nn_sites_b: list[dict[str, int | str]],
bond_dist_tol: float = 0.01,
match_bond_dists: bool = True,
) -> bool:
"""Determine whether two sets of nearest neighbors match.
Nearest neighbor data should be formatted the same as produced by
:meth:`robocrys.site.SiteAnalyzer.get_nearest_neighbors`.
Args:
nn_sites_a: The first set of nearest neighbors.
nn_sites_b: The second set of nearest neighbors.
bond_dist_tol: The tolerance used to determine if two bond lengths
are the same.
match_bond_dists: Whether to consider bond distances when matching.
Returns:
Whether the two sets of nearest neighbors match.
"""
def nn_sites_order(nn_site):
return [nn_site["element"], nn_site["dist"]]
if len(nn_sites_a) != len(nn_sites_b):
return False
nn_sites_a = sorted(nn_sites_a, key=nn_sites_order)
nn_sites_b = sorted(nn_sites_b, key=nn_sites_order)
dists_match = [
abs(site_a["dist"] - site_b["dist"]) < bond_dist_tol
if match_bond_dists
else True
for site_a, site_b in zip(nn_sites_a, nn_sites_b)
]
elements_match = [
site_a["element"] == site_b["element"]
for site_a, site_b in zip(nn_sites_a, nn_sites_b)
]
return all(d and e for d, e in zip(dists_match, elements_match))
[docs]def nnn_summaries_match(
nnn_sites_a: list[dict[str, Any]],
nnn_sites_b: list[dict[str, Any]],
likeness_tol: float = 0.001,
bond_angle_tol: float = 0.1,
match_bond_angles: bool = True,
):
"""Determine whether two sets of next nearest neighbors match.
Next nearest neighbor data should be formatted the same as produced by
:meth:`robocrys.site.SiteAnalyzer.get_next_nearest_neighbors`.
Args:
nnn_sites_a: The first set of next nearest neighbors.
nnn_sites_b: The second set of next nearest neighbors.
likeness_tol: The tolerance used to determine if two likeness parameters
are the same.
bond_angle_tol: The tolerance used to determine if two bond angles
are the same.
match_bond_angles: Whether to consider bond angles when matching.
Returns:
Whether the two sets of next nearest neighbors match.
"""
def nnn_sites_order(nnn_site):
return [
nnn_site["element"],
nnn_site["geometry"]["type"],
nnn_site["connectivity"],
sorted(nnn_site["angles"]),
]
if len(nnn_sites_a) != len(nnn_sites_b):
return False
nnn_sites_a = sorted(nnn_sites_a, key=nnn_sites_order)
nnn_sites_b = sorted(nnn_sites_b, key=nnn_sites_order)
elements_match = [
site_a["element"] == site_b["element"]
for site_a, site_b in zip(nnn_sites_a, nnn_sites_b)
]
cons_match = [
site_a["connectivity"] == site_b["connectivity"]
for site_a, site_b in zip(nnn_sites_a, nnn_sites_b)
]
geoms_match = [
geometries_match(
site_a["geometry"], site_b["geometry"], likeness_tol=likeness_tol
)
for site_a, site_b in zip(nnn_sites_a, nnn_sites_b)
]
angles_match = [
all(
abs(a_a - a_b) < bond_angle_tol
for a_a, a_b in zip(sorted(site_a["angles"]), sorted(site_b["angles"]))
)
if match_bond_angles
else True
for site_a, site_b in zip(nnn_sites_a, nnn_sites_b)
]
return all(
e and c and g and a
for e, c, g, a in zip(elements_match, cons_match, geoms_match, angles_match)
)