# Purpose: Store variables for generation of NPs using ASE
# Author: Jonathan Yik Chang Ting
# Date: 19/20/2020
from math import sqrt
import numpy as np
[docs]
GOLDEN_RATIO = (1 + sqrt(5)) / 2
[docs]
VACUUM_THICKNESS = 40.0
# Elements of interest, their lattice parameters, metallic radii, and physical properties.
# - Pd, Pt, Au values were obtained from N. W. Ashcroft and N. D. Mermin,
# Solid State Physics (Holt, Rinehart, and Winston, New York, 1976.
# - The lattice constants are 3.859, 3.912, and 4.065 Angstroms, for the
# respective FCC metals at 300 K according to W. P. Davey,
# "Precision Measurements of the Lattice Constants of Twelve Common Metals,"
# Physical Review, vol. 25, (6), pp. 753-761, 1925.
# - Metallic radii taken from Greenwood, Norman N.; Earnshaw, Alan (1997).
# Chemistry of the Elements (2nd ed.).
# - Density (rho) in kg/m^3, molar mass (m) in kg/mol, and bulk cohesive
# energy (bulkE) in eV/atom are used by the feature-extraction pipeline.
[docs]
ELE_DICT = {
"Pd": {"lc": {"FCC": 3.89}, "radius": 1.37, "mass": 106.42,
"rho": 12020, "m": 0.10642, "bulkE": 3.89},
"Pt": {"lc": {"FCC": 3.92}, "radius": 1.39, "mass": 195.08,
"rho": 21450, "m": 0.195084, "bulkE": 5.84},
"Au": {"lc": {"FCC": 4.09}, "radius": 1.44, "mass": 196.97,
"rho": 19320, "m": 0.196967, "bulkE": 3.81},
"Cu": {"lc": {"FCC": 3.61}, "radius": 1.28, "mass": 63.55,
"rho": 8960, "m": 0.063546, "bulkE": 3.49},
"Ni": {"lc": {"FCC": 3.52}, "radius": 1.25, "mass": 58.69,
"rho": 8908, "m": 0.058693, "bulkE": 4.44},
"Ag": {"lc": {"FCC": 4.09}, "radius": 1.44, "mass": 107.87,
"rho": 10490, "m": 0.107868, "bulkE": 2.95},
}
[docs]
DIAMETER_LIST = [10, 15, 20] # NP diameters of interest (Angstrom)
[docs]
SHAPE_LIST = ["OT", "SP", "IC", "CU", "DH", "TH", "RD", "TO", "CO"] # Shapes of interest
[docs]
BNP_DISTRIB_LIST = ["L10", "RAL", "RCS"] # BNP distributions of interest
[docs]
TNP_DISTRIB_LIST = [
"L10R", "CS", "CL10S", "CRALS", "RRAL", "CSRAL", "CSL10", "CRSR", "LL10"
] # TNP distributions of interest
[docs]
RATIO_LIST = [20, 40] # Ratios of interest
[docs]
CUSTOM_SHAPES = {} # User-defined custom shapes
[docs]
def update_constants(config):
"""Update global constants from a dictionary.
Args:
config: Dictionary containing constant names and their new values.
"""
global VACUUM_THICKNESS, RANDOM_DISTRIB_NO
if "VACUUM_THICKNESS" in config:
VACUUM_THICKNESS = config["VACUUM_THICKNESS"]
if "RANDOM_DISTRIB_NO" in config:
RANDOM_DISTRIB_NO = config["RANDOM_DISTRIB_NO"]
# For lists and dicts, we update in-place to preserve references in other modules
if "ELE_DICT" in config:
ELE_DICT.clear()
ELE_DICT.update(validate_ele_dict(config["ELE_DICT"]))
if "DIAMETER_LIST" in config:
DIAMETER_LIST[:] = config["DIAMETER_LIST"]
if "SHAPE_LIST" in config:
SHAPE_LIST[:] = config["SHAPE_LIST"]
if "BNP_DISTRIB_LIST" in config:
BNP_DISTRIB_LIST[:] = config["BNP_DISTRIB_LIST"]
if "TNP_DISTRIB_LIST" in config:
TNP_DISTRIB_LIST[:] = config["TNP_DISTRIB_LIST"]
if "RATIO_LIST" in config:
RATIO_LIST[:] = config["RATIO_LIST"]
if "CUSTOM_SHAPES" in config:
CUSTOM_SHAPES.clear()
CUSTOM_SHAPES.update(config["CUSTOM_SHAPES"])
[docs]
def load_config(path):
"""Load configuration from a JSON, YAML, or TOML file.
Args:
path: Path to the configuration file.
Returns:
The loaded configuration dictionary.
"""
import json
from pathlib import Path
suffix = Path(path).suffix.lower()
if suffix == ".json":
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
elif suffix in (".yaml", ".yml"):
try:
import yaml
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
except ImportError as e:
raise ImportError("PyYAML is required to load YAML config files.") from e
elif suffix == ".toml":
try:
import tomllib # Python 3.11+
except ImportError:
try:
import tomli as tomllib
except ImportError as e:
raise ImportError(
"tomllib (Python 3.11+) or tomli is required to load TOML config files."
) from e
with open(path, "rb") as f:
return tomllib.load(f)
else:
raise ValueError(f"Unsupported config file format: {suffix}")
[docs]
def validate_ele_dict(ele_dict):
"""Validate a user-supplied element dictionary.
Args:
ele_dict: Dictionary mapping element symbols to property dicts.
Returns:
The validated dictionary.
Raises:
ValueError: If required keys are missing or invalid.
"""
required_keys = {"lc", "radius", "mass"}
for element, props in ele_dict.items():
missing = required_keys - set(props.keys())
if missing:
raise ValueError(
f"Element '{element}' is missing required keys: {missing}"
)
if "FCC" not in props.get("lc", {}):
raise ValueError(
f"Element '{element}' must have lattice constant 'lc' with an 'FCC' entry"
)
return ele_dict
[docs]
def load_ele_dict_from_file(path):
"""Load an element dictionary from a JSON file.
Args:
path: Path to a JSON file containing the element dictionary.
Returns:
Validated element dictionary.
"""
import json
with open(path, "r", encoding="utf-8") as f:
ele_dict = json.load(f)
return validate_ele_dict(ele_dict)
[docs]
def dist_1d(coord1, coord2, dim):
"""Compute distance between 2 points in one of their real space coordinates."""
return round(np.sqrt(np.sum((coord2[dim] - coord1[dim]) ** 2)), 3)
[docs]
def dist_3d(coord1, coord2):
"""Compute real space distance between 2 points."""
return round(np.sqrt(np.sum((coord2 - coord1) ** 2)), 3)
[docs]
def calc_rcs_prob(obj, shape):
"""Compute the core-shell probability for each atom in *obj*.
Returns a list where higher values correspond to surface-like positions.
Args:
obj: ASE Atoms object.
shape: Nanoparticle shape string (e.g. 'IC', 'DH', 'OT').
Returns:
List of probabilities, one per atom.
"""
prob_list = []
if shape == 'IC':
mass_center = obj.get_center_of_mass()
radius = (obj.cell[0][0] - VACUUM_THICKNESS) / 2
for atom in obj:
prob_list.append(dist_3d(mass_center, atom.position) / radius)
else:
x_slices = {round(atom.position[0], 3) for atom in obj}
y_slices = {round(atom.position[1], 3) for atom in obj}
z_slices = {round(atom.position[2], 3) for atom in obj}
z_thread = {(x, y): {'max': 0, 'min': 0, 'mid': []} for x in x_slices for y in y_slices}
x_thread = {(y, z): {'max': 0, 'min': 0, 'mid': []} for y in y_slices for z in z_slices}
y_thread = {(z, x): {'max': 0, 'min': 0, 'mid': []} for z in z_slices for x in x_slices}
for atom in obj:
x, y, z = round(atom.position[0], 3), round(atom.position[1], 3), round(atom.position[2], 3)
z_thread[(x, y)]['mid'].append(z)
x_thread[(y, z)]['mid'].append(x)
y_thread[(z, x)]['mid'].append(y)
for d in (z_thread, x_thread, y_thread):
empty = [k for k, v in d.items() if len(v['mid']) == 0]
for k in empty:
del d[k]
for k, v in d.items():
v['max'] = max(v['mid'])
v['min'] = min(v['mid'])
v['mid'] = (v['max'] + v['min']) / 2
for atom in obj:
x, y, z = round(atom.position[0], 3), round(atom.position[1], 3), round(atom.position[2], 3)
z_half = (z_thread[(x, y)]['max'] - z_thread[(x, y)]['min']) / 2
x_half = (x_thread[(y, z)]['max'] - x_thread[(y, z)]['min']) / 2
y_half = (y_thread[(z, x)]['max'] - y_thread[(z, x)]['min']) / 2
z_rel = abs(z - z_thread[(x, y)]['mid']) / z_half if round(z_half, 3) != 0.0 else abs(z - z_thread[(x, y)]['mid'])
x_rel = abs(x - x_thread[(y, z)]['mid']) / x_half if round(x_half, 3) != 0.0 else abs(x - x_thread[(y, z)]['mid'])
y_rel = abs(y - y_thread[(z, x)]['mid']) / y_half if round(y_half, 3) != 0.0 else abs(y - y_thread[(z, x)]['mid'])
if shape == 'DH':
prob = 1.0 if (round(z_rel, 3) == 1.0) or (round(z_half, 3) == 0.0) else z_rel
else:
if (round(z_rel, 3) == 1.0) or (round(x_rel, 3) == 1.0) or (round(y_rel, 3) == 1.0) or (round(z_half, 3) == 0.0) or (round(x_half, 3) == 0.0) or (round(y_half, 3) == 0.0):
prob = 1.0
else:
prob = (z_rel + x_rel + y_rel) / 3
prob_list.append(prob)
return prob_list
[docs]
def parse_ele_comb(ele_comb, ele_dict=None):
"""Parse a concatenated element combination string into individual symbols.
Args:
ele_comb: String like 'AuPtPd' or 'CuAgAu'.
ele_dict: Optional element dictionary to use for matching. If None,
the built-in ELE_DICT is used.
Returns:
List of element symbols, e.g. ['Au', 'Pt', 'Pd'].
Raises:
ValueError: If the string cannot be parsed.
"""
if ele_dict is None:
ele_dict = ELE_DICT
symbols = sorted(ele_dict.keys(), key=len, reverse=True)
elements = []
i = 0
while i < len(ele_comb):
matched = False
for sym in symbols:
if ele_comb[i:].startswith(sym):
elements.append(sym)
i += len(sym)
matched = True
break
if not matched:
raise ValueError(
f"Could not parse element combination '{ele_comb}' at position {i}. "
f"Known symbols: {list(ele_dict.keys())}"
)
return elements