Source code for eon.atoms

"""Atoms / structure helpers for the eOn Python server.

Storage and I/O
    :class:`eon.structure.Structure` (alias :class:`Atoms`) is the in-memory
    working type. On disk, structures are ``readcon.ConFrame`` values
    (see :func:`eon.structure.Structure.from_conframe`).

Geometry kernels
    PBC, neighbor lists (vesin), and process-atom selection live under
    :mod:`eon.geometry`. This module re-exports them for compatibility and
    keeps CNA helpers plus a radius/color overlay. ``identical`` matches
    one partner per atom. Rotational matches and crystal space groups go
    through ``readcon_ops``.
"""
import logging

import numpy as np

from eon.geometry import (  # noqa: F401
    box_to_length_angle,
    brute_neighbor_list,
    coordination_numbers,
    get_process_atoms,
    least_coordinated,
    length_angle_to_box,
    neighbor_list,
    neighbor_list_pairs,
    neighbor_list_vectors,
    pbc,
    per_atom_norm,
    per_atom_norm_gen,
)
from eon.structure import Atoms, Structure  # noqa: F401

logger = logging.getLogger("atoms")


def _readcon_z_helpers():
    """readcon>=0.14.9 exports symbol_to_atomic_number / atomic_number_to_symbol."""
    import readcon

    to_z = getattr(readcon, "symbol_to_atomic_number", None) or getattr(
        readcon, "symbol_to_z", None
    )
    to_sym = getattr(readcon, "atomic_number_to_symbol", None) or getattr(
        readcon, "z_to_symbol", None
    )
    if to_z is None or to_sym is None:
        ver = getattr(readcon, "__version__", "unknown")
        raise ImportError(
            f"readcon {ver} has no symbol/Z helpers; need readcon>=0.14.9"
        )
    return to_z, to_sym


[docs] def atomic_number(symbol_or_z): """Z for a symbol. Integers pass through. Symbol lookup is readcon.""" if isinstance(symbol_or_z, (int, np.integer)): return int(symbol_or_z) if symbol_or_z in {"Xx", "X"}: return 0 to_z, _ = _readcon_z_helpers() z = int(to_z(symbol_or_z)) if z > 0: return z raise KeyError(f"unknown element {symbol_or_z!r}")
[docs] def symbol_for_z(z): """Chemical symbol for Z. Lookup is readcon; Z=0 is the unknown placeholder.""" z = int(z) if z == 0: return "Xx" _, to_sym = _readcon_z_helpers() symbol = to_sym(z) if symbol and symbol not in {"X", "Xx"}: return str(symbol) raise KeyError(f"unknown Z {z!r}")
def _bind_minimage_wrap_many(): """readcon_ops distances call Cell.wrap_many. The pinned minimage build exposes displacement and not wrap_many. One row uses the same origin displacement the geometry kernel uses when wrap_many is absent. A Cell that already defines the method keeps it. minimage is optional: without it there is nothing to bind, and the readcon_ops callers below are the only users of the method. """ try: import minimage except ImportError: return if hasattr(minimage.Cell, "wrap_many"): return def wrap_many(self, diffs): rows = np.atleast_2d(np.asarray(diffs, dtype=float)) zero = [0.0, 0.0, 0.0] return [self.displacement(zero, row.tolist()) for row in rows] minimage.Cell.wrap_many = wrap_many # --- structure comparison / CNA (unchanged algorithms) ---
[docs] def identical(atoms1, atoms2, epsilon_r): """True if two structures match when same-element atoms are interchangeable. Parameters ---------- atoms1, atoms2 : Structure Configurations to compare (same box within 1e-4). epsilon_r : float Max allowed MIC displacement (Å) for a pair to count as the same site. """ if len(atoms1) != len(atoms2): return False for i in range(3): for j in range(3): if abs(atoms1.box[i][j] - atoms2.box[i][j]) > 0.0001: logger.warning( "Identical returned false because boxes were not the same" ) return False box = atoms1.box ibox = np.linalg.inv(box) n = len(atoms1) names1 = np.asarray(atoms1.names) names2 = np.asarray(atoms2.names) same = (per_atom_norm(atoms1.r - atoms2.r, box, ibox) <= epsilon_r) & ( names1 == names2 ) # The identity map is one-to-one, so it settles the common case. if same.all(): return True # Start from the same-index pairs that match and augment only from the # atoms that do not. An augmenting path may still reassign a same-index # pair, so a crossed match that is one-to-one is found (Berge). # Candidates are computed for the atoms a path visits, closest first. candidates = {} def _candidates(i): cand = candidates.get(i) if cand is None: pan = per_atom_norm(atoms1.r - atoms2.r[i], box, ibox) ok = np.flatnonzero((pan <= epsilon_r) & (names1 == names2[i])) cand = ok[np.argsort(pan[ok], kind="stable")].tolist() candidates[i] = cand return cand # partner[j] is the atoms2 index matched to atoms1 atom j. partner = [-1] * n for i in np.flatnonzero(same): partner[int(i)] = int(i) def _augment(root): # Iterative depth-first search, so a long path cannot reach the # recursion limit. seen = set() frames = [[root, 0]] path = [] while frames: i, k = frames[-1] cand = _candidates(i) if k >= len(cand): frames.pop() if path: path.pop() continue frames[-1][1] = k + 1 j = cand[k] if j in seen: continue seen.add(j) path.append(j) if partner[j] == -1: for (fi, _), fj in zip(frames, path): partner[fj] = fi return True frames.append([partner[j], 0]) return False for i in np.flatnonzero(~same): if not _candidates(int(i)) or not _augment(int(i)): return False return True
[docs] def remove_net_translation(a, b): """Copy of ``a`` shifted by its mean minimum-image displacement onto ``b``. Mirrors the client's ``geometry::translationRemove``: with no fixed atoms a periodic system can drift as a whole during a saddle search, and the drifted copy is the same configuration. """ shifted = a.copy() shifted.r = a.r + np.mean(pbc(b.r - a.r, a.box), axis=0) return shifted
[docs] def match(a, b, eps_r, neighbor_cutoff, indistinguishable, check_rotation=False, use_identical=False, remove_translation=False): if len(a) != len(b): return False # Same rule as Matter::compare: translation is removed only when the # first structure has no fixed atoms, and not for the rotation checks. if remove_translation and not check_rotation and not a.fixed_mask().any(): a = remove_net_translation(a, b) if check_rotation: if indistinguishable and use_identical: return get_mappings(a, b, eps_r, neighbor_cutoff) else: return rot_match(a, b, eps_r) else: if indistinguishable and use_identical: return identical(a, b, eps_r) else: diff = pbc(a.r - b.r, a.box) return np.max(np.sum(diff**2.0, axis=1)) < eps_r**2.0
[docs] def point_energy_match(file_a, energy_a, file_b, energy_b, eps_e, eps_r, neighbor_cutoff, check_rotation=False, use_identical=False, remove_translation=False): import eon.fileio as io if abs(energy_a - energy_b) > eps_e: return False a = io.loadcon(file_a) b = io.loadcon(file_b) if match(a, b, eps_r, neighbor_cutoff, use_identical, check_rotation=check_rotation, use_identical=use_identical, remove_translation=remove_translation): return True return False
[docs] def points_energies_match(file_a, energy_a, files_b, energies_b, eps_e, eps_r, neighbor_cutoff, check_rotation=False, use_identical=False, remove_translation=False): for i in range(len(files_b)): if point_energy_match(file_a, energy_a, files_b[i], energies_b[i], eps_e, eps_r, neighbor_cutoff, check_rotation=check_rotation, use_identical=use_identical, remove_translation=remove_translation): return i return None
[docs] def crystal_spacegroup(structure, symprec=1e-5): """International symbol, number, and Hall number for a periodic crystal. One international number covers several settings. ``hall_number`` names the setting. This is not the cluster match. Iterative Rotations and Assignments answers that question. """ _bind_minimage_wrap_many() from readcon_ops import spacegroup return spacegroup(structure, atomic_number, symprec=symprec)
[docs] def rot_match(a, b, eps_r): if not (a.free.all() and b.free.all()): logger.warning("Comparing structures with frozen atoms with rotational matching; check_rotation may be set incorrectly") if len(a) == 0: return len(b) == 0 _bind_minimage_wrap_many() from readcon_ops import rotational_match judged = rotational_match(a, b, eps_r, atomic_number=atomic_number) if judged is None: return _rot_match_kabsch(a, b, eps_r) return judged
def _rot_match_kabsch(a, b, eps_r): acm = sum(a.r)/len(a) bcm = sum(b.r)/len(b) # SVD algorithm ("Least-Squares Fitting of Two 3-D Point Sets" by Arun, Huang, and Blostein) ta_r = a.r - acm tb_r = b.r - bcm H = np.dot(ta_r.transpose() , tb_r) U, S, V = np.linalg.svd(H) R = np.dot(U, V) if np.linalg.det(R) < 0: V[2] *= -1 R = np.dot(U, V) ta_r = np.dot(ta_r, R) dist = max(np.linalg.norm(ta_r - tb_r, axis=1)) return dist < eps_r
[docs] def rotm(axis, theta): ''' Gives the matrix representing a rotation of theta radians about axis ''' u = axis[0] v = axis[1] w = axis[2] u2 = u*u v2 = v*v w2 = w*w ct = np.cos(theta) st = np.sin(theta) mag = np.linalg.norm(axis) if (mag*mag == 0 or theta == 0.0): return np.identity(3) return np.array([ [u2 +(v2 +w2)*ct, u*v*(1-ct)-w*mag*st, u*w*(1-ct)+v*mag*st], [u*v*(1-ct)+w*mag*st, v2 +(u2 +w2)*ct, v*w*(1-ct)-u*mag*st], [u*w*(1-ct)-v*mag*st, v*w*(1-ct)+u*mag*st, w2 +(v2 +u2)*ct] ])/(mag*mag)
# Same labels as eonc::EpiCenters::cna (client/EpiCenters.cpp). CNA_FCC = 0 CNA_HCP = 1 CNA_OTHER = 2 def _adjacency_from_pairs(n, i, j): """Unique-index adjacency from vesin pair arrays. Each row is increasing atom index, matching ``EpiCenters.cpp`` insertion order so the CNA bond-sum still distinguishes 421 (fcc) from 422 (hcp). """ i = np.asarray(i, dtype=np.int64) j = np.asarray(j, dtype=np.int64) mask = i != j i, j = i[mask], j[mask] nl = [[] for _ in range(n)] if i.size == 0: return nl packed = np.unique(i * np.int64(n) + j) for a, b in zip((packed // n).tolist(), (packed % n).tolist()): nl[int(a)].append(int(b)) return nl def _neighbor_lists(p, cutoff): """CNA adjacency from :func:`eon.geometry.neighbor_list_pairs`.""" i, j, _s = neighbor_list_pairs(p, cutoff) return _adjacency_from_pairs(len(p), i, j) def _neighbor_sets(nl): """Per-atom neighbor sets for O(1) membership; list order stays on *nl*.""" return [set(nbs) for nbs in nl] def _common_neighbors_ordered(nl_a1, nbs_a2): """Common neighbors of a1 and a2, in a1 neighbor-list order. Bond-sum (FCC 421 vs HCP 422) depends on that order, so this must not be an unordered set intersection. """ return [a3 for a3 in nl_a1 if a3 in nbs_a2] def _common_bond_stats(nl_sets, common): bonds_nr = 0 bonds_sum = 0 for j2 in range(1, len(common)): nbs = nl_sets[common[j2]] for j1 in range(j2): if common[j1] in nbs: bonds_nr += 1 bonds_sum += j1 + j2 return bonds_nr, bonds_sum
[docs] def cna(p, cutoff): """Common-neighbor labels for every atom in *p*. Labels match the C++ client (``EpiCenters::cna``): 0 fcc (421), 1 hcp (422), 2 other. Inspired by the CNA code provided by Asap (wiki.fysik.dtu.dk/asap). """ n = len(p) can_values = np.full(n, CNA_OTHER, dtype=int) nr_FCC = np.zeros(n, dtype=int) nr_HCP = np.zeros(n, dtype=int) nl = _neighbor_lists(p, cutoff) nl_sets = _neighbor_sets(nl) for a2 in range(n): nbs_a2 = nl_sets[a2] for a1 in nl[a2]: if a1 >= a2: continue common = _common_neighbors_ordered(nl[a1], nbs_a2) if len(common) != 4: continue bonds_nr, bonds_sum = _common_bond_stats(nl_sets, common) if bonds_nr == 2: if bonds_sum == 6: nr_FCC[a1] += 1 nr_FCC[a2] += 1 else: nr_HCP[a1] += 1 nr_HCP[a2] += 1 for i in range(n): if len(nl[i]) == 12: if nr_FCC[i] == 12: can_values[i] = CNA_FCC elif nr_FCC[i] == 6 and nr_HCP[i] == 6: can_values[i] = CNA_HCP return can_values
[docs] def not_HCP_or_FCC(p, cutoff): """Indices of atoms that are neither fcc nor hcp (CNA other).""" cna_numbers = cna(p, cutoff) return [i for i, label in enumerate(cna_numbers) if label == CNA_OTHER]
[docs] def cnat(p, cutoff): """ Returns a list of cna numbers for all atoms in p Inspired by the CNA code provided by Asap (wiki.fysik.dtu.dk/asap)""" can_values = np.zeros(len(p)) nr_5 = np.zeros(len(p)) nr_6 = np.zeros(len(p)) nl = _neighbor_lists(p, cutoff) nl_sets = _neighbor_sets(nl) # loops over all the atoms for a2 in range(len(p)): nbs_a2 = nl_sets[a2] for a1 in nl[a2]: if a1 < a2: common = _common_neighbors_ordered(nl[a1], nbs_a2) # determines the connectivity of common neighbors if len(common) in [5,6]: bonds_nr, bonds_sum = _common_bond_stats(nl_sets, common) if bonds_nr == 5 and len(common) == 5: nr_5[a1] += 1 nr_5[a2] += 1 elif bonds_nr == 6 and len(common) == 6: nr_6[a1] += 1 nr_6[a2] += 1 # 1: CN12, 2: CN14, 3: CN15, 4: CN16, 5: BCC, 0: other for i in range(len(p)): if nr_5[i] == 12: if len(nl[i]) == 12: can_values[i] = 1 if (len(nl[i]) == 14) and (nr_6[i] == 2): can_values[i] = 2 if (len(nl[i]) == 15) and (nr_6[i] == 3): can_values[i] = 3 if (len(nl[i]) == 16) and (nr_6[i] == 4): can_values[i] = 4 if nr_5[i] == 0: if nr_6[i] == 8 and len(nl[i]) == 14: can_values[i] = 5 return can_values
[docs] def cnar(p, cutoff): """ Returns a list of cna numbers for all atoms in p Inspired by the CNA code provided by Asap (wiki.fysik.dtu.dk/asap)""" # not compatible with older python versions #cna = {i:{} for i in range(len(p))} # make a dict of dicts for each atom. cna = {} for i in range(len(p)): cna[i] = {} nl = _neighbor_lists(p, cutoff) nl_sets = _neighbor_sets(nl) def codeString(j,k,l): return "%d,%d,%d" % (j,k,l) # loops over all the atoms for a2 in range(len(p)): nbs_a2 = nl_sets[a2] for a1 in nl[a2]: if a1 < a2: # prevent double counting? common = _common_neighbors_ordered(nl[a1], nbs_a2) bonds_nr, bonds_sum = _common_bond_stats(nl_sets, common) code = codeString(len(common), bonds_nr, bonds_sum) if not code in cna[a1]: cna[a1][code] = 0 cna[a1][code] += 1 if not code in cna[a2]: cna[a2][code] = 0 cna[a2][code] += 1 return cna
[docs] def not_TCP(p, cutoff): """ Returns a list of indices for the atoms with cna = 0 """ not_cna = [] cna_numbers = cnat(p, cutoff) for i in range(len(cna_numbers)): if cna_numbers[i] == 0 or cna_numbers[i] == 5: not_cna.append(i) return not_cna
[docs] def not_TCP_or_BCC(p, cutoff): """ Returns a list of indices for the atoms with cna = 0 """ not_cna = [] cna_numbers = cnat(p, cutoff) for i in range(len(cna_numbers)): if cna_numbers[i] == 0: not_cna.append(i) return not_cna
[docs] def get_mappings(a, b, eps_r, neighbor_cutoff, mappings=None): """Depth-first search for a complete atom mapping from a onto b. Returns None if no mapping was found, or a dict mapping a indices to b indices. Mirror images still map; this does not test proper rotation. """ if mappings is None: b_coord = coordination_numbers(b, neighbor_cutoff) counts = {} for c in b_coord: counts[c] = counts.get(c, 0) + 1 least = min(counts, key=counts.get) a_coord = coordination_numbers(a, neighbor_cutoff) try: a_atom = list(a_coord).index(least) except ValueError: return None for i, c in enumerate(b_coord): if c != least or a.names[a_atom] != b.names[i]: continue found = get_mappings(a, b, eps_r, neighbor_cutoff, {a_atom: i}) if found is not None: return found return None n = len(a) mapped_a = mappings mapped_b = set(mapped_a.values()) unmapped_a = next((i for i in range(n) if i not in mapped_a), n) mapped_idx = list(mapped_a.keys()) diffs = pbc(a.r[unmapped_a] - a.r[mapped_idx], a.box) dists = np.linalg.norm(np.atleast_2d(diffs), axis=1) b_of_mapped = [mapped_a[i] for i in mapped_idx] want_name = a.names[unmapped_a] for b_atom in range(len(b)): if b_atom in mapped_b or b.names[b_atom] != want_name: continue b_diffs = pbc(b.r[b_atom] - b.r[b_of_mapped], b.box) b_dists = np.linalg.norm(np.atleast_2d(b_diffs), axis=1) if np.max(np.abs(dists - b_dists)) > eps_r: continue new_map = mapped_a.copy() new_map[unmapped_a] = b_atom if len(new_map) == n: return new_map found = get_mappings(a, b, eps_r, neighbor_cutoff, new_map) if found is not None: return found return None
[docs] def get_rotation_matrix(axis, theta): axis = axis / np.linalg.norm(axis) t = theta ct = np.cos(t) st = np.sin(t) T = 1.0 - ct rx, ry, rz = axis rotmat = np.zeros((3, 3)) rotmat[0][0] = T*rx*rx + ct rotmat[0][1] = T*ry*rx + rz*st rotmat[0][2] = T*rz*rx - ry*st rotmat[1][0] = T*rx*ry - rz*st rotmat[1][1] = T*ry*ry + ct rotmat[1][2] = T*rz*ry + rx*st rotmat[2][0] = T*rx*rz + ry*st rotmat[2][1] = T*ry*rz - rx*st rotmat[2][2] = T*rz*rz + ct return rotmat
[docs] def rotate(r, axis, center, angle): new_r = r.copy() if abs(angle) == 0.0: return new_r rotmat = get_rotation_matrix(axis, angle) center = center.copy() new_r -= center new_r = np.dot(new_r, rotmat) new_r += center return new_r
def _bond_perpendicular(point, origin, axis): """Component of point-origin perpendicular to a unit axis.""" relative = point - origin return relative - np.dot(relative, axis) * axis
[docs] def internal_motion(a, b): """ Takes two atoms objects and returns the motion from a to b that is entirely internal - no rotation or translation, in the form of a new atoms object. """ b = b.copy() b.r += a.r[0] - b.r[0] a0a1 = (a.r[1] - a.r[0]) / np.linalg.norm(a.r[1] - a.r[0]) b0b1 = (b.r[1] - b.r[0]) / np.linalg.norm(b.r[1] - b.r[0]) cross1 = np.cross(b0b1, a0a1) norm1 = np.linalg.norm(cross1) dot1 = float(np.clip(np.dot(a0a1, b0b1), -1.0, 1.0)) if norm1 > 1e-12: b.r = rotate(b.r, cross1 / norm1, a.r[0], np.arccos(dot1)) elif dot1 < 0.0: # Antiparallel bonds have a zero cross product. A half turn about any # perpendicular maps the bond onto the reference; atom 2 fixes the rest. helper = ( np.array([1.0, 0.0, 0.0]) if abs(float(a0a1[0])) < 0.9 else np.array([0.0, 1.0, 0.0]) ) axis_pi = np.cross(a0a1, helper) axis_pi = axis_pi / np.linalg.norm(axis_pi) b.r = rotate(b.r, axis_pi, a.r[0], np.pi) va = _bond_perpendicular(a.r[2], a.r[0], a0a1) vb = _bond_perpendicular(b.r[2], a.r[0], a0a1) nva = np.linalg.norm(va) nvb = np.linalg.norm(vb) if nva > 1e-12 and nvb > 1e-12: theta2 = np.arctan2( np.dot(a0a1, np.cross(vb, va)), np.dot(va, vb) ) b.r = rotate(b.r, a0a1, a.r[0], theta2) return b
# Presentation overlay keyed by Z. Symbol / name / mass live in readcon. _RADIUS = ( 1.0000, # 0 0.3100, # 1 0.2800, # 2 1.2800, # 3 0.9600, # 4 0.8400, # 5 0.7300, # 6 0.7100, # 7 0.6600, # 8 0.5700, # 9 0.5800, # 10 1.6600, # 11 1.4100, # 12 1.2100, # 13 1.1100, # 14 1.0700, # 15 1.0500, # 16 1.0200, # 17 1.0600, # 18 2.0300, # 19 1.7600, # 20 1.7000, # 21 1.6000, # 22 1.5300, # 23 1.3900, # 24 1.3900, # 25 1.3200, # 26 1.2600, # 27 1.2400, # 28 1.3200, # 29 1.2200, # 30 1.2200, # 31 1.2000, # 32 1.1900, # 33 1.2000, # 34 1.2000, # 35 1.1600, # 36 2.2000, # 37 1.9500, # 38 1.9000, # 39 1.7500, # 40 1.6400, # 41 1.5400, # 42 1.4700, # 43 1.4600, # 44 1.4200, # 45 1.3900, # 46 1.4500, # 47 1.4400, # 48 1.4200, # 49 1.3900, # 50 1.3900, # 51 1.3800, # 52 1.3900, # 53 1.4000, # 54 2.4400, # 55 2.1500, # 56 2.0700, # 57 2.0400, # 58 2.0300, # 59 2.0100, # 60 1.9900, # 61 1.9800, # 62 1.9800, # 63 1.9600, # 64 1.9400, # 65 1.9200, # 66 1.9200, # 67 1.8900, # 68 1.9000, # 69 1.8700, # 70 1.8700, # 71 1.7500, # 72 1.7000, # 73 1.6200, # 74 1.5100, # 75 1.4400, # 76 1.4100, # 77 1.3600, # 78 1.3600, # 79 1.3200, # 80 1.4500, # 81 1.4600, # 82 1.4800, # 83 1.4000, # 84 1.5000, # 85 1.5000, # 86 2.6000, # 87 2.2100, # 88 2.1500, # 89 2.0600, # 90 2.0000, # 91 1.9600, # 92 1.9000, # 93 1.8700, # 94 1.8000, # 95 1.6900, # 96 1.6600, # 97 1.6800, # 98 1.6500, # 99 1.6700, # 100 1.7300, # 101 1.7600, # 102 1.6100, # 103 1.5700, # 104 1.4900, # 105 1.4300, # 106 1.4100, # 107 1.3400, # 108 1.2900, # 109 1.2800, # 110 1.2100, # 111 1.2200, # 112 1.3600, # 113 1.4300, # 114 1.5800, # 115 1.6600, # 116 1.5600, # 117 1.5700, # 118 ) _COLOR = ( (1.000, 0.078, 0.576), # 0 (1.000, 1.000, 1.000), # 1 (0.851, 1.000, 1.000), # 2 (0.800, 0.502, 1.000), # 3 (0.761, 1.000, 0.000), # 4 (1.000, 0.710, 0.710), # 5 (0.565, 0.565, 0.565), # 6 (0.188, 0.314, 0.973), # 7 (1.000, 0.051, 0.051), # 8 (0.565, 0.878, 0.314), # 9 (0.702, 0.890, 0.961), # 10 (0.671, 0.361, 0.949), # 11 (0.541, 1.000, 0.000), # 12 (0.749, 0.651, 0.651), # 13 (0.941, 0.784, 0.627), # 14 (1.000, 0.502, 0.000), # 15 (1.000, 1.000, 0.188), # 16 (0.122, 0.941, 0.122), # 17 (0.502, 0.820, 0.890), # 18 (0.561, 0.251, 0.831), # 19 (0.239, 1.000, 0.000), # 20 (0.902, 0.902, 0.902), # 21 (0.749, 0.761, 0.780), # 22 (0.651, 0.651, 0.671), # 23 (0.541, 0.600, 0.780), # 24 (0.611, 0.478, 0.780), # 25 (0.878, 0.400, 0.200), # 26 (0.941, 0.565, 0.627), # 27 (0.314, 0.816, 0.314), # 28 (0.784, 0.502, 0.200), # 29 (0.490, 0.502, 0.690), # 30 (0.761, 0.561, 0.561), # 31 (0.400, 0.561, 0.561), # 32 (0.741, 0.502, 0.890), # 33 (1.000, 0.631, 0.000), # 34 (0.651, 0.161, 0.161), # 35 (0.361, 0.722, 0.820), # 36 (0.439, 0.180, 0.690), # 37 (0.000, 1.000, 0.000), # 38 (0.580, 1.000, 1.000), # 39 (0.580, 0.878, 0.878), # 40 (0.451, 0.761, 0.788), # 41 (0.329, 0.710, 0.710), # 42 (0.231, 0.620, 0.620), # 43 (0.141, 0.561, 0.561), # 44 (0.039, 0.490, 0.549), # 45 (0.000, 0.412, 0.522), # 46 (0.753, 0.753, 0.753), # 47 (1.000, 0.851, 0.561), # 48 (0.651, 0.459, 0.451), # 49 (0.400, 0.502, 0.502), # 50 (0.620, 0.388, 0.710), # 51 (0.831, 0.478, 0.000), # 52 (0.580, 0.000, 0.580), # 53 (0.259, 0.620, 0.690), # 54 (0.341, 0.090, 0.561), # 55 (0.000, 0.788, 0.000), # 56 (0.439, 0.831, 1.000), # 57 (1.000, 1.000, 0.780), # 58 (0.851, 1.000, 0.780), # 59 (0.780, 1.000, 0.780), # 60 (0.639, 1.000, 0.780), # 61 (0.561, 1.000, 0.780), # 62 (0.380, 1.000, 0.780), # 63 (0.271, 1.000, 0.780), # 64 (0.189, 1.000, 0.780), # 65 (0.122, 1.000, 0.780), # 66 (0.000, 1.000, 0.612), # 67 (0.000, 0.902, 0.459), # 68 (0.000, 0.831, 0.322), # 69 (0.000, 0.749, 0.220), # 70 (0.000, 0.671, 0.141), # 71 (0.302, 0.761, 1.000), # 72 (0.302, 0.651, 1.000), # 73 (0.129, 0.580, 0.839), # 74 (0.149, 0.490, 0.671), # 75 (0.149, 0.400, 0.588), # 76 (0.090, 0.329, 0.529), # 77 (0.816, 0.816, 0.878), # 78 (1.000, 0.820, 0.137), # 79 (0.722, 0.722, 0.816), # 80 (0.651, 0.329, 0.302), # 81 (0.341, 0.349, 0.380), # 82 (0.620, 0.310, 0.710), # 83 (0.671, 0.361, 0.000), # 84 (0.459, 0.310, 0.271), # 85 (0.259, 0.510, 0.588), # 86 (0.259, 0.000, 0.400), # 87 (0.000, 0.490, 0.000), # 88 (0.439, 0.671, 0.980), # 89 (0.000, 0.729, 1.000), # 90 (0.000, 0.631, 1.000), # 91 (0.000, 0.561, 1.000), # 92 (0.000, 0.502, 1.000), # 93 (0.000, 0.420, 1.000), # 94 (0.329, 0.361, 0.949), # 95 (0.471, 0.361, 0.890), # 96 (0.541, 0.310, 0.890), # 97 (0.631, 0.212, 0.831), # 98 (0.702, 0.122, 0.831), # 99 (0.702, 0.122, 0.729), # 100 (0.702, 0.051, 0.651), # 101 (0.741, 0.051, 0.529), # 102 (0.780, 0.000, 0.400), # 103 (0.800, 0.000, 0.349), # 104 (0.820, 0.000, 0.310), # 105 (0.851, 0.000, 0.271), # 106 (0.878, 0.000, 0.220), # 107 (0.902, 0.000, 0.180), # 108 (0.922, 0.000, 0.149), # 109 (0.922, 0.000, 0.149), # 110 (0.922, 0.000, 0.149), # 111 (0.922, 0.000, 0.149), # 112 (0.922, 0.000, 0.149), # 113 (0.922, 0.000, 0.149), # 114 (0.922, 0.000, 0.149), # 115 (0.922, 0.000, 0.149), # 116 (0.922, 0.000, 0.149), # 117 (0.922, 0.000, 0.149), # 118 ) numElements = len(_RADIUS) class _ElementStyle: """Radius/color by Z; ``elements[symbol]`` resolves Z through readcon.""" def __getitem__(self, key): z = key if isinstance(key, int) else atomic_number(key) if z < 0 or z >= len(_RADIUS): z = 0 return { "radius": _RADIUS[z], "color": list(_COLOR[z]), "number": z, "symbol": symbol_for_z(z), } def __contains__(self, key): try: self[key] return True except Exception: return False elements = _ElementStyle()