|
2 | 2 | A module for performing various species-related format conversions. |
3 | 3 | """ |
4 | 4 |
|
| 5 | +import math |
5 | 6 | import numpy as np |
6 | 7 | import os |
7 | 8 | from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Tuple, Union |
|
14 | 15 | from rdkit.Chem import rdMolTransforms as rdMT |
15 | 16 | from rdkit.Chem import SDWriter |
16 | 17 | from rdkit.Chem.rdchem import AtomValenceException |
| 18 | +from scipy.optimize import minimize |
17 | 19 |
|
18 | 20 | from arkane.common import get_element_mass, mass_by_symbol, symbol_by_number |
19 | 21 | import rmgpy.constants as constants |
@@ -2149,3 +2151,368 @@ def ics_to_scan_constraints(ics: list, |
2149 | 2151 | raise NotImplementedError(f'Given software {software} is not implemented ' |
2150 | 2152 | f'for ics_to_scan_constraints().') |
2151 | 2153 | 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