Skip to content

Commit f92a478

Browse files
authored
Zmat params (#780)
Allow users to add an atom to a ZMat even if it is defined with respect to other inconsistent atoms, e.g.: r or atom R-X, a of atoms A-B-X, and d of atoms N-L-M-X. A test was added.
2 parents 35dcafa + c3ea0ba commit f92a478

3 files changed

Lines changed: 476 additions & 1 deletion

File tree

arc/species/converter.py

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
A module for performing various species-related format conversions.
33
"""
44

5+
import math
56
import numpy as np
67
import os
78
from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Tuple, Union
@@ -14,6 +15,7 @@
1415
from rdkit.Chem import rdMolTransforms as rdMT
1516
from rdkit.Chem import SDWriter
1617
from rdkit.Chem.rdchem import AtomValenceException
18+
from scipy.optimize import minimize
1719

1820
from arkane.common import get_element_mass, mass_by_symbol, symbol_by_number
1921
import rmgpy.constants as constants
@@ -2149,3 +2151,368 @@ def ics_to_scan_constraints(ics: list,
21492151
raise NotImplementedError(f'Given software {software} is not implemented '
21502152
f'for ics_to_scan_constraints().')
21512153
return scan_trsh
2154+
2155+
2156+
def add_atom_to_xyz_using_internal_coords(xyz: Union[dict, str],
2157+
element: str,
2158+
r_index: int,
2159+
a_indices: tuple,
2160+
d_indices: tuple,
2161+
r_value: float,
2162+
a_value: float,
2163+
d_value: float,
2164+
opt_method: str = 'Nelder-Mead',
2165+
) -> Optional[dict]:
2166+
"""
2167+
Add an atom to XYZ. The new atom may have random r, a, and d index parameters
2168+
(not necessarily defined for the same respective atoms).
2169+
This function will compute the coordinates for the new atom.
2170+
This function assumes that the original xyz has at least 3 atoms.
2171+
2172+
Args:
2173+
xyz (dict): The xyz coordinates to process in a dictionary format.
2174+
element (str): The chemical element of the atom to add.
2175+
r_index (int): The index of an atom R to define the distance parameter R-X w.r.t the newly added atom, X.
2176+
a_indices (tuple): The indices of two atoms, A and B, to define the angle A-B-X parameter w.r.t the newly added atom, X.
2177+
d_indices (tuple): The indices of three atoms, L M and N, to define the dihedral angle L-M-N-X parameter w.r.t the newly added atom, X.
2178+
r_value (float): The value of the R-X distance parameter, r.
2179+
a_value (float): The value of the A-B-X angle parameter, a.
2180+
d_value (float): The value of the L-M-N-X dihedral angle parameter, d.
2181+
opt_method (str, optional): The optimization method to use for finding the new atom's coordinates.
2182+
Additional options include 'trust-constr' and 'BFGS'.
2183+
2184+
Returns:
2185+
Optional[dict]: The updated xyz coordinates.
2186+
"""
2187+
xyz = check_xyz_dict(xyz)
2188+
2189+
def calculate_errors(result_coords):
2190+
r_error = np.abs(np.sqrt(np.sum((np.array(result_coords) - np.array(xyz['coords'][r_index]))**2)) - r_value)
2191+
a_error = np.sqrt(angle_constraint(xyz['coords'][a_indices[0]], xyz['coords'][a_indices[1]], a_value)(*result_coords))
2192+
d_error = np.sqrt(dihedral_constraint(xyz['coords'][d_indices[0]], xyz['coords'][d_indices[1]], xyz['coords'][d_indices[2]], d_value)(*result_coords))
2193+
return r_error, a_error, d_error
2194+
2195+
def meets_precision(result_coords):
2196+
r_error, a_error, d_error = calculate_errors(result_coords)
2197+
return r_error < 0.01 and a_error < 0.1 and d_error < 0.1
2198+
2199+
guess_functions = [
2200+
generate_initial_guess_r_a,
2201+
generate_midpoint_initial_guess,
2202+
generate_perpendicular_initial_guess,
2203+
generate_bond_length_initial_guess,
2204+
generate_random_initial_guess,
2205+
generate_shifted_initial_guess,
2206+
]
2207+
2208+
closest_result = None
2209+
closest_error = float('inf')
2210+
2211+
for attempt, guess_func in enumerate(guess_functions, start=1):
2212+
initial_guess = guess_func(
2213+
atom_r_coord=xyz['coords'][r_index],
2214+
r_value=r_value,
2215+
atom_a_coord=xyz['coords'][a_indices[0]],
2216+
atom_b_coord=xyz['coords'][a_indices[1]],
2217+
a_value=a_value
2218+
)
2219+
2220+
try:
2221+
updated_xyz = _add_atom_to_xyz_using_internal_coords(
2222+
xyz, element, r_index, a_indices, d_indices, r_value, a_value, d_value, initial_guess, opt_method,
2223+
)
2224+
new_coord = updated_xyz['coords'][-1]
2225+
2226+
r_error, a_error, d_error = calculate_errors(new_coord)
2227+
total_error = r_error + a_error + d_error
2228+
2229+
print(f"Attempt {attempt}: r_error={r_error}, a_error={a_error}, d_error={d_error}, total_error={total_error}")
2230+
2231+
if meets_precision(new_coord):
2232+
print("Precision criteria met. Returning result.")
2233+
return updated_xyz
2234+
2235+
if total_error < closest_error:
2236+
print(f"Updating closest result. Previous closest_error={closest_error}, new total_error={total_error}")
2237+
closest_result = updated_xyz
2238+
closest_error = total_error
2239+
2240+
except Exception as e:
2241+
print(f"Attempt {attempt} with {guess_func.__name__} failed due to exception: {e}")
2242+
2243+
if closest_result is not None:
2244+
print("Returning closest result as no guess met precision criteria.")
2245+
return closest_result
2246+
2247+
print("No valid solution was found.")
2248+
return None
2249+
2250+
2251+
def _add_atom_to_xyz_using_internal_coords(xyz: dict,
2252+
element: str,
2253+
r_index: int,
2254+
a_indices: tuple,
2255+
d_indices: tuple,
2256+
r_value: float,
2257+
a_value: float,
2258+
d_value: float,
2259+
initial_guess: np.ndarray,
2260+
opt_method: str = 'Nelder-Mead',
2261+
) -> dict:
2262+
"""
2263+
Add an atom to XYZ. The new atom may have random r, a, and d index parameters
2264+
(not necessarily defined for the same respective atoms).
2265+
This function will compute the coordinates for the new atom.
2266+
This function assumes that the original xyz has at least 3 atoms.
2267+
2268+
Args:
2269+
xyz (dict): The xyz coordinates to process in a dictionary format.
2270+
element (str): The chemical element of the atom to add.
2271+
r_index (int): The index of an atom R to define the distance parameter R-X w.r.t the newly added atom, X.
2272+
a_indices (tuple): The indices of two atoms, A and B, to define the angle A-B-X parameter w.r.t the newly added atom, X.
2273+
d_indices (tuple): The indices of three atoms, L M and N, to define the dihedral angle L-M-N-X parameter w.r.t the newly added atom, X.
2274+
r_value (float): The value of the R-X distance parameter, r.
2275+
a_value (float): The value of the A-B-X angle parameter, a.
2276+
d_value (float): The value of the L-M-N-X dihedral angle parameter, d.
2277+
initial_guess (np.ndarray): The initial guess for the new atom's coordinates.
2278+
opt_method (str, optional): The optimization method to use for finding the new atom's coordinates.
2279+
Additional options include 'trust-constr' and 'BFGS'.
2280+
2281+
Returns:
2282+
dict: The updated xyz coordinates.
2283+
"""
2284+
if len(xyz['symbols']) < 3:
2285+
raise ValueError('The zmat must have at least 3 atoms to add a new atom with arbitrary parameters.')
2286+
if len(a_indices) != 2 or len(d_indices) != 3:
2287+
raise ValueError('The indices of the atoms defining the angle and dihedral must be of length 2 and 3, '
2288+
f'respectively, got {a_indices} and {d_indices}.')
2289+
2290+
coords, symbols = xyz['coords'], xyz['symbols']
2291+
2292+
atom_r_coord = coords[r_index]
2293+
atom_a_coord = coords[a_indices[0]]
2294+
atom_b_coord = coords[a_indices[1]]
2295+
atom_l_coord = coords[d_indices[0]]
2296+
atom_m_coord = coords[d_indices[1]]
2297+
atom_n_coord = coords[d_indices[2]]
2298+
2299+
sphere_eq = distance_constraint(reference_coord=atom_r_coord, distance=r_value)
2300+
angle_eq = angle_constraint(atom_a=atom_a_coord, atom_b=atom_b_coord, angle=a_value)
2301+
dihedral_eq = dihedral_constraint(atom_a=atom_l_coord, atom_b=atom_m_coord, atom_c=atom_n_coord, dihedral=d_value)
2302+
2303+
def objective_func(coord: Tuple[float, float, float]) -> float:
2304+
"""
2305+
The objective function to minimize to satisfy the sphere, angle, and dihedral constraints.
2306+
2307+
Args:
2308+
coord (Tuple[float, float, float]): The Cartesian coordinates of the new atom.
2309+
2310+
Returns:
2311+
float: The sum of the squared differences between the constraints and their desired values.
2312+
"""
2313+
x, y, z = coord
2314+
distance_constraint_ = sphere_eq(x, y, z)
2315+
angle_constraint_ = angle_eq(x, y, z)
2316+
dihedral_constraint_ = dihedral_eq(x, y, z)
2317+
2318+
sphere_error = sphere_eq(*coord)
2319+
angle_error = angle_eq(*coord)
2320+
dihedral_error = dihedral_eq(*coord)
2321+
2322+
total_error = ((distance_constraint_ / r_value) ** 2 +
2323+
(angle_constraint_ / math.radians(a_value)) ** 2 +
2324+
(dihedral_constraint_ / math.radians(d_value)) ** 2)
2325+
2326+
return total_error
2327+
2328+
result = minimize(objective_func, initial_guess, method=opt_method,
2329+
options={'maxiter': 1e+4, 'ftol': 1e-10, 'xtol': 1e-10})
2330+
if not result.success:
2331+
raise ValueError(f"Failed to find a solution: {result.message}")
2332+
2333+
coords = coords + (tuple(result.x.tolist()),)
2334+
symbols = symbols + (element,)
2335+
isotopes = xyz['isotopes'] + (get_most_common_isotope_for_element(element),)
2336+
xyz = xyz_from_data(coords=coords, symbols=symbols, isotopes=isotopes)
2337+
return xyz
2338+
2339+
2340+
def distance_constraint(reference_coord: tuple, distance: float):
2341+
"""
2342+
Generate the sphere equation for a new atom at a specific distance from a reference atom.
2343+
2344+
Args:
2345+
reference_coord (tuple): The Cartesian coordinates (x1, y1, z1) of the reference atom.
2346+
distance (float): The fixed distance (radius of the sphere) in Angstroms.
2347+
2348+
Returns:
2349+
function: A lambda function representing the sphere equation.
2350+
"""
2351+
x1, y1, z1 = reference_coord
2352+
sphere_eq = lambda x, y, z: (x - x1) ** 2 + (y - y1) ** 2 + (z - z1) ** 2 - distance ** 2
2353+
return sphere_eq
2354+
2355+
2356+
def angle_constraint(atom_a: tuple, atom_b: tuple, angle: float):
2357+
"""
2358+
Generate the angle constraint for a new atom with two other atoms in Cartesian space.
2359+
This constants atom X to a circle defined by a certain hight on a cone (looking for half angle)
2360+
2361+
Args:
2362+
atom_a (tuple): Cartesian coordinates of the first reference atom (A).
2363+
atom_b (tuple): Cartesian coordinates of the second reference atom (B).
2364+
angle (float): Desired angle (in degrees).
2365+
2366+
Returns:
2367+
function: A lambda function representing the angle constraint equation.
2368+
"""
2369+
x_a, y_a, z_a = atom_a
2370+
x_b, y_b, z_b = atom_b
2371+
target_angle = math.radians(angle)
2372+
2373+
def angle_eq(x, y, z):
2374+
"""
2375+
Calculate the angle between the vectors AB and BX, and compare it to the desired angle.
2376+
2377+
Args:
2378+
x (float): x-coordinate of the new atom.
2379+
y (float): y-coordinate of the new atom.
2380+
z (float): z-coordinate of the new atom.
2381+
2382+
Returns:
2383+
float: The difference between the calculated cosine of the angle and the desired cosine of the angle.
2384+
"""
2385+
BA = np.array([x_a - x_b, y_a - y_b, z_a - z_b])
2386+
BX = np.array([x - x_b, y - y_b, z - z_b])
2387+
BA_length = np.linalg.norm(BA)
2388+
BX_length = np.linalg.norm(BX)
2389+
if BA_length == 0 or BX_length == 0:
2390+
raise ValueError("Zero-length vector encountered in angle calculation.")
2391+
cross_product_length = np.linalg.norm(np.cross(BA, BX))
2392+
dot_product = np.dot(BA, BX)
2393+
calc_angle = math.atan2(cross_product_length, dot_product)
2394+
return (calc_angle - target_angle) ** 2
2395+
2396+
return angle_eq
2397+
2398+
2399+
def dihedral_constraint(atom_a: tuple, atom_b: tuple, atom_c: tuple, dihedral: float):
2400+
"""
2401+
Generate the dihedral angle constraint for a new atom with three other atoms in Cartesian space.
2402+
2403+
Args:
2404+
atom_a (tuple): Cartesian coordinates of the first atom (A).
2405+
atom_b (tuple): Cartesian coordinates of the second atom (B).
2406+
atom_c (tuple): Cartesian coordinates of the third atom (C).
2407+
dihedral (float): Desired dihedral angle (in degrees).
2408+
2409+
Returns:
2410+
function: A lambda function representing the dihedral constraint equation.
2411+
"""
2412+
x1, y1, z1 = atom_a
2413+
x2, y2, z2 = atom_b
2414+
x3, y3, z3 = atom_c
2415+
cos_d = math.cos(math.radians(dihedral))
2416+
sin_d = math.sin(math.radians(dihedral))
2417+
2418+
def dihedral_eq(x, y, z):
2419+
"""
2420+
Calculate the dihedral angle between the planes defined by vectors AB and BC, and compare it to the desired angle.
2421+
Return the combined equation: both cosine and sine should match.
2422+
2423+
Args:
2424+
x (float): x-coordinate of the new atom.
2425+
y (float): y-coordinate of the new atom.
2426+
z (float): z-coordinate of the new atom.
2427+
2428+
Returns:
2429+
float: The difference between the calculated cosine and sine of the dihedral angle
2430+
and the desired cosine and sine of the dihedral angle.
2431+
"""
2432+
AB = np.array([x2 - x1, y2 - y1, z2 - z1])
2433+
BC = np.array([x3 - x2, y3 - y2, z3 - z2])
2434+
CD = np.array([x - x3, y - y3, z - z3])
2435+
N1 = np.cross(AB, BC)
2436+
N2 = np.cross(BC, CD)
2437+
N1_norm = np.linalg.norm(N1)
2438+
N2_norm = np.linalg.norm(N2)
2439+
BC_norm = np.linalg.norm(BC)
2440+
cos_calc = np.dot(N1, N2) / (N1_norm * N2_norm)
2441+
sin_calc = np.dot(BC, np.cross(N1, N2)) / (BC_norm * N1_norm * N2_norm)
2442+
return (cos_calc - cos_d) ** 2 + (sin_calc - sin_d) ** 2
2443+
2444+
return dihedral_eq
2445+
2446+
2447+
def generate_initial_guess_r_a(atom_r_coord: tuple,
2448+
r_value: float,
2449+
atom_a_coord: tuple,
2450+
atom_b_coord: tuple,
2451+
a_value: float,
2452+
):
2453+
"""
2454+
Generate an initial guess for the new atom's coordinates based on the reference atom R, the distance R-X, and the angle A-B-X.
2455+
2456+
Args:
2457+
atom_r_coord (tuple): Cartesian coordinates of the reference atom R.
2458+
r_value (float): Desired distance between R and X.
2459+
atom_a_coord (tuple): Cartesian coordinates of atom A (for the angle constraint).
2460+
atom_b_coord (tuple): Cartesian coordinates of atom B (for the angle constraint).
2461+
a_value (float): Desired angle A-B-X in degrees.
2462+
2463+
Returns:
2464+
np.array: Initial guess coordinates for the new atom.
2465+
"""
2466+
# Step 1: Vector BA (for directionality)
2467+
BA = np.array(atom_a_coord) - np.array(atom_b_coord)
2468+
BA_unit = BA / np.linalg.norm(BA)
2469+
2470+
# Step 2: Find a vector orthogonal to BA
2471+
arbitrary_vector = np.array([1.0, 0.0, 0.0])
2472+
if np.allclose(BA_unit, arbitrary_vector): # Avoid collinearity
2473+
arbitrary_vector = np.array([0.0, 1.0, 0.0])
2474+
orthogonal_vector = np.cross(BA_unit, arbitrary_vector)
2475+
orthogonal_vector = orthogonal_vector / np.linalg.norm(orthogonal_vector)
2476+
2477+
# Step 3: Calculate approximate position using the angle
2478+
angle_rad = math.radians(a_value)
2479+
X_direction = np.cos(angle_rad) * BA_unit + np.sin(angle_rad) * orthogonal_vector
2480+
2481+
# Step 4: Scale by R distance and offset by R's position
2482+
X_position = np.array(atom_r_coord) + r_value * X_direction
2483+
return X_position
2484+
2485+
def generate_midpoint_initial_guess(atom_r_coord, r_value, atom_a_coord, atom_b_coord, a_value):
2486+
"""Generate an initial guess midway between the two reference atoms."""
2487+
midpoint = (np.array(atom_a_coord) + np.array(atom_b_coord)) / 2.0
2488+
direction = np.array(atom_r_coord) - midpoint
2489+
direction /= np.linalg.norm(direction)
2490+
return midpoint + r_value * direction
2491+
2492+
def generate_perpendicular_initial_guess(atom_r_coord, r_value, atom_a_coord, atom_b_coord, a_value):
2493+
"""Generate an initial guess that is perpendicular to the plane defined by the reference atoms."""
2494+
BA = np.array(atom_a_coord) - np.array(atom_b_coord)
2495+
BA_unit = BA / np.linalg.norm(BA)
2496+
# Find a vector perpendicular to BA
2497+
arbitrary_vector = np.array([1.0, 0.0, 0.0])
2498+
if np.allclose(BA_unit, arbitrary_vector):
2499+
arbitrary_vector = np.array([0.0, 1.0, 0.0])
2500+
perpendicular_vector = np.cross(BA_unit, arbitrary_vector)
2501+
perpendicular_vector /= np.linalg.norm(perpendicular_vector)
2502+
return np.array(atom_r_coord) + r_value * perpendicular_vector
2503+
2504+
def generate_random_initial_guess(atom_r_coord, r_value, atom_a_coord, atom_b_coord, a_value):
2505+
perturbation = np.random.uniform(-0.1, 0.1, 3)
2506+
base_guess = generate_initial_guess_r_a(atom_r_coord, r_value, atom_a_coord, atom_b_coord, a_value)
2507+
return base_guess + perturbation
2508+
2509+
def generate_shifted_initial_guess(atom_r_coord, r_value, atom_a_coord, atom_b_coord, a_value):
2510+
shift = np.array([0.1, -0.1, 0.1]) # A deterministic shift
2511+
base_guess = generate_initial_guess_r_a(atom_r_coord, r_value, atom_a_coord, atom_b_coord, a_value)
2512+
return base_guess + shift
2513+
2514+
def generate_bond_length_initial_guess(atom_r_coord, r_value, atom_a_coord, atom_b_coord, a_value):
2515+
"""Generate an initial guess considering only the bond length to the reference atom."""
2516+
direction = np.random.uniform(-1.0, 1.0, 3) # Random direction
2517+
direction /= np.linalg.norm(direction) # Normalize to unit vector
2518+
return np.array(atom_r_coord) + r_value * direction

0 commit comments

Comments
 (0)