diff --git a/ASH-packages.sh b/ASH-packages.sh index ce1d711e2..03d5cb979 100644 --- a/ASH-packages.sh +++ b/ASH-packages.sh @@ -20,6 +20,7 @@ mamba install -c conda-forge openmm # 745MB #xTB: semiempirical program mamba install -c conda-forge xtb # 8MB +mamba install -c conda-forge xtb-python # xtb Python API # pdbfixer: Needed for MM of biomolecules mamba install -c conda-forge pdbfixer # 0.5 MB #pySCF: Good QM program diff --git a/README.md b/README.md index d636f18ee..099789642 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ ASH is a Python-based computational chemistry and QM/MM environment for molecula Interfaces to popular QM codes: ORCA, xTB, PySCF, MRCC, ccpy, Psi4, Dalton, CFour, TeraChem, QUICK. Interface to the OpenMM library for MM and MD algorithms. Interfaces to specialized high-level QM codes like Block, Dice and ipie for DMRG, SHCI and AFQMC calculations. Interfaces to machine-learning libraries like PyTorch, MACE and MLatom for using and training machine learning potentials. Excellent environment for writing simple or complex computational chemistry workflows. +**Citation** +If ASH is useful in your research please cite us: +`ASH: a Multi-scale, Multi-theory Modeling program `_ +R. Bjornsson*, J. Comput. Chem 2026, 47, e70359. + **In case of problems:** Please open an issue on Github and we will try to fix any problems as soon as possible. diff --git a/ash/__init__.py b/ash/__init__.py index c9d8bf5ac..ec90b8f75 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -34,6 +34,9 @@ print("Forbidden names:", forbidden_inputfilenames) ashexit() +# New API test +#from . import job + #Results dataclass from .modules.module_results import ASH_Results,read_results_from_file @@ -41,7 +44,7 @@ import ash.modules.module_coords from .modules.module_coords import get_molecules_from_trajectory, eldict_covrad, write_pdbfile, Fragment, read_xyzfile, \ write_xyzfile, make_cluster_from_box, read_ambercoordinates, read_gromacsfile, split_multimolxyzfile,distance_between_atoms, \ - angle_between_atoms, dihedral_between_atoms, pdb_to_smiles, xyz_to_pdb_with_connectivity, writepdb_with_connectivity, mol_to_pdb, sdf_to_pdb + angle_between_atoms, dihedral_between_atoms from .modules.module_coords import remove_atoms_from_system_CHARMM, add_atoms_to_system_CHARMM, getwaterconstraintslist,\ QMregionfragexpand, cut_sphere, cut_cubic_box, QMPC_fragexpand, read_xyzfiles, Reaction, define_XH_constraints, simple_get_water_constraints, print_internal_coordinate_table,\ flexible_align_pdb, flexible_align_xyz, flexible_align, insert_solute_into_solvent, nuc_nuc_repulsion, calculate_RMSD @@ -82,7 +85,7 @@ from .modules.module_oniom import ONIOMTheory # Surface -from .modules.module_surface import calc_surface, calc_surface_fromXYZ, read_surfacedict_from_file, write_surfacedict_to_file +from .modules.module_surface_new import calc_surface, calc_surface_fromXYZ, read_surfacedict_from_file, write_surfacedict_to_file, RestraintTheory,analyze_surface # # QMcode interfaces from .interfaces.interface_ORCA import ORCATheory, counterpoise_calculation_ORCA, ORCA_External_Optimizer, run_orca_plot, MolecularOrbitalGrab, \ @@ -113,7 +116,7 @@ from .interfaces.interface_MNDO import MNDOTheory from .interfaces.interface_CFour import CFourTheory, run_CFour_HLC_correction, run_CFour_DBOC_correction, convert_CFour_Molden_file -from .interfaces.interface_xtb import xTBTheory, gxTBTheory +from .interfaces.interface_xtb import xTBTheory, gxTBTheory,tbliteTheory from .interfaces.interface_DFTB import DFTBTheory from .interfaces.interface_PyMBE import PyMBETheory from .interfaces.interface_MLatom import MLatomTheory @@ -123,12 +126,15 @@ from .interfaces.interface_mace import MACETheory from .interfaces.interface_fairchem import FairchemTheory from .interfaces.interface_packmol import packmol_solvate +from .interfaces.interface_openbabel import OpenBabelTheory, pdb_to_smiles, mol_to_pdb, sdf_to_pdb, writepdb_with_connectivity, \ + xyz_to_pdb_with_connectivity # MM: external and internal from .interfaces.interface_OpenMM import OpenMMTheory, OpenMM_MD, OpenMM_MDclass, OpenMM_Opt, OpenMM_Modeller, \ OpenMM_box_equilibration, write_nonbonded_FF_for_ligand, solvate_small_molecule, small_molecule_parameterizer, \ OpenMM_metadynamics, OpenMM_MD_plumed, Gentle_warm_up_MD, check_gradient_for_bad_atoms, get_free_energy_from_biasfiles, \ free_energy_from_bias_array,metadynamics_plot_data, merge_pdb_files +#define_uff # General aliases MolecularDynamics = OpenMM_MD @@ -150,9 +156,12 @@ from .modules.module_QMMM import QMMMTheory, actregiondefine, read_charges_from_psf, compute_decomposed_QM_MM_energy from .modules.module_polembed import PolEmbedTheory -# Knarr +# Knarric_optimizer_alte from .interfaces.interface_knarr import NEB, NEBTS, interpolation_geodesic +# FSM +from .interfaces.interface_fsm import FSM + #VMD from .interfaces.interface_VMD import write_VMD_script_cube @@ -171,8 +180,7 @@ from .modules.module_molcrys import molcrys, Fragmenttype # Geometry optimization -from .functions.functions_optimization import SimpleOpt, BernyOpt -from .interfaces.interface_dlfind import DLFIND_optimizer +from .functions.functions_optimization import SimpleOpt, BernyOpt, periodic_optimizer_alternating, Cart_optimizer, Cart_optimizer_class # geomeTRIC interface from .interfaces.interface_geometric_new import geomeTRICOptimizer,GeomeTRICOptimizerClass @@ -205,7 +213,13 @@ # Plotting import ash.modules.module_plotting -from .modules.module_plotting import reactionprofile_plot, contourplot, plot_Spectrum, MOplot_vertical, ASH_plot +from .modules.module_plotting import reactionprofile_plot, contourplot, volumeplot, plot_Spectrum, MOplot_vertical, ASH_plot + +# DL-FIND +from ash.interfaces.interface_dlfind import DLFIND_optimizer,DLFIND_optimizerClass + +# Sella +from ash.interfaces.interface_sella import SellaOptimizer, SellaoptimizerClass # Other import ash.interfaces.interface_crest @@ -240,3 +254,5 @@ ash.settings_ash.settings_dict["connectivity_code"] = "py" # LJ+Coulomb and pairpot arrays in nonbonded MM ash.settings_ash.settings_dict["nonbondedMM_code"] = "py" + + diff --git a/ash/databases/Benchmarking-sets/__init__.py b/ash/databases/Benchmarking-sets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/Saddlepoint-test-sets/MGMR121-B3LYP-D3-def2SVP/__init__.py b/ash/databases/Saddlepoint-test-sets/MGMR121-B3LYP-D3-def2SVP/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/Saddlepoint-test-sets/__init__.py b/ash/databases/Saddlepoint-test-sets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/__init__.py b/ash/databases/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/basis_sets/__init__.py b/ash/databases/basis_sets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/basis_sets/cfour/__init__.py b/ash/databases/basis_sets/cfour/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/basis-sets/cfour/cc-pV5Z b/ash/databases/basis_sets/cfour/cc-pV5Z similarity index 100% rename from ash/databases/basis-sets/cfour/cc-pV5Z rename to ash/databases/basis_sets/cfour/cc-pV5Z diff --git a/ash/databases/basis-sets/cfour/cc-pVDZ b/ash/databases/basis_sets/cfour/cc-pVDZ similarity index 100% rename from ash/databases/basis-sets/cfour/cc-pVDZ rename to ash/databases/basis_sets/cfour/cc-pVDZ diff --git a/ash/databases/basis-sets/cfour/cc-pVQZ b/ash/databases/basis_sets/cfour/cc-pVQZ similarity index 100% rename from ash/databases/basis-sets/cfour/cc-pVQZ rename to ash/databases/basis_sets/cfour/cc-pVQZ diff --git a/ash/databases/basis-sets/cfour/cc-pVTZ b/ash/databases/basis_sets/cfour/cc-pVTZ similarity index 100% rename from ash/databases/basis-sets/cfour/cc-pVTZ rename to ash/databases/basis_sets/cfour/cc-pVTZ diff --git a/ash/databases/basis-sets/cfour/def2-SVP b/ash/databases/basis_sets/cfour/def2-SVP similarity index 100% rename from ash/databases/basis-sets/cfour/def2-SVP rename to ash/databases/basis_sets/cfour/def2-SVP diff --git a/ash/databases/basis-sets/cfour/def2-TZVP b/ash/databases/basis_sets/cfour/def2-TZVP similarity index 100% rename from ash/databases/basis-sets/cfour/def2-TZVP rename to ash/databases/basis_sets/cfour/def2-TZVP diff --git a/ash/databases/basis-sets/cp2k/BASIS_MOLOPT b/ash/databases/basis_sets/cp2k/BASIS_MOLOPT similarity index 100% rename from ash/databases/basis-sets/cp2k/BASIS_MOLOPT rename to ash/databases/basis_sets/cp2k/BASIS_MOLOPT diff --git a/ash/databases/basis-sets/cp2k/GTH_POTENTIALS b/ash/databases/basis_sets/cp2k/GTH_POTENTIALS similarity index 100% rename from ash/databases/basis-sets/cp2k/GTH_POTENTIALS rename to ash/databases/basis_sets/cp2k/GTH_POTENTIALS diff --git a/ash/databases/basis_sets/cp2k/__init__.py b/ash/databases/basis_sets/cp2k/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/forcefields/__init__.py b/ash/databases/forcefields/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/forcefields/uff_mod.xml b/ash/databases/forcefields/uff_mod.xml new file mode 100644 index 000000000..d41ef7253 --- /dev/null +++ b/ash/databases/forcefields/uff_mod.xml @@ -0,0 +1,752 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ash/databases/fragments/3fgaba.xyz b/ash/databases/fragments/3fgaba.xyz new file mode 100644 index 000000000..d8fa0f301 --- /dev/null +++ b/ash/databases/fragments/3fgaba.xyz @@ -0,0 +1,18 @@ +16 +0 1 +O -2.94954451337551 0.41756772104132 0.39570906486802 +C -1.94726532077404 0.00496590632354 -0.21387039282475 +O -1.86486267613291 -0.98425618175571 -0.96529807180037 +C -0.63082938495411 0.85257294318251 -0.03309484428487 +C 0.59645164744937 -0.03287653921399 -0.06815433659886 +C 1.90438518068492 0.74037017428706 -0.24605311458592 +N 3.05907283524481 -0.19683120033081 -0.20769489026400 +H -0.60859388824722 1.55863625302213 -0.86234378437503 +H -0.70691653721786 1.39879504806108 0.90589734163572 +F 0.71886137831986 -0.72287937449757 1.14252449306844 +H 0.44625382173731 -0.80706377016452 -0.83173237571128 +H 1.87791981471676 1.27078699161807 -1.19694917851462 +H 1.99775942946239 1.45578778965541 0.57362920347930 +H 3.95147619228729 0.31823090034354 -0.21891777872034 +H 3.02685050790199 -0.84412002131880 -1.00946488836316 +H 2.99602715486291 -0.75495857538077 0.65883171551972 diff --git a/ash/databases/fragments/__init__.py b/ash/databases/fragments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/databases/fragments/hcn.xyz b/ash/databases/fragments/hcn.xyz new file mode 100644 index 000000000..dbea957a0 --- /dev/null +++ b/ash/databases/fragments/hcn.xyz @@ -0,0 +1,5 @@ +3 +0 1 +H -0.020873125 0.000000000 -2.604242719 +C -0.020873125 0.000000000 -1.539232719 +N -0.020873125 0.000000000 -0.385992719 diff --git a/ash/databases/fragments/hcn_bent.xyz b/ash/databases/fragments/hcn_bent.xyz new file mode 100644 index 000000000..cb1e50bdc --- /dev/null +++ b/ash/databases/fragments/hcn_bent.xyz @@ -0,0 +1,5 @@ +3 +0 1 +H 0.644715752 -0.471799213 -2.223807952 +C -0.020873125 0.000000000 -1.539232719 +N -0.020873125 0.000000000 -0.385992719 diff --git a/ash/databases/fragments/methane.xyz b/ash/databases/fragments/methane.xyz index 80f3550f2..ec7805fec 100644 --- a/ash/databases/fragments/methane.xyz +++ b/ash/databases/fragments/methane.xyz @@ -1,5 +1,5 @@ 5 -methane +0 1 C -0.413304970 0.651496617 -1.941027070 H 0.117128976 -0.299837206 -1.864837039 H 0.303242715 1.473356418 -1.883520421 diff --git a/ash/functions/functions_elstructure.py b/ash/functions/functions_elstructure.py index 54048b850..8391faf10 100644 --- a/ash/functions/functions_elstructure.py +++ b/ash/functions/functions_elstructure.py @@ -10,7 +10,7 @@ import ash.constants import ash.modules.module_coords import ash.dictionaries_lists -from ash.functions.functions_general import ashexit, isodd, print_line_with_mainheader,pygrep,print_pretty_table +from ash.functions.functions_general import ashexit, check_program_location, isodd, print_line_with_mainheader,pygrep,print_pretty_table from ash.interfaces.interface_ORCA import ORCATheory, run_orca_plot, make_molden_file_ORCA from ash.modules.module_coords import nucchargelist,elematomnumbers from ash.dictionaries_lists import eldict @@ -669,16 +669,15 @@ def DDEC_calc(elems=None, theory=None, gbwfile=None, numcores=1, DDECmodel='DDEC else: print("Found molden2aim.exe: ", molden2aim) - print("Warning: DDEC_calc requires chargemol-binary dir to be present in environment PATH variable.") + print("Warning: DDEC_calc requires chargemol binary in PATH") #Finding chargemoldir from PATH in os.path PATH=os.environ.get('PATH').split(':') print("PATH: ", PATH) print("Searching for molden2aim and chargemol in PATH") - for p in PATH: - if 'chargemol' in p: - print("Found chargemol in path line (this dir should contain the executables):", p) - chargemolbinarydir=p + + + chargemolbinarydir = check_program_location(None,None, "chargemol") #Checking if we can proceed if chargemolbinarydir is None: @@ -1820,6 +1819,12 @@ def create_cubefile_from_orbfile(orbfile, option='density', grid=3, delete_temp_ mfile = make_molden_file_ORCA(orbfile, printlevel=printlevel) print("Now using Multiwfn to create cube file from Moldenfile") cubefile = multiwfn_run(mfile, option=option, grid=grid, printlevel=printlevel) + if cubefile is None and option == 'spin-density' or option == 'spindensity': + if os.path.exists('spindensity.cub'): + cubefile = 'spindensity.cub' + else: + print("Spin density cube file not found. Something went wrong.") + ashexit() # Rename cubefile (shortens it) new_cubename=str(os.path.splitext(orbfile)[0])+".cube" os.rename(cubefile, new_cubename) @@ -2595,3 +2600,14 @@ def density_sensitivity_metric(fragment=None, functional="B3LYP", basis="def2-TZ #TODO: Option to plot difference density also return metrics_dict + +def boltzmann_populations(energies, temperature=298.15): + print("Inside boltzmann_populations function") + beta = 1/(ash.constants.R_gasconst_kcalK*temperature) + + rel_energies=np.array([en-min(energies) for en in energies])*ash.constants.hartokcal + print("Relative energies (kcal/mol):", rel_energies) + boltzmann_factors = np.exp(-1*rel_energies * beta) + populations = boltzmann_factors / np.sum(boltzmann_factors) + print("Boltzmann populations at", temperature, "K:", populations) + return populations \ No newline at end of file diff --git a/ash/functions/functions_molcrys.py b/ash/functions/functions_molcrys.py index 4ca1ede5d..e65b9f51c 100644 --- a/ash/functions/functions_molcrys.py +++ b/ash/functions/functions_molcrys.py @@ -243,7 +243,7 @@ def same_fragment(fragtype=None, nuccharge=None, mass=None, formula=None): printdebug("el_list:", el_list) printdebug("current_mass:", current_mass) formula = ash.modules.module_coords.elemlisttoformula(el_list) - print("formula:", formula) + #print("formula:", formula) for fragment in fragments: printdebug("el_list:", el_list) ncharge = ash.modules.module_coords.nucchargelist(el_list) diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index a23ab998a..c43c6e353 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -3,12 +3,13 @@ import os import ash.constants -from ash.functions.functions_general import ashexit, blankline,print_time_rel_and_tot,BC,listdiff -from ash.modules.module_coords import write_xyzfile -from ash.modules.module_coords import check_charge_mult +from ash.functions.functions_general import ashexit, blankline, print_line_with_mainheader,print_time_rel_and_tot,BC,listdiff,print_time_rel, print_if_level +from ash.modules.module_coords import check_charge_mult , write_xyzfile, print_internal_coordinate_table_new +from ash.modules.module_coords_PBC import cell_vectors_to_params, cart_coords_to_fract, fract_coords_to_cart, cell_volume, \ + write_CIF_file,write_XSF_file, write_POSCAR_file from ash.modules.module_coords import print_coords_for_atoms -#import ash - +from ash.interfaces.interface_geometric_new import geomeTRICOptimizer +from ash.modules.module_results import ASH_Results #Root mean square of numpy array, e.g. gradient def RMS_G(grad): @@ -256,8 +257,6 @@ def SimpleOpt(fragment=None, theory=None, charge=None, mult=None, optimizer='KNA print(BC.FAIL,"Optimization did not converge in {} iteration".format(maxiter),BC.END) - - #Very basic bad steepest descent algorithm. #Arbitrary scaling parameter instead of linesearch #0.8-0.9 seems to work well for H2O @@ -442,3 +441,1026 @@ def BernyOpt(theory,fragment, charge=None, mult=None): print("Final optimized energy:", fragment.energy) fragment.replace_coords(fragment.elems,geom.coords) blankline() + +############################# +# PERIODIC OPTIMIZERS +############################# +# Alternating periodic cell optimizer: first atom-opt, then cell-step etc. +# Not really that useful +def periodic_optimizer_alternating(fragment=None, theory=None, rate=0.5, maxiter=50, tol=1e-3, step_algo="SD", + force_orthorhombic=True, max_step=0.25, momentum=0.5, + atoms_tolsetting=None, atom_opt_maxiter=100): + ang2bohr=1.88972612546 + print("Learning rate:", rate) + print("maxiter:", maxiter) + + # Max step in bohrs (default = 0.1 Å = 0.188 bohrs) + print("Rate:", rate) + max_step_au = max_step*ang2bohr + print("force_orthorhombic:", force_orthorhombic) + print(f"Tolerance: {tol} Eh/Bohr") + print("Maxiter:", maxiter) + print(f"Max step size {max_step} Å") + print() + print(f"Initial cell vectors in Theory object: {theory.periodic_cell_vectors} Å") + + cell_vectors_au = theory.periodic_cell_vectors*ang2bohr + cell_vectors = theory.periodic_cell_vectors + + # Looping + velocity = np.zeros((3, 3)) + print("Initial cell_vectors:", cell_vectors) + for i in range(0,maxiter): + print("="*40) + print("Cell optimization step", i) + print("="*40) + # Optimize atom coordinates with frozen cell + print("a) Will now optimize atom coordinates") + # Note: forcing PBC to be off in geometric + res = geomeTRICOptimizer(theory=theory, fragment=fragment, force_noPBC=True, convergence_setting=atoms_tolsetting, maxiter=atom_opt_maxiter) + + # Check convergence of cell gradient + cell_gradient = theory.get_cell_gradient() + grad_norm = np.linalg.norm(cell_gradient) + print(f"Current Cell Gradient Norm: {grad_norm:.6f}") + if grad_norm < tol: + print(f"Cell converged in {i} cell-iterations (Gradient norm: {grad_norm:.6f} < tol={tol} Eh/Bohr)") + print(f"Final cell vectors: {cell_vectors} Å and parameters: ({cell_vectors_to_params(cell_vectors)})") + print(f"Final energy: {res.energy} Eh") + break + + # Convert previously optimized Cart coords to Fract coords + fract_coords = cart_coords_to_fract(fragment.coords,theory.periodic_cell_vectors) + + print("b) Will now take cell vector step") + + # Calculate cell vector step (in Bohrs) + if step_algo.lower() =="sd": + print("Doing steepest descent step") + delta_au = - (rate * cell_gradient) + elif step_algo.lower() == "damped-MD": + print("Doing momentum step") + print("velocity:", velocity) + velocity = (momentum * velocity) - (rate * cell_gradient) + print("velocity:", velocity) + delta_au = velocity + elif step_algo.lower() == "nesterov": + # Storing old + velocity_old = velocity.copy() + print("Doing Nesterov momentum step") + velocity = (momentum * velocity) - (rate * cell_gradient) + nesterov_update = -momentum * velocity_old + (1 + momentum) * velocity + delta_au = nesterov_update + elif step_algo.lower() == "cg": + print("Doing conjugate gradient step") + if i == 0: + search_dir = cell_gradient + prev_grad=None + else: + # Polak-Ribière formula for beta + diff = cell_gradient - prev_grad + beta = np.sum(cell_gradient * diff) / np.sum(prev_grad * prev_grad) + beta = max(0, beta) # Standard 'reset' for CG + search_dir = cell_gradient + (beta * search_dir) + + delta_au = - (rate * search_dir) + prev_grad = cell_gradient.copy() + else: + print("Unknown step_algo") + ashexit() + + + print("delta_au:", delta_au) + + # Force orthorhomic + if force_orthorhombic: + print("force_orthorhombic True") + diagonal_mask = np.eye(3) + delta_au = delta_au*diagonal_mask + + # Scale down step if required + if np.max(np.abs(delta_au)) > max_step_au: + print(f"Step scale down: {np.max(np.abs(delta_au))} > max_step_au: {max_step_au})") + delta_au = delta_au * (max_step / np.max(np.abs(delta_au))) + print("Actual step:", delta_au) + + # Take step + cell_vectors_au += delta_au + # Convert final cell vectors from Bohrs to Å + cell_vectors = cell_vectors_au / ang2bohr + print("Current cell vectors (Å):", cell_vectors) + print("Current cell volume (Å):", cell_volume(cell_vectors)) + # Update Theory with new cell vectors in Å + theory.update_cell(periodic_cell_vectors=cell_vectors) + print("theory.periodic_cell_vectors:", theory.periodic_cell_vectors) + + # Update fragment with new XYZ coords that match cell + new_cart_coords = fract_coords_to_cart(fract_coords,theory.periodic_cell_vectors) + print("new_cartesian_coords:", new_cart_coords) + fragment.coords=new_cart_coords + +# Cartesian-based periodic cell optimizer + + +# Wrapper function around Cart_optimizer_class +def Cart_optimizer(fragment=None, theory=None, rate=2.0, + scaling_rate_cell=1.0, maxiter=50, + step_algo="bfgs", + max_step=0.25, momentum=0.5, constrain_method='soft', + printlevel=2, conv_criteria=None, PBC_format_option="CIF", + constraints=None, frozen_atoms=None, result_write_to_disk=True, + kf_bonds=10.0, kf_angles=10.0, kf_dihedrals=10.0): + """ + Wrapper function around Cart_optimizer_class + """ + timeA=time.time() + + # EARLY EXIT + if theory is None or fragment is None: + print("Cart_optimizer requires theory and fragment objects provided. Exiting.") + ashexit() + optimizer=Cart_optimizer_class(fragment=fragment, theory=theory, rate=rate, scaling_rate_cell=scaling_rate_cell, + maxiter=maxiter, step_algo=step_algo, + max_step=max_step, momentum=momentum, PBC_format_option=PBC_format_option, + constrain_method=constrain_method, + printlevel=printlevel, conv_criteria=conv_criteria, constraints=constraints, + frozen_atoms=frozen_atoms, result_write_to_disk=result_write_to_disk, + kf_bonds=kf_bonds, kf_angles=kf_angles, kf_dihedrals=kf_dihedrals) + + result = optimizer.run() + if printlevel >= 1: + print_time_rel(timeA, modulename='Cart_optimizer', moduleindex=1) + + return result + + +class Cart_optimizer_class: + + def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, maxiter=50, step_algo="bfgs", + max_step=0.25, momentum=0.5, printlevel=2, conv_criteria=None, print_atoms_list=None, + PBC_format_option="CIF", constraints=None, constrain_method='soft', + frozen_atoms=None, result_write_to_disk=True, + kf_bonds=10.0, kf_angles=10.0, kf_dihedrals=10.0): + + print_line_with_mainheader("Cart_optimizer initialization") + self.fragment = fragment + self.theory = theory + self.rate = rate + self.scaling_rate_cell = scaling_rate_cell + self.maxiter = maxiter + self.step_algo=step_algo + self.max_step=max_step + self.momentum=momentum + self.printlevel=printlevel + self.PBC_format_option=PBC_format_option + self.print_atoms_list=print_atoms_list + self.result_write_to_disk=result_write_to_disk + # Constraints + self.constraints = constraints if constraints is not None else [] + self.constrain_method = constrain_method # 'hard' or 'soft' + # Constraint force constants for soft constraints (in Eh/Bohr^2, Eh/rad^2). Only used if constrain_method='soft'. + self.kf_bonds = kf_bonds + self.kf_angles = kf_angles + self.kf_dihedrals = kf_dihedrals + # Frozen atoms + self.frozen_atoms = frozen_atoms if frozen_atoms is not None else [] + if self.frozen_atoms: + print(f"Frozen atoms: {self.frozen_atoms}") + + self.ang2bohr=1.88972612546 + + if conv_criteria is None: + print("Convergence criteria not set by user. Using following") + self.conv_criteria = {'convergence_grms':1e-4, 'convergence_gmax':3e-4} + else: + self.conv_criteria=conv_criteria + + # Max step in bohrs (default = 0.25 Å = 0.472 bohrs) + self.max_step_au = max_step*self.ang2bohr + + print("Convergence criteria:", self.conv_criteria) + print("Rate (atoms):", self.rate) + print("Scaling for Rate (cell):", self.scaling_rate_cell) + print("Maxiter:", self.maxiter) + print(f"Max step size {self.max_step} Å") + print("Step algorithm:", self.step_algo) + print("Constraints:", self.constraints) + for con in self.constraints: + print("con:",con) + print("Constrain method:", self.constrain_method) + print("Constraint force constants:") + print(f" Bonds: {self.kf_bonds} Eh/Bohr^2") + print(f" Angles: {self.kf_angles} Eh/rad^2") + print(f" Dihedrals: {self.kf_dihedrals} Eh/rad^2") + print() + + self.PBC = False + + ####################### + # INITITAL SETUP + ####################### + + #---- PERIODIC ----- + if getattr(self.theory, "periodic", False): + print("Theory object is periodic") + print("Will run periodic cell optimization") + print(f"Initial cell vectors in Theory object: {theory.periodic_cell_vectors} Å") + self.PBC=True + + self.cell_vectors_au = theory.periodic_cell_vectors*self.ang2bohr + self.cell_vectors = theory.periodic_cell_vectors + + #---- NON-PERIODIC ----- + else: + print("Theory object is not periodic.") + + def setup_PBC(self): + # Align to standard orientation + aligned_atom_coords, aligned_vectors = self.align_to_standard_orientation(self.fragment.coords, + self.theory.periodic_cell_vectors) + print("Updating fragment coordinates and theory cell with aligned coords") + self.fragment.coords=aligned_atom_coords + self.theory.update_cell(aligned_vectors) + + # Reference + self.H_ref = aligned_vectors.copy() + self.H_ref_inv = np.linalg.inv(self.H_ref) + + def apply_cartesian_constraints(self, gradient): + """ + Zero out gradient components for frozen atoms. + Accepts either a list of atom indices to freeze, or a dict with + per-atom frozen Cartesian components, e.g.: + frozen_atoms=[0, 1, 5] # freeze all xyz + frozen_atoms={0: 'xyz', 3: 'xz', 7: 'y'} # freeze specific components + """ + grad_out = gradient.copy() + + #if isinstance(self.frozen_atoms, (list, tuple)): + # for idx in self.frozen_atoms: + # grad_out[idx] = 0.0 + + component_map = {'x': 0, 'y': 1, 'z': 2} + for idx, components in self.all_cartesian_constraints.items(): + for c in components.lower(): + if c in component_map: + grad_out[idx, component_map[c]] = 0.0 + + return grad_out + + def apply_bond_constraints(self, coords, gradient, energy, kf=10.0): + """ + Apply bond-length constraints to gradient (and energy for soft mode). + + coords: (N, 3) physical atomic coordinates in Ångström + gradient: (N+4, 3) supergradient (atoms + origin + 3 lattice rows) + energy: float, current energy + + Returns modified (energy, gradient). + """ + if not self.constraints: + return energy, gradient + + # Work on a copy so we don't mutate in-place unexpectedly + grad_out = gradient.copy() + energy_out = energy + coords_au = coords * self.ang2bohr + + for c in self.constraints['bond']: + print_if_level(f"Applying bond constraint: {c}", self.printlevel, 1) + print_if_level(f"Bond constraint force constant,kf, = {kf} (change by kf_bonds keyword)", self.printlevel, 1) + i, j, r0_ang = c + + r0 = r0_ang * self.ang2bohr # convert target bond length to Bohrs + + # Current bond vector and length + rij = coords_au[i] - coords_au[j] # (3,) + d = np.linalg.norm(rij) + if d < 1e-8: + print(f"Warning: atoms {i} and {j} are on top of each other. Skipping constraint.") + continue + e_ij = rij / d # unit vector i→j + + delta = d - r0 # signed deviation in Bohr + + if self.constrain_method == 'soft': + # Harmonic restraint: V = 0.5 * k * delta^2 + # dV/dr_i = k * delta * e_ij + # dV/dr_j = -k * delta * e_ij + energy_out += 0.5 * kf * delta**2 + grad_out[i] += kf * delta * e_ij + grad_out[j] -= kf * delta * e_ij + if self.printlevel >= 2: + print(f" Soft constraint ({i},{j}): d={d/self.ang2bohr:.4f} Å target={r0/self.ang2bohr:.4f} Å " + f"delta={delta/self.ang2bohr:.4f} Å penalty={0.5*kf*delta**2:.6f}") + + elif self.constrain_method == 'hard': + # SHAKE-style: project out the component of the gradient + # along the bond direction for both atoms. + # g_parallel_i = (g_i · e_ij) * e_ij + # g_parallel_j = -(g_j · e_ij) * e_ij (opposite sign convention) + # We zero those components to enforce the constraint. + g_i_par = np.dot(grad_out[i], e_ij) * e_ij + g_j_par = np.dot(grad_out[j], e_ij) * e_ij + grad_out[i] -= g_i_par + grad_out[j] -= g_j_par + if self.printlevel >= 2: + print(f" Hard constraint ({i},{j}): d={d:.4f} Å target={r0:.4f} Å " + f"delta={delta:.4f} Å |proj_i|={np.linalg.norm(g_i_par):.6f}") + else: + print(f"Unknown constraint method '{self.constrain_method}'. Use 'hard' or 'soft'.") + + return energy_out, grad_out + + def apply_angle_constraints(self, coords, gradient, energy, kf=10.0): + """ + Angle constraints for triplets (i, j, k). + Target angle in degrees. Gradient via chain rule through arccos. + """ + if not self.constraints: + return energy, gradient + + grad_out = gradient.copy() + energy_out = energy + coords_au = coords * self.ang2bohr + + for c in self.constraints['angle']: + print_if_level(f"Applying angle constraint: {c}", self.printlevel, 1) + print_if_level(f"Angle constraint force constant,kf, = {kf} (change by kf_angles keyword)", self.printlevel, 1) + i, j, k, theta0_deg = c # centre atom is j + theta0 = np.deg2rad(theta0_deg) + #kf = self.default_k #temp + + # Bond vectors pointing away from centre j + u = coords_au[i] - coords_au[j] + v = coords_au[k] - coords_au[j] + lu = np.linalg.norm(u) + lv = np.linalg.norm(v) + + if lu < 1e-8 or lv < 1e-8: + print(f"Warning: degenerate angle {i}-{j}-{k}. Skipping.") + continue + + u_hat = u / lu + v_hat = v / lv + cos_t = np.clip(np.dot(u_hat, v_hat), -1.0, 1.0) + theta = np.arccos(cos_t) + sin_t = np.sqrt(max(1.0 - cos_t**2, 1e-10)) # avoid /0 at 0° or 180° + + # dθ/dr_i = (u_hat × (u_hat × v_hat)) / (lu * sin_t) + # but the simpler form via arccos derivative: + # dcos/dr_i = (v_hat - cos_t * u_hat) / lu + # dθ/dr_i = -1/sin_t * dcos/dr_i + dc_dri = (v_hat - cos_t * u_hat) / lu + dc_drk = (u_hat - cos_t * v_hat) / lv + dc_drj = -(dc_dri + dc_drk) + + dt_dri = -dc_dri / sin_t + dt_drk = -dc_drk / sin_t + dt_drj = -dc_drj / sin_t + + delta = theta - theta0 # deviation in radians + + if self.constrain_method == 'soft': + energy_out += 0.5 * kf * delta**2 + grad_out[i] += kf * delta * dt_dri + grad_out[j] += kf * delta * dt_drj + grad_out[k] += kf * delta * dt_drk + if self.printlevel >= 2: + print(f" Soft angle ({i},{j},{k}): θ={np.rad2deg(theta):.3f}° " + f"target={np.rad2deg(theta0):.3f}° delta={np.rad2deg(delta):.3f}° " + f"penalty={0.5*kf*delta**2:.6f}") + + elif self.constrain_method == 'hard': + # Project out the gradient component along dθ/dr for each atom + for idx, dt_dr in [(i, dt_dri), (j, dt_drj), (k, dt_drk)]: + proj = np.dot(grad_out[idx], dt_dr) + if np.linalg.norm(dt_dr) > 1e-10: + n_hat = dt_dr / np.linalg.norm(dt_dr) + grad_out[idx] -= np.dot(grad_out[idx], n_hat) * n_hat + if self.printlevel >= 2: + print(f" Hard angle ({i},{j},{k}): θ={np.rad2deg(theta):.3f}° " + f"target={np.rad2deg(theta0):.3f}° delta={np.rad2deg(delta):.3f}°") + + return energy_out, grad_out + + def apply_dihedral_constraints(self, coords, gradient, energy, kf=0.5): + """ + Dihedral (torsion) restraints for quadruplets (i, j, k, l). + + Soft mode: + E = 0.5 * kf * delta^2 + with delta wrapped into [-pi, pi]. + + The gradient is computed by finite differences on the restraint energy. + This is slower than analytic formulas but much more robust. + """ + + #Making sure user did not use torsion + if 'dihedral' in self.constraints: + condict = self.constraints['dihedral'] + elif 'torsion' in self.constraints: + condict = self.constraints['torsion'] + else: + return energy, gradient + + grad_out = gradient.copy() + energy_out = energy + coords_au = coords * self.ang2bohr + + def dihedral_phi(ca, i, j, k, l): + """Signed dihedral angle in radians.""" + r1, r2, r3, r4 = ca[i], ca[j], ca[k], ca[l] + b1 = r2 - r1 + b2 = r3 - r2 + b3 = r4 - r3 + + n1 = np.cross(b1, b2) + n2 = np.cross(b2, b3) + + n1_norm = np.linalg.norm(n1) + n2_norm = np.linalg.norm(n2) + b2_norm = np.linalg.norm(b2) + + if n1_norm < 1e-12 or n2_norm < 1e-12 or b2_norm < 1e-12: + return None + + n1_hat = n1 / n1_norm + n2_hat = n2 / n2_norm + b2_hat = b2 / b2_norm + + x = np.dot(n1_hat, n2_hat) + #y = np.dot(np.cross(n1_hat, b2_hat), n2_hat) + y = np.dot(np.cross(n1_hat, n2_hat), b2_hat) + return np.arctan2(y, x) + + def torsion_restraint_energy(ca, i, j, k, l, phi0_rad, kf_local): + phi = dihedral_phi(ca, i, j, k, l) + if phi is None: + return None + delta = np.arctan2(np.sin(phi - phi0_rad), np.cos(phi - phi0_rad)) + return 0.5 * kf_local * delta * delta + + h = 1.0e-4 # Bohr finite-difference step + + for c in condict: + print_if_level(f"Applying torsion constraint: {c}", self.printlevel, 1) + print_if_level(f"Torsion constraint force constant,kf, = {kf} (change by kf_dihedrals keyword)", self.printlevel, 1) + i, j, k, l, phi0_deg = c + phi0 = np.deg2rad(phi0_deg) + + E0 = torsion_restraint_energy(coords_au, i, j, k, l, phi0, kf) + if E0 is None: + print(f"Warning: degenerate torsion {i}-{j}-{k}-{l}. Skipping.") + continue + + energy_out += E0 + + if self.constrain_method == 'soft': + involved = [i, j, k, l] + for idx in involved: + for a in range(3): + cp = coords_au.copy() + cm = coords_au.copy() + cp[idx, a] += h + cm[idx, a] -= h + + Ep = torsion_restraint_energy(cp, i, j, k, l, phi0, kf) + Em = torsion_restraint_energy(cm, i, j, k, l, phi0, kf) + + if Ep is None or Em is None: + continue + + grad_out[idx, a] += (Ep - Em) / (2.0 * h) + + if self.printlevel >= 2: + phi = dihedral_phi(coords_au, i, j, k, l) + delta = np.arctan2(np.sin(phi - phi0), np.cos(phi - phi0)) + print(f" Soft torsion ({i},{j},{k},{l}): " + f"φ={np.rad2deg(phi):.3f}° target={phi0_deg:.3f}° " + f"delta={np.rad2deg(delta):.3f}° " + f"penalty={E0:.6f}") + + elif self.constrain_method == 'hard': + # Hard torsion is not safely enforced by gradient projection. + # Use shake_torsion after the geometry step instead. + if self.printlevel >= 2: + phi = dihedral_phi(coords_au, i, j, k, l) + delta = np.arctan2(np.sin(phi - phi0), np.cos(phi - phi0)) + print(f" Hard torsion requested for ({i},{j},{k},{l}), " + f"but gradient projection is not reliable for dihedrals. " + f"Current φ={np.rad2deg(phi):.3f}° target={phi0_deg:.3f}° " + f"delta={np.rad2deg(delta):.3f}°") + else: + print(f"Unknown constraint method '{self.constrain_method}'. Use 'hard' or 'soft'.") + + return energy_out, grad_out + + def shake_torsion(self, coords, i, j, k, l, phi0_deg, max_iter=50, tol_deg=0.01): + """ + Directly correct atomic positions to satisfy a torsion constraint. + Moves only atoms i and l (the terminal atoms) along the torsion direction. + Called after the geometry step, before the next gradient evaluation. + """ + phi0 = np.deg2rad(phi0_deg) + tol = np.deg2rad(tol_deg) + + coords_new = coords.copy() + + for _ in range(max_iter): + b1 = coords_new[j] - coords_new[i] + b2 = coords_new[k] - coords_new[j] + b3 = coords_new[l] - coords_new[k] + + n1 = np.cross(b1, b2) + n2 = np.cross(b2, b3) + ln1 = np.linalg.norm(n1) + ln2 = np.linalg.norm(n2) + lb2 = np.linalg.norm(b2) + + if ln1 < 1e-8 or ln2 < 1e-8: + break + + m1 = np.cross(n1, b2 / lb2) + cos_p = np.dot(n1, n2) / (ln1 * ln2) + sin_p = np.dot(m1, n2) / (ln1 * ln2) + phi = np.arctan2(sin_p, cos_p) + + #phi_wrapped = (phi + np.pi) % (2*np.pi) - np.pi + #phi0_wrapped = (phi0 + np.pi) % (2*np.pi) - np.pi + #delta = phi_wrapped - phi0_wrapped + #delta = (delta + np.pi) % (2*np.pi) - np.pi + delta = np.arctan2(np.sin(phi - phi0), np.cos(phi - phi0)) + + if abs(delta) < tol: + break + + # Rotate atom i around the b2 axis by -delta/2 + # and atom l around the b2 axis by +delta/2 + # This distributes the correction symmetrically + b2_hat = b2 / lb2 + + def rotate(point, center, axis, angle): + """Rotate point around axis through center by angle (radians).""" + p = point - center + c, s = np.cos(angle), np.sin(angle) + return center + (p * c + + np.cross(axis, p) * s + + axis * np.dot(axis, p) * (1 - c)) + + coords_new[i] = rotate(coords_new[i], coords_new[j], b2_hat, -delta/2) + coords_new[l] = rotate(coords_new[l], coords_new[k], -b2_hat, delta/2) + + return coords_new + + def align_to_standard_orientation(self, fragment_coords, cell_vectors): + """ + Rotates the entire system (atoms and cell) into the standard + upper-triangular orientation. + + cell_vectors: 3x3 matrix where rows are [a, b, c] + fragment_coords: Nx3 array of atomic positions + """ + # 1. Transpose cell_vectors because QR works on columns + H = cell_vectors.T + + # 2. QR Decomposition + # H = Q * R -> R is the upper triangular matrix we want + Q, R = np.linalg.qr(H) + + # 3. Handle 'Flip' cases + # QR can sometimes return negative diagonal elements. + # We want lengths (a_x, b_y, c_z) to be positive. + d = np.sign(np.diag(R)) + # If a diagonal is 0, we treat it as positive + d[d == 0] = 1 + + # Correct Q and R so diagonals of R are positive + Q = Q * d + R = (R.T * d).T + + # 4. New Cell Vectors (R transposed back to rows) + new_cell_vectors = R.T + + # 5. New Atomic Coordinates + # We rotate the atoms using the same rotation matrix Q + # Since H_new = Q.T @ H_old, we use Q.T for the atoms + new_coords = np.dot(fragment_coords, Q) + + return new_coords, new_cell_vectors + + def compute_bfgs_step(self, current_grad, current_coords): + # Flatten everything to 1D vectors for linear algebra + g = current_grad.flatten() + x = current_coords.flatten() + n = len(g) + + # 1. INITIALIZATION + # On the first step, we don't have a Hessian yet. + # We start with an Identity matrix (equivalent to Steepest Descent). + if not hasattr(self, 'Hess_inv') or self.Hess_inv is None: + print("BFGS: First step. SD step with rate:", self.rate) + self.Hess_inv = np.eye(n) * self.rate + self.g_old = g + self.x_old = x + return -(self.rate * g).reshape(current_grad.shape) + + # 2. COMPUTE DIFFERENCES + s = x - self.x_old # Change in coordinates + y = g - self.g_old # Change in gradient + + # 3. UPDATE INVERSE HESSIAN (Sherman-Morrison-Woodbury formula) + # We only update if the curvature condition (y.s > 0) is met to maintain stability + rho_inv = np.dot(y, s) + if rho_inv > 1e-9: + rho = 1.0 / rho_inv + I = np.eye(n) + + # BFGS Update Formula + A = I - np.outer(s, y) * rho + B = I - np.outer(y, s) * rho + self.Hess_inv = np.dot(A, np.dot(self.Hess_inv, B)) + (rho * np.outer(s, s)) + else: + # If curvature is bad, reset the Hessian to Identity to avoid exploding + print("BFGS: Curvature condition not met, resetting Hessian.") + self.Hess_inv = np.eye(n) * self.rate + + # 4. COMPUTE STEP + # p = -Hess_inv * g + step_vec = -np.dot(self.Hess_inv, g) + + # Update histories + self.g_old = g + self.x_old = x + + # Return reshaped to (N+4, 3) + return step_vec.reshape(current_grad.shape) + + # Split coords into atomic and lattice + def split_coords(self,supercoords): + + R_geo = supercoords[:-4] + origin = supercoords[-4] + H_geo = supercoords[-3:] - origin + s = np.dot(R_geo - origin, self.H_ref_inv) + R_phys = np.dot(s, H_geo) + origin + return R_phys, H_geo + + def calculate_reg_gradient(self,coords): + # E + G from theory + energy,gradient=self.theory.run(current_coords=coords, elems=self.elems_phys, + charge=self.fragment.charge, mult=self.fragment.mult, Grad=True) + return energy, gradient + def calculate_supergradient(self,supercoords): + + R_phys, H_geo = self.split_coords(supercoords) + + # E + G from theory + energy,grad_phys=self.theory.run(current_coords=R_phys, elems=self.elems_phys, + charge=self.fragment.charge, mult=self.fragment.mult, Grad=True) + + # Transformation + # M is the transformation matrix: R_phys = R_geo @ M + # TODO: check units + M = np.dot(self.H_ref_inv, H_geo) + grad_Rgeo = np.dot(grad_phys, M.T) + + # Lattice gradient and masking + # Total lattice gradient: current theory cell-gradient + convection + #grad_latt_total = self.theory.cell_gradient + grad_latt_total = self.theory.get_cell_gradient() + # Standard orientation mask: + # This zeros out: a_y, a_z, and b_z + mask = np.array([ + [1, 0, 0], # dE/dax (ay, az frozen) + [1, 1, 0], # dE/dbx, dE/dby (bz frozen) + [1, 1, 1] # dE/dcx, dE/dcy, dE/dcz (all free) + ]) + grad_latt_masked = grad_latt_total * mask + # scaling lattice gradient + n_atoms = len(grad_Rgeo) + scaling_factor = 1.0 / n_atoms + grad_latt_preconditioned = grad_latt_masked * scaling_factor + # + grad_latt_final=grad_latt_preconditioned + # Making sure origin is zero + grad_origin = np.zeros((1, 3)) + # Final modified gradient to pass to geomeTRIC + mod_gradient = np.concatenate([ + grad_Rgeo, # (N, 3) + grad_origin, # (1, 3) + grad_latt_final # (3, 3) + ], axis=0) + + return energy, mod_gradient + + def compute_step(self,gradient,currcoords): + + if self.PBC: + # 1. Separate rates for Atoms vs Cell (Preconditioning) + # Often the cell needs a rate ~10x smaller than atoms in Cartesian space + rate_mask = np.ones_like(gradient) + rate_mask[-3:] *= self.scaling_rate_cell # Dampen lattice steps + effective_gradient = gradient * rate_mask + else: + effective_gradient = gradient + # Calculate delta step (in Bohrs) + if self.step_algo.lower() =="sd": + print("Taking steepest descent step") + delta_au = - (self.rate * effective_gradient) + elif self.step_algo == "damped-MD": + print("Taking damped-MD step") + print("velocity:", self.velocity) + self.velocity = (self.momentum * self.velocity) - (self.rate * effective_gradient) + print("velocity:", self.velocity) + delta_au = self.velocity + # Simple "Power" check: If we go against the gradient, kill velocity + if np.sum(delta_au * gradient) > 0: + self.velocity *= 0.0 + elif self.step_algo.lower() == "nesterov": + # Storing old + velocity_old = self.velocity.copy() + print("Taking Nesterov momentum step") + self.velocity = (self.momentum * self.velocity) - (self.rate * effective_gradient) + nesterov_update = -self.momentum * velocity_old + (1 + self.momentum) * self.velocity + delta_au = nesterov_update + elif self.step_algo.lower() == "bfgs": + print("Taking BFGS step") + delta_au = self.compute_bfgs_step(gradient, currcoords) + elif self.step_algo.lower() == "cg": + print("Taking conjugate gradient step") + if self.iteration == 0: + self.search_dir = effective_gradient + self.prev_grad = None + else: + # Polak-Ribière formula for beta + diff = effective_gradient - self.prev_grad + beta = np.sum(gradient * diff) / np.sum(self.prev_grad * self.prev_grad) + beta = max(0, beta) # Standard 'reset' for CG + self.search_dir = effective_gradient + (beta * self.search_dir) + + delta_au = - (self.rate * self.search_dir) + self.prev_grad = gradient.copy() + else: + print("Unknown step_algo") + ashexit() + + return delta_au + + def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=None): + self.run_init_time=time.time() + #print("Cart_opt: --------------------------------------------") + #print("Cart_opt: time init:", time.time()-self.run_init_time, "seconds") + # Update self fragment if a run fragment was provided + if fragment is not None: + self.fragment=fragment + self.elems_phys=fragment.elems + else: + self.elems_phys=self.fragment.elems + + if self.print_atoms_list is None: + self.print_atoms_list = self.fragment.allatoms + + # Update self theory if a run fragment was provided + if theory is not None: + self.theory=theory + + # Update constraints if provided + if constraints is not None: + self.constraints=constraints + + # Printlevel in Fragment + self.fragment.printlevel=self.printlevel + + self.charge, self.mult = check_charge_mult(charge, mult, self.theory.theorytype, self.fragment, + "CartOptimizer", theory=self.theory, printlevel=self.printlevel) + # Defining coordinates to use, PBC vs. non-PBC + if self.PBC: + print("Cart_optimizer: Running periodic optimization in Cartesian coordinates with cell optimization") + self.setup_PBC() + currcoords = np.concatenate([ + self.fragment.coords, # (N, 3) + np.zeros((1, 3)), # (1, 3) + self.theory.periodic_cell_vectors, # (3, 3) + ], axis=0) + opt_type_label="PBC" + else: + print("Cart_optimizer: Running non-periodic optimization in Cartesian coordinates") + currcoords = self.fragment.coords + opt_type_label="NonPBC" + + # Initialize velocity for momentum-based step algorithms + self.velocity = np.zeros((len(currcoords),3)) + + for file in ["Fragment-currentgeo.xyz", "PBC_opt_traj.xyz", "NonPBC_opt_traj.xyz"]: + try: + os.remove(file) + except: + pass + + #print("Cart_opt: time until LOOP:", time.time()-self.run_init_time, "seconds") + # LOOP + for iteration in range(0,self.maxiter): + self.iteration=iteration + print("="*40) + print(f"{opt_type_label} optimization step", iteration) + print("="*40) + + if self.PBC: + currcoords_au = currcoords*self.ang2bohr + R_phys, H_geo = self.split_coords(currcoords) + #Update cell + self.theory.update_cell(H_geo) + else: + R_phys = currcoords + currcoords_au = R_phys*self.ang2bohr + + # Update coordinates of atoms and cell + self.fragment.replace_coords(self.fragment.elems, R_phys, conn=False) + + # 0. PRINTING ACTIVE GEOMETRY IN EACH ITERATION + self.fragment.write_xyzfile(xyzfilename="Fragment-currentgeo.xyz") + self.fragment.write_xyzfile(xyzfilename=f"{opt_type_label}_opt_traj.xyz",writemode="a") + if self.printlevel >= 1: + print(f"Current geometry (Å) in step {iteration} (print_atoms_list region)") + print("---------------------------------------------------") + print_coords_for_atoms(R_phys, self.elems_phys,self.fragment.allatoms) + print("") + if self.PBC: + print(f"Current cell vectors (Å):{self.theory.periodic_cell_vectors}") + print(f"Current cell volume (Å):{cell_volume(H_geo)}") + + #print("Cart_opt: time until e+g step:", time.time()-self.run_init_time, "seconds") + ######################################### + # 1. Compute energy and gradient + ######################################### + if self.PBC: + energy, supergradient = self.calculate_supergradient(currcoords) + else: + energy, supergradient = self.calculate_reg_gradient(currcoords) + prev_supgrad = supergradient.copy() + #print("Cart_opt: time until after e+g step:", time.time()-self.run_init_time, "seconds") + # 1b. Apply all constraints + self.all_cartesian_constraints={} + if self.constraints: + print_if_level(f"Applying constraints: {self.constraints}", self.printlevel,2) + if 'bond' in self.constraints or 'distance' in self.constraints: + energy, supergradient = self.apply_bond_constraints(R_phys, supergradient, energy, kf=self.kf_bonds) + #print("Cart_opt: time until after bondcon step:", time.time()-self.run_init_time, "seconds") + if 'angle' in self.constraints: + energy, supergradient = self.apply_angle_constraints(R_phys, supergradient, energy, kf=self.kf_angles) + if 'torsion' in self.constraints or 'dihedral' in self.constraints: + energy, supergradient = self.apply_dihedral_constraints(R_phys, supergradient, energy, kf=self.kf_dihedrals) + #print("supergradient after dihedral constraints:", supergradient) + #print("Cart_opt: time until after dihedralcon step:", time.time()-self.run_init_time, "seconds") + # Cartesian constraints. prepare + if 'xyz' in self.constraints: + for i in self.constraints['xyz']: + self.all_cartesian_constraints[i] = 'xyz' + if 'x' in self.constraints: + for i in self.constraints['x']: + self.all_cartesian_constraints[i] = 'x' + if 'y' in self.constraints: + for i in self.constraints['y']: + self.all_cartesian_constraints[i] = 'y' + if 'z' in self.constraints: + for i in self.constraints['z']: + self.all_cartesian_constraints[i] = 'z' + if 'xy' in self.constraints: + for i in self.constraints['xy']: + self.all_cartesian_constraints[i] = 'xy' + if 'yz' in self.constraints: + for i in self.constraints['yz']: + self.all_cartesian_constraints[i] = 'yz' + if 'xz' in self.constraints: + for i in self.constraints['xz']: + self.all_cartesian_constraints[i] = 'xz' + # 1c. Apply frozen atoms + if self.frozen_atoms or len(self.all_cartesian_constraints)>0: + print_if_level("We have frozen atoms or cartesian constraints, applying them to the gradient...", self.printlevel,2) + # Combining frozen atoms list with all_cartesian_constraints dict + if isinstance(self.frozen_atoms, list): + for i in self.frozen_atoms: + self.all_cartesian_constraints[i] = 'xyz' + print_if_level(f"All Cartesian constraints: {self.all_cartesian_constraints}", self.printlevel,2) + + supergradient = self.apply_cartesian_constraints(supergradient) + #print("Cart_opt: time until after cartesiancon step:", time.time()-self.run_init_time, "seconds") + + ######################################### + # 2. Check convergence + ######################################### + grad_rms_atoms = np.sqrt(np.mean(supergradient**2)) + grad_max_atoms = abs(max(supergradient.min(), supergradient.max(), key=abs)) + + if self.PBC: + grad_rms_atoms = np.sqrt(np.mean(supergradient[:-4]**2)) + grad_max_atoms = abs(max(supergradient[:-4].min(), supergradient[:-4].max(), key=abs)) + grad_rms_cell = np.sqrt(np.mean(supergradient[-3:]**2)) + grad_max_cell = abs(max(supergradient[-3:].min(), supergradient[-3:].max(), key=abs)) + print(f"Step {iteration:3d} Energy: {energy:.10f} Eh RMSG(atoms): {grad_rms_atoms:.6f} MaxG(atoms): {grad_max_atoms:.6f} RMSG(cell): {grad_rms_cell:.6f} MaxG(cell): {grad_max_cell:.6f} Cell-volume {cell_volume(self.theory.periodic_cell_vectors):.2f} Å") + + if grad_rms_atoms < self.conv_criteria['convergence_grms'] and grad_max_atoms < self.conv_criteria['convergence_gmax'] and \ + grad_rms_cell < self.conv_criteria['convergence_grms'] and grad_max_cell < self.conv_criteria['convergence_gmax']: + print() + print(f"Optimization converged in {iteration+1} iterations. Convergence criteria ({self.conv_criteria}) fulfilled") + print(f"Final cell vectors (Å):{self.theory.periodic_cell_vectors}") + print(f"Final cell volume (Å):{cell_volume(self.theory.periodic_cell_vectors)}") + print(f"Final cell parameters: ({cell_vectors_to_params(self.theory.periodic_cell_vectors)})") + print() + print(f"Final optimized energy: {energy} Eh") + + #Writing out fragment file and XYZ file + self.fragment.print_system(filename='Fragment-optimized.ygg') + self.fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') + self.fragment.set_energy(energy) + print("\nFinal geometry") + self.fragment.print_coords() + print() + if self.printlevel >= 2: + if len(self.print_atoms_list) < 50: + print_internal_coordinate_table_new(self.fragment,actatoms=self.print_atoms_list) + print() + + print("PBC_format_option:", self.PBC_format_option) + if self.PBC_format_option.upper() =="CIF": + convert_to_pbcfile=write_CIF_file + file_ext='cif' + elif self.PBC_format_option.upper() =="XSF": + convert_to_pbcfile=write_XSF_file + file_ext='xsf' + elif self.PBC_format_option.upper() == "POSCAR": + convert_to_pbcfile=write_POSCAR_file + file_ext='POSCAR' + convert_to_pbcfile(self.fragment.coords,self.fragment.elems,cellvectors=self.theory.periodic_cell_vectors, + filename=f"Fragment-optimized.{file_ext}") + #Now returning final Results object + result = ASH_Results(label="Optimizer", energy=energy) + if self.result_write_to_disk is True: + result.write_to_disk(filename="ASH_Cart_optimizer.result") + return result + else: + print(f"Step {iteration:3d} Energy: {energy:.10f} Eh RMSG(atoms): {grad_rms_atoms:.6f} MaxG(atoms): {grad_max_atoms:.6f}") + if grad_rms_atoms < self.conv_criteria['convergence_grms'] and grad_max_atoms < self.conv_criteria['convergence_gmax'] : + + if self.printlevel >= 1: + print() + print(f"Optimization converged in {iteration+1} iterations. Convergence criteria ({self.conv_criteria}) fulfilled") + print() + print(f"Final optimized energy: {energy} Eh") + print("Cart_opt: time CONVERGENCE:", time.time()-self.run_init_time, "seconds") + + # Writing out fragment file and XYZ file + self.fragment.print_system(filename='Fragment-optimized.ygg') + self.fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') + self.fragment.set_energy(energy) + print("\nFinal geometry") + self.fragment.print_coords() + print() + if self.printlevel >= 2: + if len(self.print_atoms_list) < 50: + print_internal_coordinate_table_new(self.fragment,actatoms=self.print_atoms_list) + print() + + #Now returning final Results object + result = ASH_Results(label="Optimizer", energy=energy) + if self.result_write_to_disk is True: + result.write_to_disk(filename="ASH_Cart_optimizer.result") + return result + + ######################################### + # 3. Take step + ######################################### + # Compute step + #print("Cart_opt: time until before compute step:", time.time()-self.run_init_time, "seconds") + delta_au = self.compute_step(supergradient, currcoords) + print_if_level(f"Computed step: {delta_au}", self.printlevel,2) + + if self.PBC: + # Separate check for the lattice part (last 3 rows of delta_au) + lattice_step = delta_au[-3:] + if np.max(np.abs(lattice_step)) > (0.05 * self.ang2bohr): # Cap lattice at 0.05 Å + scale_latt = (0.05 * self.ang2bohr) / np.max(np.abs(lattice_step)) + delta_au[-3:] *= scale_latt + print_if_level(f"Lattice-specific scaling applied: {scale_latt:.3f}", self.printlevel,2) + + # Scale down step if required + if np.max(np.abs(delta_au)) > self.max_step_au: + print_if_level(f"Step scale down: {np.max(np.abs(delta_au))} > max_step_au: {self.max_step_au})", self.printlevel,2) + delta_au = delta_au * (self.max_step_au / np.max(np.abs(delta_au))) + print_if_level(f"Actual step: {delta_au}", self.printlevel,2) + + # Take the step + currcoords_au += delta_au + print_if_level(f"Cart_opt: time until after step: {time.time()-self.run_init_time} seconds", self.printlevel,2) + # Converting coordinates from Bohr to Angstrom + currcoords = currcoords_au / self.ang2bohr + + #for c in self.constraints['dihedral']: + # i, j, k, l, phi0_deg = c + # currcoords_new_ang = self.shake_torsion(currcoords, i, j, k, l, phi0_deg) + #currcoords_au = currcoords_new_ang * self.ang2bohr + #currcoords = currcoords_new_ang + + if iteration == self.maxiter-1: + print("Number of max iterations reached without reaching convergence. Sad...") \ No newline at end of file diff --git a/ash/interfaces/interface_CFour.py b/ash/interfaces/interface_CFour.py index f011b238f..5add5535a 100644 --- a/ash/interfaces/interface_CFour.py +++ b/ash/interfaces/interface_CFour.py @@ -134,8 +134,8 @@ def __init__(self, cfourdir=None, printlevel=2, cfouroptions=None, numcores=1, #Copying ASH basis file from ASH-dir to current dir if requested if ash_basisfile != None: #ash_basisfile - print("Copying ASH basis-file {} from {} to current directory".format(ash_basisfile,ash.settings_ash.ashpath+'/databases/basis-sets/cfour/')) - shutil.copyfile(ash.settings_ash.ashpath+'/databases/basis-sets/cfour/'+ash_basisfile, 'GENBAS') + print("Copying ASH basis-file {} from {} to current directory".format(ash_basisfile,ash.settings_ash.ashpath+'/databases/basis_sets/cfour/')) + shutil.copyfile(ash.settings_ash.ashpath+'/databases/basis_sets/cfour/'+ash_basisfile, 'GENBAS') #Copying basis-file from any dir to current dir elif basisfile != None: print(f"Copying basis-file {basisfile} to current directory as GENBAS") diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index b8561e77b..b2047de4c 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -6,7 +6,9 @@ from ash.functions.functions_general import ashexit, BC, print_time_rel,print_line_with_mainheader import ash.settings_ash -from ash.modules.module_coords import write_xyzfile, write_pdbfile,cubic_box_size,bounding_box_dimensions +from ash.modules.module_coords import write_xyzfile, write_pdbfile +from ash.modules.module_coords_PBC import cubic_box_size,bounding_box_dimensions +from ash.modules.module_coords_PBC import cell_vectors_to_params, cell_params_to_vectors, cubic_box_size from ash.functions.functions_parallel import check_OpenMPI # Dictionary of element radii in Angstrom for use with CP2K for GEEP embedding @@ -38,12 +40,17 @@ # 'XTB' class CP2KTheory: def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel=2, basis_dict=None, potential_dict=None, label="CP2K", - periodic=False, periodic_type='XYZ', qm_periodic_type=None, xtb_periodic=False, cell_dimensions=None, cell_vectors=None, + periodic=False, periodic_type='XYZ', qm_periodic_type=None, stress_tensor=True, stress_tensor_algo="DIAGONAL_ANALYTICAL", + xtb_type='GFN2', xtb_tblite=False, + user_input_dft=None, vdwpotential=None, + cell_dimensions=None, cell_vectors=None, qm_cell_dims=None, qm_cell_shift_par=6.0, wavelet_scf_type=40, functional=None, psolver='wavelet', potential_file='POTENTIAL', basis_file='BASIS', - basis_method='GAPW', ngrids=4, cutoff=250, rel_cutoff=60, + basis_method='GAPW', ngrids=4, xc_finer_grid=False, + cutoff=250, rel_cutoff=60, + kpoint_settings=None, method='QUICKSTEP', numcores=1, parallelization='OMP', mixed_mpi_procs=None, mixed_omp_threads=None, - center_coords=True, scf_maxiter=50, outer_scf_maxiter=10, scf_convergence=1e-6, eps_default=1e-10, + center_coords=False, scf_maxiter=50, outer_scf_maxiter=10, scf_convergence=1e-6, eps_default=1e-10, coupling='GAUSSIAN', GEEP_num_gauss=6, MM_radius_scaling=1, mm_radii=None, OT=True, OT_minimizer='DIIS', OT_preconditioner='FULL_ALL', OT_linesearch='3PNT', outer_SCF=True, outer_SCF_optimizer='SD', OT_energy_gap=0.08): @@ -53,44 +60,66 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel self.label=label self.analytic_hessian=False print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") - - #EARLY EXITS - if basis_dict is None: - print("basis_dict keyword is required") - ashexit() - if potential_dict is None: - print("potential_dict keyword is required") - ashexit() - if functional is None: - if basis_method.upper() != "XTB": + # EARLY EXITS + if basis_method.upper() != "XTB": + print("This is a regular CP2K DFT theory") + if basis_dict is None: + print("basis_dict keyword is required") + ashexit() + if potential_dict is None: + print("potential_dict keyword is required") + ashexit() + if functional is None: print("functional keyword is required for PW andd GPW ") - ashexit() + ashexit() + else: + print("This is a CP2K xTB theory") + if xtb_tblite: + print("xtb_tblite True. Using tblite version of xTB.") + print("Warning: disabling OT for xtb-tblite") + OT=False + else: + print("xtb_tblite False. Using built-in version of xTB.") + print("xtb_type:", xtb_type) - #NOTE: We still define a cell even though we may not be periodic - #If no cell provided: CONTINUE and guess cell size later + # NOTE: We still define a cell even though we may not be doing periodic calc + # If no cell provided: CONTINUE and guess cell size later if cell_dimensions is None and cell_vectors is None: print("Warning: Neither cell_dimensions or cell_vectors have been provided.") - print("This is not good but ASH will continue and try to guess the cell size from the QM-coordinates") + print("This is non-ideal but ASH will continue and try to guess the cell size from the QM-coordinates") if cell_dimensions is not None and cell_vectors is not None: print("Error: cell_dimensions and cell_vectors can not both be provided") ashexit() - #PERIODIC logic + # PERIODIC logic + self.xtb_periodic=False if periodic is True: print("Periodic is True") + if basis_method.upper() == "XTB": + print("Setting xtb_periodic to be True") + self.xtb_periodic=True self.periodic_type=periodic_type print("Periodic type:", self.periodic_type) if psolver.upper() == 'MT': print("Error: For periodic simulations the Poisson solver (psolver) can not be MT.") ashexit() + + if cell_dimensions is not None: + print("periodic_cell_dimensions:", cell_dimensions) + self.periodic_cell_dimensions = cell_dimensions + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(cell_dimensions) + elif cell_vectors is not None: + self.periodic_cell_vectors = cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(cell_vectors) else: print("Periodic is False") self.periodic_type='NONE' print("PERIODIC_TYPE:", self.periodic_type) - #Parallelization + # Parallelization self.numcores=numcores - #Type of parallelization strategy. 'OMP','MPI','Mixed' + # Type of parallelization strategy. 'OMP','MPI','Mixed' self.parallelization=parallelization self.mixed_mpi_procs=mixed_mpi_procs #Mixed only: self.mixed_omp_threads=mixed_omp_threads #Mixed only: @@ -131,6 +160,30 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel print("Numcores=1. No parallelization of CP2K requested") self.parallelization=None + # User input DFT section + # For more flexibility the user can also provide a file that contains the DFT section of a CP2K input. + # This will then be used instead of the DFT section generated by ASH. + # This allows the user to use features that are not currently implemented in the ASH input generator. + self.user_input_dft = None + if user_input_dft is not None: + print("User has provided custom DFT-section input. This will be used instead of input generated by ASH.") + if isinstance(user_input_dft, str): + print("User DFT input (string provided):") + # Check if the string is a path to a file + if os.path.isfile(user_input_dft): + print(f"User input is a file path. Reading DFT input from file: {user_input_dft}") + with open(user_input_dft, 'r') as f: + self.user_input_dft = f.read() + else: + print("User input is a string but not a valid file path. Checking if it looks like a DFT section (basic check for &DFT keyword)") + if "&DFT" in user_input_dft: + print("User input string looks like a DFT section. Using it as is.") + self.user_input_dft = user_input_dft + print(self.user_input_dft) + else: + print("Unknown format for user_input_dft. It should be either a string containing the DFT section or a file path to a file containing the DFT section.") + ashexit("Exiting") + # Printlevel self.printlevel=printlevel self.filename=filename @@ -149,7 +202,8 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel self.psolver=psolver self.wavelet_scf_type=wavelet_scf_type self.qm_periodic_type=qm_periodic_type - self.xtb_periodic=xtb_periodic # Boolean, xtB Ewald True or False + self.xtb_type=xtb_type # xTB method to use. Options: 'GFN2', 'GFN1', 'GFN0' + self.xtb_tblite=xtb_tblite # Boolean, whether to use the tblite-library version of xTB # self.cell_length=cell_length #Total cell length (full system including MM if QM/MM) self.cell_dimensions=cell_dimensions #Cell dimensions. For full system self.cell_vectors=cell_vectors #Cell vectors. For full system @@ -158,7 +212,7 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel self.functional=functional self.center_coords=center_coords - #SCF onvergence stuff + # SCF onvergence stuff self.OT=OT self.OT_minimizer=OT_minimizer self.OT_preconditioner=OT_preconditioner @@ -167,8 +221,16 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel self.outer_SCF_optimizer=outer_SCF_optimizer self.OT_energy_gap=OT_energy_gap + # Dispersion corrections + self.vdwpotential=vdwpotential + + # Stress tensor + self.stress_tensor=stress_tensor + self.stress_tensor_algo = stress_tensor_algo + #Grid stuff self.ngrids=ngrids + self.xc_finer_grid=xc_finer_grid self.cutoff=cutoff self.rel_cutoff=rel_cutoff self.scf_convergence=scf_convergence @@ -176,6 +238,9 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel self.outer_scf_maxiter=outer_scf_maxiter self.eps_default=eps_default + # K-points + self.kpoint_settings=kpoint_settings + #QM/MM self.coupling=coupling self.GEEP_num_gauss=GEEP_num_gauss @@ -197,12 +262,12 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel print("Periodic:", self.periodic) print("Periodic type:", self.periodic_type) print("QM periodic type:", self.qm_periodic_type) - print("XTB periodic:", self.xtb_periodic) print("Cell dimensions:", self.cell_dimensions) print("Cell vectors:", self.cell_vectors) print("QM cell dimensions:", self.qm_cell_dims) print("QM cell shift par:", self.qm_cell_shift_par) print("Wavelet SCF type:", self.wavelet_scf_type) + print("vdwpotential:", self.vdwpotential) print("") print("Printlevel:", self.printlevel) print("Parallelization:", self.parallelization) @@ -220,13 +285,28 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel print("Outer SCF optimizer:", self.outer_SCF_optimizer) print("OT energy gap:", self.OT_energy_gap) - #Set numcores method def set_numcores(self,numcores): self.numcores=numcores + def cleanup(): print(f"self.theorynamelabel cleanup not yet implemented.") + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + def get_cell_gradient(self): + return self.cell_gradient + # Run function. Takes coords, elems etc. arguments and computes E or E+G. def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, elems=None, Grad=False, PC=False, numcores=None, restart=False, label=None, @@ -275,7 +355,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el print("QM periodic type:", self.qm_periodic_type) print("Poisson solver", self.psolver) - print_time_rel(module_init_time, modulename=f'CP2K run-prep1', moduleindex=2) + #print_time_rel(module_init_time, modulename=f'CP2K run-prep1', moduleindex=2) #Case: QM/MM CP2K job if PC is True: print("PC true") @@ -302,7 +382,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el if self.cell_vectors is not None: print("cell_vectors:", self.cell_vectors) - print_time_rel(module_init_time, modulename=f'CP2K run-prep2', moduleindex=2) + #print_time_rel(module_init_time, modulename=f'CP2K run-prep2', moduleindex=2) #QM-CELL if self.qm_cell_dims is None: print("Warning: QM-cell box dimensions have not been set by user (qm_cell_dims keyword)") @@ -329,9 +409,9 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el dummy_coords = np.concatenate((current_coords,current_MM_coords),axis=0) dummy_charges = [0.0]*len(qm_elems) + MMcharges system_xyzfile="system_cp2k" - print_time_rel(module_init_time, modulename=f'CP2K run-prep3a', moduleindex=2) + #print_time_rel(module_init_time, modulename=f'CP2K run-prep3a', moduleindex=2) write_xyzfile(dummy_elem_list, dummy_coords, f"{system_xyzfile}", printlevel=1) - print_time_rel(module_init_time, modulename=f'CP2K run-prep3b', moduleindex=2) + #print_time_rel(module_init_time, modulename=f'CP2K run-prep3b', moduleindex=2) #Telling CP2K which atoms are QM #Dictionary with QM-atom indices (for full system), grouped by element qm_kind_dict={} @@ -355,7 +435,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el with open("charges.inc", 'w') as incfile: incfile.writelines(all_charges_lines) - print_time_rel(module_init_time, modulename=f'CP2K run-prep4c', moduleindex=2) + #print_time_rel(module_init_time, modulename=f'CP2K run-prep4c', moduleindex=2) #3. Write CP2K QM/MM inputfile write_CP2K_input(method='QMMM', jobname='ash', center_coords=self.center_coords, qm_elems=qm_elems, basis_dict=self.basis_dict, potential_dict=self.potential_dict, @@ -363,10 +443,12 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el functional=self.functional, restartfile=None, mgrid_commensurate=True, Grad=Grad, filename='cp2k', charge=charge, mult=mult, coordfile=system_xyzfile, - cell_dimensions=self.cell_dimensions, - cell_vectors=self.cell_vectors, + stress_tensor=self.stress_tensor, stress_tensor_algo=self.stress_tensor_algo, + user_input_dft=self.user_input_dft, vdwpotential=self.vdwpotential, + kpoint_settings=self.kpoint_settings, + cell_vectors=self.periodic_cell_vectors, qm_cell_dims=self.qm_cell_dims, qm_periodic_type=self.qm_periodic_type, - xtb_periodic=self.xtb_periodic, + xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type, xtb_tblite=self.xtb_tblite, basis_file=self.basis_file, potential_file=self.potential_file, periodic_type=self.periodic_type, psolver=self.psolver, coupling=self.coupling, GEEP_num_gauss=self.GEEP_num_gauss, @@ -374,13 +456,13 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el qm_kind_dict=qm_kind_dict, mm_kind_list=mm_kind_list, scf_convergence=self.scf_convergence, eps_default=self.eps_default, scf_maxiter=self.scf_maxiter, outer_scf_maxiter=self.outer_scf_maxiter, - ngrids=self.ngrids, cutoff=self.cutoff, rel_cutoff=self.rel_cutoff, printlevel=self.printlevel, + ngrids=self.ngrids, xc_finer_grid=self.xc_finer_grid, cutoff=self.cutoff, rel_cutoff=self.rel_cutoff, printlevel=self.printlevel, OT=self.OT, OT_minimizer=self.OT_minimizer, OT_preconditioner=self.OT_preconditioner, OT_linesearch=self.OT_linesearch, outer_SCF=self.outer_SCF, outer_SCF_optimizer=self.outer_SCF_optimizer, OT_energy_gap=self.OT_energy_gap) else: #No QM/MM #QM-CELL - if self.cell_dimensions is None: + if self.cell_dimensions is None and self.cell_vectors is None: print("Warning: cell dimensions have not been set by user") print("Now estimating cell box dimensions from the system oordinates.") if self.psolver == 'wavelet': @@ -406,14 +488,17 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el basis_method=self.basis_method, wavelet_scf_type=self.wavelet_scf_type, functional=self.functional, restartfile=None, Grad=Grad, filename='cp2k', charge=charge, mult=mult, + stress_tensor=self.stress_tensor, stress_tensor_algo=self.stress_tensor_algo, + user_input_dft=self.user_input_dft, vdwpotential=self.vdwpotential, + kpoint_settings=self.kpoint_settings, coordfile=system_xyzfile, scf_convergence=self.scf_convergence, eps_default=self.eps_default, scf_maxiter=self.scf_maxiter, outer_scf_maxiter=self.outer_scf_maxiter, + ngrids=self.ngrids, xc_finer_grid=self.xc_finer_grid, cutoff=self.cutoff, rel_cutoff=self.rel_cutoff, printlevel=self.printlevel, periodic_type=self.periodic_type, - xtb_periodic=self.xtb_periodic, - cell_dimensions=self.cell_dimensions, - cell_vectors=self.cell_vectors, + xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type,xtb_tblite=self.xtb_tblite, + cell_vectors=self.periodic_cell_vectors, basis_file=self.basis_file, potential_file=self.potential_file, - psolver=self.psolver, printlevel=self.printlevel, + psolver=self.psolver, OT=self.OT, OT_minimizer=self.OT_minimizer, OT_preconditioner=self.OT_preconditioner, OT_linesearch=self.OT_linesearch, outer_SCF=self.outer_SCF, outer_SCF_optimizer=self.outer_SCF_optimizer, OT_energy_gap=self.OT_energy_gap) @@ -422,8 +507,13 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el os.remove(f'ash-{self.filename}-1_0.xyz') except: pass - print_time_rel(module_init_time, modulename=f'CP2K run-prep5', moduleindex=2) - #Check for BASIS and POTENTIAL FILES before calling + #Delete old forces file if present + try: + os.remove(f'ash-{self.filename}-1_0.stress_tensor') + except: + pass + #print_time_rel(module_init_time, modulename=f'CP2K run-prep5', moduleindex=2) + # Check for BASIS and POTENTIAL FILES before calling print("Checking if POTENTIAL file exists in current dir") if os.path.isfile("POTENTIAL") is True: print(f"File exists in current directory: {os.getcwd()}") @@ -434,7 +524,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el shutil.copy(f"../POTENTIAL", f"./POTENTIAL") else: print("No file found in parent dir. Using GTHpotential file from ASH. Copying to dir as POTENTIAL") - shutil.copyfile(ash.settings_ash.ashpath+'/databases/basis-sets/cp2k/GTH_POTENTIALS', './POTENTIAL') + shutil.copyfile(ash.settings_ash.ashpath+'/databases/basis_sets/cp2k/GTH_POTENTIALS', './POTENTIAL') print("Checking if BASIS file exists in current dir") if os.path.isfile("BASIS") is True: print(f"File exists in current directory: {os.getcwd()}") @@ -445,29 +535,35 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el shutil.copy(f"../BASIS", f"./BASIS") else: print("No file found in parent dir. Using basis set file from ASH. Copying to dir as BASIS") - shutil.copyfile(ash.settings_ash.ashpath+'/databases/basis-sets/cp2k/BASIS_MOLOPT', './BASIS') - print_time_rel(module_init_time, modulename=f'CP2K run-prep6', moduleindex=2) - #Timing for Run-prep + shutil.copyfile(ash.settings_ash.ashpath+'/databases/basis_sets/cp2k/BASIS_MOLOPT', './BASIS') + #print_time_rel(module_init_time, modulename=f'CP2K run-prep6', moduleindex=2) + # Timing for Run-prep print_time_rel(module_init_time, modulename=f'CP2K run-prep', moduleindex=2) - #Run CP2K + # Run CP2K if self.parallelization == 'Mixed': run_CP2K(self.cp2kdir,self.cp2k_bin_name,self.filename,numcores=self.numcores,paramethod=self.parallelization, mixed_mpi_procs=self.mixed_mpi_procs, mixed_omp_threads=self.mixed_omp_threads) else: run_CP2K(self.cp2kdir,self.cp2k_bin_name,self.filename,numcores=self.numcores,paramethod=self.parallelization) - - #Grab energy + # Grab energy self.energy=grab_energy_cp2k(self.filename+'.out',method=self.method) print(f"Single-point {self.theorynamelabel} energy:", self.energy) print(BC.OKBLUE, BC.BOLD, f"------------ENDING {self.theorynamelabel} INTERFACE-------------", BC.END) - #Grab gradient if calculated + # Grab gradient if calculated if Grad is True: - #Grab gradient + # Grab gradient self.gradient = grab_gradient_CP2K(f'ash-{self.filename}-1_0.xyz',len(current_coords)) - #Grab PCgradient from file + # Grab stress tensor + if self.stress_tensor is True: + self.stress = get_stress_tensor(f"ash-{self.filename}-1_0.stress_tensor") + print("self.stress:", self.stress) + #exit() + self.cell_gradient = stress_to_cell_gradient(self.periodic_cell_vectors,self.stress) + print("self.cell_gradient:", self.cell_gradient) + # Grab PCgradient from file if PC is True: self.pcgradient = grab_pcgradient_CP2K(f'ash-{self.filename}-1_0.xyz',len(MMcharges),len(current_coords)) print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} run', moduleindex=2) @@ -475,7 +571,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el else: print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} run', moduleindex=2) return self.energy, self.gradient - #Returning energy without gradient + + # Returning energy without gradient else: print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} run', moduleindex=2) return self.energy @@ -520,15 +617,20 @@ def run_CP2K(cp2kdir,bin_name,filename,numcores=1, paramethod='MPI', mixed_omp_t #Regular CP2K input def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, qm_elems=None, basis_dict=None, potential_dict=None, functional=None, restartfile=None, + vdwpotential=None, Grad=True, filename='cp2k', system_coord_file_format="XYZ", - coordfile=None, + coordfile=None, user_input_dft=None, charge=None, mult=None, basis_method='GAPW', mgrid_commensurate=False, scf_maxiter=50, outer_scf_maxiter=10, scf_guess='RESTART', scf_convergence=1e-6, eps_default=1e-10, - periodic_type="XYZ", cell_dimensions=None, cell_vectors=None, - qm_cell_dims=None, qm_periodic_type=None, xtb_periodic=False, basis_file='BASIS', potential_file='POTENTIAL', + periodic_type="XYZ", cell_vectors=None, + stress_tensor=False, stress_tensor_algo='DIAGONAL_ANALYTICAL', + kpoint_settings=None, + qm_cell_dims=None, qm_periodic_type=None, + xtb_periodic=False, xtb_type='GFN2', xtb_tblite=False, + basis_file='BASIS', potential_file='POTENTIAL', psolver='wavelet', wavelet_scf_type=40, - ngrids=4, cutoff=250, rel_cutoff=60, + ngrids=4, xc_finer_grid=False, cutoff=250, rel_cutoff=60, coupling='GAUSSIAN', GEEP_num_gauss=6, MM_radius_scaling=1, mm_radii=None, qm_kind_dict=None, mm_kind_list=None, mm_ewald_type='NONE', mm_ewald_alpha=0.35, mm_ewald_gmax="21 21 21", printlevel=2, @@ -575,87 +677,129 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, #FORCE_EVAL #################### inpfile.write(f'&FORCE_EVAL\n') + if stress_tensor is True: + inpfile.write(f' STRESS_TENSOR {stress_tensor_algo}\n') inpfile.write(f' METHOD {method}\n') inpfile.write(f' &PRINT\n') inpfile.write(f' &FORCES\n') inpfile.write(f' FILENAME {filename}\n') inpfile.write(f' &END FORCES\n') + if stress_tensor is True: + inpfile.write(f' &STRESS_TENSOR\n') + inpfile.write(f' FILENAME {filename}\n') + inpfile.write(f' &END STRESS_TENSOR\n') inpfile.write(f' &END PRINT\n\n') ########## #DFT ########## - inpfile.write(f' &DFT\n') - #SCF: Control GUESS etc - inpfile.write(f' &SCF\n') - inpfile.write(f' SCF_GUESS {scf_guess}\n') - inpfile.write(f' MAX_SCF {scf_maxiter}\n') - inpfile.write(f' EPS_SCF {scf_convergence}\n') - if outer_SCF is True: - inpfile.write(f' &OUTER_SCF\n') - inpfile.write(f' OPTIMIZER {outer_SCF_optimizer}\n') - inpfile.write(f' MAX_SCF {outer_scf_maxiter}\n') - inpfile.write(f' &END OUTER_SCF\n') - if OT is True: - #Warning default OT settings here are supposedly expensive - inpfile.write(f' &OT \n') - inpfile.write(f' MINIMIZER {OT_minimizer}\n') #DIIS or CG - inpfile.write(f' PRECONDITIONER {OT_preconditioner}\n') # FULL_SINGLE_INVERSE or FULL_KINETIC - inpfile.write(f' LINESEARCH {OT_linesearch}\n') #NONE, 2PNT, 3PNT, GOLD - inpfile.write(f' ENERGY_GAP {OT_energy_gap}\n') #0.08 (default), 0.001, 0.002 - inpfile.write(f' &END OT\n') - inpfile.write(f' &END SCF\n') - inpfile.write(f' CHARGE {charge}\n') - if mult > 1: - inpfile.write(f' UKS\n') - inpfile.write(f' MULTIPLICITY {mult}\n') - inpfile.write(f' BASIS_SET_FILE_NAME {basis_file}\n') - inpfile.write(f' POTENTIAL_FILE_NAME {potential_file}\n') - if restartfile != None: - inpfile.write(f' WFN_RESTART_FILE_NAME {restartfile}\n') - #POISSON - inpfile.write(f' &POISSON\n') - inpfile.write(f' PERIODIC {periodic_type}\n') #NOTE - inpfile.write(f' PSOLVER {psolver}\n') - if psolver == 'wavelet': - inpfile.write(f' &WAVELET {psolver}\n') - inpfile.write(f' SCF_TYPE {wavelet_scf_type}\n') - inpfile.write(f' &END WAVELET {psolver}\n') - inpfile.write(f' &END POISSON\n') - #QS - inpfile.write(f' &QS\n') - inpfile.write(f' METHOD {basis_method}\n') #NOTE - if basis_method == 'XTB': - inpfile.write(f' &XTB\n') #NOTE - inpfile.write(f' CHECK_ATOMIC_CHARGES F\n') - inpfile.write(f' DO_EWALD {xtb_periodic}\n') #NOTE - inpfile.write(f' USE_HALOGEN_CORRECTION T\n') #NOTE - inpfile.write(f' &END XTB\n') #NOTE - inpfile.write(f' EPS_DEFAULT {eps_default}\n') #NOTE - inpfile.write(f' &END QS\n') - - #MGRID - inpfile.write(f' &MGRID\n') - inpfile.write(f' NGRIDS {ngrids}\n') - inpfile.write(f' CUTOFF {cutoff}\n') - inpfile.write(f' REL_CUTOFF {rel_cutoff}\n') - inpfile.write(f' COMMENSURATE {mgrid_commensurate}\n') - inpfile.write(f' &END MGRID\n') - - #PRINT stuff - inpfile.write(f' &PRINT\n') - inpfile.write(f' &MO\n') - inpfile.write(f' EIGENVALUES .TRUE.\n') - inpfile.write(f' &END MO\n') - inpfile.write(f' &END PRINT\n') - - #XC - inpfile.write(f' &XC\n') - inpfile.write(f' &XC_FUNCTIONAL {functional}\n') - inpfile.write(f' &END XC_FUNCTIONAL\n') - inpfile.write(f' &END XC\n') - - inpfile.write(f' &END DFT\n\n') + if user_input_dft is not None: + print("User has provided custom DFT-section input. This will be used instead of the input generated by ASH.") + inpfile.write(user_input_dft) + else: + print("Writing DFT section") + inpfile.write(f' &DFT\n') + #SCF: Control GUESS etc + inpfile.write(f' &SCF\n') + inpfile.write(f' SCF_GUESS {scf_guess}\n') + inpfile.write(f' MAX_SCF {scf_maxiter}\n') + inpfile.write(f' EPS_SCF {scf_convergence}\n') + if outer_SCF is True: + inpfile.write(f' &OUTER_SCF\n') + inpfile.write(f' OPTIMIZER {outer_SCF_optimizer}\n') + inpfile.write(f' MAX_SCF {outer_scf_maxiter}\n') + inpfile.write(f' &END OUTER_SCF\n') + if OT is True: + #Warning default OT settings here are supposedly expensive + inpfile.write(f' &OT \n') + inpfile.write(f' MINIMIZER {OT_minimizer}\n') #DIIS or CG + inpfile.write(f' PRECONDITIONER {OT_preconditioner}\n') # FULL_SINGLE_INVERSE or FULL_KINETIC + inpfile.write(f' LINESEARCH {OT_linesearch}\n') #NONE, 2PNT, 3PNT, GOLD + inpfile.write(f' ENERGY_GAP {OT_energy_gap}\n') #0.08 (default), 0.001, 0.002 + inpfile.write(f' &END OT\n') + inpfile.write(f' &END SCF\n') + inpfile.write(f' CHARGE {charge}\n') + if mult > 1: + inpfile.write(f' UKS\n') + inpfile.write(f' MULTIPLICITY {mult}\n') + inpfile.write(f' BASIS_SET_FILE_NAME {basis_file}\n') + inpfile.write(f' POTENTIAL_FILE_NAME {potential_file}\n') + if restartfile != None: + inpfile.write(f' WFN_RESTART_FILE_NAME {restartfile}\n') + #POISSON + inpfile.write(f' &POISSON\n') + inpfile.write(f' PERIODIC {periodic_type}\n') #NOTE + inpfile.write(f' PSOLVER {psolver}\n') + if psolver == 'wavelet': + inpfile.write(f' &WAVELET {psolver}\n') + inpfile.write(f' SCF_TYPE {wavelet_scf_type}\n') + inpfile.write(f' &END WAVELET {psolver}\n') + inpfile.write(f' &END POISSON\n') + #QS + inpfile.write(f' &QS\n') + inpfile.write(f' METHOD {basis_method}\n') #NOTE + if basis_method == 'XTB': + # Extracting xTB code number from string, e.g. GFN2 -> 2, GFN1 -> 1, GFN0 -> 0 + xtbcode = int(''.join(filter(str.isdigit, xtb_type))) + inpfile.write(f' &XTB\n') + if xtb_tblite is True: + inpfile.write(f' &TBLITE\n') + inpfile.write(f' METHOD {xtb_type}\n') + inpfile.write(f' &END\n') + else: + inpfile.write(f' GFN_TYPE {xtbcode}\n') #NOTE + inpfile.write(f' CHECK_ATOMIC_CHARGES F\n') + inpfile.write(f' DO_EWALD {xtb_periodic}\n') #NOTE + inpfile.write(f' USE_HALOGEN_CORRECTION T\n') #NOTE + inpfile.write(f' &END XTB\n') #NOTE + inpfile.write(f' EPS_DEFAULT {eps_default}\n') #NOTE + inpfile.write(f' &END QS\n') + + #MGRID + inpfile.write(f' &MGRID\n') + inpfile.write(f' NGRIDS {ngrids}\n') + inpfile.write(f' CUTOFF {cutoff}\n') + inpfile.write(f' REL_CUTOFF {rel_cutoff}\n') + inpfile.write(f' COMMENSURATE {mgrid_commensurate}\n') + inpfile.write(f' &END MGRID\n') + + #K-points + if kpoint_settings is not None: + inpfile.write(f' &KPOINTS\n') + inpfile.write(f' SCHEME MONKHORST-PACK {kpoint_settings[0]} {kpoint_settings[1]} {kpoint_settings[2]}\n') + inpfile.write(f' &END KPOINTS\n') + #PRINT stuff + inpfile.write(f' &PRINT\n') + #inpfile.write(f' &MO\n') + #inpfile.write(f' EIGENVALUES .TRUE.\n') + #inpfile.write(f' &END MO\n') + inpfile.write(f' &END PRINT\n') + + #XC + inpfile.write(f' &XC\n') + # Finer grid or not + if xc_finer_grid is True: + inpfile.write(f' &XC_GRID\n') + inpfile.write(f' USE_FINER_GRID .TRUE.\n') + inpfile.write(f' &END XC_GRID\n') + if vdwpotential is not None: + inpfile.write(f' &VDW_POTENTIAL\n') + inpfile.write(f' DISPERSION_FUNCTIONAL PAIR_POTENTIAL\n') + inpfile.write(f' &PAIR_POTENTIAL\n') + if 'D3' in vdwpotential: + inpfile.write(f' PARAMETER_FILE_NAME dftd3.dat\n') + inpfile.write(f' REFERENCE_FUNCTIONAL {functional}\n') + inpfile.write(f' TYPE {vdwpotential}\n') + if 'DFTD' in vdwpotential: + inpfile.write(f' &PRINT_DFTD\n') + inpfile.write(f' &END PRINT_DFTD\n') + inpfile.write(f' &END PAIR_POTENTIAL\n') + inpfile.write(f' &END VDW_POTENTIAL\n') + inpfile.write(f' &XC_FUNCTIONAL {functional}\n') + inpfile.write(f' &END XC_FUNCTIONAL\n') + inpfile.write(f' &END XC\n') + + inpfile.write(f' &END DFT\n\n') #QM/MM if method == 'QMMM': @@ -714,10 +858,10 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, #CELL BLOCK inpfile.write(f' &CELL\n') #This should be the total system cell size - if cell_dimensions != None: - inpfile.write(f' ABC {cell_dimensions[0]} {cell_dimensions[1]} {cell_dimensions[2]}\n') - inpfile.write(f' ALPHA_BETA_GAMMA {cell_dimensions[3]} {cell_dimensions[4]} {cell_dimensions[5]}\n') - elif cell_vectors != None: + #if cell_dimensions is not None: + # inpfile.write(f' ABC {cell_dimensions[0]} {cell_dimensions[1]} {cell_dimensions[2]}\n') + # inpfile.write(f' ALPHA_BETA_GAMMA {cell_dimensions[3]} {cell_dimensions[4]} {cell_dimensions[5]}\n') + if cell_vectors is not None: inpfile.write(f' A {cell_vectors[0][0]} {cell_vectors[0][1]} {cell_vectors[0][2]}\n') inpfile.write(f' B {cell_vectors[1][0]} {cell_vectors[1][1]} {cell_vectors[1][2]}\n') inpfile.write(f' C {cell_vectors[2][0]} {cell_vectors[2][1]} {cell_vectors[2][2]}\n') @@ -725,12 +869,13 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, inpfile.write(f' &END CELL\n') #KIND: basis and potentail for each element - for el in basis_dict.keys(): - inpfile.write(f' &KIND {el}\n') - inpfile.write(f' ELEMENT {el}\n') - inpfile.write(f' BASIS_SET {basis_dict[el]}\n') - inpfile.write(f' POTENTIAL {potential_dict[el]}\n') - inpfile.write(f' &END KIND\n') + if basis_method != 'XTB': + for el in basis_dict.keys(): + inpfile.write(f' &KIND {el}\n') + inpfile.write(f' ELEMENT {el}\n') + inpfile.write(f' BASIS_SET {basis_dict[el]}\n') + inpfile.write(f' POTENTIAL {potential_dict[el]}\n') + inpfile.write(f' &END KIND\n') inpfile.write(f'\n') #TOPOLOGY BLOCK inpfile.write(f' &TOPOLOGY\n') @@ -860,3 +1005,45 @@ def find_cp2k(cp2kdir, cp2k_bin_name): print("Note: Make sure the cp2k binaries are in your PATH and named correctly") ashexit() return + +def get_stress_tensor(file): + grab=False + stress=np.zeros((3,3)) + with open(file) as f: + for line in f: + if ' STRESS| 1/3 Trace' in line: + grab=False + if grab: + if ' STRESS| x' in line: + stress[0,0] = line.split()[2] + stress[0,1] = line.split()[3] + stress[0,2] = line.split()[4] + if ' STRESS| y' in line: + stress[1,0] = line.split()[2] + stress[1,1] = line.split()[3] + stress[1,2] = line.split()[4] + if ' STRESS| z' in line: + stress[2,0] = line.split()[2] + stress[2,1] = line.split()[3] + stress[2,2] = line.split()[4] + if 'Analytical stress tensor' in line: + grab=True + return stress + + +# Convert stress tensor to cell gradient +def stress_to_cell_gradient(lattice_matrix, stress_tensor): + # convert lattice to Bohr + h = np.asarray(lattice_matrix) * ash.constants.ang2bohr + + # convert stress to Eh/Bohr^3 + BAR_TO_EH_PER_BOHR3 = 3.398931e-9 + sigma = np.asarray(stress_tensor) * BAR_TO_EH_PER_BOHR3 + + # cell volume + V = np.linalg.det(h) + + # compute gradient + grad = -1*V * sigma @ np.linalg.inv(h).T + + return grad \ No newline at end of file diff --git a/ash/interfaces/interface_DFTB.py b/ash/interfaces/interface_DFTB.py index f8a543501..bf991079f 100644 --- a/ash/interfaces/interface_DFTB.py +++ b/ash/interfaces/interface_DFTB.py @@ -6,6 +6,7 @@ from ash.functions.functions_general import ashexit, BC, print_time_rel,print_line_with_mainheader,check_program_location from ash.modules.module_coords import elematomnumbers, write_xyzfile +from ash.modules.module_coords_PBC import cell_params_to_vectors, cell_vectors_to_params import ash.settings_ash # Basic interface to DFTB+ @@ -14,7 +15,8 @@ class DFTBTheory(): def __init__(self, dftbdir=None, hamiltonian="XTB", xtb_method="GFN2-xTB", printlevel=2, label="DFTB", numcores=1, slaterkoster_dict=None, maxmom_dict=None, hubbard_derivs_dict=None, Gauss_blur_width=0.0, SCC=True, ThirdOrderFull=False, ThirdOrder=False, hcorrection_zeta=None, - MaxSCCIterations=300): + MaxSCCIterations=300, periodic=False, periodic_cell_vectors=None, + periodic_cell_dimensions=None, kpoint_values=[1,1,1]): self.theorynamelabel="DFTB" self.label=label @@ -51,6 +53,26 @@ def __init__(self, dftbdir=None, hamiltonian="XTB", xtb_method="GFN2-xTB", print self.ThirdOrderFull=ThirdOrderFull self.ThirdOrder=ThirdOrder + # PBC + self.periodic=periodic + self.periodic_cell_vectors=None # initially + self.kpoint_values=kpoint_values # k-point values: [1,1,1] for gamma point in all directions + if self.periodic: + print("PBC enabled") + if periodic_cell_vectors is None and periodic_cell_dimensions is None: + print("Error: for periodic calculations, you must specify either periodic_cell_vectors or periodic_cell_dimensions") + ashexit() + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + elif periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions = periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + print("Cell vectors:", self.periodic_cell_vectors) + print("Cell dimensions:", self.periodic_cell_dimensions) if maxmom_dict is None: print("Warning: No maxmom_dict keyword (dictionary of Maximum Angular Momenta for each element) provided") @@ -91,6 +113,19 @@ def set_numcores(self,numcores): def cleanup(self): print(f"{self.theorynamelabel} cleanup not yet implemented.") + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + def get_cell_gradient(self): + return self.cell_gradient + # Run function. Takes coords, elems etc. arguments and computes E or E+G. def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, elems=None, Grad=False, PC=False, numcores=None, restart=False, label=None, @@ -143,7 +178,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el slaterkoster_dict=self.slaterkoster_dict, maxmom_dict=self.maxmom_dict, MMcharges=MMcharges, MMcoords=current_MM_coords, Gauss_blur_width=self.Gauss_blur_width, SCC=self.SCC, ThirdOrderFull=self.ThirdOrderFull, ThirdOrder=self.ThirdOrder, hubbard_derivs_dict=self.hubbard_derivs_dict, hcorrection_zeta=self.hcorrection_zeta, - MaxSCCIterations=self.MaxSCCIterations) + MaxSCCIterations=self.MaxSCCIterations, periodic=self.periodic, + periodic_cell_vectors=self.periodic_cell_vectors, kpoint_values=self.kpoint_values) print_time_rel(module_init_time, modulename=f'DFTB prep-run', moduleindex=3) # Run DFTB @@ -166,6 +202,10 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # Grab gradient if calculated if Grad is True: + + if self.periodic: + self.cell_gradient = get_cell_gradient("detailed.out") + # Grab PCgradient from separate file if PC is True: print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} run', moduleindex=2) @@ -180,7 +220,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # def write_DFTB_input(hamiltonian,xtbmethod,xyzfilename, elems,coords,charge,mult, PC=False, MMcharges=None, MMcoords=None, Grad=False, SCC=True, slaterkoster_dict=None, maxmom_dict=None, Gauss_blur_width=0.0, ThirdOrderFull=False, ThirdOrder=False, - hubbard_derivs_dict=None, hcorrection_zeta=None, MaxSCCIterations=300): + hubbard_derivs_dict=None, hcorrection_zeta=None, MaxSCCIterations=300, + periodic=False, periodic_cell_vectors=None, kpoint_values=[1,1,1]): # Open file f = open("dftb_in.hsd", "w") @@ -188,19 +229,57 @@ def write_DFTB_input(hamiltonian,xtbmethod,xyzfilename, elems,coords,charge,mult # List to keep inputlines inputlines=[] + ############# # Geometry - geo1="Geometry = xyzFormat {\n" - geo2=f"<<< '{xyzfilename}' \n}}\n" + ############# + + # PBC + if periodic: + inputlines.append("Geometry = {"+"\n") + elemtypes=list(set(elems)) + inputlines.append('TypeNames = { ' + ' '.join(f'"{x}"' for x in elemtypes) + ' }'+"\n") + inputlines.append('TypesAndCoordinates [Angstrom] = {'+'\n') + for e,c in zip(elems,coords): + inputlines.append(f"{elemtypes.index(e)+1} {c[0]} {c[1]} {c[2]}"+"\n") + inputlines.append("}"+"\n") + + inputlines.append("Periodic = Yes"+"\n") + inputlines.append("LatticeVectors [Angstrom] = {"+"\n") + for line in periodic_cell_vectors: + inputlines.append(f"{line[0]:.6f} {line[1]:.6f} {line[2]:.6f}"+"\n") + inputlines.append("}"+"\n") + # Closing geometry block + inputlines.append('}\n') + # or not + else: + geo1="Geometry = { xyzFormat {\n" + geo2=f" <<< '{xyzfilename}' \n"+"}"+"\n" - inputlines.append(geo1) - inputlines.append(geo2) + inputlines.append(geo1) + inputlines.append(geo2) - # Method - method1=f"Hamiltonian = {hamiltonian} {{"+"\n" + #Closing geometry block + inputlines.append('}\n') + + ############# + # HAMILTONIAN + ############# + method1=f"Hamiltonian = {hamiltonian}" +"{"+"\n" inputlines.append(method1) if 'XTB' in hamiltonian.upper(): - method2=f"Method = '{xtbmethod}'"+'\n}\n' + method2=f"Method = '{xtbmethod}'"+'\n\n' inputlines.append(method2) + + #PBC: k-points + if periodic: + inputlines.append("KPointsAndWeights = SupercellFolding {"+"\n") + inputlines.append(f"{kpoint_values[0]} 0 0"+"\n") + inputlines.append(f"0 {kpoint_values[1]} 0"+"\n") + inputlines.append(f"0 0 {kpoint_values[2]}"+"\n") + inputlines.append("0 0 0"+"\n") + + inputlines.append("}"+"\n") + else: # PC if PC: @@ -216,6 +295,7 @@ def write_DFTB_input(hamiltonian,xtbmethod,xyzfilename, elems,coords,charge,mult inputlines.append(' }\n') inputlines.append(' }\n') inputlines.append('}\n') + # SCC if SCC is True: SCCkeyword="Yes" @@ -253,7 +333,20 @@ def write_DFTB_input(hamiltonian,xtbmethod,xyzfilename, elems,coords,charge,mult inputlines.append(f' {el} = "{maxmom_dict[el]}"\n') inputlines.append(' }\n') - inputlines.append('}\n') + #PBC: k-points + if periodic: + inputlines.append(" KPointsAndWeights = SupercellFolding {"+"\n") + inputlines.append("KPointsAndWeights = SupercellFolding {"+"\n") + inputlines.append(f"{kpoint_values[0]} 0 0"+"\n") + inputlines.append(f"0 {kpoint_values[1]} 0"+"\n") + inputlines.append(f"0 0 {kpoint_values[2]}"+"\n") + inputlines.append("0 0 0"+"\n") + + inputlines.append("}"+"\n") + + + # Close Hamiltonian + inputlines.append('}\n') #Options optionline="Options { WriteDetailedOut = Yes }\n" @@ -338,3 +431,21 @@ def create_pcfile(filename,coords,pchargelist): for p,c in zip(pchargelist,coords): line = "{} {} {} {}".format(c[0], c[1], c[2], p) pcfile.write(line+'\n') + +def get_cell_gradient(file): + gradient=np.zeros((3,3)) + counter=0 + grab=False + with open(file) as f: + for line in f: + if grab: + if len(line.split()) == 3: + gradient[counter,0] = line.split()[0] + gradient[counter,1] = line.split()[1] + gradient[counter,2] = line.split()[2] + counter+=1 + if 'Total lattice derivs' in line: + grab=True + if 'Maximal' in line: + grab=False + return gradient \ No newline at end of file diff --git a/ash/interfaces/interface_GPAW.py b/ash/interfaces/interface_GPAW.py index 53c13ba27..202b4f48c 100644 --- a/ash/interfaces/interface_GPAW.py +++ b/ash/interfaces/interface_GPAW.py @@ -4,7 +4,7 @@ import ash.modules.module_coords from ash.modules.module_results import ASH_Results from ash.modules.module_theory import QMTheory -from ash.modules.module_coords import cubic_box_size,bounding_box_dimensions +from ash.modules.module_coords_PBC import cubic_box_size,bounding_box_dimensions import os import sys import glob diff --git a/ash/interfaces/interface_ORCA.py b/ash/interfaces/interface_ORCA.py index 1a87d63a9..bd7d2af06 100644 --- a/ash/interfaces/interface_ORCA.py +++ b/ash/interfaces/interface_ORCA.py @@ -11,7 +11,7 @@ from ash.functions.functions_general import ashexit,insert_line_into_file,BC,print_time_rel, print_line_with_mainheader, pygrep2, \ pygrep, search_list_of_lists_for_index,print_if_level, writestringtofile, check_program_location, listdiff from ash.modules.module_singlepoint import Singlepoint -from ash.modules.module_coords import check_charge_mult +from ash.modules.module_coords import check_charge_mult, print_internal_coordinate_table_new import ash.functions.functions_elstructure import ash.constants import ash.settings_ash @@ -58,6 +58,9 @@ def __init__(self, orcadir=None, orcasimpleinput='', printlevel=2, basis_per_ele print("String:", orcasimpleinput.upper()) print("orcasimpleinput should only contain information on electronic-structure method (e.g. functional), basis set, grid, SCF convergence etc.") ashexit() + if '!' not in orcasimpleinput: + print(BC.FAIL,"Error. orcasimpleinput should contain at least a '!' with method and basis set information", BC.END) + ashexit() # Whether to check ORCA outputfile for errors and warnings or not # Generally recommended. Could be disabled to speed up I/O a tiny bit @@ -361,7 +364,7 @@ def Opt(self, fragment=None, Grad=None, Hessian=None, numcores=None, charge=None fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') #Printing internal coordinate table - ash.modules.module_coords.print_internal_coordinate_table(fragment) + print_internal_coordinate_table_new(fragment) print_time_rel(module_init_time, modulename='ORCA Opt-run', moduleindex=2) return # Method to grab dipole moment from an ORCA outputfile (assumes run has been executed) @@ -370,7 +373,10 @@ def get_dipole_moment(self): print("Dipole moment:", dm) return dm def get_polarizability_tensor(self): + print("here") + print("self.filename+'.out':", self.filename+'.out') polarizability,diag_pz = grab_polarizability_tensor(self.filename+'.out') + print("polarizability:", polarizability) return polarizability # Run function. Takes coords, elems etc. arguments and computes E or E+G. def run(self, current_coords=None, charge=None, mult=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, @@ -1006,7 +1012,10 @@ def ORCAfinalenergygrab(file, errors='ignore'): else: #Changing: sometimes ORCA adds info to the right of energy #Energy=float(line.split()[-1]) - Energy=float(line.split()[4]) + if "(MM)" in line: + Energy=float(line.split()[5]) + else: + Energy=float(line.split()[4]) if Energy is None: print(BC.FAIL,"ASH found no energy in file:", file, BC.END) print(BC.FAIL,"Something went wrong with ORCA run. Check ORCA outputfile:", file, BC.END) @@ -1117,26 +1126,30 @@ def grab_polarizability_tensor(outfile): pz_tensor = np.zeros((3,3)) diag_pz_tensor=[] count=0 - grab=False;grab2=False + grab=False;grab2=False;grab3=False with open(outfile) as f: for line in f: - if grab2 is True: + if grab3 is True: if len(line.split()) == 0: grab2=False else: diag_pz_tensor.append(float(line.split()[0])) diag_pz_tensor.append(float(line.split()[1])) diag_pz_tensor.append(float(line.split()[2])) + grab=False;grab2=False;grab3=False if grab is True: - if 'diagonalized tensor:' in line: - grab=False + if 'The raw cartesian tensor' in line: grab2=True - if len(line.split()) == 3: + if 'diagonalized tensor:' in line: + grab2=False + grab3=True + if grab2 is True and len(line.split()) == 3: pz_tensor[count,0]=float(line.split()[0]) pz_tensor[count,1]=float(line.split()[1]) pz_tensor[count,2]=float(line.split()[2]) count+=1 - if 'THE POLARIZABILITY TENSOR' in line: + if 'STATIC POLARIZABILITY TENSOR' in line: + print("grab True") grab=True return pz_tensor, diag_pz_tensor @@ -2181,11 +2194,11 @@ def check_if_file_exists(): if option=='density': plottype = 2 elif option=='cisdensity': - plottype = 2 + plottype = 23 elif option=='spindensity': plottype = 3 elif option=='cisspindensity': - plottype = 3 + plottype = 23 elif option=='mo': plottype = 1 else: diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 1ba601286..f62c99458 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -8,7 +8,6 @@ #import ash import ash.constants -import ash.modules.module_coords ashpath = os.path.dirname(ash.__file__) from ash.functions.functions_general import ashexit, BC, print_time_rel, listdiff, printdebug, print_line_with_mainheader, find_replace_string_in_file, \ @@ -17,16 +16,18 @@ from ash.functions.functions_elstructure import DDEC_calc, DDEC_to_LJparameters from ash.modules.module_coords import Fragment, write_pdbfile, distance_between_atoms, list_of_masses, write_xyzfile, \ - change_origin_to_centroid, get_centroid, check_charge_mult, check_gradient_for_bad_atoms, get_molecule_members_loop_np2, \ - pdb_to_smiles,xyz_to_pdb_with_connectivity,writepdb_with_connectivity,mol_to_pdb + change_origin_to_centroid, get_centroid, check_charge_mult, check_gradient_for_bad_atoms, get_molecule_members_loop_np2, define_dummy_topology, get_connected_atoms_dict + +from ash.modules.module_coords_PBC import cell_params_to_vectors, cell_vectors_to_params from ash.modules.module_MM import UFF_modH_dict, MMforcefield_read -from ash.interfaces.interface_xtb import xTBTheory, grabatomcharges_xTB +from ash.interfaces.interface_xtb import xTBTheory, grabatomcharges_xTB, tbliteTheory from ash.interfaces.interface_ORCA import ORCATheory, grabatomcharges_ORCA, chargemodel_select from ash.modules.module_singlepoint import Singlepoint from ash.interfaces.interface_plumed import plumed_MTD_analyze from ash.interfaces.interface_mdtraj import MDtraj_import, MDtraj_imagetraj, MDtraj_RMSF import ash.functions.functions_parallel import ash.modules.module_plotting +from ash.interfaces.interface_openbabel import pdb_to_smiles,xyz_to_pdb_with_connectivity,writepdb_with_connectivity,mol_to_pdb class OpenMMTheory: @@ -38,7 +39,7 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo nonbondedMethod_noPBC='NoCutoff', nonbonded_cutoff_noPBC=20, xmlfiles=None, pdbfile=None, use_parmed=False, xmlsystemfile=None, do_energy_decomposition=False, - periodic=False, periodic_cell_dimensions=None, PBCvectors=None, + periodic=False, periodic_cell_dimensions=None, PBCvectors=None, periodic_cell_vectors=None, charmm_periodic_cell_dimensions=None, customnonbondedforce=False, periodic_nonbonded_cutoff=12, dispersion_correction=True, nonbondedMethod_PBC='PME', @@ -60,6 +61,7 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo # $OPENMM_CPU_THREADS in shell # before running. os.environ['OMP_NUM_THREADS'] = str(numcores) + os.environ['OPENMM_CPU_THREADS'] = str(numcores) print("OpenMM CPU threads set to:", os.environ['OMP_NUM_THREADS']) self.numcores=numcores #Setting for general ASH compatibility @@ -194,7 +196,7 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo # Initializing self.coords = [] self.charges = [] - self.Periodic = periodic + self.periodic = periodic self.periodic_nonbonded_cutoff=periodic_nonbonded_cutoff self.nonbonded_cutoff_noPBC=nonbonded_cutoff_noPBC #Methods for nonbonded interactions, PBC and no-PBC @@ -233,6 +235,13 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo # Initializing pdb_pbc_vectors=None + # Phasing out PBCvectors + if PBCvectors is not None: + print("Warning: PBCvectors keyword is on its way out. Use periodic_cell_vectors instead") + if periodic_cell_vectors is None: + periodic_cell_vectors=PBCvectors + + # #Always creates object we call self.forcefield that contains topology attribute if CHARMMfiles is True: if self.printlevel > 0: @@ -340,10 +349,10 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo #if float(openmm.__version__) >= 8.1: if version.parse(openmm.__version__) >= version.parse("8.1"): - if PBCvectors is None: + if periodic_cell_vectors is None: temp_pbc_vecs=None else: - temp_pbc_vecs=PBCvectors*openmm.unit.angstrom #Adding units + temp_pbc_vecs=periodic_cell_vectors*openmm.unit.angstrom #Adding units #If cell dims provided instead if periodic_cell_dimensions is None: temp_pbc_cell_value=None @@ -483,16 +492,21 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo # Create list of atomnames, used in PDB topology and XML file atomnames_full=[j+str(i) for i,j in enumerate(fragment.elems)] # Write PDB-file frag.pdb with dummy atomnames - write_pdbfile(fragment, outputname="frag", atomnames=atomnames_full) + #write_pdbfile(fragment, outputname="frag", atomnames=atomnames_full) # Load PDB-file and create topology - pdb = openmm.app.PDBFile("frag.pdb") - self.topology = pdb.topology + #pdb = openmm.app.PDBFile("frag.pdb") + #self.topology = pdb.topology + + #Creating new + #fragment.define_topology() + #self.topology = fragment.pdb_topology + self.topology = define_dummy_topology(fragment.elems) # Create dummy XML file xmlfile = write_xmlfile_nonbonded(filename="dummy.xml", resnames=["DUM"], atomnames_per_res=[atomnames_full], atomtypes_per_res=[fragment.elems], elements_per_res=[fragment.elems], masses_per_res=[fragment.masses], charges_per_res=[[0.0]*fragment.numatoms], - sigmas_per_res=[[0.0]*fragment.numatoms], epsilons_per_res=[[0.0]*fragment.numatoms], skip_nb=True) + sigmas_per_res=[[0.0]*fragment.numatoms], epsilons_per_res=[[0.0]*fragment.numatoms], skip_nb=False) # Create dummy forcefield self.forcefield = openmm.app.ForceField(xmlfile) @@ -536,13 +550,13 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo # NOW CREATE SYSTEM UNLESS already created (xmlsystemfile) if self.system is None: # Periodic or non-periodic ystem - if self.Periodic is True: + if self.periodic is True: if self.printlevel > 0: print("System is periodic.") print_line_with_subheader1("Setting up periodicity.") #Inspect and set PBC in self.topology and self.forcefield #Necessary for system creation with periodics (otherwise failure) - self.set_periodics_before_system_creation(PBCvectors,pdb_pbc_vectors,periodic_cell_dimensions,CHARMMfiles,Amberfiles,use_parmed,) + self.set_periodics_before_system_creation(periodic_cell_vectors,pdb_pbc_vectors,periodic_cell_dimensions,CHARMMfiles,Amberfiles,use_parmed,) #Nonbonded method to use for PBC if self.nonbondedMethod_PBC == 'PME': @@ -611,13 +625,9 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo rigidWater=self.rigidwater, ewaldErrorTolerance=self.ewalderrortolerance, nonbondedCutoff=self.periodic_nonbonded_cutoff * openmm.unit.angstroms, residueTemplates=residueTemplates) - #FINAL PRINTING OF SYSTEM PBC VECTORS - a, b, c = self.system.getDefaultPeriodicBoxVectors() - if self.printlevel > 0: - print_line_with_subheader2("Periodic vectors:") - print(a) - print(b) - print(c) + # Setting as periodic_cell_vectors + self.periodic_cell_vectors = np.array([[v._value*10 for v in vec] for vec in self.system.getDefaultPeriodicBoxVectors()]) + print("Periodic_cell_vectors (Å)", periodic_cell_vectors) # Force modification here # print("OpenMM Forces defined:", self.system.getForces()) @@ -929,29 +939,29 @@ def write_pdbfile(self, positions=None, outputname="system"): ashexit() #Function that handles periodicity in forcefield objects (for Amber, CHARMM). TODO: Test GROMACS and XML - def set_periodics_before_system_creation(self,PBCvectors,pdb_pbc_vectors,periodic_cell_dimensions,CHARMMfiles,Amberfiles,use_parmed): + def set_periodics_before_system_creation(self,periodic_cell_vectors,pdb_pbc_vectors,periodic_cell_dimensions,CHARMMfiles,Amberfiles,use_parmed): import openmm from packaging import version if use_parmed is True: import parmed print("Inspecting periodicity input before system creation") - print("PBCVectors:", PBCvectors) + print("periodic_cell_vectors:", periodic_cell_vectors) print("periodic_cell_dimensions:", periodic_cell_dimensions) print("pdb_pbc_vectors:", pdb_pbc_vectors) #IF PBC vectors provided then we need to set them in the topology (otherwise system creation does not work) - if PBCvectors is not None: - print("\nPBC vectors provided by user (in Angstrom):", PBCvectors) + if periodic_cell_vectors is not None: + print("\nPBC vectors provided by user (in Angstrom):", periodic_cell_vectors) print("Setting PBC vectors in topology object") - self.topology.setPeriodicBoxVectors(PBCvectors*openmm.unit.angstroms) + self.topology.setPeriodicBoxVectors(periodic_cell_vectors*openmm.unit.angstroms) print("Topology PBC vectors set:", self.topology.getPeriodicBoxVectors()) #Setting PBC forcefield object print("Setting PBC box vectors in forcefield object") if CHARMMfiles is True: - self.forcefield.box_vectors = PBCvectors*openmm.unit.angstrom + self.forcefield.box_vectors = periodic_cell_vectors*openmm.unit.angstrom print("PBC box vectors set:", self.forcefield.box_vectors) elif Amberfiles is True and use_parmed is True: #Necessary for parmed object to define box_vectors in forcefield object - self.forcefield.box_vectors = PBCvectors*openmm.unit.angstrom + self.forcefield.box_vectors = periodic_cell_vectors*openmm.unit.angstrom print("PBC box vectors set:", self.forcefield.box_vectors) elif Amberfiles is True and use_parmed is False: #Not necessary to define box_vectors (grabbed from topology above) but we have to make sure PBC is on @@ -966,10 +976,9 @@ def set_periodics_before_system_creation(self,PBCvectors,pdb_pbc_vectors,periodi print("Warning: Will assume cubic box and set PBC vectors in a hacky way") self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"] =np.array([0.0,0.0,0.0,0.0]) self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][0] = 90.0 - self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][1] = PBCvectors[0][0] - self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][2] = PBCvectors[1][1] - self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][3] = PBCvectors[2][2] - + self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][1] = periodic_cell_vectors[0][0] + self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][2] = periodic_cell_vectors[1][1] + self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][3] = periodic_cell_vectors[2][2] elif periodic_cell_dimensions is not None: print("\nPBC cell dimensions provided by user:", periodic_cell_dimensions) #print("Setting PBC vectors in topology") @@ -1030,7 +1039,7 @@ def set_periodics_before_system_creation(self,PBCvectors,pdb_pbc_vectors,periodi self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][2] = periodic_cell_dimensions[1] self.forcefield._prmtop._raw_data["BOX_DIMENSIONS"][3] = periodic_cell_dimensions[2] elif pdb_pbc_vectors is not None: - print("Warning: neither user keyword PBCvectors or periodic_cell_dimensions was set (None)") + print("Warning: neither user keyword periodic_cell_vectors or periodic_cell_dimensions was set (None)") print("However, we found PBC information inside PDB-topology of the PDB-file that was read in. Using this and continuing") #Should work automatically elif self.topology.getPeriodicBoxVectors() is not None: @@ -1070,6 +1079,55 @@ def set_positions(self, coords,simulation): simulation.context.setPositions(pos) print_if_level("Coordinates set", self.printlevel,1) + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + # This method is called by Periodic optimizers + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + import openmm + print("Updating cell vectors") + print("New periodic_cell_vectors are:", periodic_cell_vectors) + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + # Now updating actual OpenMM objects + #Converting to nm + cellvecs_nm = self.periodic_cell_vectors/10 + a = cellvecs_nm[0] + b = cellvecs_nm[1] + c = cellvecs_nm[2] + + # We may have to adjust the nonbonded cutoff. + # Shortest box dimension (diagonal elements, safe estimate for triclinic) + min_box_dim = min(cellvecs_nm[0,0], cellvecs_nm[1,1], cellvecs_nm[2,2]) + hard_limit_cutoff = 0.499 * min_box_dim # just under OpenMM's hard limit of 0.5 + + # Find NonbondedForce and update cutoff only if the box has become too small + for i in range(self.system.getNumForces()): + force = self.system.getForce(i) + if isinstance(force, openmm.NonbondedForce): + current_cutoff = force.getCutoffDistance().value_in_unit(openmm.unit.nanometer) + + # Store the original intended cutoff the first time we see it + if not hasattr(self, '_original_cutoff_nm'): + self._original_cutoff_nm = current_cutoff + print(f"Storing original cutoff: {self._original_cutoff_nm:.3f} nm") + + # Desired cutoff: restore original if box allows, otherwise use hard limit + desired_cutoff = min(self._original_cutoff_nm, hard_limit_cutoff) + + if abs(desired_cutoff - current_cutoff) > 1e-6: # only update if actually changed + print(f"Adjusting cutoff from {current_cutoff:.3f} to {desired_cutoff:.3f} nm " + f"(box limit: {hard_limit_cutoff:.3f} nm, original: {self._original_cutoff_nm:.3f} nm)") + force.setCutoffDistance(desired_cutoff * openmm.unit.nanometer) + break + # Note we are modifying the system and topology itself because we are doing OpenMMTheory.run that creates new sim and context each time + self.system.setDefaultPeriodicBoxVectors(a,b,c) + #Topology + self.topology.setPeriodicBoxVectors(cellvecs_nm) + #Add dummy #https://simtk.org/plugins/phpBB/viewtopicPhpbb.php?f=161&t=10049&p=0&start=0&view=&sid=b844250e55b14682fb21b5f66a4d810f #https://github.com/openmm/openmm/issues/2262 @@ -1188,7 +1246,7 @@ def add_centerforce(self, center_coords=None, atomindices=None, forceconstant=1. print(f"Forceconstant: {forceconstant} kcal/mol/Ang^2") print(f"Force acting at values larger than {distance} Ang:") #Distinguish periodic and nonperiodic scenarios: - if self.Periodic is True: + if self.periodic is True: centerforce = openmm.CustomExternalForce("0.5*k * max(0,periodicdistance(x, y, z, x0, y0, z0) - r0)^2") else: centerforce = openmm.CustomExternalForce("0.5*k * max(0,((x-x0)^2+(y-y0)^2+(z-z0)^2)-r0)^2") @@ -1237,7 +1295,7 @@ def add_flatbottom_centerforce(self, molA_indices=None, molB_indices = None, dis #centerforce = openmm.CustomCentroidBondForce(2, "0.5*k*(distance(g1,g2)-r0)^2") centerforce = openmm.CustomCentroidBondForce(2, "0.5*k*max(0, distance(g1,g2)-r0)^2") #Periodic case (note: periodicdistance not available for CustomCentroidBondForce) - if self.Periodic is True: + if self.periodic is True: print("Warning: add_flatbottom_centerforce with PBC is not well tested") centerforce.setUsesPeriodicBoundaryConditions=True #centerforce = openmm.CustomExternalForce("k *periodicdistance(x, y, z, x0, y0, z0)") @@ -1439,6 +1497,10 @@ def freeze_atoms(self, frozen_atoms=None): for i in frozen_atoms: self.system.setParticleMass(i, 0 * openmm.unit.daltons) + # Also adding exceptions to nonbonded force to avoid interactions between frozen atoms (causes problems otherwise in NPT) + print("Also adding exceptions to nonbonded force for frozen atoms to avoid interactions between them (avoids problems in NPT).") + self.addexceptions(frozen_atoms) + #Update list of current masses self.system_masses = [self.system.getParticleMass(i)._value for i in self.allatoms] @@ -1485,33 +1547,21 @@ def set_active_and_frozen_regions(self, active_atoms=None, frozen_atoms=None): # This removes interactions between particles in a region (e.g. QM-QM or frozen-frozen pairs) # Give list of atom indices for which we will remove all pairs - # Todo: Way too slow to do for big list of e.g. frozen atoms but works well for qmatoms list size - # Alternative: Remove force interaction and then add in the interaction of active atoms to frozen atoms - # should be reasonably fast - # https://github.com/openmm/openmm/issues/2124 - # https://github.com/openmm/openmm/issues/1696 def addexceptions(self, atomlist): - print("atomlist:",atomlist) import openmm timeA = time.time() - import itertools print("Add exceptions/exclusions. Removing i-j interactions for list:", len(atomlist), "atoms") - # Has duplicates - # [self.nonbonded_force.addException(i,j,0, 0, 0, replace=True) for i in atomlist for j in atomlist] - # https://stackoverflow.com/questions/942543/operation-on-every-pair-of-element-in-a-list - # [self.nonbonded_force.addException(i,j,0, 0, 0, replace=True) for i,j in itertools.combinations(atomlist, r=2)] numexceptions = 0 numexclusions = 0 printdebug("self.system.getForces() ", self.system.getForces()) - # print("self.nonbonded_force:", self.nonbonded_force) for force in self.system.getForces(): printdebug("force:", force) if isinstance(force, openmm.NonbondedForce): print("Case Nonbondedforce. Adding Exception for ij pair.") - for i in atomlist: - for j in atomlist: + for idx_i, i in enumerate(atomlist): + for j in atomlist[idx_i + 1:]: printdebug("i,j : {} and {} ".format(i, j)) force.addException(i, j, 0, 0, 0, replace=True) @@ -1533,8 +1583,10 @@ def addexceptions(self, atomlist): #Using set of frozensets to get unique pairs all_exclusions = [force.getExclusionParticles(exclindex) for exclindex in range(0,force.getNumExclusions()) ] existing_exclusions = {frozenset(excl) for excl in all_exclusions} - for k in atomlist: - for l in atomlist: + #for k in atomlist: + # for l in atomlist: + for idx_k, k in enumerate(atomlist): + for l in atomlist[idx_k + 1:]: if not frozenset((k,l)) in existing_exclusions: existing_exclusions.add(frozenset([k,l])) force.addExclusion(k, l) @@ -1621,12 +1673,12 @@ def create_simulation(self, internal=False): #NOTE: Not sure if needed anymore self.simulation = openmm.app.simulation.Simulation(self.topology, self.system, self.integrator, openmm.Platform.getPlatformByName(self.platform_choice), - self.properties) + self.properties) return else: simulation = openmm.app.simulation.Simulation(self.topology, self.system, self.integrator, openmm.Platform.getPlatformByName(self.platform_choice), - self.properties) + self.properties) print_time_rel(timeA, modulename="creating/updating simulation", currprintlevel=self.printlevel) return simulation @@ -1698,10 +1750,45 @@ def compute_DOF(self): dof -= 3 self.dof=dof + # Compute cell gradient numerically + def compute_cell_gradient_fd(self,context, eps=1e-4): + import openmm + # Conversion factors + NM_TO_BOHR = 18.89726124 # 1 nm = 18.897... Bohr + KJMOL_TO_EH = 1.0 / 2625.4996 # 1 kJ/mol = 1/2625.5 Hartree + eps_nm = eps / NM_TO_BOHR # convert eps to nm for OpenMM + + state = context.getState(getEnergy=True, getPositions=True) + E0 = state.getPotentialEnergy().value_in_unit(openmm.unit.kilojoule_per_mole) * KJMOL_TO_EH + box = state.getPeriodicBoxVectors(asNumpy=True).value_in_unit(openmm.unit.nanometer) # (3,3) in nm + print("box:", box) + + # Only lower-triangular indices are valid for OpenMM triclinic box + valid_indices = [(0,0), (1,0), (1,1), (2,0), (2,1), (2,2)] + + grad = np.zeros((3, 3)) + for (i, j) in valid_indices: + box_pert = box.copy() + box_pert[i, j] += eps_nm + context.setPeriodicBoxVectors(*box_pert) + E_plus = context.getState(getEnergy=True).getPotentialEnergy().value_in_unit(openmm.unit.kilojoule_per_mole) * KJMOL_TO_EH + grad[i, j] = (E_plus - E0) / eps # dE[Eh] / dh[Bohr] + context.setPeriodicBoxVectors(*box) # restore + return grad # Eh/Bohr + + # Get cell gradient (called by an Optimizer e.g.) + def get_cell_gradient(self): + print("Inside get_cell_gradient") + # First compute the cell gradient numerically + # Using self.stored_context (should have been defined by .run call) + self.cell_gradient = self.compute_cell_gradient_fd(self.stored_context, eps=1e-4) + print("OpenMM cell gradient:",self.cell_gradient) + return self.cell_gradient + #NOTE: Adding charge/mult/PC here to be consistent with QM_theories. Not used - def run(self, current_coords=None, elems=None, Grad=False, fragment=None, qmatoms=None, label=None, charge=None, mult=None, PC=False, current_MM_coords=None, MMcharges=None, - mm_elems=None, - numcores=1): + def run(self, current_coords=None, elems=None, Grad=False, fragment=None, qmatoms=None, label=None, + charge=None, mult=None, PC=False, current_MM_coords=None, MMcharges=None, + mm_elems=None, qm_elems=None, numcores=1): module_init_time = time.time() timeA = time.time() import openmm @@ -1847,70 +1934,19 @@ def delete_exceptions(self, atomlist): for force in self.system.getForces(): if isinstance(force, openmm.NonbondedForce): for exc in range(force.getNumExceptions()): - # print(force.getExceptionParameters(exc)) + #print(force.getExceptionParameters(exc)) # force.getExceptionParameters(exc) p1, p2, chargeprod, sigmaij, epsilonij = force.getExceptionParameters(exc) if p1 in atomlist or p2 in atomlist: - # print("p1: {} and p2: {}".format(p1,p2)) - # print("chargeprod:", chargeprod) - # print("sigmaij:", sigmaij) - # print("epsilonij:", epsilonij) + #print("p1: {} and p2: {}".format(p1,p2)) + #print("chargeprod:", chargeprod) + #print("sigmaij:", sigmaij) + #print("epsilonij:", epsilonij) chargeprod._value = 0.0 force.setExceptionParameters(exc, p1, p2, chargeprod, sigmaij, epsilonij) - # print("New:", force.getExceptionParameters(exc)) + #print("New:", force.getExceptionParameters(exc)) print_time_rel(timeA, modulename="delete_exceptions") - # # Function to - # def zero_nonbondedforce(self, atomlist, zeroCoulomb=True, zeroLJ=True): - # timeA = time.time() - # print("Zero-ing nonbondedforce") - - # def charge_sigma_epsilon(charge, sigma, epsilon): - # if zeroCoulomb is True: - # newcharge = charge - # newcharge._value = 0.0 - - # else: - # newcharge = charge - # if zeroLJ is True: - # newsigma = sigma - # newsigma._value = 0.0 - # newepsilon = epsilon - # newepsilon._value = 0.0 - # else: - # newsigma = sigma - # newepsilon = epsilon - # return [newcharge, newsigma, newepsilon] - - # # Zero all nonbonding interactions for atomlist - # for force in self.system.getForces(): - # if isinstance(force, openmm.NonbondedForce): - # # Setting single particle parameters - # for atomindex in atomlist: - # oldcharge, oldsigma, oldepsilon = force.getParticleParameters(atomindex) - # newpars = charge_sigma_epsilon(oldcharge, oldsigma, oldepsilon) - # print(newpars) - # force.setParticleParameters(atomindex, newpars[0], newpars[1], newpars[2]) - # print("force.getNumExceptions() ", force.getNumExceptions()) - # print("force.getNumExceptionParameterOffsets() ", force.getNumExceptionParameterOffsets()) - # print("force.getNonbondedMethod():", force.getNonbondedMethod()) - # print("force.getNumGlobalParameters() ", force.getNumGlobalParameters()) - # # Now doing exceptions - # for exc in range(force.getNumExceptions()): - # print(force.getExceptionParameters(exc)) - # force.getExceptionParameters(exc) - # p1, p2, chargeprod, sigmaij, epsilonij = force.getExceptionParameters(exc) - # # chargeprod._value=0.0 - # # sigmaij._value=0.0 - # # epsilonij._value=0.0 - # newpars2 = charge_sigma_epsilon(chargeprod, sigmaij, epsilonij) - # force.setExceptionParameters(exc, p1, p2, newpars2[0], newpars2[1], newpars2[2]) - # # print("New:", force.getExceptionParameters(exc)) - # # force.updateParametersInContext(self.simulation.context) - # elif isinstance(force, openmm.CustomNonbondedForce): - # print("customnonbondedforce not implemented") - # ashexit() - # Updating LJ interactions in OpenMM object. Used to set LJ sites to zero e.g. so that they do not contribute # Can be used to get QM-MM LJ interaction energy def update_LJ_epsilons(self, atomlist, epsilons): @@ -2543,7 +2579,7 @@ def get_state(self): #Writing final PDB-file. If system is non-periodic (according to OpenMMTheory settings) then we set enforcePeriodicBox to False #to avoid some strange geometry translation - if openmmobject.Periodic is True: + if openmmobject.periodic is True: if printlevel >= 1: print(f"Writing final PDB file (enforcePeriodicBox={enforcePeriodicBox})") positions=simulation.context.getState(getPositions=True, enforcePeriodicBox=enforcePeriodicBox).getPositions() @@ -3263,9 +3299,9 @@ def write_xmlfile_nonbonded(resnames=None, atomnames_per_res=None, atomtypes_per LJforcelines = [] for resname, atomtypelist, chargelist, sigmalist, epsilonlist in zip(resnames, atomtypes_per_res, charges_per_res, sigmas_per_res, epsilons_per_res): - print("atomtypelist:", atomtypelist) - print("chargelist.", chargelist) - print("sigmalist", sigmalist) + #print("atomtypelist:", atomtypelist) + #print("chargelist.", chargelist) + #print("sigmalist", sigmalist) for atype, charge, sigma, epsilon in zip(atomtypelist, chargelist, sigmalist, epsilonlist): if charmm == True: #LJ parameters zero here @@ -3296,11 +3332,13 @@ def write_xmlfile_nonbonded(resnames=None, atomnames_per_res=None, atomtypes_per # All other atoms xmlfile.write("\n") xmlfile.write("\n") + # Write nonbonded block (even if skip_nb is True) + xmlfile.write("\n".format(coulomb14scale, lj14scale)) if skip_nb is False: if charmm == True: #Writing both Nonbnded force block and also LennardJonesForce block - xmlfile.write("\n".format(coulomb14scale, lj14scale)) + #xmlfile.write("\n".format(coulomb14scale, lj14scale)) for nonbondedline in nonbondedlines: xmlfile.write(nonbondedline) xmlfile.write("\n") @@ -3310,10 +3348,11 @@ def write_xmlfile_nonbonded(resnames=None, atomnames_per_res=None, atomtypes_per xmlfile.write("\n") else: #Only NonbondedForce block - xmlfile.write("\n".format(coulomb14scale, lj14scale)) + #xmlfile.write("\n".format(coulomb14scale, lj14scale)) for nonbondedline in nonbondedlines: xmlfile.write(nonbondedline) - xmlfile.write("\n") + #Close nonbondedforce block + xmlfile.write("\n") xmlfile.write("\n") print("Wrote XML-file:", filename) return filename @@ -3400,6 +3439,7 @@ def OpenMM_MD(fragment=None, theory=None, timestep=0.001, simulation_steps=None, barostat=None, pressure=1, trajectory_file_option='DCD', trajfilename='trajectory', specialtraj_frequency=1000, specialatoms=None, energy_file_option=None, force_file_option=None, atomic_units_force_reporter=False, coupling_frequency=1, charge=None, mult=None, printlevel=2, hydrogenmass=1.5, + force_periodic=None, periodic_cell_dimensions=None, anderson_thermostat=False, platform='CPU', constraints=None, restraints=None, enforcePeriodicBox=True, special_wrapping=False, special_wrapping_updatepos=False, wrapping_atoms=None, dummyatomrestraint=False, center_on_atoms=None, solute_indices=None, @@ -3412,6 +3452,7 @@ def OpenMM_MD(fragment=None, theory=None, timestep=0.001, simulation_steps=None, barostat=barostat, pressure=pressure, trajectory_file_option=trajectory_file_option, specialtraj_frequency=specialtraj_frequency, specialatoms=specialatoms, energy_file_option=energy_file_option, force_file_option=force_file_option, atomic_units_force_reporter=atomic_units_force_reporter, constraints=constraints, restraints=restraints, + force_periodic=force_periodic, periodic_cell_dimensions=periodic_cell_dimensions, coupling_frequency=coupling_frequency, anderson_thermostat=anderson_thermostat, platform=platform, enforcePeriodicBox=enforcePeriodicBox, special_wrapping=special_wrapping, special_wrapping_updatepos=special_wrapping_updatepos, wrapping_atoms=wrapping_atoms, dummyatomrestraint=dummyatomrestraint, center_on_atoms=center_on_atoms, solute_indices=solute_indices, @@ -3442,6 +3483,7 @@ def __init__(self, fragment=None, theory=None, charge=None, mult=None, timestep= energy_file_option=None, force_file_option=None, atomic_units_force_reporter=False, coupling_frequency=1, printlevel=2, platform='CPU', anderson_thermostat=False, hydrogenmass=1.5, constraints=None, restraints=None, + force_periodic=False, periodic_cell_dimensions=None, enforcePeriodicBox=True, special_wrapping=False, special_wrapping_updatepos=False, wrapping_atoms=None, dummyatomrestraint=False, center_on_atoms=None, solute_indices=None, datafilename=None, dummy_MM=False, plumed_object=None, add_centerforce=False, @@ -3521,22 +3563,30 @@ def __init__(self, fragment=None, theory=None, charge=None, mult=None, timestep= #CASE: ONIOMTHeory that might containOpenMMTheory elif isinstance(theory, ash.ONIOMTheory): print("This is an ONIOMTheory object") - print("ONIOMTheory objects are not currently supported") + #print("ONIOMTheory objects are not currently supported") + self.theory_runtype ="ONIOM" #self.QM_MM_object = theory self.ONIOM_object = theory - self.theory_runtype ="ONIOM" - - for t in theory.theories_N: - if isinstance(t,OpenMMTheory): - print("Found OpenMMTheory object inside ONIOMTheory") - self.openmmobject=t - print("Problem: ONIOMTheory containing an OpenMMTheory is currently not supported yet. Complain to developer") - ashexit() - #If nothing found then we create: + #MMtheory_index = [t.theorytype for t in theory.theories_N].index("MM") + #print("MM theory found at index:", MMtheory_index) + #self.openmmobject = theory.theories_N[MMtheory_index] + #print("self.openmmobject:", self.openmmobject) + + #for t in theory.theories_N: + # if isinstance(t,OpenMMTheory): + # print("Found OpenMMTheory object inside ONIOMTheory") + # self.openmmobject=t + # print("Warnign: ONIOMTheory containing an OpenMMTheory object is currently not officially supported yet. Complain to developer") + # #ashexit() + + #RB NOTE: Creating a new OpenMMTheory object regardless of whether one exists in the ONIOMTheory if self.openmmobject is None: + print("Creating new OpenMMTheory object to drive simulation") #Creating dummy OpenMMTheory (basic topology, particle masses, no forces except CMMRemoval) self.openmmobject = OpenMMTheory(fragment=fragment, dummysystem=True, platform=platform, printlevel=printlevel, - hydrogenmass=hydrogenmass, constraints=constraints) #NOTE: might add more options here + hydrogenmass=hydrogenmass, constraints=constraints, + periodic=force_periodic, + periodic_cell_dimensions=periodic_cell_dimensions) #NOTE: might add more options here print("Turning on externalforce option.") self.openmm_externalforceobject = self.openmmobject.add_custom_external_force() @@ -3566,7 +3616,9 @@ def __init__(self, fragment=None, theory=None, charge=None, mult=None, timestep= if self.openmmobject is None: #Creating dummy OpenMMTheory (basic topology, particle masses, no forces except CMMRemoval) self.openmmobject = OpenMMTheory(fragment=fragment, dummysystem=True, platform=platform, printlevel=printlevel, - hydrogenmass=hydrogenmass, constraints=constraints) #NOTE: might add more options here + hydrogenmass=hydrogenmass, constraints=constraints, + periodic=force_periodic, + periodic_cell_dimensions=periodic_cell_dimensions) #NOTE: might add more options here self.wraptheory_object = theory print("Turning on externalforce option.") @@ -3583,7 +3635,8 @@ def __init__(self, fragment=None, theory=None, charge=None, mult=None, timestep= print("OpenMM platform:", platform) #Creating dummy OpenMMTheory (basic topology, particle masses, no forces except CMMRemoval) self.openmmobject = OpenMMTheory(fragment=fragment, dummysystem=True, platform=platform, printlevel=printlevel, - hydrogenmass=hydrogenmass, constraints=constraints) #NOTE: might add more options here + hydrogenmass=hydrogenmass, constraints=constraints, periodic=force_periodic, + periodic_cell_dimensions=periodic_cell_dimensions) #NOTE: might add more options here print("Creating new OpenMM custom external force for external QM theory.") self.openmm_externalforceobject = self.openmmobject.add_custom_external_force() self.QM_MM_object = None @@ -3632,7 +3685,7 @@ def __init__(self, fragment=None, theory=None, charge=None, mult=None, timestep= self.user_cvforce2=None # Initializing possibility of user CV object self.user_biasvar2=None #Initializing possibility of user biasvariable #PERIODIC or not - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: #Generally we want True except sometimes we do our own wrapping self.enforcePeriodicBox=enforcePeriodicBox else: @@ -3741,7 +3794,7 @@ def __init__(self, fragment=None, theory=None, charge=None, mult=None, timestep= # print("after barostat added") self.integrator = "LangevinMiddleIntegrator" - print("Barostat requires using integrator:", integrator) + print("Barostat requires using integrator:", self.integrator) self.openmmobject.set_simulation_parameters(timestep=self.timestep, temperature=self.temperature, integrator=self.integrator, coupling_frequency=self.coupling_frequency) elif anderson_thermostat is True: @@ -3851,6 +3904,7 @@ def __init__(self, fragment=None, theory=None, charge=None, mult=None, timestep= # Let's list all OpenMM object system forces for sanity print("enforcePeriodicBox:", self.enforcePeriodicBox) print("OpenMM Forces defined:", self.openmmobject.system.getForces()) + print_time_rel(module_init_time, modulename="OpenMM_MD setup", moduleindex=1) #Set sim reporters. Needs to be done after simulation is created and not modified anymore @@ -4133,7 +4187,7 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m print("OpenMM System forces present before run:", forceclassnames) #Printing PBCs - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: print("Checking Initial PBC vectors.") self.state = self.simulation.context.getState() a, b, c = self.state.getPeriodicBoxVectors() @@ -4200,7 +4254,7 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m # PBC and Wrapping ########################################### #Defining boxvectors in case we need - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: print("Periodic Boundary Conditions used.") if self.enforcePeriodicBox is True: @@ -4255,6 +4309,20 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m wrapping_atoms=self.wrapping_atoms print(f"Will use atoms {wrapping_atoms} for wrapping") + ######################################## + # Writing intial frame to disk as PDB. + ######################################## + pdb_filename=self.trajfilename+"_firstframe.pdb" + print("Writing intial frame to disk as PDB-file:", pdb_filename) + blastate = self.simulation.context.getState(getEnergy=True, getPositions=True, + getForces=True, enforcePeriodicBox=self.enforcePeriodicBox) + with open(pdb_filename, 'w') as f: + openmm.app.pdbfile.PDBFile.writeHeader(self.openmmobject.topology, f) + openmm.app.pdbfile.PDBFile.writeModel(self.openmmobject.topology, + blastate.getPositions(asNumpy=True).value_in_unit( + openmm.unit.angstrom), f) + openmm.app.pdbfile.PDBFile.writeFooter(self.openmmobject.topology,f) + ############################################################################### # MD LOOP for each Theory-Runtype: WRAP, QMMM, QM, ONIOM, dummy_MM, MM @@ -4290,7 +4358,7 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m print_time_rel(checkpoint, modulename="get current_coords", moduleindex=2, currprintlevel=self.printlevel, currthreshold=2) #Periodic wrapping handling - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: if self.special_wrapping is True: if self.printlevel >= 2: print("special_wrapping is True. Wrapping handled by mdtraj") @@ -4386,7 +4454,7 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m print_time_rel(checkpoint, modulename="get current_coords", moduleindex=2, currprintlevel=self.printlevel, currthreshold=2) #Periodic wrapping handling - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: if self.special_wrapping is True: if self.printlevel >= 2: print("special_wrapping is True. Wrapping handled by mdtraj") @@ -4487,7 +4555,7 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m checkpoint = time.time() #Periodic wrapping handling - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: if self.special_wrapping is True: if self.printlevel >= 2: print("special_wrapping is True. Wrapping handled by mdtraj") @@ -4600,7 +4668,7 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m checkpoint = time.time() #Periodic wrapping handling - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: if self.special_wrapping is True: if self.printlevel >= 2: print("special_wrapping is True. Wrapping handled by mdtraj") @@ -4620,6 +4688,8 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m # Run step to get full system ONIOM gradient. # Updates OpenMM object with ONIOM forces + #Note: Unlike QM/MM we don't do any exit_after_customexternalforce_update here because ONIOM object does not update OpenMM object itself + # Easier. Drawback that we may have 2 OpenMMTheory objects defined. energy,gradient=self.ONIOM_object.run(current_coords=current_coords, elems=self.fragment.elems, Grad=True, charge=self.charge, mult=self.mult) if self.printlevel >= 2: print("Energy:", energy) @@ -4708,7 +4778,7 @@ def run(self, simulation_steps=None, simulation_time=None, metadynamics=False, m checkpoint = time.time() #Periodic wrapping handling - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: if self.special_wrapping is True: if self.printlevel >= 2: print("special_wrapping is True. Wrapping handled by mdtraj") @@ -4793,7 +4863,7 @@ def finalize_simulation(self): ########################## #PERIODIC BOX VECTORS ########################## - if self.openmmobject.Periodic is True: + if self.openmmobject.periodic is True: print("Checking PBC vectors:") a, b, c = self.state.getPeriodicBoxVectors() print(f"A: ", a) @@ -6403,7 +6473,7 @@ def calc_total_nonbonding_energy(system): return coulomb_energy, lj_energy -#Function that uses parmed to write and XML-file topology and OpenMM system +#Function that uses parmed to write an XML-file topology and OpenMM system #Warning: Nonbonded 14 scaling requires modification after writing def write_xmlfile_parmed(topology,system,xmlfilename): # Load Parmed diff --git a/ash/interfaces/interface_Turbomole.py b/ash/interfaces/interface_Turbomole.py index 7c2933bd3..ec5995665 100644 --- a/ash/interfaces/interface_Turbomole.py +++ b/ash/interfaces/interface_Turbomole.py @@ -5,16 +5,20 @@ import numpy as np import pathlib from ash.functions.functions_general import ashexit, BC, print_time_rel,print_line_with_mainheader, writestringtofile +from ash.modules.module_coords import nucchargelist +from ash.modules.module_coords_PBC import cell_vectors_to_params, cell_params_to_vectors import ash.settings_ash from ash.functions.functions_parallel import check_OpenMPI # Turbomole Theory object. class TurbomoleTheory: - def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel=2, label="Turbomole", - numcores=1, parallelization='SMP', functional=None, gridsize="m4", scfconv=7, symmetry="c1", rij=True, + def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel=2, label="Turbomole", uff=False, + numcores=1, parallelization='SMP', functional=None, dispersion=None, gridsize="m4", scfconv=7, symmetry="c1", rij=True, basis=None, jbasis=None, scfiterlimit=50, maxcor=500, ricore=500, controlfile=None,skip_control_gen=False, - mp2=False, pointcharge_type=None, pc_gaussians=None): + mp2=False, pointcharge_type=None, pc_gaussians=None, + periodic=False, periodic_cell_vectors=None, PBC_dimension=3, + periodic_cell_dimensions=None, kpoint_values=[1,1,1]): self.theorynamelabel="Turbomole" self.label=label @@ -25,6 +29,7 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= # self.scfiterlimit=scfiterlimit self.functional=functional + self.dispersion=dispersion self.symmetry=symmetry self.scfconv=scfconv self.gridsize=gridsize @@ -37,6 +42,7 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= self.parallelization=parallelization self.mpi_is_setup=False self.smp_is_setup=False + self.uff=uff # controlfile from user self.controlfile=controlfile @@ -51,14 +57,47 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= # if pointcharge_type is 'mxrank=Z' where Z is max multipole rank then we are doing point-multipole embedding. TODO: input not yet ready # if pointcharge_type is 'pe'. Polarizable embedding. TODO: not yet ready - # Basis set check - if controlfile is None: - print("No controlfile provided. This requires basis to be provided") - if basis is None: - print(BC.WARNING, f"No basis set provided to {self.theorynamelabel}Theory. Exiting...", BC.END) - ashexit() + # UFF + if self.uff: + print("Initializing Turbomole UFF option") + self.skip_control_gen=True + self.turbo_scf_exe="uff" + self.filename_scf="uff" + # not-UFF, i.e. QM + else: + print("Initializing Turbomole QM") + # QM controfile or Basis set check + if controlfile is None: + print("No controlfile provided. This requires basis keyword to be provided") + if basis is None: + print(BC.WARNING, f"No basis set provided to {self.theorynamelabel}Theory. Exiting...", BC.END) + ashexit() self.basis=basis + # PBC + self.periodic=periodic + self.PBC_dimension=PBC_dimension # PBC dimension 1:1D, 2:2D, 3:3D + self.periodic_cell_vectors=None # initially + self.kpoint_values=kpoint_values # k-point kpoint_values: [1,1,1] for gamma point in all directions + self.cellderiv=False # Boolean for calculating cell derivate or not. default False + if self.periodic: + print("PBC enabled") + self.cellderiv=True + if periodic_cell_vectors is None and periodic_cell_dimensions is None: + print("Error: for periodic calculations, you must specify either periodic_cell_vectors or periodic_cell_dimensions") + ashexit() + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + elif periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions = periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + print("Cell vectors:", self.periodic_cell_vectors) + print("Cell dimensions:", self.periodic_cell_dimensions) + # User controlfile if self.controlfile is not None: if self.rij is True: @@ -90,7 +129,14 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= elif functional is not None: self.dft=True print("Functional provided. Choosing Turbomole executables to be ridft and rdgrad") - if rij is True: + print("Dispersion correction:", self.dispersion) + if self.periodic: + self.turbo_scf_exe="riper" + self.turbo_exe_grad="riper" + self.filename_scf="riper" + self.filename_grad="riper" + + elif rij is True: self.turbo_scf_exe="ridft" self.turbo_exe_grad="rdgrad" self.filename_scf="ridft" @@ -107,7 +153,14 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= ashexit() else: self.jbasis=jbasis - print("self.turbo_scf_exe:", self.turbo_scf_exe) + # UFF + elif self.uff: + print("UFF..") + # else + else: + print("Error: No controlfile provided, not MP2, not DFT (no functional provided). Unclear what type of calculation this is. Exiting.") + ashexit() + # Checking OpenMPI if numcores != 1: print(f"Parallel job requested with numcores: {numcores} . Make sure that the correct OpenMPI version is available in your environment") @@ -145,14 +198,34 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= self.printlevel=printlevel self.numcores=numcores + # Get sysname once + self.run_sysname() + + # Counter for how often TurbomoleTheory.run is called + self.runcalls=0 + # Set numcores method def set_numcores(self,numcores): self.numcores=numcores + def cleanup(self): files=['coord','control','energy','gradient', 'auxbasis', 'basis', 'mos', 'ridft.out', 'rdgrad.out', 'ricc2.out', 'statistics'] for f in files: if os.path.exists(f): os + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + def get_cell_gradient(self): + return self.cell_gradient + def setup_mpi(self,numcores): print("Setting up MPI for Turbomole") print("TURBODIR:", self.TURBODIR) @@ -160,10 +233,11 @@ def setup_mpi(self,numcores): os.environ['PARNODES'] = str(numcores) print("PARA_ARCH has been set to: MPI") print("PARNODES has been set to ", numcores) - self.sysname=sp.run(['sysname'], stdout=sp.PIPE).stdout.decode('utf-8').replace("\n","") - print("sysname is now", self.sysname) + #self.sysname=sp.run([f'{self.TURBODIR}/scripts/sysname'], stdout=sp.PIPE).stdout.decode('utf-8').replace("\n","") + #print("sysname is now", self.sysname) os.environ['PATH']=f"{self.TURBODIR}/bin/{self.sysname}" + os.pathsep+os.environ['PATH'] print("PATH:", os.environ['PATH']) + self.run_sysname() self.mpi_is_setup=True def setup_smp(self,numcores): @@ -173,15 +247,18 @@ def setup_smp(self,numcores): os.environ['PARNODES'] = str(numcores) print("PARA_ARCH has been set to: SMP") print("PARNODES has been set to ", numcores) - self.sysname=sp.run(['sysname'], stdout=sp.PIPE).stdout.decode('utf-8').replace("\n","") - print("sysname is now", self.sysname) os.environ['PATH']=f"{self.TURBODIR}/bin/{self.sysname}" + os.pathsep+os.environ['PATH'] print("PATH:", os.environ['PATH']) + self.run_sysname() self.smp_is_setup=True + def run_sysname(self): + print("Running sysname script to find out system architecture") + self.sysname=sp.run([f'{self.TURBODIR}/scripts/sysname'], stdout=sp.PIPE).stdout.decode('utf-8').replace("\n","") + print("sysname is now", self.sysname) + def run_turbo(self,filename, exe="ridft", numcores=1, parallelization=None): print(f"Running executable {exe} and writing to output {filename}.out") - with open(filename+'.out', 'w') as ofile: if numcores >1: if parallelization == 'MPI': @@ -196,9 +273,11 @@ def run_turbo(self,filename, exe="ridft", numcores=1, parallelization=None): self.setup_smp(numcores) print("Now running Turbomole using binaries in dir:", f"{self.TURBODIR}/bin/{self.sysname}") process = sp.run([f"{self.TURBODIR}/bin/{self.sysname}" + f'/{exe}'], check=True, stdout=ofile, stderr=ofile, universal_newlines=True) + else: + print("Error: parallelization method not recognized. Choose either 'MPI' or 'SMP'. Exiting...") + ashexit() else: - #process = sp.run([turbomoledir + f'/{exe}'], check=True, stdout=ofile, stderr=ofile, universal_newlines=True) - self.sysname=sp.run(['sysname'], stdout=sp.PIPE).stdout.decode('utf-8').replace("\n","") + print("Running in serial mode") process = sp.run([f"{self.TURBODIR}/bin/{self.sysname}" + f'/{exe}'], check=True, stdout=ofile, stderr=ofile, universal_newlines=True) # Run function. Takes coords, elems etc. arguments and computes E or E+G. @@ -210,7 +289,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el numcores = self.numcores print(BC.OKBLUE, BC.BOLD, f"------------RUNNING {self.theorynamelabel} INTERFACE-------------", BC.END) - #Checking if charge and mult has been provided + # Checking if charge and mult has been provided if charge is None or mult is None: print(BC.FAIL, f"Error. charge and mult has not been defined for {self.theorynamelabel}Theory.run method", BC.END) ashexit() @@ -218,14 +297,14 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el print("Job label:", label) - #Coords provided to run + # Coords provided to run if current_coords is not None: pass else: print("no current_coords") ashexit() - #What elemlist to use. If qm_elems provided then QM/MM job, otherwise use elems list + # What elemlist to use. If qm_elems provided then QM/MM job, otherwise use elems list if qm_elems is None: if elems is None: print("No elems provided") @@ -252,10 +331,14 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el os.remove('control') print("Creating controlfile") - create_control_file(functional=self.functional, gridsize=self.gridsize, scfconv=self.scfconv, dft=self.dft, - symmetry="c1", basis=self.basis, jbasis=self.jbasis, rij=self.rij, mp2=self.mp2, + numelectrons = int(nucchargelist(qm_elems) - charge) + create_control_file(runcalls=self.runcalls, functional=self.functional, dispersion=self.dispersion,gridsize=self.gridsize, scfconv=self.scfconv, dft=self.dft, + symmetry="c1", basis=self.basis, jbasis=self.jbasis, rij=self.rij, mp2=self.mp2, + periodic=self.periodic, PBC_dimension=self.PBC_dimension,cell_vectors=self.periodic_cell_vectors,kpoint_values=self.kpoint_values, + cellderiv=self.cellderiv, scfiterlimit=self.scfiterlimit, maxcor=self.maxcor, ricore=self.ricore, charge=charge, mult=mult, - pcharges=MMcharges, pccoords=current_MM_coords, pointcharge_type=self.pointcharge_type, pc_gaussians=self.pc_gaussians) + pcharges=MMcharges, pccoords=current_MM_coords, pointcharge_type=self.pointcharge_type, pc_gaussians=self.pc_gaussians, + numelectrons=numelectrons) # User-controlled controlfile else: print("controlfile option chosen: ", self.controlfile) @@ -270,14 +353,37 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el ################# print("Running Turbomole executable:", self.turbo_scf_exe) - if os.path.isfile("control") is False: - print("No control file present. Exiting") - ashexit() - # SCF-energy only + + # Check for control file unless UFF + if self.uff is False: + if os.path.isfile("control") is False: + print("No control file present. Exiting") + ashexit() + + # Run energy only (SCF for DFT/WFT. or UFF) self.run_turbo(self.filename_scf, exe=self.turbo_scf_exe, parallelization=self.parallelization, numcores=self.numcores) - self.energy = grab_energy_from_energyfile() - print("SCF Energy:", self.energy) + # Updating runcalls (this will also make sure that mos file is read in next run) + self.runcalls+=1 + + if self.uff: + print("Grabbing UFF energy and gradient") + if os.path.isfile("uffenergy") is False: + print("Error: No uffenergy file created. Something went wrong with the Turbomole run. Check Turbomole output files for more info. Exiting...") + self.energy = grab_energy_from_energyfile(file="uffenergy") + print("UFF Energy:", self.energy) + # Gradient + self.gradient = grab_gradient(len(current_coords), file="uffgradient") + print("self.gradient:", self.gradient) + + else: + # Check if energy file has been created + if os.path.isfile("energy") is False: + print("Error: No energy file created. Something went wrong with the Turbomole run. Check Turbomole output files for more info. Exiting...") + ashexit() + + self.energy = grab_energy_from_energyfile() + print("SCF Energy:", self.energy) # MP2 energy only if self.mp2 is True: @@ -289,13 +395,23 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el print("Total MP2 energy:", self.energy) # GRADIENT - if Grad is True: - print("Running Turbomole-gradient executable") - print("self.turbo_exe_grad:", self.turbo_exe_grad) - print("self.filename_grad:", self.filename_grad) - self.run_turbo(self.filename_grad, exe=self.turbo_exe_grad, parallelization=self.parallelization, - numcores=self.numcores) - self.gradient = grab_gradient(len(current_coords)) + if Grad is True and self.uff is False: + + # Run gradient calc unless riper + if self.periodic: + print("Turbomole RIPER has already computed gradient") + # Now grab gradient + self.gradient = grab_gradient(len(current_coords)) + # Now grab cell gradient + self.cell_gradient = grab_cellgrad(file="control") + else: + print("Running Turbomole-gradient executable") + print("self.turbo_exe_grad:", self.turbo_exe_grad) + print("self.filename_grad:", self.filename_grad) + self.run_turbo(self.filename_grad, exe=self.turbo_exe_grad, parallelization=self.parallelization, + numcores=self.numcores) + # Now grab gradient + self.gradient = grab_gradient(len(current_coords)) if PC: self.pcgradient = grab_pcgradient(len(MMcharges)) @@ -355,18 +471,49 @@ def create_coord_file(elems,coords, write_unit='BOHR', periodic_info=None, filen coordfile.write(f"{periodic_info[0]} {periodic_info[1]} {periodic_info[2]} {periodic_info[3]} {periodic_info[4]} {periodic_info[5]}\n") coordfile.write("$end\n") -def create_control_file(functional="lh12ct-ssifpw92", gridsize="m4", scfconv="7", symmetry="c1", rij=True, dft=True, mp2=False, - basis="def2-SVP", jbasis="def2-SVP", scfiterlimit=30, maxcor=500, ricore=500, charge=None, mult=None, - pcharges=None, pccoords=None, pointcharge_type=None, pc_gaussians=None): +def create_control_file(runcalls=None, functional="lh12ct-ssifpw92", dispersion=None, gridsize="m4", scfconv="7", symmetry="c1", rij=True, dft=True, mp2=False, + basis="def2-SVP", jbasis="def2-SVP", scfiterlimit=30, maxcor=500, ricore=500, charge=None, mult=None, + periodic=False, PBC_dimension=3, cell_vectors=None, kpoint_values=[1,1,1], cellderiv=False, + pcharges=None, pccoords=None, pointcharge_type=None, pc_gaussians=None, numelectrons=None): if pccoords is not None: pccoords=pccoords*1.88972612546 - ehtline=f"$eht charge={charge} unpaired={mult-1}" + # MO-line. First assuming to be empty unless runcalls > 0(turbomole will do an EHT guess automatically) + mosline="" + # Guess-line + ehtline=f"$eht charge={charge} unpaired={mult-1}" -#Skipping orb section for now -#$closed shells -# a 1-7 ( 2 ) + # Closed vs. open-shell. Han dles occupations and MO-files + if mult == 1: + print("Case closed-shell. Writing closed-shell occupation in control file.") + shellsection=f"""$closed shells +a 1-{int(numelectrons/2)} ( 2 )""" + # If not first call then we read file mos (close-shell MO file). + if runcalls > 0: + print("Making sure mos-file from previous run is read in new control file") + mosline = "$scfmo file=mos" + else: + print("First call. No mos file will be read") + else: + print("Case open-shell. Guessing occupation to be written in $uhf section of control file.") + num_a_electrons = int((numelectrons + mult - 1) / 2) + num_b_electrons = int((numelectrons - mult + 1) / 2 ) + print("Assuming num_a_electrons:", num_a_electrons) + print("Assuming num_b_electrons:", num_b_electrons) + shellsection=f"""$uhf +$alpha shells +a 1-{num_a_electrons} ( 1 ) +$beta shells +a 1-{num_b_electrons} ( 1 ) +""" + if runcalls > 0: + print("Making sure alpha and beta mo-file from previous run are read in new control file") + mosline = """$uhfmo_alpha file=alpha +$uhfmo_beta file=beta""" + else: + print("First call. No alpha/beta MO files will be read") + # Now defining big control string. controlstring=f""" $title @@ -377,7 +524,8 @@ def create_control_file(functional="lh12ct-ssifpw92", gridsize="m4", scfconv="7" jbas ={jbasis} $basis file=basis {ehtline} -$scfmo file=mos +{mosline} +{shellsection} $scfiterlimit {scfiterlimit} $scfdamp start=0.300 step=0.050 min=0.100 $scfdump @@ -388,12 +536,34 @@ def create_control_file(functional="lh12ct-ssifpw92", gridsize="m4", scfconv="7" $grad file=gradient $scfconv {scfconv} """ + if periodic is True: + controlstring += f"""$periodic {PBC_dimension} +$lattice angs + {cell_vectors[0,0]} {cell_vectors[0,1]} {cell_vectors[0,2]} + {cell_vectors[1,0]} {cell_vectors[1,1]} {cell_vectors[1,2]} + {cell_vectors[2,0]} {cell_vectors[2,1]} {cell_vectors[2,2]} +$kpoints + nkpoints {kpoint_values[0]} {kpoint_values[1]} {kpoint_values[2]} +\n""" + if cellderiv: + controlstring += f"$optcell \n" if dft is True: controlstring += f"""$dft functional {functional} - gridsize {gridsize}""" - + gridsize {gridsize}\n""" + #Dispersion + if dispersion is not None: + if 'D3' in dispersion.upper(): + if '0' in dispersion.upper() or 'ZERO' in dispersion.upper(): + controlstring += "$disp3\n" + else: + controlstring += "$disp3 -bj\n" + elif 'D2' in dispersion.upper(): + controlstring += "$disp\n" + elif 'D4' in dispersion.upper(): + controlstring += "$disp4\n" + if mp2 is True: controlstring += f"""\n$denconv .1d-6 $ricc2 @@ -437,9 +607,9 @@ def create_control_file(functional="lh12ct-ssifpw92", gridsize="m4", scfconv="7" writestringtofile(controlstring, 'control') -def grab_energy_from_energyfile(column=1): +def grab_energy_from_energyfile(file="energy", column=1): energy = None - with open('energy', 'r') as energyfile: + with open(file, 'r') as energyfile: for line in energyfile: if '$end' in line: return energy @@ -447,18 +617,38 @@ def grab_energy_from_energyfile(column=1): energy = float(line.split()[column]) return energy -def grab_gradient(numatoms): +# Fails if multiple SCF cycles are present in file (happens for riper) +def grab_gradient_old(numatoms,file="gradient"): gradient = np.zeros((numatoms,3)) - with open('gradient', 'r') as gradfile: + with open(file, 'r') as gradfile: gradlines = gradfile.readlines() counter=0 for i,line in enumerate(gradlines): + print("line:", line) if '$end' in line: break if i > numatoms+1: gradient[counter] = [float(j.replace('D','E')) for j in line.split()] counter+=1 + return gradient +def grab_gradient(numatoms,file="gradient"): + gradient = np.zeros((numatoms,3)) + with open(file, 'r') as gradfile: + gradlines = gradfile.readlines() + #Reverse lines + gradlines.reverse() + # Setting counter to be numatoms + counter=numatoms + #Read lines in reverse + for i,line in enumerate(gradlines): + # Break when done + if counter == 0: + break + # Grab gradient + if '$end' not in line: + gradient[counter-1] = [float(j.replace('D','E')) for j in line.split()] + counter-=1 return gradient def grab_pcgradient(numpc,filename="pc_gradient"): @@ -486,4 +676,28 @@ def turbomole_grabhessian(numatoms,hessfile="hessian"): for n,v in enumerate(vals): hessian[i,n] = float(v) i = int(line.split()[0])-1 - return hessian \ No newline at end of file + return hessian + +# Usually in controlfile +def grab_cellgrad(file="control"): + cellgrad=np.zeros((3,3)) + grab=False + lines=[] + with open(file) as f: + for line in f: + if grab is True and '$end' in line: + grab=False + if grab: + lines.append(line) + if '$gradlatt' in line: + grab=True + cellgrad[0,0] = float(lines[-3].replace('D','E').split()[0]) + cellgrad[0,1] = float(lines[-3].replace('D','E').split()[1]) + cellgrad[0,2] = float(lines[-3].replace('D','E').split()[2]) + cellgrad[1,0] = float(lines[-2].replace('D','E').split()[0]) + cellgrad[1,1] = float(lines[-2].replace('D','E').split()[1]) + cellgrad[1,2] = float(lines[-2].replace('D','E').split()[2]) + cellgrad[2,0] = float(lines[-1].replace('D','E').split()[0]) + cellgrad[2,1] = float(lines[-1].replace('D','E').split()[1]) + cellgrad[2,2] = float(lines[-1].replace('D','E').split()[2]) + return cellgrad \ No newline at end of file diff --git a/ash/interfaces/interface_crest.py b/ash/interfaces/interface_crest.py index 6bc3964f0..1cbee98a2 100644 --- a/ash/interfaces/interface_crest.py +++ b/ash/interfaces/interface_crest.py @@ -10,7 +10,12 @@ import ash.settings_ash # New crest interface that supports ASH levels of theory (Limitation: must be picklable) -def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="ancopt", numcores=1, charge=None, mult=None): +def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="imtd-gc", + ewin=6.0, rthr=None, ethr=None, bthr=None, + shake=None, tstep=None, dump=None,length_ps=None,temp=None, hmass=None, + kpush=None, alpha=None, cvtype=None, dump_ps=None, + numcores=1, charge=None, mult=None, + topocheck=True, constraints=None): module_init_time=time.time() if fragment is None or theory is None: @@ -31,15 +36,105 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="ancopt", # Write initial XYZ-file fragment.write_xyzfile(xyzfilename="struc.xyz") - # Pickle for serializing theory object - import pickle - # Serialize theory object for later use - theoryfilename="theory.saved" - pickle.dump(theory, open(theoryfilename, "wb" )) - - # Write ASH inputfile: ash_input.py - ashinput=f""" -from ash import * + ##################### + # Parsing options + ##################### + + #Constraints file + constraints_line="" + if constraints is not None: + print("constraints were found:", constraints) + print("Writing constraints file: constraints.inp") + # constraints ={'atoms':'1-26', 'metadyn_atoms':} + constraintsfile="constraints.inp" + with open(constraintsfile, 'w') as f: + f.write(f"$constrain\n") + if 'atoms' in constraints: + f.write(f' atoms: {constraints["atoms"]}\n') + if 'elements' in constraints: + f.write(f' elements: {constraints["elements"]}\n') + if 'bond' in constraints: + f.write(f' bond:{constraints["bond"]}\n') + if 'distance' in constraints: + f.write(f' distance:{constraints["distance"]}\n') + if 'angle' in constraints: + f.write(f' angle:{constraints["angle"]}\n') + if 'dihedral' in constraints: + f.write(f' dihedral:{constraints["dihedral"]}\n') + if 'force' in constraints: + f.write(f' force constant={constraints["force"]}\n') + if 'reference' in constraints: + f.write(f' reference={constraints["reference"]}\n') + if 'metadyn_atoms' in constraints: + f.write(f"$metadyn\n") + f.write(f' atoms: {constraints["metadyn_atoms"]}\n') + f.write(f"$end\n") + constraints_line=f'constraints="{constraintsfile}"' + + #Dynamics options + dynamics_options=[] + if shake is not None: + dynamics_options.append(f"shake={shake}") + if tstep is not None: + dynamics_options.append(f"tstep={tstep}") + if dump is not None: + dynamics_options.append(f"dump={dump}") + if length_ps is not None: + dynamics_options.append(f"length_ps={length_ps}") + if temp is not None: + dynamics_options.append(f"temp={temp}") + if hmass is not None: + dynamics_options.append(f"hmass={hmass}") + dynamics_lines = "\n".join(dynamics_options) + + #MTD options + mtd_options=[] + if kpush is not None: + mtd_options.append(f"kpush={kpush}") + if alpha is not None: + mtd_options.append(f"alpha={alpha}") + if cvtype is not None: + mtd_options.append(f"cvtype={cvtype}") + if dump_ps is not None: + mtd_options.append(f"dump_ps={dump_ps}") + mtd_lines = "\n".join(mtd_options) + + + #CREGEN options + cregen_options=[] + if ewin is not None: + cregen_options.append(f"ewin={ewin}") + if rthr is not None: + cregen_options.append(f"rthr={rthr}") + if ethr is not None: + cregen_options.append(f"ethr={ethr}") + if bthr is not None: + cregen_options.append(f"bthr={bthr}") + cregen_lines = "\n".join(cregen_options) + + # What type of theory. + # Can be valid crest-theory string (gfn1, gfn2, gfnff) or ASH Theory + + if isinstance(theory, str): + print(f"Theory input is a string:{theory} Checking if valid") + if 'gfn' in theory.lower(): + print("Theory is a GFN method:", theory) + else: + print("Error: Invalid theory-keyword. Valid options are: gfn1, gfn2, gfnff") + ashexit() + theorylines=f"""method = "{theory.lower()}" + """ + else: + print("A Theory object was passed.") + print("Now serializing.") + # Pickle for serializing theory object + import pickle + # Serialize theory object for later use + theoryfilename="theory.saved" + pickle.dump(theory, open(theoryfilename, "wb" )) + + # Write ASH inputfile: ash_input.py + ashinput=f"""from ash import * from ash.interfaces.interface_ORCA import print_gradient_in_ORCAformat import pickle @@ -48,29 +143,48 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="ancopt", theory = pickle.load(open(\"../{theoryfilename}\", \"rb\" )) result = Singlepoint(theory=theory, fragment=frag, Grad=True) print_gradient_in_ORCAformat(result.energy,result.gradient,"genericinp", extrabasename="") - """ - with open("ash_input.py", "w") as f: - f.write(ashinput) - # Write toml file +""" + with open("ash_input.py", "w") as f: + f.write(ashinput) + + theorylines=f"""method = "generic" +binary = "python3 ../ash_input.py" +gradfile = "genericinp.engrad" +gradtype = "engrad" +""" + + # Write CREST toml file # Note: crest created dirs caleld calculation.level.X etc. and enters them tomlinput=f"""# CREST 3 input file input = "struc.xyz" runtype="{runtype}" threads = {numcores} +{constraints_line} +topo = {str(topocheck).lower()} +[cregen] +{cregen_lines} [calculation] elog="energies.log" +[dynamics] +{dynamics_lines} + +[[dynamics.meta]] +{mtd_lines} + [[calculation.level]] -method = "generic" -binary = "python3 ../ash_input.py" -gradfile = "genericinp.engrad" -gradtype = "engrad" +{theorylines} uhf = {mult-1} chrg = {charge}""" with open("input.toml", "w") as f: f.write(tomlinput) + print("CREST run-type:", runtype) + + if runtype == "imtd-gc": + print(f"Note:Energy window is {ewin} kcal/mol") + print("Now calling CREST like this: crest --input input.toml") process = sp.run([crestdir + '/crest', '--input', 'input.toml']) @@ -78,10 +192,10 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="ancopt", # Get conformers try: - list_conformers, list_xtb_energies = get_crest_conformers(charge=charge, mult=mult) - module_init_time + list_conformers, list_energies = get_crest_conformers(charge=charge, mult=mult) + return list_conformers, list_energies except: - return + return None, None @@ -259,7 +373,7 @@ def get_crest_conformers(crest_calcdir='crest-calc',conf_file="crest_conformers. #Getting energies from title lines for i in all_titles: - en=float(i) + en=float(i[0]) list_xtb_energies.append(en) for (els,cs,eny) in zip(all_elems,all_coords,list_xtb_energies): diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 7fe07208e..a51f13342 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -5,11 +5,14 @@ import numpy as np from numpy.ctypeslib import as_array from numpy.typing import ArrayLike +import signal +import os import os import time -from ash.functions.functions_general import ashexit, blankline,BC,print_time_rel,print_line_with_mainheader,listdiff,search_list_of_lists_for_index -from ash.modules.module_coords import check_charge_mult, fullindex_to_actindex,print_internal_coordinate_table,write_xyzfile,elemstonuccharges +from ash.functions.functions_general import ashexit, BC,print_time_rel,print_line_with_mainheader,search_list_of_lists_for_index,print_if_level +from ash.modules.module_coords import check_charge_mult, print_internal_coordinate_table_new,write_xyzfile,elemstonuccharges, print_coords_for_atoms +from ash.modules.module_coords_PBC import write_CIF_file, write_XSF_file, write_POSCAR_file, cell_vectors_to_params, cell_volume, align_to_standard_orientation from ash.modules.module_theory import NumGradclass from ash.modules.module_results import ASH_Results from ash.modules.module_freq import NumFreq,AnFreq,calc_hessian_xtb @@ -25,7 +28,8 @@ def DLFIND_optimizer(jobtype=None, theory=None, fragment=None, fragment2=None, c icoord=None, iopt=None, nimage=None, hessian_choice="numfreq", inithessian=0, numfreq_npoint=1, numfreq_displacement=0.005, numfreq_hessatoms=None, - numfreq_force_projection=None, print_atoms_list=None): + numfreq_force_projection=None, print_atoms_list=None, + force_noPBC=False, PBC_format_option='CIF'): """ Wrapper function around DLFIND_optimizerClass """ @@ -42,7 +46,8 @@ def DLFIND_optimizer(jobtype=None, theory=None, fragment=None, fragment2=None, c hessian_choice=hessian_choice, inithessian=inithessian, numfreq_npoint=numfreq_npoint,numfreq_displacement=numfreq_displacement, numfreq_hessatoms=numfreq_hessatoms,numfreq_force_projection=numfreq_force_projection, - print_atoms_list=print_atoms_list) + print_atoms_list=print_atoms_list, + force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) # If NumGrad then we wrap theory object into NumGrad class object if NumGrad: @@ -66,7 +71,8 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char icoord=None, iopt=None, nimage=None, delta=0.01, hessian_choice='numfreq', inithessian=None, numfreq_npoint=1,numfreq_displacement=0.005,numfreq_force_projection=None, - numfreq_hessatoms=None, print_atoms_list=None): + numfreq_hessatoms=None, print_atoms_list=None, + force_noPBC=False, PBC_format_option='CIF'): print_line_with_mainheader("DLFIND_optimizer initialization") print() @@ -87,38 +93,38 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char ashexit() # EARLY EXITS - if theory is None or fragment is None: - print("DLFIND_optimizer requires theory and fragment objects provided. Exiting.") - ashexit() + #if theory is None or fragment is None: + # print("DLFIND_optimizer requires theory and fragment objects provided. Exiting.") + # ashexit() if jobtype is None and icoord is None: print("Error: You must either select a jobtype keyword (e.g. opt, neb, dimer, instanton) or select DL-FIND icoord and iopt codes") print("Example: DLFIND_optimizer(jobtype='opt') ") ashexit() - elif jobtype == "opt": + elif jobtype.lower() == "opt": print("jobtype: opt chosen") print("Choosing icoord=1 (HDLC internal coordinates) and iopt=3 (L-BFGS minimizer)") print("For other coordinate-systems: choose icoord=0 (cartesian), icoord=2 (hdlc-tc), icoord=3 (dlc-prim), icoord=3 (dlc-tc)") print("For other opt algorithms: choose iopt codes: 0: sd, 1: cg-autorestart, 2: cg-restart10, 3: lbfgs, 10: P-RFO") icoord=1 iopt=3 - elif jobtype == "tsopt" or jobtype == "ts": + elif jobtype.lower() == "tsopt" or jobtype.lower() == "ts": print("jobtype: tsopt chosen") - print("Choosing icoord=120 (HDLC internal coordinates) and iopt=10 (P-RFO)") + print("Choosing icoord=3 (HDLC internal coordinates) and iopt=10 (P-RFO)") print("Note: inithessian option is:", inithessian) icoord=3 iopt=10 - elif jobtype == "neb": + elif jobtype.lower() == "neb": print("jobtype: neb chosen") print("Choosing icoord=120 (NEB with frozen endpoints) and iopt=3 (L-BFGS)") icoord=120 iopt=3 - elif jobtype == "dimer": + elif jobtype.lower() == "dimer": print("jobtype: dimer chosen") print("Choosing icoord=210 (Dimer) and iopt=3 (L-BFGS)") icoord=210 iopt=3 - elif jobtype == "qts" or jobtype == "instanton" : + elif jobtype.lower() == "qts" or jobtype.lower() == "instanton" : print("jobtype: qts chosen (a.k.a. instanton)") print("Choosing icoord=190 (qts) and iopt=3 (L-BFGS)") icoord=190 @@ -129,22 +135,14 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char self.fragment=fragment - self.theory=theory - - nuccharges = elemstonuccharges(self.fragment.elems) - - charge, mult = check_charge_mult(charge, mult, theory.theorytype, fragment, "DLFIND-optimizer", theory=theory) - - # Possible Fragment2 handling self.fragment2=fragment2 - if self.fragment2 is not None: - print("Fragment2 provided. This only makes sense for NEB and dimer jobs") - positions2 = self.fragment2.coords * 1.88972612546 - nframe=1 - else: - positions2=None - nframe=0 + self.theory=theory + # Periodic + self.PBC_format_option=PBC_format_option + self.PBC=False # False by default unless detected in theory + self.force_noPBC=force_noPBC + ############# #HESSIAN ############# @@ -186,141 +184,114 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char # Residues for HDLC self.residues=residues + #Constraints self.constraints=constraints + self.actatoms=actatoms + self.frozenatoms=frozenatoms - #Connectivity ? - - - ######################################## - # ACTIVE/FROZEN AND RESIDUE HANDLING - ######################################## - if self.residues is None: - print("No residues provided to optimizer. Creating a single residue for whole active system.") - else: - print("Residues provided to optimizer:", self.residues) - # What to optimize etc. - self.spec=[] - if actatoms is not None: - print("Actatoms provided:", actatoms) - print("All atoms:", fragment.allatoms) - for i in fragment.allatoms: - if i in actatoms: - if self.residues is not None: - self.spec.append(search_list_of_lists_for_index(i,self.residues)+1) - else: - self.spec.append(1) - else: - self.spec.append(-1) - elif frozenatoms is not None: - print("Frozenatoms provided:", frozenatoms) - print("All atoms:", fragment.allatoms) - for i in fragment.allatoms: - if i in frozenatoms: - self.spec.append(-1) - else: - if self.residues is not None: - self.spec.append(search_list_of_lists_for_index(i,self.residues)+1) - else: - self.spec.append(1) - else: - print("Case: no actatoms or frozenatoms provided. All atoms will be active.") - print("All atoms:", fragment.allatoms) - if self.residues is None: - self.spec=[1 for i in list(range(fragment.numatoms))] - else: - print("Residues provided:", self.residues) - for i in fragment.allatoms: - resid = search_list_of_lists_for_index(i,self.residues) - self.spec.append(resid+1) - - # Nuclear charges - self.spec=self.spec + nuccharges - - # Constraints. should be dict: constraints={'bond':[[0,1]], 'angle':[[98,99,100]]} - if self.constraints is not None: - print("Constraints passed: ", constraints) - self.numcons=0 - conlist=[] - for k,v in constraints.items(): - if k == 'bond': - print("Found bond constraint between atoms:", v) - for x in v: - b = [1,x[0]+1,x[1]+1,0,0] - conlist += b - self.numcons+=1 - elif k == 'angle': - print("Found angle constraint between atoms:", v) - for x in v: - b = [2,x[0]+1,x[1]+1,x[2]+1,0] - conlist += b - self.numcons+=1 - elif k == 'dihedral': - print("Found dihedral constraint between atoms:", v) - for x in v: - b = [3,x[0]+1,x[1]+1,x[2]+1,x[3]+1] - conlist += b - self.numcons+=1 - print("DL-FIND constraints-list:", conlist) - print("Number of constraints:", self.numcons) - self.spec = self.spec + conlist - else: - print("No constraints present") - self.numcons=0 - - # Spec - self.spec=self.spec+[1 for i in list(range(fragment.numatoms))] #? - - self.nspec=len(self.spec) - - - # Print-atoms choice - # If not specified then active-region or all-atoms - if print_atoms_list is None: - #Print-atoms list not specified. What to do: - if actatoms is not None: - #If QM/MM object then QM-region: - if isinstance(theory,QMMMTheory): - print("Theory class: QMMMTheory") - print("Will by default print only QM-region in output (use print_atoms_list option to change)") - self.print_atoms_list=theory.qmatoms - elif isinstance(theory,ONIOMTheory): - print("Theory class: ONIOMTheory") - print("Will by default print only Region1 in output (use print_atoms_list option to change)") - self.print_atoms_list=theory.regions_N[0] - else: - # Print actatoms since using Active Region (can be too much) - self.print_atoms_list=self.actatoms - else: - #No act-region. Print all atoms - self.print_atoms_list=fragment.allatoms - + self.print_atoms_list=print_atoms_list self.result_write_to_disk=result_write_to_disk - #Tracking DL-FIND cycles - self.dlfind_eg_calls=0 - self.dlfind_opt_cycles=0 - self.dlfind_neb_cycles=0 - self.dlfind_dimer_cycles=0 - - - self.NEB_energies_dict={} - self.NEB_geometries={} # Create function to calculate energies and gradients @dlf_get_gradient_wrapper def ash_e_g_func(coordinates, iimage, kiter, theory): self.dlfind_eg_calls+=1 coordinates_ang = coordinates*0.5291772109303 - energy, gradient = theory.run(current_coords=coordinates_ang, elems=self.fragment.elems, charge=charge, mult=mult, Grad=True) - # NEB: Storing current geometry for each image - # Note: spawned climbing image will be number nimage - if self.icoord >= 100 and self.icoord < 150 : - self.NEB_geometries[iimage] = coordinates_ang - self.NEB_energies_dict[iimage] = energy + if self.PBC: + print("Inside PBC") + + # Split coords into atomic and lattic + R_geo = coordinates_ang[:-4] + origin = coordinates_ang[-4] + H_geo = coordinates_ang[-3:] - origin + + # --- Enforce Standard Orientation in each step --- + print("Enforcing orientation") + # 1. Ensure the Origin dummy atom stays at exactly 0,0,0 + origin[:] = 0.0 + # 2. Force H_geo to be strictly upper-triangular + # Vector A: Only Ax is allowed (Ay and Az are zero) + H_geo[0, 1] = 0.0 # ay = 0 + H_geo[0, 2] = 0.0 # az = 0 + # Vector B: Only Bx and By are allowed (Bz is zero) + H_geo[1, 2] = 0.0 # bz = 0 + # ----------------------------------------------------- + s = np.dot(R_geo - origin, self.H_ref_inv) + R_phys = np.dot(s, H_geo) + origin + #Update cell parameters in theory + self.theory.update_cell(H_geo) + + self.full_current_coords=R_phys + self.fragment.replace_coords(self.fragment.elems, self.full_current_coords, conn=False) + + #PRINTING ACTIVE GEOMETRY IN EACH GEOMETRIC ITERATION + self.fragment.write_xyzfile(xyzfilename="Fragment-currentgeo.xyz") + if self.printlevel >= 1: + print(f"Current geometry (Å) in step {self.dlfind_opt_cycles} (print_atoms_list region)") + print("---------------------------------------------------") + print_coords_for_atoms(R_phys, self.elems_phys, self.print_atoms_list) + print("") + print("Note: printed only print_atoms_list (this is not necessarily all atoms) ") + print(f"Current cell vectors (Å):{H_geo}") + print(f"Current cell volume (Å):{cell_volume(H_geo)}") + + # E + G from theory + energy,grad_phys=self.theory.run(current_coords=R_phys, elems=self.elems_phys, + charge=self.charge, mult=self.mult, Grad=True) + self.energy = energy + + # Transformation + # M is the transformation matrix: R_phys = R_geo @ M + M = np.dot(self.H_ref_inv, H_geo) + grad_Rgeo = np.dot(grad_phys, M.T) + + # Convection, implicit lattice gradient + #grad_convection = np.dot(s.T, grad_phys) + + # Lattice gradient and masking + #Total lattice gradient: current theory cell-gradient + convection + #grad_latt_total = self.theory.cell_gradient + grad_latt_total = self.theory.get_cell_gradient() + # Standard orientation mask: + # This zeros out: a_y, a_z, and b_z + mask = np.array([ + [1, 0, 0], # dE/dax (ay, az frozen) + [1, 1, 0], # dE/dbx, dE/dby (bz frozen) + [1, 1, 1] # dE/dcx, dE/dcy, dE/dcz (all free) + ]) + grad_latt_masked = grad_latt_total * mask + # Making sure origin is zero + grad_origin = np.zeros((1, 3)) + # Final modified gradient to pass to geomeTRIC + mod_gradient = np.concatenate([ + grad_Rgeo, # (N, 3) + grad_origin, # (1, 3) + grad_latt_masked # (3, 3) + ], axis=0) + return energy, mod_gradient - return energy, gradient + else: + self.fragment.coords=coordinates_ang + #PRINTING ACTIVE GEOMETRY IN EACH GEOMETRIC ITERATION + self.fragment.write_xyzfile(xyzfilename="Fragment-currentgeo.xyz") + if self.printlevel >= 1: + print(f"Current geometry (Å) in step {self.dlfind_opt_cycles} (print_atoms_list region)") + print("---------------------------------------------------") + print_coords_for_atoms(coordinates_ang, self.fragment.elems, self.print_atoms_list) + print("") + print("Note: printed only print_atoms_list (this is not necessarily all atoms) ") + energy, gradient = self.theory.run(current_coords=coordinates_ang, elems=self.fragment.elems, charge=self.charge, mult=self.mult, Grad=True) + + # NEB: Storing current geometry for each image + # Note: spawned climbing image will be number nimage + if self.icoord >= 100 and self.icoord < 150 : + self.NEB_geometries[iimage] = coordinates_ang + self.NEB_energies_dict[iimage] = energy + + return energy, gradient # Modified wrapper function def dlf_get_hessian_wrapper(func: Callable) -> Callable: @@ -359,10 +330,10 @@ def hess_func(coords): print("NumFreq Npoint:", self.numfreq_npoint) result_freq = NumFreq(theory=self.theory, fragment=self.fragment, printlevel=0, - npoint=self.numfreq_npoint, displacement=self.numfreq_displacement, - hessatoms=self.numfreq_hessatoms,force_projection=self.numfreq_force_projection, - runmode='serial', - numcores=self.theory.numcores) + npoint=self.numfreq_npoint, displacement=self.numfreq_displacement, + hessatoms=self.numfreq_hessatoms,force_projection=self.numfreq_force_projection, + runmode='serial', + numcores=self.theory.numcores) hessian = result_freq.hessian elif self.hessian_choice == "anfreq": print("AnFreq option requested") @@ -372,8 +343,8 @@ def hess_func(coords): print("xTB Hessian option requested") #Calling xtb to get Hessian, written to disk. Returns name of Hessianfile hessianfile = calc_hessian_xtb(fragment=fragment, actatoms=self.fragment.allatoms, - numcores=self.theory.numcores, use_xtb_feature=True, - charge=charge, mult=mult) + numcores=self.theory.numcores, use_xtb_feature=True, + charge=charge, mult=mult) hessian = np.loadtxt("Hessian_from_xtb") elif 'file:' in self.hessian_choice: print("A file was detected as Hessian choice:", self.hessian_choice) @@ -389,10 +360,10 @@ def hess_func(coords): return hessian - # Create function to store results from DL-FIND #@dlf_put_coords_wrapper def store_results(a,nvar,switch, energy, coordinates, iam): + print("Called store_results with switch:", switch) if switch > 0: coords = as_array(coordinates, (nvar,)).reshape(-1, 3) coordinates_ang = coords*0.5291772109303 @@ -427,80 +398,374 @@ def store_results(a,nvar,switch, energy, coordinates, iam): # Traj-writing for regular opt if self.icoord < 100: - self.dlfind_opt_cycles+=1 - #print("="*70) - #print(f"DLFIND OPTIMIZATION CYCLE {self.dlfind_opt_cycles}") - #print("="*70) - #Storing current coordinates - #traj_coords.append(np.array(coordinates_ang)) - print("Writing regular-opt traj") - write_xyzfile(fragment.elems, coordinates_ang, "DLFIND_opt_traj", printlevel=2, writemode='a', title=f"Energy: {energy}") - self.current_geo=coordinates_ang + if switch == 1: + print("Writing regular opt traj") + self.dlfind_opt_cycles+=1 + #print("="*70) + #print(f"DLFIND OPTIMIZATION CYCLE {self.dlfind_opt_cycles}") + #print("="*70) + #Storing current coordinates + #traj_coords.append(np.array(coordinates_ang)) + print_if_level(f"Writing regular-opt traj",self.printlevel,1) + write_xyzfile(self.fragment.elems, coordinates_ang, "DLFIND_opt_traj", printlevel=self.printlevel, writemode='a', title=f"Energy: {energy}") + self.current_geo=coordinates_ang + # Unclear what switch 2 and 3 are... + elif switch == 2: + # print("switch 2") + pass + elif switch == 3: + # print("switch 3") + pass + else: + # print("Unknown switch") + pass # Traj-writing for dimer elif self.icoord >= 200: print("Writing Dimer traj") if switch == 1: # 1: actual geometry - write_xyzfile(fragment.elems, coordinates_ang, "DLFIND_dimertraj_1", printlevel=2, writemode='a', title=f"Energy: {energy}") + write_xyzfile(fragment.elems, coordinates_ang, "DLFIND_dimertraj_1", printlevel=self.printlevel, writemode='a', title=f"Energy: {energy}") self.current_geo=coordinates_ang elif switch == 2: # Approximate: self.dlfind_dimer_cycles+=1 # transition mode - write_xyzfile(fragment.elems, coordinates_ang, "DLFIND_dimertraj_2", printlevel=2, writemode='a', title=f"Energy: {energy}") + write_xyzfile(fragment.elems, coordinates_ang, "DLFIND_dimertraj_2", printlevel=self.printlevel, writemode='a', title=f"Energy: {energy}") elif switch == 3: - write_xyzfile(fragment.elems, coordinates_ang, "DLFIND_dimertraj_3", printlevel=2, writemode='a', title=f"Energy: {energy}") + write_xyzfile(fragment.elems, coordinates_ang, "DLFIND_dimertraj_3", printlevel=self.printlevel, writemode='a', title=f"Energy: {energy}") self.traj_energies.append(energy) return + self.dlf_get_gradient = functools.partial(ash_e_g_func, theory=self.theory) + self.dlf_get_hessian = functools.partial(hess_func) + self.dlf_put_coords = functools.partial( store_results, None) + + # Should be run only once + def setup_PBC(self): + + # Real elements + self.elems_phys=self.fragment.elems + # Align to standard orientation + aligned_atom_coords, aligned_vectors = align_to_standard_orientation(self.fragment.coords, + self.theory.periodic_cell_vectors) + self.fragment.coords=aligned_atom_coords + self.theory.update_cell(aligned_vectors) + + # Reference + self.H_ref = aligned_vectors.copy() + self.H_ref_inv = np.linalg.inv(self.H_ref) + + # Defining DLFIND_coords to have aligned coords and 4 dummyatoms + self.DLFIND_coords = np.concatenate((aligned_atom_coords,[[0.0,0.0,0.0]],aligned_vectors),axis=0) + self.DLFIND_elems = self.fragment.elems+ ['F','F','F','F'] + + def print_settings(self): + # Print-atoms choice + # If not specified then active-region or all-atoms + if self.print_atoms_list is None: + #Print-atoms list not specified. What to do: + if self.actatoms is not None: + #If QM/MM object then QM-region: + if isinstance(self.theory,QMMMTheory): + print("Theory class: QMMMTheory") + print("Will by default print only QM-region in output (use print_atoms_list option to change)") + self.print_atoms_list=self.theory.qmatoms + elif isinstance(self.theory,ONIOMTheory): + print("Theory class: ONIOMTheory") + print("Will by default print only Region1 in output (use print_atoms_list option to change)") + self.print_atoms_list=self.theory.regions_N[0] + else: + # Print actatoms since using Active Region (can be too much) + self.print_atoms_list=self.actatoms + else: + #No act-region. Print all atoms + self.print_atoms_list=self.fragment.allatoms + + def setup_constraints_act_frozen(self): + + ######################################## + # ACTIVE/FROZEN AND RESIDUE HANDLING + ######################################## + if self.residues is None: + print_if_level("No residues provided to optimizer. Creating a single residue for whole active system.",self.printlevel,2) + else: + print("Residues provided to optimizer:", self.residues) + + # What to optimize etc. + self.spec=[] + + if self.PBC: + allatoms = self.fragment.allatoms + [self.fragment.numatoms, self.fragment.numatoms+1, self.fragment.numatoms+2, self.fragment.numatoms+3] + numatoms=self.fragment.numatoms + 4 + elems = self.fragment.elems + ['F','F','F','F'] + else: + allatoms = self.fragment.allatoms + numatoms=self.fragment.numatoms + elems = self.fragment.elems + + + # First identify possible frozen constraints defined in constraints dict + if self.constraints is not None: + print("RB here") + # Check if any Cartesian constraint is present + if any(k in self.constraints for k in {'x','y','z','xy','xz','yz','xyz'}): + if self.frozenatoms is None: + self.frozenatoms=[] + print_if_level(f"Cartesian constraints found in constraints dict.", self.printlevel,2 ) + # Grab possible xyz constraints frm constraints dict + frozenatoms_x = self.constraints.get('x',[]) + frozenatoms_y = self.constraints.get('y',[]) + frozenatoms_z = self.constraints.get('z',[]) + frozenatoms_xy = self.constraints.get('xy',[]) + frozenatoms_xz = self.constraints.get('xz',[]) + frozenatoms_yz = self.constraints.get('yz',[]) + frozenatoms_xyz = self.constraints.get('xyz',[]) + # XYZ constraints are the same frozenatoms, adding + self.frozenatoms = self.frozenatoms+frozenatoms_xyz + print("frozenatoms_z:", frozenatoms_z) + if self.actatoms is not None: + print_if_level("Actatoms provided:", self.actatoms) + + if self.PBC: + print("PBC detected. Adding 4 dummy atoms to actatoms if not already present") + for i in range(self.fragment.numatoms, self.fragment.numatoms+4): + if i not in self.actatoms: + self.actatoms.append(i) + + if self.frozenatoms is not None: + if len(self.frozenatoms) > 0: + print("frozenatoms:", self.frozenatoms) + print("Error: actatoms and frozenatoms cannot both be defined") + ashexit() + print_if_level(f"All atoms: {allatoms}", self.printlevel,2 ) + + for i in self.fragment.allatoms: + if i in self.actatoms: + if self.residues is not None: + self.spec.append(search_list_of_lists_for_index(i,self.residues)+1) + else: + self.spec.append(1) + else: + self.spec.append(-1) + elif self.frozenatoms is not None: + print_if_level(f"Frozenatoms provided: {self.frozenatoms}", self.printlevel,2 ) + print_if_level(f"All atoms: {self.fragment.allatoms}", self.printlevel,2 ) + # Loopign over all atoms, + # Adding -1 for frozen, +1 for active, and if residues provided then adding residue number for active atoms + # Also adding -2,-3,-4 for frozen atoms with Cartesian constraints in x,y,z and -23,-24,-34 for frozen atoms with xy,xz,yz constraints + for i in allatoms: + if i in self.frozenatoms: + self.spec.append(-1) + elif i in frozenatoms_x: + self.spec.append(-2) + elif i in frozenatoms_y: + self.spec.append(-3) + elif i in frozenatoms_z: + self.spec.append(-4) + elif i in frozenatoms_xy: + self.spec.append(-23) + elif i in frozenatoms_xz: + self.spec.append(-24) + elif i in frozenatoms_yz: + self.spec.append(-34) + else: + if self.residues is not None: + self.spec.append(search_list_of_lists_for_index(i,self.residues)+1) + else: + self.spec.append(1) + else: + print_if_level("Case: no actatoms or frozenatoms provided. All atoms will be active.", self.printlevel,2) + print_if_level(f"All atoms: {allatoms}", self.printlevel,2) + if self.residues is None: + # If no residues provided then all atoms get spec 1 (active) + # Doing all real atoms + #for i in self.fragment.allatoms: + self.spec=[1 for i in self.fragment.allatoms] + print("self.spec:", self.spec) + if self.PBC: + print("PBC detected. Adding 4 dummy atoms as a separate residue") + self.spec = self.spec + [2,2,2,2] + else: + print_if_level(f"Residues provided: {self.residues}", self.printlevel,2) + for i in allatoms: + resid = search_list_of_lists_for_index(i,self.residues) + self.spec.append(resid+1) + + # Nuclear charges + nuccharges = elemstonuccharges(elems) + self.spec=self.spec + nuccharges + + # Constraints. should be dict: constraints={'bond':[[0,1]], 'angle':[[98,99,100]]} + if self.constraints is not None: + print_if_level(f"Constraints passed to DL-FIND: {self.constraints}", self.printlevel,2) + self.numcons=0 + conlist=[] + for k,v in self.constraints.items(): + if k == 'bond' or k == 'distance': + print_if_level(f"Found bond constraint between atoms: {v}", self.printlevel,2) + for x in v: + b = [1,x[0]+1,x[1]+1,0,0] + conlist += b + self.numcons+=1 + elif k == 'angle': + print_if_level(f"Found angle constraint between atoms: {v}", self.printlevel,2) + for x in v: + b = [2,x[0]+1,x[1]+1,x[2]+1,0] + conlist += b + self.numcons+=1 + elif k == 'dihedral' or k == 'torsion': + print_if_level(f"Found dihedral constraint between atoms: {v}", self.printlevel,2) + for x in v: + b = [3,x[0]+1,x[1]+1,x[2]+1,x[3]+1] + conlist += b + self.numcons+=1 + print_if_level(f"DL-FIND constraints-list: {conlist}", self.printlevel,2) + print_if_level(f"Number of constraints: {self.numcons}", self.printlevel,2) + self.spec = self.spec + conlist + else: + print_if_level("No constraints present", self.printlevel,2) + self.numcons=0 + + # Spec (microterative) not used for now + self.spec=self.spec+[1 for i in list(range(numatoms))] #? + + self.nspec=len(self.spec) + + print("DL-FIND spec list:", self.spec) + + + def prepare_run(self): + + from libdlfind.callback import make_dlf_get_params self.traj_energies = [] self.current_geo = [] + # Converting coordinates from Angstrom to Bohr positions = self.fragment.coords * 1.88972612546 + nz = self.fragment.numatoms + if self.PBC: + print("Preparing for PBC optimization. Using aligned coordinates with 4 dummy atoms") + print("self.DLFIND_coords:", self.DLFIND_coords) + positions = self.DLFIND_coords * 1.88972612546 + nz = self.fragment.numatoms + 4 + + # Possible Fragment2 handling + if self.fragment2 is not None: + print("Fragment2 provided. This only makes sense for NEB and dimer jobs") + positions2 = self.fragment2.coords * 1.88972612546 + nframe=1 + else: + positions2=None + nframe=0 + + # Setup constraints and frozen/active stuff + self.setup_constraints_act_frozen() + self.dlf_get_params = make_dlf_get_params(coords=positions, coords2=positions2, icoord=self.icoord, iopt=self.iopt, maxcycle=self.maxcycle,tolerance=self.tolerance, tolerance_e=self.tolerance_e, inithessian=self.inithessian, - nframe=nframe, nz = self.fragment.numatoms, + nframe=nframe, nz=nz, ncons=self.numcons, delta=self.delta, spec=self.spec, printl=self.printlevel, nimage=self.nimage) - self.dlf_get_gradient = functools.partial(ash_e_g_func, theory=theory) - self.dlf_get_hessian = functools.partial(hess_func) - self.dlf_put_coords = functools.partial( store_results, None) - # Delete old traj file before beginning remove_files=['DLFIND_opt_traj.xyz','DLFIND_dimertraj_1.xyz', 'DLFIND_dimertraj_2.xyz','DLFIND_dimertraj_3.xyz','DLFIND_NEBpath_current.xyz', 'DLFIND_NEBpath_all.xyz', 'DLFIND_CIgeo_traj.xyz'] - print("Removing possible old files:", remove_files) + print_if_level(f"Removing possible old files: {remove_files}", self.printlevel,2) for rfile in remove_files: try: os.remove(rfile) - print("removed ", rfile) + print_if_level(f"removed {rfile} ", self.printlevel,2) except FileNotFoundError: #print(f"file {rfile} not found") pass - print("\nArguments passed to DL-FIND:") - print("icoord:", self.icoord) - print("iopt:", self.iopt) - print("maxcycle:", maxcycle) - print("spec:", self.spec) - if icoord == 120: - print("NEB nimage:", nimage) - - def run(self, theory=None, fragment=None, charge=None, mult=None): + print_if_level(f"\nArguments passed to DL-FIND:", self.printlevel,2) + print_if_level(f"icoord: {self.icoord}", self.printlevel,2) + print_if_level(f"iopt: {self.iopt}", self.printlevel,2) + print_if_level(f"maxcycle: {self.maxcycle}", self.printlevel,2) + print_if_level(f"spec ({len(self.spec)}): {self.spec}", self.printlevel,2) + if self.icoord == 120: + print_if_level(f"NEB nimage: {self.nimage}", self.printlevel,2) + def run(self, theory=None, fragment=None, fragment2=None, constraints=None, charge=None, mult=None): from libdlfind import dl_find - if self.fragment2 is None: - nvarin=self.fragment.numatoms * 3 - nvarin2=0 + + #Tracking DL-FIND cycles + self.dlfind_eg_calls = 0 + self.dlfind_opt_cycles = 0 + self.dlfind_neb_cycles = 0 + self.dlfind_dimer_cycles = 0 + self.NEB_energies_dict={} + self.NEB_geometries={} + + # Runcounter + self.runcounter=0 + + + # Update self fragment if a run fragment was provided + if fragment is not None: + self.fragment=fragment + + # Update self theory if a run fragment was provided + if theory is not None: + self.theory=theory + + # Check if PBCs used by theory + if getattr(self.theory, "periodic", False): + print("Detected periodicity in Theory object") + if self.force_noPBC is True: + print("force_noPBC flag is True. Will run optimization without PBC") + self.PBC=False + else: + print("Activating periodic routines ") + print("Setting up PBC for DL-FIND optimization") + self.setup_PBC() + self.PBC=True + print("PBC setup complete") + if fragment2 is None and self.fragment2 is None: + nvarin=self.fragment.numatoms * 3 + 4*3 # 4 dummy atoms with 3 coords each + nvarin2=0 + # TODO: fragment2 + #elif fragment2 is not None: + # nvarin = self.fragment.numatoms * 3 + 4*3 # 4 dummy atoms with 3 coords each + # nvarin2 = self.fragment2.numatoms * 3 + #elif self.fragment2 is not None: + # nvarin = self.fragment.numatoms * 3 + # nvarin2 = self.fragment2.numatoms * 3 + # Update constraints if provided else: - # Fragment 1 and 2 - nvarin = self.fragment.numatoms * 3 - nvarin2 = self.fragment2.numatoms * 3 + if fragment2 is None and self.fragment2 is None: + nvarin=self.fragment.numatoms * 3 + nvarin2=0 + elif fragment2 is not None: + nvarin = self.fragment.numatoms * 3 + nvarin2 = self.fragment2.numatoms * 3 + elif self.fragment2 is not None: + nvarin = self.fragment.numatoms * 3 + nvarin2 = self.fragment2.numatoms * 3 + # Update constraints if provided + if constraints is not None: + self.constraints=constraints + + if self.runcounter == 0: + self.print_settings() + + # Prepare run, including constraints etc. + self.prepare_run() + self.charge, self.mult = check_charge_mult(charge, mult, self.theory.theorytype, self.fragment, + "DLFIND-optimizer", theory=self.theory, printlevel=self.printlevel) # Run DL-FIND print("Now starting DL-FIND") + + def _sigint_handler(signum, frame): + print("\nCtrl-C caught! Aborting DL-FIND run...") + signal.signal(signal.SIGINT, signal.SIG_DFL) # restore default handler + os.kill(os.getpid(), signal.SIGINT) # re-send signal at OS level + + signal.signal(signal.SIGINT, _sigint_handler) + dl_find( nvarin=nvarin, nvarin2=nvarin2, nspec=self.nspec, dlf_get_gradient=self.dlf_get_gradient, @@ -511,30 +776,55 @@ def run(self, theory=None, fragment=None, charge=None, mult=None): # Regular optimization if self.icoord < 100: - print(f"\nDL-FIND optimization finished in {self.dlfind_opt_cycles} steps!") + print(f"\nDL-FIND optimization converged in {self.dlfind_opt_cycles} steps!") print("Number of DL-FIND energy-gradient evaluations:", self.dlfind_eg_calls) # Print results finalenergy=self.traj_energies[-1] print("Final optimized energy:", finalenergy) + # Final coordinate handling - final_coords=self.current_geo - fragment.replace_coords(fragment.elems,final_coords, conn=False) - # Writing out fragment file and XYZ file - fragment.print_system(filename='Fragment-optimized.ygg') - fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') - fragment.set_energy(finalenergy) + if self.PBC: + self.fragment.print_system(filename='Fragment-optimized.ygg') + self.fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') + self.fragment.set_energy(finalenergy) + print("Final geometry") + self.fragment.print_coords() + print("PBC True. Writing final optimized geometry in PBC-format") + print("PBC_format_option:", self.PBC_format_option) + if self.PBC_format_option.upper() == "CIF": + convert_to_pbcfile=write_CIF_file + file_ext='cif' + elif self.PBC_format_option.upper() == "XSF": + convert_to_pbcfile=write_XSF_file + file_ext='xsf' + elif self.PBC_format_option.upper() == "POSCAR": + convert_to_pbcfile=write_POSCAR_file + file_ext='POSCAR' + pbcfile = convert_to_pbcfile(self.fragment.coords,self.fragment.elems,cellvectors=theory.periodic_cell_vectors, + filename=f"Fragment-optimized.{file_ext}") + print(f"Final cell vectors (Å):{theory.periodic_cell_vectors}") + print(f"Final cell parameters: {cell_vectors_to_params(theory.periodic_cell_vectors)}") + print(f"Final cell volume (Å): {cell_volume(theory.periodic_cell_vectors)}") + else: + # Writing out fragment file and XYZ file + self.fragment.print_system(filename='Fragment-optimized.ygg') + self.fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') + self.fragment.set_energy(finalenergy) + print("Final geometry") + self.fragment.print_coords() # Printing internal coordinate table if self.printlevel >= 2: - print_internal_coordinate_table(fragment,actatoms=self.print_atoms_list) + if len(self.print_atoms_list) < 50: + print_internal_coordinate_table_new(self.fragment,actatoms=self.print_atoms_list) print() - # Now returning final Results object + # Results object result = ASH_Results(label="DLFIND_optimizer", energy=finalenergy) + if self.result_write_to_disk is True: - result.write_to_disk(filename="DLFIND_optimizer.result") - return result + result.write_to_disk(filename="DLFIND_optimizer.result", printlevel=self.printlevel) elif self.icoord >= 100 and self.icoord < 150: # NEB job complete @@ -547,7 +837,7 @@ def run(self, theory=None, fragment=None, charge=None, mult=None): # Now returning final Results object #result = ASH_Results(label="DLFIND_optimizer", energy=finalenergy) result = ASH_Results(label="DLFIND_NEB-CI calc", energy=CI_fragment_energy, geometry=CI_fragment_coords, - charge=charge, mult=mult, MEP_energies_dict=self.NEB_energies_dict, + charge=self.charge, mult=self.mult, MEP_energies_dict=self.NEB_energies_dict, barrier_energy=None) if self.result_write_to_disk is True: @@ -563,19 +853,87 @@ def run(self, theory=None, fragment=None, charge=None, mult=None): # Final coordinate handling final_coords=self.current_geo - fragment.replace_coords(fragment.elems,final_coords, conn=False) + self.fragment.replace_coords(self.fragment.elems,final_coords, conn=False) # Writing out fragment file and XYZ file - fragment.print_system(filename='Fragment-optimized.ygg') - fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') - fragment.set_energy(finalenergy) + self.fragment.print_system(filename='Fragment-optimized.ygg') + self.fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') + self.fragment.set_energy(finalenergy) # Printing internal coordinate table if self.printlevel >= 2: - print_internal_coordinate_table(fragment,actatoms=self.print_atoms_list) + if len(self.print_atoms_list) < 50: + print_internal_coordinate_table_new(self.fragment,actatoms=self.print_atoms_list) print() - # Now returning final Results object - result = ASH_Results(label="DLFIND_optimizer", energy=finalenergy) + # Results object + result = ASH_Results(label="DLFIND_dimer", energy=finalenergy) + if self.result_write_to_disk is True: - result.write_to_disk(filename="DLFIND_optimizer.result") - return result \ No newline at end of file + result.write_to_disk(filename="DLFIND_dimer.result", printlevel=self.printlevel) + + + return result + + +# Helper function to define residues +def define_residues(fragment=None, min_size=5, max_size=15): + _COVALENT_RADII = { + 'H': 0.31, 'He': 0.28, + 'Li': 1.28, 'Be': 0.96, 'B': 0.84, 'C': 0.76, 'N': 0.71, 'O': 0.66, + 'F': 0.57, 'Ne': 0.58, + 'Na': 1.66, 'Mg': 1.41, 'Al': 1.21, 'Si': 1.11, 'P': 1.07, 'S': 1.05, + 'Cl': 1.02, 'Ar': 1.06, + 'K': 2.03, 'Ca': 1.76, 'Sc': 1.70, 'Ti': 1.60, 'V': 1.53, 'Cr': 1.39, + 'Mn': 1.61, 'Fe': 1.52, 'Co': 1.50, 'Ni': 1.24, 'Cu': 1.32, 'Zn': 1.22, + 'Ga': 1.22, 'Ge': 1.20, 'As': 1.19, 'Se': 1.20, 'Br': 1.20, 'Kr': 1.16, + 'Rb': 2.20, 'Sr': 1.95, 'Y': 1.90, 'Zr': 1.75, 'Nb': 1.64, 'Mo': 1.54, + 'Tc': 1.47, 'Ru': 1.46, 'Rh': 1.42, 'Pd': 1.39, 'Ag': 1.45, 'Cd': 1.44, + 'In': 1.42, 'Sn': 1.39, 'Sb': 1.39, 'Te': 1.38, 'I': 1.39, 'Xe': 1.40, + 'Cs': 2.44, 'Ba': 2.15, 'La': 2.07, 'Ce': 2.04, 'Pr': 2.03, 'Nd': 2.01, + 'Hf': 1.75, 'Ta': 1.70, 'W': 1.62, 'Re': 1.51, 'Os': 1.44, 'Ir': 1.41, + 'Pt': 1.36, 'Au': 1.36, 'Hg': 1.32, 'Tl': 1.45, 'Pb': 1.46, 'Bi': 1.48, + } + elems=fragment.elems + coords=fragment.coords + num_atoms = len(elems) + coords = np.array(coords) + + # 1. Build Connectivity (Adjacency List) + adj = [[] for _ in range(num_atoms)] + for i in range(num_atoms): + for j in range(i + 1, num_atoms): + dist = np.linalg.norm(coords[i] - coords[j]) + # Threshold: sum of radii + 0.45A tolerance + threshold = _COVALENT_RADII.get(elems[i], 0.7) + \ + _COVALENT_RADII.get(elems[j], 0.7) + 0.45 + if dist < threshold: + adj[i].append(j) + adj[j].append(i) + + # 2. Split into Residues using Greedy BFS + unvisited = set(range(num_atoms)) + residues = [] + + while unvisited: + # Start a new residue from an arbitrary unvisited atom + root = min(unvisited) + current_res = [] + queue = [root] + + while queue and len(current_res) < max_size: + node = queue.pop(0) + if node in unvisited: + unvisited.remove(node) + current_res.append(node) + # Add neighbors to the queue to keep the residue contiguous + for neighbor in adj[node]: + if neighbor in unvisited: + queue.append(neighbor) + + # Cleanup: If a residue is too small, merge it with the last one + if len(current_res) < min_size and residues: + residues[-1].extend(current_res) + else: + residues.append(current_res) + + return residues \ No newline at end of file diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index 324ff672d..2a9c021b6 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -1,18 +1,19 @@ import time -import numpy as np import os from ash.modules.module_coords import elemstonuccharges +from ash.modules.module_coords_PBC import cell_params_to_vectors, cell_vectors_to_params from ash.functions.functions_general import ashexit, BC,print_time_rel from ash.functions.functions_general import print_line_with_mainheader +from ash.interfaces.interface_mace import stress_to_grad import ash.constants # Simple interface to Fairchem -# Use: +# Use: # VIA MODEL NAMES # Models available in version 2: uma-s-1p1 (faster,very good), uma-m-1p1 (slower,best) -# Example: model_name = "uma-s-1p1" +# Example: model_name = "uma-s-1p1" # Requires hugging-face token activated in shell # e.g. export HF_TOKEN=xxxxxxx @@ -20,8 +21,10 @@ #model_file="uma-s-1p1.pt" class FairchemTheory(): - def __init__(self, model_name=None, model_file=None, task_name=None, device="cuda", seed=41, numcores=1): + def __init__(self, model_name=None, model_file=None, task_name=None, platform="cuda", device=None, seed=41, numcores=1, + printlevel=2, periodic=False, periodic_cell_vectors=None, periodic_cell_dimensions=None): + module_init_time=time.time() # Early exits try: import fairchem @@ -43,23 +46,90 @@ def __init__(self, model_name=None, model_file=None, task_name=None, device="cud print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") - + self.printlevel=printlevel self.task_name=task_name - self.device=device + + # Platform/device + if device is not None: + print("Warning: device keyword is deprecated. Use platform instead") + ashexit() + self.platform=platform.lower() + self.model_name=model_name self.model_file=model_file self.seed=seed self.numcores=numcores - if self.device.lower() == 'cpu': + # PBC + self.periodic=periodic + self.periodic_cell_vectors=None # initially + self.stress=False + if self.periodic: + print("PBC enabled in FairchemTheory") + self.stress=True + if periodic_cell_vectors is None and periodic_cell_dimensions is None: + print("Error: for periodic calculations, you must specify either periodic_cell_vectors or periodic_cell_dimensions") + ashexit() + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + elif periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions = periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + print("Cell vectors:", self.periodic_cell_vectors) + print("Cell dimensions:", self.periodic_cell_dimensions) + + # Counter for runcalls + self.runcalls=0 + + if self.platform.lower() == 'cpu': #Works ?? os.environ['OMP_NUM_THREADS'] = str(numcores) + from fairchem.core import pretrained_mlip, FAIRChemCalculator + if self.model_name is not None: + print("Model set:", self.model_name) + predictor = pretrained_mlip.get_predict_unit(self.model_name, device=self.platform) + self.calc = FAIRChemCalculator(predictor, task_name=self.task_name, seed=self.seed) + elif self.model_file is not None: + print("Model-file set:", self.model_file) + # TODO: can we fix + #print("Warning: single-atom systems do not work with this approach") + self.calc = FAIRChemCalculator.from_model_checkpoint(self.model_file, + task_name=self.task_name, device=self.platform, + seed=self.seed) + else: + print("Error:Neither model or model_file was set") + ashexit() + print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} init', moduleindex=2) + + def cleanup(self): + pass + + + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + def get_cell_gradient(self): + return self.cell_gradient + def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, elems=None, Grad=False, PC=False, numcores=None, restart=False, label=None, Hessian=False, charge=None, mult=None): module_init_time=time.time() + if self.printlevel >= 2: + print(BC.OKBLUE,BC.BOLD, f"------------RUNNING {self.theorynamelabel} INTERFACE-------------", BC.END) # What elemlist to use. If qm_elems provided then QM/MM job, otherwise use elems list if qm_elems is None: if elems is None: @@ -68,39 +138,53 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el else: qm_elems = elems - from fairchem.core import pretrained_mlip, FAIRChemCalculator - - if self.model_name is not None: - print("Model set:", self.model_name) - predictor = pretrained_mlip.get_predict_unit(self.model_name, device=self.device) - calc = FAIRChemCalculator(predictor, task_name=self.task_name, seed=self.seed) - elif self.model_file is not None: - print("Model-file set:", self.model_file) - calc = FAIRChemCalculator.from_model_checkpoint(self.model_file, - task_name=self.task_name, device=self.device, - seed=self.seed) - else: - print("Error:Neither model or model_file was set") - ashexit() - import ase - atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) - # Setting charge/mult - atoms.info["charge"] = charge - atoms.info["spin"] = mult + if self.runcalls == 0: + print("First runcall. Creating atoms object") + if self.periodic: + self.atoms = ase.atoms.Atoms(qm_elems,positions=current_coords, cell=self.periodic_cell_vectors, + pbc=True) + else: + self.atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) + + self.atoms.info["charge"] = charge + self.atoms.info["spin"] = mult + # Assigning calculator + self.atoms.calc =self.calc + elif len(self.atoms.numbers) != len(current_coords): + print("Number-of-atoms mismatch (new molecule?). Creating new atoms object") + if self.periodic: + self.atoms = ase.atoms.Atoms(qm_elems,positions=current_coords, cell=self.periodic_cell_vectors, + pbc=True) + else: + self.atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) - # Assigning calculator - atoms.calc = calc + self.atoms.info["charge"] = charge + self.atoms.info["spin"] = mult + # Assigning calculator + self.atoms.calc =self.calc + else: + print("Updating coordinates in atoms object") + self.atoms.set_positions(current_coords) # Energy - en = atoms.get_potential_energy() + en = self.atoms.get_potential_energy() self.energy = float(en*ash.constants.evtohar) - + if self.printlevel >= 2: + print(f"Single-point {self.theorynamelabel} energy:", self.energy) if Grad: - forces = atoms.get_forces() + forces = self.atoms.get_forces() self.gradient = forces/-51.422067090480645 - + if self.stress: + stress_ev_ang3 = self.atoms.get_stress(voigt=False) + self.cell_gradient = stress_to_grad(stress_ev_ang3,self.atoms.get_volume(), self.atoms.get_cell()) + print("Cell gradient:", self.cell_gradient) + + self.runcalls+=1 + if self.printlevel >= 2: + print(BC.OKBLUE,BC.BOLD,f"------------ENDING {self.theorynamelabel}-INTERFACE-------------", BC.END) print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} run', moduleindex=2) + if Grad: return self.energy, self.gradient else: diff --git a/ash/interfaces/interface_fsm.py b/ash/interfaces/interface_fsm.py new file mode 100644 index 000000000..f73816a68 --- /dev/null +++ b/ash/interfaces/interface_fsm.py @@ -0,0 +1,163 @@ +from ash.functions.functions_general import ashexit, print_line_with_mainheader +from ash.modules.module_singlepoint import Singlepoint +from ash.modules.module_coords import Fragment +from ash.constants import hartoeV +import numpy as np +import copy + +# Simpler ASH-ASE calculator +class ASH_ASE_calculator: + def __init__(self, theory=None, fragment=None): + self.theory = theory + # Used for elems, charge and mult + self.fragment = fragment + self.calls=0 + def get_potential_energy(self, atomsobj): + print("Called ASHcalc get_potential_energy") + self.calls+=1 + print(atomsobj) + #Copy ASE coords into ASH fragment + coords = copy.copy(atomsobj.positions) + energy,gradient = self.theory.run(current_coords=coords, elems=self.fragment.elems, + charge=self.fragment.charge, mult=self.fragment.mult, Grad=True) + self.potenergy = energy*hartoeV + self.forces = -gradient*51.4220674763 + return self.potenergy + + def get_forces(self, atomsobj): + print("Called ASHcalc get_forces") + return self.forces + + +def FSM(reactant=None, product=None, theory=None, method="L-BFGS-B", optcoords="ric", + nnodes_min=10, interp="lst", ninterp=100, stepsize=0.0, interpolate=False, maxiter=1, maxls=3, dmax=0.3, outdir=".", verbose=True): + fsm = FreezingString_class(reactant=reactant, product=product, theory=theory, method=method, optcoords=optcoords, + nnodes_min=nnodes_min, interp=interp, ninterp=ninterp, stepsize=stepsize, + interpolate=interpolate, maxiter=maxiter, maxls=maxls, dmax=dmax, outdir=outdir, verbose=verbose) + fsm.run() + + +class FreezingString_class: + + def __init__(self,reactant=None, product=None, theory=None, method="L-BFGS-B", optcoords="cart", + nnodes_min=10, interp="lst", ninterp=100, stepsize=0.0, interpolate=False, + maxiter=1, maxls=3, dmax=0.3, outdir=".", verbose=True): + print_line_with_mainheader("Freezing String calculation initialized") + try: + import ase + from mlfsm.geom import project_trans_rot + except: + print("Error import ase or mlfsm. Check if installed") + ashexit() + + # ASH Fragments (or ASE atoms) + #self.reactant=reactant + #self.product + self.elems= reactant.elems + self.reactant_ase = ase.atoms.Atoms(reactant.elems,positions=reactant.coords) + self.product_ase = ase.atoms.Atoms(product.elems,positions=product.coords) + + # Align product to reactant structure + _, aligned_product = project_trans_rot(self.reactant_ase.get_positions(), self.product_ase.get_positions()) + self.product_ase.set_positions(aligned_product.reshape(-1, 3)) + + + self.reactant_ase.info.update({"charge": reactant.charge, "spin": reactant.mult}) + self.product_ase.info.update({"charge": product.charge, "spin": product.mult}) + + # Theory as ASE + self.calc = ASH_ASE_calculator(fragment=reactant, theory=theory) + self.method = method + self.nnodes_min = nnodes_min + self.interp = interp + self.ninterp = ninterp + self.stepsize = stepsize + self.interpolate = interpolate + self.maxiter = maxiter + self.maxls = maxls + self.dmax = dmax + self.optcoords = optcoords + self.outdir = outdir + self.verbose=verbose + + def run(self): + print_line_with_mainheader("Freezing String run") + try: + from mlfsm.cos import FreezingString + from mlfsm.opt import CartesianOptimizer, InternalsOptimizer + + except: + print("Error import mlfsm. Check if installed") + ashexit() + + + # Initialize FSM string + #, stepsize=self.stepsize + print("Creating Freezing String object") + string = FreezingString(self.reactant_ase, self.product_ase, nnodes_min=self.nnodes_min, + interp_method=self.interp, ninterp=self.ninterp) + + if self.interpolate: + print("Interpolating...") + string.interpolate(self.outdir) + return + #import mlfsm + #optimizer: mlfsm.Optimizer + # Choose optimizer + if self.optcoords == "cart": + print("Coordinates: Cartesian") + optimizer = CartesianOptimizer(self.calc, self.method, self.maxiter, self.maxls, self.dmax) + elif self.optcoords == "ric": + print("Coordinates: Internal") + optimizer = InternalsOptimizer(self.calc, self.method, self.maxiter, self.maxls, self.dmax) + else: + raise ValueError("Check optimizer coordinates") + print("Starting FSM optimization") + + # Run FSM + while string.growing: + string.grow() + string.optimize(optimizer) + string.write(self.outdir) + + print(f"Gradient calls: {string.ngrad}") + + # Grab paths and energies + all_atoms = string.r_string + string.p_string[::-1] + all_tot_energies = np.array(string.r_energy + string.p_energy[::-1]) + all_rel_energies = all_tot_energies - min(all_tot_energies) + ts_idx = all_rel_energies.argmax() + print("ts_idx:", ts_idx) + print("TS atom positions:", all_atoms[ts_idx].get_positions()) + print(("TS energy (eV):", all_tot_energies[ts_idx])) + ts_atoms = all_atoms[ts_idx] + + SP = Fragment(elems=self.elems, coords=ts_atoms.get_positions(), + charge=self.reactant_ase.info["charge"], + mult=self.reactant_ase.info["spin"]) + SP.write_xyzfile(xyzfilename=f"TS_guess.xyz") + SP.print_coords() + SP.set_energy(all_tot_energies[ts_idx]/hartoeV) + print(f"TS guess energy : {SP.energy} Eh") + exit() + + + print("Attempting to plot FSM path") + path = [structure.get_positions() for structure in all_atoms] + from mlfsm.geom import calculate_arc_length + s = calculate_arc_length(np.array(path)) + import matplotlib.pyplot as plt + fig, ax = plt.subplots() + ax.plot(s, all_rel_energies, label="FSM Path") + ax.scatter(s[ts_idx], all_rel_energies[ts_idx], color="red", label="TS Guess") + ax.scatter(s[0], all_rel_energies[0], color="black", label="Reactant/Product") + ax.scatter(s[-1], all_rel_energies[-1], color="black") + ax.set_xlabel("Arclength (Å)") + ax.set_ylabel("Energy (eV)") + _ = ax.legend() + + # Save figure + fig.savefig(f"{self.outdir}/FSM_path.png", dpi=300) + + #except: + # print("Error importing matplotlib. Check if installed") \ No newline at end of file diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index c1c53f8f7..4646b07f7 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -8,9 +8,12 @@ from ash.modules.module_theory import MicroIterativeclass #from ash.modules.module_oniom import ONIOMTheory from ash.interfaces.interface_OpenMM import OpenMMTheory -from ash.modules.module_coords import print_coords_for_atoms,print_internal_coordinate_table,write_XYZ_for_atoms,write_xyzfile,write_coords_all -from ash.functions.functions_general import ashexit, blankline,BC,print_time_rel,print_line_with_mainheader,print_line_with_subheader1,print_if_level +from ash.modules.module_coords import print_coords_for_atoms,print_internal_coordinate_table_new,write_XYZ_for_atoms,write_xyzfile,write_coords_all from ash.modules.module_coords import check_charge_mult, fullindex_to_actindex +from ash.modules.module_coords_PBC import cell_volume, cell_vectors_to_params, write_CIF_file, write_XSF_file, write_POSCAR_file, \ + align_to_standard_orientation +from ash.functions.functions_general import ashexit, blankline,BC,print_time_rel,print_line_with_mainheader,print_line_with_subheader1,print_if_level, pygrep2 + from ash.modules.module_freq import write_hessian,calc_hessian_xtb, approximate_full_Hessian_from_smaller, read_hessian from ash.modules.module_results import ASH_Results from ash.modules.module_theory import NumGradclass @@ -18,15 +21,14 @@ ################################################## # NEW Interface to geomeTRIC Optimization Library ################################################## -#Attempt to write a simpler more modular interface #Wrapper function around GeomeTRICOptimizerClass -#NOTE: theory and fragment given to Optimizer function but not part of Class initialization. Only passed to run method def geomeTRICOptimizer(theory=None, fragment=None, charge=None, mult=None, coordsystem='tric', force_coordsystem=False, frozenatoms=None, constraints=None, constraintsinputfile=None, irc=False, rigid=False, enforce_constraints=None, constrainvalue=False, maxiter=250, ActiveRegion=False, actatoms=None, NumGrad=False, convergence_setting=None, conv_criteria=None, print_atoms_list=None, TSOpt=False, hessian=None, partial_hessian_atoms=None, - modelhessian=None, subfrctor=1, MM_PDB_traj_write=False, printlevel=2, result_write_to_disk=True): + modelhessian=None, subfrctor=1, MM_PDB_traj_write=False, printlevel=2, result_write_to_disk=True, + force_noPBC=False, PBC_format_option='CIF'): """ Wrapper function around GeomeTRICOptimizerClass """ @@ -37,13 +39,14 @@ def geomeTRICOptimizer(theory=None, fragment=None, charge=None, mult=None, coord print("geomeTRICOptimizer requires theory and fragment objects provided. Exiting.") ashexit() #NOTE: Class does not take fragment and theory - optimizer=GeomeTRICOptimizerClass(charge=charge, mult=mult, coordsystem=coordsystem, frozenatoms=frozenatoms, + optimizer=GeomeTRICOptimizerClass(theory=theory, charge=charge, mult=mult, coordsystem=coordsystem, frozenatoms=frozenatoms, maxiter=maxiter, ActiveRegion=ActiveRegion, actatoms=actatoms, TSOpt=TSOpt, hessian=hessian, partial_hessian_atoms=partial_hessian_atoms,modelhessian=modelhessian, constraintsinputfile=constraintsinputfile,irc=irc,rigid=rigid,enforce_constraints=enforce_constraints, convergence_setting=convergence_setting, conv_criteria=conv_criteria, print_atoms_list=print_atoms_list, subfrctor=subfrctor, MM_PDB_traj_write=MM_PDB_traj_write, - printlevel=printlevel, force_coordsystem=force_coordsystem, result_write_to_disk=result_write_to_disk) + printlevel=printlevel, force_coordsystem=force_coordsystem, result_write_to_disk=result_write_to_disk, + force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) # If NumGrad then we wrap theory object into NumGrad class object if NumGrad: @@ -66,12 +69,13 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', convergence_setting=None, conv_criteria=None, TSOpt=False, hessian=None, constraintsinputfile=None, irc=False,rigid=False,enforce_constraints=None, print_atoms_list=None, partial_hessian_atoms=None, modelhessian=None, - subfrctor=1, MM_PDB_traj_write=False, printlevel=2, force_coordsystem=False, result_write_to_disk=True): - + subfrctor=1, MM_PDB_traj_write=False, printlevel=2, force_coordsystem=False, result_write_to_disk=True, + force_noPBC=False, PBC_format_option='CIF'): + import time + self.time_init=time.time() self.printlevel=printlevel print_line_with_mainheader("geomeTRICOptimizer initialization") print_if_level("Creating optimizer object", self.printlevel,2) - ############################### # Going through user options ############################### @@ -105,8 +109,8 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', self.TSOpt=TSOpt self.subfrctor=subfrctor - #IRC - self.irc=irc + # IRC + self.irc = irc # Rigid opt self.rigid=rigid # Enforce constraints option @@ -130,6 +134,24 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', #Setup convergence criteria (sets self.conv_criteria) self.convergence_criteria(convergence_setting,conv_criteria) + # PBC + if getattr(theory, "periodic", False): + print("Detected periodicity in Theory object") + print("Activating periodic routines ") + self.PBC=True + self.PBC_format_option=PBC_format_option + print("Switching coordsystem to hdlc") + self.coordsystem="hdlc" + print("Final PBC coordinate file written in format:", self.PBC_format_option) + + if force_noPBC is True: + print("Warning: force_noPBC set to True. Turning off PBC") + self.PBC=False + else: + print("Theory is not periodic") + self.PBC=False + + ###################### #SOME PRINTING of settings ###################### @@ -224,7 +246,7 @@ def define_constraints(self,constraints): ######################################## # For QM/MM we need to convert full-system atoms into active region atoms #constraints={'bond':[[8854,37089]]} - if self.ActiveRegion == True: + if self.ActiveRegion: if constraints != None: print("Constraints set. Active region true") print("User-defined constraints (fullsystem-indices):", constraints) @@ -243,17 +265,62 @@ def define_constraints(self,constraints): except: angleconstraints = None try: - dihedralconstraints = constraints['dihedral'] + if 'dihedral' in constraints: + dihedralconstraints = constraints['dihedral'] + elif 'torsion' in constraints: + dihedralconstraints = constraints['torsion'] + else: + dihedralconstraints=None except: dihedralconstraints = None + try: + xyzconstraints = constraints['xyz'] + except: + xyzconstraints = None + try: + xconstraints = constraints['x'] + except: + xconstraints = None + try: + yconstraints = constraints['y'] + except: + yconstraints = None + try: + zconstraints = constraints['z'] + except: + zconstraints = None + try: + xyconstraints = constraints['xy'] + except: + xyconstraints = None + try: + xzconstraints = constraints['xz'] + except: + xzconstraints = None + try: + yzconstraints = constraints['yz'] + except: + yzconstraints = None else: bondconstraints=None angleconstraints=None dihedralconstraints=None + xyzconstraints=None + xconstraints=None + yconstraints=None + zconstraints=None + xyconstraints=None + xzconstraints=None + yzconstraints=None + + return bondconstraints, angleconstraints, dihedralconstraints,xyzconstraints, \ + xconstraints, yconstraints, zconstraints, xyconstraints, xzconstraints, yzconstraints - return bondconstraints, angleconstraints, dihedralconstraints - def write_constraintsfile(self,frozenatoms,bondconstraints,constrainvalue,angleconstraints,dihedralconstraints): + + def write_constraintsfile(self,frozenatoms,bondconstraints,constrainvalue,angleconstraints, + dihedralconstraints,xconstraints,yconstraints, + zconstraints,xyconstraints,xzconstraints,yzconstraints): if self.printlevel >= 1: print("Inside write_constraintsfile") @@ -312,7 +379,6 @@ def write_constraintsfile(self,frozenatoms,bondconstraints,constrainvalue,anglec else: confile.write(f'angle {angleentry[0]+1} {angleentry[1]+1} {angleentry[2]+1}\n') if dihedralconstraints is not None: - print("dihedralconstraints:", dihedralconstraints) self.constraintsfile='constraints.txt' with open("constraints.txt", 'a') as confile: if constrainvalue is True: @@ -327,6 +393,48 @@ def write_constraintsfile(self,frozenatoms,bondconstraints,constrainvalue,anglec confile.write(f'dihedral {dihedralentry[0]+1} {dihedralentry[1]+1} {dihedralentry[2]+1} {dihedralentry[3]+1} {dihedralentry[4]}\n') else: confile.write(f'dihedral {dihedralentry[0]+1} {dihedralentry[1]+1} {dihedralentry[2]+1} {dihedralentry[3]+1}\n') + if xconstraints is not None: + self.constraintsfile='constraints.txt' + with open("constraints.txt", 'a') as confile: + confile.write('$freeze\n') + for xentry in xconstraints: + #Changing from zero-indexing (ASH) to 1-indexing (geomeTRIC) + confile.write(f'x {xentry+1}\n') + if yconstraints is not None: + self.constraintsfile='constraints.txt' + with open("constraints.txt", 'a') as confile: + confile.write('$freeze\n') + for yentry in yconstraints: + #Changing from zero-indexing (ASH) to 1-indexing (geomeTRIC) + confile.write(f'y {yentry+1}\n') + if zconstraints is not None: + self.constraintsfile='constraints.txt' + with open("constraints.txt", 'a') as confile: + confile.write('$freeze\n') + for zentry in zconstraints: + #Changing from zero-indexing (ASH) to 1-indexing (geomeTRIC) + confile.write(f'z {zentry+1}\n') + if xyconstraints is not None: + self.constraintsfile='constraints.txt' + with open("constraints.txt", 'a') as confile: + confile.write('$freeze\n') + for xyentry in xyconstraints: + #Changing from zero-indexing (ASH) to 1-indexing (geomeTRIC) + confile.write(f'xy {xyentry+1}\n') + if xzconstraints is not None: + self.constraintsfile='constraints.txt' + with open("constraints.txt", 'a') as confile: + confile.write('$freeze\n') + for xzentry in xzconstraints: + #Changing from zero-indexing (ASH) to 1-indexing (geomeTRIC) + confile.write(f'xz {xzentry+1}\n') + if yzconstraints is not None: + self.constraintsfile='constraints.txt' + with open("constraints.txt", 'a') as confile: + confile.write('$freeze\n') + for yzentry in yzconstraints: + #Changing from zero-indexing (ASH) to 1-indexing (geomeTRIC) + confile.write(f'yz {yzentry+1}\n') def cleanup(self): #Clean-up before we begin @@ -447,19 +555,18 @@ def hessian_option(self,fragment,actatoms,theory,charge,mult,modelhessian): print("Unknown Hessian option") ashexit() - #If using Active region then we write only those coordinates to disk (initialxyzfiletric) def setup_active_region_geometry(self,fragment): if len(self.actatoms) == 0: print("Error: List of active atoms (actatoms) provided is empty. This is not allowed.") ashexit() - #Sorting list, otherwise trouble + # Sorting list, otherwise trouble self.actatoms.sort() print("Active Region option Active. Passing only active-region coordinates to geomeTRIC.") print("Active atoms list:", self.actatoms) print("Number of active atoms:", len(self.actatoms)) - #Check that the actatoms list does not contain atom indices higher than the number of atoms + # Check that the actatoms list does not contain atom indices higher than the number of atoms largest_atom_index=max(self.actatoms) if largest_atom_index >= fragment.numatoms: print(BC.FAIL,f"Found active-atom index ({largest_atom_index}) that is larger or equal (>=) than the number of atoms of system ({fragment.numatoms})!",BC.END) @@ -471,7 +578,7 @@ def setup_active_region_geometry(self,fragment): #Writing act-region coords (only) of ASH fragment to disk as XYZ file and reading into geomeTRIC write_xyzfile(actelems, actcoords, 'initialxyzfiletric') - #Running geomeTRIC object + # Running geomeTRIC object def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=None, constrainvalue=False): if self.printlevel > 1: print() @@ -486,11 +593,14 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No fragment.charge=charge fragment.mult=mult + #Printlevel of fragment + fragment.printlevel=self.printlevel + ################# # CONSTRAINTS ################# #If constraints not directly provided to run method, then we look at self.constraints and then fragment.constraints - if constraints == None: + if constraints is None: if self.printlevel >= 1: print("No constraints provided to run method.") print("Testing if constraints present in optimizer object") @@ -515,12 +625,13 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No if self.printlevel >= 1: print("\nConstraints: ", constraints) print("constrainvalue: ", constrainvalue) - - #Getting specific constraints and writing to file - bondconstraints, angleconstraints, dihedralconstraints = self.define_constraints(constraints) + # Getting specific constraints and writing to file + bondconstraints, angleconstraints, dihedralconstraints,xyzconstraints, xconstraints, yconstraints, zconstraints, xyconstraints, xzconstraints, yzconstraints = self.define_constraints(constraints) + if xyzconstraints is not None: + print("xyzconstraints found. Adding to frozenatoms") + self.frozenatoms = self.frozenatoms + xyzconstraints self.write_constraintsfile(self.frozenatoms,bondconstraints,constrainvalue,angleconstraints, - dihedralconstraints) - + dihedralconstraints,xconstraints,yconstraints,zconstraints,xyconstraints,xzconstraints,yzconstraints) if self.constraintsinputfile is not None: print("constraintsinputfile provided:", self.constraintsinputfile) if os.path.isfile(self.constraintsinputfile) is False: @@ -529,7 +640,6 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No self.constraintsfile=self.constraintsinputfile ################# - #Check if atom and do Singlepoint instead if so if fragment.numatoms == 1: print("System contains 1 atom, optimization makes no sense.") @@ -547,7 +657,6 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No #Determine geometry-printout in each iteration. Requires knowledge on theory and fragment self.print_atoms_output_setting(theory,fragment) - #Hessian option self.hessian_option(fragment,self.actatoms,theory,charge,mult,self.modelhessian) @@ -563,21 +672,36 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No print(BC.WARNING,"Either install geomeTRIC using pip:\n conda install geometric\n or \n pip install geometric\n or manually from Github (https://github.com/leeping/geomeTRIC)", BC.END) print("Actual error message:", e) ashexit(code=9) - - #Read geometry from XYZ-file into geomeTRIC Molecule object + # bondorders + # generally unused, except PBC + self.bothre=0.0 + + # Read geometry from XYZ-file into geomeTRIC Molecule object + if self.PBC is True: + print("For PBC we activate constraints") + #self.constraintsfile="constraints.txt" + self.bothre=0.5 + # mol_geometric_frag=geometric.molecule.Molecule("initialxyzfiletric.xyz") + # + #else: + #print("1self.constraintsfile:",self.constraintsfile) mol_geometric_frag=geometric.molecule.Molecule("initialxyzfiletric.xyz") - #Defining ASHengineclass engine object containing geometry and theory. ActiveRegion boolean passed. - #Also now passing list of atoms to print in each step. + # Defining ASHengineclass engine object containing geometry and theory. ActiveRegion boolean passed. + # Also now passing list of atoms to print in each step. ashengine = ASHengineclass(mol_geometric_frag,theory, ActiveRegion=self.ActiveRegion, actatoms=self.actatoms, print_atoms_list=self.print_atoms_list, MM_PDB_traj_write=self.MM_PDB_traj_write, charge=charge, mult=mult, conv_criteria=self.conv_criteria, fragment=fragment, printlevel=self.printlevel, - maxiter=self.maxiter) - - #Defining args object, containing engine object + maxiter=self.maxiter, PBC=self.PBC) + #print("2self.constraintsfile:",self.constraintsfile) + # Defining args object, containing engine object + #print("3self.constraintsfile:",self.constraintsfile) + #print("self.enforce_constraints:", self.enforce_constraints) + #exit() + print("self.constraintsfile:",self.constraintsfile) final_geometric_args=geomeTRICArgsObject(ashengine,self.constraintsfile,coordsys=self.coordsystem, maxiter=self.maxiter, conv_criteria=self.conv_criteria, transition=self.TSOpt, hessian=self.hessian, subfrctor=self.subfrctor, - verbose=0, irc=self.irc,rigid=self.rigid,enforce_constraints=self.enforce_constraints) + verbose=0, irc=self.irc,rigid=self.rigid,enforce_constraints=self.enforce_constraints, bothre=self.bothre) if self.printlevel >= 1: print("Convergence criteria:", self.conv_criteria) @@ -592,13 +716,14 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No ################################### # RUNNING ################################### + print_time_rel(self.time_init, modulename='Time spent before run_optimizer', moduleindex=2) geometric.optimize.run_optimizer(**vars(final_geometric_args)) time.sleep(1) ################################### if self.printlevel >= 1: blankline() - print(f"geomeTRIC Geometry optimization converged in {ashengine.iteration_count} steps!") + print(f"geomeTRIC Geometry optimization converged in {ashengine.iteration_count+1} steps!") blankline() # QM/MM: Doing final energy evaluation if Truncated PC option was on @@ -625,6 +750,29 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') fragment.set_energy(finalenergy) + if self.ActiveRegion is not True: + print("Final geometry") + fragment.print_coords() + print() + + # PBC + if self.PBC: + print("PBC True. Writing final optimized geometry in PBC-format") + print("PBC_format_option:", self.PBC_format_option) + if self.PBC_format_option.upper() =="CIF": + convert_to_pbcfile=write_CIF_file + file_ext='cif' + elif self.PBC_format_option.upper() =="XSF": + convert_to_pbcfile=write_XSF_file + file_ext='xsf' + elif self.PBC_format_option.upper() == "POSCAR": + convert_to_pbcfile=write_POSCAR_file + file_ext='POSCAR' + pbcfile = convert_to_pbcfile(fragment.coords,fragment.elems,cellvectors=theory.periodic_cell_vectors, + filename=f"Fragment-optimized.{file_ext}") + print(f"Final cell vectors (Å):{theory.periodic_cell_vectors}") + print(f"Final cell parameters: ({cell_vectors_to_params(theory.periodic_cell_vectors)})") + print(f"Final cell volume (Å):{cell_volume(theory.periodic_cell_vectors)}") #Active region XYZ-file if self.ActiveRegion is True: write_XYZ_for_atoms(fragment.coords, fragment.elems, self.actatoms, "Fragment-optimized_Active") @@ -634,7 +782,8 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No #Printing internal coordinate table if self.printlevel >= 2: - print_internal_coordinate_table(fragment,actatoms=self.print_atoms_list) + if len(self.print_atoms_list) < 50: + print_internal_coordinate_table_new(fragment,actatoms=self.print_atoms_list) blankline() #Now returning final Results object @@ -646,7 +795,8 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No class geomeTRICArgsObject: - def __init__(self,eng,constraintsfile, coordsys, maxiter, conv_criteria, transition,hessian,subfrctor,verbose,irc,rigid,enforce_constraints): + def __init__(self,eng,constraintsfile, coordsys, maxiter, conv_criteria, transition,hessian,subfrctor,verbose,irc,rigid,enforce_constraints, + bothre): self.coordsys=coordsys self.maxiter=maxiter self.transition=transition @@ -655,6 +805,7 @@ def __init__(self,eng,constraintsfile, coordsys, maxiter, conv_criteria, transit self.verbose=verbose self.irc=irc self.rigid=rigid + self.bothre=bothre if self.rigid is True: print("Rigid optimization enabled.") print("Activating revised constraint algorithm") @@ -685,7 +836,7 @@ def __init__(self,eng,constraintsfile, coordsys, maxiter, conv_criteria, transit #Defining ASH engine class used to communicate with geomeTRIC class ASHengineclass: def __init__(self,geometric_molf, theory, ActiveRegion=False, actatoms=None,print_atoms_list=None, charge=None, mult=None, conv_criteria=None, fragment=None, - MM_PDB_traj_write=False, printlevel=2, maxiter=None): + MM_PDB_traj_write=False, printlevel=2, maxiter=None, PBC=False): #MM_PDB_traj_write on/off. Can be pretty big files self.MM_PDB_traj_write=MM_PDB_traj_write #Defining M attribute of engine object as geomeTRIC Molecule object @@ -695,8 +846,11 @@ def __init__(self,geometric_molf, theory, ActiveRegion=False, actatoms=None,prin self.ActiveRegion=ActiveRegion #Defining current_coords for full system (not only act region) self.full_current_coords=[] - #Manual iteration count + #E+G count + self.EG_count=0 + # Proper iteration count self.iteration_count=0 + #Maxiter self.maxiter=maxiter #Defining initial E @@ -711,6 +865,28 @@ def __init__(self,geometric_molf, theory, ActiveRegion=False, actatoms=None,prin self.fragment=fragment self.printlevel=printlevel + # Setting BO matrix to be None + self.BOmatrix=None + # PBC + + self.PBC=PBC + if self.PBC is True: + # Real elements + self.elems_phys=self.fragment.elems + # Align to standard orientation + aligned_atom_coords, aligned_vectors = align_to_standard_orientation(self.fragment.coords, theory.periodic_cell_vectors) + self.fragment.coords=aligned_atom_coords + self.theory.update_cell(aligned_vectors) + + # Reference + self.H_ref = aligned_vectors.copy() + self.H_ref_inv = np.linalg.inv(self.H_ref) + + + # Modifying self.M to have aligned coords and 4 dummyatoms + self.M.xyzs = [np.concatenate((aligned_atom_coords,[[0.0,0.0,0.0]],aligned_vectors),axis=0)] + self.M.elem = self.M.elem + ['F','F','F','F'] + def load_guess_files(self,dirname): if self.printlevel >= 1: print("geometric called load_guess_files option for ASHengineclass.") @@ -725,8 +901,42 @@ def detect_dft(self): print("geometric called detect_dft option option for ASHengineclass.") return True #geometric checks if calc_bondorder method is implemented for the ASHengine. Disabled until we implement this - #def calc_bondorder(self,coords,dirname): - # print("geometric called calc_bondorder option option for ASHengineclass.") + def calc_bondorder(self,coords,dirname): + print("geometric called calc_bondorder option option for ASHengineclass.") + if self.BOmatrix is not None: + return self.BOmatrix + else: + print("no BOmatrix found") + if self.PBC: + print("PBC and BOmatrix handling") + # Bond orders + self.BOmatrix = np.zeros((len(self.M.elem), len(self.M.elem)), dtype=int) + # bond orders based on fragment connectivity + self.fragment.calc_connectivity() + from ash.modules.module_coords import get_connected_atoms_dict + conndict = get_connected_atoms_dict(self.fragment.coords, self.fragment.elems, 1.0, 0.1) + print("conndict:", conndict) + for i,conn in conndict.items(): + for c in conn: + self.BOmatrix[i,c] = self.BOmatrix[c,i] = 1.0 + + # Connecting origin and lattice atoms + n_orig=len(self.elems_phys) + self.BOmatrix[n_orig,n_orig+1] = self.BOmatrix[n_orig+1,n_orig] = 1 + self.BOmatrix[n_orig,n_orig+2] = self.BOmatrix[n_orig+2,n_orig] = 1 + self.BOmatrix[n_orig,n_orig+3] = self.BOmatrix[n_orig+3,n_orig] = 1 + + #print("BOmatrix:", self.BOmatrix) + + #self.M.qm_bondorder = [self.BOmatrix] + #self.M.build_topology(force_bonds=False, bond_order=1.0) + #print("2elf.M.xyzs:", self.M.__dict__) + return self.BOmatrix + else: + print("No BO option implemented") + return None + + return None # print("This option is currently unsupported in ASH. Continuing.") #TODO: geometric will regularly do ClearCalcs in an optimization def clearCalcs(self): @@ -772,7 +982,6 @@ def write_pdbtrajectory(self): #Defining calculator. #Read_data and copydir not used (dummy variables) def calc(self,coords,tmp, read_data=None, copydir=None): - print("") if self.iteration_count == self.maxiter: print("Maxiter reached. ASH is stopping.") @@ -787,15 +996,25 @@ def calc(self,coords,tmp, read_data=None, copydir=None): if isinstance(self.theory,MicroIterativeclass): print("Micro-iterative option active") self.theory.engine=self - print() #Updating coords in object #Need to combine with rest of full-system coords timeA=time.time() self.M.xyzs[0] = coords.reshape(-1, 3) * ash.constants.bohr2ang - #print_time_rel(timeA, modulename='geometric ASHcalc.calc reshape', moduleindex=2) - timeA=time.time() currcoords=self.M.xyzs[0] + + # Call method to use + if self.ActiveRegion is True: + egdict = self.actregion_calc(currcoords) + elif self.PBC is True: + print("Doing PBC opt-step") + egdict =self.PBC_calc(currcoords) + else: + egdict = self.regular_calc(currcoords) + + return egdict + + def actregion_calc(self,currcoords): #Special act-region (for QM/MM) since GeomeTRIC does not handle huge system and constraints if self.ActiveRegion is True: #Defining full_coords as original coords temporarily @@ -868,25 +1087,118 @@ def calc(self,coords,tmp, read_data=None, copydir=None): #print_time_rel(timeA, modulename='geometric ASHcalc.calc writetraj full', moduleindex=2) timeA=time.time() - self.iteration_count += 1 + + # Read last line of geometric_OPTtraj.log to get step + step_lines = pygrep2("Step ", "geometric_OPTtraj.log", print_output=False, errors=None) + if len(step_lines) > 0: + iteration=int(step_lines[-1].split("Step", 1)[1].split(":", 1)[0].strip()) + self.iteration_count=int(iteration) + self.EG_count += 1 + return {'energy': E, 'gradient': Grad_act.flatten()} - else: - self.full_current_coords=currcoords - self.fragment.replace_coords(self.fragment.elems, self.full_current_coords, conn=False) - #PRINTING ACTIVE GEOMETRY IN EACH GEOMETRIC ITERATION - self.fragment.write_xyzfile(xyzfilename="Fragment-currentgeo.xyz") - #print("Current geometry (Å) in step {}".format(self.iteration_count)) - if self.printlevel >= 1: - print(f"Current geometry (Å) in step {self.iteration_count} (print_atoms_list region)") - print("---------------------------------------------------") - print_coords_for_atoms(currcoords, self.fragment.elems, self.print_atoms_list) - print("") - print("Note: printed only print_atoms_list (this is not necessarily all atoms) ") - E,Grad=self.theory.run(current_coords=currcoords, elems=self.M.elem, charge=self.charge, mult=self.mult, Grad=True) - #label='Iter'+str(self.iteration_count) - self.iteration_count += 1 - self.energy = E - return {'energy': E, 'gradient': Grad.flatten()} + + # Basic calc: no actregion, no PBC + def regular_calc(self,currcoords): + self.full_current_coords=currcoords + self.fragment.replace_coords(self.fragment.elems, self.full_current_coords, conn=False) + #PRINTING ACTIVE GEOMETRY IN EACH GEOMETRIC ITERATION + self.fragment.write_xyzfile(xyzfilename="Fragment-currentgeo.xyz") + if self.printlevel >= 1: + print(f"Current geometry (Å) in step {self.iteration_count} (print_atoms_list region)") + print("---------------------------------------------------") + print_coords_for_atoms(currcoords, self.fragment.elems, self.print_atoms_list) + print("") + print("Note: printed only print_atoms_list (this is not necessarily all atoms) ") + E,Grad=self.theory.run(current_coords=currcoords, elems=self.M.elem, charge=self.charge, mult=self.mult, Grad=True) + # Read last line of geometric_OPTtraj.log to get step + step_lines = pygrep2("Step ", "geometric_OPTtraj.log", print_output=False, errors=None) + if len(step_lines) > 0: + iteration=int(step_lines[-1].split("Step", 1)[1].split(":", 1)[0].strip()) + self.iteration_count=int(iteration) + self.EG_count += 1 + self.energy = E + return {'energy': E, 'gradient': Grad.flatten()} + + def PBC_calc(self,currcoords): + # Split coords into atomic and lattic + R_geo = currcoords[:-4] + origin = currcoords[-4] + H_geo = currcoords[-3:] - origin + + # --- Enforce Standard Orientation in each step --- + print("Enforcing orientation") + # 1. Ensure the Origin dummy atom stays at exactly 0,0,0 + origin[:] = 0.0 + # 2. Force H_geo to be strictly upper-triangular + # Vector A: Only Ax is allowed (Ay and Az are zero) + H_geo[0, 1] = 0.0 # ay = 0 + H_geo[0, 2] = 0.0 # az = 0 + # Vector B: Only Bx and By are allowed (Bz is zero) + H_geo[1, 2] = 0.0 # bz = 0 + # ----------------------------------------------------- + s = np.dot(R_geo - origin, self.H_ref_inv) + R_phys = np.dot(s, H_geo) + origin + #Update cell parameters in theory + self.theory.update_cell(H_geo) + + self.full_current_coords=R_phys + self.fragment.replace_coords(self.fragment.elems, self.full_current_coords, conn=False) + #PRINTING ACTIVE GEOMETRY IN EACH GEOMETRIC ITERATION + self.fragment.write_xyzfile(xyzfilename="Fragment-currentgeo.xyz") + if self.printlevel >= 1: + print(f"Current geometry (Å) in step {self.iteration_count} (print_atoms_list region)") + print("---------------------------------------------------") + print_coords_for_atoms(R_phys, self.elems_phys, self.print_atoms_list) + print("") + print("Note: printed only print_atoms_list (this is not necessarily all atoms) ") + print(f"Current cell vectors (Å):{H_geo}") + print(f"Current cell volume (Å):{cell_volume(H_geo)}") + + # E + G from theory + E,grad_phys=self.theory.run(current_coords=R_phys, elems=self.elems_phys, + charge=self.charge, mult=self.mult, Grad=True) + self.EG_count += 1 + self.energy = E + + # Read last line of geometric_OPTtraj.log to get step + step_lines = pygrep2("Step ", "geometric_OPTtraj.log", print_output=False, errors=None) + if len(step_lines) > 0: + iteration=int(step_lines[-1].split("Step", 1)[1].split(":", 1)[0].strip()) + self.iteration_count=int(iteration) + + # Transformation + # M is the transformation matrix: R_phys = R_geo @ M + M = np.dot(self.H_ref_inv, H_geo) + grad_Rgeo = np.dot(grad_phys, M.T) + + # Convection, implicit lattice gradient + #grad_convection = np.dot(s.T, grad_phys) + + # Lattice gradient and masking + #Total lattice gradient: current theory cell-gradient + convection + #grad_latt_total = self.theory.cell_gradient + grad_latt_total = self.theory.get_cell_gradient() + # Standard orientation mask: + # This zeros out: a_y, a_z, and b_z + mask = np.array([ + [1, 0, 0], # dE/dax (ay, az frozen) + [1, 1, 0], # dE/dbx, dE/dby (bz frozen) + [1, 1, 1] # dE/dcx, dE/dcy, dE/dcz (all free) + ]) + grad_latt_masked = grad_latt_total * mask + # Making sure origin is zero + grad_origin = np.zeros((1, 3)) + # Final modified gradient to pass to geomeTRIC + mod_gradient = np.concatenate([ + grad_Rgeo, # (N, 3) + grad_origin, # (1, 3) + grad_latt_masked # (3, 3) + ], axis=0) + + return {'energy': E, 'gradient': mod_gradient.flatten()} + + + #Function Convert constraints indices to actatom indices diff --git a/ash/interfaces/interface_knarr.py b/ash/interfaces/interface_knarr.py index ee212bd5e..6996fe06d 100644 --- a/ash/interfaces/interface_knarr.py +++ b/ash/interfaces/interface_knarr.py @@ -440,7 +440,6 @@ def NEB(reactant=None, product=None, theory=None, images=8, CI=True, free_end=Fa new_reactant = reactant new_product = product - if TS_guess_file != None: TS_guess = ash.Fragment(xyzfile=TS_guess_file, charge=charge, mult=mult, printlevel=0) #Writing XYZ-file for TSguess @@ -548,7 +547,8 @@ def NEB(reactant=None, product=None, theory=None, images=8, CI=True, free_end=Fa if ActiveRegion is True: print("Error: Currently, geodesic interpolations are not compatible with ActiveRegion=True. Use IDPP interpolation instead") ashexit() - interpolxyzfile = interpolation_geodesic(reactant=new_reactant, product=new_product, images=images) + # Geodesic interpolation. If TS_guess has been defined then R,TSguess and P structures are used, otherwise just R and P + interpolxyzfile = interpolation_geodesic(reactant=new_reactant, product=new_product, tsguess=TS_guess, images=images) os.rename(interpolxyzfile, "initial_guess_path.xyz") print("\nReading initial path") @@ -564,9 +564,8 @@ def NEB(reactant=None, product=None, theory=None, images=8, CI=True, free_end=Fa path = InitializePathObject(nim, react) path.SetCoords(rp) - print("Starting NEB") - #Setting printlevel of theory during E+Grad steps 1=very-little, 2=more, 3=lots, 4=verymuch + # Setting printlevel of theory during E+Grad steps 1=very-little, 2=more, 3=lots, 4=verymuch print("NEB printlevel is:", printlevel) theory.printlevel=printlevel print("Theory print level will now be set to:", theory.printlevel) @@ -591,7 +590,6 @@ def NEB(reactant=None, product=None, theory=None, images=8, CI=True, free_end=Fa #Now finding highest energy image Saddlepoint_fragment = prepare_saddlepoint(path,neb_settings,reactant,calculator,ActiveRegion,actatoms,charge,mult, numatoms, "IDPP", write_tangent=False) print("WARNING: This is a highly approximate guess for the saddlepoint, based on the highest energy image from a single-iteration NEB.") - #return Saddlepoint_fragment, calculator.energies_dict #Returning result object result = ASH_Results(label="NEB-Singleiter calc", energy=Saddlepoint_fragment.energy, geometry=Saddlepoint_fragment.coords, @@ -1265,7 +1263,7 @@ def dominant_atoms_in_CI_tangent(tangent,reactant,product,SP,tsmode_tangent_thre #Standalone geodesic-interpolation function -def interpolation_geodesic(reactant=None, product=None, images=None): +def interpolation_geodesic(reactant=None, product=None, tsguess=None, images=None): print("Using geodesic-interpolate path generation") print("See Github repository: https://github.com/virtualzx-nad/geodesic-interpolate") print("""If you use this, make sure to cite: @@ -1314,6 +1312,10 @@ def __init__(self, filename=None,nimages=None, tol=2e-3, save_raw=None, # Creating combined XYZ-file reactant.printlevel=1 reactant.write_xyzfile(xyzfilename="R_P_combined.xyz", writemode='w') + # Add TSguess geometry if present + if tsguess is not None: + print("A TS guess structure was defined and will be used during interpolation") + tsguess.write_xyzfile(xyzfilename="R_P_combined.xyz", writemode='a') product.write_xyzfile(xyzfilename="R_P_combined.xyz", writemode='a') # Read the initial geometries. diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 349993039..56f55c8a1 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -1,26 +1,21 @@ import time import numpy as np import shutil +import os -from ash.modules.module_coords import elemstonuccharges -from ash.functions.functions_general import ashexit, BC,print_time_rel +from ash.modules.module_coords_PBC import cell_params_to_vectors, cell_vectors_to_params +from ash.functions.functions_general import ashexit, BC, print_if_level,print_time_rel from ash.functions.functions_general import print_line_with_mainheader import ash.constants # Simple interface to MACE for both using and training - class MACETheory(): - def __init__(self, config_filename="config.yml", - filename="mace.model", model_file=None, printlevel=2, - label="MACETheory", numcores=1, device="cpu", return_zero_gradient=False): - # Early exits - try: - import mace - except ImportError: - print("Problem importing mace. Make sure you have installed mace-correctly") - print("Most likely you need to do: pip install mace-torch") - print("Also recommended: pip install cuequivariance_torch") - ashexit() + def __init__(self, config_filename="config.yml", + model_name=None, model_name_subtype=None, model_name_head=None, + model_file=None, printlevel=2, mace_load_dispersion=False, mace_dispersion_xc=None, + label="MACETheory", numcores=1, platform="cpu", device=None, return_zero_gradient=False, default_dtype="float64", + energy_weight=None, forces_weight=None, max_num_epochs=None, valid_fraction=None, + periodic=False, periodic_cell_vectors=None, periodic_cell_dimensions=None): self.theorytype = 'QM' self.theorynamelabel = 'MACE' @@ -28,19 +23,77 @@ def __init__(self, config_filename="config.yml", self.analytic_hessian = True self.numcores = numcores self.config_filename=config_filename - self.filename = filename self.printlevel = printlevel + self.properties = {} + + # Parallelization at CPU level + os.environ['OMP_NUM_THREADS'] = str(numcores) + os.environ['MKL_NUM_THREADS'] = str(numcores) + os.environ['OPENBLAS_NUM_THREADS'] = str(numcores) + + print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") + # Early exits + try: + import mace + except ImportError: + print("Problem importing mace. Make sure you have installed mace-correctly") + print("Most likely you need to do: pip install mace-torch") + print("Also recommended: pip install cuequivariance_torch") + ashexit() # Ignore predicted forces and return zero gradient self.return_zero_gradient=return_zero_gradient + # Polarmace (activated later if detected) + self.polarmace=False + + # New interface: activated later if needed + self.new_interface=False + + self.default_dtype=default_dtype + # Model attribute is None until we have loaded a model self.model=None - + # self.model_file=model_file - self.device=device.lower() + self.model_name=model_name #for quickly loading foundational models + self.model_name_subtype=model_name_subtype #subtype of foundational model + self.model_name_head = model_name_head # choose head of multi-head foundational model + self.mace_load_dispersion=mace_load_dispersion # activate dispersion + self.mace_dispersion_xc=mace_dispersion_xc # functional keyword + # Training parameters + self.energy_weight=energy_weight + self.forces_weight=forces_weight + self.max_num_epochs=max_num_epochs + self.valid_fraction=valid_fraction - print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") + # Platform/device + if device is not None: + print("Warning: device keyword is deprecated. Use platform instead") + ashexit() + self.platform=platform.lower() + + # PBC + self.periodic=periodic + self.periodic_cell_vectors=None # initially + self.stress=False + if self.periodic: + print("PBC enabled in MaceTHeory") + self.stress=True + if periodic_cell_vectors is None and periodic_cell_dimensions is None: + print("Error: for periodic calculations, you must specify either periodic_cell_vectors or periodic_cell_dimensions") + ashexit() + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + elif periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions = periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + print("Cell vectors:", self.periodic_cell_vectors) + print("Cell dimensions:", self.periodic_cell_dimensions) self.training_done=False @@ -50,26 +103,56 @@ def cleanup(self): def set_numcores(self,numcores): self.numcores=numcores - def train(self, config_file="config.yml", name="model",model="MACE", device='cpu', + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + def get_cell_gradient(self): + return self.cell_gradient + + def train(self, config_file="config.yml", name="model",model="MACE", platform=None, device=None, valid_fraction=0.1, train_file="train_data_mace.xyz",E0s=None, energy_key='energy_REF', forces_key='forces_REF', - energy_weight=1, forces_weight=100, + energy_weight=1, forces_weight=100, seed=42, max_num_epochs=500, swa=True, batch_size=10, max_L = 0, r_max = 5.0, num_channels=128, results_dir= "MACE_models", checkpoints_dir = "MACE_models", log_dir ="MACE_models", model_dir="MACE_models"): module_init_time=time.time() + if self.energy_weight is not None: + energy_weight=self.energy_weight + if self.forces_weight is not None: + forces_weight=self.forces_weight + if self.max_num_epochs is not None: + max_num_epochs=self.max_num_epochs + if self.valid_fraction is not None: + valid_fraction=self.valid_fraction + self.train_file=train_file self.valid_fraction=valid_fraction + if device is not None: + print("Warning: device keyword is deprecated. Please use platform instead") + ashexit() + + if platform is None: + print("Warning: platform not passed to train. Using object's platform attribute:", self.platform) + platform=self.platform + print("Training activated") print("Training parameters:") print("config_file", config_file) print("name:", model) print("model:", model) - print("device:", device) + print("platform:", platform) print("Validation set fraction (valid_fraction):", valid_fraction) print("train_file:", self.train_file) print("E0s:", E0s) @@ -79,6 +162,7 @@ def train(self, config_file="config.yml", name="model",model="MACE", device='cpu print("forces_weight:", forces_weight) print("max_num_epochs:", max_num_epochs) print("swa:", swa) + print("seed:", seed) print("batch_size:", batch_size) print("max_L:", max_L) print("r_max:", r_max) @@ -94,12 +178,12 @@ def train(self, config_file="config.yml", name="model",model="MACE", device='cpu print("\nWriting MACE config file to disk as as:", self.config_filename) #write_mace_config(config_file=self.config_filename) print() - write_mace_config(config_file=config_file, name=name, model=model, device=device, + write_mace_config(config_file=config_file, name=name, model=model, platform=platform, valid_fraction=valid_fraction, train_file=self.train_file,E0s=E0s, energy_key=energy_key, forces_key=forces_key, energy_weight=energy_weight, forces_weight=forces_weight, max_num_epochs=max_num_epochs, swa=swa, batch_size=batch_size, - max_L = max_L, r_max = r_max, + max_L = max_L, r_max = r_max, seed=seed, num_channels=num_channels, results_dir= results_dir, checkpoints_dir = checkpoints_dir, log_dir=log_dir, model_dir=model_dir) @@ -127,16 +211,16 @@ def train(self, config_file="config.yml", name="model",model="MACE", device='cpu print(f"Moving and renaming file {results_dir}/{name}_stagetwo_compiled.model to : {self.model_file}") shutil.move(f"{results_dir}/{name}_stagetwo_compiled.model", self.model_file) else: - self.model_file=f"{results_dir}/{name}_stagetwo_compiled.model" + self.model_file=f"{os.path.abspath(os.getcwd())}/{results_dir}/{name}_stagetwo_compiled.model" print("model_file attribute is:", self.model_file ) print("MACETheory object can now be used directly.") # If we train with a specific device we would want to use that same device for evaluation/prediction - self.device=device - print("Setting device of object to be ", self.device) + self.platform=platform + print("Setting platform of object to be ", self.platform) - #Load model - self.model_load() + #Load model from file + self.modelfile_load() ############# #STATISTICS @@ -182,28 +266,116 @@ def check_file_exists(self, file): if file_present is False: print(f"File {file} does not exist. Exiting.") ashexit() - # Get statistics for training, sub-training and validation set - #def get_statistics(): - #FIle ./valid_indices_123.txt contains indices of training set that are validation - #Read training file #self.train_file - #Get validation set. Convert data into Eh and Eh/Bohr - #Create dict: valDB - # + def modelfile_load(self): + module_init_time=time.time() - # from mlatom.MLtasks analyzing - # - # self.result_molDB = analyzing(valDB, ref_value='energy', est_value='estimated_y', ref_grad='energy_gradients', - # est_grad='estimated_xyz_derivatives_y', set_name="valDB") + if 'polar' in self.model_file.lower(): + print("Model file name contains 'polar'. Assuming this is a polar MACE model. Loading via mace_polar") + self.polarmace=True + self.new_interface=True + from mace.calculators import mace_polar + self.model = mace_polar( + model=self.model_file, + device=self.platform, + default_dtype=self.default_dtype) # use float32 for faster MD) + elif 'mh' in self.model_file.lower(): + print("Model file name contains 'mh'. Assuming this is a multihead MACE model. Loading via mace_mp.") + self.new_interface=True + from mace.calculators import mace_mp + print("D3 dispersion:", self.mace_load_dispersion) + print("D3 xc:", self.mace_dispersion_xc) + if self.model_name_head is None: + print("Error: no head provided for an MH model. You need to select head by ASH model_name_head keyword.") + ashexit() + #self.model = mace_mp(model=self.model_file, default_dtype=self.default_dtype, device=self.platform) + else: + print("Using head:", self.model_name_head) + self.model = mace_mp(model=self.model_file, default_dtype=self.default_dtype, device=self.platform, head=self.model_name_head, + dispersion=self.mace_load_dispersion, dispersion_xc=self.mace_dispersion_xc) + else: + print("Loading regular MACE via Pytorch") + import torch + # Load model + print(f"Loading model from file {self.model_file}. Platform is: {self.platform}") + self.model = torch.load(f=self.model_file, map_location=torch.device(self.platform)) + self.model = self.model.to(self.platform) # for possible cuda problems + print_time_rel(module_init_time, modulename=f'MACE model-load', moduleindex=2) + + # Load foundational model by name + def modelname_load(self): + print("Inside modelname_load") + print("model_name:", self.model_name) + print("model_name_subtype:", self.model_name_subtype) + print("model_name_head:", self.model_name_head) + print("default_dtype:", self.default_dtype) + print() + if self.model_name.lower() in ['mace-ani-cc','mace_anicc']: + print("MACE-ANI-CC model requested") + from mace.calculators import mace_anicc + self.model = mace_anicc(device=self.platform, default_dtype=self.default_dtype) + # MACE-OMol + elif self.model_name.lower() in ['mace_omol','mace-omol']: + print("MACE-OMOL model requested") + from mace.calculators import mace_omol + print("Loading MACE-OMol model:") + print("Using extra_large model by default (MACE-omol-0-extra-large-1024.model)") + self.model = mace_omol(model="extra_large", device=self.platform, default_dtype=self.default_dtype) + # MACE-OFF + elif self.model_name.lower() in ['mace_off23','mace_off', 'mace-off', 'mace-off23']: + print("MACE-OFF model requested") + from mace.calculators import mace_off + if self.model_name_subtype is None: + print("Loading MACE-OFF model:") + print("Using medium model by default (use model_name_subtype keyword to choose small, medium, large)") + self.model = mace_off(model="medium", device=self.platform, default_dtype=self.default_dtype) + else: + print("MACE-OFF model with modelname_subtype:", self.model_name_subtype) + self.model = mace_off(model=self.model_name_subtype, device=self.platform, default_dtype=self.default_dtype) + # MACE Materials Project (MP) models + elif self.model_name.lower() in ['mace-mp','mace-mh']: + from mace.calculators import mace_mp + if self.model_name_subtype is None: + print("Loading MACE-MP model:") + print("Using medium-mpa-0 model by default (use model_name_subtype keyword to choose between small, medium, large or medium-mpa-0)") + print("D3 dispersion:", self.mace_load_dispersion) + print("D3 xc:", self.mace_dispersion_xc) + self.model = mace_mp(model="medium", device=self.platform, default_dtype=self.default_dtype, + dispersion=self.mace_load_dispersion, dispersion_xc=self.mace_dispersion_xc) + else: + print("MACE-MP model with modelname_subtype:", self.model_name_subtype) + if self.model_name_head is None: + print("No model_name_head chosen. Please choose head via keyword model_name_head.") + ashexit() + else: + self.model = mace_mp(model=self.model_name_subtype, head=self.model_name_head, device=self.platform, default_dtype=self.default_dtype, + dispersion=self.mace_load_dispersion, dispersion_xc=self.mace_dispersion_xc) + # MACE Polar + elif self.model_name.lower() in ['mace-polar','mace_polar', 'mace-polar-1']: + from mace.calculators import mace_polar + if self.model_name_subtype is None: + print("Loading MACE-Polar model:") + #print("Using polar-1-m model by default (use model_name_subtype keyword to choose between polar-1-s, polar-1-m, polar-1-l)") + self.model = mace_polar(model="polar-1-m", + device=self.platform, + default_dtype=self.default_dtype) # use float32 for faster MD + else: + print("MACE-MP model with modelname_subtype:", self.model_name_subtype) + self.model = mace_polar(model=self.model_name_subtype, + device=self.platform, + default_dtype=self.default_dtype) # use float32 for faster MD + else: + print("No valid model_name was found that could be loaded (typo?)") + ashexit() + # Enabling new_interface for these models + self.new_interface = True - def model_load(self): - module_init_time=time.time() - import torch - # Load model - print(f"Loading model from file {self.model_file}. Device is: {self.device}") - self.model = torch.load(f=self.model_file, map_location=torch.device(self.device)) - self.model = self.model.to(self.device) # for possible cuda problems - print_time_rel(module_init_time, modulename=f'MACE model-load', moduleindex=2) + def get_dipole_moment(self): + if "dipole" not in self.properties: + print("Dipole moment not available") + return None + else: + return self.properties["dipole"] def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, elems=None, Grad=False, PC=False, numcores=None, restart=False, label=None, Hessian=False, @@ -220,8 +392,6 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el print(BC.FAIL, f"Error. charge and mult has not been defined for {self.theorynamelabel}Theory.run method", BC.END) ashexit() - print("Job label:", label) - # Early exits # Coords provided to run if current_coords is None: @@ -239,100 +409,128 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el else: qm_elems = elems - # Check availability of model before proceeding further - if self.model_file is None: - print("MACETheory model_file has not been defined.") - print("Either load a valid model or train a model") - ashexit() - # Checking if file exists - self.check_file_exists(self.model_file) - - #Making sure Grad is True + # Making sure Grad is True if doing Hessian if Hessian: Grad=True - # Call model to get energy - from mace.cli.eval_configs import main - from mace import data - from mace.tools import torch_geometric, torch_tools, utils - from mace.tools import utils, to_one_hot, atomic_numbers_to_indices - import torch - from mace.modules.utils import compute_hessians_vmap, compute_hessians_loop, compute_forces - + print_if_level("Running on platform/device:", self.printlevel, 2) + # Checking if model is alreadyloaded if self.model is None: - print("Model has not been loaded yet.") - self.model_load() + print("A model has not been loaded yet.") + # We can only proceed if we have a model_file or model_name so checking + if self.model_file is None and self.model_name is None: + print("Neither model_file or model_name have been defined.") + print("Either load a valid model (model_file or model_name keywords) or train a model (train method) before running") + ashexit() - # Simplest to use ase here to create Atoms object + # Loading will define self.model + if self.model_file is not None: + print("Loading MACE model from file:", self.model_file) + # Checking first f file exists + self.check_file_exists(self.model_file) + #Load model + self.modelfile_load() + elif self.model_name is not None: + print("Loading via model_name:", self.model_name) + self.modelname_load() + else: + print("Error: Neither modelfile or modelname was defined.") + ashexit() + + # Creating ASE atoms object (MACE has ASE has dependency anyway) import ase - atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) - config = data.config_from_atoms(atoms) - z_table = utils.AtomicNumberTable([int(z) for z in self.model.atomic_numbers]) - # Create dataloader - data_loader = torch_geometric.dataloader.DataLoader( - dataset=[data.AtomicData.from_config( - config, z_table=z_table, cutoff=float(self.model.r_max), heads=None)], - shuffle=False, - drop_last=False) - # - option_1=True - if option_1: + if self.periodic: + atoms = ase.atoms.Atoms(qm_elems,positions=current_coords, cell=self.periodic_cell_vectors, + pbc=True) + else: + atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) + atoms.info["charge"] = charge + atoms.info["spin"] = mult + + # New simpler MACE interface via ASE + # Works for foundational models + if self.new_interface is True: + # Add loaded model to ASE calculator + atoms.calc = self.model + + # Run energy + self.energy = atoms.get_potential_energy() * ash.constants.evtohar + print("Energy:", self.energy) + forces = atoms.get_forces() + self.gradient = forces/-51.422067090480645 + if self.stress: + stress_ev_ang3 = atoms.get_stress(voigt=False) + self.cell_gradient = stress_to_grad(stress_ev_ang3,atoms.get_volume(), atoms.get_cell()) + print("Cell gradient:", self.cell_gradient) + + # Grab some other attributes if e.g. polarmace + if self.polarmace: + self.charges = self.model.results["charges"] + print("PolarMACE: Getting charges:", self.charges) + # dipole + self.properties["dipole"] = self.model.results["dipole"] + print("PolarMACE: Getting dipole:", self.properties["dipole"]) + + # Older interface: suitable for loading user-trained regular MACE models + else: + # Call model to get energy + from mace.cli.eval_configs import main + from mace import data + from mace.tools import torch_geometric, torch_tools, utils + from mace.tools import utils, to_one_hot, atomic_numbers_to_indices + import torch + + # Charge and spin: only makes sense for mace_polar + atoms.info["charge"] = charge + atoms.info["spin"] = mult + + config = data.config_from_atoms(atoms) + z_table = utils.AtomicNumberTable([int(z) for z in self.model.atomic_numbers]) + # Create dataloader + data_loader = torch_geometric.dataloader.DataLoader( + dataset=[data.AtomicData.from_config( + config, z_table=z_table, cutoff=float(self.model.r_max), heads=None)], + shuffle=False, + drop_last=False) + # # Get batch for batch in data_loader: - batch = batch.to(self.device) + batch = batch.to(self.platform) # Run model try: - output = self.model(batch.to_dict(), compute_stress=False, compute_force=False) - + output = self.model(batch.to_dict(), compute_stress=self.stress, compute_force=Grad) except RuntimeError as e: print("RuntimeError occurred. Trying type changes. Message", e) self.model = self.model.float() # sometimes necessary to avoid type problems - output = self.model(batch.to_dict(), compute_stress=False, compute_force=False) + output = self.model(batch.to_dict(), compute_stress=self.stress, compute_force=Grad) print_time_rel(module_init_time, modulename=f'MACE run - after energy', moduleindex=2) # Grab energy en = torch_tools.to_numpy(output["energy"])[0] self.energy = float(en*ash.constants.evtohar) - # Grad Boolean if Grad: - # Calculate forces - forces_tensor = compute_forces(output["energy"], batch["positions"]) - print_time_rel(module_init_time, modulename=f'MACE run - after forces', moduleindex=2) - forces_np = torch_tools.to_numpy(forces_tensor) - self.gradient = forces_np/-51.422067090480645 + self.gradient = torch_tools.to_numpy(output["forces"])/-51.422067090480645 + if self.stress: + stress_ev_ang3 = torch_tools.to_numpy(output["stress"][0]) + self.cell_gradient = stress_to_grad(stress_ev_ang3,atoms.get_volume(), atoms.get_cell()) + print("Cell gradient:",self.cell_gradient) # Hessian if Hessian: print("Running Hessian") + from mace.modules.utils import compute_hessians_vmap, compute_forces + # + forces_tensor = compute_forces(output["energy"], batch["positions"]) + forces_np = torch_tools.to_numpy(forces_tensor) + self.gradient = forces_np/-51.422067090480645 + # Calculate forces hess = compute_hessians_vmap(forces_tensor,batch["positions"]) hessian = torch_tools.to_numpy(hess) print("hessian:", hessian) print_time_rel(module_init_time, modulename=f'MACE run - after hessian', moduleindex=2) + self.hessian = hessian*0.010291772 - # This worked previously - else: - print("previous regular mode") - for batch in data_loader: - try: - output = self.model(batch.to_dict(), compute_stress=False, compute_force=Grad) - except RuntimeError as e: - print("RuntimeError occurred. Trying type changes. Message", e) - self.model = self.model.float() # sometimes necessary to avoid type problems - output = self.model(batch.to_dict(), compute_stress=False, compute_force=Grad) - - # Get energy and forces - en = torch_tools.to_numpy(output["energy"])[0] - self.energy = float(en*ash.constants.evtohar) - if Grad: - forces = np.split( - torch_tools.to_numpy(output["forces"]), - indices_or_sections=batch.ptr[1:], - axis=0)[0] - self.gradient = forces/-51.422067090480645 - - if Hessian: - self.hessian = hessian*0.010291772 - print(f"Single-point {self.theorynamelabel} energy:", self.energy) + print_if_level(f"Single-point {self.theorynamelabel} energy: {self.energy}", self.printlevel, 1) print(BC.OKBLUE, BC.BOLD, f"------------ENDING {self.theorynamelabel} INTERFACE-------------", BC.END) # Special option @@ -361,13 +559,12 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # max_L: symmetry of messages. affects speed and accuracy. default 1 (compromise of speed/acc), 2 more accurate and slower, 0 is fast # r_max: cutoff radius of local env. Recommended: 4-7 Ang #NOTE: E0s="average" is easiest but not recommended. -##todo: seed -def write_mace_config(config_file="config.yml", name="model",model="MACE", device='cpu', +def write_mace_config(config_file="config.yml", name="model",model="MACE", platform='cpu', valid_fraction=0.1, train_file="train_data_mace.xyz",E0s=None, energy_key='energy_REF', forces_key='forces_REF', energy_weight=1, forces_weight=100, max_num_epochs=500, swa=True, batch_size=10, - max_L = 0, r_max = 5.0, + max_L = 0, r_max = 5.0, seed=42, num_channels=128, results_dir= "MACE_models", checkpoints_dir = "MACE_models", log_dir ="MACE_models", model_dir="MACE_models"): @@ -389,7 +586,8 @@ def write_mace_config(config_file="config.yml", name="model",model="MACE", devic forces_key= forces_key, energy_weight=energy_weight, forces_weight=forces_weight, -device= device, +device= platform, +seed= seed, batch_size= batch_size, max_num_epochs= max_num_epochs, swa= swa) @@ -400,4 +598,10 @@ def write_mace_config(config_file="config.yml", name="model",model="MACE", devic data[E0s] = E0s with open(config_file, 'w') as outfile: - yaml.dump(data, outfile, default_flow_style=False, sort_keys=False) \ No newline at end of file + yaml.dump(data, outfile, default_flow_style=False, sort_keys=False) + +def stress_to_grad(stress_ev_ang3,vol,cell): + inv_cell_T = np.linalg.inv(cell).T + grad_ev_ang = vol * np.dot(stress_ev_ang3, inv_cell_T) + cell_gradient = grad_ev_ang * (0.5291772105638411 / 27.211386024367243) + return cell_gradient \ No newline at end of file diff --git a/ash/interfaces/interface_multiwfn.py b/ash/interfaces/interface_multiwfn.py index 07422044c..0dbbcf2e4 100644 --- a/ash/interfaces/interface_multiwfn.py +++ b/ash/interfaces/interface_multiwfn.py @@ -373,6 +373,15 @@ def write_multiwfn_input_option(option=None, grid=3, frozenorbitals=None, densit 0 q """ + elif option =="spindensity" or option =="spin-density": + inputformula=f"""5 +5 +{grid} +2 +0 +q + """ + else: print("write_multiwfn_input_option: unknown option") ashexit() diff --git a/ash/interfaces/interface_openbabel.py b/ash/interfaces/interface_openbabel.py new file mode 100644 index 000000000..3e3bbf15d --- /dev/null +++ b/ash/interfaces/interface_openbabel.py @@ -0,0 +1,279 @@ +import subprocess as sp +import os +import shutil +import time +import numpy as np + +from ash.functions.functions_general import ashexit, BC, print_time_rel,print_line_with_mainheader +import ash.settings_ash +from ash.modules.module_coords import reformat_element + +# Interface to OpenBabel for running implemented theories (e.g. UFF) +# TODO: Move other OpenBabel functionality to this file + +class OpenBabelTheory(): + def __init__(self, forcefield="UFF", chargemodel=None, label="OpenBabelTheory", + printlevel=2, user_atomcharges=None): + self.label = label + self.printlevel = printlevel + self.theorytype = 'QM' + self.theorynamelabel = 'OpenBabel' + self.forcefield=forcefield #UFF, GAFF, MMF94, Ghemical, etc. See https://openbabel.org/docs/dev/Forcefields/FF.html for options + self.chargemodel=chargemodel #gasteiger, mmff94, qeq, qtpie. See https://openbabel.org/docs/dev/Forcefields/ChargeModels.html for options + + self.user_atomcharges=user_atomcharges + from openbabel import openbabel as ob + from openbabel import pybel + + def cleanup(self): + print("No cleanup implemented") + + def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, + elems=None, Grad=False, PC=False, numcores=None, restart=False, label=None, + charge=None, mult=None): + module_init_time = time.time() + print(BC.OKBLUE, BC.BOLD, f"------------RUNNING {self.theorynamelabel} INTERFACE-------------", BC.END) + + from openbabel import openbabel as ob + from openbabel import pybel + + #What elemlist to use. If qm_elems provided then QM/MM job, otherwise use elems list + if qm_elems is None: + if elems is None: + print("No elems provided") + ashexit() + else: + qm_elems = elems + + # Create an OBMol object and populate it with the current geometry + mol = ob.OBMol() + for elem, coord in zip(qm_elems, current_coords): + # Create the atom object + atom = mol.NewAtom() + atomic_num = ob.GetAtomicNum(elem) + atom.SetAtomicNum(atomic_num) + atom.SetVector(coord[0], coord[1], coord[2]) + + print("Determining connectivity and bond orders for FF...") + mol.ConnectTheDots() + mol.PerceiveBondOrders() + + # Turn off auto charges + #mol.SetAutomaticPartialCharge(False) + #mol.SetPartialChargesPerceived() + #mol.SetAutomaticFormalCharge(False) + + def print_charges(mol): + # Print charges for each atom + for i in range(1, mol.NumAtoms() + 1): + atom = mol.GetAtom(i) + # Get charge from your dict, default to 0.0 if not found + charge = atom.GetPartialCharge() + print(f"Atom {i} charge: {charge}") + def set_charges(mol,usercharges): + # Set charges for each atom + for i in range(1, mol.NumAtoms() + 1): + atom = mol.GetAtom(i) + atom.SetPartialCharge(usercharges[i-1]) + print("Initial charges in mol (before applying any charge model):") + print_charges(mol) + + # Charge model + if self.chargemodel is not None: + print("Charge model is active") + print("Charge model is currently disabled") + #ashexit() + if self.user_atomcharges is not None: + print("Setting charges to user-atomcharges") + set_charges(mol,self.user_atomcharges) + else: + self.cm = ob.OBChargeModel.FindType(self.chargemodel) + print("Computing charges using OpenBabel charge model:", self.chargemodel) + success = self.cm.ComputeCharges(mol) + if not success: + raise RuntimeError("Failed to compute charges") + print("Charges (after applying charge model):") + print_charges(mol) + self.ff = ob.OBForceField.FindForceField(self.forcefield) + self.ff.Setup(mol) + self.ff.GetPartialCharges(mol) + # NOTE: still not working + else: + print("No chargemodel is active") + self.ff = ob.OBForceField.FindForceField(self.forcefield) + success = self.ff.Setup(mol) + + print("Computing regular FF energy:") + self.energy = self.ff.Energy() / ash.constants.hartokj + print("FF energy:", self.energy) + #elec_energy = self.ff.E_Electrostatic() + #print("Electrostatic energy:", elec_energy) + if Grad: + self.gradient = np.zeros((len(qm_elems), 3)) + for i in range(len(qm_elems)): + atom = mol.GetAtom(i + 1) + f = self.ff.GetGradient(atom) + self.gradient[i, 0] = f.GetX()*-1 + self.gradient[i, 1] = f.GetY()*-1 + self.gradient[i, 2] = f.GetZ()*-1 + self.gradient = self.gradient * 0.00020155 + print(f"Single-point {self.theorynamelabel} energy:", self.energy) + print(BC.OKBLUE, BC.BOLD, f"------------ENDING {self.theorynamelabel} INTERFACE-------------", BC.END) + + # Returning energy and gradient + if Grad is True: + print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} run', moduleindex=2) + return self.energy, self.gradient + # Returning energy without gradient + else: + print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} run', moduleindex=2) + return self.energy + + +################################### +# Other Openbabel functionality +################################### + +#Function to convert Mol file to PDB-file via OpenBabel +def mol_to_pdb(file): + #OpenBabel + try: + from openbabel import pybel + except ModuleNotFoundError: + print("Error: mol_to_pdb requires OpenBabel library but it could not be imported") + print("You can install like this: conda install --yes -c conda-forge openbabel") + ashexit() + mol = next(pybel.readfile("mol", file)) + mol.write(format='pdb', filename=os.path.splitext(file)[0]+'.pdb', overwrite=True) + print("Wrote PDB-file:", os.path.splitext(file)[0]+'.pdb') + return os.path.splitext(file)[0]+'.pdb' + +#Function to convert SDF file to PDB-file via OpenBabel +def sdf_to_pdb(file): + #OpenBabel + try: + from openbabel import openbabel + from openbabel import pybel + except ModuleNotFoundError: + print("Error: sdf_to_pdb requires OpenBabel library but it could not be imported") + print("You can install like this: conda install --yes -c conda-forge openbabel") + ashexit() + mol = next(pybel.readfile("sdf", file)) + + #Write do disk as PDB-file + mol.write(format='pdb', filename=os.path.splitext(file)[0]+'temp.pdb', overwrite=True) + #Read-in again (this will create a Residue) + newmol = next(pybel.readfile("pdb", os.path.splitext(file)[0]+'temp.pdb')) + os.remove(os.path.splitext(file)[0]+'temp.pdb') + + #Atomlabel = {0:'C1',1:'X',2:'C',3:'C',4:'C',5:'C',6:'C',7:'C',8:'C',9:'C',10:'C',11:'C',12:'C'} + #Change atomnames (AtomIDs) to something sensible (OpenBabel does not do this by default) + print("Creating new atomnames for PDBfile") + #Note: currently just combining element and atomindex to get a unique atomname (otherwise Modeller will not work) + #TODO: make something better (element-specific numbering?) + for res in pybel.ob.OBResidueIter(newmol.OBMol): + for i,atom in enumerate(openbabel.OBResidueAtomIter(res)): + atomname = res.GetAtomID(atom) + #print("atomname:", atomname) + res.SetAtomID(atom,atomname.strip()+str(i+1)) + atomname = res.GetAtomID(atom) + #print("atomname:", atomname) + #res.SetAtomID(atom,Atomlabel[i]) + + #Write final PDB-file + newmol.write(format='pdb', filename=os.path.splitext(file)[0]+'.pdb', overwrite=True) + print("Wrote PDB-file:", os.path.splitext(file)[0]+'.pdb') + return os.path.splitext(file)[0]+'.pdb' + +#Function to read in PDB-file and write new one with CONECT lines (geometry needs to be sensible) +#NOTE: Requires OpenBabel which seems unnecessary, probably better to use OpenMM functionality instead +def writepdb_with_connectivity(file): + #OpenBabel + try: + from openbabel import pybel + except ModuleNotFoundError: + print("Error: writepdb_with_connectivity requires OpenBabel library but it could not be imported") + print("You can install like this: conda install --yes -c conda-forge openbabel") + ashexit() + mol = next(pybel.readfile("pdb", file)) + mol.write(format='pdb', filename=os.path.splitext(file)[0]+'_withcon.pdb', overwrite=True) + print("Wrote PDB-file:", os.path.splitext(file)[0]+'_withcon.pdb') + return os.path.splitext(file)[0]+'_withcon.pdb' + +#Function to read in XYZ-file (small molecule) and create PDB-file with CONECT lines (geometry needs to be sensible) +def xyz_to_pdb_with_connectivity(file, resname="UNL"): + print("xyz_to_pdb_with_connectivity function:") + #OpenBabel + try: + from openbabel import openbabel + from openbabel import pybel + except ModuleNotFoundError: + print("Error: xyz_to_pdb_with_connectivity requires OpenBabel library but it could not be imported") + print("You can install OpenBabel like this: conda install --yes -c conda-forge openbabel") + ashexit() + #Read in XYZ-file + mol = next(pybel.readfile("xyz", file)) + #Write do disk as PDB-file + mol.write(format='pdb', filename=os.path.splitext(file)[0]+'temp.pdb', overwrite=True) + #Read-in again (this will create a Residue) + newmol = next(pybel.readfile("pdb", os.path.splitext(file)[0]+'temp.pdb')) + + os.remove(os.path.splitext(file)[0]+'temp.pdb') + + #Atomlabel = {0:'C1',1:'X',2:'C',3:'C',4:'C',5:'C',6:'C',7:'C',8:'C',9:'C',10:'C',11:'C',12:'C'} + #Change atomnames (AtomIDs) to something sensible (OpenBabel does not do this by default) + print("Creating new atomnames for PDBfile") + #Note: currently just combining element and atomindex to get a unique atomname (otherwise Modeller will not work) + #TODO: make something better (element-specific numbering?) + for res in pybel.ob.OBResidueIter(newmol.OBMol): + #Setting residue name + res.SetName(resname) + for i,atom in enumerate(openbabel.OBResidueAtomIter(res)): + atomname = res.GetAtomID(atom) + #print("atomname:", atomname) + res.SetAtomID(atom,atomname.strip()+str(i+1)) + atomname = res.GetAtomID(atom) + #print("atomname:", atomname) + #res.SetAtomID(atom,Atomlabel[i]) + + #Write final PDB-file + newmol.write(format='pdb', filename=os.path.splitext(file)[0]+'.pdb', overwrite=True) + print("Wrote PDB-file:", os.path.splitext(file)[0]+'.pdb') + return os.path.splitext(file)[0]+'.pdb' + +#Function to convert PDB-file to SMILES string +def pdb_to_smiles(fname: str) -> str: + #OpenBabel + try: + from openbabel import pybel + except ModuleNotFoundError: + print("Error: pdb_to_smiles requires OpenBabel library but it could not be imported") + print("You can install like this: conda install --yes -c conda-forge openbabel") + ashexit() + mol = next(pybel.readfile("pdb", fname)) + smi = mol.write(format="smi") + return smi.split()[0].strip() + +#Function to convert SMILES string to elements and coordinates list +def smiles_to_coords(smiles_string): + #OpenBabel + try: + from openbabel import pybel + from openbabel import openbabel + except ModuleNotFoundError: + print("Error: smiles_to_coords requires OpenBabel library but it could not be imported") + print("You can install like this: conda install --yes -c conda-forge openbabel") + ashexit() + print("Reading SMILES by OpenBabel") + mol = pybel.readstring("smi", smiles_string) + print("Guessing 3D coordinates (uses MMFF94 forcefield)") + mol.make3D() + b_mol = mol.OBMol + atomnums = [] + coords = [] + for atom in openbabel.OBMolAtomIter(b_mol): + atomnums.append(atom.GetAtomicNum()) + coords.append([atom.GetX(), atom.GetY(), atom.GetZ()]) + elems = [reformat_element(atn, isatomnum=True) for atn in atomnums] + #frag = Fragment(elems=elems, coords=coords, charge=charge, mult=mult) + return elems, coords \ No newline at end of file diff --git a/ash/interfaces/interface_packmol.py b/ash/interfaces/interface_packmol.py index 7efc8668f..21a8a9973 100644 --- a/ash/interfaces/interface_packmol.py +++ b/ash/interfaces/interface_packmol.py @@ -3,7 +3,7 @@ import shutil import math from ash.functions.functions_general import ashexit, BC,print_time_rel, print_line_with_mainheader,listdiff -from ash.modules.module_coords import writepdb_with_connectivity +from ash.interfaces.interface_openbabel import writepdb_with_connectivity import ash.settings_ash diff --git a/ash/interfaces/interface_plumed.py b/ash/interfaces/interface_plumed.py index 36be93ba7..a7bc3049f 100644 --- a/ash/interfaces/interface_plumed.py +++ b/ash/interfaces/interface_plumed.py @@ -121,7 +121,7 @@ def plumed_MTD_analyze(path_to_plumed=None, Plot_To_Screen=False, CV1_type=None, print("Problem importing matplotlib (make sure it is installed in your environment). Plotting is not possible but continuing.") path_to_plumed=check_program_location(path_to_plumed,'path_to_plumed','plumed') - + print("Path to plumed:", path_to_plumed) ############################### # USER SETTINGS ############################### @@ -240,7 +240,7 @@ def plumed_MTD_analyze(path_to_plumed=None, Plot_To_Screen=False, CV1_type=None, # The plumed sum_hills command that is run. print("") - if MultipleWalker==True: + if MultipleWalker is True: #Removing old HILLS.ALL if present try: os.remove('HILLS.ALL') @@ -266,28 +266,28 @@ def plumed_MTD_analyze(path_to_plumed=None, Plot_To_Screen=False, CV1_type=None, print("") #RUN PLUMED_ASH OBJECT function if CV1_grid_limits is None: - call_plumed_sum_hills(path_to_plumed,"HILLS.ALL",CVnum) + call_plumed_sum_hills(path_to_plumed=path_to_plumed,hillsfile="HILLS.ALL",ndim=CVnum) else: #Changing input unit from Angstrom to nm or degree to radian if CVnum == 1: - call_plumed_sum_hills(path_to_plumed,'HILLS.ALL',ndim=CVnum, ming=[CV1_grid_limits[0]/ CV1_scaling], maxg=[CV1_grid_limits[1]/ CV1_scaling]) + call_plumed_sum_hills(path_to_plumed=path_to_plumed,hillsfile='HILLS.ALL',ndim=CVnum, ming=[CV1_grid_limits[0]/ CV1_scaling], maxg=[CV1_grid_limits[1]/ CV1_scaling]) elif CVnum == 2: #Changing input unit from Angstrom to nm or degree to radian - call_plumed_sum_hills(path_to_plumed,'HILLS.ALL',ndim=CVnum, ming=[CV1_grid_limits[0]/ CV1_scaling,CV2_grid_limits[0]/ CV2_scaling], + call_plumed_sum_hills(path_to_plumed=path_to_plumed,hillsfile='HILLS.ALL',ndim=CVnum, ming=[CV1_grid_limits[0]/ CV1_scaling,CV2_grid_limits[0]/ CV2_scaling], maxg=[CV1_grid_limits[1]/ CV1_scaling,CV2_grid_limits[1]/ CV2_scaling]) else: print("Calling call_plumed_sum_hills") # call_plumed_sum_hills(path_to_plumed,"HILLS") if CV1_grid_limits == None: - call_plumed_sum_hills(path_to_plumed,"HILLS",CVnum) + call_plumed_sum_hills(path_to_plumed=path_to_plumed,hillsfile="HILLS",ndim=CVnum) else: #Changing input unit from Angstrom to nm or degree to radian if CVnum == 1: - call_plumed_sum_hills(path_to_plumed,'HILLS',ndim=CVnum, ming=[CV1_grid_limits[0]/ CV1_scaling], maxg=[CV1_grid_limits[1]/ CV1_scaling]) + call_plumed_sum_hills(path_to_plumed=path_to_plumed,hillsfile='HILLS',ndim=CVnum, ming=[CV1_grid_limits[0]/ CV1_scaling], maxg=[CV1_grid_limits[1]/ CV1_scaling]) elif CVnum == 2: #Changing input unit from Angstrom to nm or degree to radian - call_plumed_sum_hills(path_to_plumed,'HILLS',ndim=CVnum, ming=[CV1_grid_limits[0]/ CV1_scaling,CV2_grid_limits[0]/ CV2_scaling], maxg=[CV1_grid_limits[1]/ CV1_scaling,CV2_grid_limits[1]/ CV2_scaling]) + call_plumed_sum_hills(path_to_plumed=path_to_plumed,hillsfile='HILLS',ndim=CVnum, ming=[CV1_grid_limits[0]/ CV1_scaling,CV2_grid_limits[0]/ CV2_scaling], maxg=[CV1_grid_limits[1]/ CV1_scaling,CV2_grid_limits[1]/ CV2_scaling]) HILLSFILELIST=['HILLS'] # Single COLVAR file COLVARFILELIST=['COLVAR'] @@ -399,6 +399,7 @@ def plumed_MTD_analyze(path_to_plumed=None, Plot_To_Screen=False, CV1_type=None, colvar_value.append(float(line.split()[1])) biaspot_value.append(float(line.split()[2])) elif CVnum == 2: + #print("line:", line) if number_of_fields >= 4: if len(line) > 10: time.append(float(line.split()[0])) @@ -456,6 +457,7 @@ def plumed_MTD_analyze(path_to_plumed=None, Plot_To_Screen=False, CV1_type=None, #Possible energy conversion biaspot_value_kcal=np.array(biaspot_value)/energy_scaling + print("final_rc2:", final_rc2) biaspot_value_kcal_list.append(biaspot_value_kcal) time_list.append(time) @@ -525,7 +527,7 @@ def plumed_MTD_analyze(path_to_plumed=None, Plot_To_Screen=False, CV1_type=None, plt.scatter(cv, biaspot, marker='o', linestyle='-', s=3, linewidth=1, label='Walker'+str(num)) #lg2 = plt.legend(shadow=True, fontsize='xx-small', bbox_to_anchor=(0.0, 0.0), loc='lower left') - if WellTemp==True: + if WellTemp is True: #Subplot 4: Gaussian height from HILLS plt.subplot(2, 2, 4) plt.gca().set_title('G-height vs. time', fontsize='small', style='italic', fontweight='bold') @@ -562,22 +564,26 @@ def flatten(list): plt.gca().set_title('Free energy vs. CV', fontsize='small', style='italic', fontweight='bold') plt.xlabel('{} ({})'.format(CV1_type,CV1_indices), fontsize='small') plt.ylabel('{} ({})'.format(CV2_type,CV2_indices), fontsize='small') - if CV1_type=='Torsion': - plt.xlim([-180,180]) - plt.ylim([-180,180]) - else: - print("Subplot 1 free energy surface") - print("Choosing sensible x and y values based on min and max") - #print("final_rc:", final_rc) - #print("final_rc2:", final_rc2) - #min_x=min(final_rc) - #max_x=max(final_rc) - #min_y=min(final_rc2) - #max_y=max(final_rc2) - #plt.xlim([min_x,max_x]) - #plt.ylim([min_y,max_y]) - #plt.xlim(CV1_plot_limits) - #plt.ylim(CV2_plot_limits) + #if CV1_type.lower()=='torsion': + # plt.xlim([-180,180]) + # #plt.ylim([-180,180]) + #else: + print("Subplot 1 free energy surface") + print("Choosing sensible x and y values based on min and max") + #print("final_rc:", final_rc) + #print("final_rc2:", final_rc2) + min_x=min(final_rc) + max_x=max(final_rc) + min_y=min(final_rc2) + max_y=max(final_rc2) + print("min_x:", min_x) + print("max_x:", max_x) + print("min_y:", min_y) + print("max_y:", max_y) + plt.xlim([min_x,max_x]) + plt.ylim([min_y,max_y]) + #plt.xlim(CV1_plot_limits) + #plt.ylim(CV2_plot_limits) cm = plt.cm.get_cmap(colormap) colorscatter=plt.scatter(final_rc, final_rc2, c=Relfreeenergy_kcal, marker='o', linestyle='-', linewidth=1, cmap=cm) cbar = plt.colorbar(colorscatter) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index eec326980..c648e173f 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -1,9 +1,11 @@ import time from ash.functions.functions_general import ashexit, BC,print_time_rel, print_line_with_mainheader,listdiff -import ash.modules.module_coords +from ash.modules.module_coords import nucchargelist, create_coords_string +from ash.modules.module_coords_PBC import cell_vectors_to_params, cell_params_to_vectors from ash.modules.module_results import ASH_Results from ash.functions.functions_elstructure import get_ec_entropy,get_entropy +from ash.modules.module_singlepoint import Singlepoint import os import sys import glob @@ -46,7 +48,10 @@ def __init__(self, printsetting=False, printlevel=2, numcores=1, label="pyscf", loscpath=None, LOSC_window=None, mcpdft=False, mcpdft_functional=None, PBC_lattice_vectors=None,rcut_ewald=8, rcut_hcore=6, radii=None, - neo=False, nuc_basis=None): + neo=False, nuc_basis=None, + periodic=False, periodic_cell_vectors=None, periodic_cell_dimensions=None, + ke_cutoff=None, kpoints=None, + hubbard_U=None): self.theorynamelabel="PySCF" self.theorytype="QM" @@ -95,6 +100,14 @@ def __init__(self, printsetting=False, printlevel=2, numcores=1, label="pyscf", print("Error importing density_functional_approximation_dm21 library. Exiting") print("Make sure DM21 is installed in the Python environment. See https://github.com/google-deepmind/deepmind-research/tree/master/density_functional_approximation_dm21") ashexit() + if "skala" in self.functional.lower(): + print("Skala functional detected. Attemping to import skala") + try: + from skala.pyscf import SkalaKS + except ModuleNotFoundError as e: + print("Error importing skala library. Exiting") + print("Make sure skala is installed in the Python environment. See https://github.com/microsoft/skala") + ashexit() else: if scf_type == 'RKS' or scf_type == 'UKS': print("Error: RKS/UKS chosen but no functional. Exiting") @@ -152,6 +165,31 @@ def __init__(self, printsetting=False, printlevel=2, numcores=1, label="pyscf", # Counter for how often pyscftheory.run is called self.runcalls = 0 + #PBC + self.periodic=periodic + self.periodic_cell_vectors=None # initially + self.ke_cutoff=ke_cutoff + self.kpoints=kpoints # K-points e.g. [2,2,2] + if self.periodic: + print("PBC enabled") + print("PySCFTheory with PBC is currently disabled in ASH") + # NOTE: wait until grid and stress implementations have stabilized + ashexit() + if periodic_cell_vectors is None and periodic_cell_dimensions is None: + print("Error: for periodic calculations, you must specify either periodic_cell_vectors or periodic_cell_dimensions") + ashexit() + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + elif periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions = periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + print("Cell vectors:", self.periodic_cell_vectors) + print("Cell dimensions:", self.periodic_cell_dimensions) + #CPPE Polarizable Embedding options self.pe=pe # Potfile from user or passed on via QM/MM Theory object ? @@ -222,6 +260,11 @@ def __init__(self, printsetting=False, printlevel=2, numcores=1, label="pyscf", self.fcidumpfile=fcidumpfile self.fcidumpfile_molpro_orbsym=fcidumpfile_molpro_orbsym # Boolean. True/False + + # Hubbard U for DFT+U calculations with RKSpU or UKSpU methods + # if scf_type is RKSpU or UKSpu. Example: hubbard_U=[[]"Mn 3d"], [2.8]] + self.hubbard_U=hubbard_U + #CAS self.CAS=CAS self.CASSCF=CASSCF @@ -365,23 +408,23 @@ def __init__(self, printsetting=False, printlevel=2, numcores=1, label="pyscf", self.CASSCF_totnumstates=sum(self.CASSCF_numstates) print("Total number of CASSCF states: ", self.CASSCF_totnumstates) - - #Are we doing an initial SCF calculation or not - #Generally yes. - #TODO: Can we skip this for CASSCF? + # Are we doing an initial SCF calculation or not + # Generally yes. + # TODO: Can we skip this for CASSCF? self.SCF=True - #Attempting to load pyscf - #self.load_pyscf() self.numcores=numcores if self.losc is True: self.load_losc(loscpath) - #Number of orbitals and basis functions (only setup upon run) + # Number of orbitals and basis functions (only setup upon run) self.num_basis_functions=None self.num_orbs=None - #Print the options + # How pointcharge gradient is calculated + self.PC_gradient_code = "new" # new or old + + # Print the options if self.printlevel >= 1: print("SCF:", self.SCF) print("SCF-type:", self.scf_type) @@ -821,15 +864,15 @@ def run_population_analysis(self, mf, unrestricted=False, dm=None, type='Mullike if unrestricted is False: if dm is None: dm = mf.make_rdm1() - mulliken_populations =mull_pop_func(self.mol,dm, verbose=verbose) + mulliken_populations =mull_pop_func(self.molcellobject,dm, verbose=verbose) print(f"{label} Mulliken charges:", mulliken_populations[1]) elif unrestricted is True: if dm is None: dm = mf.make_rdm1() #print("dm:", dm) #print("dm.shape:", dm.shape) - mulliken_populations =mull_pop_func(self.mol,dm, verbose=verbose) - mulliken_spinpopulations = mull_spinpop_func(self.mol,dm, verbose=verbose) + mulliken_populations =mull_pop_func(self.molcellobject,dm, verbose=verbose) + mulliken_spinpopulations = mull_spinpop_func(self.molcellobject,dm, verbose=verbose) print(f"{label} Mulliken charges:", mulliken_populations[1]) print(f"{label} Mulliken spin pops:", mulliken_spinpopulations[1]) return @@ -1204,9 +1247,10 @@ def run_MP2_density(self, mp2object, MP2_DF=None, DFMP2_density_relaxed=None): print(natocc) print() print("NO-based polyradical metrics:") - ash.functions.functions_elstructure.poly_rad_index_nu(natocc) - ash.functions.functions_elstructure.poly_rad_index_nu_nl(natocc) - ash.functions.functions_elstructure.poly_rad_index_n_d(natocc) + from ash.functions.functions_elstructure import poly_rad_index_nu, poly_rad_index_nu_nl, poly_rad_index_n_d + poly_rad_index_nu(natocc) + poly_rad_index_nu_nl(natocc) + poly_rad_index_n_d(natocc) print() molden_name=f"pySCF_MP2_natorbs" print(f"Writing MP2 natural orbitals to Moldenfile: {molden_name}.molden") @@ -1847,9 +1891,10 @@ def run_CC_density(self,ccobject=None,mf=None): print(natocc) print() print("NO-based polyradical metrics:") - ash.functions.functions_elstructure.poly_rad_index_nu(natocc) - ash.functions.functions_elstructure.poly_rad_index_nu_nl(natocc) - ash.functions.functions_elstructure.poly_rad_index_n_d(natocc) + from ash.functions.functions_elstructure import poly_rad_index_nu, poly_rad_index_nu_nl, poly_rad_index_n_d + poly_rad_index_nu(natocc) + poly_rad_index_nu_nl(natocc) + poly_rad_index_n_d(natocc) print() print(f"Writing {self.CCmethod} natural orbitals to Moldenfile: {molden_name}.molden") self.write_orbitals_to_Moldenfile(self.mol, natorb, natocc, label=molden_name) @@ -1861,9 +1906,10 @@ def get_dipole_moment(self, dm=None, label=None): if self.printlevel >=1: print("get_dipole_moment function.") - #if self.platform =="GPU": - # print("Dipole moment calculation not currently supported on GPU") - # return None + # For PBC return None + if self.periodic: + print("Warning: PBC not yet available") + return None if label == None: label="" @@ -1871,6 +1917,7 @@ def get_dipole_moment(self, dm=None, label=None): if self.printlevel >=1: print("No DM provided. Using mean-field object dm") #MF dipole moment + dipole = self.mf.dip_moment(unit='A.U.',verbose=self.printlevel) if self.printlevel >=1: print(f"MF Dipole moment ({label}): {dipole} A.U.") @@ -1902,7 +1949,7 @@ def create_mol(self, qm_elems, current_coords, charge, mult, cartesian_basis=Non print("Creating mol object") import pyscf - coords_string=ash.modules.module_coords.create_coords_string(qm_elems,current_coords) + coords_string=create_coords_string(qm_elems,current_coords) # NEO (requires special pyscf) if neo: print("neo option activated. Warning: requires special pyscf with neo") @@ -1934,18 +1981,54 @@ def create_mol(self, qm_elems, current_coords, charge, mult, cartesian_basis=Non self.mol.cart = cartesian_basis - - #Update mol object with coordinates or charge/mult - #def update_mol(self, qm_elems, current_coords, charge, mult): - # coords_string=ash.modules.module_coords.create_coords_string(qm_elems,current_coords) - # self.mol.atom = coords_string - # self.mol.charge = charge - # self.mol.spin = mult-1 + #Create pyscf periodic cell object + def create_cell(self, qm_elems, current_coords, charge, mult, cartesian_basis=None): + if self.printlevel >= 1: + print("Creating cell object") + from pyscf.pbc import gto + + coords_string=create_coords_string(qm_elems,current_coords) + #Defining pyscf mol object and populating + self.cell = gto.Cell() + #cell system printing. Hardcoding to 3 as otherwise too much PySCF printing + self.cell.verbose = 3 + + self.cell.atom = coords_string + self.cell.charge = charge + self.cell.spin = mult-1 + + # Lattice parameters + self.cell.a = self.periodic_cell_vectors + + # Kinetic energy cutoff. Only if user requested + if self.ke_cutoff is not None: + print("Setting kinetic energy cutoff in cell:", self.ke_cutoff) + self.cell=self.ke_cutoff + + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + # Update self.cell if created + if hasattr(self, "cell"): + print("Updating pyscf cell object") + self.cell.a=self.periodic_cell_vectors + exit() + else: + print("pySCF cell object not yet created") + + def get_cell_gradient(self): + return self.cell_gradient #Define basis in mol object def define_basis(self,elems=None): if self.printlevel >= 1: - print("Defining basis set in mol object") + print("Defining basis set in mol/cell object") import pyscf #PYSCF basis object: https://sunqm.github.io/pyscf/tutorial.html #NOTE: We should also support basis set exchange API: https://github.com/pyscf/pyscf/issues/1299 @@ -1963,51 +2046,98 @@ def define_basis(self,elems=None): if self.printlevel >= 3: print("basis_per_elem:", basis_per_elem) self.basis_dict[elem]=basis_per_elem - self.mol.basis=self.basis_dict + self.molcellobject.basis=self.basis_dict else: - self.mol.basis=self.basis_dict + self.molcellobject.basis=self.basis_dict else: if self.printlevel >= 1: print("Using basis set from input string") - self.mol.basis=self.basis + self.molcellobject.basis=self.basis if self.printlevel >= 1: - print("Basis set:", self.mol.basis) + print("Basis set:", self.molcellobject.basis) #Optional setting magnetic moments if self.magmom != None: if self.printlevel >= 1: print("Setting magnetic moments from user-input:", self.magmom) - self.mol.magmom=self.magmom #Should be a list of the collinear spins of each atom + self.molcellobject.magmom=self.magmom #Should be a list of the collinear spins of each atom #ECP: Can be string ('def2-SVP') or dict or a dict with element-specific keys and values - self.mol.ecp = self.ecp + if self.periodic: + self.molcellobject.pseudo=self.ecp + else: + self.molcellobject.ecp = self.ecp #Memory settings - self.mol.max_memory = self.memory + self.molcellobject.max_memory = self.memory ########### #Create mf object (self.mf) via method def create_mf(self): if self.printlevel >= 1: print("Creating pySCF mf object") - import pyscf + if self.periodic: + from pyscf.pbc import scf + else: + from pyscf import scf + #RKS v UKS v RHF v UHF v GHF v GKS #TODO: Dirac HF and KS also - if self.scf_type == 'RKS': - self.mf = pyscf.scf.RKS(self.mol) + if "skala" in self.functional.lower(): + print("here") + from skala.pyscf import SkalaKS + self.mf = SkalaKS(self.molcellobject, xc=self.functional) + + elif self.scf_type == 'RKS': + self.mf = scf.RKS(self.molcellobject) elif self.scf_type == 'ROKS': - self.mf = pyscf.scf.ROKS(self.mol) + self.mf = scf.ROKS(self.molcellobject) elif self.scf_type == 'ROHF': - self.mf = pyscf.scf.ROHF(self.mol) + self.mf = scf.ROHF(self.molcellobject) elif self.scf_type == 'UKS': - self.mf = pyscf.scf.UKS(self.mol) + self.mf = scf.UKS(self.molcellobject) elif self.scf_type == 'RHF': - self.mf = pyscf.scf.RHF(self.mol) + self.mf = scf.RHF(self.molcellobject) elif self.scf_type == 'UHF': - self.mf = pyscf.scf.UHF(self.mol) + self.mf = scf.UHF(self.molcellobject) elif self.scf_type == 'GHF': - self.mf = pyscf.scf.GHF(self.mol) + self.mf = scf.GHF(self.molcellobject) elif self.scf_type == 'GKS': - self.mf = pyscf.scf.GKS(self.mol) + self.mf = scf.GKS(self.molcellobject) + elif self.scf_type == 'RKSpU': + print("Creating RKSpU mean-field object.") + if self.hubbard_U is None: + print("Error: Hubbard U value must be provided for RKSpU calculation") + ashexit() + print("self.hubbard_U:", self.hubbard_U) + self.mf = self.molcellobject.RKSpU(xc=self.functional, U_idx=self.hubbard_U[0], U_val=self.hubbard_U[1]) + elif self.scf_type == 'UKSpU': + print("Creating UKSpU mean-field object.") + if self.hubbard_U is None: + print("Error: Hubbard U value must be provided for RKSpU calculation") + ashexit() + print("self.hubbard_U:", self.hubbard_U) + self.mf = self.molcellobject.UKSpU(xc=self.functional, U_idx=self.hubbard_U[0], U_val=self.hubbard_U[1]) + #K-point methods + elif self.scf_type == 'KRHF': + self.mf = scf.KRHF(self.molcellobject, kpts=self.cell.make_kpts(self.kpoints)) + elif self.scf_type == 'KRKS': + self.mf = scf.KRKS(self.molcellobject, kpts=self.cell.make_kpts(self.kpoints)) + elif self.scf_type == 'KROHF': + self.mf = scf.KROHF(self.molcellobject, kpts=self.cell.make_kpts(self.kpoints)) + elif self.scf_type == 'KROKS': + self.mf = scf.KROKS(self.molcellobject, kpts=self.cell.make_kpts(self.kpoints)) + elif self.scf_type == 'KUHF': + self.mf = scf.KUHF(self.molcellobject, kpts=self.cell.make_kpts(self.kpoints)) + elif self.scf_type == 'KUKS': + self.mf = scf.KUKS(self.molcellobject, kpts=self.cell.make_kpts(self.kpoints)) + elif self.scf_type == 'KGHF': + self.mf = scf.KGHF(self.molcellobject, kpts=self.cell.make_kpts(self.kpoints)) + elif self.scf_type == 'KGKS': + self.mf = scf.KGKS(self.molcellobject, kpts=self.cell.make_kpts(self.kpoints)) + else: + print("Unknown scf-type:", self.scf_type) + ashexit() + print("mf object:", self.mf) #Probably depreceated. Created mf for GPU. def create_mf_for_gpu(self): @@ -2282,14 +2412,17 @@ def set_embedding_options(self, PC=False, MM_coords=None, MMcharges=None): if self.platform == 'GPU': print("QM/MM embedding for GPU. Adding pointcharges via create_mm_mol from gpu4pyscf") - from gpu4pyscf.qmmm.pbc import mm_mole - from gpu4pyscf.qmmm.pbc.itrf import add_mm_charges, qmmm_for_scf - if self.PBC_lattice_vectors is None: - print("PBC lattice vectors not set, needed for QM/MM with GPU4pyscf. Exiting") - ashexit() + print("Note: PBC lattice vectors not set, GPU4pyscf will do non-PBC QM/MM") + from gpu4pyscf.qmmm import mm_mole + from gpu4pyscf.qmmm.itrf import add_mm_charges, qmmm_for_scf + mm_mol = mm_mole.create_mm_mol(MM_coords, MMcharges, radii=self.radii) + else: + print(f"Note: PBC lattice vectors are set: {self.PBC_lattice_vectors} GPU4pyscf will do PBC QM/MM") + from gpu4pyscf.qmmm.pbc import mm_mole + from gpu4pyscf.qmmm.pbc.itrf import add_mm_charges, qmmm_for_scf + mm_mol = mm_mole.create_mm_mol(MM_coords, self.PBC_lattice_vectors, MMcharges, radii=self.radii, rcut_ewald=self.rcut_ewald, rcut_hcore=self.rcut_hcore) - mm_mol = mm_mole.create_mm_mol(MM_coords, self.PBC_lattice_vectors, MMcharges, radii=self.radii, rcut_ewald=self.rcut_ewald, rcut_hcore=self.rcut_hcore) self.mf = qmmm_for_scf(self.mf, mm_mol) #pyscf.qmmm.itrf.add_mm_charges(self.mf, MM_coords, MMcharges) else: @@ -2395,7 +2528,6 @@ def run_BS_SCF(self, mult=None, dm=None): #Independent method to run SCF using previously defined mf object and possible input dm def run_SCF(self,mf=None, dm=None, max_cycle=None): import pyscf - import pyscf.dft if self.printlevel >= 1: print("\nInside run_SCF") module_init_time=time.time() @@ -2424,7 +2556,11 @@ def run_SCF(self,mf=None, dm=None, max_cycle=None): scf_result = mf.run(dm) #Grid if 'KS' in self.scf_type: - print("Number of gridpoints used in calculation:", len(self.mf.grids.coords)) + try: + print("Number of gridpoints used in calculation:", len(self.mf.grids.coords)) + except: + # Multigrid PBC + pass E_tot = scf_result.e_tot if self.printlevel >=1: print("SCF done!") @@ -2441,17 +2577,19 @@ def run_SCF(self,mf=None, dm=None, max_cycle=None): if self.platform == 'GPU': import gpu4pyscf import gpu4pyscf.qmmm - print("self.mf:", self.mf) - print(self.mf.__dict__) - #TODO: need to account for UKS here later #if isinstance(self.mf, gpu4pyscf.qmmm.pbc.itrf.QMMMRKS): self.num_orbs = len(self.mf.mo_energy) #else: # self.num_orbs = len(self.mf.mo_energy[0]) else: - if isinstance(self.mf, pyscf.scf.hf.RHF) or isinstance(self.mf, pyscf.dft.rks.RKS) : + if isinstance(self.mf, pyscf.scf.hf.RHF) or isinstance(self.mf, pyscf.dft.rks.RKS): self.num_orbs = len(self.mf.mo_occ) # Restricted + elif self.periodic: + import pyscf.pbc + if isinstance(self.mf, pyscf.pbc.dft.rks.RKS): + self.num_orbs = len(self.mf.mo_occ) # Restricted else: + #UHF/UKS self.num_orbs = len(self.mf.mo_occ[0]) if self.printlevel >= 1: @@ -2518,7 +2656,7 @@ def prepare_run(self, current_coords=None, current_MM_coords=None, MMcharges=Non pass #Checking if charge and mult has been provided - if charge == None or mult == None: + if charge is None or mult is None: print(BC.FAIL, "Error. charge and mult has not been defined for PYSCFTheory.run method", BC.END) ashexit() @@ -2545,14 +2683,14 @@ def prepare_run(self, current_coords=None, current_MM_coords=None, MMcharges=Non #Setting number of electrons for system (used by load_chkfile etc) - self.num_electrons = int(ash.modules.module_coords.nucchargelist(qm_elems) - charge) + self.num_electrons = int(nucchargelist(qm_elems) - charge) if self.printlevel >= 1: print("Number of electrons:", self.num_electrons) print() - ##################### - #CREATE MOL OBJECT - ##################### + ############################### + #CREATE MOL OBJECT or CELL + ############################### qH_atoms=None if self.neo: print("NEO mode. Selecting all H-atoms") @@ -2560,20 +2698,35 @@ def prepare_run(self, current_coords=None, current_MM_coords=None, MMcharges=Non print("qH-atom indices:", qH_atoms) #TODO: skip link atoms needed - self.create_mol(qm_elems, current_coords, charge, mult, cartesian_basis=self.cartesian_basis, - neo=self.neo, quantum_nuc=qH_atoms, nuc_basis=self.nuc_basis) - + if self.periodic: + self.create_cell(qm_elems, current_coords, charge, mult, cartesian_basis=self.cartesian_basis) + self.molcellobject=self.cell + else: + self.create_mol(qm_elems, current_coords, charge, mult, cartesian_basis=self.cartesian_basis, + neo=self.neo, quantum_nuc=qH_atoms, nuc_basis=self.nuc_basis) + # General mol/cell object + self.molcellobject=self.mol ##################### # BASIS ##################### #Only define basis set if regular job (not FCIDUMP or read-in MF) if self.fcidumpfile is None and self.mf_object is None: + #if self.periodic: + # CELL self.define_basis(elems=qm_elems) - print("Building pyscf mol object") - self.mol.build() - # Defining number of basis functions - self.num_basis_functions=len(self.mol.ao_labels()) + print("Building pyscf cell object") + self.molcellobject.build() + # Defining number of basis functions + self.num_basis_functions=len(self.molcellobject.ao_labels()) + #else: + # # MOL + # self.define_basis(elems=qm_elems) + # print("Building pyscf mol object") + # self.mol.build() + # # Defining number of basis functions + # self.num_basis_functions=len(self.mol.ao_labels()) + if self.printlevel >= 1: print("Number of basis functions:", self.num_basis_functions) @@ -2625,6 +2778,12 @@ def prepare_run(self, current_coords=None, current_MM_coords=None, MMcharges=Non #DFT ##################### self.set_DFT_options() + + # Multigrid for PBC + if self.periodic: + print("PBC: using multigrid") + from pyscf.pbc.dft.multigrid import MultiGridNumInt2 + self.mf._numint = MultiGridNumInt2(self.cell) ################### #SCF CONVERGENCE @@ -2644,7 +2803,12 @@ def prepare_run(self, current_coords=None, current_MM_coords=None, MMcharges=Non ############################## #DENSITY FITTING and SGX ############################## - self.set_DF_mf_options(Grad=Grad,elems=qm_elems) + if self.periodic: + print("Periodic density fitting") + + else: + # Molecular + self.set_DF_mf_options(Grad=Grad,elems=qm_elems) ############################## #FROZEN ORBITALS in CC @@ -2658,7 +2822,7 @@ def prepare_run(self, current_coords=None, current_MM_coords=None, MMcharges=Non # PLATFORM CHANGE ############################# #Testing to convert mf object to GPU before QM/MM - if self.platform == 'GPU': + if self.platform.upper() == 'GPU': print("GPU platform requested. Will now convert mf object to GPU") self.mf = self.mf.to_gpu() ############################## @@ -2889,13 +3053,27 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, print("Gradient for postSCF methods is not implemented in ASH interface") #TODO: Enable TDDFT, CASSCF, MP2, CC gradient etc ashexit() - #Caluclate regular SCF gradient + #Calculate regular SCF gradient else: if self.printlevel >1: print("Calculating regular SCF gradient") checkpoint=time.time() - g = self.mf.nuc_grad_method() + if self.platform == "GPU": + print("Calculating gradient on GPU") + g = self.mf.Gradients() + else: + + g = self.mf.nuc_grad_method() + #g = self.mf.Gradients() self.gradient = g.kernel() + + # PBC cell gradient + if self.periodic: + self.cell_gradient = g.get_stress() + print("self.cell_gradient:", self.cell_gradient) + exit() + + print_time_rel(checkpoint, modulename='pyscf_gradient', moduleindex=2) #Applying dispersion gradient last @@ -2909,14 +3087,28 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, if PC is True: if self.printlevel >=1: print("Calculating pointcharge gradient") - #Make density matrix - checkpoint=time.time() - dm = self.mf.make_rdm1() - print_time_rel(checkpoint, modulename='pySCF make_rdm1 for PC', moduleindex=2) - current_MM_coords_bohr = current_MM_coords*ash.constants.ang2bohr + from ash.constants import ang2bohr + current_MM_coords_bohr = current_MM_coords*ang2bohr checkpoint=time.time() - self.pcgrad = pyscf_pointcharge_gradient(self.mol,np.array(current_MM_coords_bohr),np.array(MMcharges),dm, GPU=self.GPU_pcgrad) - print_time_rel(checkpoint, modulename='pyscf_pointcharge_gradient', moduleindex=2) + + if self.PC_gradient_code == "new": + print("Calculating pointcharge gradient (new way)") + checkpoint=time.time() + dm = self.mf.make_rdm1() + if dm.ndim ==3: + dm = dm[0] + g_mm_h1 = g.grad_hcore_mm(dm) + g_mm_nuc = g.grad_nuc_mm() + self.pcgrad = g_mm_h1 + g_mm_nuc + print_time_rel(checkpoint, modulename='pyscf_newpointcharge_gradient', moduleindex=2) + else: + print("Calculating pointcharge gradient (old way)") + #Make density matrix + checkpoint=time.time() + dm = self.mf.make_rdm1() + print_time_rel(checkpoint, modulename='pySCF make_rdm1 for PC', moduleindex=2) + self.pcgrad = pyscf_pointcharge_gradient(self.mol,np.array(current_MM_coords_bohr),np.array(MMcharges),dm, GPU=self.GPU_pcgrad) + print_time_rel(checkpoint, modulename='pyscf_pointcharge_gradient', moduleindex=2) if self.printlevel >1: print("Gradient calculation done") @@ -2973,15 +3165,15 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, #Uses pyscf mol and MM coords and charges and provided density matrix to get pointcharge gradient def pyscf_pointcharge_gradient(mol,mm_coords,mm_charges,dm, GPU=False): time0=time.time() - #Making sure density matrix is as it should - if dm.shape[0] == 2: - dmf = np.array(dm[0] + dm[1]) #unrestricted - else: - dmf=np.array(dm) #GPU if GPU is True: import cupy + if dm.shape[0] == 2: + dmf = cupy.asarray(dm[0] + dm[1]) #unrestricted + else: + dmf=dm + einsumfunc = cupy.einsum linalg_norm_func=cupy.linalg.norm @@ -2993,6 +3185,13 @@ def pyscf_pointcharge_gradient(mol,mm_coords,mm_charges,dm, GPU=False): array_mod=cupy.asarray #CPU else: + #if isinstance(dm, gpu4pyscf.lib.cupy_helper.CPArrayWithTag): + # print("Converting dm to CPU (as requested)") + # dm = dm.get() + if dm.shape[0] == 2: + dmf = np.array(dm[0] + dm[1]) #unrestricted + else: + dmf=np.array(dm) def dummy(f): return f array_mod=dummy einsumfunc=np.einsum @@ -3033,20 +3232,22 @@ def dummy(f): return f def pyscf_MR_correction(fragment, theory=None, MLmethod='CCSD(T)'): print_line_with_mainheader("pyscf_MR_correction") print("Multireference correction via pyscf-based theories: Dice or Block. Calculates difference w.r.t CCSD(T)") + from ash.interfaces.interface_dice import DiceTheory + from ash.interfaces.interface_block import BlockTheory #Checking that correct theory is provided if theory == None: print("Theory must be provided") ashexit() - elif isinstance(theory,ash.DiceTheory): + elif isinstance(theory,DiceTheory): print("DiceTheory object provided") - elif isinstance(theory,ash.BlockTheory): + elif isinstance(theory,BlockTheory): print("BlockTheory object provided") else: print("Unrecognized theory object provided. Must be DiceTheory or BlockTheory") ashexit() #Now calling Singlepoint on the HLTheory - result_HL = ash.Singlepoint(fragment=fragment, theory=theory) + result_HL = Singlepoint(fragment=fragment, theory=theory) ################################### #Active space CCSD or CCSD(T) via pyscf @@ -3111,7 +3312,7 @@ def make_molden_file_PySCF_from_chkfile(fragment=None, basis=None, chkfile=None, mol = pyscf.gto.Mole() #Mol system printing. Hardcoding to 3 as otherwise too much PySCF printing mol.verbose = 3 - coords_string=ash.modules.module_coords.create_coords_string(fragment.elems,fragment.coords) + coords_string=create_coords_string(fragment.elems,fragment.coords) mol.atom = coords_string mol.symmetry = None mol.charge = fragment.charge @@ -3148,7 +3349,7 @@ def pyscf_CCSD_T_natorb_selection(fragment=None, pyscftheoryobject=None, numcore #Use input PySCFTheory object for MF calculation and run pyscfcalc = pyscftheoryobject - result = ash.Singlepoint(fragment=fragment, theory=pyscfcalc) #Run a SP job using object + result = Singlepoint(fragment=fragment, theory=pyscfcalc) #Run a SP job using object #Define frozen core frozen_orbital_indices=pyscfcalc.determine_frozen_core(fragment.elems) @@ -3164,7 +3365,8 @@ def pyscf_CCSD_T_natorb_selection(fragment=None, pyscftheoryobject=None, numcore if Do_CC_active_space is True: #Select active space full_list = list(range(0,pyscfcalc.num_orbs)) - act_list = ash.select_indices_from_occupations(MP2_natocc,selection_thresholds=thresholds) + from ash.functions.functions_elstructure import select_indices_from_occupations + act_list = select_indices_from_occupations(MP2_natocc,selection_thresholds=thresholds) print("Full orbital list:", full_list) print("Size of full orbital list:", len(full_list)) print("Selected active orbital list:", act_list) @@ -3648,13 +3850,13 @@ def DFA_error_analysis(fragment=None, DFA_obj=None, REF_obj=None, DFA_DM=None, R if DFA_DM is None: print("Warning: No DFA_DM matric provided to DFA_error_analysis") print("Now doing single-point calculation using DFA_obj to get DM") - dfa_result = ash.Singlepoint = ash.Singlepoint(fragment=fragment, theory=DFA_obj) + dfa_result = Singlepoint(fragment=fragment, theory=DFA_obj) DFA_DM = DFA_obj.dm DFA_E = dfa_result.energy if REF_DM is None: print("Warning: No REF_DM matric provided to DFA_error_analysis") print("Now doing single-point calculation using REF_obj to get REF_DM") - ref_result = ash.Singlepoint = ash.Singlepoint(fragment=fragment, theory=REF_obj) + ref_result = Singlepoint(fragment=fragment, theory=REF_obj) REF_DM = REF_obj.dm if REF_E is None: @@ -3684,7 +3886,7 @@ def DFA_error_analysis(fragment=None, DFA_obj=None, REF_obj=None, DFA_DM=None, R #Not using run_SCF anymore as we may have post-SCF contributions DFA_obj.dm=ref_DM_inv DFA_obj.scf_maxiter=0 - res = ash.Singlepoint(theory=DFA_obj, fragment=fragment) + res = Singlepoint(theory=DFA_obj, fragment=fragment) #scf_result_1 = DFA_obj.run_SCF(dm=ref_DM_inv, max_cycle=0) E_DFA_nref=res.energy print("E_DFA_nref:", E_DFA_nref) diff --git a/ash/interfaces/interface_sella.py b/ash/interfaces/interface_sella.py new file mode 100644 index 000000000..c0ff86b0d --- /dev/null +++ b/ash/interfaces/interface_sella.py @@ -0,0 +1,365 @@ +import numpy as np +import copy +import shutil +import time +from ash.modules.module_coords import Fragment, print_coords_for_atoms,write_XYZ_for_atoms,write_xyzfile,write_coords_all, print_internal_coordinate_table_new +from ash.functions.functions_general import ashexit, blankline,BC, listdiff,print_time_rel,print_line_with_mainheader,print_line_with_subheader1,print_if_level +from ash.modules.module_coords import check_charge_mult, fullindex_to_actindex +from ash.modules.module_results import ASH_Results +from ash.modules.module_theory import NumGradclass +from ash.modules.module_singlepoint import Singlepoint +from ash.constants import hartoeV, bohr2ang +from ash.modules.module_QMMM import QMMMTheory + +# Sella TS optimizer +# TODO active region +# TODO PBC + + +def SellaOptimizer(theory=None, fragment=None, charge=None, mult=None, printlevel=2, NumGrad=False, + convergence_gmax=1e-4, maxiter=150, result_write_to_disk=False, + constraints=None, actatoms=None, frozenatoms=None, + gamma=0.03, eta=1e-4): + """ + Wrapper function around SellaoptimizerClass + """ + timeA=time.time() + + # EARLY EXIT + #if theory is None or fragment is None: + # print("SellaOptimizer requires theory and fragment objects provided. Exiting.") + # ashexit() + # NOTE: Class does not take fragment and theory + optimizer = SellaoptimizerClass(convergence_gmax=convergence_gmax, + printlevel=printlevel, maxiter=maxiter, result_write_to_disk=result_write_to_disk, + constraints=constraints, actatoms=actatoms, frozenatoms=frozenatoms, + gamma=gamma, eta=eta) + + # If NumGrad then we wrap theory object into NumGrad class object + if NumGrad: + print("NumGrad flag detected. Wrapping theory object into NumGrad class") + print("This enables numerical-gradient calculation for theory") + theory = NumGradclass(theory=theory) + + # Providing theory and fragment to run method. + result = optimizer.run(theory=theory, fragment=fragment, charge=charge, mult=mult) + if printlevel >= 1: + print_time_rel(timeA, modulename='Sella', moduleindex=1) + + return result + +# Class for optimization. +class SellaoptimizerClass: + def __init__(self,printlevel=2, + convergence_gmax=3e-4, maxiter=150, result_write_to_disk=False, + constraints=None, actatoms=None, frozenatoms=None, + gamma=0.03, eta=1e-4): + + self.printlevel=printlevel + print_line_with_mainheader("SellaOptimizer initialization") + print_if_level("Creating optimizer object", self.printlevel,2) + + # Input maxg tolerance in Eh/Bohr + # Converting to eV/Angstrom for Sella + self.convergence_gmax=convergence_gmax + self.tolerance_ev_ang = convergence_gmax * hartoeV / bohr2ang + self.maxiter = maxiter + self.result_write_to_disk = result_write_to_disk + self.constraints = constraints + + # Active and frozen atoms + # Check if both defined + if actatoms is not None and frozenatoms is not None: + print("Error: both active and frozen atoms defined. Please specify only one of them. Exiting.") + ashexit() + + self.frozenatoms = frozenatoms + self.actatoms = actatoms + self.gamma = gamma + self.eta = eta + + + print_if_level(f"GradMax convergence tolerance: {self.convergence_gmax} Eh/Bohr", self.printlevel, 2) + print_if_level(f"Converted tolerance for Sella: {self.tolerance_ev_ang} eV/Angstrom", self.printlevel, 2) + print_if_level(f"Maximum optimization steps: {self.maxiter}", self.printlevel, 2) + print_if_level(f"Constraints: {self.constraints}", self.printlevel, 2) + print_if_level(f"Gamma (convergence crit. for iterative eigensolver): {self.gamma}", self.printlevel, 2) + print_if_level(f"Eta (step size for iterative eigensolver): {self.eta}", self.printlevel, 2) + + # If using Active region then we define the system geometry as only + def setup_active_region_geometry(self,fragment): + + if len(self.actatoms) == 0: + print("Error: List of active atoms (actatoms) provided is empty. This is not allowed.") + ashexit() + # Sorting list, otherwise trouble + self.actatoms.sort() + print("Active Region option Active. Passing only active-region coordinates to Sella.") + print("Active atoms list:", self.actatoms) + print("Number of active atoms:", len(self.actatoms)) + + # Check that the actatoms list does not contain atom indices higher than the number of atoms + largest_atom_index = max(self.actatoms) + if largest_atom_index >= fragment.numatoms: + print(BC.FAIL,f"Found active-atom index ({largest_atom_index}) that is larger or equal (>=) than the number of atoms of system ({fragment.numatoms})!",BC.END) + print(BC.FAIL,"This does not make sense. Please provide a correct actatoms list. Exiting.",BC.END) + ashexit() + + # Get active region coordinates and elements + actcoords, actelems = fragment.get_coords_for_atoms(self.actatoms) + newfrag = Fragment(coords=actcoords, elems=actelems, charge=fragment.charge, mult=fragment.mult, printlevel=0) + return newfrag + + def setup_constraints(self, atoms, constraints, fragment): + from sella import Constraints + + sellacons = Constraints(atoms) + + # Bonds + if constraints is not None: + if 'bond' in constraints: + for bondcon in constraints['bond']: + sellacons.fix_bond(tuple(bondcon)) + # Angles + if 'angle' in constraints: + for anglecon in constraints['angle']: + sellacons.fix_angle(tuple(anglecon)) + # Dihedrals + if 'dihedral' in constraints: + for dihedralcon in constraints['dihedral']: + sellacons.fix_dihedral(tuple(dihedralcon)) + # XYZ and partial Cart constraints + if 'xyz' in constraints: + for xyzcon in constraints['xyz']: + sellacons.fix_translation(xyzcon) + elif 'xy' in constraints: + for xycon in constraints['xy']: + sellacons.fix_translation(xycon, directions=[0,1]) + elif 'x' in constraints: + for xcon in constraints['x']: + sellacons.fix_translation(xcon, directions=[0]) + elif 'y' in constraints: + for ycon in constraints['y']: + sellacons.fix_translation(ycon, directions=[1]) + elif 'z' in constraints: + for zcon in constraints['z']: + sellacons.fix_translation(zcon, directions=[2]) + elif 'yz' in constraints: + for yzcon in constraints['yz']: + sellacons.fix_translation(yzcon, directions=[1,2]) + elif 'xz' in constraints: + for xzcon in constraints['xz']: + sellacons.fix_translation(xzcon, directions=[0,2]) + + # Frozen atoms specified, same as XYZ constraint but specified differently by user + if self.frozenatoms is not None: + print("Frozen atoms specified. Adding XYZ constraints for frozen atoms:", self.frozenatoms) + for frozenatom in self.frozenatoms: + sellacons.fix_translation(index=frozenatom) + print("All Sella constraints:", sellacons) + return sellacons + + def run(self, theory=None, fragment=None, charge=None, mult=None,constraints=None): + + print_line_with_subheader1("Running Sella optimization") + from sella import Sella + import ase + + # Constraints provided to run or at initialization + if constraints is None: + constraints = self.constraints + print("constraints:", constraints) + + # Active region setup. For a big system, we have to pass only the active region geometry to Sella + if self.actatoms is not None: + self.original_fragment = copy.deepcopy(fragment) + self.active_fragment = self.setup_active_region_geometry(fragment) + print(f"Active region fragment contains {self.active_fragment.numatoms} atoms") + else: + self.original_fragment=None # + self.active_fragment = fragment + + + # Creating ASE object + fragment.printlevel=0 + atoms = ase.atoms.Atoms(self.active_fragment.elems,positions=self.active_fragment.coords) + + + # Setup constraints for Sella + sella_constraints = None + if self.constraints is not None or self.frozenatoms is not None: + sella_constraints = self.setup_constraints(atoms, constraints,fragment) + print("sella_constraints:", sella_constraints) + + # Attaching calculator + print("Creating ASH-ASE calculator") + atoms.calc = ASH_ASE_calculator(theory=theory, fragment=self.active_fragment, + full_fragment=self.original_fragment, actatoms=self.actatoms) + + # Set up a Sella Dynamics object + dyn = Sella( + atoms, constraints=sella_constraints, + gamma=self.gamma, eta=self.eta) + + def write_traj(a=atoms, trajname="sella_optim"): + print(f"Writing (active) trajectory to file: {trajname}.xyz") + self.active_fragment.coords = copy.copy(a.get_positions()) + self.active_fragment.write_xyzfile(xyzfilename=trajname+'.xyz', writemode='a') + + def write_full_traj(a=atoms, trajname="sella_optim_full"): + print(f"Writing full trajectory to file: {trajname}.xyz") + #self.original_fragment = copy.copy(a.get_positions()) + atoms.calc.full_fragment.write_xyzfile(xyzfilename=trajname+'.xyz', writemode='a') + def write_qmregion_traj(a=atoms, trajname="sella_optim_qmregion"): + print(f"Writing QM-region trajectory to file: {trajname}.xyz") + qm_elems = [atoms.calc.full_fragment.elems[i] for i in theory.qmatoms] + qm_coords = np.array([atoms.calc.full_fragment.coords[i] for i in theory.qmatoms]) + frag = Fragment(coords=qm_coords, elems=qm_elems, printlevel=0) + frag.write_xyzfile(xyzfilename=trajname+'.xyz', writemode='a') + + + # Attaching traj function + #dyn.attach(print_step, interval=1) + dyn.attach(write_traj, interval=1) + # Attaching full traj write also if using active region + if self.actatoms is not None: + dyn.attach(write_full_traj, interval=1) + if isinstance(theory, QMMMTheory): + dyn.attach(write_qmregion_traj, interval=1) + + # Running optimization step by step + for step in range(self.maxiter): + conv = dyn.run(self.tolerance_ev_ang, 1) + # print("Sella step completed. Converged?", conv) + if conv: + print("Converged") + break + if conv is False: + print() + print(f"Sella Geometry optimization did not converge in {self.maxiter} steps. Exiting.") + fragment.write_xyzfile(xyzfilename='Fragment-current.xyz') + print() + ashexit() + + # DONE + if self.printlevel >= 1: + print() + print(f"Sella Geometry optimization converged in {step+1} steps!") + print() + + finalenergy = atoms.calc.energy_eH + + if self.printlevel >= 1: + print(f"Final optimized energy: {finalenergy} Eh") + + # Writing out fragment file and XYZ file + fragment.print_system(filename='Fragment-optimized.ygg') + fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') + fragment.set_energy(finalenergy) + + + if self.actatoms is None: + print("Final geometry:") + fragment.print_coords() + print() + + #Active region XYZ-file + if self.actatoms is None: + write_XYZ_for_atoms(fragment.coords, fragment.elems, self.actatoms, + "Fragment-optimized_Active") + + #QM-region XYZ-file + if isinstance(theory,QMMMTheory): + write_XYZ_for_atoms(fragment.coords, fragment.elems, theory.qmatoms, + "Fragment-optimized_QMregion") + + # Printing internal coordinate table + if self.printlevel >= 2: + if fragment.numatoms < 50: + print_internal_coordinate_table_new(fragment,actatoms=fragment.allatoms) + print() + + # Now returning final Results object + # Note: could include the geometry in object but can be very large causing printing head-aches on screen, + # ignoring for now since the geometry is in the Fragment object anyway + result = ASH_Results(label="SellaOptimizer", energy=finalenergy) + if self.result_write_to_disk is True: + result.write_to_disk(filename="SellaOptimizer.result") + return result + + +# Simpler ASH-ASE calculator +class ASH_ASE_calculator: + def __init__(self, theory=None, fragment=None, full_fragment=None, actatoms=None): + self.theory = theory + # Used for elems, charge and mult + self.fragment = fragment + self.full_fragment = full_fragment + self.actatoms = actatoms + self.forcecalls = 0 + self.forces = None + self.energycalls = 0 + self.energy_eH = None + self.energy_eV = None + self.gradient = None + # Initializing coordinates used by Sella + self.coords=fragment.coords + + def get_potential_energy(self, atomsobj): + #print("Called ASHcalc get_potential_energy") + #print("Energy call number:", self.energycalls) + self.energycalls += 1 + # Have coordinates changed? + if np.array_equal(atomsobj.get_positions(), self.coords): + if self.energy_eV is not None: + # Returning old energy + return self.energy_eV + else: + print("No energy available (1st step?). Will do calculation") + # ? + exit() + return self.energy_eV + + def get_forces(self, atomsobj): + #print("Force call number:", self.forcecalls) + self.forcecalls+=1 + #print("Called ASHcalc get_forces") + # Have coordinates changed? + if np.array_equal(atomsobj.get_positions(), self.coords): + if self.forces is not None: + return self.forces + else: + print("Running first E+G calculation") + print("Note: following Sella printout units are in eV and eV/Ang") + #print("Will calculate new forces") + + # Copying current active coordinates from Atoms object + self.coords = copy.copy(atomsobj.positions) + # Updating fragment geometry with new active region geometry from Sella + self.fragment.coords = copy.copy(self.coords) + + # Active region or not + if self.actatoms is not None: + + #Replacing act-region coordinates in full_coords with coords from currcoords + self.full_fragment.coords[self.actatoms] = self.coords + + # Computing E+G of full system + energy, fullgrad = self.theory.run(current_coords=self.full_fragment.coords, elems=self.full_fragment.elems, + charge=self.full_fragment.charge, mult=self.full_fragment.mult, Grad=True) + # Extracting active region gradient from full gradient + Grad_act = np.array([fullgrad[i] for i in self.actatoms]) + self.gradient = Grad_act + self.forces = -Grad_act * 51.4220674763 + # No active region + else: + energy, gradient = self.theory.run(current_coords=self.coords, elems=self.fragment.elems, + charge=self.fragment.charge, mult=self.fragment.mult, Grad=True) + self.gradient = gradient + self.forces = -gradient * 51.4220674763 + + # Energy + self.energy_eH = energy + self.energy_eV = energy*hartoeV + + return self.forces diff --git a/ash/interfaces/interface_torch.py b/ash/interfaces/interface_torch.py index 6b3621583..6ee33240c 100644 --- a/ash/interfaces/interface_torch.py +++ b/ash/interfaces/interface_torch.py @@ -2,8 +2,10 @@ import numpy as np from ash.modules.module_coords import elemstonuccharges +from ash.modules.module_coords_PBC import cell_vectors_to_params, cell_params_to_vectors from ash.functions.functions_general import ashexit, BC,print_time_rel from ash.functions.functions_general import print_line_with_mainheader +from ash.interfaces.interface_mace import stress_to_grad import ash.constants # TODO: Make sure energy is a general thing in PyTorch model @@ -14,7 +16,8 @@ class TorchTheory(): def __init__(self, filename="torch.pt", model_name=None, model_object=None, model_file=None, printlevel=2, label="TorchTheory", numcores=1, - platform=None, train=False, aimnet_mode="new"): + platform="cpu", train=False, aimnet_mode="new", + periodic=False, periodic_cell_vectors=None, periodic_cell_dimensions=None): # Early exits try: import torch @@ -40,9 +43,10 @@ def __init__(self, filename="torch.pt", model_name=None, model_object=None, self.printlevel = printlevel print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") - # Device choice + # Device choice + self.platform=platform if platform == 'cuda': - print("Platfrom CUDA selected. Will attempt to use.") + print("Platform CUDA selected. Will attempt to use.") self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') elif platform == 'mps': print("Platfrom MPS selected. Will use.") @@ -52,6 +56,28 @@ def __init__(self, filename="torch.pt", model_name=None, model_object=None, self.device = torch.device('cpu') print("Torch device selected:", self.device) + # PBC + self.periodic=periodic + self.periodic_cell_vectors=None # initially + self.stress=False + if self.periodic: + print("PBC enabled in Torchtheory") + self.stress=True + if periodic_cell_vectors is None and periodic_cell_dimensions is None: + print("Error: for periodic calculations, you must specify either periodic_cell_vectors or periodic_cell_dimensions") + ashexit() + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + elif periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions = periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + print("Cell vectors:", self.periodic_cell_vectors) + print("Cell dimensions:", self.periodic_cell_dimensions) + ################################ # Model selection ################################ @@ -72,7 +98,7 @@ def __init__(self, filename="torch.pt", model_name=None, model_object=None, self.load_aimnet_model(model_file=model_file, aimnet_mode=self.aimnet_mode) else: # - self.model = torch.load(model_file, map_location=torch.device('cpu')) + self.model = torch.load(model_file, map_location=torch.device(self.device)) #torch.load_state_dict(model_file) #If TorchScript saved @@ -105,6 +131,18 @@ def __init__(self, filename="torch.pt", model_name=None, model_object=None, if train is True: print("Training will be done") + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + def get_cell_gradient(self): + return self.cell_gradient def cleanup(self): print("No cleanup implemented") @@ -123,7 +161,7 @@ def load_model(self,model_file): import torch # sTODO: weights only option ? #self.model = torch.jit.load(model_file) - self.model = torch.load(model_file, map_location=torch.device('cpu')) + self.model = torch.load(model_file, map_location=torch.device(self.device)) def save_model(self,filename=None, index=None): import torch @@ -155,10 +193,10 @@ def load_aimnet_model(self,model_name=None, model_file=None, aimnet_mode="old"): print("Model:", model_name) print("File:", model_file) if model_name is not None: - self.model = AIMNet2Calculator(str(model_name).lower()) + self.model = AIMNet2Calculator(str(model_name).lower(), device=self.platform) elif model_file is not None: print("Loading file:", model_file) - self.model = AIMNet2Calculator(model_file) + self.model = AIMNet2Calculator(model_file, device=self.platform) else: print("Error: Unknown model and no model_file selected") ashexit() @@ -179,6 +217,10 @@ def load_aimnet_model(self,model_name=None, model_file=None, aimnet_mode="old"): print("File:", model_file) self.model = AIMNet2ASE(model_name) + # Changing device + self.model.base_calc.device=self.platform + print("device used:", self.model.base_calc.device) + def load_ani_model(self,model): print("ANI-type model requested") print("Models available: ANI1ccx, ANI1x and ANI2x") @@ -261,7 +303,11 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # new aimnet2 if 'aimnet2' in str(self.model).lower() and self.aimnet_mode =="new": import ase - atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) + if self.periodic: + atoms = ase.atoms.Atoms(qm_elems,positions=current_coords, cell=self.periodic_cell_vectors, + pbc=True) + else: + atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) # Assigning calculator #Setting charge and mult in model self.model.charge=charge @@ -280,6 +326,10 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el forces = atoms.get_forces() self.gradient = forces/-51.422067090480645 + if self.stress: + stress_ev_ang3 = atoms.get_stress(voigt=False) + self.cell_gradient = stress_to_grad(stress_ev_ang3,atoms.get_volume(), atoms.get_cell()) + print("Cell gradient:", self.cell_gradient) # TorchANI else: # Converting coordinates and element information to Torch tensors diff --git a/ash/interfaces/interface_veloxchem.py b/ash/interfaces/interface_veloxchem.py new file mode 100644 index 000000000..2b40f9a8c --- /dev/null +++ b/ash/interfaces/interface_veloxchem.py @@ -0,0 +1,99 @@ +import subprocess as sp +import os +import shutil +import time +import numpy as np +import pathlib +from ash.modules.module_theory import Theory +from ash.functions.functions_general import ashexit, BC, print_time_rel,print_line_with_mainheader, writestringtofile +from ash.modules.module_coords import nucchargelist +from ash.modules.module_coords_PBC import cell_vectors_to_params, cell_params_to_vectors +import ash.settings_ash +from ash.functions.functions_parallel import check_OpenMPI + +# Veloxchem Theory object. + +class VeloxchemTheory(Theory): + def __init__(self, scf_type="restricted", xcfun=None, basis=None): + super().__init__() + + self.theorynamelabel="Veloxchem" + + try: + import veloxchem as vlx + except: + print("Error: Veloxchem could not be imported") + ashexit() + + if scf_type == "restricted": + print("Creating ScfRestrictedDriver") + self.scf_drv = vlx.ScfRestrictedDriver() + elif scf_type == "unrestricted": + print("Creating ScfUnestrictedDriver") + self.scf_drv = vlx.ScfUnrestrictedDriver() + elif scf_type == "restrictedopen": + print("Creating ScfRestrictedOpenDriver") + self.scf_drv = vlx.ScfRestrictedOpenDriver() + self.scf_drv.filename = "vlx_output" + # basis name + self.basis=basis + + #if xcfun is not None: + # print("Setting xcfun to:", xcfun) + # scf_drv.xcfun = xcfun + + + def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, + elems=None, Grad=False, PC=False, numcores=None, restart=False, label=None, Hessian=False, + charge=None, mult=None): + import veloxchem as vlx + module_init_time=time.time() + if numcores is None: + numcores = self.numcores + + print(BC.OKBLUE, BC.BOLD, f"------------RUNNING {self.theorynamelabel} INTERFACE-------------", BC.END) + # Checking if charge and mult has been provided + if charge is None or mult is None: + print(BC.FAIL, f"Error. charge and mult has not been defined for {self.theorynamelabel}Theory.run method", BC.END) + ashexit() + + print("Job label:", label) + + # Coords provided to run + if current_coords is not None: + pass + else: + print("no current_coords") + ashexit() + + # What elemlist to use. If qm_elems provided then QM/MM job, otherwise use elems list + if qm_elems is None: + if elems is None: + print("No elems provided") + ashexit() + else: + qm_elems = elems + + # veloxchem molecule + #lines = [str(len(qm_elems)), "title"] + #lines += [f"{el} {x:.6f} {y:.6f} {z:.6f}" for el, (x, y, z) in zip(qm_elems, current_coords)] + #xyz_string = "\n".join(lines) + #molecule = vlx.Molecule.read_xyz_string(xyz_string) + + # Creating molecule + molecule = vlx.Molecule(qm_elems, current_coords, units='angstrom', charge=charge, mult=mult) + molecule.print_keywords() + # Creating basis set object + basis = vlx.MolecularBasis.read(molecule, self.basis) + + scf_results = self.scf_drv.compute(molecule, basis) + print("scf_results:",scf_results) + self.energy=None + self.gradient=None + # Grad + + if Grad: + return self.energy,self.gradient + + else: + return self.energy \ No newline at end of file diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 01ad17d22..84aeb18c3 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -8,36 +8,66 @@ import ash.settings_solvation import ash.settings_ash from ash.modules.module_theory import Theory -from ash.modules.module_coords import write_xyzfile from ash.functions.functions_general import ( ashexit, blankline, reverse_lines, print_time_rel, BC, print_line_with_mainheader, print_if_level ) import ash.modules.module_coords from ash.modules.module_coords import ( + write_xyzfile, elemstonuccharges, check_multiplicity, check_charge_mult ) +from ash.modules.module_coords_PBC import cell_params_to_vectors, cell_vectors_to_params # Interface to the preliminary g-xTB implementation (warning: only numerical gradient) class gxTBTheory(Theory): - def __init__(self, method=None, printlevel=2, numcores=1): + def __init__(self, gxtbdir=None, method=None, printlevel=2, numcores=1): super().__init__() self.theorynamelabel = "gxtb" + self.theorytype="QM" self.printlevel = printlevel + self.analytic_hessian=False + print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") # Check if gxtb in PATH + if gxtbdir is None: + print(BC.WARNING, "No gxtbdir argument passed to gxTBTheory. Attempting to find gxtbdir variable inside settings_ash", BC.END) + try: + print("settings_ash.settings_dict:", ash.settings_ash.settings_dict) + self.gxtbdir=ash.settings_ash.settings_dict["gxtbdir"] + except: + print(BC.WARNING,"Found no gxtbdir variable in ash.settings_ash module either.",BC.END) + try: + self.gxtbdir = os.path.dirname(shutil.which('gxtb')) + print( + BC.OKGREEN, + "Found gxtb in path. Setting gxtbdir to:", + self.gxtbdir, + BC.END + ) + except: + print("Found no gxtb executable in path. Exiting... ") + ashexit() + else: + self.gxtbdir = gxtbdir + + # Setting GXTBHOME + os.environ['GXTBHOME'] = self.gxtbdir + + print("Warning: Interface is hardcoded to assume that gxtb executable and .gxtb, .eeq and .basisq files are all present in gxtbdir. Make sure these are present. Interface will exit if not.") + print("gxtbdir has been set to:", self.gxtbdir) + # Checking if required gxtb files are present in gxtbdir from pathlib import Path - home = Path.home() - if os.path.isfile(f"{home}/.gxtb") is False: - print("~/.gxtb file does not exist") + if os.path.isfile(f"{self.gxtbdir}/.gxtb") is False: + print(f"{self.gxtbdir}/.gxtb file does not exist") ashexit() - if os.path.isfile(f"{home}/.eeq") is False: - print("~/.eeq file does not exist") + if os.path.isfile(f"{self.gxtbdir}/.eeq") is False: + print(f"{self.gxtbdir}/.eeq file does not exist") ashexit() - if os.path.isfile(f"{home}/.basisq") is False: - print("~/.basisq file does not exist") + if os.path.isfile(f"{self.gxtbdir}/.basisq") is False: + print(f"{self.gxtbdir}/.basisq file does not exist") ashexit() @@ -48,6 +78,12 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el module_init_time=time.time() numatoms=len(current_coords) write_xyzfile(elems, current_coords, "gxtb", printlevel=2, writemode='w', title="title") + + # Writing Charge and Multiplicity to files + with open(".CHRG", "w") as f: + f.write(f"{charge}\n") + with open(".UHF", "w") as f: + f.write(f"{mult-1}\n") command_list=["gxtb", "-c", "gxtb.xyz"] if Grad: @@ -75,11 +111,15 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el class xTBTheory: def __init__(self, xtbdir=None, xtbmethod='GFN1', runmode='inputfile', numcores=1, printlevel=2, filename='xtb_', maxiter=500, electronic_temp=300, label=None, accuracy=0.1, hardness_PC=1000, solvent=None, - use_tblite=False, periodic=False, periodic_cell_dimensions=None, extraflag=None, grab_charges=False): + use_tblite=False, periodic=False, periodic_cell_dimensions=None, periodic_cell_vectors=None, + extraflag=None, grab_charges=False, + grab_BOs=False): - self.theorynamelabel="xTB" self.theorytype="QM" + self.theorynamelabel="xTB" + self.label=label self.analytic_hessian=False + print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") # Hardness of pointcharge. GAM factor. Big number means PC behaviour self.hardness=hardness_PC @@ -96,17 +136,35 @@ def __init__(self, xtbdir=None, xtbmethod='GFN1', runmode='inputfile', numcores= # Passing special extra flag to xtb binary self.extraflag=extraflag - # Grab xTB charges in every run if chosen + # Grab xTB charges in every run if enabled self.grab_charges=grab_charges + + # Grab Bond orders (Wiberg BOs) in every run if enabled + self.grab_BOs=grab_BOs + self.BOs=None self.periodic=periodic self.periodic_cell_dimensions=periodic_cell_dimensions if self.periodic is True: print("Periodic boundary conditions enabled. Will pass periodicity information to xtb") - if self.periodic_cell_dimensions is None: - print("Error: No periodic_cell_dimensions was passed yet periodic is True") - print("Please pass a list of box dimensions and angles as a list, e.g. periodic_cell_dimensions=[20.0,20.0,20.0, 90.0, 90.0,90.0]") + if self.use_tblite is False: + self.use_tblite=True + print("Warning: PBC requires use of tblite library, enabling use_tblite and continuing.") + # What information to use + if periodic_cell_dimensions is None and periodic_cell_vectors is None: + print("Error: If periodic is True, either periodic_cell_dimensions or periodic_cell_vectors need to be set") + print("periodic_cell_dimensions: (a,b,c,alpha,beta,gamma) in units of Å and °") + print("periodic_cell_vectors: 3x3 array in units of Å") + ashexit() + elif periodic_cell_dimensions is not None and periodic_cell_vectors is not None: + print("Error: periodic_cell_dimensions and periodic_cell_vectors can not both be set") ashexit() + elif periodic_cell_dimensions is not None: + print("periodic_cell_dimensions:", periodic_cell_dimensions) + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + elif periodic_cell_vectors is not None: + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) # Controlling output in xtb-library if self.printlevel >= 3: @@ -259,8 +317,7 @@ def Hessian(self, fragment=None, Hessian=None, numcores=None, label=None, charge # Write turbomole style coord file but in Angstrom and with PBC info coordfile="xtb_coord" ash.interfaces.interface_Turbomole.create_coord_file(elems,current_coords, write_unit='ANGS', - periodic_info=self.periodic_cell_dimensions, filename=coordfile) - + periodic_info=self.periodic_cell_dimensions, filename=coordfile) else: # Write xyz_file if molecule ash.modules.module_coords.write_xyzfile(elems, current_coords, self.filename, printlevel=self.printlevel) @@ -369,13 +426,29 @@ def Opt(self, fragment=None, Grad=None, Hessian=None, numcores=None, label=None, fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') # Printing internal coordinate table - ash.modules.module_coords.print_internal_coordinate_table(fragment) + ash.modules.module_coords.print_internal_coordinate_table_new(fragment) print_time_rel(module_init_time, modulename='xtB Opt-run', moduleindex=2) return - + #Method to grab dipole moment from an xtb outputfile (assumes run has been executed) def get_dipole_moment(self): return grab_dipole_moment(self.filename+'.out') + + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + def get_cell_gradient(self): + return self.cell_gradient + def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, printlevel=None, elems=None, Grad=False, PC=False, numcores=None, label=None, charge=None, mult=None): module_init_time=time.time() @@ -401,7 +474,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el ashexit() # Checking if charge and mult has been provided - if charge == None or mult == None: + if charge is None or mult is None: print(BC.FAIL, "Error. charge and mult has not been defined for xTBTheory.run method", BC.END) ashexit() @@ -461,10 +534,17 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el if self.grab_charges: # Reading default xTB charges from file charges self.charges = grabatomcharges_xTB() + if self.grab_BOs: + # Reading default xTB charges from file charges + self.BOs = grab_bondorder_matrix(len(qm_elems)) + - # Check if finished. Grab energy + # Check if finished. Grab energy, gradient, pcgradient, cellgradient if Grad is True: self.energy,self.grad=xtbgradientgrab(num_qmatoms) + if self.periodic: + self.cell_gradient = grab_latticegrad() + print("cell_gradient:", self.cell_gradient) if PC is True: # Grab pointcharge gradient. i.e. gradient on MM atoms from QM-MM elstat interaction. self.pcgrad = xtbpcgradientgrab(num_mmatoms) @@ -535,7 +615,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el if self.calcobject == None: print("Creating new xTB calc object") # Storing number of elements - self.stored_numatoms=len(qm_elems_numbers) + self.stored_atoms_sum=sum(qm_elems_numbers) self.calcobject = Calculator(param_method, qm_elems_numbers, coords_au, charge=charge, uhf=mult-1) self.calcobject.set_verbosity(self.verbosity) self.calcobject.set_electronic_temperature(self.electronic_temp) @@ -549,11 +629,11 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el else: if self.printlevel >= 2: print("Updating coordinates in xTB calcobject") - if len(coords_au) != self.stored_numatoms: - print("Warning: Number of coordinates not consistent with previous elements.") + if sum(qm_elems_numbers) != self.stored_atoms_sum: + print("Warning: Coordinates not consistent with previous elements.") print("Creating new xTB calc object") # Storing number of elements - self.stored_numatoms=len(qm_elems_numbers) + self.stored_atoms_sum=sum(qm_elems_numbers) self.calcobject = Calculator(param_method, qm_elems_numbers, coords_au, charge=charge, uhf=mult-1) self.calcobject.set_verbosity(self.verbosity) self.calcobject.set_electronic_temperature(self.electronic_temp) @@ -724,6 +804,7 @@ def run_xtb_SP(xtbdir, xtbmethod, coordfile, charge, mult, Grad=False, Opt=False else: tblite_flag="" + # Optional extraflag if extraflag is None: extraflag="" @@ -743,12 +824,18 @@ def run_xtb_SP(xtbdir, xtbmethod, coordfile, charge, mult, Grad=False, Opt=False xtbembed_line1="" xtbembed_line2="" + gxtb=False if 'GFN2' in xtbmethod.upper(): xtbflag = 2 elif 'GFN1' in xtbmethod.upper(): xtbflag = 1 elif 'GFN0' in xtbmethod.upper(): xtbflag = 0 + elif 'GFNFF' in xtbmethod.upper(): + print("GFN-FF has been chosen") + elif 'GXTB' in xtbmethod.upper() or 'G-XTB' in xtbmethod.upper() : + print("g-xtb has been chosen") + gxtb = True else: print(f"Unknown xtbmethod chosen ({xtbmethod}). Exiting...") ashexit() @@ -768,8 +855,17 @@ def run_xtb_SP(xtbdir, xtbmethod, coordfile, charge, mult, Grad=False, Opt=False else: jobflag="" #NOTE. - command_list=[xtbdir + '/xtb', coordfile, '--gfn', str(xtbflag), jobflag, '--chrg', str(charge), '--uhf', str(uhf), '--iterations', str(maxiter), tblite_flag, spinpol_flag, - '--etemp', str(electronic_temp), '--acc', str(accuracy), '--parallel', str(numcores), solvent_line1, solvent_line2, xtbembed_line1, xtbembed_line2, extraflag] + if 'GFNFF' in xtbmethod.upper(): + command_list=[xtbdir + '/xtb', coordfile, '--gfnff', jobflag, '--chrg', str(charge), '--uhf', str(uhf), '--iterations', str(maxiter), tblite_flag, spinpol_flag, + '--etemp', str(electronic_temp), '--acc', str(accuracy), '--parallel', str(numcores), solvent_line1, solvent_line2, xtbembed_line1, xtbembed_line2, extraflag] + elif gxtb: + + command_list=[xtbdir + '/xtb', coordfile, '--gxtb', jobflag, '--chrg', str(charge), '--uhf', str(uhf), '--iterations', str(maxiter), tblite_flag, spinpol_flag, + '--etemp', str(electronic_temp), '--acc', str(accuracy), '--parallel', str(numcores), solvent_line1, solvent_line2, xtbembed_line1, xtbembed_line2, extraflag] + else: + command_list=[xtbdir + '/xtb', coordfile, '--gfn', str(xtbflag), jobflag, '--chrg', str(charge), '--uhf', str(uhf), '--iterations', str(maxiter), tblite_flag, spinpol_flag, + '--etemp', str(electronic_temp), '--acc', str(accuracy), '--parallel', str(numcores), solvent_line1, solvent_line2, xtbembed_line1, xtbembed_line2, extraflag] + # Remove empty arguments command_list=list(filter(None, command_list)) @@ -988,6 +1084,21 @@ def grabatomcharges_xTB(): charges.append(float(line.split()[0])) return charges +def grab_latticegrad(file="gradlatt"): + gradient = np.zeros((3, 3)) + counter=0 + with open(file) as f: + for i,line in enumerate(f): + if '$end' in line: + break + if i >= 5: + gradient[counter,0] = line.split()[0] + gradient[counter,1] = line.split()[1] + gradient[counter,2] = line.split()[2] + counter+=1 + return gradient + + #Grab xTB charges from outputfile. Choice between Mulliken and CM5 def grabatomcharges_xTB_output(filename, chargemodel="CM5"): @@ -1023,3 +1134,205 @@ def grab_dipole_moment(outfile): if ' dipole moment from electron density' in line: grab=True return dipole_moment + +def grab_bondorder_matrix(numatoms): + BO = np.zeros((numatoms, numatoms)) + with open("wbo") as f: + lines=f.readlines() + for l in lines: + i,j,b=l.split() + BO[int(i)-1,int(j)-1] = float(b) + BO[int(j)-1,int(i)-1] = float(b) + return BO + + + +#TODO: +# periodic +# PCs + +# TODO : file-restart capability via npz + +# Interface to tbliteTheory +class tbliteTheory(Theory): + def __init__(self, method=None, printlevel=2, numcores=1, spinpol=False, solvation_method=None, solvent_name=None, solvent_eps=None, + maxiter=500, electronic_temp=9.5e-4, accuracy=1.0, grab_BOs=False, grab_charges=False, grab_DM=False, autostart=True, + periodic=False, periodic_cell_dimensions=None, periodic_cell_vectors=None): + super().__init__() + print_line_with_mainheader("tblite INTERFACE") + print("method:", method) + self.theorytype="QM" + self.analytic_hessian=False + self.theorynamelabel = "tblite" + self.printlevel = printlevel + self.method=method + + # + self.accuracy=accuracy + self.maxiter=maxiter + self.electronic_temp=electronic_temp + self.spinpol=spinpol + # Solvation + self.solvation_method=solvation_method + self.solvent_name=solvent_name + self.solvent_eps=solvent_eps + + # + self.grab_BOs=grab_BOs + self.grab_charges=grab_charges + self.grab_DM=grab_DM + + + # Autostart + self.autostart=autostart + # Results. Used to store results after run, can be used to restart + # Initially None, will be set after run + self.results=None + + # Parallelization + print("Setting number of cores for tblite to: OMP_NUM_THREADS=", numcores) + os.environ['OMP_NUM_THREADS'] = str(numcores) + + # Periodic boundary conditions + self.periodic=periodic + if self.periodic: + print("Periodic boundary conditions enabled") + self.periodic_dims=np.array([True,True,True]) + if periodic_cell_dimensions is None and periodic_cell_vectors is None: + print("Error: If periodic is True, either periodic_cell_dimensions or periodic_cell_vectors need to be set") + print("periodic_cell_dimensions: (a,b,c,alpha,beta,gamma) in units of Å and °") + print("periodic_cell_vectors: 3x3 array in units of Å") + ashexit() + elif periodic_cell_dimensions is not None and periodic_cell_vectors is not None: + print("Error: periodic_cell_dimensions and periodic_cell_vectors can not both be set") + print("periodic_cell_dimensions: (a,b,c,alpha,beta,gamma) in units of Å and °") + print("periodic_cell_vectors: 3x3 array in units of Å") + ashexit() + elif periodic_cell_dimensions is not None: + print("periodic_cell_dimensions:", periodic_cell_dimensions) + # Convert to cell vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + else: + self.periodic_cell_vectors = periodic_cell_vectors + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + # Note: using cell vectors + print("Cell vectors (Å)", self.periodic_cell_vectors) + try: + import tblite + except Exception as e: + print("Problem importing tblite library. Have you installed tblite properly ?") + print("See: https://github.com/tblite/tblite") + print("Installation might be done like this:") + print(" mamba install tblite") + print(" mamba install tblite-python") + print("Full error message:", e) + ashexit(code=9) + # Update cell using either periodic_cell_vectors or periodic_cell_dimensions + def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): + print("Updating cell vectors") + if periodic_cell_vectors is not None: + self.periodic_cell_vectors = periodic_cell_vectors + + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) + elif periodic_cell_dimensions is not None: + self.periodic_cell_dimensions=periodic_cell_dimensions + + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + + def get_cell_gradient(self): + return self.cell_gradient + + def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, + elems=None, Grad=False, PC=False, numcores=None, restart=False, label=None, + charge=None, mult=None): + + module_init_time=time.time() + import tblite.interface as tb + + # Checking if charge and mult has been provided + if charge is None or mult is None: + print(BC.FAIL, "Error. charge and mult has not been defined for tbliteTheory.run method", BC.END) + ashexit() + + # What elemlist to use. If qm_elems provided then QM/MM job, otherwise use elems list + if qm_elems is None: + if elems is None: + print("No elems provided") + ashexit() + else: + qm_elems = elems + + #Preparing coords + coords_au=np.array(current_coords)*ash.constants.ang2bohr + qm_elems_numbers=np.array(elemstonuccharges(qm_elems)) + + # Creating xtb calculator object + # TODO: Update object instead of creating new every time + if self.periodic is True: + # Changing units from Ang to Bohr + xtb = tb.Calculator(self.method, qm_elems_numbers, coords_au, charge=charge, uhf=mult-1, + lattice=self.periodic_cell_vectors * ash.constants.ang2bohr, periodic=self.periodic_dims) + else: + xtb = tb.Calculator(self.method, qm_elems_numbers, coords_au, charge=charge, uhf=mult-1) + + # set attributes + xtb.set("max-iter", self.maxiter) + xtb.set("temperature", self.electronic_temp) + xtb.set("accuracy", self.accuracy) + xtb.set("verbosity",self.printlevel) + # Spinpolarization + if self.spinpol: + print("activating spin polarization") + xtb.add("spin-polarization") + # Solvation + if self.solvation_method is not None: + print("activating solvation method:", self.solvation_method) + if 'alpb' in self.solvation_method.lower(): + print("ALPB solvation model with solvent:", self.solvent_name) + xtb.add("alpb-solvation", self.solvent_name) + elif 'gbsa' in self.solvation_method.lower(): + print("GBSA solvation model with solvent:", self.solvent_name) + xtb.add("gbsa-solvation", self.solvent_name) + elif 'cpcm' in self.solvation_method.lower(): + print("CPCM solvation model with eps:", self.solvent_eps) + xtb.add("cpcm-solvation", self.solvent_eps) + + #Run + if self.autostart is True and self.results is not None: + print_if_level("Auto-starting tblite calculation using previous results object", self.printlevel,2) + print_if_level("Warning: if this leads to problems, set autostart=False in tbliteTheory", self.printlevel,2) + self.results = xtb.singlepoint(self.results) + else: + print("Starting new tblite singlepoint calculation") + self.results = xtb.singlepoint() + + + self.energy = self.results.get("energy") + print("Tblite energy:", self.energy) + # Periodic + if self.periodic: + # Grab the virial + virial = self.results.get("virial") + # Convert virial to cell gradient + self.cell_gradient = np.dot(virial,np.linalg.inv(self.periodic_cell_vectors * ash.constants.ang2bohr).T) + print("cell_gradient", self.cell_gradient) + #Charges + if self.grab_charges: + self.charges = self.results.get("charges") + #Bond orders + if self.grab_BOs: + self.BOs = self.results.get("bond-orders") + #DM + if self.grab_DM: + self.DM = self.results.get("density-matrix") + + #Gradient + if Grad: + self.gradient = self.results.get("gradient") + + if Grad: + print_time_rel(module_init_time, modulename='tblite run', moduleindex=2, currprintlevel=self.printlevel, currthreshold=1) + return self.energy, self.gradient + else: + print_time_rel(module_init_time, modulename='tblite run', moduleindex=2, currprintlevel=self.printlevel, currthreshold=1) + return self.energy \ No newline at end of file diff --git a/ash/knarr/KNARRatom/utilities.py b/ash/knarr/KNARRatom/utilities.py index c67ebf87f..42e2890cb 100755 --- a/ash/knarr/KNARRatom/utilities.py +++ b/ash/knarr/KNARRatom/utilities.py @@ -275,6 +275,7 @@ def MakeEulerRotation(r, phi, theta, psi): def Convert1To3(ndim, rxyz): #Rb. py3 conversion. int instead of float + rxyz = np.asarray(rxyz).ravel() # rnew = np.zeros(shape=(int(ndim / 3), 3)) ind = 0 for i in range(0, ndim, 3): diff --git a/ash/knarr/KNARRcalculator/utilities.py b/ash/knarr/KNARRcalculator/utilities.py index 54271b82d..f71da2bef 100755 --- a/ash/knarr/KNARRcalculator/utilities.py +++ b/ash/knarr/KNARRcalculator/utilities.py @@ -152,10 +152,10 @@ def GetAllConfigDistances(ndim, rxyz, dr = DMIC(3, dr, pbc, cell) dist = np.sqrt(dr[0] * dr[0] + dr[1] * dr[1] + dr[2] * dr[2]) - rcurr_dist[atom0, atom1] = dist - rcurr_dx[atom0, atom1] = dr[0] - rcurr_dy[atom0, atom1] = dr[1] - rcurr_dz[atom0, atom1] = dr[2] + rcurr_dist[atom0, atom1] = dist.item() + rcurr_dx[atom0, atom1] = dr[0].item() + rcurr_dy[atom0, atom1] = dr[1].item() + rcurr_dz[atom0, atom1] = dr[2].item() return rcurr_dist, rcurr_dx, rcurr_dy, rcurr_dz diff --git a/ash/knarr/KNARRio/io.py b/ash/knarr/KNARRio/io.py index 90a7003a9..eb1e45a14 100755 --- a/ash/knarr/KNARRio/io.py +++ b/ash/knarr/KNARRio/io.py @@ -125,12 +125,12 @@ def WriteXYZ(fname, ndim, rxyz, symb, energy=None): with open(fname, "w") as f: f.write(str(ndim / 3) + '\n') if energy is not None: - f.write('E=%12.8lf\n' % energy) + f.write('E=%12.8lf\n' % energy.item()) else: f.write('\n') for j in range(0, ndim, 3): - f.write('%s %12.8f %12.8f %12.8f\n' % (symb[j], rxyz[j + 0], rxyz[j + 1], rxyz[j + 2])) + f.write('%s %12.8f %12.8f %12.8f\n' % (symb[j], rxyz[j + 0].item(), rxyz[j + 1].item(), rxyz[j + 2].item())) return None @@ -143,16 +143,16 @@ def WriteXYZF(fname, ndim, rxyz, symb, energy=None, fxyz=None): with open(fname, "w") as f: f.write(str(ndim / 3) + '\n') if energy is not None: - f.write('E=%12.8lf\n' % energy) + f.write('E=%12.8lf\n' % energy.item()) else: f.write('\n') if fxyz is None: for j in range(0, ndim, 3): - f.write('%s %12.8f %12.8f %12.8f\n' % (symb[j], rxyz[j + 0], rxyz[j + 1], rxyz[j + 2])) + f.write('%s %12.8f %12.8f %12.8f\n' % (symb[j], rxyz[j + 0].item(), rxyz[j + 1].item(), rxyz[j + 2].item())) else: for j in range(0, ndim, 3): - f.write('%s %12.8f %12.8f %12.8f %12.8f %12.8f %12.8f\n' % (symb[j], rxyz[j + 0], rxyz[j + 1], rxyz[j + 2], - fxyz[j + 0], fxyz[j + 1], fxyz[j + 2])) + f.write('%s %12.8f %12.8f %12.8f %12.8f %12.8f %12.8f\n' % (symb[j], rxyz[j + 0].item(), rxyz[j + 1].item(), rxyz[j + 2].item(), + fxyz[j + 0].item(), fxyz[j + 1].item(), fxyz[j + 2].item())) return None @@ -308,11 +308,11 @@ def WritePath(fname, ndimIm, nim, rxyz, symb, energy=None): if energy is None: f.write('\n') else: - f.write('% 8.6f \n' % energy[i]) + f.write('% 8.6f \n' % energy[i].item()) for j in range(0, ndimIm, 3): z = i * ndimIm + j - f.write('%2s % 12.8f % 12.8f % 12.8f\n' % (symb[z], rxyz[z], rxyz[z + 1], rxyz[z + 2])) + f.write('%2s % 12.8f % 12.8f % 12.8f\n' % (symb[z], rxyz[z].item(), rxyz[z + 1].item(), rxyz[z + 2].item())) return None @@ -323,11 +323,11 @@ def WriteTraj(fname, ndimIm, nim, rxyz, symb, energy=None): if energy is None: f.write('\n') else: - f.write('%8.6lf \n' % energy[i]) + f.write('%8.6lf \n' % energy[i].item()) for j in range(0, ndimIm, 3): z = i * ndimIm + j - f.write('%2s % 12.8f % 12.8f % 12.8f\n' % (symb[z], rxyz[z], rxyz[z + 1], rxyz[z + 2])) + f.write('%2s % 12.8f % 12.8f % 12.8f\n' % (symb[z], rxyz[z].item(), rxyz[z + 1].item(), rxyz[z + 2].item())) return None @@ -336,7 +336,7 @@ def WriteSingleImageTraj(fname, ndim, rxyz, symb, E): f.write(str(ndim / 3) + '\n') f.write('%8.6lf \n' % E) for j in range(0, ndim, 3): - f.write('%2s % 12.8lf % 12.8lf % 12.8lf\n' % (symb[j], rxyz[j + 0], rxyz[j + 1], rxyz[j + 2])) + f.write('%2s % 12.8lf % 12.8lf % 12.8lf\n' % (symb[j], rxyz[j + 0].item(), rxyz[j + 1].item(), rxyz[j + 2].item())) return None @@ -417,7 +417,7 @@ def WriteEnergyFile(fname, energy, nim=None): else: assert len(energy) == nim for i in range(nim): - f.write('%12.8lf \n' % energy[i]) + f.write('%12.8lf \n' % energy[i].item()) return None diff --git a/ash/knarr/KNARRio/output_print.py b/ash/knarr/KNARRio/output_print.py index 4c6feac99..308e4b526 100755 --- a/ash/knarr/KNARRio/output_print.py +++ b/ash/knarr/KNARRio/output_print.py @@ -39,7 +39,7 @@ def PrintConfigurationPath(header, ndim, ndimIm, nim, ndof, rxyz, constr, symb, for i in range(0, ndimIm, 3): z = k * ndimIm + i print('% 2ls % 12.8lf % 12.8lf % 12.8lf % 2li % 2li % 2li' % ( - symb[z], rxyz[z], rxyz[z + 1], rxyz[z + 2], constr[z], constr[z + 1], constr[z + 2])) + symb[z], rxyz[z].item(), rxyz[z + 1].item(), rxyz[z + 2].item(), constr[z].item(), constr[z + 1].item(), constr[z + 2].item())) else: raise RuntimeError("Are you sure you know what you are printing?") diff --git a/ash/knarr/KNARRjobs/neb.py b/ash/knarr/KNARRjobs/neb.py index 77da3c71c..4f4415e97 100755 --- a/ash/knarr/KNARRjobs/neb.py +++ b/ash/knarr/KNARRjobs/neb.py @@ -262,8 +262,8 @@ def DoNEB(path, calculator, neb, optimizer, second_run=False): print('Energy of end points: ') #print(' Reactant: % 6.6f %s' % (path.GetEnergy()[0], KNARRsettings.energystring)) #print(' Product : % 6.6f %s' % (path.GetEnergy()[path.GetNim() - 1], KNARRsettings.energystring)) - print(' Reactant: % 6.6f %s' % (0.03674930495120813*path.GetEnergy()[0], 'Eh')) - print(' Product : % 6.6f %s' % (0.03674930495120813*path.GetEnergy()[path.GetNim() - 1], 'Eh')) + print(' Reactant: % 6.6f %s' % (0.03674930495120813*path.GetEnergy()[0].item(), 'Eh')) + print(' Product : % 6.6f %s' % (0.03674930495120813*path.GetEnergy()[path.GetNim() - 1].item(), 'Eh')) path.UpdateF() maxf_reactant = np.max(abs(path.GetF()[0:path.GetNDofIm()])) @@ -406,8 +406,8 @@ def DoNEB(path, calculator, neb, optimizer, second_run=False): ('it', 'dS', 'dE', 'CI', 'RMSF', 'MaxF', 'RMSF_CI', 'MaxF_CI', 'step')) print(f"Thresholds: {tol_rms_f:8.4f} {tol_max_f:8.4f} {tol_rms_fci:8.4f} {tol_max_fci:8.4f}") print("%4i %6.2lf %8.3lf %5li %8.4lf %8.4lf %8.4lf %8.4lf %8.4lf @" - % (it, s[-1], 23.060541945329334*(path.GetEnergy()[ci] - Ereactant), ci, rmsf_noci, - maxf_noci, rmsf_ci, maxf_ci, np.max(abs(step)))) + % (it, s[-1], 23.060541945329334*(path.GetEnergy()[ci].item() - Ereactant.item()), ci, rmsf_noci.item(), + maxf_noci, rmsf_ci.item(), maxf_ci, np.max(abs(step)))) print("-"*80) #RB addition. Get tangent in every iteration and provide to calculator @@ -427,7 +427,7 @@ def DoNEB(path, calculator, neb, optimizer, second_run=False): ('it', 'dS', 'dE', 'HEI', 'RMSF', 'MaxF', 'step')) print(f"Switch-on CI:{tol_turn_on_ci:>36.4f}") print("%4i %6.2lf %10.6lf %5li %9.4lf %9.4lf %9.4lf @" - % (it, s[-1], 23.060541945329334*(path.GetEnergy()[hei] - Ereactant), hei, rmsf, maxf, np.max(abs(step)))) + % (it, s[-1], 23.060541945329334*(path.GetEnergy()[hei] - Ereactant).item(), hei, rmsf.item(), maxf, np.max(abs(step)))) print("-"*70) PiecewiseCubicEnergyInterpolation(basename + ".interp", path.GetNim(), s, path.GetEnergy(), freal_paral, it) @@ -628,7 +628,7 @@ def DoNEB(path, calculator, neb, optimizer, second_run=False): extra="CI" else: extra="" print('%4i %6.2f %12.6f %12.4f %12.6f %6s' % ( - i, s[i], 1/(27.211386245988)*path.GetEnergy()[i], 23.060541945329334*(path.GetEnergy()[i] - path.GetEnergy()[0][0]), + i, s[i], 1/(27.211386245988)*path.GetEnergy()[i].item(), 23.060541945329334*(path.GetEnergy()[i].item() - path.GetEnergy()[0][0]), np.max(abs(freal_perp[i * path.GetNDofIm():(i + 1) * path.GetNDofIm()])),extra)) WritePath(basename + "_MEP.xyz", path.GetNDimIm(), path.GetNim(), path.GetCoords(), diff --git a/ash/knarr/KNARRjobs/path.py b/ash/knarr/KNARRjobs/path.py index 6c4877f54..e2810f968 100755 --- a/ash/knarr/KNARRjobs/path.py +++ b/ash/knarr/KNARRjobs/path.py @@ -59,8 +59,7 @@ def DoPathInterpolation(path, parameters): path.SetConfig2(prod_coords) path.SetConfig1(atom_coords) rmsdafter = RMS3(path.GetNDimIm(), path.GetConfig1() - path.GetConfig2()) - - print('RMSD: %6.3f -> %6.3f %s' % (rmsdbefore, rmsdafter, KNARRsettings.lengthstring)) + print('RMSD: %6.3f -> %6.3f %s' % (rmsdbefore.item(), rmsdafter.item(), KNARRsettings.lengthstring)) rp = PathLinearInterpol(path.GetNDimIm(), path.nim, path.GetConfig1(), path.GetConfig2(), @@ -84,7 +83,7 @@ def DoPathInterpolation(path, parameters): path.SetConfig1(atom_coords) rmsdafter = RMS3(path.GetNDimIm(), path.GetConfig1() - path.GetInsertionConfig()) - print('RMSD: %6.3f -> %6.3f %s' % (rmsdbefore, rmsdafter, KNARRsettings.lengthstring)) + print('RMSD: %6.3f -> %6.3f %s' % (rmsdbefore.item(), rmsdafter.item(), KNARRsettings.lengthstring)) print('Minimzation of RMSD (I-to-P):') rmsdbefore = RMS3(path.GetNDimIm(), path.GetInsertionConfig() - path.GetConfig2()) @@ -94,7 +93,7 @@ def DoPathInterpolation(path, parameters): path.SetInsertionConfig(insertion_coords) rmsdafter = RMS3(path.GetNDimIm(), path.GetConfig1() - path.GetInsertionConfig()) - print('RMSD: %6.3f -> %6.3f %s' % (rmsdbefore, rmsdafter, KNARRsettings.lengthstring)) + print('RMSD: %6.3f -> %6.3f %s' % (rmsdbefore.item(), rmsdafter.item(), KNARRsettings.lengthstring)) optimal_index = [] list_of_indices = range(2, int(path.GetNim() - 2)) @@ -245,7 +244,7 @@ def IDPP_OPT(path, max_iter=3000, spring_const=10.0, time_step=0.01, maxf = np.max(abs(freal_perp)) hei = np.argmax(path.GetEnergy()) print("%4i %6.2lf % 6.6lf %3li %8.4lf %8.4lf %8.4lf" - % (it, s[-1], path.GetEnergy()[hei] - Ereactant, hei, rmsf, maxf, np.max(abs(step)))) + % (it, s[-1], path.GetEnergy()[hei].item() - Ereactant, hei, rmsf.item(), maxf, np.max(abs(step)))) PiecewiseCubicEnergyInterpolation(basename + ".interp", path.GetNim(), s, path.GetEnergy(), freal_paral, it) if (tol_max_f > maxf and tol_rms_f > rmsf): converged = True diff --git a/ash/knarr/KNARRjobs/utilities.py b/ash/knarr/KNARRjobs/utilities.py index bc4863612..ebdafaff4 100755 --- a/ash/knarr/KNARRjobs/utilities.py +++ b/ash/knarr/KNARRjobs/utilities.py @@ -44,8 +44,8 @@ def GenerateVibrTrajectory(fname, ndim, R, symb, w, npts=40, A=1.0): f.write('%i\n\n' % (ndim / 3)) for j in range(0, ndim, 3): f.write('%s %12.8f %12.8f %12.8f \n' \ - % (symb[j], dx[k] * w[j] + R[j], dx[k] * w[j + 1] + R[j + 1], - dx[k] * w[j + 2] + R[j + 2])) + % (symb[j], dx[k] * w[j].item() + R[j].item(), dx[k] * w[j + 1].item() + R[j + 1].item(), + dx[k] * w[j + 2].item() + R[j + 2].item())) return None @@ -295,7 +295,7 @@ def ComputeLengthOfPath(ndim, nim, r, pbc=False, cell=None): for i in range(1, nim): r0 = r[(i - 1) * ndim:(i) * ndim] r1 = r[i * ndim:(i + 1) * ndim] - s[i] = s[i - 1] + Distance(ndim, r0, r1, pbc, cell) + s[i] = s[i - 1] + Distance(ndim, r0, r1, pbc, cell).item() return s @@ -670,7 +670,7 @@ def PiecewiseCubicEnergyInterpolation(fname, nim, s, energy, forces, it): xi = np.linspace(0, dr, 20) for j in xi: p = a * j ** 3 + b * j ** 2 + c * j + d - eintp.append(float(p)) + eintp.append(float(p.item())) xintp.append(float(j + s[i])) if fname: @@ -678,7 +678,7 @@ def PiecewiseCubicEnergyInterpolation(fname, nim, s, energy, forces, it): g.write('Iteration: %i\n' % it) g.write('Images:\n') for i in range(nim): - g.write('%.4f %12.6f %12.8f \n' % (s[i] / s[-1], s[i], energy[i])) + g.write('%.4f %12.6f %12.8f \n' % (s[i].item() / s[-1].item(), s[i].item(), energy[i].item())) g.write('Interp.:\n') for i in range(len(eintp)): diff --git a/ash/modules/module_MM.py b/ash/modules/module_MM.py index 6f3337baf..eb5306564 100644 --- a/ash/modules/module_MM.py +++ b/ash/modules/module_MM.py @@ -5,7 +5,9 @@ from ash.modules.module_theory import Theory from ash.functions.functions_general import ashexit, blankline,print_time_rel,BC, load_julia_interface import ash.constants - +#from ash.modules.module_freq import AnFreq, NumFreq +#from ash.interfaces.interface_geometric_new import Optimizer +#from ash.interfaces.interface_OpenMM import basic_atom_charges_ORCA, basic_atomcharges_xTB # Simple nonbonded MM theory. Charges and LJ-potentials class NonBondedTheory(Theory): @@ -267,7 +269,7 @@ def update_LJ_epsilons(self,atomlist,epsilons): # current_coords is now used for full_coords, charges for full coords def run(self, current_coords=None, elems=None, charges=None, connectivity=None, numcores=1, label=None, - Coulomb=True, Grad=True, qmatoms=None, actatoms=None, frozenatoms=None, charge=None, mult=None): + Coulomb=True, Grad=False, qmatoms=None, actatoms=None, frozenatoms=None, charge=None, mult=None): module_init_time=time.time() if current_coords is None: print("No current_coords argument. Exiting...") @@ -422,7 +424,10 @@ def run(self, current_coords=None, elems=None, charges=None, connectivity=None, if self.printlevel >= 2: print(BC.OKBLUE, BC.BOLD, "------------ENDING NONBONDED MM CODE-------------", BC.END) print_time_rel(module_init_time, modulename='NonbondedTheory run', moduleindex=2) - return self.MMEnergy, self.MMGradient + if Grad: + return float(self.MMEnergy), self.MMGradient + else: + return float(self.MMEnergy) # MMAtomobject used to store LJ parameter and possibly charge for MM atom with atomtype, e.g. OT @@ -903,3 +908,297 @@ def LJCoulpy(coords,atomtypes, charges, LJPairpotentials, connectivity=None): final_gradient = LJfinal_gradient + Coulgradient return final_energy,final_gradient + + +# Seminario implementation + + +# Given optimized fragment and Hessian, derive FF parameters using Seminario method. +def seminario(fragment=None, hessian=None, method="original"): + print("Inside Seminario function") + if fragment is None or hessian is None: + print("fragment and hessian must be provided for Seminario method") + ashexit() + # + if method == "original": + print("Using original Seminario method") + elif method == "modified": + print("Using modified Seminario method (to be implemented)") + ashexit() + else: + print("Unknown method for Seminario. Use 'original' or 'modified'") + ashexit() + + # Bonds + # Distance matrix from fragment coordinates. and partial + coords_au = fragment.coords*ash.constants.ang2bohr + num_atoms = len(coords_au) + + # Get distance matrix for coords + def distance_matrix(coords): + """ Calculate the distance matrix for a set of coordinates. """ + diff = coords[:, np.newaxis, :] - coords[np.newaxis, :, :] + dist = np.sqrt(np.sum(diff ** 2, axis=-1)) + return dist + + #dist_matrix = np.zeros((num_atoms, num_atoms)) + + def sum_seminario(unitvec,eigenvecs,eigenvals): + fc_au=0.0 + for h in range(3): + projection = abs(np.dot(unitvec, eigenvecs[:, h])) + fc_au += np.abs(eigenvals[h]) * (projection) + return fc_au + + def get_partial_hessian(hessian, i, j): + return hessian[3 * i:3 * i + 3, 3 * j:3 * j + 3] + + def do_bond(i, j, coords_au, hessian): + # distance vec + vec = coords_au[i] - coords_au[j] + unitvec = vec / np.linalg.norm(vec) + # Partial Hessian + partial_hessian = get_partial_hessian(hessian, i, j) + # Diagonalize partial Hessian + evals, evecs = np.linalg.eig(-partial_hessian) + fc_au = sum_seminario(unitvec,evecs,evals) + return fc_au + + def do_bonds(list_of_bonds, coords_au, hessian): + bond_FCs=[] + for bond in list_of_bonds: + i=bond[0] + j=bond[1] + fc_au = do_bond(i, j, coords_au, hessian) + factor=ash.constants.harkcal / (ash.constants.bohr2ang ** 2) + fc_kcal = abs(fc_au * factor) + print("Force constant for bond between atoms {} and {}: {} kcal/mol/Å^2".format(i, j, fc_kcal)) + bond_FCs.append((i, j, fc_kcal)) + + return bond_FCs + # Angles + def do_angle(i,j,k,coords_au, hessian, dist_matrix): + print("coords_au[i]:", coords_au[i]) + vec_ij = coords_au[j] - coords_au[i] + print("vec_ij:", vec_ij) + print() + print("coords_au[j]:", coords_au[j]) + print("coords_au[k]:", coords_au[k]) + vec_kj = coords_au[j] - coords_au[k] + print("vec_kj:", vec_kj) + unitvec_ij = vec_ij / np.linalg.norm(vec_ij) + print("unitvec_ij:", unitvec_ij) + unitvec_kj = vec_kj / np.linalg.norm(vec_kj) + print("unitvec_kj:", unitvec_kj) + parthess_ij = get_partial_hessian(hessian, i, j) + parthess_kj = get_partial_hessian(hessian, k, j) + # Diagonalize partial Hessian for each pair + evals_ij, evecs_ij = np.linalg.eig(-parthess_ij) + evals_kj, evecs_kj = np.linalg.eig(-parthess_kj) + + un = np.cross(vec_kj, vec_ij) + print("un:", un) + un = un / np.linalg.norm(un) + print("un:", un) + upa = np.cross(un, vec_ij) + print("upa:", upa) + upc = np.cross(vec_kj, un) + print("upc:", upc) + + sum1 = sum_seminario(upa, evecs_ij, evals_ij) + print("sum1:", sum1) + sum2 = sum_seminario(upc, evecs_kj, evals_kj) + print("sum2:", sum2) + lenij = dist_matrix[i, j] + print("lenij:", lenij) + lenkj = dist_matrix[k, j] + print("lenkj:", lenkj) + #fc_au = ( 1 / ( (lenij**2) * sum1) ) + ( 1 / ( (lenkj**2) * sum2) ) + #fc_au = 1/fc_au + fc_au = 1.0 / (1.0/(sum1*lenij*lenij)+1.0/(sum2*lenkj*lenkj)) + print("fc_au:", fc_au) + return fc_au + + def do_angles(list_of_angles, coords_au, hessian, dist_matrix): + angle_FCs=[] + for angle in list_of_angles: + print("angle:", angle) + i=angle[0] + j=angle[1] + k=angle[2] + fc_au = do_angle(i,j,k, coords_au, hessian, dist_matrix) + factor=ash.constants.harkcal + print("factor:", factor) + fc_kcal = abs(fc_au * factor) + print("Force constant for angle between atoms {}, {} and {}: {} kcal/mol/rad^2".format(i, j, k, fc_kcal)) + angle_FCs.append((i,j,k,fc_kcal)) + return angle_FCs + + # Distance matrix in bohrs + dist_matrix = distance_matrix(coords_au) + # Do bonds + list_of_bonds=[[0,1]] # Placeholder. To be replaced by actual bond list from fragment connectivity + # TODO: Do both i-j and j-i pairs and average + bond_FCs = do_bonds(list_of_bonds, coords_au, hessian) + print("") + # Do angles + list_of_angles=[[1,0,2],[0,1,2]] # Placeholder. To be replaced by actual angle list from fragment connectivity + angle_FCs = do_angles(list_of_angles, coords_au, hessian, dist_matrix) + #Dihedrals + + + #Define FF dictionary to store parameters + bondedFF = {"bonds": bond_FCs, "angles": angle_FCs, "dihedrals": []} + + return bondedFF + +# def derive_FF_parameters(fragment=None, theory=None, anfreq=False, +# hessian=None, charge_model=None, charges=None, method="original"): + +# if fragment.charge is None or fragment.mult is None: +# print("Fragment charge and/or multiplicity not present in object. Exiting") +# ashexit() + +# #Optimization +# print("Running geometry optimization for fragment using theory:", theory) +# result_opt = Optimizer(fragment=fragment, theory=theory) + +# #NumFreq or AnFreq +# if hessian is None: +# print("No Hessian provided. Will calculate Hessian.") +# if anfreq == True: +# print("anfreq is set to True. Will try to calculate Hessian using AnFreq method (only possible for some interfaces and some methods inside external QM programs)") +# print("Running AnFreq calculation to get Hessian") +# result_freq = AnFreq(theory=theory, fragment=fragment) +# else: +# print("anfreq is set to False. Will try to calculate Hessian using NumFreq method (numerical differentiation of gradients)..") +# print("Running NumFreq calculation to get Hessian") +# result_freq = NumFreq(theory=theory, fragment=fragment) +# hessian = result_freq.hessian +# else: +# print("Hessian provided. Will use this Hessian for FF parameter derivation.") + +# # Charge calculation +# if charges is None: +# print("No charges provided. Will calculate charges using charge_model") +# print("charge_model:", charge_model) +# if charge_model == "xTB": +# print("Using xTB charges") +# charges = basic_atomcharges_xTB(fragment=fragment, charge=charge, mult=mult, xtbmethod='GFN2') +# elif charge_model == "CM5_ORCA" or charge_model == "CM5": +# print("CM5_ORCA option chosen") +# atompropdict = basic_atom_charges_ORCA(fragment=fragment, charge=charge, mult=mult, +# orcatheory=theory, chargemodel="CM5", numcores=numcores) +# charges = atompropdict['charges'] +# elif charge_model == "DDEC3" or charge_model == "DDEC6": +# print("Using {} atomcharges and DDEC-derived parameters.".format(charge_model)) +# atompropdict = basic_atom_charges_ORCA(fragment=fragment, charge=charge, mult=mult, +# orcatheory=theory, chargemodel=charge_model, numcores=numcores) +# charges = atompropdict['charges'] +# else: +# print("Unknown charge_model option") +# exit() +# exit() +# else: +# print("Charges provided. Will use these charges for FF parameter derivation.") + +# # Call seminario function to derive bonded FF parameters +# bondedFF = seminario(fragment=None, hessian=hessian, method=method) + +# # Nonbonded parameters +# nonbondedFF = {'charges': charges, 'sigmas': None, 'epsilons': None} + +# print("Forcefield parameters derived using Seminario method. FF.) +# # Write FF parameters to OpenMM XML format file +# # something similar to write_xmlfile_nonbonded +# write_xmlfile_full(resnames=None, atomnames_per_res=None, atomtypes_per_res=None, elements_per_res=None, masses_per_res=None, +# charges_per_res=None, sigmas_per_res=None, epsilons_per_res=None, filename="system.xml", coulomb14scale=0.833333, +# lj14scale=0.5, skip_nb=False, charmm=False) + + +# # Write full forcefield XML file with all parameters (atomtypes, nonbonded, bonds, angles, torsions etc) +# def write_xmlfile_full(resnames=None, atomnames_per_res=None, atomtypes_per_res=None, elements_per_res=None, +# masses_per_res=None, charges_per_res=None, sigmas_per_res=None, +# epsilons_per_res=None, filename="system.xml", coulomb14scale=0.833333, +# lj14scale=0.5, skip_nb=False, charmm=False): +# print("Inside write_xmlfile_full") + +# assert len(resnames) == len(atomnames_per_res) == len(atomtypes_per_res) +# # Get list of all unique atomtypes, elements, masses +# # all_atomtypes=list(set([item for sublist in atomtypes_per_res for item in sublist])) +# # all_elements=list(set([item for sublist in elements_per_res for item in sublist])) +# # all_masses=list(set([item for sublist in masses_per_res for item in sublist])) + +# # Create list of all AtomTypelines (unique) +# atomtypelines = [] +# for resname, atomtypelist, elemlist, masslist in zip(resnames, atomtypes_per_res, elements_per_res, masses_per_res): +# for atype, elem, mass in zip(atomtypelist, elemlist, masslist): +# atomtypeline = "\n".format(atype, atype, elem, +# str(mass)) +# if atomtypeline not in atomtypelines: +# atomtypelines.append(atomtypeline) +# # BONDED PARAMETERS + +# #Bonds + +# #Angles + +# #Dihedrals + +# # NONBONDED PARAMETERS +# # Create list of all nonbonded lines (unique) +# nonbondedlines = [] +# LJforcelines = [] +# for resname, atomtypelist, chargelist, sigmalist, epsilonlist in zip(resnames, atomtypes_per_res, charges_per_res, +# sigmas_per_res, epsilons_per_res): +# for atype, charge, sigma, epsilon in zip(atomtypelist, chargelist, sigmalist, epsilonlist): +# if charmm == True: +# #LJ parameters zero here +# nonbondedline = "\n".format(atype, charge,0.0, 0.0) +# #Here we set LJ parameters +# ljline = "\n".format(atype, sigma, epsilon) +# if nonbondedline not in nonbondedlines: +# nonbondedlines.append(nonbondedline) +# if ljline not in LJforcelines: +# LJforcelines.append(ljline) +# else: +# nonbondedline = "\n".format(atype, charge, +# sigma, epsilon) +# if nonbondedline not in nonbondedlines: +# nonbondedlines.append(nonbondedline) + +# with open(filename, 'w') as xmlfile: +# xmlfile.write("\n") +# xmlfile.write("\n") +# for atomtypeline in atomtypelines: +# xmlfile.write(atomtypeline) +# xmlfile.write("\n") +# xmlfile.write("\n") +# for resname, atomnamelist, atomtypelist in zip(resnames, atomnames_per_res, atomtypes_per_res): +# xmlfile.write("\n".format(resname)) +# for i, (atomname, atomtype) in enumerate(zip(atomnamelist, atomtypelist)): +# xmlfile.write("\n".format(atomname, atomtype)) +# # All other atoms +# xmlfile.write("\n") +# xmlfile.write("\n") +# if skip_nb is False: + +# if charmm == True: +# #Writing both Nonbnded force block and also LennardJonesForce block +# xmlfile.write("\n".format(coulomb14scale, lj14scale)) +# for nonbondedline in nonbondedlines: +# xmlfile.write(nonbondedline) +# xmlfile.write("\n") +# xmlfile.write("\n".format(lj14scale)) +# for ljline in LJforcelines: +# xmlfile.write(ljline) +# xmlfile.write("\n") +# else: +# #Only NonbondedForce block +# xmlfile.write("\n".format(coulomb14scale, lj14scale)) +# for nonbondedline in nonbondedlines: +# xmlfile.write(nonbondedline) +# xmlfile.write("\n") +# xmlfile.write("\n") +# print("Wrote XML-file:", filename) +# return filename \ No newline at end of file diff --git a/ash/modules/module_PES_rewrite.py b/ash/modules/module_PES_rewrite.py index f7412c357..447327b9d 100644 --- a/ash/modules/module_PES_rewrite.py +++ b/ash/modules/module_PES_rewrite.py @@ -34,32 +34,34 @@ def PhotoElectron(theory=None, fragment=None, method=None, vibrational_option=No Initialstate_charge=None, Initialstate_mult=None, Ionizedstate_charge=None, Ionizedstate_mult=None, numionstates=5, initialorbitalfiles=None, densities='None', densgridvalue=40, - deltaSCF_ionize=False, deltaSCF_PMOM=False, + deltaSCF_ionize=False, deltaSCF_PMOM=False, deltaSCFkeyword=None, tda=True,brokensym=False, HSmult=None, atomstoflip=None, check_stability=True, CAS_Initial=None, CAS_Final = None, CASCI_Final=False, MRCI_CASCI_Final=False, MRCI_SOC=False, btPNO=False, DLPNO=False, no_shakeup=False, virt_offset=0, - path_wfoverlap=None, tprintwfvalue=1e-5, noDyson=False): + path_wfoverlap=None, tprintwfvalue=1e-5, noDyson=False, + OODFT_CC=False): """ Wrapper function around PhotoElectron Class """ print_line_with_mainheader("PhotoElectron") - timeA=time.time() - #NOTE: Create different PhotoElectronClass for each theory: PhotoElectronClass_ORCA, PhotoElectronClass_PySCF, PhotoElectronClass_MRCC ?? - #So much of the code is theory-specific anyway - #Method then selects class to use. Probably should switch to dictionaries for all the keywords then + timeA = time.time() + # NOTE: Create different PhotoElectronClass for each theory: PhotoElectronClass_ORCA, PhotoElectronClass_PySCF, PhotoElectronClass_MRCC ?? + # So much of the code is theory-specific anyway + # Method then selects class to use. Probably should switch to dictionaries for all the keywords then - photo=PhotoElectronClass(theory=theory, fragment=fragment, method=method, vibrational_option=vibrational_option, trajectory=trajectory, numcores=numcores, memory=memory,label=label, + photo = PhotoElectronClass(theory=theory, fragment=fragment, method=method, vibrational_option=vibrational_option, trajectory=trajectory, numcores=numcores, memory=memory,label=label, Initialstate_charge=Initialstate_charge, Initialstate_mult=Initialstate_mult, Ionizedstate_charge=Ionizedstate_charge, Ionizedstate_mult=Ionizedstate_mult, numionstates=numionstates, initialorbitalfiles=initialorbitalfiles, densities=densities, densgridvalue=densgridvalue, tda=tda,brokensym=brokensym, HSmult=HSmult, atomstoflip=atomstoflip, check_stability=check_stability, - deltaSCF_ionize=deltaSCF_ionize, deltaSCF_PMOM=deltaSCF_ionize, + deltaSCF_ionize=deltaSCF_ionize, deltaSCF_PMOM=deltaSCF_ionize, deltaSCFkeyword=deltaSCFkeyword, CAS_Initial=CAS_Initial, CAS_Final=CAS_Final, no_shakeup=no_shakeup,virt_offset=virt_offset, MRCI_CASCI_Final=MRCI_CASCI_Final, MRCI_SOC=MRCI_SOC, CASCI_Final=CASCI_Final, btPNO=btPNO, DLPNO=DLPNO, - path_wfoverlap=path_wfoverlap, tprintwfvalue=tprintwfvalue, noDyson=noDyson) + path_wfoverlap=path_wfoverlap, tprintwfvalue=tprintwfvalue, noDyson=noDyson, + OODFT_CC=OODFT_CC) result = photo.run() print_time_rel(timeA, modulename='PhotoElectron', moduleindex=1) return result @@ -72,11 +74,12 @@ def __init__(self,theory=None, fragment=None, method=None, vibrational_option=No Ionizedstate_charge=None, Ionizedstate_mult=None, numionstates=5, initialorbitalfiles=None, densities='None', densgridvalue=100, tda=True,brokensym=False, HSmult=None, atomstoflip=None, check_stability=True, - deltaSCF_ionize=False, deltaSCF_PMOM=False, + deltaSCF_ionize=False, deltaSCF_PMOM=False, deltaSCFkeyword=None, CAS_Initial=None, CAS_Final = None, no_shakeup=False, virt_offset=0, MRCI_CASCI_Final=False, MRCI_SOC=False, CASCI_Final=False, btPNO=False, DLPNO=False, - path_wfoverlap=None, tprintwfvalue=1e-5, noDyson=False): + path_wfoverlap=None, tprintwfvalue=1e-5, noDyson=False, + OODFT_CC=False): """ PhotoElectron module """ @@ -196,6 +199,8 @@ def __init__(self,theory=None, fragment=None, method=None, vibrational_option=No self.trajectory=trajectory self.deltaSCF_ionize=deltaSCF_ionize #DeltaSCF whether to ionize from init-state instead of setting CFG self.deltaSCF_PMOM=deltaSCF_PMOM #Whether to use PMOM or not + self.deltaSCFkeyword=deltaSCFkeyword # Add extra ORCA simple keyword when doing deltaSCF calcs only + self.OODFT_CC=OODFT_CC # CCSD(T) on top of deltaSCF print("PES method:", self.method) if self.method == 'MRCI' or self.method=='MREOM': print("MREOM:", self.MREOM) @@ -415,12 +420,17 @@ def run_tddft_densities(self,fragment): print("Calling orca_plot to create Cube-file for Final state TDDFT-state.") #Doing spin-density Cubefile for each cisr file + densityfilename=f"{self.theory.filename}.cisrre.singlet.iroot{tddftstate}" run_orca_plot(orcadir=self.theory.orcadir, filename=self.theory.filename + '.gbw', option='cisspindensity',gridvalue=self.densgridvalue, - densityfilename=self.theory.filename+'.cisr' ) - os.rename(self.theory.filename + '.spindens.cube', 'Final_State_mult' + str(fstate.mult)+'TDDFTstate_'+str(tddftstate)+'.spindens.cube') + densityfilename=densityfilename ) + # Note: file is named eldens despite being spindens + os.rename(self.theory.filename + '.eldens.cube', 'Final_State_mult' + str(fstate.mult)+'TDDFTstate_'+str(tddftstate)+'.spindens.cube') + #Doing eldensity Cubefile for each cisp file and then take difference with Initstate-SCF cubefile + densityfilename=f"{self.theory.filename}.cispre.singlet.iroot{tddftstate}" + #self.theory.filename+'.cisp' run_orca_plot(orcadir=self.theory.orcadir, filename=self.theory.filename + '.gbw', option='cisdensity',gridvalue=self.densgridvalue, - densityfilename=self.theory.filename+'.cisp' ) + densityfilename=densityfilename ) os.rename(self.theory.filename + '.eldens.cube', 'Final_State_mult' + str(fstate.mult)+'TDDFTstate_'+str(tddftstate)+'.eldens.cube') final_dens = 'Final_State_mult' + str(fstate.mult)+'TDDFTstate_'+str(tddftstate)+'.eldens.cube' @@ -1038,15 +1048,26 @@ def create_SCF_configurations(self): SCF_CFG_betahole=[] deltascfline_CFG_alphahole=[] deltascfline_CFG_betahole=[] + + #NOTE: we need ground-state ion configuration of each multiplicity too for fstate in self.Finalstates: if fstate.mult > self.stateI.mult: print(f"\nMultiplicity: {fstate.mult}. Creating BETA-hole") + print("fstate.numionstates:", fstate.numionstates) + # Check if too many states for occupations + if fstate.numionstates > len(self.stateI.occupations_beta): + print(f"Too many states {fstate.numionstates} requested for OO-DFT, based on occupied orbitals.") + print(len(self.stateI.occupations_beta)) + fstate.numionstates=len(self.stateI.occupations_beta) + print("Changing states to:", fstate.numionstates) + + + for i in range(fstate.numionstates): occ_beta = copy.copy(self.stateI.occupations_beta) reverse_ind_counter=-1-i occ_beta[reverse_ind_counter]=0 - print("New BETA Configuration:", occ_beta) SCF_CFG_betahole.append([self.stateI.occupations_alpha,occ_beta]) if i == 0: #Ground-state ion SCF, no deltaSCF line @@ -1089,6 +1110,7 @@ def run_DELTASCF(self,fragment,theory): self.run_SCF_InitState(fragment,theory) print("now done with initial state SCF") + #Create strings for the SCF configurations and deltaSCF lines if self.deltaSCF_ionize is True: print("deltaSCF_ionize option active!") @@ -1102,6 +1124,11 @@ def run_DELTASCF(self,fragment,theory): IPs_all=[] Ionstates_energies_all=[] + # DELTASCF extra keywords + if self.deltaSCFkeyword is not None: + print("Adding deltaSCFkeyword to ORCA input string:", self.deltaSCFkeyword) + theory.orcasimpleinput += f" {self.deltaSCFkeyword} " + #LOOPING over Finalstate-multiplicities for fstate in self.Finalstates: if self.deltaSCF_ionize: @@ -1132,6 +1159,12 @@ def run_DELTASCF(self,fragment,theory): #Adding ALPHACONF/BETACONF line as separate SCF block (empty if ground ion state) theory.orcablocks = self.orig_orcablocks + deltascfblock state_result = ash.Singlepoint(fragment=fragment, theory=theory, charge=charge, mult=mult) + # CCSD(T) correction on top + print("self.OODFT_CC:", self.OODFT_CC) + if self.OODFT_CC: + print("Now running noiter CCSD(T) on top of deltaSCF") + theory.extraline = theory.extraline.replace("DELTASCF","CCSD(T) noiter ") + state_result = ash.Singlepoint(fragment=fragment, theory=theory, charge=charge, mult=mult) finalsinglepointenergy = state_result.energy ip = (finalsinglepointenergy-self.stateI.energy)*ash.constants.hartoeV @@ -1167,6 +1200,10 @@ def run_DELTASCF(self,fragment,theory): run_orca_plot(orcadir=theory.orcadir,filename=f"{theory.filename}.gbw", option='spindensity', gridvalue=self.densgridvalue) #Move into Calculated_densities dir shutil.move(f"{theory.filename}.spindens.cube", 'Calculated_densities/' + f"{label}.spindens.cube") + + + + return IPs_all, Ionstates_energies_all # Calculate Ionized state via SCF+TDDFT approach @@ -1620,17 +1657,24 @@ def run_SCF_InitState(self,fragment,theory): self.InitSCF = ash.Singlepoint(fragment=fragment, theory=theory, charge=self.Initialstate_charge, mult=self.Initialstate_mult) - finalsinglepointenergy = self.InitSCF.energy stability = check_stability_in_output(theory.filename+'.out') if stability is False and self.check_stability is True: print("PES: Unstable initial state. Exiting...") ashexit() + if self.OODFT_CC: + print("SCF InitState. Now running noiter CCSD(T) on top of deltaSCF") + theory.extraline = theory.extraline + "! CCSD(T) noiter " + state_result = ash.Singlepoint(fragment=fragment, theory=theory, charge=self.Initialstate_charge, mult=self.Initialstate_mult) + + #Grab energy of initial state if self.method == 'CASSCF' or self.method =='CASCI': self.stateI.energy=casscfenergygrab(theory.filename+'.out') elif self.method == 'NEVPT2' or self.method == 'NEVPT2-F12': self.stateI.energy=finalenergiesgrab(theory.filename+'.out')[0] + elif self.OODFT_CC: + self.stateI.energy=state_result.energy else: self.stateI.energy=scfenergygrab(theory.filename+'.out') @@ -2076,39 +2120,45 @@ def run(self): curr_state_data_dict = ash.interfaces.interface_ORCA.read_ORCA_json_file(curr_jsonfile) totnumorbitals, numocc_alpha, numocc_beta, restricted = get_orb_info_from_dict(curr_state_data_dict) - #Write CURRENT-state MOs to disk in wfoverlap format - create_wfoverlap_MO_file(curr_state_data_dict, "mos_curr", mo_threshold=1e-12,frozencore=0) - - # Creating determinant-string for Current State from orbital information - curr_determinant_string = get_dets_from_single(totnumorbitals, - numocc_alpha, numocc_beta, restricted, 0) - writestringtofile(curr_determinant_string, "dets_curr") - - print("\nRunning WFOverlap to calculate Dyson norms for Finalstate with mult: ", fstate.mult) - # WFOverlap calculation needs files: AO_overl, mos_init, mos_final, dets_final, dets_init - wfoverlapinput = """ - mix_aoovl=AO_overl - a_mo=mos_curr - b_mo=mos_init - a_det=dets_curr - b_det=dets_init - a_mo_read=0 - b_mo_read=0 - ao_read=0 - moprint=1 - """ - #Calling wfoverlap - run_wfoverlap(wfoverlapinput,self.path_wfoverlap,self.memory,self.numcores) - #Grabbing Dyson norms from wfovl.out - dyson_norm=grabDysonnorms() - os.rename("wfovl.out",f"Final_State_mult{fstate.mult}_state{i}.wfovl.out") - dysonnorms.append(dyson_norm[0]) #Only one dyson norm - print(BC.OKBLUE,f"\nDyson norm for state: ({dyson_norm})",BC.ENDC) - if len(dyson_norm) == 0: - print("Dyson norm is empty. Something went wrong with WfOverlap calculation.") - print("Setting Dyson norm to zero and continuing.") - dysonnorms.append(0.0) - self.finaldysonnorms=self.finaldysonnorms+dyson_norm + if self.noDyson: + print("NoDyson True. Setting Dysonnorms to zero") + dysonnorms=[0.0 for i in frag_IPs] + self.finaldysonnorms=dysonnorms + + else: + #Write CURRENT-state MOs to disk in wfoverlap format + create_wfoverlap_MO_file(curr_state_data_dict, "mos_curr", mo_threshold=1e-12,frozencore=0) + + # Creating determinant-string for Current State from orbital information + curr_determinant_string = get_dets_from_single(totnumorbitals, + numocc_alpha, numocc_beta, restricted, 0) + writestringtofile(curr_determinant_string, "dets_curr") + + print("\nRunning WFOverlap to calculate Dyson norms for Finalstate with mult: ", fstate.mult) + # WFOverlap calculation needs files: AO_overl, mos_init, mos_final, dets_final, dets_init + wfoverlapinput = """ + mix_aoovl=AO_overl + a_mo=mos_curr + b_mo=mos_init + a_det=dets_curr + b_det=dets_init + a_mo_read=0 + b_mo_read=0 + ao_read=0 + moprint=1 + """ + #Calling wfoverlap + run_wfoverlap(wfoverlapinput,self.path_wfoverlap,self.memory,self.numcores) + #Grabbing Dyson norms from wfovl.out + dyson_norm=grabDysonnorms() + os.rename("wfovl.out",f"Final_State_mult{fstate.mult}_state{i}.wfovl.out") + dysonnorms.append(dyson_norm[0]) #Only one dyson norm + print(BC.OKBLUE,f"\nDyson norm for state: ({dyson_norm})",BC.ENDC) + if len(dyson_norm) == 0: + print("Dyson norm is empty. Something went wrong with WfOverlap calculation.") + print("Setting Dyson norm to zero and continuing.") + dysonnorms.append(0.0) + self.finaldysonnorms=self.finaldysonnorms+dyson_norm #Dyson frag_dysonnorms=dysonnorms #frag_dysonnorms = self.run_dyson_calc(frag_IPs) @@ -3020,7 +3070,7 @@ def MRCI_SOC_grab(file): minE=None with open(file) as f: for line in f: - if 'Center of electronic charge' in line: + if '*************************************' in line: grab2=False if grab2 is True: if 'STATE' in line: @@ -3081,11 +3131,17 @@ def mrci_state_energies_grab(file,SORCI=False, SOC=False): prev_grabbed_blockinfo=False current_roots=None currentmult=None + numCIblocks=None with open(file) as f: for line in f: #print("line:", line) #print("prev_grabbed_blockinfo:", prev_grabbed_blockinfo) #print("grab_blockinfo:", grab_blockinfo) + + # RBapr 2026: getting number of CI blocks + if 'Number of CI-blocks ...' in line: + numCIblocks = int(line.split()[-1]) + #Note. Grabbing block info from CASSCF output if '<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>' in line: if prev_grabbed_blockinfo is False: @@ -3095,31 +3151,45 @@ def mrci_state_energies_grab(file,SORCI=False, SOC=False): else: grab_blockinfo=False if grab_blockinfo is True: + if 'BLOCK' in line: blocknum = int(line.split()[1]) mult = int(line.split()[3]) roots = int(line.split("=")[-1]) block_dict[blocknum] = (mult,roots) #print("block_dict:", block_dict) + if numCIblocks == 1: + grab_blockinfo = False + #exit() #Only reading 2 blocks (two multiplicities) #Unncessary? if len(block_dict) == 2: grab_blockinfo = False #Grabbing actual MRCI state energies if grab is True and string in line: + #print("here") Energy=float(line.split()[3]) state_energies.append(Energy) if len(state_energies) == current_roots: + #print("xx") mult_dict[currentmult] = state_energies #print("mult_dict:", mult_dict) state_energies=[] #Getting info about what block we are currently reading in the output if final_part is True: if '* CI-BLOCK' in line: + #print("here") + #print(line) blockgrab=True currentblock=int(line.split()[-2]) - currentmult=block_dict[currentblock][0] - current_roots = block_dict[currentblock][1] + #print("currentblock:", currentblock) + #print("block_dict:", block_dict) + if numCIblocks == 1: + currentmult=block_dict[0][0] + current_roots = block_dict[0][1] + else: + currentmult=block_dict[currentblock][0] + current_roots = block_dict[currentblock][1] if 'TRANSITION ENERGIES' in line: grab = False if blockgrab is True: @@ -3127,6 +3197,7 @@ def mrci_state_energies_grab(file,SORCI=False, SOC=False): grab=True if 'S O R C I (DDCI3-STEP)' in line: final_part=True + print("mult_dict:", mult_dict) return mult_dict @@ -4188,17 +4259,17 @@ def plot_PES_Spectrum(IPs=None, dysonnorms=None, mos_alpha=None, mos_beta=None, if MOPlot is True: # MO-DOSPLOT for initial state. Here assuming MO energies of initial state to be good approximations for IPs ax.plot(x, occDOS_alpha, 'C2', label='alphaMO') - ax.stem(stk_alpha2, stk_alpha2height, label='alphaMO', basefmt=" ", markerfmt=' ', linefmt='C2-', use_line_collection=True) + ax.stem(stk_alpha2, stk_alpha2height, label='alphaMO', basefmt=" ", markerfmt=' ', linefmt='C2-') if hftyp_I == "UHF": ax.plot(x, occDOS_beta, 'C2', label='betaMO') - ax.stem(stk_beta2, stk_beta2height, label='betaMO', basefmt=" ", markerfmt=' ', linefmt='C2-', use_line_collection=True) + ax.stem(stk_beta2, stk_beta2height, label='betaMO', basefmt=" ", markerfmt=' ', linefmt='C2-') ############## # TDDFT-STATES ############### ax.plot(x, tddftDOS, 'C3', label='TDDFT') - ax.stem(IPs, dysonnorms, label='TDDFT', markerfmt=' ', basefmt=' ', linefmt='C3-', use_line_collection=True) + ax.stem(IPs, dysonnorms, label='TDDFT', markerfmt=' ', basefmt=' ', linefmt='C3-') plt.xlabel('eV') plt.ylabel('Intensity') ################################# diff --git a/ash/modules/module_QMMM.py b/ash/modules/module_QMMM.py index 873e2c2f3..b85631b72 100644 --- a/ash/modules/module_QMMM.py +++ b/ash/modules/module_QMMM.py @@ -145,25 +145,24 @@ def __init__(self, qm_theory=None, qmatoms=None, fragment=None, mm_theory=None, print("MM region ({} atoms)".format(len(self.mmatoms))) # Setting QM/MM qmatoms in QMtheory also (used for Spin-flipping currently) - self.qm_theory.qmatoms=self.qmatoms + self.qm_theory.qmatoms = self.qmatoms - - #Setting numcores of object. + # Setting numcores of object. # This will be when calling QMtheory and probably MMtheory # numcores-setting in QMMMTheory takes precedent if numcores != 1: - self.numcores=numcores + self.numcores = numcores # If QMtheory numcores was set (and QMMMTHeory not) elif self.qm_theory.numcores != 1: self.numcores=self.qm_theory.numcores # Default 1 proc else: - self.numcores=1 + self.numcores = 1 print("QM/MM object selected to use {} cores".format(self.numcores)) # Embedding type: mechanical, electrostatic etc. - self.embedding=embedding + self.embedding = embedding # Charge-boundary method self.chargeboundary_method=chargeboundary_method # Options: 'chargeshift', 'rcd' @@ -173,6 +172,7 @@ def __init__(self, qm_theory=None, qmatoms=None, fragment=None, mm_theory=None, elif self.embedding.lower() == "pbcmm-elstat" or self.embedding.lower() == "pbcmm-electrostatic" or self.embedding.lower() == "pbcmm-electronic": self.embedding="pbcmm-elstat" self.PC = True + exit() elif self.embedding.lower() == "mechanical" or self.embedding.lower() == "mech": self.embedding="mech" self.PC = False @@ -188,7 +188,7 @@ def __init__(self, qm_theory=None, qmatoms=None, fragment=None, mm_theory=None, # Whether MM-shifted performed or not. Will be set to True by self.ShiftMMCharges self.chargeshifting_done=False - # if atomcharges are not passed to QMMMTheory object, get them from MMtheory (that should have been defined then) + # if atomcharges are not passed to QMMMTheory object, get them from MMtheory (that should have defined then) if charges is None: print("No atomcharges list passed to QMMMTheory object") self.charges=[] @@ -277,6 +277,7 @@ def __init__(self, qm_theory=None, qmatoms=None, fragment=None, mm_theory=None, print("Boundaryatoms (QM:MM pairs):", self.boundaryatoms) print("Note: used connectivity settings, scale={} and tol={} to determine boundary.".format(conn_scale,conn_tolerance)) self.linkatoms = True + print("Linkatom_forceprojection_method:", self.linkatom_forceproj_method) # Get MM boundary information. Stored as self.MMboundarydict self.get_MMboundary(conn_scale,conn_tolerance) else: @@ -284,17 +285,19 @@ def __init__(self, qm_theory=None, qmatoms=None, fragment=None, mm_theory=None, self.linkatoms=False self.dipole_correction=False - # Removing possible QM atom constraints in OpenMMTheory - # Will only apply when running OpenMM_Opt or OpenMM_MD if self.mm_theory_name == "OpenMMTheory": + # Removing possible QM atom constraints in OpenMMTheory + # Will only apply when running OpenMM_Opt or OpenMM_MD self.mm_theory.remove_constraints_for_atoms(self.qmatoms) - # Remove bonded interactions in MM part. Only in OpenMM. Assuming they were never defined in NonbondedTHeory + # Remove bonded interactions in MM part. Only in OpenMM. Assuming they were never defined in NonbondedTheory + # Applies to both elstat and mech embedding. print("Removing bonded terms for QM-region in MMtheory") self.mm_theory.modify_bonded_forces(self.qmatoms) - # NOTE: Temporary. Adding exceptions for nonbonded QM atoms. Will ignore QM-QM Coulomb and LJ interactions. - # NOTE: For QM-MM interactions Coulomb charges are zeroed below (update_charges and delete_exceptions) + # Adding exceptions for nonbonded QM atoms. Will ignore QM-QM Coulomb and QM-QM LJ interactions. + # Applies to both elstat and mech embedding. + # NOTE: For QM-MM elstat interactions Coulomb charges are zeroed below (update_charges and delete_exceptions) print("Removing nonbonded terms for QM-region in MMtheory (QM-QM interactions)") self.mm_theory.addexceptions(self.qmatoms) @@ -306,7 +309,6 @@ def __init__(self, qm_theory=None, qmatoms=None, fragment=None, mm_theory=None, # and Charge-shift QM-MM boundary # Zero QM charges for electrostatic embedding - # TODO: DO here or inside run instead?? Needed for MM code. if self.embedding.lower() == "elstat": print("Charges of QM atoms set to 0 (since Electrostatic Embedding):") self.ZeroQMCharges() #Modifies self.charges_qmregionzeroed @@ -314,24 +316,24 @@ def __init__(self, qm_theory=None, qmatoms=None, fragment=None, mm_theory=None, # TODO: make sure this works for OpenMM and for NonBondedTheory # Updating charges in MM object. self.mm_theory.update_charges(self.qmatoms,[0.0 for i in self.qmatoms]) + + # Also removing QM-MM Coulomb interaction exceptions in OpenMM + if self.mm_theory_name == "OpenMMTheory": + # Deleting Coulomb exception interactions involving QM and MM atoms + self.mm_theory.delete_exceptions(self.qmatoms) + elif self.embedding.lower() == "pbcmm-elstat": print("PBC Electrostatic embedding enabled.") print("This means that QM-atoms will be zeroed for QM-MM interactions calculated by QM program") print("But MM program will have charged defined for QM-region") self.ZeroQMCharges() #Modifies self.charges_qmregionzeroed + exit() + + # TODO: Exceptions # Note: possible to set QM-charges to something specific: Mulliken, ESP # specialQMcharges = [something] # self.mm_theory.update_charges(self.qmatoms,specialQMcharges) - if self.mm_theory_name == "OpenMMTheory": - # Deleting Coulomb exception interactions involving QM and MM atoms - self.mm_theory.delete_exceptions(self.qmatoms) - # Option to create OpenMM externalforce that handles full system - if self.openmm_externalforce == True: - print("openmm_externalforce is True") - # print("Creating new OpenMM custom external force") - # MOVED FROM HERE TO OPENMM_MD - # Printing charges: all or only QM if self.printlevel > 2: for i in self.allatoms: @@ -345,7 +347,7 @@ def __init__(self, qm_theory=None, qmatoms=None, fragment=None, mm_theory=None, print("MM atom {} ({}) charge: {}".format(i, self.elems[i], self.charges_qmregionzeroed[i])) blankline() else: - # Case: No actual MM theory but we still want to zero charges for QM elstate embedding calculation + # Case: No actual MM theory but we still want to zero charges for QM elstat embedding calculation # TODO: Remove option for no MM theory or keep this ?? if self.embedding.lower() == "elstat": self.ZeroQMCharges() #Modifies self.charges_qmregionzeroed @@ -361,10 +363,19 @@ def get_MMboundary(self,scale,tol): #Creating dictionary for each MM1 atom and its connected atoms: MM2-4 self.MMboundarydict={} for (QM1atom,MM1atom) in self.boundaryatoms.items(): - connatoms = ash.modules.module_coords.get_connected_atoms(self.coords, self.elems, scale,tol, MM1atom) - #Deleting QM-atom from connatoms list - connatoms.remove(QM1atom) - self.MMboundarydict[MM1atom] = connatoms + if isinstance(MM1atom,list): + for mat in MM1atom: + connatoms = ash.modules.module_coords.get_connected_atoms(self.coords, self.elems, scale,tol, mat) + #Deleting QM-atom from connatoms list + connatoms.remove(QM1atom) + self.MMboundarydict[mat] = connatoms + # OLD: should never apply anymore, we always have a list + # TODO: delete + else: + connatoms = ash.modules.module_coords.get_connected_atoms(self.coords, self.elems, scale,tol, MM1atom) + # Deleting QM-atom from connatoms list + connatoms.remove(QM1atom) + self.MMboundarydict[MM1atom] = connatoms # Used by ShiftMMCharges self.MMboundary_indices = list(self.MMboundarydict.keys()) @@ -701,7 +712,7 @@ def TruncatedPCgradientupdate(self, QMgradient_wo_linkatoms, PCgradient): return newQMgradient_wo_linkatoms, new_full_PC_gradient - def set_numcores(self,numcores): + def set_numcores(self, numcores): print(f"Setting new numcores {numcores}for QMtheory and MMtheory") self.qm_theory.set_numcores(numcores) self.mm_theory.set_numcores(numcores) @@ -730,12 +741,10 @@ def run(self, current_coords=None, elems=None, Grad=False, numcores=1, exit_afte print("QM Module:", self.qm_theory_name) print("MM Module:", self.mm_theory_name) - # exit_after_customexternalforce_update can be enabled both at runtime and by initialization if self.exit_after_customexternalforce_update is True: exit_after_customexternalforce_update=self.exit_after_customexternalforce_update - # OPTION: QM-region charge/mult from QMMMTheory definition # If qm_charge/qm_mult defined then we use. Otherwise charge/mult may have been defined by jobtype-function and passed on via run if self.qm_charge is not None: @@ -818,17 +827,18 @@ def mech_run(self, current_coords=None, elems=None, Grad=False, numcores=1, exit if self.qm_theory_name == "None" or self.qm_theory_name == "ZeroTheory": print("No QMtheory. Skipping QM calc") QMenergy=0.0;self.linkatoms=False - QMgradient=np.array([0.0, 0.0, 0.0]) + # QMgradient=np.array([0.0, 0.0, 0.0]) + QMgradient=np.zeros((len(used_qmcoords),3)) else: # Calling QM theory, providing current QM and MM coordinates. if Grad is True: QMenergy, QMgradient = self.qm_theory.run(current_coords=used_qmcoords, qm_elems=self.current_qmelems, Grad=True, PC=False, numcores=numcores, charge=charge, mult=mult) else: - QMenergy = self.qm_theory.run(current_coords=used_qmcoords,qm_elems=self.current_qmelems, Grad=False, + QMenergy = self.qm_theory.run(current_coords=used_qmcoords, qm_elems=self.current_qmelems, Grad=False, PC=False, numcores=numcores, charge=charge, mult=mult) - print_time_rel(CheckpointTime, modulename='QM step', moduleindex=2,currprintlevel=self.printlevel, currthreshold=1) + print_time_rel(CheckpointTime, modulename='QM step', moduleindex=2, currprintlevel=self.printlevel, currthreshold=1) CheckpointTime = time.time() ############################ @@ -905,8 +915,6 @@ def mech_run(self, current_coords=None, elems=None, Grad=False, numcores=1, exit else: print("Unknown linkatom_forceproj_method. Exiting") ashexit() - #print("QM1grad contrib:", QM1grad_contrib) - #print("MM1grad contrib:", MM1grad_contrib) # Updating full QM_MM_gradient self.QM_MM_gradient[fullatomindex_qm] += QM1grad_contrib self.QM_MM_gradient[fullatomindex_mm] += MM1grad_contrib @@ -951,7 +959,7 @@ def mech_run(self, current_coords=None, elems=None, Grad=False, numcores=1, exit CheckpointTime = time.time() # print("QM/MM Grad is True") # Provide self.QM_MM_gradient to OpenMMTheory - if self.openmm_externalforce == True: + if self.openmm_externalforce is True: print_if_level(f"OpenMM externalforce is True", self.printlevel,2) # Calculate energy associated with external force so that we can subtract it later # self.extforce_energy = 3 * np.mean(np.sum(self.QM_MM_gradient * current_coords * 1.88972612546, axis=0)) @@ -1294,7 +1302,7 @@ def elstat_run(self, current_coords=None, elems=None, Grad=False, numcores=1, ex #LINKATOM FORCE PROJECTION if self.linkatoms is True: CheckpointTime = time.time() - + #print("self.linkatoms_dict:", self.linkatoms_dict) for pair in sorted(self.linkatoms_dict.keys()): #Grabbing linkatom data linkatomindex=self.linkatom_indices.pop(0) @@ -1351,7 +1359,7 @@ def elstat_run(self, current_coords=None, elems=None, Grad=False, numcores=1, ex self.MMenergy, self.MMgradient= self.mm_theory.run(current_coords=current_coords, charges=self.charges_qmregionzeroed, connectivity=self.connectivity, - qmatoms=self.qmatoms, actatoms=self.actatoms) + qmatoms=self.qmatoms, actatoms=self.actatoms, Grad=Grad) elif self.mm_theory_name == "OpenMMTheory": if self.printlevel >= 2: @@ -1824,14 +1832,14 @@ def linkatom_force_adv(Qcoord, Mcoord, Lcoord, Lgrad): C[i,i] = C[i,i] + 1.0 # Multiplying C matrix with Linkatom gradient - g_x=C[0,0]*Lgrad[0]+C[0,1]*Lgrad[1]+C[0,2]*Lgrad[2] - g_y=C[1,0]*Lgrad[0]+C[1,1]*Lgrad[1]+C[1,2]*Lgrad[2] - g_z=C[2,0]*Lgrad[0]+C[2,1]*Lgrad[1]+C[2,2]*Lgrad[2] + g_x=float(C[0,0]*Lgrad[0]+C[0,1]*Lgrad[1]+C[0,2]*Lgrad[2]) + g_y=float(C[1,0]*Lgrad[0]+C[1,1]*Lgrad[1]+C[1,2]*Lgrad[2]) + g_z=float(C[2,0]*Lgrad[0]+C[2,1]*Lgrad[1]+C[2,2]*Lgrad[2]) # Multiplying B matrix with Linkatom gradient - gg_x=B[0,0]*Lgrad[0]+B[0,1]*Lgrad[1]+B[0,2]*Lgrad[2] - gg_y=B[1,0]*Lgrad[0]+B[1,1]*Lgrad[1]+B[1,2]*Lgrad[2] - gg_z=B[2,0]*Lgrad[0]+B[2,1]*Lgrad[1]+B[2,2]*Lgrad[2] + gg_x=float(B[0,0]*Lgrad[0]+B[0,1]*Lgrad[1]+B[0,2]*Lgrad[2]) + gg_y=float(B[1,0]*Lgrad[0]+B[1,1]*Lgrad[1]+B[1,2]*Lgrad[2]) + gg_z=float(B[2,0]*Lgrad[0]+B[2,1]*Lgrad[1]+B[2,2]*Lgrad[2]) # Return QM1_gradient and MM1_gradient contribution (to be added) return [g_x,g_y,g_z],[gg_x,gg_y,gg_z] diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index 84fcea8d8..4840b43be 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -392,6 +392,7 @@ def add_coords_from_string(self, coordsstring, scale=None, tol=None, conncalc=Fa # self.calc_connectivity(scale=scale, tol=tol) def create_coords_from_smiles(self, smiles): print("Creating coordinates from SMILES string:", smiles) + from ash.interfaces.interface_openbabel import smiles_to_coords elems, coords = smiles_to_coords(smiles) self.elems = elems self.coords = reformat_list_to_array(coords) @@ -488,7 +489,7 @@ def add_coords(self, elems, coords, conn=True, scale=None, tol=None): def print_coords(self): if self.printlevel >= 2: - print("Defined coordinates (Å):") + print("Cartesian coordinates (Å):") #print_coords_all(self.coords, self.elems) for i,(el, c) in enumerate(zip(self.elems, self.coords)): line = " {:<4} {:4} {:>12.6f} {:>12.6f} {:>12.6f}".format(i,el, c[0], c[1], c[2]) @@ -577,7 +578,7 @@ def read_pdbfile_openmm(self,filename): try: import openmm.app except ImportError: - print("Error: OpenMM not found. Cannot read PDB file.") + print("Error: OpenMM library not found. ASH requires OpenMM library to read PDB files.") ashexit() pdb = openmm.app.PDBFile(filename) self.coords = np.array([[i.x*10,i.y*10,i.z*10] for i in pdb.positions]) @@ -781,6 +782,7 @@ def write_pdbfile(self,filename="Fragment"): resnames=self.pdb_resnames,residlabels=self.pdb_residlabels, segmentlabels=None, conect_lines=self.pdb_conect_lines) return f"{filename}.pdb" + # Create new topology from scratch if none is defined (defined automatically when reading PDB-files by OpenMM) def define_topology(self, scale=1.0, tol=0.1, resname="MOL"): try: @@ -802,7 +804,9 @@ def define_topology(self, scale=1.0, tol=0.1, resname="MOL"): connectivity_dict = get_connected_atoms_dict(self.coords,self.elems, scale,tol) #Looping over molecules defined by connectivity for mol in self.connectivity: + print("mol:", mol) residue = self.pdb_topology.addResidue(resname, chain) + print("residue:", residue) # Defaultdictionary to keep track of unique element-atomnames atomnames_dict=defaultdict(int) @@ -819,12 +823,17 @@ def define_topology(self, scale=1.0, tol=0.1, resname="MOL"): #print("atomname is O1 and 3-atom residue. Probably water") #print("using atomname as O instead of O1 aids OpenMM recognition") atomname="O" - + print("Adding atom:", atomname, "element:", element, "to residue:", residue) + print("at:", at, "el:", el) self.pdb_topology.addAtom(atomname, element, residue) + print("here, residue:", residue) + print("----------------___") print("Adding connectivity to PDB topology") ash.interfaces.interface_OpenMM.openmm_add_bonds_to_topology(self.pdb_topology, connectivity_dict) + return self.pdb_topology + # Write PDB-file via OpenMM def write_pdbfile_openmm(self,filename="Fragment", calc_connectivity=False, pdb_topology=None, skip_connectivity=False, resname="MOL"): @@ -832,7 +841,7 @@ def write_pdbfile_openmm(self,filename="Fragment", calc_connectivity=False, pdb_ try: import openmm.app except ImportError: - print("Error: OpenMM not found. Cannot read PDB file.") + print("Error: OpenMM library not found. ASH requires OpenMM library to write PDB files.") ashexit() #Adding extension @@ -866,26 +875,18 @@ def write_pdbfile_openmm(self,filename="Fragment", calc_connectivity=False, pdb_ self.pdb_topology._bonds=[] openmm.app.PDBFile.writeFile(self.pdb_topology, self.coords, file=open(f"{filename}", 'w')) print(f"Wrote PDB-file: {filename}") + return filename def write_xyzfile(self, xyzfilename="Fragment-xyzfile.xyz", writemode='w', write_chargemult=True, write_energy=True): with open(xyzfilename, writemode) as ofile: ofile.write(str(len(self.elems)) + '\n') - #Title line #Write charge,mult and energy by default. Will be None if not available if write_chargemult is True and write_energy is True: ofile.write("{} {} {}\n".format(self.charge,self.mult,self.energy)) else: ofile.write("title\n") - #elif write_chargemult is True and write_energy is True: - # ofile.write("{} {}\n".format(self.charge,self.mult)) - # Energy written otherwise - #else: - # if self.energy is None: - # ofile.write("Energy: None" + '\n') - # else: - # ofile.write("Energy: {:14.8f}".format(self.energy) + '\n') #Coordinates for el, c in zip(self.elems, self.coords): @@ -929,16 +930,6 @@ def get_subset_coords_with_linkatoms(self,qmatoms): def print_system(self, filename='fragment.ygg'): if self.printlevel >= 2: print("Printing fragment to disk: ", filename) - - # Checking that lists have same length (as zip will just ignore the mismatch) - # print("len(self.atomlist)", len(self.atomlist)) - # rint("len(self.elems)",len(self.elems) ) - # print("len(self.coords)",len(self.coords) ) - # print("len(self.atomcharges)", len(self.atomcharges) ) - # print("len(self.fragmenttype_labels)", len(self.fragmenttype_labels) ) - # print("len(self.atomtypes)", len(self.atomtypes)) - - print("", ) printdebug("len(self.atomlist): ", len(self.atomlist)) printdebug("len(self.elems): ", len(self.elems)) printdebug("len(self.coords): ", len(self.coords)) @@ -1158,7 +1149,98 @@ def remove_zero_charges(charges, coords): newcoords.append(coord) return newcharges, newcoords +# NEW function to print internal coordinate table for active atoms based on connectivity. + +def print_internal_coordinate_table_new(fragment, actatoms=None): + """ + Prints a tabulated view of internal coordinates for active atoms + based on the fragment's connectivity. + """ + def _measure_bond(coords, i, j): + """Bond length in Angstrom between atoms i and j.""" + return float(np.linalg.norm(coords[i] - coords[j])) + + def _measure_angle(coords, i, j, k): + """Angle i-j-k in degrees (j is the vertex).""" + v1 = coords[i] - coords[j] + v2 = coords[k] - coords[j] + cos_a = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) + return float(np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0)))) + + def _measure_dihedral(coords, i, j, k, l): + """Dihedral angle i-j-k-l in degrees (range -180 to 180).""" + b1 = coords[j] - coords[i] + b2 = coords[k] - coords[j] + b3 = coords[l] - coords[k] + n1 = np.cross(b1, b2) + n2 = np.cross(b2, b3) + m1 = np.cross(n1, b2 / np.linalg.norm(b2)) + return float(np.degrees(np.arctan2(np.dot(m1, n2), np.dot(n1, n2)))) + + if actatoms is None: + actatoms = fragment.allatoms + coords = fragment.coords + elems = fragment.elems + from ash.modules.module_surface_new import _build_connectivity + conn = _build_connectivity(coords, elems) + + # Header + print() + print("=" * 30) + print("Internal Coordinates") + print("=" * 30) + print(f"{'Type':<10} {'Atoms':<20} {'Elements':<15} {'Value':>10}") + print("-" * 60) + + # We use sets to avoid printing the same geometric feature twice + # (e.g., bond 0-1 and 1-0) + seen_bonds = set() + seen_angles = set() + seen_dihedrals = set() + + for i in actatoms: + # --- Bonds (i-j) --- + for j in conn[i]: + bond_key = tuple(sorted((i, j))) + if bond_key not in seen_bonds: + val = _measure_bond(coords, i, j) + label = f"{elems[i]}-{elems[j]}" + print(f"{'Bond':<10} {str(bond_key):<20} {label:<15} {val:>10.4f} Å") + seen_bonds.add(bond_key) + + # --- Angles (i-j-k) --- + # i is the vertex (j-i-k) + neighbors = list(conn[i]) + for idx_a in range(len(neighbors)): + for idx_b in range(idx_a + 1, len(neighbors)): + j, k = neighbors[idx_a], neighbors[idx_b] + angle_key = tuple(sorted((j, k)) + [i]) # vertex last for keying + if angle_key not in seen_angles: + val = _measure_angle(coords, j, i, k) + label = f"{elems[j]}-{elems[i]}-{elems[k]}" + print(f"{'Angle':<10} {f'({j},{i},{k})':<20} {label:<15} {val:>10.2f}°") + seen_angles.add(angle_key) + + # --- Dihedrals (i-j-k-l) --- + # Logic: Find a bond (i-j), then find neighbors of i and j + for j in conn[i]: + for h in conn[i]: + if h == j: continue + for k in conn[j]: + if k == i or k == h: continue + # Path is h-i-j-k + di_key = (h, i, j, k) + rev_key = (k, j, i, h) + if di_key not in seen_dihedrals and rev_key not in seen_dihedrals: + val = _measure_dihedral(coords, h, i, j, k) + label = f"{elems[h]}-{elems[i]}-{elems[j]}-{elems[k]}" + print(f"{'Dihedral':<10} {str(di_key):<20} {label:<15} {val:>10.2f}°") + seen_dihedrals.add(di_key) + + print("-" * 60) + +# OLD FUNCTION. def print_internal_coordinate_table(fragment, actatoms=None): timeA = time.time() print("\nPrinting internal coordinate table") @@ -1377,23 +1459,12 @@ def write_coords_all(coords, elems, indices=None, labels=None, labels2=None, fil f.close() - +############################################################## # Functions to get distance, angle, coordinates of fragment +############################################################## + def distance(A, B): return sqrt(pow(A[0] - B[0], 2) + pow(A[1] - B[1], 2) + pow(A[2] - B[2], 2)) # fastest - # return sum((v_i - u_i) ** 2 for v_i, u_i in zip(A, B)) ** 0.5 #slow - # return np.sqrt(np.sum((A - B) ** 2)) #very slow - # return np.linalg.norm(A - B) #VERY slow - # return sqrt(sum((px - qx) ** 2.0 for px, qx in zip(A, B))) #slow - # return sqrt(sum([pow((a - b),2) for a, b in zip(A, B)])) #OK - # return np.sqrt((A[0] - B[0]) ** 2 + (A[1] - B[1]) ** 2 + (A[2] - B[2]) ** 2) #Very slow - # return math.sqrt((A[0] - B[0]) ** 2 + (A[1] - B[1]) ** 2 + (A[2] - B[2]) ** 2) #faster - # return math.sqrt(math.pow(A[0] - B[0],2) + math.pow(A[1] - B[1],2) + math.pow(A[2] - B[2],2)) #faster - # return sqrt(sum((A-B)**2)) #slow - # return sqrt(sum(pow((A - B),2))) does not work - # return np.sqrt(np.power((A-B),2).sum()) #very slow - # return sqrt(np.power((A - B), 2).sum()) - # return np.sum((A - B) ** 2)**0.5 #very slow def angle(A, B, C): AB = A - B @@ -1430,6 +1501,9 @@ def dihedral(A, B, C, D): dihedral_angle = dihedral_angle * 180 / np.pi return dihedral_angle + + + #User-functions #atoms is a list of atom indices, def distance_between_atoms(fragment=None, atoms=None): @@ -1720,9 +1794,9 @@ def create_coords_string(elems, coords): # Takes list of elements and gives formula def elemlisttoformula(list): # This dict comprehension was slow for large systems. Using set to reduce iterations - dict = {i: list.count(i) for i in set(list)} + elemdict = {i: list.count(i) for i in set(list)} formula = "" - for item in dict.items(): + for item in elemdict.items(): el = item[0] count = item[1] # string=el+str(count) @@ -1936,7 +2010,7 @@ def split_multimolxyzfile(file, writexyz=False, skipindex=1,return_fragments=Fal # Grab title if titlegrab is True: if len(line.split()) > 0: - all_titles.append(line.split()[-1]) + all_titles.append(line.split()) else: all_titles.append("NA") titlegrab = False @@ -2318,10 +2392,10 @@ def write_pdbfile(fragment, outputname="ASHfragment", openmmobject=None, atomnam # Using last 4 letters of atomnmae atomnamestring = atomname[-4:] - #TODO: atomname should be unique so we should add a number here ideally + + if not any(char.isdigit() for char in atomnamestring): + atomnamestring=atomnamestring+str(count+1) - #print(atomnamestring) - #exit() # Using string format from: cupnet.net/pdb-format/ #NOTE: Changed resid from integer to string so that we can support the hex notation for resids when resids go above 9999 @@ -3023,6 +3097,103 @@ def reorder(reorder_method, p_coord, q_coord, p_atoms, q_atoms): +########################################## +# MOLECULAR CRYSTAL PBC FUNCTIONS +########################################## + + +# Extend cell in general with original cell in center +# NOTE: Taken from functions_molcrys. +# TODO: Remove function from functions_molcrys +def cell_extend_frag(cellvectors, coords, elems, cellextpars): + printdebug("cellextpars:", cellextpars) + permutations = [] + for i in range(int(cellextpars[0])): + for j in range(int(cellextpars[1])): + for k in range(int(cellextpars[2])): + permutations.append([i, j, k]) + permutations.append([-i, j, k]) + permutations.append([i, -j, k]) + permutations.append([i, j, -k]) + permutations.append([-i, -j, k]) + permutations.append([i, -j, -k]) + permutations.append([-i, j, -k]) + permutations.append([-i, -j, -k]) + # Removing duplicates and sorting + permutations = sorted([list(x) for x in set(tuple(x) for x in permutations)], + key=lambda x: (abs(x[0]), abs(x[1]), abs(x[2]))) + # permutations = permutations.sort(key=lambda x: x[0]) + printdebug("Num permutations:", len(permutations)) + numcells = np.prod(cellextpars) + numcells = len(permutations) + extended = np.zeros((len(coords) * numcells, 3)) + new_elems = [] + index = 0 + for perm in permutations: + shift = cellvectors[0:3, 0:3] * perm + shift = shift[:, 0] + shift[:, 1] + shift[:, 2] + # print("Permutation:", perm, "shift:", shift) + for d, el in zip(coords, elems): + new_pos = d + shift + extended[index] = new_pos + new_elems.append(el) + # print("extended[index]", extended[index]) + # print("extended[index+1]", extended[index+1]) + index += 1 + printdebug("extended coords num", len(extended)) + printdebug("new_elems num,", len(new_elems)) + return extended, new_elems + + +# From Pymol. Not sure if useful +# NOTE: also in functions_molcrys +def cellbasis(angles, edges): + from math import cos, sin, radians, sqrt + """ + For the unit cell with given angles and edge lengths calculate the basis + transformation (vectors) as a 4x4 numpy.array + """ + rad = [radians(i) for i in angles] + basis = np.identity(4) + basis[0][1] = cos(rad[2]) + basis[1][1] = sin(rad[2]) + basis[0][2] = cos(rad[1]) + basis[1][2] = (cos(rad[0]) - basis[0][1] * basis[0][2]) / basis[1][1] + basis[2][2] = sqrt(1 - basis[0][2] ** 2 - basis[1][2] ** 2) + edges.append(1.0) + return basis * edges # numpy.array multiplication! + +# Create a molecular cluster from a periodix box based on radius and chosen atom(s) + +def make_cluster_from_box(fragment=None, radius=10, center_atomindices=[0], cellparameters=None): + print_line_with_subheader2("Make cluster from box") + # Choosing how far to extend cell based on chosen cluster-radius + if radius < cellparameters[0]: + cellextension = [2, 2, 2] + else: + cellextension = [3, 3, 3] + + print("Cell parameters:", cellparameters) + print("Radius: {} Å".format(radius)) + print("Cell extension used: ", cellextension) + print("Cluster will be centered on atom indices: ", center_atomindices) + + # Extend cell + cellvectors = cellbasis(cellparameters[3:6], cellparameters[0:3]) + ext_coords, ext_elems = cell_extend_frag(cellvectors, fragment.coords, fragment.elems, cellextension) + print("Size of extended cell: ", len(ext_elems)) + extcellfrag = Fragment(elems=ext_elems, coords=ext_coords, printlevel=2) + # Cut cluster with radius R from extended cell, centered on atomic index. Returns list of atoms + atomlist = QMregionfragexpand(fragment=extcellfrag, initial_atoms=center_atomindices, radius=radius) + + # Grabbing coords and elems from atomlist and creating new fragment + clustercoords = np.take(ext_coords, atomlist, axis=0) + clusterelems = [ext_elems[i] for i in atomlist] + newfrag = Fragment(elems=clusterelems, coords=clustercoords, printlevel=0) + + return newfrag + + # QM-region expand function. Finds whole fragments. # Used by molcrys. Similar to get_solvshell function in functions_solv.py def QMregionfragexpand(fragment=None, initial_atoms=None, radius=None): @@ -3185,16 +3356,18 @@ def get_boundary_atoms(qmatoms, coords, elems, scale, tol, excludeboundaryatomli if len(boundaryatom) > 1: print(BC.FAIL, - "Problem. Found more than 1 boundaryatom for QM-atom {} . This is not allowed".format(qmatom), + "Warning. Found more than 1 boundaryatom for QM-atom {} . This is considered unusual".format(qmatom), BC.END) print("This typically either happens when your QM-region is badly defined or a QM-atom is clashing with an MM atom") print("QM atom : ", qmatom) print("MM Boundaryatoms (connected to QM-atom based on distance) : ", boundaryatom) - print("Please define the QM-region so that only 1 linkatom would be required.") + #print("Please define the QM-region so that only 1 linkatom would be required.") print("MM Boundary atom coordinates (for debugging):") for b in boundaryatom: print(f"{b} {elems[b]} {coords[b][0]} {coords[b][1]} {coords[b][2]}") - ashexit() + # Adding to dict + qm_mm_boundary_dict[qmatom] = boundaryatom + #ashexit() elif len(boundaryatom) == 1: # Warn if QM-MM boundary is not a plain-vanilla C-C bond @@ -3210,7 +3383,7 @@ def get_boundary_atoms(qmatoms, coords, elems, scale, tol, excludeboundaryatomli print(BC.WARNING, "To override exit, add: unusualboundary=True to QMMMTheory object ", BC.END) ashexit() # Adding to dict - qm_mm_boundary_dict[qmatom] = boundaryatom[0] + qm_mm_boundary_dict[qmatom] = [boundaryatom[0]] print("QM-MM boundary dictionary:", qm_mm_boundary_dict) print_time_rel(timeA, modulename="get_boundary_atoms") return qm_mm_boundary_dict @@ -3234,7 +3407,7 @@ def get_linkatom_positions(qm_mm_boundary_dict, qmatoms, coords, elems, linkatom print("linkatom_simple_distance was set by user:", linkatom_simple_distance) #Dict of linkatom distances for different elements linkdistances_dict = {('C', 'H'): 1.09, ('O', 'H'): 0.98, ('N', 'H'): 0.99} - print("Linkdatom distance dictionary:", linkdistances_dict) + print("Linkatom distance dictionary:", linkdistances_dict) # If dictionary of linkatom-distances provided then use that instead if linkatom_method == 'ratio': if linkatom_ratio == 'Auto' and bondpairs_eq_dict is None: @@ -3246,50 +3419,52 @@ def get_linkatom_positions(qm_mm_boundary_dict, qmatoms, coords, elems, linkatom print("qm_mm_boundary_dict:", qm_mm_boundary_dict) # Get coordinates for QMX and MMX pair. Create new L coordinate that has a modified distance to QMX linkatoms_dict = {} + # Looping over QM-MM boundaries for dict_item in qm_mm_boundary_dict.items(): - qmatom_coords = np.array(coords[dict_item[0]]) - mmatom_coords = np.array(coords[dict_item[1]]) - - #Determine linkatom distance - if linkatom_method == 'ratio': - #print("Linkatom method: ratio") - if linkatom_ratio == 'Auto': - print("Automatic ratio. Determining ratio based on dict of equilibrium distances") - #TODO - R_eq_QM_H = bondpairs_eq_dict[(elems[dict_item[0]], linkatom_type)] - R_eq_QM_MM = bondpairs_eq_dict[(elems[dict_item[0]], elems[dict_item[1]])] - print("R_eq_QM_H:", R_eq_QM_H) - print("R_eq_QM_MM:", R_eq_QM_MM) - linkatom_ratio = R_eq_QM_H / R_eq_QM_MM - print("Determined ratio:", linkatom_ratio) - print("not yet ready") - ashexit() - r_QM1_MM1 = distance(qmatom_coords, mmatom_coords) - # See https://www.ncbi.nlm.nih.gov/pmc/articles/PMC9314059/ - linkatom_coords = linkatom_ratio *(mmatom_coords - qmatom_coords) + qmatom_coords - #linkatom_distance = r_QM1_MM1 * (bondpairs_eq_dict[(elems[dict_item[0]], 'H')] / bondpairs_eq_dict[(elems[dict_item[0]], elems[dict_item[1]])]) - linkatom_distance = distance(qmatom_coords, linkatom_coords) - print(f"Linkatom distance (QM1-L) determined to be: {linkatom_distance} (using ratio {linkatom_ratio})") - elif linkatom_method == 'simple': - #print("Linkatom method: simple") - if linkatom_simple_distance is None: - #print("linkatom_simple_distance not set. Getting standard distance from dictionary for element:", elems[dict_item[0]]) - #Getting from dict - linkatom_distance = linkdistances_dict[(elems[dict_item[0]], linkatom_type)] + qmatom=dict_item[0] + # Looping over MM-atoms in boundary (i.e. we can have a MM1-QM1-MM1 situation e.g. requiring multiple linkatoms) + for mmatom in dict_item[1]: + qmatom_coords = np.array(coords[qmatom]) + mmatom_coords = np.array(coords[mmatom]) + #Determine linkatom distance + if linkatom_method == 'ratio': + #print("Linkatom method: ratio") + if linkatom_ratio == 'Auto': + print("Automatic ratio. Determining ratio based on dict of equilibrium distances") + #TODO + R_eq_QM_H = bondpairs_eq_dict[(elems[qmatom], linkatom_type)] + R_eq_QM_MM = bondpairs_eq_dict[(elems[qmatom], elems[mmatom])] + print("R_eq_QM_H:", R_eq_QM_H) + print("R_eq_QM_MM:", R_eq_QM_MM) + linkatom_ratio = R_eq_QM_H / R_eq_QM_MM + print("Determined ratio:", linkatom_ratio) + print("not yet ready") + ashexit() + r_QM1_MM1 = distance(qmatom_coords, mmatom_coords) + # See https://www.ncbi.nlm.nih.gov/pmc/articles/PMC9314059/ + linkatom_coords = linkatom_ratio *(mmatom_coords - qmatom_coords) + qmatom_coords + #linkatom_distance = r_QM1_MM1 * (bondpairs_eq_dict[(elems[dict_item[0]], 'H')] / bondpairs_eq_dict[(elems[dict_item[0]], elems[dict_item[1]])]) + linkatom_distance = distance(qmatom_coords, linkatom_coords) + print(f"Linkatom distance (QM1-L) determined to be: {linkatom_distance} (using ratio {linkatom_ratio})") + elif linkatom_method == 'simple': + #print("Linkatom method: simple") + if linkatom_simple_distance is None: + #print("linkatom_simple_distance not set. Getting standard distance from dictionary for element:", elems[dict_item[0]]) + #Getting from dict + linkatom_distance = linkdistances_dict[(elems[qmatom], linkatom_type)] + else: + #print("linkatom_simple_distance was set by user:", linkatom_simple_distance) + #Getting from user + linkatom_distance = linkatom_simple_distance + print("Linkatom distance (QM1-L) is:", linkatom_distance) + #Determining coords + linkatom_coords = list(qmatom_coords + (mmatom_coords - qmatom_coords) * ( + linkatom_distance / distance(qmatom_coords, mmatom_coords))) else: - #print("linkatom_simple_distance was set by user:", linkatom_simple_distance) - #Getting from user - linkatom_distance = linkatom_simple_distance - print("Linkatom distance (QM1-L) is:", linkatom_distance) - #Determining coords - linkatom_coords = list(qmatom_coords + (mmatom_coords - qmatom_coords) * ( - linkatom_distance / distance(qmatom_coords, mmatom_coords))) - else: - print("Invalid linkatom_method. Exiting.") - ashexit() - - linkatoms_dict[(dict_item[0], dict_item[1])] = linkatom_coords - #print_time_rel(timeA, modulename="get_linkatom_positions") + print("Invalid linkatom_method. Exiting.") + ashexit() + + linkatoms_dict[(qmatom, mmatom)] = linkatom_coords return linkatoms_dict @@ -3309,120 +3484,6 @@ def get_molecules_from_trajectory(file, writexyz=False, skipindex=1, conncalc=Fa return list_of_molecules -# Extend cell in general with original cell in center -# NOTE: Taken from functions_molcrys. -# TODO: Remove function from functions_molcrys -def cell_extend_frag(cellvectors, coords, elems, cellextpars): - printdebug("cellextpars:", cellextpars) - permutations = [] - for i in range(int(cellextpars[0])): - for j in range(int(cellextpars[1])): - for k in range(int(cellextpars[2])): - permutations.append([i, j, k]) - permutations.append([-i, j, k]) - permutations.append([i, -j, k]) - permutations.append([i, j, -k]) - permutations.append([-i, -j, k]) - permutations.append([i, -j, -k]) - permutations.append([-i, j, -k]) - permutations.append([-i, -j, -k]) - # Removing duplicates and sorting - permutations = sorted([list(x) for x in set(tuple(x) for x in permutations)], - key=lambda x: (abs(x[0]), abs(x[1]), abs(x[2]))) - # permutations = permutations.sort(key=lambda x: x[0]) - printdebug("Num permutations:", len(permutations)) - numcells = np.prod(cellextpars) - numcells = len(permutations) - extended = np.zeros((len(coords) * numcells, 3)) - new_elems = [] - index = 0 - for perm in permutations: - shift = cellvectors[0:3, 0:3] * perm - shift = shift[:, 0] + shift[:, 1] + shift[:, 2] - # print("Permutation:", perm, "shift:", shift) - for d, el in zip(coords, elems): - new_pos = d + shift - extended[index] = new_pos - new_elems.append(el) - # print("extended[index]", extended[index]) - # print("extended[index+1]", extended[index+1]) - index += 1 - printdebug("extended coords num", len(extended)) - printdebug("new_elems num,", len(new_elems)) - return extended, new_elems - - -# From Pymol. Not sure if useful -# NOTE: also in functions_molcrys -def cellbasis(angles, edges): - from math import cos, sin, radians, sqrt - """ - For the unit cell with given angles and edge lengths calculate the basis - transformation (vectors) as a 4x4 numpy.array - """ - rad = [radians(i) for i in angles] - basis = np.identity(4) - basis[0][1] = cos(rad[2]) - basis[1][1] = sin(rad[2]) - basis[0][2] = cos(rad[1]) - basis[1][2] = (cos(rad[0]) - basis[0][1] * basis[0][2]) / basis[1][1] - basis[2][2] = sqrt(1 - basis[0][2] ** 2 - basis[1][2] ** 2) - edges.append(1.0) - return basis * edges # numpy.array multiplication! - -# Create a molecular cluster from a periodix box based on radius and chosen atom(s) - -def make_cluster_from_box(fragment=None, radius=10, center_atomindices=[0], cellparameters=None): - print_line_with_subheader2("Make cluster from box") - # Choosing how far to extend cell based on chosen cluster-radius - if radius < cellparameters[0]: - cellextension = [2, 2, 2] - else: - cellextension = [3, 3, 3] - - print("Cell parameters:", cellparameters) - print("Radius: {} Å".format(radius)) - print("Cell extension used: ", cellextension) - print("Cluster will be centered on atom indices: ", center_atomindices) - - # Extend cell - cellvectors = cellbasis(cellparameters[3:6], cellparameters[0:3]) - ext_coords, ext_elems = cell_extend_frag(cellvectors, fragment.coords, fragment.elems, cellextension) - print("Size of extended cell: ", len(ext_elems)) - extcellfrag = ash.Fragment(elems=ext_elems, coords=ext_coords, printlevel=2) - # Cut cluster with radius R from extended cell, centered on atomic index. Returns list of atoms - atomlist = QMregionfragexpand(fragment=extcellfrag, initial_atoms=center_atomindices, radius=radius) - - # Grabbing coords and elems from atomlist and creating new fragment - clustercoords = np.take(ext_coords, atomlist, axis=0) - clusterelems = [ext_elems[i] for i in atomlist] - newfrag = ash.Fragment(elems=clusterelems, coords=clustercoords, printlevel=0) - - return newfrag - - -# Set up constraints -# def set_up_MMwater_bondconstraints(actatoms, oxygentype='OT'): -# print("set_up_MMwater_bondconstraints") -# print("Assuming oxygen atom type is: ", oxygentype) -# print("Change with keyword arguement: oxygentype='XX") -# ashexit() -# # Go over actatoms and check if oxygen-water type - -# # Shift nested list by number e.g. shift([[1,2],[100,101]], -1) gives : [[0,1],[99,100]] -# # TODO: generalize -# def shift_nested(ll, par): -# new = [] -# for l in ll: -# new.append([l[0] + par, l[1] + par]) -# return new - -# bondconslist = shift_nested(bondlist, -1) -# constraints = {'bond': bondconslist} - -# return constraints - - # Function to update list of atomindices after deletion of a list of atom indices (used in remove_atoms functions below) def update_atom_indices_upon_deletion(atomlist, dellist): # Making sure dellist is sorted and determining highest and lowest value @@ -3777,10 +3838,9 @@ def is_even(number): #Check if charge/mult variables are not None. If None check fragment #Only done for QM theories not MM. Passing theorytype string (e.g. from theory.theorytype if available) def check_charge_mult(charge, mult, theorytype, fragment, jobtype, theory=None, printlevel=2): - #Check if QM or QM/MM theory if theorytype == "QM": - if charge == None or mult == None: + if charge is None or mult is None: if printlevel >= 2: print(BC.WARNING,f"Charge/mult was not provided to {jobtype}",BC.END) if fragment.charge != None and fragment.mult != None: @@ -3938,149 +3998,6 @@ def simple_get_water_constraints(fragment,starting_index=None, onlyHH=False): return constraints -#Function to convert Mol file to PDB-file via OpenBabel -def mol_to_pdb(file): - #OpenBabel - try: - from openbabel import pybel - except ModuleNotFoundError: - print("Error: mol_to_pdb requires OpenBabel library but it could not be imported") - print("You can install like this: conda install --yes -c conda-forge openbabel") - ashexit() - mol = next(pybel.readfile("mol", file)) - mol.write(format='pdb', filename=os.path.splitext(file)[0]+'.pdb', overwrite=True) - print("Wrote PDB-file:", os.path.splitext(file)[0]+'.pdb') - return os.path.splitext(file)[0]+'.pdb' - -#Function to convert SDF file to PDB-file via OpenBabel -def sdf_to_pdb(file): - #OpenBabel - try: - from openbabel import openbabel - from openbabel import pybel - except ModuleNotFoundError: - print("Error: sdf_to_pdb requires OpenBabel library but it could not be imported") - print("You can install like this: conda install --yes -c conda-forge openbabel") - ashexit() - mol = next(pybel.readfile("sdf", file)) - - #Write do disk as PDB-file - mol.write(format='pdb', filename=os.path.splitext(file)[0]+'temp.pdb', overwrite=True) - #Read-in again (this will create a Residue) - newmol = next(pybel.readfile("pdb", os.path.splitext(file)[0]+'temp.pdb')) - os.remove(os.path.splitext(file)[0]+'temp.pdb') - - #Atomlabel = {0:'C1',1:'X',2:'C',3:'C',4:'C',5:'C',6:'C',7:'C',8:'C',9:'C',10:'C',11:'C',12:'C'} - #Change atomnames (AtomIDs) to something sensible (OpenBabel does not do this by default) - print("Creating new atomnames for PDBfile") - #Note: currently just combining element and atomindex to get a unique atomname (otherwise Modeller will not work) - #TODO: make something better (element-specific numbering?) - for res in pybel.ob.OBResidueIter(newmol.OBMol): - for i,atom in enumerate(openbabel.OBResidueAtomIter(res)): - atomname = res.GetAtomID(atom) - #print("atomname:", atomname) - res.SetAtomID(atom,atomname.strip()+str(i+1)) - atomname = res.GetAtomID(atom) - #print("atomname:", atomname) - #res.SetAtomID(atom,Atomlabel[i]) - - #Write final PDB-file - newmol.write(format='pdb', filename=os.path.splitext(file)[0]+'.pdb', overwrite=True) - print("Wrote PDB-file:", os.path.splitext(file)[0]+'.pdb') - return os.path.splitext(file)[0]+'.pdb' - -#Function to read in PDB-file and write new one with CONECT lines (geometry needs to be sensible) -#NOTE: Requires OpenBabel which seems unnecessary, probably better to use OpenMM functionality instead -def writepdb_with_connectivity(file): - #OpenBabel - try: - from openbabel import pybel - except ModuleNotFoundError: - print("Error: writepdb_with_connectivity requires OpenBabel library but it could not be imported") - print("You can install like this: conda install --yes -c conda-forge openbabel") - ashexit() - mol = next(pybel.readfile("pdb", file)) - mol.write(format='pdb', filename=os.path.splitext(file)[0]+'_withcon.pdb', overwrite=True) - print("Wrote PDB-file:", os.path.splitext(file)[0]+'_withcon.pdb') - return os.path.splitext(file)[0]+'_withcon.pdb' - -#Function to read in XYZ-file (small molecule) and create PDB-file with CONECT lines (geometry needs to be sensible) -def xyz_to_pdb_with_connectivity(file, resname="UNL"): - print("xyz_to_pdb_with_connectivity function:") - #OpenBabel - try: - from openbabel import openbabel - from openbabel import pybel - except ModuleNotFoundError: - print("Error: xyz_to_pdb_with_connectivity requires OpenBabel library but it could not be imported") - print("You can install OpenBabel like this: conda install --yes -c conda-forge openbabel") - ashexit() - #Read in XYZ-file - mol = next(pybel.readfile("xyz", file)) - #Write do disk as PDB-file - mol.write(format='pdb', filename=os.path.splitext(file)[0]+'temp.pdb', overwrite=True) - #Read-in again (this will create a Residue) - newmol = next(pybel.readfile("pdb", os.path.splitext(file)[0]+'temp.pdb')) - - os.remove(os.path.splitext(file)[0]+'temp.pdb') - - #Atomlabel = {0:'C1',1:'X',2:'C',3:'C',4:'C',5:'C',6:'C',7:'C',8:'C',9:'C',10:'C',11:'C',12:'C'} - #Change atomnames (AtomIDs) to something sensible (OpenBabel does not do this by default) - print("Creating new atomnames for PDBfile") - #Note: currently just combining element and atomindex to get a unique atomname (otherwise Modeller will not work) - #TODO: make something better (element-specific numbering?) - for res in pybel.ob.OBResidueIter(newmol.OBMol): - #Setting residue name - res.SetName(resname) - for i,atom in enumerate(openbabel.OBResidueAtomIter(res)): - atomname = res.GetAtomID(atom) - #print("atomname:", atomname) - res.SetAtomID(atom,atomname.strip()+str(i+1)) - atomname = res.GetAtomID(atom) - #print("atomname:", atomname) - #res.SetAtomID(atom,Atomlabel[i]) - - #Write final PDB-file - newmol.write(format='pdb', filename=os.path.splitext(file)[0]+'.pdb', overwrite=True) - print("Wrote PDB-file:", os.path.splitext(file)[0]+'.pdb') - return os.path.splitext(file)[0]+'.pdb' - -#Function to convert PDB-file to SMILES string -def pdb_to_smiles(fname: str) -> str: - #OpenBabel - try: - from openbabel import pybel - except ModuleNotFoundError: - print("Error: pdb_to_smiles requires OpenBabel library but it could not be imported") - print("You can install like this: conda install --yes -c conda-forge openbabel") - ashexit() - mol = next(pybel.readfile("pdb", fname)) - smi = mol.write(format="smi") - return smi.split()[0].strip() - -#Function to convert PDB-file to SMILES string -def smiles_to_coords(smiles_string): - #OpenBabel - try: - from openbabel import pybel - from openbabel import openbabel - except ModuleNotFoundError: - print("Error: smiles_to_coords requires OpenBabel library but it could not be imported") - print("You can install like this: conda install --yes -c conda-forge openbabel") - ashexit() - print("Reading SMILES by OpenBabel") - mol = pybel.readstring("smi", smiles_string) - print("Guessing 3D coordinates (uses MMFF94 forcefield)") - mol.make3D() - b_mol = mol.OBMol - atomnums = [] - coords = [] - for atom in openbabel.OBMolAtomIter(b_mol): - atomnums.append(atom.GetAtomicNum()) - coords.append([atom.GetX(), atom.GetY(), atom.GetZ()]) - elems = [reformat_element(atn, isatomnum=True) for atn in atomnums] - #frag = Fragment(elems=elems, coords=coords, charge=charge, mult=mult) - return elems, coords #Function that adds R-group to an ASH fragment def swap_R_group(fragment=None, Rgroup=None, atomindex=None) -> Fragment: @@ -4114,33 +4031,6 @@ def swap_R_group(fragment=None, Rgroup=None, atomindex=None) -> Fragment: return newfragment -#Function that calculates box size of a molecule in a cubic box -#with optional shift -def cubic_box_size(coords, shift=0.0): - # max and min for x,y,z coords - max_values = np.max(coords, axis=0) - min_values = np.min(coords, axis=0) - #Differences for x,y,z - span_x = max_values[0] - min_values[0] - span_y = max_values[1] - min_values[1] - span_z = max_values[2] - min_values[2] - # Max span for each x,y,z - max_span = max(span_x, span_y, span_z) - #Optional shift - final_span = max_span + shift - return final_span - -#More general -def bounding_box_dimensions(coordinates,shift=0.0): - # Get max and min values for x, y, z coordinates - max_values = np.max(coordinates, axis=0) - min_values = np.min(coordinates, axis=0) - - # Calculate the differences along each axis to determine dimensions - dimensions = max_values - min_values - final_dims = dimensions + shift - return dimensions # Return the dimensions of the bounding box - # Combien and place 2 fragments def combine_and_place_fragments(ref_frag, trans_frag): @@ -4318,3 +4208,29 @@ def find_nearest_atom(a,b): print("Atom coordinates:", a[idx_min]) return int(idx_min[0]), a[idx_min] +# Very simple dummy topology (no connectivity or bonds) +def define_dummy_topology(elems,scale=1.0, tol=0.1, resname="MOL"): + try: + import openmm.app + except ImportError: + print("Error: OpenMM not found. Cannot define a topology") + ashexit() + print("Defining new basic single-chain, multi-residue topology") + pdb_topology = openmm.app.Topology() + chain = pdb_topology.addChain() + #Looping over molecules defined by connectivity + residue = pdb_topology.addResidue(resname, chain) + + # Defaultdictionary to keep track of unique element-atomnames + atomnames_dict=defaultdict(int) + for el in elems: + #el = elems[at] + atomnumber = openmm.app.Element.getBySymbol(el).atomic_number + element = openmm.app.Element.getByAtomicNumber(atomnumber) + # Define unique atomname + atomnames_dict[el] += 1 + atomname = f"{el}{atomnames_dict[el]}" + pdb_topology.addAtom(atomname, element, residue) + return pdb_topology + + diff --git a/ash/modules/module_coords_PBC.py b/ash/modules/module_coords_PBC.py new file mode 100644 index 000000000..af2e733c3 --- /dev/null +++ b/ash/modules/module_coords_PBC.py @@ -0,0 +1,244 @@ +import numpy as np +from ash.functions.functions_general import ashexit + +# This module contains functions for handling periodic boundary conditions (PBC) and related coordinate transformations. + + + +#Function that calculates box size of a molecule in a cubic box +#with optional shift +def cubic_box_size(coords, shift=0.0): + # max and min for x,y,z coords + max_values = np.max(coords, axis=0) + min_values = np.min(coords, axis=0) + #Differences for x,y,z + span_x = max_values[0] - min_values[0] + span_y = max_values[1] - min_values[1] + span_z = max_values[2] - min_values[2] + # Max span for each x,y,z + max_span = max(span_x, span_y, span_z) + #Optional shift + final_span = max_span + shift + return final_span + +#More general +def bounding_box_dimensions(coordinates,shift=0.0): + # Get max and min values for x, y, z coordinates + max_values = np.max(coordinates, axis=0) + min_values = np.min(coordinates, axis=0) + + # Calculate the differences along each axis to determine dimensions + dimensions = max_values - min_values + final_dims = dimensions + shift + return dimensions # Return the dimensions of the bounding box + + + +def cell_params_to_vectors(parameters): + a, b, c, alpha, beta, gamma = parameters + # Convert angles to radians + rad_a = np.radians(alpha) + rad_b = np.radians(beta) + rad_g = np.radians(gamma) + + # Calculate components + ax = a + ay = 0.0 + az = 0.0 + + bx = b * np.cos(rad_g) + by = b * np.sin(rad_g) + bz = 0.0 + + cx = c * np.cos(rad_b) + cy = c * (np.cos(rad_a) - np.cos(rad_b) * np.cos(rad_g)) / np.sin(rad_g) + cz = np.sqrt(c**2 - cx**2 - cy**2) + + vectors = np.array([[ax,ay,az],[bx,by,bz],[cx,cy,cz]]) + return vectors + +def cell_vectors_to_params(vectors): + va, vb, vc = vectors[0], vectors[1], vectors[2] + + # Calculate lengths (norms) + a = np.linalg.norm(va) + b = np.linalg.norm(vb) + c = np.linalg.norm(vc) + + # Calculate angles using the dot product formula: + # cos(theta) = (v1 . v2) / (|v1| * |v2|) + alpha_rad = np.arccos(np.dot(vb, vc) / (b * c)) + beta_rad = np.arccos(np.dot(va, vc) / (a * c)) + gamma_rad = np.arccos(np.dot(va, vb) / (a * b)) + + # Convert radians to degrees + alpha = np.degrees(alpha_rad) + beta = np.degrees(beta_rad) + gamma = np.degrees(gamma_rad) + + return [float(a), float(b), float(c), float(alpha), float(beta), float(gamma)] + +# Basic conversion of Cartesian coordinates to fractional coordinates and reverse +def cart_coords_to_fract(cart_coords, cellvectors): + M = np.array(cellvectors) + frac = np.dot(cart_coords, np.linalg.inv(M)) + return frac + +def fract_coords_to_cart(fract_coords, cellvectors): + cart = np.dot(fract_coords, np.array(cellvectors)) + return cart + +def cell_volume(vectors): + a = vectors[0,:] + b = vectors[1,:] + c = vectors[2,:] + V = abs(np.dot(a, np.cross(b, c))) + return V + +# Write Cartesian-based POSCAR files +def write_POSCAR_file(coords,elems,cellvectors=None, celldimensions=None, filename="POSCAR"): + + if cellvectors is None and celldimensions is None: + print("Error: Either cellvectors or celldimensions should be provided") + ashexit() + elif celldimensions is not None: + # converting + cellvectors=cell_params_to_vectors(celldimensions) + + # Unique elements in original order + unique_elements = [] + for e in elems: + if e not in unique_elements: + unique_elements.append(e) + # Count atoms of each elemtype + counts = [elems.count(e) for e in unique_elements] + + with open(filename, 'w') as f: + f.write("ASH created POSCAR file"+"\n") + f.write("1.0"+"\n") + f.write(f"{cellvectors[0,0]:.4f} {cellvectors[0,1]:.4f} {cellvectors[0,2]:.4f} "+"\n") + f.write(f"{cellvectors[1,0]:.4f} {cellvectors[1,1]:.4f} {cellvectors[1,2]:.4f}"+"\n") + f.write(f"{cellvectors[2,0]:.4f} {cellvectors[2,1]:.4f} {cellvectors[2,2]:.4f}"+"\n") + f.write(f"{' '.join(unique_elements)}\n") + f.write(f"{' '.join(map(str, counts))}\n") + f.write(f"Cartesian"+"\n")# coord system + for target_el in unique_elements: + for el, c in zip(elems, coords): + if el == target_el: + f.write(f"{c[0]:.8f} {c[1]:.8f} {c[2]:.8f}\n") + print("Wrote POSCAR file") + return filename + +# Write XSF files +def write_XSF_file(coords, elems, cellvectors=None, celldimensions=None, filename="structure.xsf"): + + if cellvectors is None and celldimensions is None: + print("Error: Either cellvectors or celldimensions should be provided") + ashexit() + elif celldimensions is not None: + # Assuming your helper function handles the conversion + cellvectors = cell_params_to_vectors(celldimensions) + + with open(filename, 'w') as f: + # Header for periodic structures + f.write("CRYSTAL\n") + + # Section 1: Lattice Vectors + f.write("PRIMVEC\n") + for i in range(3): + f.write(f" {cellvectors[i,0]:.10f} {cellvectors[i,1]:.10f} {cellvectors[i,2]:.10f}\n") + + # Section 2: Atomic Coordinates + f.write("PRIMCOORD\n") + # Header for coordinates: [Number of atoms] [Number of units, usually 1] + f.write(f"{len(elems)} 1\n") + + # XSF supports either Atomic Number or Element Symbol. + # Using Element Symbol is more human-readable and works perfectly in VMD. + for el, c in zip(elems, coords): + f.write(f"{el} {c[0]:.10f} {c[1]:.10f} {c[2]:.10f}\n") + + print(f"Wrote XSF file: {filename}") + return filename + + +def write_CIF_file(coords, elems, cellvectors=None, celldimensions=None, filename="structure.cif"): + + if cellvectors is None and celldimensions is None: + print("Error: Either cellvectors or celldimensions should be provided") + ashexit() + elif celldimensions is not None: + # Assuming your helper function handles the conversion + cellvectors = cell_params_to_vectors(celldimensions) + elif cellvectors is not None: + celldimensions = cell_vectors_to_params(cellvectors) + + # Cart to fract + frac_coords = cart_coords_to_fract(coords,cellvectors) + + # celldimensions should be [a, b, c, alpha, beta, gamma] + a, b, c, alpha, beta, gamma = celldimensions + + with open(filename, 'w') as f: + f.write("data_ASH_output\n") + f.write(f"_cell_length_a {a:.6f}\n") + f.write(f"_cell_length_b {b:.6f}\n") + f.write(f"_cell_length_c {c:.6f}\n") + f.write(f"_cell_angle_alpha {alpha:.6f}\n") + f.write(f"_cell_angle_beta {beta:.6f}\n") + f.write(f"_cell_angle_gamma {gamma:.6f}\n\n") + + # We use P1 symmetry (no symmetry) so every atom is listed explicitly + f.write("_symmetry_space_group_name_H-M 'P 1'\n") + f.write("_symmetry_Int_Tables_number 1\n\n") + + # The Atom Loop + f.write("loop_\n") + f.write("_atom_site_label\n") + f.write("_atom_site_type_symbol\n") + f.write("_atom_site_fract_x\n") + f.write("_atom_site_fract_y\n") + f.write("_atom_site_fract_z\n") + + for i, (el, c) in enumerate(zip(elems, frac_coords)): + # We add an index to the label (e.g., Na1, Na2) to keep them unique + f.write(f"{el}{i+1} {el} {c[0]:.8f} {c[1]:.8f} {c[2]:.8f}\n") + + print(f"Wrote CIF file: {filename}") + return filename + +def align_to_standard_orientation(fragment_coords, cell_vectors): + """ + Rotates the entire system (atoms and cell) into the standard + upper-triangular orientation. + + cell_vectors: 3x3 matrix where rows are [a, b, c] + fragment_coords: Nx3 array of atomic positions + """ + # 1. Transpose cell_vectors because QR works on columns + H = cell_vectors.T + + # 2. QR Decomposition + # H = Q * R -> R is the upper triangular matrix we want + Q, R = np.linalg.qr(H) + + # 3. Handle 'Flip' cases + # QR can sometimes return negative diagonal elements. + # We want lengths (a_x, b_y, c_z) to be positive. + d = np.sign(np.diag(R)) + # If a diagonal is 0, we treat it as positive + d[d == 0] = 1 + + # Correct Q and R so diagonals of R are positive + Q = Q * d + R = (R.T * d).T + + # 4. New Cell Vectors (R transposed back to rows) + new_cell_vectors = R.T + + # 5. New Atomic Coordinates + # We rotate the atoms using the same rotation matrix Q + # Since H_new = Q.T @ H_old, we use Q.T for the atoms + new_coords = np.dot(fragment_coords, Q) + + return new_coords, new_cell_vectors diff --git a/ash/modules/module_freq.py b/ash/modules/module_freq.py index 5cae17db3..e8115a608 100644 --- a/ash/modules/module_freq.py +++ b/ash/modules/module_freq.py @@ -437,10 +437,13 @@ def NumFreq(fragment=None, theory=None, charge=None, mult=None, npoint=2, displa Hessrow=(grad_pos_1d - original_grad_1d)/displacement_bohr hessian[hessindex,:]=Hessrow grad_pos_1d=0 - #IR #IR intensities if dipoles available if len(displacement_dipole_dictionary) > 0: - if len(displacement_dipole_dictionary[lookup_string_pos]) > 0: + # Make sure it's not a dict of None's + if any(value is None for value in displacement_dipole_dictionary.values()): + #print("None values in displacement_dipole_dictionary. Skipping IR") + pass + elif len(displacement_dipole_dictionary[lookup_string_pos]) > 0: disp_dipole = np.array(displacement_dipole_dictionary[lookup_string_pos]) dd_deriv = (disp_dipole - original_dipole)/displacement_bohr dipole_derivs[hessindex,:] = dd_deriv @@ -484,7 +487,11 @@ def NumFreq(fragment=None, theory=None, charge=None, mult=None, npoint=2, displa #IR intensities if dipoles available if len(displacement_dipole_dictionary) > 0: - if len(displacement_dipole_dictionary[lookup_string_pos]) > 0: + # Make sure it's not a dict of None's + if any(value is None for value in displacement_dipole_dictionary.values()): + #print("None values in displacement_dipole_dictionary. Skipping IR") + pass + elif len(displacement_dipole_dictionary[lookup_string_pos]) > 0: disp_dipole_pos = np.array(displacement_dipole_dictionary[lookup_string_pos]) disp_dipole_neg = np.array(displacement_dipole_dictionary[lookup_string_neg]) dd_deriv = (disp_dipole_pos - disp_dipole_neg)/(2*displacement_bohr) @@ -858,33 +865,45 @@ def thermochemcalc(vfreq,atoms,fragment, multiplicity, temp=298.15,pressure=1.0, print("\nDoing rotatational analysis:") # Moments of inertia (amu A^2 ), eigenvalues center = get_center(coords,elems=elems) - rinertia = list(inertia(elems,coords,center)) + #rinertia = list(inertia(elems,coords,center)) + rinertia = [float(i) for i in inertia(elems,coords,center)] + print("Moments of inertia (amu Å^2):", rinertia) #Changing units to m and kg I=np.array(rinertia)*ash.constants.amu2kg*ash.constants.ang2m**2 #Average I_av=(I[0]+I[1]+I[2])/3 - #Rotational temperatures - #k_b_JK or R_JK - rot_temps_x=ash.constants.h_planck**2 / (8*math.pi**2 * ash.constants.k_b_JK * I[0]) - rot_temps_y=ash.constants.h_planck**2 / (8*math.pi**2 * ash.constants.k_b_JK * I[1]) - rot_temps_z=ash.constants.h_planck**2 / (8*math.pi**2 * ash.constants.k_b_JK * I[2]) - print("Rotational temperatures: {}, {}, {} K".format(rot_temps_x,rot_temps_y,rot_temps_z)) - #Rotational constants - rotconstants = calc_rotational_constants(fragment, printlevel=1) #Rotational energy and entropy if moltype == "atom": q_r=1.0 S_rot=0.0 E_rot=0.0 elif moltype == "linear": + #Rotational temperatures (linear case) + rot_temps=[] + for in_I in I: + if in_I != 0.0: + rot_temps.append(float(ash.constants.h_planck**2 / (8*math.pi**2 * ash.constants.k_b_JK * in_I))) + print("Rotational temperatures: {} K".format(rot_temps)) + rot_temps_x=rot_temps[0] #Symmetry number sigma_r=1.0 q_r=(1/sigma_r)*(temp/(rot_temps_x)) S_rot=ash.constants.R_gasconst*(math.log(q_r)+1.0) E_rot=ash.constants.R_gasconst*temp + #Rotational constants + rotconstants = calc_rotational_constants(fragment, printlevel=1) else: #Nonlinear case + + #Rotational temperatures + rot_temps_x=ash.constants.h_planck**2 / (8*math.pi**2 * ash.constants.k_b_JK * I[0]) + rot_temps_y=ash.constants.h_planck**2 / (8*math.pi**2 * ash.constants.k_b_JK * I[1]) + rot_temps_z=ash.constants.h_planck**2 / (8*math.pi**2 * ash.constants.k_b_JK * I[2]) + print("Rotational temperatures: {}, {}, {} K".format(rot_temps_x,rot_temps_y,rot_temps_z)) + #Rotational constants + rotconstants = calc_rotational_constants(fragment, printlevel=1) + if symmetry_number is None: print("Case: nonlinear system and no user-provided symmetry_number.") print("Setting symmetry number to 1.0 (appropriate for C1, Ci and Cs pointgroups)") @@ -1262,7 +1281,8 @@ def calc_rotational_constants(frag, printlevel=2): coords=frag.coords elems=frag.elems center = get_center(coords,elems=elems) - rinertia = list(inertia(elems,coords,center)) + #rinertia = list(inertia(elems,coords,center)) + rinertia = [float(i) for i in inertia(elems,coords,center)] #Converting from moments of inertia in amu A^2 to rotational constants in Ghz. #COnversion factor from http://openmopac.net/manual/thermochemistry.html @@ -1974,8 +1994,8 @@ def detect_linear(fragment=None, coords=None, elems=None, threshold=1e-4): return True #Linear check via moments of inertia center = get_center(coords,elems=elems) - rinertia = list(inertia(elems,coords,center)) - #print("rinertia:", rinertia) + #rinertia = list(inertia(elems,coords,center)) + rinertia = [float(i) for i in inertia(elems,coords,center)] #Checking if rinertia contains an almost zero-value if any([abs(i) < threshold for i in rinertia]) is True: #print("Small value detected: ", rinertia) @@ -2197,7 +2217,8 @@ def project_rot_and_trans(coords,mass,Hessian): # Obtain the number of rotational degrees of freedom RotDOF = 0 for i in range(3): - if abs(Ivals[i]) > 1.0e-10: + print("Ivals[i]:", Ivals[i]) + if abs(Ivals[i]) > 1.0e-5: RotDOF += 1 TR_DOF = 3 + RotDOF if TR_DOF not in (5, 6): diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index 492e80708..3c675c38d 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -15,14 +15,15 @@ # Also helper tools for Torch and MLatom interfaces # Function to create ML training data given XYZ-files and 2 ASH theories -def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=None, num_snapshots=None, random_snapshots=True, +def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=None, xyz_files=None, num_snapshots=None, random_snapshots=True, dcd_pdb_topology=None, nth_frame_in_traj=1, printlevel=2, - theory_1=None, theory_2=None, charge=0, mult=1, Grad=True, runmode="serial", numcores=1): + theory_1=None, theory_2=None, charge=0, mult=1, Grad=True, runmode="serial", numcores=1, + energies_atoms_dict=None, random_seed_set=None): print("-"*50) print("create_ML_training_data function") print("-"*50) - if xyz_dir is None and xyz_trajectory is None and dcd_trajectory is None: - print("Error: create_ML_training_data requires xyz_dir, xyz_trajectory or dcd_trajectory option to be set!") + if xyz_dir is None and xyz_trajectory is None and xyz_files is None and dcd_trajectory is None: + print("Error: create_ML_training_data requires xyz_dir, xyz_trajectory,xyz_files or dcd_trajectory option to be set!") ashexit() if theory_1 is None: @@ -37,6 +38,7 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No print("xyz_dir:", xyz_dir) print("xyz_trajectory:", xyz_trajectory) + print("xyz_files:",xyz_files) print("dcd_trajectory:", dcd_trajectory) print("Charge:", charge) print("Mult:", mult) @@ -62,6 +64,10 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No print(f"Number of snapshots (num_snapshots keyword) set to {num_snapshots}") if random_snapshots is True: print(f"random_snapshots is True. Taking {num_snapshots} random XYZ snapshots.") + if random_seed_set is not None: + if isinstance(random_seed_set,int): + print("Setting random seed:", random_seed_set) + random.seed(random_seed_set) list_of_xyz_files = random.sample(full_list_of_xyz_files, num_snapshots) else: print("random_snapshots is False. Taking the first", num_snapshots, "snapshots.") @@ -102,6 +108,10 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No print(f"Number of snapshots (num_snapshots keyword) set to {num_snapshots}") if random_snapshots is True: print(f"random_snapshots is True. Taking {num_snapshots} random XYZ snapshots.") + if random_seed_set is not None: + if isinstance(random_seed_set,int): + print("Setting random seed:", random_seed_set) + random.seed(random_seed_set) list_of_xyz_files = random.sample(full_list_of_xyz_files, num_snapshots) else: print("random_snapshots is False. Taking the first", num_snapshots, "snapshots.") @@ -113,6 +123,29 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No print(list_of_xyz_files) os.chdir('..') + # XYZ-files + elif xyz_files is not None: + print("XYZ-files specified.") + full_list_of_xyz_files=xyz_files + print("Number of XYZ-files specified:", len(full_list_of_xyz_files)) + if num_snapshots is None: + print("num_snapshots has not been set by user") + print("This means that we will take all snapshots") + print("Setting num_snapshots to:", len(full_list_of_xyz_files)) + num_snapshots=len(full_list_of_xyz_files) + print(f"Number of snapshots (num_snapshots keyword) set to {num_snapshots}") + if random_snapshots is True: + print(f"random_snapshots is True. Taking {num_snapshots} random XYZ snapshots.") + if random_seed_set is not None: + if isinstance(random_seed_set,int): + print("Setting random seed:", random_seed_set) + random.seed(random_seed_set) + list_of_xyz_files = random.sample(full_list_of_xyz_files, num_snapshots) + else: + print("random_snapshots is False. Taking the first", num_snapshots, "snapshots.") + list_of_xyz_files=full_list_of_xyz_files[:num_snapshots] + print(f"List of XYZ-files to use (num {len(list_of_xyz_files)}):", list_of_xyz_files) + # XYZ TRAJECTORY elif xyz_trajectory is not None: print("XYZ-trajectory specified.") @@ -125,7 +158,6 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No print("Deleted old xyz_traj_split") except: pass - print("here") os.mkdir("xyz_traj_split") os.chdir("xyz_traj_split") @@ -146,6 +178,10 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No print(f"Number of snapshots (num_snapshots keyword) set to {num_snapshots}") if random_snapshots is True: print(f"random_snapshots is True. Taking {num_snapshots} random XYZ snapshots.") + if random_seed_set is not None: + if isinstance(random_seed_set,int): + print("Setting random seed:", random_seed_set) + random.seed(random_seed_set) list_of_xyz_files = random.sample(full_list_of_xyz_files, num_snapshots) else: print("random_snapshots is False. Taking the first", num_snapshots, "snapshots.") @@ -169,12 +205,17 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No gradients=[] fragments=[] labels=[] + + # Removing + theory_1.cleanup() + theory_2.cleanup() + if runmode=="serial": print("Runmode is serial!") print("Will now loop over XYZ-files") print("For a large dataset consider using parallel runmode") - for file in list_of_xyz_files: - print("\nNow running file:", file) + for n,file in enumerate(list_of_xyz_files): + print(f"\nNow running file ({n+1}/{len(list_of_xyz_files)}): {file}") basefile=os.path.basename(file) label=basefile.split(".")[0] frag = Fragment(xyzfile=file, charge=charge, mult=mult,printlevel=printlevel) @@ -216,6 +257,8 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No elif runmode=="parallel": print("Runmode is parallel!") print("Will now run parallel calculations") + print(f"Total number of calculations: {len(list_of_xyz_files)}") + print(f"Number of CPU cores available: {numcores}") # Fragments print("Looping over fragments first") for file in list_of_xyz_files: @@ -229,7 +272,7 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No fragments.append(frag) # Parallel run - print("Making sure numcores is set to 1 for both theories") + print("Warning: Making sure numcores is set to 1 for both theories") theory_1.set_numcores(1) from ash.functions.functions_parallel import Job_parallel @@ -264,31 +307,35 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No gradients.append(gradient) # Calculate energies for atoms - energies_atoms_dict={} - unique_elems_per_frag = [list(set(frag.elems)) for frag in fragments] - unique_elems = list(set([j for i in unique_elems_per_frag for j in i])) - - from dictionaries_lists import atom_spinmults - for uniq_el in unique_elems: - mult = atom_spinmults[uniq_el] - atomfrag = Fragment(atom=uniq_el, charge=0, mult=mult, printlevel=0) - print("Now running Theory 1 for atom:", uniq_el) - theory_1.printlevel=0 - theory_1.cleanup() - result_1 = Singlepoint(theory=theory_1, fragment=atomfrag, printlevel=0, - result_write_to_disk=False) - if delta is True: - theory_2.printlevel=0 - # Running theory 2 - print("Now running Theory 2 for atom:", uniq_el) - theory_2.cleanup() - result_2 = Singlepoint(theory=theory_2, fragment=atomfrag, printlevel=0, - result_write_to_disk=False) - # Delta energy - atomenergy = result_2.energy - result_1.energy - else: - atomenergy = result_1.energy - energies_atoms_dict[uniq_el] = atomenergy + if energies_atoms_dict is None: + print("\nNow calculating isolated atom reference energies for each element in the training set") + energies_atoms_dict={} + unique_elems_per_frag = [list(set(frag.elems)) for frag in fragments] + unique_elems = list(set([j for i in unique_elems_per_frag for j in i])) + + from dictionaries_lists import atom_spinmults + for uniq_el in unique_elems: + mult = atom_spinmults[uniq_el] + atomfrag = Fragment(atom=uniq_el, charge=0, mult=mult, printlevel=0) + print("Now running Theory 1 for atom:", uniq_el) + theory_1.printlevel=0 + theory_1.cleanup() + result_1 = Singlepoint(theory=theory_1, fragment=atomfrag, printlevel=0, + result_write_to_disk=False) + if delta is True: + theory_2.printlevel=0 + # Running theory 2 + print("Now running Theory 2 for atom:", uniq_el) + theory_2.cleanup() + result_2 = Singlepoint(theory=theory_2, fragment=atomfrag, printlevel=0, + result_write_to_disk=False) + # Delta energy + atomenergy = result_2.energy - result_1.energy + else: + atomenergy = result_1.energy + energies_atoms_dict[uniq_el] = atomenergy + else: + print("\nUsing user-provided isolated atom reference energies for each element in the training set") print("\nAtomic energies:", energies_atoms_dict) ########################################### @@ -519,10 +566,8 @@ def format_cell(cell): print(f"Selected {len(chosen_configs)} configs with high stdevs") return chosen_configs - - # TODO: COMBINE TRAINING FILES def active_learning(ml_theories=None, e_f_weights=None, training_dir=None, maxiter=10, theory_1=None, theory_2=None, Grad=True, - init_base_cfgs=25, threshold=0.0001, max_add_snaps=5, maxepochs=100, selection="energy-range", + init_base_cfgs=15, threshold=0.0001, max_add_snaps=5, maxepochs=100, selection="energy-range", noupdate=False, random_selection=False, random_seed_set=False, seed=42, charge=None, mult=None, runmode="serial", numcores=1): @@ -588,34 +633,67 @@ def move_chosen_files(chosen,dirname): # Choose base set: # This can be replaced by a list of chosen XYZ-files instead if random_seed_set: - random.seed(42) + print("Using random seed:", seed) + random.seed(seed) base_cfgs = random.sample(xyzfiles, init_base_cfgs) # Move chosen base configs to base - move_chosen_files(base_cfgs,"base") + #move_chosen_files(base_cfgs,"base") + move_chosen_files(base_cfgs,"current_set") + + # Determine number of elements + num_elems = len(list(set(Fragment(xyzfile=base_cfgs[0]).elems))) # ACTIVE LEARNING LOOP chosen_cfgs=[] + current_xyzfiles=[] for iter in range(maxiter): print("="*50) print(f"ACTIVE LEARNING ITERATION {iter}") print("="*50) # Base CFGS and rest configs - base_cfgs += chosen_cfgs other_cfgs = listdiff(xyzfiles,base_cfgs) print(f"NUM CURRENT BASE CONFIGS : {len(base_cfgs)}") + print(f"NUM NEW BASE CONFIGS : {len(chosen_cfgs)}") print([os.path.basename(i) for i in base_cfgs]) print(f"NUM CURRENT OTHER CONFIGS : {len(other_cfgs)}") + print("other_cfgs:", other_cfgs) + if len(other_cfgs) == 0: + print("Warning: No remaining CONFIGS left. Exiting loop") + print("Final number of cfgs in base:", len(base_cfgs)) + break print() - # Create training data for base - # Note: should be rewritten for only other_cfgs and then combine train_data_mace.xyz files - create_ML_training_data(xyz_dir=f"{xyzdir}/../base", random_snapshots=True, printlevel=0, + # Create training data for new cfgs + if iter == 0: + current_xyzfiles = base_cfgs + else: + current_xyzfiles = chosen_cfgs + print("current_xyzfiles:", current_xyzfiles) + create_ML_training_data(xyz_files=current_xyzfiles, random_snapshots=True, printlevel=0, theory_1=theory_1, theory_2=theory_2, charge=charge, mult=mult, Grad=Grad, runmode=runmode, numcores=numcores) - - # TODO: COMBINE TRAINING FILES - - #ML Theories + # Keep track of each iteration's training data + os.rename("train_data_mace.xyz", f"train_data_mace{iter}.xyz") + # First iter, we only have train_data_mace0.xyz + if iter == 0: + shutil.copyfile(f"train_data_mace{iter}.xyz", "train_data_mace.xyz") + else: + # Append new data to train_data_mace.xyz + with open("train_data_mace.xyz", "w") as outfile: + for i in range(iter+1): + # write atomic references only once + if i == 0: + with open(f"train_data_mace{i}.xyz", "r") as infile: + for line in infile: + outfile.write(line) + else: + with open(f"train_data_mace{i}.xyz", "r") as infile: + lines = infile.readlines() + # Skip atomic references + data_lines = lines[3*num_elems:] + for line in data_lines: + outfile.write(line) + # ML Theories for i,(ml,efw) in enumerate(zip(ml_theories, e_f_weights)): # Unique model filename ml.model_file=f"ML_ep{maxepochs}_ew_{e_f_weights[i][0]}_fw_{e_f_weights[i][1]}_iter{iter}.model" @@ -625,8 +703,10 @@ def move_chosen_files(chosen,dirname): # Check consistency of models and choose outliers chosen_cfgs = query_by_committee(mltheories=ml_theories, configs=other_cfgs, Grad=Grad, threshold=threshold, num_snaps=max_add_snaps, label=str(iter), selection=selection) + # if random_selection is True: if random_seed_set: + print("Using random seed:", seed) random.seed(seed) chosen_cfgs = random.sample(other_cfgs, max_add_snaps) @@ -635,15 +715,29 @@ def move_chosen_files(chosen,dirname): print("Final number of cfgs in base:", len(base_cfgs)) print("ACTIVE LEARNING COMPLETE!") break + # What to do with chosen configs if noupdate is True: chosen_cfgs=[] - else: + #else: #Move chosen configs to base - move_chosen_files(chosen_cfgs,"base") + #print("RB") + #print("Now moving chosen configs to base dir:", chosen_cfgs) + #move_chosen_files(chosen_cfgs,"base") + #Add to base + base_cfgs += chosen_cfgs print("Active learning is complete.") - if iter == maxiter: + print("iter:", iter) + print("maxiter:", maxiter) + if iter == maxiter-1: print("Warning: Active learning loop did not converge. Check the results carefully") else: print("Active learning loop converged") print("Final set of configurations are found in directory: base") + move_chosen_files(base_cfgs,"base") + + +###################################### +# WORKFLOW FUNCTIONS FOR TRAINING +####################################### + diff --git a/ash/modules/module_oniom.py b/ash/modules/module_oniom.py index 1ecddd4d3..a82724166 100644 --- a/ash/modules/module_oniom.py +++ b/ash/modules/module_oniom.py @@ -54,9 +54,9 @@ def __init__(self, theories_N=None, regions_N=None, regions_chargemult=None, if len(theories_N) != len(regions_N): print("Error: Number of theories and regions must match") ashexit() - if len(theories_N) != len(regions_chargemult): - print("Error: Number of theories and regions_chargemult must match") - ashexit() + #if len(theories_N) != len(regions_chargemult): + # print("Error: Number of theories and regions_chargemult must match") + # ashexit() # Full system self.fragment=fragment self.allatoms = self.fragment.allatoms @@ -97,6 +97,9 @@ def __init__(self, theories_N=None, regions_N=None, regions_chargemult=None, self.charge = self.fullregion_charge self.mult = self.fullregion_mult + # OpenMM special handling. + # We need to create a special OpenMMTheory object to handle region1 + self.openmmobject_R1 = None # print("Embedding:", self.embedding) print("Theories:") @@ -120,8 +123,13 @@ def __init__(self, theories_N=None, regions_N=None, regions_chargemult=None, print("\nRegions provided:") # for i,r in enumerate(self.regions_N): - print(f"Region {i+1} ({len(r)} atoms):", r) - print("Allatoms:", self.allatoms) + if r is not None: + print(f"Region {i+1} ({len(r)} atoms):", r) + print("Total system size:", len(self.allatoms), "atoms") + if len(self.allatoms) < 200: + print("Allatoms list:", self.allatoms) + else: + print("Skipping printing of allatoms list (too long)") print("\nRegion-chargemult info provided:") # for i,r in enumerate(self.regions_chargemult): @@ -171,6 +179,7 @@ def __init__(self, theories_N=None, regions_N=None, regions_chargemult=None, print("Boundaryatoms (HL:LL pairs):", self.boundaryatoms) print("Note: used connectivity settings, scale={} and tol={} to determine boundary.".format(conn_scale,conn_tolerance)) self.linkatoms = True + print("Linkatom_forceprojection_method:", self.linkatom_forceproj_method) # Get MM boundary information. Stored as self.MMboundarydict self.get_MMboundary(self.boundaryatoms,conn_scale,conn_tolerance) elif len(self.theories_N) == 3 and len(self.boundaryatoms_HL_ML) > 0: @@ -178,6 +187,7 @@ def __init__(self, theories_N=None, regions_N=None, regions_chargemult=None, print("Boundaryatoms (HL:LL pairs):", self.boundaryatoms_HL_ML) print("Note: used connectivity settings, scale={} and tol={} to determine boundary.".format(conn_scale,conn_tolerance)) self.linkatoms = True + print("Linkatom_forceprojection_method:", self.linkatom_forceproj_method) # Get MM boundary information. Stored as self.MMboundarydict self.get_MMboundary(self.boundaryatoms_HL_ML,conn_scale,conn_tolerance) elif len(self.theories_N) == 3 and len(self.boundaryatoms_ML_LL) > 0: @@ -185,6 +195,7 @@ def __init__(self, theories_N=None, regions_N=None, regions_chargemult=None, print("Boundaryatoms (HL:LL pairs):", self.boundaryatoms_ML_LL) print("Note: used connectivity settings, scale={} and tol={} to determine boundary.".format(conn_scale,conn_tolerance)) self.linkatoms = True + print("Linkatom_forceprojection_method:", self.linkatom_forceproj_method) # Get MM boundary information. Stored as self.MMboundarydict self.get_MMboundary(self.boundaryatoms_ML_LL,conn_scale,conn_tolerance) else: @@ -367,11 +378,20 @@ def get_MMboundary(self,boundaryatoms,scale,tol): # if boundarydict is not empty we need to zero MM1 charge and distribute charge from MM1 atom to MM2,MM3,MM4 #Creating dictionary for each MM1 atom and its connected atoms: MM2-4 self.MMboundarydict={} - for (QM1atom,MM1atom) in boundaryatoms.items(): - connatoms = get_connected_atoms(self.fragment.coords, self.fragment.elems, scale,tol, MM1atom) - #Deleting QM-atom from connatoms list - connatoms.remove(QM1atom) - self.MMboundarydict[MM1atom] = connatoms + for (QM1atom,MM1atom) in self.boundaryatoms.items(): + if isinstance(MM1atom,list): + for mat in MM1atom: + connatoms = get_connected_atoms(self.fragment.coords, self.fragment.elems, scale,tol, mat) + #Deleting QM-atom from connatoms list + connatoms.remove(QM1atom) + self.MMboundarydict[mat] = connatoms + # OLD: should never apply anymore, we always have a list + # TODO: delete + else: + connatoms = get_connected_atoms(self.fragment.coords, self.fragment.elems, scale,tol, MM1atom) + # Deleting QM-atom from connatoms list + connatoms.remove(QM1atom) + self.MMboundarydict[MM1atom] = connatoms # Used by ShiftMMCharges self.MMboundary_indices = list(self.MMboundarydict.keys()) @@ -470,7 +490,7 @@ def run(self, current_coords=None, Grad=False, elems=None, charge=None, mult=Non ############################################### # RUN OTHER REGIONS ############################################### - + # LOOPING OVER OTHER THEORY-REGION COMBOS for j,region in enumerate(self.regions_N): print("\nj:",j) @@ -613,12 +633,66 @@ def run(self, current_coords=None, Grad=False, elems=None, charge=None, mult=Non # For an MM-theory like OpenMM we have to do some special handling if theory.theorytype == "MM": print("Case: Theory is MM") - # Other region (i.e. not region1) - theory.update_charges(other_region,[0.0 for x in other_region]) - theory.update_LJ_epsilons(other_region,[0.0 for x in other_region]) - theory.modify_bonded_forces(other_region) - # NOTE: Fullsystem coordinates still passed here - res = theory.run(current_coords=full_coords, elems=full_elems, Grad=Grad, numcores=theory.numcores, label=label) + # NEW: Now creating separate OpenMMTheory object for region1 + newmode=True + if newmode is True: + print("theory.topology:", theory.topology) + print("theory.topology dict:", theory.topology.__dict__) + if self.openmmobject_R1 is None: + print("Creating new OpenMMTheory object for region1") + # Create if not existing + # Create new OpenMMTheory object using partial topology only + from ash import OpenMMTheory + import openmm + # New topology for region + mod_topology = openmm.app.Topology() + print("region:", region) + print("theory.topology.chains():", theory.topology.chains()) + print("Num chains:", theory.topology.getNumChains()) + for chain in theory.topology.chains(): + print("Adding chain:", chain) + atomsinchain = [at.index for at in chain.atoms()] + # Check if chain has any atoms in region + if any(i in atomsinchain for i in region): + # Then adding chain + newchain = mod_topology.addChain() + # Looping over residues + for res in chain.residues(): + resatoms = [i.index for i in res.atoms()] + # Only add residue if it has atoms in region + if any(i in resatoms for i in region): + newres = mod_topology.addResidue(res.name, newchain) + for at in res.atoms(): + if at.index in region: + mod_topology.addAtom(at.name, at.element, newres) + + # Get all bonds of chain + allbonds_in_chain = [b for r in chain.residues() for b in r.bonds()] + for b in allbonds_in_chain: + if b[0].index in region and b[1].index in region: + b0new=list(mod_topology.atoms())[region.index(b[0].index)] + b1new=list(mod_topology.atoms())[region.index(b[1].index)] + #print("Adding bond between:", b0new, b1new) + mod_topology.addBond(b0new, b1new) + mod_topology._periodicBoxVectors=theory.topology._periodicBoxVectors + print("\nmod_topology:", mod_topology) + print("atoms:", list(mod_topology.atoms())) + print("mod_topology dict:", mod_topology.__dict__) + self.openmmobject_R1 = OpenMMTheory(topoforce=True, + topology=mod_topology, forcefield=theory.forcefield, + autoconstraints=None, rigidwater=False) + + # Run region + # NOTE: No linkatoms + res = self.openmmobject_R1.run(current_coords=region_coords_final, elems=region_elems_final, + Grad=Grad, numcores=theory.numcores, label=label) + #else: + # # Other region (i.e. not region1) + # theory.update_charges(other_region,[0.0 for x in other_region]) + # theory.update_LJ_epsilons(other_region,[0.0 for x in other_region]) + # theory.modify_bonded_forces(other_region) + # # NOTE: Fullsystem coordinates still passed here + # res = theory.run(current_coords=full_coords, elems=full_elems, Grad=Grad, numcores=theory.numcores, label=label) # if the theory is QM/MM then this elif theory.theorytype == "QM/MM": print("Case: Theory is QM/MM object") @@ -652,10 +726,17 @@ def run(self, current_coords=None, Grad=False, elems=None, charge=None, mult=Non print(f"Energy (Region1-HL): {E_dict[(0,0)]} Eh") print(f"Energy (Region1-LL): {E_dict[(1,0)]} Eh") if Grad: + ##################### # Gradient assembled + ##################### + # Adding LL theory on Full region self.gradient = G_dict[(1,-1)] + print("Full G_dict[(1,-1):", G_dict[(1,-1)]) + # Adding HL theory contribution on region1 + print("HL G_dict[(0,0)]:", G_dict[(0,0)]) for at, g in zip(self.regions_N[0], G_dict[(0,0)]): self.gradient[at] += g + # Subtracting LL theory on region1 for at, g in zip(self.regions_N[0], G_dict[(1,0)]): self.gradient[at] -= g @@ -664,44 +745,46 @@ def run(self, current_coords=None, Grad=False, elems=None, charge=None, mult=Non print("Linkatom force projection now") print("Looping over linkatoms") for i,linkatomindex in enumerate(self.linkatom_indices): + print("i:", i) + print("linkatomindex:", linkatomindex) pair = sorted(self.linkatoms_dict.keys())[i] + print("pair:", pair) Lcoord=self.linkatoms_dict[pair] print("Lcoord:", Lcoord) - # Looping over theory-levels calculated - diffgrad=G_dict[(0,0)]-G_dict[(1,0)] - for theory_grad in [diffgrad]: - # for theory_grad in [G_dict[(0,0)], G_dict[(1,0)]]: - # Region gradient - Lgrad=theory_grad[linkatomindex] - print("Lgrad:", Lgrad) - # Getting QM1 info - fullatomindex_qm=pair[0] - regionatomindex=self.regions_N[0].index(fullatomindex_qm) - r_coords = np.take(current_coords,self.regions_N[0],axis=0) - Qcoord=r_coords[regionatomindex] - print("Qcoord:", Qcoord) - # Grabbing MMatom info - fullatomindex_mm=pair[1] - Mcoord=full_coords[fullatomindex_mm] - print("Mcoord:", Mcoord) - print("self.linkatom_forceproj_method:", self.linkatom_forceproj_method) - # Getting gradient contribution to QM1 and MM1 atoms from linkatom - if self.linkatom_forceproj_method == "adv": - QM1grad_contrib, MM1grad_contrib = linkatom_force_adv(Qcoord, Mcoord, Lcoord, Lgrad) - elif self.linkatom_forceproj_method == "lever": - QM1grad_contrib, MM1grad_contrib = linkatom_force_lever(Qcoord, Mcoord, Lcoord, Lgrad) - elif self.linkatom_forceproj_method == "chain": - QM1grad_contrib, MM1grad_contrib = linkatom_force_chainrule(Qcoord, Mcoord, Lcoord, Lgrad) - elif self.linkatom_forceproj_method.lower() == "none" or self.linkatom_forceproj_method == None: - QM1grad_contrib = np.zeros(3) - MM1grad_contrib = np.zeros(3) - else: - print("Unknown linkatom_forceproj_method. Exiting") - ashexit() - print("QM1grad_contr:", QM1grad_contrib) - print("MM1grad_contr:", MM1grad_contrib) - self.gradient[fullatomindex_qm] += QM1grad_contrib - self.gradient[fullatomindex_mm] += MM1grad_contrib + print("G_dict[(0,0)]:",G_dict[(0,0)]) + print("G_dict[(1,0)]:",G_dict[(1,0)]) + # Get linkatom gradient contribution from diff-theory + #print("G_dict[(0,0)][linkatomindex]:", G_dict[(0,0)][linkatomindex]) + #print("G_dict[(1,0)][linkatomindex]:", G_dict[(1,0)][linkatomindex]) + Lgrad=G_dict[(0,0)][linkatomindex]-G_dict[(1,0)][linkatomindex] + print("Lgrad:", Lgrad) + # Getting QM1 info + fullatomindex_qm=pair[0] + regionatomindex=self.regions_N[0].index(fullatomindex_qm) + r_coords = np.take(current_coords,self.regions_N[0],axis=0) + Qcoord=r_coords[regionatomindex] + print("Qcoord:", Qcoord) + # Grabbing MMatom info + fullatomindex_mm=pair[1] + Mcoord=full_coords[fullatomindex_mm] + print("Mcoord:", Mcoord) + # Getting gradient contribution to QM1 and MM1 atoms from linkatom + if self.linkatom_forceproj_method == "adv": + QM1grad_contrib, MM1grad_contrib = linkatom_force_adv(Qcoord, Mcoord, Lcoord, Lgrad) + elif self.linkatom_forceproj_method == "lever": + QM1grad_contrib, MM1grad_contrib = linkatom_force_lever(Qcoord, Mcoord, Lcoord, Lgrad) + elif self.linkatom_forceproj_method == "chain": + QM1grad_contrib, MM1grad_contrib = linkatom_force_chainrule(Qcoord, Mcoord, Lcoord, Lgrad) + elif self.linkatom_forceproj_method.lower() == "none" or self.linkatom_forceproj_method == None: + QM1grad_contrib = np.zeros(3) + MM1grad_contrib = np.zeros(3) + else: + print("Unknown linkatom_forceproj_method. Exiting") + ashexit() + print("QM1grad_contr:", QM1grad_contrib) + print("MM1grad_contr:", MM1grad_contrib) + self.gradient[fullatomindex_qm] += QM1grad_contrib + self.gradient[fullatomindex_mm] += MM1grad_contrib # 3-layer ONIOM Energy and Gradient expression elif len(self.theories_N) == 3: @@ -715,7 +798,7 @@ def run(self, current_coords=None, Grad=False, elems=None, charge=None, mult=Non print(f"Energy (Region2-LL): {E_dict[(2,1)]} Eh") if Grad: - print("Gradient for 3-layer ONIOM is not yet ready") + print("Sorry: Gradient for 3-layer ONIOM is not yet ready") ashexit() if self.linkatoms is True: diff --git a/ash/modules/module_plotting.py b/ash/modules/module_plotting.py index 92b30d9af..b73f848a8 100644 --- a/ash/modules/module_plotting.py +++ b/ash/modules/module_plotting.py @@ -1,6 +1,10 @@ import numpy as np from ash.functions.functions_general import print_line_with_mainheader,print_line_with_subheader1,ashexit +from ash.constants import hartokcal +#Relative energy conversion (if RelativeEnergy is True) +conversionfactor = { 'kcal/mol' : 627.50946900, 'kcal/mol' : 627.50946900, 'kJ/mol' : 2625.499638, 'kJpermol' : 2625.499638, + 'eV' : 27.211386245988, 'cm-1' : 219474.6313702 } #repeated here so that plotting can be stand-alone class BC: HEADER = '\033[95m' @@ -282,16 +286,17 @@ def savefig(self, filename, imageformat=None, dpi=None): #Input: dictionary of (X,Y): energy entries #NOTE: Partially deprecated thanks to ASHplot. Relative energy option is useful though. #TODO: Keep but call ASHplot here instead of doing separate plotting -def reactionprofile_plot(surfacedictionary, finalunit='',label='Label', x_axislabel='Coord', y_axislabel='Energy', dpi=200, mode='pyplot', +def reactionprofile_plot(surfacedictionary, finalunit=None,label='Label', x_axislabel='Coord', y_axislabel='Energy', dpi=200, mode='pyplot', imageformat='png', RelativeEnergy=True, pointsize=40, scatter_linewidth=2, line_linewidth=1, color='blue', filename='Plot'): print_line_with_mainheader("reactionprofile_plot") plt = load_matplotlib() + if plt is None: + print("Error: Matplotlib needs to be installed. Exiting") + ashexit() - conversionfactor = { 'a.u.': 1.0, 'Eh': 1.0, 'au': 1.0, 'kcal/mol' : 627.50946900, 'kcal/mol' : 627.50946900, 'kJ/mol' : 2625.499638, 'kJpermol' : 2625.499638, - 'eV' : 27.211386245988, 'cm-1' : 219474.6313702 } e=[] coords=[] @@ -299,10 +304,16 @@ def reactionprofile_plot(surfacedictionary, finalunit='',label='Label', x_axisla #Sorting keys dictionary before grabbing so that line-plot is correct for key in sorted(surfacedictionary.keys()): - coords.append(key) + if isinstance(key, tuple): + print("Warning: key {} is a tuple. Only the first value will be used for plotting.".format(key)) + coords.append(float(key[0])) #Making sure we add a float,not a tuple + else: + coords.append(float(key)) e.append(surfacedictionary[key]) if RelativeEnergy is True: + print("RelativeEnergy option. Using finalunit:", finalunit) + print("Other options are:", conversionfactor) #List of energies and relenergies here refenergy=float(min(e)) rele=[] @@ -315,6 +326,12 @@ def reactionprofile_plot(surfacedictionary, finalunit='',label='Label', x_axisla print(f"finalvalues ({len(finalvalues)}): {finalvalues}") print(f"Relative energies({finalunit}): {finalvalues}") + # Write relative energies to file: + print(f"Writing relative energies to file: surface_results_relE.txt") + with open(f'surface_results_relE.txt', 'w') as relfile: + for i,j in zip(coords, finalvalues): + relfile.write("{:13.10f} {:13.10f} \n".format(i,j)) + if mode == 'pyplot': plt.close() #Clear memory of previous plots plt.scatter(coords, finalvalues, color=color, marker = 'o', s=pointsize, linewidth=scatter_linewidth ) @@ -344,9 +361,7 @@ def contourplot(surfacedictionary, label='Label',x_axislabel='Coord', y_axislabe interpolparameter=10, colormap='inferno_r', dpi=200, imageformat='png', RelativeEnergy=True, numcontourlines=500, contour_alpha=0.75, contourline_color='black', clinelabels=False, contour_values=None, title=""): print_line_with_mainheader("contourplot") - #Relative energy conversion (if RelativeEnergy is True) - conversionfactor = { 'kcal/mol' : 627.50946900, 'kcal/mol' : 627.50946900, 'kJ/mol' : 2625.499638, 'kJpermol' : 2625.499638, - 'eV' : 27.211386245988, 'cm-1' : 219474.6313702 } + e=[] coords=[] x_c=[] @@ -368,6 +383,7 @@ def contourplot(surfacedictionary, label='Label',x_axislabel='Coord', y_axislabe #Creating relative-energy array here. Unmodified property is used if False if RelativeEnergy is True: print("RelativeEnergy option. Using finalunit:", finalunit) + print("Other options are:", conversionfactor) refenergy=float(min(e)) relsurfacedictionary={} for i in surfacedictionary: @@ -578,7 +594,7 @@ def plot_Spectrum(xvalues=None, yvalues=None, plotname='Spectrum', range=None, u ax.plot(x, spectrum, label=plotname, color=color) if plot_sticks is True: - ax.stem(xvalues, yvalues, label=plotname, markerfmt=' ', basefmt=' ', linefmt=color, use_line_collection=True) + ax.stem(xvalues, yvalues, label=plotname, markerfmt=' ', basefmt=' ', linefmt=color) plt.xlabel(unit) plt.ylabel('Intensity') ################################# @@ -632,3 +648,82 @@ def MOplot_vertical(mos_dict, pointsize=4000, linewidth=2, label="Label", yrange plt.savefig(label+"."+imageformat, format=imageformat, dpi=200) print("Created plot:", label+"."+imageformat) + +def volumeplot(surfacedictionary, x_axislabel='X', y_axislabel='Y', z_axislabel='Z', filename="surface", + colorbar_label='ΔE (kcal/mol)', colorscale='RdBu_r', + opacity=0.1,surface_count=20, + RelativeEnergy=True, finalunit='kcal/mol', title="3D Potential Energy Surface", + imageformat='png', plot_in_browser=True): + try: + import plotly.graph_objects as go + except: + print("Use of volumeplot requires the plotly library. Loading plotly failed. Probably not installed") + print("Please install using e.g. pip: pip install plotly") + ashexit() + + # ── Unpack into coordinate and value arrays ─────────────────────────────────── + keys = np.array(list(surfacedictionary.keys())) # shape (N, 3) + vals = np.array(list(surfacedictionary.values())) # shape (N,) + + x_vals = keys[:, 0] # e.g. bondlength + y_vals = keys[:, 1] # e.g. angle + z_vals = keys[:, 2] # e.g. dihedral + + #Creating relative-energy array here. Unmodified property is used if False + if RelativeEnergy is True: + print("RelativeEnergy option. Using finalunit:", finalunit) + print("Other options are:", conversionfactor) + vals_rel = (vals - vals.min()) * conversionfactor[finalunit] + + # ── Reshape to a 3D grid (assumes a regular, complete grid scan) ────────────── + x_unique = np.unique(x_vals) + y_unique = np.unique(y_vals) + z_unique = np.unique(z_vals) + + nx, ny, nz = len(x_unique), len(y_unique), len(z_unique) + + # Build index maps for fast lookup + x_idx = {v: i for i, v in enumerate(x_unique)} + y_idx = {v: i for i, v in enumerate(y_unique)} + z_idx = {v: i for i, v in enumerate(z_unique)} + + # Meshgrid so Plotly gets proper 3D coordinate arrays + X, Y, Z = np.meshgrid(x_unique, y_unique, z_unique, indexing='ij') + values = np.full((nx, ny, nz), np.nan) + + for (x, y, z), e in zip(keys, vals_rel): + values[x_idx[x], y_idx[y], z_idx[z]] = e + + # ── Plot ────────────────────────────────────────────────────────────────────── + fig = go.Figure(data=go.Volume( + x=X.flatten(), + y=Y.flatten(), + z=Z.flatten(), + value=values.flatten(), + isomin=0.0, + isomax=float(np.nanpercentile(vals_rel, 80)), # focus on lower-energy region + opacity=opacity, + surface_count=surface_count, + colorscale=colorscale, # blue=low energy, red=high — intuitive for PES + colorbar=dict(title=colorbar_label), + caps=dict(x_show=False, y_show=False, z_show=False), + )) + + fig.update_layout( + title=title, + scene=dict( + xaxis_title=x_axislabel, + yaxis_title=y_axislabel, + zaxis_title=z_axislabel, + ), + margin=dict(l=0, r=0, b=0, t=40), + ) + + # Save PNG + fig.write_image(f"{filename}.{imageformat}") + + # Save HTML + fig.write_html(f"{filename}.html") + + if plot_in_browser: + fig.show() \ No newline at end of file diff --git a/ash/modules/module_results.py b/ash/modules/module_results.py index 26237d73b..85279a023 100644 --- a/ash/modules/module_results.py +++ b/ash/modules/module_results.py @@ -1,6 +1,7 @@ from dataclasses import dataclass import numpy as np from ash.modules.module_coords import Fragment +from ash.functions.functions_general import print_if_level # Dataclasses https://realpython.com/python-data-classes/ @@ -70,14 +71,14 @@ class ASH_Results: barrier_energy: float = None # Print only defined attributes - def print_defined(self): - print("\nPrinting defined attributes of ASH_Results dataclass") + def print_defined(self, printlevel=2,): + print_if_level("\nPrinting defined attributes of ASH_Results dataclass", printlevel,2) for k,v in self.__dict__.items(): if v is not None: print(f"{k}: {v}") - def write_to_disk(self,filename="ASH.result"): + def write_to_disk(self,filename="ASH.result", printlevel=2): import json - print("\nWriting to disk defined attributes of ASH_Results dataclass") + print_if_level("\nWriting to disk defined attributes of ASH_Results dataclass", printlevel,2) f = open(filename,'w') newdict={} @@ -87,8 +88,8 @@ def write_to_disk(self,filename="ASH.result"): if isinstance(v,np.ndarray): # Check for nans in array if np.any(np.isnan(v)): - print("Warning: nan in array: ", k) - print("Skipping writing to disk") + print_if_level(f"Warning: nan in array {k}", printlevel,2) + print_if_level(f"Skipping writing to disk", printlevel,2) #exit() else: newv= v.tolist() @@ -104,33 +105,39 @@ def write_to_disk(self,filename="ASH.result"): else: newdict[k]=v elif isinstance(v,Fragment): - print("Warning: Fragment object is not included in ASH.result on disk") + print_if_level(f"Warning: Fragment object is not included in ASH.result on disk", printlevel,2) else: newdict[k]=v - print("Results object data:") + + print_if_level("Results object data:", printlevel,2) for k,v in newdict.items(): if type(v) is list or type(v) is np.ndarray: if len(v) < 20: - print(f"{k} : {len(v)}") + print_if_level(f"{k} : {len(v)}", printlevel,2) else: - print(f"{k} : too long to print") + print_if_level(f"{k} : too long to print", printlevel,2) else: if v is not None: - print(f"{k} : {v}") + print_if_level(f"{k} : {v}", printlevel,2) #print(f"{k} : {v}") # Dump new dict - f.write(json.dumps(newdict, allow_nan=True)) + try: + f.write(json.dumps(newdict, allow_nan=True)) + except TypeError as e: + print_if_level(f"Error writing ASH_Results to disk: {e}", printlevel,2) + print_if_level("Skipping writing to disk", printlevel,2) + return f.close() # Read ASH-Results data from disk -def read_results_from_file(filename="ASH.result"): +def read_results_from_file(filename="ASH.result", printlevel=2): import json - print("Reading ASH_Results data from file:", filename) + print_if_level("Reading ASH_Results data from file:", filename) data = json.load(open(filename)) - print("Data read from file:") + print_if_level("Data read from file:", printlevel,2) for k,v in data.items(): - print(f"{k} : {v}") + print_if_level(f"{k} : {v}", printlevel,2) r = ASH_Results(**data) return r diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py new file mode 100644 index 000000000..2232cda70 --- /dev/null +++ b/ash/modules/module_surface_new.py @@ -0,0 +1,2057 @@ +import os +import glob +import shutil +import copy +import time +import itertools +import numpy as np +#import ash +from ash.functions.functions_general import frange, BC, natural_sort, print_line_with_mainheader, \ + print_line_with_subheader1,print_time_rel, ashexit, print_if_level +import ash.functions.functions_parallel +from ash.modules.module_coords import check_charge_mult +from ash.modules.module_coords_PBC import write_CIF_file, write_POSCAR_file, write_XSF_file +from ash.modules.module_results import ASH_Results +from ash.interfaces.interface_geometric_new import geomeTRICOptimizer,GeomeTRICOptimizerClass +from ash.interfaces.interface_dlfind import DLFIND_optimizer, DLFIND_optimizerClass +from ash.modules.module_theory import NumGradclass +from ash.constants import ang2bohr +from ash.functions.functions_optimization import Cart_optimizer_class + +# New rewritten calc_surface function +def calc_surface( + fragment=None, theory=None, charge=None, mult=None, optimizer='geometric', printlevel=2, + scantype='UNRELAXED', resultfile='surface_results.txt', + keepoutputfiles=True, keepmofiles=False, + runmode='serial', coordsystem='dlc', maxiter=250, + NumGrad=False, extraconstraints=None, + set_geometry_via_restraint=True, + convergence_setting=None, conv_criteria=None, + subfrctor=1, force_noPBC=False, + numcores=1, ActiveRegion=False, actatoms=None, + PBC_format_option="CIF", + # ---- New N-dimensional interface ---- + RC_list=None, + # ---- Legacy 1D/2D interface (kept for backward compatibility) ---- + RC1_range=None, RC1_type=None, RC1_indices=None, + RC2_range=None, RC2_type=None, RC2_indices=None, +): + """Calculate an N-dimensional potential energy surface (1D, 2D, 3D, …). + + The preferred interface is *RC_list*, a list of reaction-coordinate dicts:: + + RC_list=[ + {'type': 'bond', 'indices': [[0, 1]], 'range': [1.0, 2.0, 0.1]}, + {'type': 'angle', 'indices': [[0, 1, 2]], 'range': [90, 180, 10]}, + ] + + The legacy ``RC1_*`` / ``RC2_*`` keyword arguments continue to work unchanged. + + Args: + fragment : ASH Fragment object + theory : ASH Theory object + charge, mult : charge and multiplicity + scantype : 'UNRELAXED' or 'RELAXED' + resultfile : filename for surface results + keepoutputfiles : copy QM output files per point + keepmofiles : copy MO files per point + runmode : 'serial' or 'parallel' + numcores : number of cores for parallel mode + coordsystem : coordinate system for geomeTRIC + maxiter : max optimisation iterations + NumGrad : use numerical gradients + extraconstraints : additional constraints dict + convergence_setting: geomeTRIC convergence preset + conv_criteria : explicit convergence criteria dict + subfrctor : subfrctor for geomeTRIC + force_noPBC : disable PBC in optimiser + ActiveRegion : use active region in optimisation + actatoms : list of active atoms + PBC_format_option : 'CIF', 'XSF', or 'POSCAR' + RC_list : list of RC dicts (new interface) + RC1_*/RC2_* : legacy 1D/2D parameters + + Returns: + ASH_Results with surfacepoints dict + """ + module_init_time = time.time() + print_line_with_mainheader("CALC_SURFACE FUNCTION") + + # NOW SETTING UP OPTIMIZER + # Defining extraconstraints + extraconstraints={} if extraconstraints is None else extraconstraints + if isinstance(optimizer,str): + if optimizer.lower() == "geometric": + print("Optimizer to use for surface scan: geomeTRIC") + opt_arguments = { + 'coordsystem': coordsystem, + 'maxiter': maxiter, + 'convergence_setting': convergence_setting, + 'conv_criteria': conv_criteria, + 'subfrctor': subfrctor, + 'force_noPBC': force_noPBC, 'PBC_format_option': PBC_format_option, + 'ActiveRegion': ActiveRegion, + 'result_write_to_disk':False, + 'printlevel':printlevel, + } + # Creating optimizer object + optimizerobj = GeomeTRICOptimizerClass(**opt_arguments) + # For geomeTRIC we use constrainvalue True + extraoopt_run_kws={'constrainvalue':True} + # For geometric we don't have to preset + presetting_geometry_required=False + + elif optimizer.lower() in ['dlfind','dl-find']: + print("Optimizer to use for surface scan: DL-FIND") + opt_arguments={'maxcycle':maxiter,'iopt':3, 'icoord':1, 'printlevel':printlevel} + + # Creating optimizer object + optimizerobj = DLFIND_optimizerClass(**opt_arguments) + extraoopt_run_kws={} + # DL-FIND: need to be preset + presetting_geometry_required=True + elif optimizer.lower() in ['cartopt', 'cart_opt', 'cart-opt', 'cartesian']: + print("Optimizer to use for surface scan: Cart_optimizer") + opt_arguments={'maxiter':maxiter,'printlevel':printlevel} + + # Creating optimizer object + optimizerobj = Cart_optimizer_class(**opt_arguments) + extraoopt_run_kws={} + # Cart_optimizer: no presetting required + presetting_geometry_required=False + else: + print("Wrong optimizer option chosen. Valid options are: geometric and dlfind") + ashexit() + elif isinstance(optimizer,GeomeTRICOptimizerClass): + print("A GeomeTRICOptimizerClass object was provided") + optimizerobj=optimizer + # For geomeTRIC we use constrainvalue True + extraoopt_run_kws={'constrainvalue':True} + # For geometric we don't have to preset + presetting_geometry_required=False + # Merge constraints if defined in both optimizer object and extraconstraints argument + extraconstraints = _merge_dicts(optimizerobj.constraints, extraconstraints) + elif isinstance(optimizer,DLFIND_optimizerClass): + print("A DLFIND_optimizerClass object was provided") + optimizerobj=optimizer + opt_arguments={} + extraoopt_run_kws={} + # DL-FIND: need to be preset + presetting_geometry_required=True + # Merge constraints if defined in both optimizer object and extraconstraints argument + extraconstraints = _merge_dicts(optimizerobj.constraints, extraconstraints) + elif isinstance(optimizer,Cart_optimizer_class): + print("A Cart_optimizer_class object was provided") + optimizerobj=optimizer + opt_arguments={} + extraoopt_run_kws={} + # Cart_optimizer: no presetting required + presetting_geometry_required=False + # Merge constraints if defined in both optimizer object and extraconstraints argument + extraconstraints = _merge_dicts(optimizerobj.constraints, extraconstraints) + else: + print("optimizer keyword should either be a string (geometric or dlfind) or an Optimizer object (GeomeTRICOptimizerClass or DLFIND_optimizerClass)") + ashexit() + + # Build connectivity once + conn = _build_connectivity(fragment.coords, fragment.elems) + + # Changing printlevel of fragment + fragment.printlevel=printlevel + + # -- NumGrad wrapping --------------------------------------------------- + if NumGrad: + print("NumGrad flag detected. Wrapping theory object into NumGrad class") + theory = NumGradclass(theory=theory) + + # -- Charge/mult check -------------------------------------------------- + charge, mult = check_charge_mult( + charge, mult, theory.theorytype, fragment, "calc_surface", theory=theory, + ) + + # -- Build RC_list (legacy compat) -------------------------------------- + if RC_list is None: + RC_list = _legacy_to_rc_list( + RC1_type, RC1_indices, RC1_range, + RC2_type, RC2_indices, RC2_range, + ) + RC_list = _normalise_rc_list(RC_list) + dimension = len(RC_list) + print(f"Number of reaction coordinates (dimension): {dimension}") + # -- Build value lists and total point count ---------------------------- + RC_value_lists = _build_rc_value_lists(RC_list) + totalnumpoints = 1 + for vl in RC_value_lists: + totalnumpoints *= len(vl) + for i, vl in enumerate(RC_value_lists): + print(f"RCvalue{i + 1}_list: {vl}") + print(f"Number of surfacepoints to calculate: {totalnumpoints}") + + # -- Read existing results ---------------------------------------------- + surfacedictionary = read_surfacedict_from_file(resultfile, dimension=dimension) + print("Initial surfacedictionary:", surfacedictionary) + + # -- Output-file policy ------------------------------------------------- + keepoutputfiles, keepmofiles = _silence_outputfiles_for_special_theories( + theory, keepoutputfiles, keepmofiles, + ) + print("keepoutputfiles:", keepoutputfiles) + print("keepmofiles:", keepmofiles) + + # -- PBC setup ---------------------------------------------------------- + if getattr(theory, "periodic", False): + print( + "Warning: Theory is periodic. Constrained geometry optimizations by " + "Optimizer will optimize both atom and cell parameters" + ) + print("Set force_noPBC=True if you do not want cell-parameter optimisation.") + print(f"PBC_format_option: {PBC_format_option}") + convert_to_pbcfile = _select_pbc_converter(PBC_format_option) + + # -- Create/reset output directories ------------------------------------ + _setup_directories(theory) + + # ----------------------------------------------------------------------- + # PARALLEL MODE + # ----------------------------------------------------------------------- + if runmode == 'parallel': + print("Parallel runmode. Number of cores:", numcores) + if numcores == 1: + print("Error: numcores must be > 1 for parallel runmode. Exiting.") + ashexit() + + surfacepointfragments_list = [] + + if scantype.upper() == 'UNRELAXED': + # Geometry-setting pass with ZeroTheory + zerotheory = ash.ZeroTheory() + pointcount = 0 + for rc_values in itertools.product(*RC_value_lists): + pointcount += 1 + key = _point_key(rc_values) + label = _point_label(rc_values) + print(f"======= Surfacepoint {pointcount}/{totalnumpoints}: {label} =======") + if key in surfacedictionary: + continue + allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment, + printlevel=printlevel) + print_if_level(f"allconstraints: {allconstraints}",printlevel,2) + + # Copying fragment + newfrag = copy.copy(fragment) + newfrag.printlevel=printlevel + newfrag.label = key + + # Here we modify geometry + print_if_level(f"For an unrelaxed scan we need to modify geometry first (done in serial fashion)",printlevel,2) + print_if_level(f"set_geometry_via_restraint: {set_geometry_via_restraint}",printlevel,2) + if set_geometry_via_restraint is True: + print_if_level(f"Modifying geometry to set constraints via DL-FIND restraint optimization",printlevel,2) + # NOTE: passing extraconstraints if any + _preset_geometry_restraint(newfrag, RC_list, rc_values, optimizerobj, + opt_arguments, charge, mult,printlevel=1, extraconstraints=extraconstraints, + extraoopt_run_kws=extraoopt_run_kws, + force_constant=10000.0) + else: + print_if_level(f"Modifying geometry to set constraints via coordinate manipulation",printlevel,2) + _set_geometry_direct(newfrag, RC_list, rc_values, conn=conn) + _verify_geometry(fragment, RC_list, rc_values, printlevel=printlevel) + + xyzname = f"{label}.xyz" + newfrag.write_xyzfile(xyzfilename=xyzname) + shutil.move(xyzname, f"surface_xyzfiles/{xyzname}") + _handle_pbc(theory, newfrag, label, convert_to_pbcfile) + surfacepointfragments_list.append(newfrag) + + result_surface = ash.functions.functions_parallel.Job_parallel( + fragments=surfacepointfragments_list, theories=[theory], numcores=numcores, + ) + surfacedictionary = result_surface.energies_dict + + elif scantype.upper() == 'RELAXED': + print("Warning: Relaxed scans in parallel mode are experimental") + pointcount = 0 + for rc_values in itertools.product(*RC_value_lists): + pointcount += 1 + key = _point_key(rc_values) + label = _point_label(rc_values) + print(f"======= Surfacepoint {pointcount}/{totalnumpoints}: {label} =======") + if key in surfacedictionary: + continue + allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment, + printlevel=printlevel) + print_if_level(f"allconstraints: {allconstraints}", printlevel,2) + newfrag = copy.copy(fragment) + newfrag.printlevel=printlevel + + if presetting_geometry_required: + print_if_level(f"For DL-FIND we need to modify geometry first to the desired constraint value.",printlevel,2) + print_if_level(f"set_geometry_via_restraint: {set_geometry_via_restraint}",printlevel,2) + if set_geometry_via_restraint is True: + print_if_level(f"Modifying geometry to get constraint value via DL-FIND restraint optimization",printlevel,2) + _preset_geometry_restraint(newfrag, RC_list, rc_values, optimizerobj, + opt_arguments, charge, mult,printlevel=1, extraconstraints=extraconstraints, + extraoopt_run_kws=extraoopt_run_kws, + force_constant=10000.0) + else: + print_if_level(f"Modifying geometry to get constraint value via coordinate manipulation",printlevel,2) + _set_geometry_direct(newfrag, RC_list, rc_values, conn=conn) + _verify_geometry(newfrag, RC_list, rc_values, printlevel=printlevel) + newfrag.label = key + newfrag.constraints = allconstraints + surfacepointfragments_list.append(newfrag) + + result_surface = ash.functions.functions_parallel.Job_parallel( + fragments=surfacepointfragments_list, theories=[theory], + numcores=numcores, Opt=True, optimizer=optimizer, + ) + # Copy optimised XYZ files to surface_xyzfiles/ + for rc_values in itertools.product(*RC_value_lists): + key = _point_key(rc_values) + label = _point_label(rc_values) + d = result_surface.worker_dirnames[key] + shutil.copy( + d + "/Fragment-optimized.xyz", + f"surface_xyzfiles/{label}.xyz", + ) + surfacedictionary = result_surface.energies_dict + + print("Parallel calculation done!") + print("surfacedictionary:", surfacedictionary) + if len(surfacedictionary) != totalnumpoints: + print( + f"Warning: Dictionary incomplete! " + f"Got {len(surfacedictionary)}, expected {totalnumpoints}" + ) + + # ----------------------------------------------------------------------- + # SERIAL MODE + # ----------------------------------------------------------------------- + elif runmode == 'serial': + print("Serial runmode") + zerotheory = ash.ZeroTheory() + pointcount = 0 + + for rc_values in itertools.product(*RC_value_lists): + surfacepoint_time_init=time.time() + pointcount += 1 + key = _point_key(rc_values) + label = _point_label(rc_values) + + # Resetting constraints is optimizer object to be safe + optimizerobj.constraints=None + + print("=" * 50) + print(f"Surfacepoint: {pointcount} / {totalnumpoints}") + print(f" {label}") + if scantype.upper() == 'UNRELAXED': + print(" Unrelaxed scan: first setting geometry and then doing single-point calculation") + else: + print(" Relaxed scan: relaxing geometry with theory + constraints.") + print("=" * 50) + + if key in surfacedictionary: + print(f"{label} already in dict. Skipping.") + continue + + allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment, + printlevel=printlevel) + print_if_level(f"All constraints: {allconstraints}", printlevel,1) + + if scantype.upper() == 'UNRELAXED': + # Here we modify geometry + print_if_level(f"For an unrelaxed scan we need to modify geometry first (done in serial fashion)",printlevel,2) + print_if_level(f"set_geometry_via_restraint: {set_geometry_via_restraint}",printlevel,2) + if set_geometry_via_restraint is True: + print_if_level(f"Modifying geometry to set constraints via DL-FIND restraint optimization",printlevel,2) + # NOTE: passing extraconstraints if any + _preset_geometry_restraint(fragment, RC_list, rc_values, optimizerobj, + opt_arguments, charge, mult,printlevel=1, extraconstraints=extraconstraints, + extraoopt_run_kws=extraoopt_run_kws, + force_constant=10000.0) + else: + print_if_level(f"Modifying geometry to set constraints via coordinate manipulation",printlevel,2) + _set_geometry_direct(fragment, RC_list, rc_values, conn=conn) + _verify_geometry(fragment, RC_list, rc_values, printlevel=printlevel) + + print_if_level(f"Now running single-point calculation using Theory", printlevel,2) + result = ash.Singlepoint( + fragment=fragment, theory=theory, charge=charge, mult=mult, + ) + else: # RELAXED + if presetting_geometry_required: + print_if_level(f"For DL-FIND and Cart_optimizer we need to modify geometry first to set constraints.", printlevel,2) + if set_geometry_via_restraint is True: + print_if_level(f"Modifying geometry to set constraints via DL-FIND restraint optimization", printlevel,2) + # NOTE: passing extraconstraints if any + _preset_geometry_restraint(fragment, RC_list, rc_values, optimizerobj, + opt_arguments, charge, mult,printlevel=1, extraconstraints=extraconstraints, + extraoopt_run_kws=extraoopt_run_kws, + force_constant=10000.0) + else: + print_if_level(f"Modifying geometry to set constraints via coordinate manipulation", printlevel,2) + _set_geometry_direct(fragment, RC_list, rc_values, conn=conn) + _verify_geometry(fragment, RC_list, rc_values, printlevel=printlevel) + else: + print_if_level(f"For geometric Optimizer we enforce constraints during optimization.", printlevel,2) + print_if_level(f"Now running Relaxed Optimization", printlevel,2) + #if pointcount == 2: + # fragment.print_coords() + # ashexit() + # Running optimizer object + + # Resetting Hessian inverse in optimizer + if isinstance(optimizerobj, Cart_optimizer_class): + if hasattr(optimizerobj, 'Hess_inv'): + optimizerobj.Hess_inv = None + + #Running optimizer object, passing theory, fragment, constraints and possible extra kws + result = optimizerobj.run(theory=theory,fragment=fragment, constraints=allconstraints, **extraoopt_run_kws) + + #if pointcount == 2: + # print("2nd point optimization result:", result) + # ashexit() + energy = float(result.energy) + print(f" {label} Energy: {energy}") + + # -- File I/O --------------------------------------------------- + fragment.write_xyzfile(xyzfilename="surface_traj.xyz", writemode='a') + xyzname = f"{label}.xyz" + fragment.write_xyzfile(xyzfilename=xyzname) + shutil.move(xyzname, f"surface_xyzfiles/{xyzname}") + _handle_output_files(theory, label, keepoutputfiles, keepmofiles, printlevel=printlevel) + _handle_pbc(theory, fragment, label, convert_to_pbcfile) + + surfacedictionary[key] = float(energy) + write_surfacedict_to_file(surfacedictionary, resultfile, dimension=dimension) + + print(f"Time for surface point {pointcount}: {time.time() - surfacepoint_time_init:.2f} seconds") + + print("surfacedictionary:", surfacedictionary) + + else: + print(f"Error: Unknown runmode '{runmode}'. Use 'serial' or 'parallel'.") + ashexit() + + # ----------------------------------------------------------------------- + # Post-processing + # ----------------------------------------------------------------------- + write_surfacedict_to_file(surfacedictionary, resultfile, dimension=dimension) + + # Combine all per-point XYZ files into a single trajectory + xyzfile_list = glob.glob("surface_xyzfiles/*.xyz") + + with open("surface_traj_final.xyz", 'w') as outfile: + for xyzfile in natural_sort(xyzfile_list): + with open(xyzfile) as infile: + outfile.write(infile.read()) + + print_time_rel(module_init_time, modulename='calc_surface', moduleindex=0) + + result = ASH_Results(label="Surface calc", surfacepoints=surfacedictionary) + try: + result.write_to_disk(filename="ASH_surface.result") + except TypeError as e: + print("Problem writing ASH_surface.result to disk. Skipping.") + print("Error:", e) + return result + +# FROM XYZ +def calc_surface_fromXYZ( + xyzdir=None, multixyzfile=None, theory=None, charge=None, mult=None, optimizer="geometric", + dimension=None, resultfile='surface_results.txt', printlevel=2, + scantype='UNRELAXED', runmode='serial', + coordsystem='dlc', maxiter=250, extraconstraints=None, + convergence_setting=None, conv_criteria=None, subfrctor=1, NumGrad=False, + numcores=None, + keepoutputfiles=True, force_noPBC=False, PBC_format_option="CIF", + keepmofiles=False, read_mofiles=False, mofilesdir=None, + # New ND interface: + RC_list=None, + # Legacy 1D/2D interface (kept for backward compatibility): + RC1_type=None, RC1_indices=None, + RC2_type=None, RC2_indices=None, +): + """Calculate an N-dimensional surface from a directory of XYZ files. + + XYZ filenames must follow the convention produced by calc_surface:: + + RC1_-RC2_-...-RCN_.xyz + + RC information is only required for RELAXED scans (to rebuild constraints). + For UNRELAXED scans all RC arguments may be omitted. + + Preferred interface uses RC_list (same format as calc_surface, but 'range' + is ignored and may be omitted since the grid is defined by the XYZ files):: + + calc_surface_fromXYZ( + xyzdir='surface_xyzfiles', theory=theory, charge=0, mult=1, + scantype='Relaxed', dimension=2, + RC_list=[ + {'type': 'bond', 'indices': [[0, 1], [0, 2]]}, + {'type': 'angle', 'indices': [[1, 0, 2]]}, + ], + ) + + Legacy 1D/2D keyword arguments (RC1_type, RC1_indices, RC2_type, + RC2_indices) continue to work unchanged. + + Args: + xyzdir : directory containing XYZ files + dimension : number of RC coordinates; inferred from RC_list if + not provided, or from the first filename as fallback + theory : ASH Theory object + charge, mult : charge and multiplicity + scantype : 'UNRELAXED' or 'RELAXED' + runmode : 'serial' or 'parallel' + numcores : cores for parallel mode + RC_list : list of RC dicts (new ND interface) + RC1_type/indices : legacy 1D/2D constraint specification + RC2_type/indices : legacy 2D constraint specification + read_mofiles : read MO files from mofilesdir + mofilesdir : directory containing MO files + + Returns: + ASH_Results with surfacepoints dict + """ + module_init_time = time.time() + print_line_with_mainheader("CALC_SURFACE_FROMXYZ FUNCTION") + if isinstance(optimizer,str): + if optimizer.lower() == "geometric": + print("Optimizer to use for surface scan: geomeTRIC") + Optimizer=geomeTRICOptimizer + Optimizerclass=GeomeTRICOptimizerClass + opt_arguments = { + 'coordsystem': coordsystem, + 'maxiter': maxiter, + 'convergence_setting': convergence_setting, + 'conv_criteria': conv_criteria, + 'subfrctor': subfrctor, + 'force_noPBC': force_noPBC, + 'PBC_format_option': PBC_format_option} + elif optimizer.lower() in ['dlfind','dl-find']: + print("Optimizer to use for surface scan: DL-FIND") + Optimizer=DLFIND_optimizer + Optimizerclass=DLFIND_optimizerClass + opt_arguments={} + elif isinstance(optimizer,GeomeTRICOptimizerClass): + print("A GeomeTRICOptimizerClass object was provided") + elif isinstance(optimizer,DLFIND_optimizerClass): + print("A GeomeTRICOptimizerClass object was provided") + opt_arguments={} + else: + print("optimizer keyword should either be a string (geometric or dlfind) or an Optimizer object") + ashexit() + + + + # -- NumGrad wrapping --------------------------------------------------- + if NumGrad: + print("NumGrad flag detected. Wrapping theory object into NumGrad class") + theory = NumGradclass(theory=theory) + + # -- Basic argument checks ---------------------------------------------- + if charge is None or mult is None: + print(BC.FAIL, "Error: charge and mult must be defined for calc_surface_fromXYZ", BC.END) + ashexit() + if xyzdir is None: + print("Error: xyzdir must be provided") + ashexit() + if read_mofiles and mofilesdir is None: + print("Error: mofilesdir not set but read_mofiles=True. Exiting.") + ashexit() + + # -- Build RC_list from legacy kwargs if needed ------------------------- + if RC_list is None and RC1_type is not None: + # Legacy path: build RC_list without 'range' (not needed here) + RC_list = [{'type': RC1_type, 'indices': RC1_indices}] + if RC2_type is not None: + RC_list.append({'type': RC2_type, 'indices': RC2_indices}) + + # Normalise indices to list-of-lists + if RC_list is not None: + RC_list = _normalise_rc_list(RC_list) + + # For RELAXED scans RC_list is mandatory + if scantype.upper() == 'RELAXED' and not RC_list: + print( + "Error: RC_list (or legacy RC1_type/RC1_indices) is required for " + "RELAXED scans in calc_surface_fromXYZ" + ) + ashexit() + + # -- Discover XYZ files ------------------------------------------------- + xyzfile_list = sorted(glob.glob(xyzdir + '/*.xyz')) + totalnumpoints = len(xyzfile_list) + if totalnumpoints == 0: + print(f"Found no XYZ-files in directory '{xyzdir}'. Exiting") + ashexit() + + # -- Infer dimension ---------------------------------------------------- + if dimension is None: + if RC_list is not None: + dimension = len(RC_list) + else: + # Infer from first filename: count how many 'RC' tokens appear + first_file = os.path.basename(xyzfile_list[0]) + dimension = first_file.replace('.xyz', '').count('RC') + print(f"Inferred dimension={dimension}") + + print("XYZdir:", xyzdir) + print("Theory:", theory) + print("Dimension:", dimension) + print("Scan type:", scantype) + print("keepoutputfiles:", keepoutputfiles) + print("keepmofiles:", keepmofiles) + print("read_mofiles:", read_mofiles) + print("mofilesdir:", mofilesdir) + print("runmode:", runmode) + print("totalnumpoints:", totalnumpoints) + + # -- Read existing results ---------------------------------------------- + surfacedictionary = read_surfacedict_from_file(resultfile, dimension=dimension) + print("Initial surfacedictionary:", surfacedictionary) + + if len(surfacedictionary) == totalnumpoints: + print( + f"Surface dictionary size {len(surfacedictionary)} matches " + f"total number of XYZ files {totalnumpoints}. All data present." + ) + result = ASH_Results(label="Surface calc XYZ", surfacepoints=surfacedictionary) + result.write_to_disk(filename="ASH_surface_xyz.result") + return result + + # -- Output-file policy ------------------------------------------------- + keepoutputfiles, keepmofiles = _silence_outputfiles_for_special_theories( + theory, keepoutputfiles, keepmofiles, + ) + print("keepoutputfiles:", keepoutputfiles) + print("keepmofiles:", keepmofiles) + + # -- Directory setup ---------------------------------------------------- + if scantype.upper() == 'RELAXED': + if os.path.exists('surface_xyzfiles'): + print(BC.FAIL, "surface_xyzfiles directory already exists. Please remove it.", BC.END) + ashexit() + os.mkdir('surface_xyzfiles') + + if runmode == 'serial': + shutil.rmtree("surface_outfiles", ignore_errors=True) + os.makedirs("surface_outfiles", exist_ok=True) + shutil.rmtree("surface_mofiles", ignore_errors=True) + os.makedirs("surface_mofiles", exist_ok=True) + + # ----------------------------------------------------------------------- + # Helper: parse RC values from filename + # Handles filenames like RC1_1.45-RC2_90.0-RC3_0.0.xyz + # ----------------------------------------------------------------------- + def parse_rc_values(relfile): + base = relfile.replace('.xyz', '') + # Split on '-RC' to get ['RC1_1.45', '2_90.0', '3_0.0'] + parts = base.split('-RC') + vals = [] + for part in parts: + # Each part is like 'RC1_1.45' or '2_90.0' — value is after last '_' + vals.append(float(part.split('_')[-1])) + return tuple(vals[:dimension]) + + # ----------------------------------------------------------------------- + # Helper: build geomeTRIC constraints for a given point + # ----------------------------------------------------------------------- + def build_constraints(rc_vals, frag): + if not RC_list: + return {} + return set_constraints_nd(RC_list, rc_vals, extraconstraints, fragment=frag) + + # ----------------------------------------------------------------------- + # PARALLEL + # ----------------------------------------------------------------------- + if runmode == 'parallel': + if numcores is None: + print("Error: numcores argument required for parallel runmode") + ashexit() + + surfacepointfragments_list = [] + for file in xyzfile_list: + relfile = os.path.basename(file) + rc_vals = parse_rc_values(relfile) + key = _point_key(rc_vals) + if key in surfacedictionary: + continue + newfrag = ash.Fragment(xyzfile=file, label=key, charge=charge, mult=mult) + if scantype.upper() == 'RELAXED': + newfrag.constraints = build_constraints(rc_vals,newfrag) + surfacepointfragments_list.append(newfrag) + + if scantype.upper() == 'UNRELAXED': + kwargs = dict( + fragments=surfacepointfragments_list, + theories=[theory], + numcores=numcores, + ) + if read_mofiles: + kwargs['mofilesdir'] = mofilesdir + results = ash.functions.functions_parallel.Job_parallel(**kwargs) + + else: # RELAXED + optimizer = Optimizerclass( + maxiter=maxiter, + convergence_setting=convergence_setting, + **opt_arguments, + ) + kwargs = dict( + fragments=surfacepointfragments_list, + theories=[theory], + numcores=numcores, + Opt=True, + optimizer=optimizer, + ) + if read_mofiles: + kwargs['mofilesdir'] = mofilesdir + results = ash.functions.functions_parallel.Job_parallel(**kwargs) + + print("Parallel calculation done!") + surfacedictionary = {k: float(v) for k, v in results.energies_dict.items()} + if len(surfacedictionary) != totalnumpoints: + print( + f"Warning: Dictionary incomplete! " + f"Got {len(surfacedictionary)}, expected {totalnumpoints}" + ) + + # ----------------------------------------------------------------------- + # SERIAL + # ----------------------------------------------------------------------- + elif runmode == 'serial': + for count, file in enumerate(xyzfile_list): + relfile = os.path.basename(file) + rc_vals = parse_rc_values(relfile) + key = _point_key(rc_vals) + label = _point_label(rc_vals) + + print("=" * 66) + print(f"Surfacepoint: {count + 1} / {totalnumpoints}") + print(f"XYZ-file: {relfile} ({label})") + print("=" * 66) + + if read_mofiles: + mofile = f"{mofilesdir}/{theory.filename}_{label}.gbw" + print(f"Will read MO-file: {mofile}") + if theory.__class__.__name__ == "ORCATheory": + theory.moreadfile = mofile + + if key in surfacedictionary: + print(f"{label} already in dict. Skipping.") + continue + + mol = ash.Fragment(xyzfile=file) + + if scantype.upper() == 'UNRELAXED': + result = ash.Singlepoint( + theory=theory, fragment=mol, charge=charge, mult=mult, + ) + + else: # RELAXED + allconstraints = build_constraints(rc_vals,mol) + result = Optimizer( + fragment=mol, theory=theory, maxiter=maxiter, + constraints=allconstraints, + convergence_setting=convergence_setting, + charge=charge, mult=mult, **opt_arguments, + ) + xyzname = f"{label}.xyz" + mol.write_xyzfile(xyzfilename=xyzname) + shutil.move(xyzname, f"surface_xyzfiles/{xyzname}") + + energy = float(result.energy) + print(f"Energy of {relfile}: {energy} Eh") + _handle_output_files(theory, label, keepoutputfiles, keepmofiles, printlevel=printlevel) + surfacedictionary[key] = energy + # Write after every point so partial results are never lost + write_surfacedict_to_file(surfacedictionary, resultfile, dimension=dimension) + + else: + print(f"Error: Unknown runmode '{runmode}'. Use 'serial' or 'parallel'.") + ashexit() + + # ----------------------------------------------------------------------- + # Post-processing + # ----------------------------------------------------------------------- + write_surfacedict_to_file(surfacedictionary, resultfile, dimension=dimension) + print("Final surfacedictionary:", surfacedictionary) + print_time_rel(module_init_time, modulename='calc_surface_fromXYZ', moduleindex=0) + + result = ASH_Results(label="Surface calc XYZ", surfacepoints=surfacedictionary) + result.write_to_disk(filename="ASH_surface_xyz.result") + return result + + + +# HELPER FUNCTIONS + +def _merge_dicts(dict1, dict2): + """Merge two dictionaries, concatenating lists if keys overlap.""" + if dict1 is None: + dict1 = {} + if dict2 is None: + dict2 = {} + merged = dict(dict1) # start with dict1's keys and values + for key, value in dict2.items(): + if key in merged: + merged[key] = merged[key] + value # concatenate lists + else: + merged[key] = value + return merged + +def read_surfacedict_from_file(resultfile, dimension=None): + """Read surface dictionary from resultfile. + + Returns an empty dict if the file does not exist. + Keys are tuples of floats (uniform for all dimensions). + """ + surfacedictionary = {} + if not os.path.isfile(resultfile): + return surfacedictionary + print(f"Found existing resultfile: {resultfile}. Reading entries.") + with open(resultfile) as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + tokens = line.split() + try: + energy = float(tokens[-1]) + rc_vals = tuple(float(t) for t in tokens[:-1]) + #if dimension == 1: + # # Legacy: 1D keys stored as bare float in old files + # key = rc_vals[0] if len(rc_vals) == 1 else rc_vals + #else: + key = rc_vals + surfacedictionary[key] = float(energy) + except (ValueError, IndexError): + print(f"Warning: could not parse line: {line!r}") + return surfacedictionary + +def write_surfacedict_to_file(surfacedictionary, resultfile, dimension=None): + with open(resultfile, 'w') as f: + f.write("# Surface scan results\n") + f.write("# RC1 [RC2 ...] Energy\n") + # Normalise keys to tuples so sorted() always works regardless of + # whether the dict came from a fresh run or a legacy result file + normalised = { + (k,) if not isinstance(k, tuple) else k: v + for k, v in surfacedictionary.items() + } + for key, energy in sorted(normalised.items()): + rc_str = ' '.join(str(v) for v in key) + f.write(f"{rc_str} {energy}\n") + + +# SUPPORT FUNCTIONS (not to be called by user) + +def _silence_outputfiles_for_special_theories(theory, keepoutputfiles, keepmofiles): + name = theory.__class__.__name__ + if name in ("ZeroTheory", "ORCA_CC_CBS_Theory"): + return False, False + return keepoutputfiles, keepmofiles + +def _select_pbc_converter(PBC_format_option): + opt = PBC_format_option.upper() + if opt == "CIF": + return write_CIF_file + elif opt == "XSF": + return write_XSF_file + elif opt == "POSCAR": + return write_POSCAR_file + else: + print(f"Warning: Unknown PBC_format_option '{PBC_format_option}', defaulting to CIF") + return write_CIF_file + +def _legacy_to_rc_list(RC1_type, RC1_indices, RC1_range, + RC2_type, RC2_indices, RC2_range): + if RC1_type is None or RC1_indices is None: + print("Error: RC1_type and RC1_indices are required") + ashexit() + RC_list = [{'type': RC1_type, 'indices': RC1_indices, 'range': RC1_range}] + if RC2_type is not None: + RC_list.append({'type': RC2_type, 'indices': RC2_indices, 'range': RC2_range}) + return RC_list + +def _normalise_rc_list(RC_list): + """Ensure every RC dict has 'indices' as a list-of-lists.""" + out = [] + for rc in RC_list: + rc = dict(rc) # shallow copy so we don't mutate caller's data + indices = rc['indices'] + if not any(isinstance(el, list) for el in indices): + indices = [indices] + rc['indices'] = indices + out.append(rc) + return out + +def _build_rc_value_lists(RC_list): + """Return a list of value-lists, one per RC dimension.""" + result = [] + for rc in RC_list: + r = rc['range'] + vals = list(frange(r[0], r[1], r[2])) + vals.append(float(r[1])) # always include the endpoint + result.append(vals) + return result + +def _setup_directories(theory): + """Create/reset the standard surface output directories.""" + for d in ("surface_xyzfiles", "surface_outfiles", "surface_mofiles"): + shutil.rmtree(d, ignore_errors=True) + os.mkdir(d) + try: + os.remove("surface_traj.xyz") + except FileNotFoundError: + pass + if getattr(theory, "periodic", False): + shutil.rmtree("surface_pbcfiles", ignore_errors=True) + os.mkdir("surface_pbcfiles") + print("Created directory: surface_pbcfiles") + +def _point_key(rc_values): + """Dictionary key for a surface point. + + A 1-tuple behaves exactly like the old scalar key for 1D surfaces, + but we keep it as a tuple throughout so the logic is uniform. + Callers that need the old scalar key for 1D can unpack themselves. + """ + return tuple(rc_values) + +def _point_label(rc_values): + """Human-readable label: 'RC1_1.5-RC2_120.0-RC3_2.0' etc.""" + return '-'.join(f'RC{i + 1}_{v}' for i, v in enumerate(rc_values)) + +def set_constraints_nd(RC_list, rc_values, extraconstraints=None, fragment=None, printlevel=2): + """Build a geomeTRIC constraints dict for any number of reaction coordinates. + + Args: + RC_list : list of RC dicts (already normalised, indices are list-of-lists) + rc_values : tuple of current values, one per RC + extraconstraints : optional additional constraints dict; each entry is a + list of [*indices, value] or just [*indices] (no value). + If no value is present and fragment is provided, the + current geometry value is measured and appended. + If no value and no fragment, an error is raised. + fragment : ASH fragment, used to measure current constraint values + when extraconstraints entries have no value appended. + + Returns: + dict suitable for geomeTRICOptimizer's ``constraints`` argument + """ + allconstraints = {} + + # RC constraints — value always explicitly provided + for rc, val in zip(RC_list, rc_values): + rc_type = rc['type'] + allconstraints.setdefault(rc_type, []) + for indices in rc['indices']: + allconstraints[rc_type].append([*indices, val]) + + if extraconstraints: + for constraint_type, entries in extraconstraints.items(): + allconstraints.setdefault(constraint_type, []) + # Expected atom counts per constraint type (number of index atoms) + natoms = {'bond': 2, 'angle': 3, 'dihedral': 4, 'distance': 2, + 'cartesian': 1, 'translation-x': 1, 'translation-y': 1, + 'translation-z': 1, 'rotation-x': 1, 'rotation-y': 1, + 'rotation-z': 1} + expected_natoms = natoms.get(constraint_type.lower(), None) + + for entry in entries: + # Determine whether a value is already appended: + # if the entry has more elements than the expected atom count, + # the last element is the value. + if expected_natoms is not None and len(entry) > expected_natoms: + # Value already present — use as-is + allconstraints[constraint_type].append(list(entry)) + elif expected_natoms is not None and len(entry) == expected_natoms: + # No value — measure from current geometry or error + if fragment is None: + if printlevel > 1: + print( + f"Error: extraconstraint of type '{constraint_type}' " + f"with indices {entry} has no value, and no fragment " + f"was provided to measure it from." + ) + ashexit() + val = _measure_constraint(fragment, constraint_type, entry) + if printlevel > 1: + print( + f"extraconstraint '{constraint_type}' {entry}: " + f"no value provided, using current geometry value {val:.6f}" + ) + allconstraints[constraint_type].append([*entry, val]) + else: + # Unknown type or ambiguous length — append as-is with a warning + if printlevel > 1: + print( + f"Warning: cannot determine whether value is present for " + f"extraconstraint type '{constraint_type}', entry {entry}. " + f"Appending as-is." + ) + if isinstance(entry,int): + allconstraints[constraint_type].append(entry) + else: + allconstraints[constraint_type].append(list(entry)) + + return allconstraints + +def _measure_constraint(fragment, constraint_type, indices): + """Measure the current value of a geometric constraint from fragment coords. + + Args: + fragment : ASH fragment (must have .coords in Angstrom) + constraint_type : 'bond', 'angle', or 'dihedral' + indices : list of atom indices (0-based) + + Returns: + float — bond length in Å, angle or dihedral in degrees + """ + coords = np.array(fragment.coords) # shape (natoms, 3) + + ct = constraint_type.lower() + + if ct in ('bond', 'distance'): + a, b = indices + return float(np.linalg.norm(coords[a] - coords[b])) + + elif ct == 'angle': + a, b, c = indices + v1 = coords[a] - coords[b] + v2 = coords[c] - coords[b] + cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) + cos_angle = np.clip(cos_angle, -1.0, 1.0) + return float(np.degrees(np.arccos(cos_angle))) + + elif ct == 'dihedral': + a, b, c, d = indices + b1 = coords[b] - coords[a] + b2 = coords[c] - coords[b] + b3 = coords[d] - coords[c] + n1 = np.cross(b1, b2) + n2 = np.cross(b2, b3) + m1 = np.cross(n1, b2 / np.linalg.norm(b2)) + x = np.dot(n1, n2) + y = np.dot(m1, n2) + return float(np.degrees(np.arctan2(y, x))) + + else: + print( + f"Warning: _measure_constraint does not know how to measure " + f"'{constraint_type}'. Returning 0.0 as placeholder value." + ) + return 0.0 + +def _handle_pbc(theory, fragment, pointlabel, convert_to_pbcfile): + """Move PBC coordinate file to surface_pbcfiles/ if theory is periodic.""" + if not getattr(theory, "periodic", False): + return + pbcfile = convert_to_pbcfile( + fragment.coords, fragment.elems, + cellvectors=theory.periodic_cell_vectors, + ) + ext = pbcfile.split('.')[-1] + shutil.move(pbcfile, f"surface_pbcfiles/{pointlabel}.{ext}") + +def _handle_output_files(theory, pointlabel, keepoutputfiles, keepmofiles, printlevel=2): + """Copy QM output / MO files to their surface subdirectories.""" + if not hasattr(theory, 'theorytype') or theory.theorytype != "QM": + if keepoutputfiles or keepmofiles: + print("Warning: For hybrid theories, outputfiles and MO-files are not kept") + return + if keepoutputfiles: + try: + shutil.copyfile( + theory.filename + '.out', + f'surface_outfiles/{theory.filename}_{pointlabel}.out', + ) + except TypeError: + print_if_level("Theory has no outputfile, probably. ignoring", printlevel,2) + pass + except FileNotFoundError: + print_if_level("Outputfile might have been deleted. ignoring", printlevel,2) + pass + except AttributeError: + print_if_level("Theory has no outputfile, probably. ignoring", printlevel,2) + pass + if keepmofiles: + try: + shutil.copyfile( + theory.filename + '.gbw', + f'surface_mofiles/{theory.filename}_{pointlabel}.gbw', + ) + except FileNotFoundError: + pass + + + + + + + + +# --------------------------------------------------------------------------- +# Covalent radii (Angstrom) — used for connectivity detection +# Subset covering most common elements; extend as needed. +# --------------------------------------------------------------------------- +_COVALENT_RADII = { + 'H': 0.31, 'He': 0.28, + 'Li': 1.28, 'Be': 0.96, 'B': 0.84, 'C': 0.76, 'N': 0.71, 'O': 0.66, + 'F': 0.57, 'Ne': 0.58, + 'Na': 1.66, 'Mg': 1.41, 'Al': 1.21, 'Si': 1.11, 'P': 1.07, 'S': 1.05, + 'Cl': 1.02, 'Ar': 1.06, + 'K': 2.03, 'Ca': 1.76, 'Sc': 1.70, 'Ti': 1.60, 'V': 1.53, 'Cr': 1.39, + 'Mn': 1.61, 'Fe': 1.52, 'Co': 1.50, 'Ni': 1.24, 'Cu': 1.32, 'Zn': 1.22, + 'Ga': 1.22, 'Ge': 1.20, 'As': 1.19, 'Se': 1.20, 'Br': 1.20, 'Kr': 1.16, + 'Rb': 2.20, 'Sr': 1.95, 'Y': 1.90, 'Zr': 1.75, 'Nb': 1.64, 'Mo': 1.54, + 'Tc': 1.47, 'Ru': 1.46, 'Rh': 1.42, 'Pd': 1.39, 'Ag': 1.45, 'Cd': 1.44, + 'In': 1.42, 'Sn': 1.39, 'Sb': 1.39, 'Te': 1.38, 'I': 1.39, 'Xe': 1.40, + 'Cs': 2.44, 'Ba': 2.15, 'La': 2.07, 'Ce': 2.04, 'Pr': 2.03, 'Nd': 2.01, + 'Hf': 1.75, 'Ta': 1.70, 'W': 1.62, 'Re': 1.51, 'Os': 1.44, 'Ir': 1.41, + 'Pt': 1.36, 'Au': 1.36, 'Hg': 1.32, 'Tl': 1.45, 'Pb': 1.46, 'Bi': 1.48, +} +_DEFAULT_RADIUS = 1.50 # fallback for unknown elements +_CONNECTIVITY_TOLERANCE = 0.40 # Angstrom added to sum of covalent radii + + +# --------------------------------------------------------------------------- +# Measurement helpers +# --------------------------------------------------------------------------- + +def _measure_bond(coords, i, j): + """Bond length in Angstrom between atoms i and j.""" + return float(np.linalg.norm(coords[i] - coords[j])) + + +def _measure_angle(coords, i, j, k): + """Angle i-j-k in degrees (j is the vertex).""" + v1 = coords[i] - coords[j] + v2 = coords[k] - coords[j] + cos_a = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) + return float(np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0)))) + + +def _measure_dihedral(coords, i, j, k, l): + """Dihedral angle i-j-k-l in degrees (range -180 to 180).""" + b1 = coords[j] - coords[i] + b2 = coords[k] - coords[j] + b3 = coords[l] - coords[k] + n1 = np.cross(b1, b2) + n2 = np.cross(b2, b3) + m1 = np.cross(n1, b2 / np.linalg.norm(b2)) + return float(np.degrees(np.arctan2(np.dot(m1, n2), np.dot(n1, n2)))) + + +# --------------------------------------------------------------------------- +# Connectivity +# --------------------------------------------------------------------------- + +def _build_connectivity(coords, elems): + coords = np.asarray(coords) + n = len(elems) + radii = np.array([ + _COVALENT_RADII.get(e.capitalize(), _DEFAULT_RADIUS) for e in elems + ]) + conn = [set() for _ in range(n)] + for i in range(n): + for j in range(i + 1, n): + dist = np.linalg.norm(coords[i] - coords[j]) + threshold = radii[i] + radii[j] + _CONNECTIVITY_TOLERANCE + # Ignore very short distances (e.g. same atom or ghost atoms) + if 0.4 < dist < threshold: + conn[i].add(j) + conn[j].add(i) + return conn + +def _atoms_on_side(start, fixed, conn): + """BFS: return set of atom indices reachable from *start* without + crossing *fixed*. Used to find which atoms move when a bond is stretched + or a dihedral is rotated. + + Args: + start : atom index to start BFS from + fixed : atom index that acts as the barrier (not included in result) + conn : adjacency list from _build_connectivity + + Returns: + set of atom indices (includes *start*, excludes *fixed*) + """ + visited = {fixed} # seed with fixed so BFS never crosses it + queue = [start] + visited.add(start) + while queue: + current = queue.pop() + for neighbour in conn[current]: + if neighbour not in visited: + visited.add(neighbour) + queue.append(neighbour) + visited.discard(fixed) + return visited + + +# --------------------------------------------------------------------------- +# Bond length +# --------------------------------------------------------------------------- + +def _set_bond(coords, i, j, target, conn): + """Set bond length i-j to *target* Angstrom by translating the smaller + connected fragment. + + The atom with the smaller connected component (determined by BFS through + *conn* with the i-j bond removed) is moved together with all atoms on its + side. + + Args: + coords : (N, 3) numpy array, modified in-place + i, j : atom indices defining the bond + target : target bond length in Angstrom + conn : adjacency list from _build_connectivity + """ + current = _measure_bond(coords, i, j) + if abs(current - target) < 1e-6: + return + + # Find which side is smaller — move that side + side_i = _atoms_on_side(i, fixed=j, conn=conn) + side_j = _atoms_on_side(j, fixed=i, conn=conn) + + if len(side_i) <= len(side_j): + move_atoms = side_i + direction = coords[i] - coords[j] # points toward i from j + else: + move_atoms = side_j + direction = coords[j] - coords[i] # points toward j from i + + unit = direction / np.linalg.norm(direction) + delta = (target - current) * unit + for atom in move_atoms: + coords[atom] += delta + + +# --------------------------------------------------------------------------- +# Bond angle +# --------------------------------------------------------------------------- + +def _set_angle(coords, i, j, k, target_deg, conn): + current_deg = _measure_angle(coords, i, j, k) + delta_deg = target_deg - current_deg + if abs(delta_deg) < 1e-6: + return + + v1 = coords[i] - coords[j] # vector from vertex to i + v2 = coords[k] - coords[j] # vector from vertex to k + + # Rotation axis perpendicular to the i-j-k plane + axis = np.cross(v1, v2) + axis_norm = np.linalg.norm(axis) + + if axis_norm < 1e-8: + # v1 and v2 are (anti)parallel — the plane is undefined. + # Build an arbitrary perpendicular to v1 as the rotation axis. + axis = _arbitrary_perpendicular(v1) + else: + axis = axis / axis_norm + + # Rotate the smaller side + side_i = _atoms_on_side(i, fixed=j, conn=conn) + side_k = _atoms_on_side(k, fixed=j, conn=conn) + + # Fallback: if connectivity failed, move just the single terminal atom + if len(side_i) == 0: + print(f"Warning: _set_angle: no atoms found on i-side of bond {j}-{i}. " + f"Check connectivity. Falling back to moving atom {i} only.") + side_i = {i} + if len(side_k) == 0: + print(f"Warning: _set_angle: no atoms found on k-side of bond {j}-{k}. " + f"Check connectivity. Falling back to moving atom {k} only.") + side_k = {k} + + if len(side_i) <= len(side_k): + move_atoms = side_i + angle_rad = np.radians(delta_deg) + else: + move_atoms = side_k + angle_rad = np.radians(-delta_deg) + + # --- Sign check: trial rotation --- + R_trial = _rotation_matrix(axis, angle_rad) + pivot = coords[j] + coords_trial = coords.copy() + for atom in move_atoms: + coords_trial[atom] = pivot + R_trial @ (coords_trial[atom] - pivot) + + achieved_trial = _measure_angle(coords_trial, i, j, k) + error_pos = abs(achieved_trial - target_deg) + error_neg = abs(_measure_angle( + _apply_rotation(coords, move_atoms, pivot, + _rotation_matrix(axis, -angle_rad)), i, j, k + ) - target_deg) + + # Pick the direction that gets closer to target + if error_neg < error_pos: + angle_rad = -angle_rad + + R = _rotation_matrix(axis, angle_rad) + for atom in move_atoms: + coords[atom] = pivot + R @ (coords[atom] - pivot) + +def _apply_rotation(coords, move_atoms, pivot, R): + """Return a copy of coords with move_atoms rotated — used for trial checks.""" + coords_trial = coords.copy() + for atom in move_atoms: + coords_trial[atom] = pivot + R @ (coords_trial[atom] - pivot) + return coords_trial +# --------------------------------------------------------------------------- +# Dihedral angle +# --------------------------------------------------------------------------- + +def _set_dihedral(coords, i, j, k, l, target_deg, conn): + current_deg = _measure_dihedral(coords, i, j, k, l) + delta_deg = target_deg - current_deg + + # Wrap into (-180, 180] + delta_deg = (delta_deg + 180.0) % 360.0 - 180.0 + + if abs(delta_deg) < 1e-6: + return + + axis = coords[k] - coords[j] + axis = axis / np.linalg.norm(axis) + + move_atoms = _atoms_on_side(l, fixed=k, conn=conn) + + # Trial rotation with +delta to check sign + R_trial = _rotation_matrix(axis, np.radians(delta_deg)) + pivot = coords[k] + coords_trial = coords.copy() + for atom in move_atoms: + coords_trial[atom] = pivot + R_trial @ (coords_trial[atom] - pivot) + + achieved_trial = _measure_dihedral(coords_trial, i, j, k, l) + error_pos = abs((achieved_trial - target_deg + 180.0) % 360.0 - 180.0) + + # If positive delta moved us away, flip the sign + if error_pos > abs(delta_deg) * 0.5: + delta_deg = -delta_deg + + R = _rotation_matrix(axis, np.radians(delta_deg)) + for atom in move_atoms: + coords[atom] = pivot + R @ (coords[atom] - pivot) + + +# --------------------------------------------------------------------------- +# Low-level math helpers +# --------------------------------------------------------------------------- + +def _rotation_matrix(axis, angle_rad): + """Rodrigues' rotation formula: 3x3 rotation matrix. + + Args: + axis : unit vector (length-3 array) + angle_rad : rotation angle in radians + + Returns: + (3, 3) numpy array + """ + c = np.cos(angle_rad) + s = np.sin(angle_rad) + t = 1.0 - c + x, y, z = axis + return np.array([ + [t*x*x + c, t*x*y - s*z, t*x*z + s*y], + [t*x*y + s*z, t*y*y + c, t*y*z - s*x], + [t*x*z - s*y, t*y*z + s*x, t*z*z + c ], + ]) + + +def _arbitrary_perpendicular(v): + """Return a unit vector perpendicular to *v* (for collinear edge case).""" + v = np.asarray(v, dtype=float) + if abs(v[0]) < 0.9: + perp = np.array([1.0, 0.0, 0.0]) + else: + perp = np.array([0.0, 1.0, 0.0]) + perp = np.cross(v, perp) + return perp / np.linalg.norm(perp) + + +# --------------------------------------------------------------------------- +# A function to set the geometry directly +# --------------------------------------------------------------------------- + +def _set_geometry_direct(fragment, RC_list, rc_values, conn=None): + """Move fragment.coords to the target RC values without any optimiser. + + Supports constraint types: 'bond', 'angle', 'dihedral'. + Connectivity is built once from the current geometry. + All RC coordinates are applied sequentially; if multiple RCs share atoms + they are applied in the order given (same order as RC_list). + + For symmetric constraints (multiple index sets per RC, e.g. two equivalent + bonds) all index sets are applied for the same target value. + + Args: + fragment : ASH Fragment object with .coords (Angstrom) and .elems + RC_list : normalised RC_list (indices already list-of-lists) + rc_values : tuple of target values, one per RC entry in RC_list + """ + coords = np.array(fragment.coords, dtype=float) # working copy + elems = fragment.elems + + # Build connectivity once — cheap, done from current geometry + print("Connectivity of atoms 0,1,2:", {a: conn[a] for a in [0,1,2]}) + + for rc, target in zip(RC_list, rc_values): + rc_type = rc['type'].lower() + for indices in rc['indices']: # rc['indices'] is list-of-lists + if rc_type in ('bond', 'distance'): + i, j = indices + _set_bond(coords, i, j, float(target), conn) + + elif rc_type == 'angle': + i, j, k = indices + _set_angle(coords, i, j, k, float(target), conn) + + elif rc_type in ('dihedral', 'torsion'): + i, j, k, l = indices + _set_dihedral(coords, i, j, k, l, float(target), conn) + + else: + print( + f"Warning: _set_geometry_direct does not support constraint " + f"type '{rc_type}'. Skipping." + ) + + # Write the modified coordinates back into the fragment + fragment.coords =coords + + +# --------------------------------------------------------------------------- +# Verifying the set geometry constraint +# --------------------------------------------------------------------------- + +def _verify_geometry(fragment, RC_list, rc_values, tol=1e-3, printlevel=2): + coords = np.array(fragment.coords) + print_if_level(" RC pre-set verification:", printlevel,2) + for i, (rc, target) in enumerate(zip(RC_list, rc_values)): + rc_type = rc['type'].lower() + for indices in rc['indices']: + if rc_type in ('bond', 'distance'): + achieved = _measure_bond(coords, *indices) + deviation = abs(achieved - target) + elif rc_type == 'angle': + achieved = _measure_angle(coords, *indices) + deviation = abs(achieved - target) + elif rc_type == 'dihedral': + achieved = _measure_dihedral(coords, *indices) + # Normalize deviation to (-180, 180] — 190 and -170 are identical + deviation = abs((achieved - target + 180.0) % 360.0 - 180.0) + else: + continue + flag = " <-- WARNING" if deviation > tol else "" + if printlevel > 1: + print( + f" RC{i+1} {rc_type} {indices}: " + f"target={target:.4f} achieved={achieved:.4f} " + f"dev={deviation:.4f}{flag}" + ) + + +# --------------------------------------------------------------------------- +# Implementation of a RestraintTheory: alternative way of setting restraints +# --------------------------------------------------------------------------- + +def _preset_geometry_restraint(fragment, RC_list, rc_values, optimizerobj, + opt_arguments, charge, mult,printlevel=1, extraconstraints=None, + extraoopt_run_kws=None, + force_constant=10000.0): + """Drive geometry to target RC values using RestraintTheory + any optimiser.""" + restraints = [] + for rc, target in zip(RC_list, rc_values): + for indices in rc['indices']: + restraints.append({ + 'type': rc['type'], + 'indices': indices, + 'target': float(target), + }) + + restraint_theory = RestraintTheory( + restraints=restraints, + force_constant=force_constant, + ) + + # Strip any constraints from opt_arguments — we don't want them here + preset_args = {k: v for k, v in opt_arguments.items() + if k not in ('constraints', 'constrainvalue')} + # Optimizing with restraint theory, passing extraconstraints as contraints if present + optimizerobj.run(theory=restraint_theory,fragment=fragment, constraints=extraconstraints, **extraoopt_run_kws) + + #optimizer( + # fragment=fragment, theory=restraint_theory, constraints=extraconstraints, + # charge=charge, mult=mult, printlevel=printlevel, + # **preset_args, + #) + + + +class RestraintTheory: + def __init__(self, fragment=None, printlevel=None, numcores=1, label=None, + restraints=None, force_constant=10000.0): + """RestraintTheory: A theory that implements harmonic restraint potentials + on internal coordinates (bonds, angles, dihedrals). Designed to be used + with an optimiser to drive geometry to target RC values. + + The energy and gradient are purely from harmonic restraints: + E = 0.5 * k * (q - q0)^2 + where q is the current value of the internal coordinate and q0 is the + target value. Angles and dihedrals use degree units internally but the + force constant should be chosen accordingly (see below). + + Args: + fragment : ASH fragment. Defaults to None. + printlevel : print verbosity 0-3. Defaults to None. + numcores : number of cores (unused, for consistency). Defaults to 1. + label : string label. Defaults to None. + restraints : list of restraint dicts, each with keys: + 'type' : 'bond', 'angle', or 'dihedral' + 'indices' : list of atom indices + 'target' : target value (Å for bonds, + degrees for angles/dihedrals) + Example: + [{'type': 'bond', 'indices': [0, 1], 'target': 1.2, 'forceconstant': 50}, + {'type': 'angle', 'indices': [1, 0, 2], 'target': 104.5, 'forceconstant': 20}, + {'type': 'dihedral', 'indices': [0,1,2,3], 'target': 180.0, 'forceconstant': 10}] + force_constant : Global harmonic force constant k. Only used if no forceconstant in individual restraintdict. + Defaults to 10000.0. + Units: energy/Ų for bonds, energy/deg² for angles + and dihedrals. The default is chosen to be stiff + enough to reach the target closely in a few steps. + Reduce if the optimiser has convergence problems. + """ + self.numcores = numcores + self.printlevel = printlevel + self.label = label + self.fragment = fragment + self.filename = "restrainttheory" + self.theorynamelabel = "RestraintTheory" + self.theorytype = "QM" # treated as QM so ASH passes coords/grad + + self.restraints = restraints if restraints is not None else [] + self.force_constant = force_constant + + self.energy = 0.0 + self.gradient = None + + # ------------------------------------------------------------------ + # Internal coordinate measurement + # ------------------------------------------------------------------ + + @staticmethod + def _measure_bond(coords, i, j): + return float(np.linalg.norm(coords[i] - coords[j])) + + @staticmethod + def _measure_bond_difference(coords, i, j, k, l): + """q = |r_i - r_j| - |r_k - r_l|""" + r1 = float(np.linalg.norm(coords[i] - coords[j])) + r2 = float(np.linalg.norm(coords[k] - coords[l])) + return r1 - r2 + + @staticmethod + def _measure_angle(coords, i, j, k): + v1 = coords[i] - coords[j] + v2 = coords[k] - coords[j] + cos_a = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) + return float(np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0)))) + + @staticmethod + def _measure_dihedral(coords, i, j, k, l): + b1 = coords[j] - coords[i] + b2 = coords[k] - coords[j] + b3 = coords[l] - coords[k] + n1 = np.cross(b1, b2) + n2 = np.cross(b2, b3) + m1 = np.cross(n1, b2 / np.linalg.norm(b2)) + return float(np.degrees(np.arctan2(np.dot(m1, n2), np.dot(n1, n2)))) + + # ------------------------------------------------------------------ + # Analytical gradients of internal coordinates w.r.t. Cartesian coords + # ------------------------------------------------------------------ + + @staticmethod + def _bond_gradient(coords, i, j): + """dq/dX for bond length q = |r_i - r_j|. + Returns (natoms, 3) sparse gradient array.""" + natoms = len(coords) + grad = np.zeros((natoms, 3)) + r = coords[i] - coords[j] + r_norm = np.linalg.norm(r) + if r_norm < 1e-10: + return grad + unit = r / r_norm + grad[i] += unit + grad[j] -= unit + return grad + + @staticmethod + def _bond_difference_gradient(coords, i, j, k, l): + """dq/dX for q = bond(i,j) - bond(k,l). + Returns (natoms, 3) sparse gradient array.""" + natoms = len(coords) + grad = np.zeros((natoms, 3)) + + # +1 * gradient of first bond + r1 = coords[i] - coords[j] + r1_norm = np.linalg.norm(r1) + if r1_norm > 1e-10: + u1 = r1 / r1_norm + grad[i] += u1 + grad[j] -= u1 + + # -1 * gradient of second bond + r2 = coords[k] - coords[l] + r2_norm = np.linalg.norm(r2) + if r2_norm > 1e-10: + u2 = r2 / r2_norm + grad[k] -= u2 + grad[l] += u2 + + return grad + + @staticmethod + def _angle_gradient(coords, i, j, k): + """dq/dX for angle q (degrees) at vertex j. + Returns (natoms, 3) sparse gradient array.""" + natoms = len(coords) + grad = np.zeros((natoms, 3)) + v1 = coords[i] - coords[j] + v2 = coords[k] - coords[j] + n1 = np.linalg.norm(v1) + n2 = np.linalg.norm(v2) + if n1 < 1e-10 or n2 < 1e-10: + return grad + + cos_a = np.dot(v1, v2) / (n1 * n2) + cos_a = np.clip(cos_a, -1.0 + 1e-10, 1.0 - 1e-10) + sin_a = np.sqrt(1.0 - cos_a**2) + if sin_a < 1e-10: + return grad + + # d(angle_rad)/dX, then convert to degrees + # Using the standard Wilson B-matrix elements + u1 = v1 / n1 + u2 = v2 / n2 + # Gradient w.r.t. atom i + gi = (cos_a * u1 - u2) / (n1 * sin_a) + # Gradient w.r.t. atom k + gk = (cos_a * u2 - u1) / (n2 * sin_a) + # Gradient w.r.t. vertex j (negative sum) + gj = -(gi + gk) + + deg_per_rad = 180.0 / np.pi + grad[i] += gi * deg_per_rad + grad[j] += gj * deg_per_rad + grad[k] += gk * deg_per_rad + return grad + + @staticmethod + def _dihedral_gradient(coords, i, j, k, l): + """dq/dX for dihedral angle q (degrees) i-j-k-l. + Returns (natoms, 3) sparse gradient array.""" + natoms = len(coords) + grad = np.zeros((natoms, 3)) + + b1 = coords[j] - coords[i] + b2 = coords[k] - coords[j] + b3 = coords[l] - coords[k] + + n1 = np.cross(b1, b2) + n2 = np.cross(b2, b3) + n1_norm = np.linalg.norm(n1) + n2_norm = np.linalg.norm(n2) + b2_norm = np.linalg.norm(b2) + + if n1_norm < 1e-10 or n2_norm < 1e-10 or b2_norm < 1e-10: + return grad + + n1_u = n1 / n1_norm + n2_u = n2 / n2_norm + b2_u = b2 / b2_norm + + # Standard Blondel & Karplus (1996) dihedral gradient + gi = (b2_norm / n1_norm**2) * n1 + gl = -(b2_norm / n2_norm**2) * n2 + gj = (-np.dot(b1, b2) / (b2_norm * n1_norm**2)) * n1 \ + + (np.dot(b3, b2) / (b2_norm * n2_norm**2)) * n2 + gk = -gj - gi - gl # translational invariance: sum = 0 + + deg_per_rad = 180.0 / np.pi + grad[i] += gi * deg_per_rad + grad[j] += gj * deg_per_rad + grad[k] += gk * deg_per_rad + grad[l] += gl * deg_per_rad + return grad + + # ------------------------------------------------------------------ + # Main run method + # ------------------------------------------------------------------ + + def run(self, current_coords=None, elems=None, Grad=False, PC=False, + numcores=None, charge=None, mult=None, label=None, + current_MM_coords=None, MMcharges=None, qm_elems=None): + + # Convert coords from Å to Bohr for all internal calculations + coords = current_coords * ang2bohr + natoms = len(coords) + + energy = 0.0 + gradient = np.zeros((natoms, 3)) + + for r in self.restraints: + rtype = r['type'].lower() + idx = r['indices'] + # Check if forceconstant in r: + k = float(r.get('force_constant', self.force_constant)) + + if rtype in ('bond', 'distance'): + # target given in Å — convert to Bohr + target = float(r['target']) * ang2bohr + q = self._measure_bond(coords, *idx) # now in Bohr + dq = q - target # Bohr + energy += 0.5 * k * dq**2 # Eh (k in Eh/Bohr²) + if Grad: + dqdX = self._bond_gradient(coords, *idx) # dimensionless (Bohr/Bohr) + gradient += k * dq * dqdX # Eh/Bohr + elif rtype in ('bond_difference', 'bond_diff'): + # indices: [i, j, k, l] — restrains bond(i,j) - bond(k,l) + # target given in Å — convert to Bohr + target = float(r['target']) * ang2bohr + q = self._measure_bond_difference(coords, *idx) # Bohr + dq = q - target # Bohr + energy += 0.5 * k * dq**2 # Eh (k in Eh/Bohr²) + if Grad: + dqdX = self._bond_difference_gradient(coords, *idx) # dimensionless + gradient += k * dq * dqdX # Eh/Bohr + elif rtype == 'angle': + # target given in degrees — convert to radians + target = float(r['target']) * np.pi / 180.0 + q = self._measure_angle(coords, *idx) * np.pi / 180.0 # rad + dq = (q - target + np.pi) % (2*np.pi) - np.pi # rad + energy += 0.5 * k * dq**2 # Eh (k in Eh/rad²) + if Grad: + # _angle_gradient currently returns deg/Å — convert to rad/Bohr + dqdX = self._angle_gradient(coords, *idx) # deg/Bohr (coords in Bohr) + dqdX *= np.pi / 180.0 # → rad/Bohr + gradient += k * dq * dqdX # Eh/Bohr + + elif rtype in ('dihedral', 'torsion'): + # same as angle + target = float(r['target']) * np.pi / 180.0 + q = self._measure_dihedral(coords, *idx) * np.pi / 180.0 + dq = (q - target + np.pi) % (2*np.pi) - np.pi + energy += 0.5 * k * dq**2 # Eh (k in Eh/rad²) + if Grad: + dqdX = self._dihedral_gradient(coords, *idx) # deg/Bohr + dqdX *= np.pi / 180.0 # → rad/Bohr + gradient += k * dq * dqdX # Eh/Bohr + + self.energy = energy # Eh + self.gradient = gradient # Eh/Bohr + + if not Grad: + return self.energy + else: + return self.energy, self.gradient + + +# --------------------------------------------------------------------------- +# Surface analysis +# --------------------------------------------------------------------------- + + +def analyze_surface(resultfile='surface_results.txt', dimension=None, + energy_unit='kcal/mol', tol=1e-6): + """Analyze a surface scan result file for minima, maxima, and saddle points. + + Works for any dimension but critical point classification beyond 1D relies + on finite-difference estimation of the Hessian on the grid, so results are + only as good as the grid resolution. + + Args: + resultfile : path to surface_results.txt + dimension : number of RC coordinates (inferred if None) + energy_unit : 'kcal/mol', 'kJ/mol', or 'Eh' for relative energies + tol : energy tolerance for detecting flat regions + + Returns: + dict with keys 'global_min', 'local_minima', 'global_max', + 'local_maxima', 'saddle_points' + Each entry is a list of dicts with 'coords', 'energy', 'rel_energy'. + """ + + # -- Unit conversion ---------------------------------------------------- + conv = {'kcal/mol': 627.509, 'kJ/mol': 2625.50, 'Eh': 1.0} + if energy_unit not in conv: + print(f"Warning: unknown energy_unit '{energy_unit}', using kcal/mol") + energy_unit = 'kcal/mol' + factor = conv[energy_unit] + + # -- Read data ---------------------------------------------------------- + surfacedictionary = read_surfacedict_from_file(resultfile, dimension) + if dimension is None: + dimension = len(list(surfacedictionary.keys())[0]) + + print(f"Read {len(surfacedictionary)} points, dimension={dimension}") + + if dimension == 1: + return _analyze_1d(surfacedictionary, factor, energy_unit, tol) + else: + return _analyze_nd(surfacedictionary, dimension, factor, energy_unit, tol) + + +# --------------------------------------------------------------------------- +# 1D analysis +# --------------------------------------------------------------------------- + +def _analyze_1d(surfacedictionary, factor, energy_unit, tol): + # 1. Sort and extract + keys = sorted(surfacedictionary.keys()) + coords = np.array([k if isinstance(k, tuple) else (k,) for k in keys]) + energies = np.array([surfacedictionary[k] for k in keys]) + + # 2. Periodicity Detection & Trimming + # If the first and last points are the same physical location (e.g., -180 and 180), + # we remove the last point to avoid "neighboring itself" in the cycle. + is_periodic = (abs(abs(coords[-1][0] - coords[0][0]) - 360.0) < 1.0) + + if is_periodic: + print("Periodic scan detected. Wrapping boundaries for analysis.") + analysis_energies = energies[:-1] + analysis_coords = coords[:-1] + else: + analysis_energies = energies + analysis_coords = coords + + n = len(analysis_energies) + local_minima = [] + local_maxima = [] + + # 3. Find Critical Points + for idx in range(n): + e = analysis_energies[idx] + + if is_periodic: + left = analysis_energies[(idx - 1) % n] + right = analysis_energies[(idx + 1) % n] + else: + if idx == 0 or idx == n - 1: continue + left = analysis_energies[idx - 1] + right = analysis_energies[idx + 1] + + # Use >= or <= with tol to be inclusive of "flat" minima/maxima if needed, + # but strict inequality is usually safer for discrete scans. + is_min = (e < left - tol) and (e < right - tol) + is_max = (e > left + tol) and (e > right + tol) + + entry = {'coords': tuple(analysis_coords[idx]), 'energy': e} + + if is_min: + local_minima.append(entry) + elif is_max: + local_maxima.append(entry) + + # 4. Global vs Local Assignment + if not local_minima: + # Fallback if no local minima found due to high tol + idx_min = np.argmin(analysis_energies) + local_minima = [{'coords': tuple(analysis_coords[idx_min]), 'energy': analysis_energies[idx_min]}] + + local_minima.sort(key=lambda x: x['energy']) + local_maxima.sort(key=lambda x: x['energy'], reverse=True) + + global_min = local_minima[0] + global_max = local_maxima[0] if local_maxima else None + + # 5. Compute Relative Energies + for entry in local_minima + local_maxima: + entry['rel_energy'] = (entry['energy'] - global_min['energy']) * factor + + result = { + 'global_min': global_min, + 'local_minima': local_minima[1:], + 'global_max': global_max, + 'local_maxima': local_maxima[1:], + 'saddle_points': [], + } + + # Assuming _print_analysis is defined elsewhere + _print_analysis(result, factor, energy_unit, dimension=1) + return result + +# --------------------------------------------------------------------------- +# ND analysis (2D, 3D, ...) +# --------------------------------------------------------------------------- + + +def _analyze_nd(surfacedictionary, dimension, factor, energy_unit, tol): + import itertools as it + # --- 1. Grid Setup --- + all_keys = sorted(surfacedictionary.keys()) + axes = [np.array(sorted({k[d] for k in all_keys})) for d in range(dimension)] + shape = tuple(len(a) for a in axes) + index_maps = [{v: i for i, v in enumerate(a)} for a in axes] + grid = np.full(shape, np.nan) + for key, energy in surfacedictionary.items(): + idx = tuple(index_maps[d][key[d]] for d in range(dimension)) + grid[idx] = float(energy) + + global_min_e = np.nanmin(grid) + local_minima, local_maxima, saddle_candidates = [], [], [] + + # --- 2. Iterate Interior Points --- + ranges = [range(1, s - 1) for s in shape] + for idx in it.product(*ranges): + e0 = grid[idx] + if np.isnan(e0): continue + + # A. Calculate Gradient Norm (Stationary Check) + grads = [] + for d in range(dimension): + i_p, i_m = list(idx), list(idx) + i_p[d] += 1; i_m[d] -= 1 + h = axes[d][idx[d]+1] - axes[d][idx[d]-1] + grads.append((grid[tuple(i_p)] - grid[tuple(i_m)]) / h) + + gnorm = np.linalg.norm(grads) + + # B. Strict Neighbor Comparison (topology test) + # Check neighbors along principal axes + nb_vals = [] + for d in range(dimension): + i_p, i_m = list(idx), list(idx) + i_p[d] += 1; i_m[d] -= 1 + nb_vals.append((grid[tuple(i_m)], grid[tuple(i_p)])) + + # Determine if it's an extreme or a saddle + # is_min: lower than all immediate neighbors + is_min = all(e0 < v_m - tol and e0 < v_p - tol for v_m, v_p in nb_vals) + # is_max: higher than all immediate neighbors + is_max = all(e0 > v_m + tol and e0 > v_p + tol for v_m, v_p in nb_vals) + + # is_saddle: max in one dir, min in another (for 2D) + # We check: (Min in X and Max in Y) OR (Max in X and Min in Y) + is_saddle = False + if dimension == 2: + (x_m, x_p), (y_m, y_p) = nb_vals + saddle_1 = (e0 < x_m and e0 < x_p) and (e0 > y_m and e0 > y_p) + saddle_2 = (e0 > x_m and e0 > x_p) and (e0 < y_m and e0 < y_p) + is_saddle = saddle_1 or saddle_2 + + coords = tuple(axes[d][idx[d]] for d in range(dimension)) + entry = {'coords': coords, 'energy': e0, 'rel_energy': (e0 - global_min_e)*factor, 'gnorm': gnorm} + + if is_min: + local_minima.append(entry) + elif is_max: + local_maxima.append(entry) + elif is_saddle: + saddle_candidates.append(entry) + + # --- 3. Non-Maximum Suppression (Clustering) --- + # This is the "Magic" step that deletes duplicates in flat regions + def cluster_points(points, is_saddle=False): + if not points: return [] + # Sort by gradient norm (we want the point closest to a true stationary point) + points.sort(key=lambda x: x['gnorm']) + unique = [] + for p in points: + is_redundant = False + for u in unique: + # If point is within 2 grid steps of a better one, discard it + dist = np.array([abs(p['coords'][d] - u['coords'][d]) for d in range(dimension)]) + step = np.array([axes[d][1] - axes[d][0] for d in range(dimension)]) + if all(dist <= step * 2.1): # 2-step radius + is_redundant = True + break + if not is_redundant: + unique.append(p) + return unique + + refined_minima = cluster_points(local_minima) + refined_maxima = cluster_points(local_maxima) + refined_saddles = cluster_points(saddle_candidates, is_saddle=True) + + # Final result construction + refined_minima.sort(key=lambda x: x['energy']) + refined_maxima.sort(key=lambda x: x['energy'], reverse=True) + refined_saddles.sort(key=lambda x: x['energy']) + + result = { + 'global_min': refined_minima[0] if refined_minima else None, + 'local_minima': refined_minima[1:], + 'global_max': refined_maxima[0] if refined_maxima else None, + 'local_maxima': refined_maxima[1:], + 'saddle_points': refined_saddles, + } + _print_analysis(result, factor, energy_unit, dimension=dimension) + return result + + +# --------------------------------------------------------------------------- +# Pretty printer +# --------------------------------------------------------------------------- + +def _print_analysis(result, factor, energy_unit, dimension): + col_w = 12 + + def fmt_coords(coords): + return ' '.join(f'{v:>10.4f}' for v in coords) + + def fmt_entry(entry, tag=''): + c = fmt_coords(entry['coords']) + e = f"{entry['energy']:>18.10f} Eh" + r = f"{entry['rel_energy']:>12.4f} {energy_unit}" + order_str = '' + if 'order' in entry: + order_str = f" ({entry['order']}-order SP)" + return f" {c} {e} {r} {tag}{order_str}" + + print() + print("=" * 80) + print("SURFACE ANALYSIS") + print("=" * 80) + + print("\nMINIMA") + print("-" * 80) + if result['global_min']: + print(fmt_entry(result['global_min'], tag='(global min)')) + if result['local_minima']: + for entry in result['local_minima']: + print(fmt_entry(entry, tag='(local min)')) + if not result['global_min'] and not result['local_minima']: + print(" None found (may be on boundary or grid too coarse)") + + print("\nMAXIMA") + print("-" * 80) + if result['global_max']: + print(fmt_entry(result['global_max'], tag='(global max)')) + if result['local_maxima']: + for entry in result['local_maxima']: + print(fmt_entry(entry, tag='(local max)')) + if not result['global_max'] and not result['local_maxima']: + print(" None found (may be on boundary or grid too coarse)") + + print("\nSADDLE POINTS") + print("-" * 80) + if result['saddle_points']: + for entry in result['saddle_points']: + print(fmt_entry(entry)) + else: + print(" None found") + + print("=" * 80) + print() \ No newline at end of file diff --git a/ash/modules/module_surface.py b/ash/modules/module_surface_old.py similarity index 88% rename from ash/modules/module_surface.py rename to ash/modules/module_surface_old.py index cc03831f8..12ea5ea2c 100644 --- a/ash/modules/module_surface.py +++ b/ash/modules/module_surface_old.py @@ -12,10 +12,10 @@ import copy import time #import ash -from ash.functions.functions_general import frange, BC, print_line_with_mainheader,print_line_with_subheader1,print_time_rel, ashexit +from ash.functions.functions_general import frange, BC, natural_sort, print_line_with_mainheader,print_line_with_subheader1,print_time_rel, ashexit from ash.modules.module_freq import calc_rotational_constants import ash.functions.functions_parallel -from ash.modules.module_coords import check_charge_mult +from ash.modules.module_coords import check_charge_mult, write_CIF_file, write_POSCAR_file, write_XSF_file from ash.modules.module_results import ASH_Results from ash.interfaces.interface_geometric_new import geomeTRICOptimizer,GeomeTRICOptimizerClass from ash.modules.module_theory import NumGradclass @@ -24,9 +24,9 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='UNRELAXED', resultfile='surface_results.txt', keepoutputfiles=True, keepmofiles=False,runmode='serial', coordsystem='dlc', maxiter=250, NumGrad=False, - extraconstraints=None, convergence_setting=None, conv_criteria=None, subfrctor=1, + extraconstraints=None, convergence_setting=None, conv_criteria=None, subfrctor=1, force_noPBC=False, numcores=1, ActiveRegion=False, actatoms=None, RC1_range=None, RC1_type=None, RC1_indices=None, - RC2_range=None, RC2_type=None, RC2_indices=None): + RC2_range=None, RC2_type=None, RC2_indices=None, PBC_format_option="CIF"): """Calculate 1D/2D surface Args: @@ -119,9 +119,26 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U print("keepoutputfiles: ", keepoutputfiles) print("keepmofiles: ", keepmofiles) - pointcount=0 + # Check if theory is periodc + if getattr(theory, "periodic", False): + print("Warning: Theory is periodic. Constrained geometry optimizations by geomeTRIC Optimizer will optimize both atom and cell parameters") + print("Set force_noPBC to True if you do not want optimization of cell parameters.") + try: + shutil.rmtree("surface_pbcfiles") + except: + pass + print("Creating directory: surface_pbcfiles to store coordinate files with PBC information") + os.mkdir('surface_pbcfiles') + print(f"PBC_format_option: {PBC_format_option} i.e. file-format to use for files in surface_pbcfiles (options are: CIF, XSF and POSCAR)") + if PBC_format_option.upper() =="CIF": + convert_to_pbcfile=write_CIF_file + elif PBC_format_option.upper() =="XSF": + convert_to_pbcfile=write_XSF_file + elif PBC_format_option.upper() == "POSCAR": + convert_to_pbcfile=write_POSCAR_file + #Create directories to keep track of surface XYZ files, outputfiles, fragmentfiles, MOfiles #Deleting old directories first @@ -129,30 +146,35 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U shutil.rmtree("surface_xyzfiles") except: pass + try: shutil.rmtree("surface_outfiles") except: pass - #try: - # shutil.rmtree("surface_fragfiles") - #except: - # pass + try: shutil.rmtree("surface_mofiles") except: pass os.mkdir('surface_xyzfiles') os.mkdir('surface_outfiles') - #os.mkdir('surface_fragfiles') os.mkdir('surface_mofiles') + try: + os.remove("surface_traj.xyz") + except: + pass + ########################### # PARALLEL ########################### if runmode=='parallel': print("Parallel runmode.") - #surfacepointfragments={} + print("Number of cores: ", numcores) + if numcores == 1: + print("Error: numcores is set to 1. Please set numcores to a value higher than 1 for parallel runmode. Exiting...") + ashexit() surfacepointfragments_lists=[] ##################### # PARALLEL: UNRELAXED @@ -177,12 +199,16 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U #Running zero-theory with optimizer just to set geometry geomeTRICOptimizer(fragment=fragment, theory=zerotheory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, constrainvalue=True, convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor, - ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False) + ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False, force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) #Shallow copy of fragment newfrag = copy.copy(fragment) newfrag.label = (RCvalue1,RCvalue2) newfrag.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz") shutil.move("RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz", "surface_xyzfiles/RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz") + # PBC + if getattr(theory, "periodic", False): + pbcfile = convert_to_pbcfile(newfrag.coords,newfrag.elems,cellvectors=theory.periodic_cell_vectors) + shutil.move(pbcfile, "surface_pbcfiles/RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+f".{pbcfile.split('.')[-1]}") #surfacepointfragments[(RCvalue1,RCvalue2)] = newfrag surfacepointfragments_lists.append(newfrag) @@ -215,7 +241,7 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U #Running zero-theory with optimizer just to set geometry geomeTRICOptimizer(fragment=fragment, theory=zerotheory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, constrainvalue=True, convergence_setting=convergence_setting,conv_criteria=conv_criteria, subfrctor=subfrctor, - ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False) + ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False, force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) #Shallow copy of fragment newfrag = copy.copy(fragment) #newfrag.label = str(RCvalue1)+"_"+str(RCvalue2) @@ -223,6 +249,10 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U newfrag.label = (RCvalue1) newfrag.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)) shutil.move("RC1_"+str(RCvalue1), "surface_xyzfiles/RC1_"+str(RCvalue1)) + # PBC + if getattr(theory, "periodic", False): + pbcfile = convert_to_pbcfile(newfrag.coords,newfrag.elems,cellvectors=theory.periodic_cell_vectors) + shutil.move(pbcfile, "surface_pbcfiles/RC1_"+str(RCvalue1)+f".{pbcfile.split('.')[-1]}") surfacepointfragments_lists.append(newfrag) print("surfacepointfragments_lists: ", surfacepointfragments_lists) @@ -236,7 +266,7 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U #Create optimizer object optimizer=GeomeTRICOptimizerClass(maxiter=maxiter, coordsystem=coordsystem, convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor, - ActiveRegion=ActiveRegion, actatoms=actatoms) + ActiveRegion=ActiveRegion, actatoms=actatoms, force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) print("Warning: Relaxed scans in parallel mode are experimental") ########################### # PARALLEL: RELAXED: DIM 2 @@ -352,12 +382,17 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U #Running zero-theory with optimizer just to set geometry geomeTRICOptimizer(fragment=fragment, theory=zerotheory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, constrainvalue=True, convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor, - charge=charge, mult=mult, ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False) + charge=charge, mult=mult, ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False, force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) + + # Write to trajectory + fragment.write_xyzfile(xyzfilename="surface_traj.xyz", writemode='a') # Write geometry to disk fragment.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz") #fragment.print_system(filename="RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".ygg") shutil.move("RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz", "surface_xyzfiles/RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz") + + #shutil.move("RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".ygg", "surface_fragfiles/RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".ygg") # Single-point calculation on adjusted geometry if theory is not None: @@ -371,8 +406,15 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U shutil.copyfile(theory.filename+'.gbw', 'surface_mofiles/'+str(theory.filename)+'_'+pointlabel+'.gbw') else: print("Warning: For hybrid theories, outputfiles and MO-files are not kept") - surfacedictionary[(RCvalue1,RCvalue2)] = energy + # PBC + if getattr(theory, "periodic", False): + pbcfile = convert_to_pbcfile(fragment.coords,fragment.elems,cellvectors=theory.periodic_cell_vectors) + shutil.move(pbcfile, "surface_pbcfiles/RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+f".{pbcfile.split('.')[-1]}") + + surfacedictionary[(RCvalue1,RCvalue2)] = float(energy) + # Write surfacedictionary to file after each step + write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) else: print("RC1, RC2 values in dict already. Skipping.") print("surfacedictionary:", surfacedictionary) @@ -394,7 +436,10 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U #Running zero-theory with optimizer just to set geometry geomeTRICOptimizer(fragment=fragment, theory=zerotheory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, constrainvalue=True, convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor, - charge=charge, mult=mult, ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False) + charge=charge, mult=mult, ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False, force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) + + # Write to trajectory + fragment.write_xyzfile(xyzfilename="surface_traj.xyz", writemode='a') #Write geometry to disk: RC1_2.02.xyz fragment.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)+".xyz") @@ -412,7 +457,15 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U shutil.copyfile(theory.filename+'.gbw', 'surface_mofiles/'+str(theory.filename)+'_'+pointlabel+'.gbw') else: print("Warning: For hybrid theories, outputfiles and MO-files are not kept") - surfacedictionary[(RCvalue1)] = energy + + # PBC + if getattr(theory, "periodic", False): + pbcfile = convert_to_pbcfile(fragment.coords,fragment.elems,cellvectors=theory.periodic_cell_vectors) + shutil.move(pbcfile, "surface_pbcfiles/RC1_"+str(RCvalue1)+f".{pbcfile.split('.')[-1]}") + + surfacedictionary[(RCvalue1)] = float(energy) + # Write surfacedictionary to file after each step + write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) else: print("RC1 value in dict already. Skipping.") ##################### @@ -438,7 +491,8 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U # Running result = geomeTRICOptimizer(fragment=fragment, theory=theory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, constrainvalue=True, convergence_setting=convergence_setting, conv_criteria=conv_criteria, - subfrctor=subfrctor,charge=charge, mult=mult, ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False) + subfrctor=subfrctor,charge=charge, mult=mult, ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False, + force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) energy = result.energy print("RCvalue1: {} RCvalue2: {} Energy: {}".format(RCvalue1,RCvalue2, energy)) if theory.theorytype == "QM": @@ -451,13 +505,25 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U shutil.copyfile(theory.filename+'.gbw', 'surface_mofiles/'+str(theory.filename)+'_'+pointlabel+'.gbw') else: print("Warning: For hybrid theories, outputfiles and MO-files are not kept") - surfacedictionary[(RCvalue1,RCvalue2)] = energy + surfacedictionary[(RCvalue1,RCvalue2)] = float(energy) + + # Write surfacedictionary to file after each step + write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) + + # Write to trajectory + fragment.write_xyzfile(xyzfilename="surface_traj.xyz", writemode='a') # Write geometry to disk fragment.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz") #fragment.print_system(filename="RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".ygg") shutil.move("RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz", "surface_xyzfiles/RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz") #shutil.move("RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".ygg", "surface_fragfiles/RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".ygg") + + # PBC + if getattr(theory, "periodic", False): + pbcfile = convert_to_pbcfile(fragment.coords,fragment.elems,cellvectors=theory.periodic_cell_vectors) + shutil.move(pbcfile, "surface_pbcfiles/RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+f".{pbcfile.split('.')[-1]}") + else: print("RC1, RC2 values in dict already. Skipping.") print("surfacedictionary:", surfacedictionary) @@ -479,7 +545,8 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U result = geomeTRICOptimizer(fragment=fragment, theory=theory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, constrainvalue=True, convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor,charge=charge, mult=mult, - ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False) + ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False, + force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) energy = result.energy print("RCvalue1: {} Energy: {}".format(RCvalue1, energy)) if theory.theorytype == "QM": @@ -492,13 +559,25 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U shutil.copyfile(theory.filename+'.gbw', 'surface_mofiles/'+str(theory.filename)+'_'+pointlabel+'.gbw') else: print("Warning: For hybrid theories, outputfiles and MO-files are not kept") - surfacedictionary[(RCvalue1)] = energy + surfacedictionary[(RCvalue1)] = float(energy) + + # Write surfacedictionary to file after each step + write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) - #Write geometry to disk + # Write to trajectory + fragment.write_xyzfile(xyzfilename="surface_traj.xyz", writemode='a') + + # Write geometry to disk fragment.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)+".xyz") - #fragment.print_system(filename="RC1_"+str(RCvalue1)+".ygg") + # fragment.print_system(filename="RC1_"+str(RCvalue1)+".ygg") shutil.move("RC1_"+str(RCvalue1)+".xyz", "surface_xyzfiles/"+"RC1_"+str(RCvalue1)+".xyz") - #shutil.move("RC1_"+str(RCvalue1)+".ygg", "surface_fragfiles/"+"RC1_"+str(RCvalue1)+".ygg") + + # PBC + if getattr(theory, "periodic", False): + pbcfile = convert_to_pbcfile(fragment.coords,fragment.elems,cellvectors=theory.periodic_cell_vectors) + shutil.move(pbcfile, "surface_pbcfiles/RC1_"+str(RCvalue1)+f".{pbcfile.split('.')[-1]}") + + # shutil.move("RC1_"+str(RCvalue1)+".ygg", "surface_fragfiles/"+"RC1_"+str(RCvalue1)+".ygg") else: print("RC1 value in dict already. Skipping.") @@ -506,6 +585,16 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U # Writing dictionary to file write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) + # Combining XYZ-files in surface_xyzfiles into single XYZ-file for visualization + def combine_xyzfiles_in_directory(xyzdir, outputfilename): + xyzfile_list = glob.glob(xyzdir+'/*.xyz') + with open(outputfilename, 'w') as outfile: + for xyzfile in natural_sort(xyzfile_list): + with open(xyzfile, 'r') as infile: + contents = infile.read() + outfile.write(contents) + combine_xyzfiles_in_directory(xyzdir="surface_xyzfiles", outputfilename="surface_traj_final.xyz") + print_time_rel(module_init_time, modulename='calc_surface', moduleindex=0) result = ASH_Results(label="Surface calc", surfacepoints=surfacedictionary) try: @@ -521,7 +610,8 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U # TODO: Parallelization and Relaxed mode def calc_surface_fromXYZ(xyzdir=None, multixyzfile=None, theory=None, charge=None, mult=None, dimension=None, resultfile='surface_results.txt', scantype='UNRELAXED',runmode='serial', coordsystem='dlc', maxiter=250, extraconstraints=None, convergence_setting=None, conv_criteria=None, subfrctor=1, NumGrad=False, - numcores=None, RC1_type=None, RC2_type=None, RC1_indices=None, RC2_indices=None, keepoutputfiles=True, + numcores=None, RC1_type=None, RC2_type=None, RC1_indices=None, RC2_indices=None, keepoutputfiles=True, + force_noPBC=False, keepmofiles=False,read_mofiles=False, mofilesdir=None): module_init_time=time.time() print_line_with_mainheader("CALC_SURFACE_FROMXYZ FUNCTION") @@ -751,7 +841,7 @@ def __init__(self,RC1,RC2=None): elif scantype.upper() == 'RELAXED': #Create optimizer object optimizer=GeomeTRICOptimizerClass(maxiter=maxiter, coordsystem=coordsystem, - convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor, result_write_to_disk=False) + convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor, result_write_to_disk=False, force_noPBC=force_noPBC) print("Warning: calc_surface_fromXYZ Relaxed option is experimental") if read_mofiles == True: #print("Will read MO-file: {}".format(mofilesdir+'/'+str(theory.filename)+'_'+pointlabel+'.gbw')) @@ -816,7 +906,7 @@ def __init__(self,RC1,RC2=None): result = geomeTRICOptimizer(fragment=mol, theory=theory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, constrainvalue=True, convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor, - charge=charge, mult=mult, result_write_to_disk=False) + charge=charge, mult=mult, result_write_to_disk=False, force_noPBC=force_noPBC) energy = result.energy #Write geometry to disk in dir : surface_xyzfiles mol.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)+"-RC2_"+str(RCvalue2)+".xyz") @@ -830,7 +920,7 @@ def __init__(self,RC1,RC2=None): if keepmofiles == True: shutil.copyfile(theory.filename+'.gbw', 'surface_mofiles/'+str(theory.filename)+'_'+pointlabel+'.gbw') #theory.cleanup() - surfacedictionary[(RCvalue1,RCvalue2)] = energy + surfacedictionary[(RCvalue1,RCvalue2)] = float(energy) #Writing dictionary to file #write_surfacedict_to_file(surfacedictionary,resultfile, dimension=2) #print("") @@ -875,7 +965,7 @@ def __init__(self,RC1,RC2=None): result = geomeTRICOptimizer(fragment=mol, theory=theory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, constrainvalue=True, convergence_setting=convergence_setting, conv_criteria=conv_criteria, subfrctor=subfrctor, - charge=charge, mult=mult, result_write_to_disk=False) + charge=charge, mult=mult, result_write_to_disk=False, force_noPBC=force_noPBC) energy = result.energy #Write geometry to disk in dir : surface_xyzfiles mol.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)+".xyz") @@ -887,7 +977,7 @@ def __init__(self,RC1,RC2=None): shutil.copyfile(theory.filename+'.out', 'surface_outfiles/'+str(theory.filename)+'_'+pointlabel+'.out') if keepmofiles == True: shutil.copyfile(theory.filename+'.gbw', 'surface_mofiles/'+str(theory.filename)+'_'+pointlabel+'.gbw') - surfacedictionary[(RCvalue1)] = energy + surfacedictionary[(RCvalue1)] = float(energy) #Writing dictionary to file #write_surfacedict_to_file(surfacedictionary,resultfile, dimension=1) print("") @@ -1032,3 +1122,4 @@ def write_surfacedict_to_file(surfacedict,file="surface_results.txt",dimension=N y=d[0][1] e=d[1] f.write(str(x)+" "+str(y)+" "+str(e)+'\n') + diff --git a/ash/modules/module_workflows.py b/ash/modules/module_workflows.py index 9474e4bc1..94266eb40 100644 --- a/ash/modules/module_workflows.py +++ b/ash/modules/module_workflows.py @@ -202,7 +202,8 @@ def thermochemprotocol_single(fragment=None, Opt_theory=None, SP_theory=None, nu print("-------------------------------------------------------------------------") print("THERMOCHEM PROTOCOL-single: Step 3. High-level single-point calculation") print("-------------------------------------------------------------------------") - + if SP_theory is None: + SP_theory=Opt_theory result_step3 = ash.Singlepoint(fragment=fragment, theory=SP_theory, charge=charge, mult=mult) FinalE = result_step3.energy #Get energy components diff --git a/ash/settings_ash.py b/ash/settings_ash.py index 3363c8b58..c1bfb3289 100644 --- a/ash/settings_ash.py +++ b/ash/settings_ash.py @@ -73,12 +73,27 @@ def try_read_setting(stringvalue, datatype): #NOTE: Warning. If user added quotation marks around string then things go awry. Look into # Keywords to look up in ash_user_settings.ini try_read_setting("orcadir", "string") +try_read_setting("xtbdir", "string") +try_read_setting("gxtbdir", "string") +try_read_setting("cp2kdir", "string") +try_read_setting("dracodir", "string") try_read_setting("mrccdir", "string") try_read_setting("daltondir", "string") -try_read_setting("xtbdir", "string") try_read_setting("psi4dir", "string") try_read_setting("cfourdir", "string") try_read_setting("crestdir", "string") +try_read_setting("gaussiandir", "string") +try_read_setting("mndodir", "string") +try_read_setting("multiwfndir", "string") +try_read_setting("nwchemdir", "string") +try_read_setting("pymbedir", "string") +try_read_setting("quickdir", "string") +try_read_setting("terachemdir", "string") +try_read_setting("turbomoledir", "string") +try_read_setting("demondir", "string") +try_read_setting("dicedir", "string") +try_read_setting("packmoldir", "string") + try_read_setting("connectivity_code", "string") try_read_setting("nonbondedMM_code", "string") try_read_setting("scale", "float") diff --git a/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py b/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py new file mode 100644 index 000000000..f6317f7a0 --- /dev/null +++ b/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py @@ -0,0 +1,87 @@ +from ash import * +from ash.functions.functions_elstructure import boltzmann_populations +from ash.interfaces.interface_ORCA import tddftgrab, tddftintens_grab + +numcores=10 + +######### +#System +######### +charge=0; mult=1 +frag = Fragment(databasefile="h2o.xyz", charge=charge,mult=mult) +temperature=298.15 +num_wigner_samples=10 + +############# +# Theories +############# +ll_theory = xTBTheory(xtbmethod="GFN2") +hl_theory = ORCATheory(orcasimpleinput="! CAM-B3LYP 6-311++G(d,p) CPCM(DMSO) tightscf", numcores=numcores) +#hl_theory = ORCATheory(orcasimpleinput="! hf-3c") +# Spectroscopy theory +blocks="""%tddft +nroots 10 +tda false +end +""" +tddft_theory = ORCATheory(orcasimpleinput="! CAM-B3LYP 6-311++G(d,p) CPCM(DMSO) tightscf",orcablocks=blocks, numcores=numcores) + +############################# +# 1. Conformational sampling +############################# +#new_call_crest(fragment=frag, theory=ll_theory, runtype="imtd-gc", numcores=numcores) +call_crest(fragment=frag, xtbmethod='GFN2-xTB', charge=charge, mult=mult, energywindow=6, numcores=numcores) + +#Get xtB conformers as fragments +frags = get_molecules_from_trajectory("crest_conformers.xyz") +# Set charge/mult for each conformer +for frag in frags: frag.charge = charge; frag.mult=mult + +########################################## +# 2. Opt+Freq (for Boltzmann populations) +########################################## +G_frags=[] +# Loop over frag and do Opt+Freq +for frag in frags: + FinalE, componentsdict, thermochem = thermochemprotocol_single(fragment=frag, Opt_theory=hl_theory, numcores=numcores, memory=5000, + analyticHessian=True, temp=temperature, pressure=1.0) + G=FinalE+thermochem["Gcorr"] + G_frags.append(G) +boltzmann_weights = boltzmann_populations(G_frags, temperature=temperature) +print("boltzmann_weights:", boltzmann_weights) + +########################################## +# 3. Wigner distributed TDDFT +########################################## +all_trans_energies=[] +all_trans_intensities=[] +# Loop over conformer fragments +for i,frag in enumerate(frags): + # Wigner distributions + wigner_frags = wigner_distribution(fragment=frag, hessian=frag.hessian, temperature=temperature, num_samples=num_wigner_samples) + # TDDFT on each Wigner fragment + conf_trans_energies=[] + conf_trans_intensities=[] + for wfrag in wigner_frags: + Singlepoint(theory=tddft_theory, fragment=wfrag) + # Get transition energies and intensities + transition_energies = tddftgrab(f"{tddft_theory.filename}.out") + transition_intensitites = tddftintens_grab(f"{tddft_theory.filename}.out") + # Weight intensities by Boltzmann weight for that conformer + transition_intensitites= np.array(transition_intensitites) * boltzmann_weights[i] + # Conformer-specific E and intens + conf_trans_energies += transition_energies + conf_trans_intensities += [float(transi) for transi in transition_intensitites] + # All + all_trans_energies += transition_energies + all_trans_intensities += [float(transi) for transi in transition_intensitites] + + # Plot spectrum (applies broadening to every stick) + plot_Spectrum(xvalues=conf_trans_energies, yvalues=conf_trans_intensities, plotname=f'TDDFT_conf{i}', + range=[0,10], unit='eV', broadening=0.075, points=10000, imageformat='png', dpi=200, matplotlib=True, + CSV=True, color='blue', plot_sticks=True) + +# Plot spectrum (applies broadening to every stick) +plot_Spectrum(xvalues=all_trans_energies, yvalues=all_trans_intensities, plotname='TDDFT_final', + range=[0,10], unit='eV', broadening=0.075, points=10000, imageformat='png', dpi=200, matplotlib=True, + CSV=True, color='blue', plot_sticks=True) \ No newline at end of file diff --git a/job.py b/job.py new file mode 100644 index 000000000..4d6526844 --- /dev/null +++ b/job.py @@ -0,0 +1,7 @@ +# New API test +#from .jobs.singlepoint import Singlepoint +#from .jobs.optimizer import Optimizer +#from .jobs.numfreq import NumFreq +#from .jobs.md import MolecularDynamics +# +#__all__ = ["Singlepoint", "Optimizer", "NumFreq", "MolecularDynamics"] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 532977ad7..ea046e213 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,8 @@ authors = [ {name = "R. Bjornsson", email = "ragnar.bjornsson@gmail.com" } ] readme = "README.md" -#Some problem with 3.12 (jan 2024) -requires-python = ">= 3.7, < 3.12" +#Some problem with 3.12 (jan 2024) q +requires-python = ">= 3.7" #geometric and numpy main dependencies dependencies = [ 'geometric >=1.0.1', @@ -20,7 +20,7 @@ requires = ["setuptools >=61.0.0"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ['ash*', 'ash.external', 'ash.functions','ash.interfaces','ash.knarr','ash.modules','ash.tests'] - +where = ["."] +include = ["ash*"] [tool.setuptools.package-data] -"*" = ["*.*"] # +"ash" = ["**/*"] diff --git a/scripts/subash.sh b/scripts/subash.sh index 7bca94f70..39aa1dd37 100644 --- a/scripts/subash.sh +++ b/scripts/subash.sh @@ -217,9 +217,10 @@ outputname="\$job.out" multiwalker=$multiwalker -#NUM_CORES -NUM_CORES=\$((SLURM_JOB_NUM_NODES*SLURM_CPUS_ON_NODE)) - +#NUM_CORES (note: SLURM_NTASKS is safer due to Slurm plugins) +#NUM_CORES=\$((SLURM_JOB_NUM_NODES*SLURM_CPUS_ON_NODE)) +NUM_CORES=\$SLURM_NTASKS +echo "NUM_CORES: \$NUM_CORES" #Setting MKL_NUM_THREADS, OMP_NUM_THREADS,OPENMM_CPU_THREADS to threads variable (should be 1 usually) #Note: Both OpenMM and pyscf threading behaved oddly unless we set this to 1 initially @@ -291,6 +292,8 @@ cp \$SLURM_SUBMIT_DIR/*.gbw \$tdir/ 2>/dev/null cp \$SLURM_SUBMIT_DIR/*.molden \$tdir/ 2>/dev/null cp \$SLURM_SUBMIT_DIR/*nat \$tdir/ 2>/dev/null cp \$SLURM_SUBMIT_DIR/*.chk \$tdir/ 2>/dev/null +cp \$SLURM_SUBMIT_DIR/*.dcd \$tdir/ 2>/dev/null +cp \$SLURM_SUBMIT_DIR/*.model \$tdir/ 2>/dev/null cp \$SLURM_SUBMIT_DIR/*.xtl \$tdir/ 2>/dev/null cp \$SLURM_SUBMIT_DIR/*.ff \$tdir/ 2>/dev/null cp \$SLURM_SUBMIT_DIR/*.ygg \$tdir/ 2>/dev/null @@ -359,6 +362,11 @@ then echo "Copying files to dir: walkersim\$i" >> \$SLURM_SUBMIT_DIR/\$outputname cp * walkersim\$i/ cd walkersim\$i + # Checking if multiple walker feature by plumed is also used in the script (Replace 'XYZ' by walker number in the input file) + if grep -q "WALKERS_N" "$file"; then + echo "Multiple walker feature of PLumed also used" + sed -i "s/XYZ/\$i/g" $file + fi echo "Entering dir: walkersim\$i" >> \$SLURM_SUBMIT_DIR/\$outputname echo "Process launched : \$i" >> \$SLURM_SUBMIT_DIR/\$outputname sleep 2