From d698841c03551ade37f4c762e8989524adcea3be Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 28 Oct 2025 22:05:56 +0100 Subject: [PATCH 001/134] some ML updates: create_ML_training_data now supporting xyz_files, active-learning test --- ash/modules/module_machine_learning.py | 91 +++++++++++++++++++++----- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index 492e80708..19095b9d6 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -15,14 +15,14 @@ # 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): 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 +37,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) @@ -113,6 +114,25 @@ 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.") + 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.") @@ -519,10 +539,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): @@ -592,30 +610,62 @@ def move_chosen_files(chosen,dirname): 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,6 +675,7 @@ 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: random.seed(seed) @@ -635,11 +686,16 @@ 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: @@ -647,3 +703,4 @@ def move_chosen_files(chosen,dirname): else: print("Active learning loop converged") print("Final set of configurations are found in directory: base") + move_chosen_files(base_cfgs,"base") From eb8874f415ddb2daa0eb9e2ef3d9a902e89775af Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 28 Oct 2025 22:32:50 +0100 Subject: [PATCH 002/134] macetheory: train method device defaults to None, will use init attribute if None --- ash/interfaces/interface_mace.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 349993039..45aba8947 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -50,7 +50,7 @@ def cleanup(self): def set_numcores(self,numcores): self.numcores=numcores - def train(self, config_file="config.yml", name="model",model="MACE", device='cpu', + def train(self, config_file="config.yml", name="model",model="MACE", 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, @@ -64,6 +64,10 @@ def train(self, config_file="config.yml", name="model",model="MACE", device='cpu self.train_file=train_file self.valid_fraction=valid_fraction + if device is None: + print("Warning: device not passed to train. Using object's device attribute:", self.device) + device=self.device + print("Training activated") print("Training parameters:") print("config_file", config_file) @@ -329,7 +333,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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) From 1146e66d8fc8df6b572841efed7026f118887051 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 29 Oct 2025 09:12:31 +0100 Subject: [PATCH 003/134] active learning: seed --- ash/modules/module_machine_learning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index 19095b9d6..a689354d1 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -606,7 +606,7 @@ 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) + random.seed(seed) base_cfgs = random.sample(xyzfiles, init_base_cfgs) # Move chosen base configs to base From 34e1ba3ad6a278ccc69a412b3b9314ab9b725006 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 29 Oct 2025 09:42:34 +0100 Subject: [PATCH 004/134] active learning: print seed --- ash/modules/module_machine_learning.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index a689354d1..db0ed222b 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -606,6 +606,7 @@ 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: + print("Using random seed:", seed) random.seed(seed) base_cfgs = random.sample(xyzfiles, init_base_cfgs) @@ -678,6 +679,7 @@ def move_chosen_files(chosen,dirname): # 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) From 26dffad17c7c0888932c6aea060bb9379165c713 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 29 Oct 2025 14:45:42 +0100 Subject: [PATCH 005/134] fixes --- ash/modules/module_machine_learning.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index db0ed222b..501555e0d 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -700,7 +700,9 @@ def move_chosen_files(chosen,dirname): 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") From a9feb3bb39763355ac7e4a1160f292ed27500b8d Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 29 Oct 2025 19:11:43 +0100 Subject: [PATCH 006/134] macetheory: seed --- ash/interfaces/interface_mace.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 45aba8947..02a779d42 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -371,7 +371,7 @@ def write_mace_config(config_file="config.yml", name="model",model="MACE", devic 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"): @@ -394,6 +394,7 @@ def write_mace_config(config_file="config.yml", name="model",model="MACE", devic energy_weight=energy_weight, forces_weight=forces_weight, device= device, +seed= seed, batch_size= batch_size, max_num_epochs= max_num_epochs, swa= swa) From 0d58d70f4ae44022915f99aadf69130d78767606 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 30 Oct 2025 12:24:58 +0100 Subject: [PATCH 007/134] - boltzmann_populations function - get_crest_conformers fix - split_multimolxyzfile title grab - matplotlib: removed deprecated features - thermochemprotocol_single: fix for no SP theory - Added crest_Wigner_TDDFT workflow --- ash/functions/functions_elstructure.py | 8 ++ ash/interfaces/interface_crest.py | 4 +- ash/modules/module_PES_rewrite.py | 6 +- ash/modules/module_coords.py | 2 +- ash/modules/module_plotting.py | 2 +- ash/modules/module_workflows.py | 3 +- .../crest_plus_wigner_plus_TDDFT.py | 86 +++++++++++++++++++ 7 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py diff --git a/ash/functions/functions_elstructure.py b/ash/functions/functions_elstructure.py index 54048b850..1afdc0451 100644 --- a/ash/functions/functions_elstructure.py +++ b/ash/functions/functions_elstructure.py @@ -2595,3 +2595,11 @@ 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): + beta = 1/(ash.constants.R_gasconst_kcalK*temperature) + + rel_energies=np.array([en-min(energies) for en in energies])*ash.constants.hartokcal + boltzmann_factors = np.exp(-1*rel_energies * beta) + populations = boltzmann_factors / np.sum(boltzmann_factors) + return populations \ No newline at end of file diff --git a/ash/interfaces/interface_crest.py b/ash/interfaces/interface_crest.py index 6bc3964f0..acac0faee 100644 --- a/ash/interfaces/interface_crest.py +++ b/ash/interfaces/interface_crest.py @@ -248,7 +248,7 @@ def call_crest_entropy(fragment=None, crestdir=None, charge=None, mult=None, num #Grabbing crest conformers. Goes inside rest-calc dir and finds file called crest_conformers.xyz #Creating ASH fragments for each conformer -def get_crest_conformers(crest_calcdir='crest-calc',conf_file="crest_conformers.xyz", charge=None, mult=None): +def get_crest_conformers(S='crest-calc',conf_file="crest_conformers.xyz", charge=None, mult=None): print("") print("Now finding Crest conformers and creating ASH fragments...") os.chdir(crest_calcdir) @@ -259,7 +259,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/modules/module_PES_rewrite.py b/ash/modules/module_PES_rewrite.py index f7412c357..5e319ec54 100644 --- a/ash/modules/module_PES_rewrite.py +++ b/ash/modules/module_PES_rewrite.py @@ -4188,17 +4188,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_coords.py b/ash/modules/module_coords.py index 84fcea8d8..279a28adf 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -1936,7 +1936,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 diff --git a/ash/modules/module_plotting.py b/ash/modules/module_plotting.py index 92b30d9af..54d42ff44 100644 --- a/ash/modules/module_plotting.py +++ b/ash/modules/module_plotting.py @@ -578,7 +578,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') ################################# 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/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..6f37c0b4f --- /dev/null +++ b/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py @@ -0,0 +1,86 @@ +from ash import * +from ash.functions.functions_elstructure import boltzmann_populations +from ash.interfaces.interface_ORCA import tddftgrab, tddftintens_grab + +numcores=1 + +######### +#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") +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) + +############################# +# 1. Conformational sampling +############################# +new_call_crest(fragment=frag, theory=ll_theory, runtype="imtd-gc", numcores=1) + +#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 From d6a5d6cb47b17b88127266729ef992c6e9a1945b Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 30 Oct 2025 13:51:46 +0100 Subject: [PATCH 008/134] fixes --- ash/interfaces/interface_crest.py | 2 +- .../ensemble_averaging/crest_plus_wigner_plus_TDDFT.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ash/interfaces/interface_crest.py b/ash/interfaces/interface_crest.py index acac0faee..b0b3b72a6 100644 --- a/ash/interfaces/interface_crest.py +++ b/ash/interfaces/interface_crest.py @@ -248,7 +248,7 @@ def call_crest_entropy(fragment=None, crestdir=None, charge=None, mult=None, num #Grabbing crest conformers. Goes inside rest-calc dir and finds file called crest_conformers.xyz #Creating ASH fragments for each conformer -def get_crest_conformers(S='crest-calc',conf_file="crest_conformers.xyz", charge=None, mult=None): +def get_crest_conformers(crest_calcdir='crest-calc',conf_file="crest_conformers.xyz", charge=None, mult=None): print("") print("Now finding Crest conformers and creating ASH fragments...") os.chdir(crest_calcdir) diff --git a/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py b/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py index 6f37c0b4f..8a5397b33 100644 --- a/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py +++ b/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py @@ -2,7 +2,7 @@ from ash.functions.functions_elstructure import boltzmann_populations from ash.interfaces.interface_ORCA import tddftgrab, tddftintens_grab -numcores=1 +numcores=10 ######### #System @@ -16,20 +16,20 @@ # Theories ############# ll_theory = xTBTheory(xtbmethod="GFN2") -#hl_theory = ORCATheory(orcasimpleinput="! CAM-B3LYP 6-311++G(d,p) CPCM(DMSO) tightscf") -hl_theory = ORCATheory(orcasimpleinput="! hf-3c") +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) +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=1) +new_call_crest(fragment=frag, theory=ll_theory, runtype="imtd-gc", numcores=numcores) #Get xtB conformers as fragments frags = get_molecules_from_trajectory("crest_conformers.xyz") From 0cf6894170da43e9f89f125cb23480c9d883ad0a Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 3 Nov 2025 15:46:22 +0000 Subject: [PATCH 009/134] - boltzmann_populations: tweaks - xtBTheory: grab_BOs option and grab_bondorder_matrix function - tweaks to crest_wigner script --- ash/functions/functions_elstructure.py | 15 +++++++----- ash/interfaces/interface_xtb.py | 23 +++++++++++++++++-- .../crest_plus_wigner_plus_TDDFT.py | 3 ++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/ash/functions/functions_elstructure.py b/ash/functions/functions_elstructure.py index 1afdc0451..6f14aaec0 100644 --- a/ash/functions/functions_elstructure.py +++ b/ash/functions/functions_elstructure.py @@ -2597,9 +2597,12 @@ def density_sensitivity_metric(fragment=None, functional="B3LYP", basis="def2-TZ return metrics_dict def boltzmann_populations(energies, temperature=298.15): - beta = 1/(ash.constants.R_gasconst_kcalK*temperature) - - rel_energies=np.array([en-min(energies) for en in energies])*ash.constants.hartokcal - boltzmann_factors = np.exp(-1*rel_energies * beta) - populations = boltzmann_factors / np.sum(boltzmann_factors) - return populations \ No newline at end of file + 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/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 01ad17d22..426a618ef 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -75,7 +75,8 @@ 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, extraflag=None, grab_charges=False, + grab_BOs=False): self.theorynamelabel="xTB" self.theorytype="QM" @@ -96,8 +97,12 @@ 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 @@ -461,6 +466,10 @@ 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 if Grad is True: @@ -1023,3 +1032,13 @@ 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 \ No newline at end of file diff --git a/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py b/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py index 8a5397b33..f6317f7a0 100644 --- a/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py +++ b/examples/workflows/ensemble_averaging/crest_plus_wigner_plus_TDDFT.py @@ -29,7 +29,8 @@ ############################# # 1. Conformational sampling ############################# -new_call_crest(fragment=frag, theory=ll_theory, runtype="imtd-gc", numcores=numcores) +#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") From d05f3bd82fb302d4716e6249148793e6789e178b Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 13 Nov 2025 13:54:55 +0100 Subject: [PATCH 010/134] - tbliteTheory: first version (no PCs yet) - first implementation of interface to Sella, not working yet --- ash/__init__.py | 2 +- ash/interfaces/interface_sella.py | 116 +++++++++++++++++++++++++++ ash/interfaces/interface_xtb.py | 128 +++++++++++++++++++++++++++++- 3 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 ash/interfaces/interface_sella.py diff --git a/ash/__init__.py b/ash/__init__.py index c9d8bf5ac..c994f4357 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -113,7 +113,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 diff --git a/ash/interfaces/interface_sella.py b/ash/interfaces/interface_sella.py new file mode 100644 index 000000000..da99d556a --- /dev/null +++ b/ash/interfaces/interface_sella.py @@ -0,0 +1,116 @@ +import numpy as np +import copy +import shutil +import time +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 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 + +def SellaOptimizer(theory=None, fragment=None, charge=None, mult=None, printlevel=2, NumGrad=False): + """ + Wrapper function around SellaptimizerClass + """ + 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=SellaptimizerClass(charge=charge, mult=mult) + + # 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. Also constraints + result = optimizer.run(theory=theory, fragment=fragment, charge=charge, mult=mult, + printlevel=printlevel) + if printlevel >= 1: + print_time_rel(timeA, modulename='Sella', moduleindex=1) + + return result + +# Class for optimization. +class SellaptimizerClass: + def __init__(self,theory=None, charge=None, mult=None, printlevel=2): + + self.printlevel=printlevel + print_line_with_mainheader("SellaOptimizer initialization") + print_if_level("Creating optimizer object", self.printlevel,2) + + def run(self, theory=None, fragment=None, charge=None, mult=None,printlevel=2): + + from sella import Sella + import ase + + # Creating ASE object + atoms = ase.atoms.Atoms(fragment.elems,positions=fragment.coords) + # + print("Creating ASH-ASE calculator") + atoms.calc = ASHcalc(fragment=fragment, theory=theory, charge=charge, mult=mult) + + # Set up a Sella Dynamics object + dyn = Sella( + atoms, + trajectory='sella.traj') + + dyn.run(1e-3, 1) + + +class ASHcalc(): + def __init__(self, fragment=None, theory=None, charge=None, mult=None): + self.gradientcalls=0 + self.fragment=fragment + self.theory=theory + self.results={} + self.name='ash' + self.parameters={} + self.atoms=None + self.forces=[] + self.charge=charge + self.mult=mult + def get_potential_energy(self, atomsobj): + return self.potenergy + def get_forces(self, atomsobj): + timeA = time.time() + print("Called ASHcalc get_forces") + # Check if coordinates have changed. If not, return old forces + if np.array_equal(atomsobj.get_positions(), self.fragment.coords) == True: + #coordinates have not changed + print("Coordinates unchanged.") + if len(self.forces)==0: + print("No forces available (1st step?). Will do calulation") + else: + print("Returning old forces") + print_time_rel(timeA, modulename="get_forces: returning old forces") + return self.forces + print("Will calculate new forces") + + self.gradientcalls+=1 + + # Copy ASE coords into ASH fragment + self.fragment.coords=copy.copy(atomsobj.positions) + print("atomsobj.positions:", atomsobj.positions) + # Calculate E+G + result = Singlepoint(theory=self.theory, fragment=self.fragment, Grad=True, charge=self.charge, mult=self.mult) + energy = result.energy + gradient = result.gradient + # Converting E and G from Eh and Eh/Bohr to ASE units: eV and eV/Angstrom + self.potenergy = energy * hartoeV + print("gradient:", gradient) + self.forces = -1 * gradient * hartoeV / bohr2ang + print("Forces:", self.forces) + # Adding forces to results also (sometimes called) + self.results['forces'] = self.forces + # print("potenergy:", self.potenergy) + + print("ASHcalc get_forces done") + print_time_rel(timeA, modulename="get_forces") + return self.forces \ No newline at end of file diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 426a618ef..e0b03b78b 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -1041,4 +1041,130 @@ def grab_bondorder_matrix(numatoms): 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 \ No newline at end of file + return BO + + + +#TODO: +# periodic +# PCs + +# 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): + super().__init__() + 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 + + # Parallelization + print("Setting number of cores for tblite to: OMP_NUM_THREADS=", numcores) + os.environ['OMP_NUM_THREADS'] = str(numcores) + + try: + import tblite + except Exception as e: + print("Problem importing xTtbliteB 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) + + + 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 + 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 + results = xtb.singlepoint() + + self.energy = results.get("energy") + #Charges + if self.grab_charges: + self.charges = results.get("charges") + #Bond orders + if self.grab_BOs: + self.BOs = results.get("bond-orders") + #DM + if self.grab_DM: + self.charges = results.get("density_matrix") + + #Gradient + if Grad: + self.gradient = results.get("gradient") + + + if Grad: + print_time_rel(module_init_time, modulename='tblite run', moduleindex=2) + return self.energy, self.gradient + else: + print_time_rel(module_init_time, modulename='tblite run', moduleindex=2) + return self.energy \ No newline at end of file From 3810ffabea01c48b971741d958fb61ddfda4e61d Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 13 Nov 2025 14:01:52 +0100 Subject: [PATCH 011/134] tblitetheory fixes --- ash/interfaces/interface_xtb.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index e0b03b78b..37b34080b 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -1054,6 +1054,8 @@ 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): super().__init__() + print_line_with_mainheader("tblite INTERFACE") + print("method:", method) self.theorytype="QM" self.analytic_hessian=False self.theorynamelabel = "tblite" @@ -1155,7 +1157,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el self.BOs = results.get("bond-orders") #DM if self.grab_DM: - self.charges = results.get("density_matrix") + self.DM = results.get("density-matrix") #Gradient if Grad: From 289390b67b89d81dd2de2098828ea91aaa02d408 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 20 Nov 2025 09:40:41 +0100 Subject: [PATCH 012/134] - Bugfix: Mechanical embedding (some QM-MM Coulomb interactions were deleted) - some small cleanup in module QM/MM and OpenMMTheory --- ash/interfaces/interface_OpenMM.py | 68 +++--------------------------- ash/modules/module_QMMM.py | 62 +++++++++++++-------------- 2 files changed, 36 insertions(+), 94 deletions(-) diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 1ba601286..bf9d13a31 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -1497,14 +1497,9 @@ def addexceptions(self, atomlist): 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) @@ -1847,70 +1842,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): diff --git a/ash/modules/module_QMMM.py b/ash/modules/module_QMMM.py index 873e2c2f3..7c071f603 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=[] @@ -284,17 +284,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 +308,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 +315,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 +346,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 @@ -701,7 +702,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 +731,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 +817,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 +905,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 +949,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)) From 775fa724fbfab69b609783cbf0be52752d4622b9 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 20 Nov 2025 12:52:10 +0100 Subject: [PATCH 013/134] tblitetheory: support for autostart --- ash/interfaces/interface_xtb.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 37b34080b..87d9903af 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -1052,7 +1052,7 @@ def grab_bondorder_matrix(numatoms): # 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): + maxiter=500, electronic_temp=9.5e-4, accuracy=1.0, grab_BOs=False, grab_charges=False, grab_DM=False, autostart=True): super().__init__() print_line_with_mainheader("tblite INTERFACE") print("method:", method) @@ -1077,6 +1077,13 @@ def __init__(self, method=None, printlevel=2, numcores=1, spinpol=False, solvati 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) @@ -1146,22 +1153,28 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el xtb.add("cpcm-solvation", self.solvent_eps) #Run - results = xtb.singlepoint() + if self.autostart is True and self.results is not None: + print("Auto-starting tblite calculation using previous results object") + print("Warning: if this leads to problems, set autostart=False in tbliteTheory") + self.results = xtb.singlepoint(self.results) + else: + print("Starting new tblite singlepoint calculation") + self.results = xtb.singlepoint() - self.energy = results.get("energy") + self.energy = self.results.get("energy") #Charges if self.grab_charges: - self.charges = results.get("charges") + self.charges = self.results.get("charges") #Bond orders if self.grab_BOs: - self.BOs = results.get("bond-orders") + self.BOs = self.results.get("bond-orders") #DM if self.grab_DM: - self.DM = results.get("density-matrix") + self.DM = self.results.get("density-matrix") #Gradient if Grad: - self.gradient = results.get("gradient") + self.gradient = self.results.get("gradient") if Grad: From 5ff97302cb490dca93a21539caa2c4b34d0f86bb Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 20 Nov 2025 13:37:21 +0100 Subject: [PATCH 014/134] ash results: write_to_disk, added try statement if problem --- ash/interfaces/interface_xtb.py | 2 ++ ash/modules/module_results.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 87d9903af..081283c25 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -1049,6 +1049,8 @@ def grab_bondorder_matrix(numatoms): # 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, diff --git a/ash/modules/module_results.py b/ash/modules/module_results.py index 26237d73b..01071baaf 100644 --- a/ash/modules/module_results.py +++ b/ash/modules/module_results.py @@ -119,7 +119,12 @@ def write_to_disk(self,filename="ASH.result"): print(f"{k} : {v}") #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("Error writing ASH_Results to disk:", e) + print("Skipping writing to disk") + return f.close() # Read ASH-Results data from disk From f28f69d96031363a5073d3fa75e8ac5ee33312b9 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 21 Nov 2025 05:56:37 +0100 Subject: [PATCH 015/134] Fairchem interface: updated to avoid imports and reloads, much faster --- ash/interfaces/interface_fairchem.py | 62 +++++++++++++++----------- ash/modules/module_machine_learning.py | 9 ++-- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index 324ff672d..0826bfe2b 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -1,5 +1,4 @@ import time -import numpy as np import os from ash.modules.module_coords import elemstonuccharges from ash.functions.functions_general import ashexit, BC,print_time_rel @@ -8,11 +7,11 @@ # 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 @@ -22,6 +21,7 @@ class FairchemTheory(): def __init__(self, model_name=None, model_file=None, task_name=None, device="cuda", seed=41, numcores=1): + module_init_time=time.time() # Early exits try: import fairchem @@ -51,10 +51,28 @@ def __init__(self, model_name=None, model_file=None, task_name=None, device="cud self.seed=seed self.numcores=numcores + # Counter for runcalls + self.runcalls=0 + if self.device.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.device) + 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) + self.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() + print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} init', moduleindex=2) + 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): @@ -68,39 +86,29 @@ 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) + if self.runcalls == 0: + print("First runcall. Creating atoms object") + import ase + 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 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 - - # Assigning calculator - atoms.calc = calc + 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 Grad: - forces = atoms.get_forces() + forces = self.atoms.get_forces() self.gradient = forces/-51.422067090480645 + self.runcalls+=1 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/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index 501555e0d..6409bd969 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -145,7 +145,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") @@ -193,8 +192,8 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No 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}/{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) @@ -236,6 +235,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: @@ -249,7 +250,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 From 41586779578738f0e3ad65a7f8b497f2b824911a Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 21 Nov 2025 06:04:57 +0100 Subject: [PATCH 016/134] fairchemtheory: some cleanup --- ash/interfaces/interface_fairchem.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index 0826bfe2b..7d758d3d6 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -19,7 +19,8 @@ #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, device="cuda", seed=41, numcores=1, + printlevel=2): module_init_time=time.time() # Early exits @@ -43,7 +44,7 @@ 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 self.model_name=model_name @@ -78,6 +79,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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: @@ -101,12 +104,15 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # 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 = self.atoms.get_forces() self.gradient = forces/-51.422067090480645 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: From 8e777891a68a9f57387cbb6918f9494058c73c0e Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 27 Nov 2025 20:53:31 +0100 Subject: [PATCH 017/134] - QM/MM : minor cleanup in linkatom_force_adv (avoiding numpy float) - MACETheory: adding training keywords(weights, epochs, valid-fraction) to init - Dynamics: ONIOMTheory is now properly supported for MD. Dev Note: creates special OpenMMTheory object for MD regardless of whether ONIOM-object contains an OpenMMTheory object. - ONIOMTheory: Now supporting OpenMMTheory as Theory-level. Creates special OpenMMTheory object for Region1. Only ONIOM-2 for now. Residue limitation, working on. - pyscftheory: platform logic fixed - FairChemTheory: cleanup --- ash/interfaces/interface_OpenMM.py | 50 ++++--- ash/interfaces/interface_fairchem.py | 3 + ash/interfaces/interface_geometric_new.py | 34 +++-- ash/interfaces/interface_mace.py | 21 ++- ash/interfaces/interface_pyscf.py | 2 +- ash/modules/module_QMMM.py | 13 +- ash/modules/module_coords.py | 24 +++ ash/modules/module_machine_learning.py | 2 +- ash/modules/module_oniom.py | 170 ++++++++++++++++------ 9 files changed, 235 insertions(+), 84 deletions(-) diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index bf9d13a31..1a1ade0db 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -483,10 +483,18 @@ 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 + from ash.modules.module_coords import define_dummy_topology + self.topology = define_dummy_topology(fragment.elems) + print("self.topology:", self.topology) + print("self.topology dict:", self.topology.__dict__) # Create dummy XML file xmlfile = write_xmlfile_nonbonded(filename="dummy.xml", resnames=["DUM"], atomnames_per_res=[atomnames_full], atomtypes_per_res=[fragment.elems], @@ -3207,9 +3215,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 @@ -3465,22 +3473,28 @@ 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,) #NOTE: might add more options here print("Turning on externalforce option.") self.openmm_externalforceobject = self.openmmobject.add_custom_external_force() @@ -4564,6 +4578,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) diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index 7d758d3d6..73cef6d72 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -74,6 +74,9 @@ def __init__(self, model_name=None, model_file=None, task_name=None, device="cud ashexit() print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} init', moduleindex=2) + def cleanup(): + pass + 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): diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index c1c53f8f7..7ac245617 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -9,7 +9,7 @@ #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.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_coords import check_charge_mult, fullindex_to_actindex 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 @@ -564,17 +564,17 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No print("Actual error message:", e) ashexit(code=9) - #Read geometry from XYZ-file into geomeTRIC Molecule object + # Read geometry from XYZ-file into geomeTRIC Molecule object 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 + # Defining args object, containing engine object 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) @@ -695,8 +695,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 @@ -772,7 +775,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.") @@ -868,7 +870,16 @@ 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=step_lines[-1].split()[1] + self.iteration_count=int(iteration) + self.EG_count += 1 + + + return {'energy': E, 'gradient': Grad_act.flatten()} else: self.full_current_coords=currcoords @@ -884,7 +895,12 @@ def calc(self,coords,tmp, read_data=None, copydir=None): 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 + # 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=step_lines[-1].split()[1] + self.iteration_count=int(iteration) + self.EG_count += 1 self.energy = E return {'energy': E, 'gradient': Grad.flatten()} diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 02a779d42..203923be1 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -1,6 +1,7 @@ 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 @@ -12,7 +13,8 @@ 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): + label="MACETheory", numcores=1, device="cpu", return_zero_gradient=False, + energy_weight=None, forces_weight=None, max_num_epochs=None, valid_fraction=None): # Early exits try: import mace @@ -37,6 +39,12 @@ def __init__(self, config_filename="config.yml", # Model attribute is None until we have loaded a model self.model=None + # Training parameters + self.energy_weight=energy_weight + self.forces_weight=forces_weight + self.max_num_epochs=max_num_epochs + self.valid_fraction=valid_fraction + self.model_file=model_file self.device=device.lower() @@ -60,6 +68,15 @@ def train(self, config_file="config.yml", name="model",model="MACE", device=None 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 @@ -131,7 +148,7 @@ def train(self, config_file="config.yml", name="model",model="MACE", device=None 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.") diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index eec326980..fe6e01be2 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2658,7 +2658,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() ############################## diff --git a/ash/modules/module_QMMM.py b/ash/modules/module_QMMM.py index 7c071f603..f2206f207 100644 --- a/ash/modules/module_QMMM.py +++ b/ash/modules/module_QMMM.py @@ -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: @@ -1822,14 +1823,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 279a28adf..65c9fe7c2 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -4318,3 +4318,27 @@ 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_machine_learning.py b/ash/modules/module_machine_learning.py index 6409bd969..6141c2b10 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -193,7 +193,7 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No print("Will now loop over XYZ-files") print("For a large dataset consider using parallel runmode") for n,file in enumerate(list_of_xyz_files): - print(f"\nNow running file ({n}/{len(list_of_xyz_files)}): {file}") + 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) diff --git a/ash/modules/module_oniom.py b/ash/modules/module_oniom.py index 1ecddd4d3..5f1016c41 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: @@ -470,7 +481,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 +624,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 +717,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 +736,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 +789,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: From 4fe6f63a929fda9935c7f4b73f21e2af705de889 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 27 Nov 2025 20:56:20 +0100 Subject: [PATCH 018/134] Follow up to last commit: geometric interface. Opt Iterations are counted differently now (was based E+G calls, which failed for numerical Hessian runs). Now reading geometric-logfile, silly but works. --- ash/interfaces/interface_geometric_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index 7ac245617..df98daeaa 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -598,7 +598,7 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No ################################### 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 From 5fa7f2b424a00ddebf88116b9a20a4a423e578b1 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 28 Nov 2025 15:11:05 +0100 Subject: [PATCH 019/134] fairchem theory: cleanup bugfix --- ash/interfaces/interface_fairchem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index 73cef6d72..3708130c2 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -74,7 +74,7 @@ def __init__(self, model_name=None, model_file=None, task_name=None, device="cud ashexit() print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} init', moduleindex=2) - def cleanup(): + def cleanup(self): pass def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_elems=None, mm_elems=None, From bce82871d3766af1a40c6ff3a0d89b0e1f24965d Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sun, 30 Nov 2025 13:09:08 +0100 Subject: [PATCH 020/134] - tblitetheory: printing energy - fairchem: creating new atoms object if molecule has changed --- ash/interfaces/interface_fairchem.py | 8 ++++++++ ash/interfaces/interface_xtb.py | 1 + 2 files changed, 9 insertions(+) diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index 3708130c2..b808f2b38 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -100,6 +100,14 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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") + import ase + 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 else: print("Updating coordinates in atoms object") self.atoms.set_positions(current_coords) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 081283c25..4fa0e3d33 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -1164,6 +1164,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el self.results = xtb.singlepoint() self.energy = self.results.get("energy") + print("Tblite energy:", self.energy) #Charges if self.grab_charges: self.charges = self.results.get("charges") From 8813826e0b9367c45586b26d73272fb3f4400b6a Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 1 Dec 2025 14:48:49 +0100 Subject: [PATCH 021/134] Knarr: geodesic interpolation and TSguess can now be used together --- ash/interfaces/interface_knarr.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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. From ef021b6728c6a77f9efb3fd8ba835b25161ddf15 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 2 Dec 2025 13:19:31 +0100 Subject: [PATCH 022/134] xtb interface: Fix for GFN-FF --- ash/interfaces/interface_xtb.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 4fa0e3d33..04fdcec1d 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -758,6 +758,9 @@ def run_xtb_SP(xtbdir, xtbmethod, coordfile, charge, mult, Grad=False, Opt=False xtbflag = 1 elif 'GFN0' in xtbmethod.upper(): xtbflag = 0 + elif 'GFNFF' in xtbmethod.upper(): + print("GFN-FF has been chosen") + #exit() else: print(f"Unknown xtbmethod chosen ({xtbmethod}). Exiting...") ashexit() @@ -777,8 +780,13 @@ 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] + 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)) From 1559f8874ad526fc1251158704972c8feb05653f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 18 Dec 2025 14:06:10 +0100 Subject: [PATCH 023/134] - fairchemtheory: warning for single-atom systems - get_boundary_atomsm get_linkatom_positions and get_MMboundary (QMMM and ONIOM): now supporting multiple linkatoms --- ash/interfaces/interface_fairchem.py | 2 + ash/modules/module_QMMM.py | 19 ++++-- ash/modules/module_coords.py | 98 +++++++++++++++------------- 3 files changed, 67 insertions(+), 52 deletions(-) diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index b808f2b38..868aca971 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -66,6 +66,8 @@ def __init__(self, model_name=None, model_file=None, task_name=None, device="cud 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.device, seed=self.seed) diff --git a/ash/modules/module_QMMM.py b/ash/modules/module_QMMM.py index f2206f207..7df8aed21 100644 --- a/ash/modules/module_QMMM.py +++ b/ash/modules/module_QMMM.py @@ -363,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()) @@ -1293,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) diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index 65c9fe7c2..7ba50a3fe 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -3185,16 +3185,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 +3212,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 +3236,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 +3248,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 From 90dd02eb4c08ea5d70bb2cf83bf8083f37b5bac5 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 18 Dec 2025 15:24:26 +0100 Subject: [PATCH 024/134] packaging: newer python versions allowed --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 532977ad7..4e9d7b2aa 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', From c79e91a3299da9f52b1638baf4ff8d58bab948c1 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 19 Jan 2026 15:01:00 +0100 Subject: [PATCH 025/134] Frozen atoms in OpenMMTheory: Now exceptions are added automatically at the same time as particle masses are set to zero. Fixes the old problem of behaviour of frozen atoms in NPT simulations --- ash/interfaces/interface_OpenMM.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 1a1ade0db..2f89e77b3 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -1447,6 +1447,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] From d3ef1b8561e69cb823f28c61d8739f175a4a482b Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 19 Jan 2026 16:08:25 +0100 Subject: [PATCH 026/134] addexceptions: fix, unnecessary looping --- ash/interfaces/interface_OpenMM.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 2f89e77b3..03645945b 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -1497,16 +1497,10 @@ 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") numexceptions = 0 @@ -1517,8 +1511,8 @@ def addexceptions(self, atomlist): 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) From c1555c72b305591d1a6c8badc86e7baa3989da53 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 19 Jan 2026 20:44:01 +0100 Subject: [PATCH 027/134] addexceptions: further fix for looping of exclusions --- ash/interfaces/interface_OpenMM.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 03645945b..9661a0171 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -1498,7 +1498,6 @@ 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 def addexceptions(self, atomlist): - print("atomlist:",atomlist) import openmm timeA = time.time() print("Add exceptions/exclusions. Removing i-j interactions for list:", len(atomlist), "atoms") @@ -1534,8 +1533,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) From 66a23672a18d3b4895666bac3cb39f9fe71d7d78 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 09:39:45 +0100 Subject: [PATCH 028/134] plumed_MTD_analyze: fixes, small printing bugfix for OpenMM run with barostat --- ash/functions/functions_molcrys.py | 2 +- ash/interfaces/interface_OpenMM.py | 2 +- ash/interfaces/interface_plumed.py | 16 ++++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) 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/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 9661a0171..0f16152e7 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -3698,7 +3698,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: diff --git a/ash/interfaces/interface_plumed.py b/ash/interfaces/interface_plumed.py index 36be93ba7..5b2300a49 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",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",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'] From ad4d87da4665b535f8cf4a2e4fe6766dec8dddce Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 09:40:36 +0100 Subject: [PATCH 029/134] fix --- ash/interfaces/interface_plumed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ash/interfaces/interface_plumed.py b/ash/interfaces/interface_plumed.py index 5b2300a49..649855647 100644 --- a/ash/interfaces/interface_plumed.py +++ b/ash/interfaces/interface_plumed.py @@ -266,7 +266,7 @@ 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=path_to_plumed,hillsfile="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: @@ -280,7 +280,7 @@ def plumed_MTD_analyze(path_to_plumed=None, Plot_To_Screen=False, CV1_type=None, 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=path_to_plumed,hillsfile="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: From 5bea32d90c7020a853672290015e79c8e26aa78f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 09:48:27 +0100 Subject: [PATCH 030/134] debugging --- ash/interfaces/interface_plumed.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ash/interfaces/interface_plumed.py b/ash/interfaces/interface_plumed.py index 649855647..3fa7e71ae 100644 --- a/ash/interfaces/interface_plumed.py +++ b/ash/interfaces/interface_plumed.py @@ -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])) From 73709464b5d7741859ec615dd89eb7415cc5a5a3 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 09:58:25 +0100 Subject: [PATCH 031/134] plumed plot: limits --- ash/interfaces/interface_plumed.py | 36 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/ash/interfaces/interface_plumed.py b/ash/interfaces/interface_plumed.py index 3fa7e71ae..1d4bae05c 100644 --- a/ash/interfaces/interface_plumed.py +++ b/ash/interfaces/interface_plumed.py @@ -399,7 +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) + #print("line:", line) if number_of_fields >= 4: if len(line) > 10: time.append(float(line.split()[0])) @@ -526,7 +526,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') @@ -563,22 +563,22 @@ 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) + 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) From 834282ce0126088b7d911a04bbedcf463d5c06df Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 09:59:16 +0100 Subject: [PATCH 032/134] debugging --- ash/interfaces/interface_plumed.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ash/interfaces/interface_plumed.py b/ash/interfaces/interface_plumed.py index 1d4bae05c..c0746db7a 100644 --- a/ash/interfaces/interface_plumed.py +++ b/ash/interfaces/interface_plumed.py @@ -575,6 +575,10 @@ def flatten(list): 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) From 13ce9c3245317623c075c5cdcd3219e55ed1c7ae Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 15:57:10 +0100 Subject: [PATCH 033/134] pyscf: gpu4pyscf, QM/MM non-PBC and PBC, not tested --- ash/interfaces/interface_pyscf.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index fe6e01be2..1478e8bf4 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2282,14 +2282,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: From 52d40541bcace4a0109a7e8810462367abed16ea Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 16:22:52 +0100 Subject: [PATCH 034/134] gpu4pyscf: gradients change --- ash/interfaces/interface_pyscf.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 1478e8bf4..9d9703284 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2897,7 +2897,11 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, 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() self.gradient = g.kernel() print_time_rel(checkpoint, modulename='pyscf_gradient', moduleindex=2) From 17bff11108683ff19726ba588b63af1f8abfa67a Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 16:30:44 +0100 Subject: [PATCH 035/134] pyscf_pointcharge_gradient: some gpu business --- ash/interfaces/interface_pyscf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 9d9703284..5e9d4c37b 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2980,11 +2980,12 @@ 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() + print("dm type:", type(dm)) #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) + dmf=np.array(dm.get()) #GPU if GPU is True: From 74ecb618381f70ae170946346f7510f1a0e5b99f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 16:34:02 +0100 Subject: [PATCH 036/134] pyscf_pointcharge_gradient: mods --- ash/interfaces/interface_pyscf.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 5e9d4c37b..816f611e3 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2979,13 +2979,8 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, #Based on https://github.com/pyscf/pyscf/blob/master/examples/qmmm/30-force_on_mm_particles.py #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): + import gpu4pyscf time0=time.time() - print("dm type:", type(dm)) - #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.get()) #GPU if GPU is True: @@ -3001,6 +2996,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 From c1c9cc356a585c479643236ef11cf92c71589826 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 16:35:39 +0100 Subject: [PATCH 037/134] fix --- ash/interfaces/interface_pyscf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 816f611e3..1f4161399 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2984,6 +2984,7 @@ def pyscf_pointcharge_gradient(mol,mm_coords,mm_charges,dm, GPU=False): #GPU if GPU is True: + dmf=dm import cupy einsumfunc = cupy.einsum linalg_norm_func=cupy.linalg.norm From 1dc98cf4d9d3198a93760a57edb3ff8394da1dea Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 16:38:08 +0100 Subject: [PATCH 038/134] more debugging --- ash/interfaces/interface_pyscf.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 1f4161399..6115268e9 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2984,7 +2984,10 @@ def pyscf_pointcharge_gradient(mol,mm_coords,mm_charges,dm, GPU=False): #GPU if GPU is True: - dmf=dm + if dm.shape[0] == 2: + dmf = np.array(dm[0] + dm[1]) #unrestricted + else: + dmf=dm import cupy einsumfunc = cupy.einsum linalg_norm_func=cupy.linalg.norm From 709c25ab22dbcf7c6e8bdd02dfe5b5e4abaf8a42 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 16:38:50 +0100 Subject: [PATCH 039/134] fix --- ash/interfaces/interface_pyscf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 6115268e9..bd2194bb2 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2985,7 +2985,7 @@ def pyscf_pointcharge_gradient(mol,mm_coords,mm_charges,dm, GPU=False): #GPU if GPU is True: if dm.shape[0] == 2: - dmf = np.array(dm[0] + dm[1]) #unrestricted + dmf = cupy.asarray(dm[0] + dm[1]) #unrestricted else: dmf=dm import cupy From 77fa1efde44ab9914f82b0653f1d53275486ba6c Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 16:39:15 +0100 Subject: [PATCH 040/134] fix --- ash/interfaces/interface_pyscf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index bd2194bb2..318e78c33 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2984,11 +2984,12 @@ def pyscf_pointcharge_gradient(mol,mm_coords,mm_charges,dm, GPU=False): #GPU if GPU is True: + import cupy if dm.shape[0] == 2: dmf = cupy.asarray(dm[0] + dm[1]) #unrestricted else: dmf=dm - import cupy + einsumfunc = cupy.einsum linalg_norm_func=cupy.linalg.norm From e83d7b045a405adcc912b0af9401abd7d7c45083 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 20:13:02 +0100 Subject: [PATCH 041/134] pyscf_pointcharge_gradient: bugfix --- ash/interfaces/interface_pyscf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 318e78c33..e92c0a1cf 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -3001,9 +3001,9 @@ 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 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: From ad2679be9f6c72d60d0aa8c3bc135498f05e8ebc Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 21 Jan 2026 20:37:03 +0100 Subject: [PATCH 042/134] fix --- ash/interfaces/interface_plumed.py | 1 + ash/interfaces/interface_pyscf.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/ash/interfaces/interface_plumed.py b/ash/interfaces/interface_plumed.py index c0746db7a..a7bc3049f 100644 --- a/ash/interfaces/interface_plumed.py +++ b/ash/interfaces/interface_plumed.py @@ -457,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) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index e92c0a1cf..7271a5674 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2979,7 +2979,6 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, #Based on https://github.com/pyscf/pyscf/blob/master/examples/qmmm/30-force_on_mm_particles.py #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): - import gpu4pyscf time0=time.time() #GPU From 9e568dcbc8d327f90e471234dcbcaac8c332a019 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 23 Jan 2026 14:31:15 +0100 Subject: [PATCH 043/134] pyscf QM/MM: calling new routine for PC gradient --- ash/interfaces/interface_pyscf.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 7271a5674..7f0970883 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2902,7 +2902,9 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, g = self.mf.Gradients() else: g = self.mf.nuc_grad_method() + print("g object:", g) self.gradient = g.kernel() + print("Debug: QM self.gradient:", self.gradient) print_time_rel(checkpoint, modulename='pyscf_gradient', moduleindex=2) #Applying dispersion gradient last @@ -2916,14 +2918,22 @@ 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 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) + new_way = True + if new_way: + print("Using new way pointcharge gradient") + g_mm_h1 = g.grad_hcore_mm(self.mf.make_rdm1()) + g_mm_nuc = g.grad_nuc_mm() + self.pcgrad = g_mm_h1 + g_mm_nuc + else: + #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") From 080b1cad5d8d8de842bdd84c910fd52955d17398 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 23 Jan 2026 15:05:01 +0100 Subject: [PATCH 044/134] pyscf pcgrad debugging --- ash/interfaces/interface_pyscf.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 7f0970883..36938e6b0 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2924,6 +2924,15 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, new_way = True if new_way: print("Using new way pointcharge gradient") + dm = self.mf.make_rdm1() + print("dm:", dm) + print("dm type:", type(dm)) + print("dm shape:", dm.shape) + print("dm ndim:", dm.ndim) + if dm.ndim == 2: + print("ndim 2") + else: + print("ndim diff") g_mm_h1 = g.grad_hcore_mm(self.mf.make_rdm1()) g_mm_nuc = g.grad_nuc_mm() self.pcgrad = g_mm_h1 + g_mm_nuc From b856719638654ef50e15868f07d495f0975a3e10 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 23 Jan 2026 15:06:39 +0100 Subject: [PATCH 045/134] more debugging --- ash/interfaces/interface_pyscf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 36938e6b0..d0c250d4e 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2931,6 +2931,11 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, print("dm ndim:", dm.ndim) if dm.ndim == 2: print("ndim 2") + elif dm.ndim ==3: + print("ndim 3") + dm = dm[0] + print("dm ndim:", dm.ndim) + else: print("ndim diff") g_mm_h1 = g.grad_hcore_mm(self.mf.make_rdm1()) From dd314feae76554e9d2ae7af1f6ee66ebe56348f1 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 23 Jan 2026 15:07:59 +0100 Subject: [PATCH 046/134] fix hopefully --- ash/interfaces/interface_pyscf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index d0c250d4e..bad0cd437 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2938,7 +2938,7 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, else: print("ndim diff") - g_mm_h1 = g.grad_hcore_mm(self.mf.make_rdm1()) + g_mm_h1 = g.grad_hcore_mm(dm) g_mm_nuc = g.grad_nuc_mm() self.pcgrad = g_mm_h1 + g_mm_nuc else: From 0eba4fa00741b0d0853f1691753951729ec8534f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 27 Jan 2026 12:52:32 +0100 Subject: [PATCH 047/134] crest interface: changed default runtype to be "imtd-gc" Added energy window option (default 6 kcal/mol) --- ash/interfaces/interface_crest.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ash/interfaces/interface_crest.py b/ash/interfaces/interface_crest.py index b0b3b72a6..d8e712741 100644 --- a/ash/interfaces/interface_crest.py +++ b/ash/interfaces/interface_crest.py @@ -10,7 +10,8 @@ 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", numcores=1, + charge=None, mult=None, energywindow=6.0): module_init_time=time.time() if fragment is None or theory is None: @@ -57,7 +58,8 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="ancopt", input = "struc.xyz" runtype="{runtype}" threads = {numcores} - +[cregen] +ewin = {energywindow} [calculation] elog="energies.log" @@ -71,6 +73,11 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="ancopt", 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 {energywindow} kcal/mol") + print("Now calling CREST like this: crest --input input.toml") process = sp.run([crestdir + '/crest', '--input', 'input.toml']) From 52bf40e12b67ac49079501620fb7d439ed7f7485 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 27 Jan 2026 14:00:42 +0100 Subject: [PATCH 048/134] create_ML_training_data: option to supply atom energies via keyword energies_atoms_dict --- ash/modules/module_machine_learning.py | 57 ++++++++++++++------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index 6141c2b10..8c1437d05 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -17,7 +17,8 @@ # 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, 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): print("-"*50) print("create_ML_training_data function") print("-"*50) @@ -285,31 +286,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) ########################################### From 19a696c35b4bfc9da00446444d447fdb84333179 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 2 Feb 2026 10:22:34 +0100 Subject: [PATCH 049/134] gxtb interface: charge and mult now done --- ash/interfaces/interface_xtb.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 04fdcec1d..21457d3ff 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -48,6 +48,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: From 1da8f05ca3e08050ca89d4cb554d63d484964d04 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 2 Feb 2026 10:30:28 +0100 Subject: [PATCH 050/134] gxtb: small fix --- ash/interfaces/interface_xtb.py | 3 ++- ash/modules/module_coords.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 21457d3ff..39c441b13 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -25,6 +25,7 @@ class gxTBTheory(Theory): def __init__(self, method=None, printlevel=2, numcores=1): super().__init__() self.theorynamelabel = "gxtb" + self.theorytype="QM" self.printlevel = printlevel # Check if gxtb in PATH @@ -412,7 +413,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() diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index 7ba50a3fe..42a8cb296 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -3781,10 +3781,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: From 763efc136db096ac4b8d5e46dfcc6a8ffa708f67 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 2 Feb 2026 14:14:47 +0100 Subject: [PATCH 051/134] pyscf: timings for pointcharge gradient etc. --- ash/interfaces/interface_pyscf.py | 38 ++++++++++++------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index bad0cd437..ba343e68c 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -365,23 +365,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) @@ -2921,27 +2921,19 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, current_MM_coords_bohr = current_MM_coords*ash.constants.ang2bohr checkpoint=time.time() - new_way = True - if new_way: - print("Using new way pointcharge gradient") + + if self.PC_gradient_code == "new": + print("Calculating pointcharge gradient (new way)") + checkpoint=time.time() dm = self.mf.make_rdm1() - print("dm:", dm) - print("dm type:", type(dm)) - print("dm shape:", dm.shape) - print("dm ndim:", dm.ndim) - if dm.ndim == 2: - print("ndim 2") - elif dm.ndim ==3: - print("ndim 3") + if dm.ndim ==3: dm = dm[0] - print("dm ndim:", dm.ndim) - - else: - print("ndim diff") 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() From 402f00ee4b9c9003bdd7af9943b1e807556a8dcf Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 12 Feb 2026 15:24:39 +0100 Subject: [PATCH 052/134] bugfix: basis-sets directory not copied over for pip ASH installation due to missing __init__.py files --- ash/databases/Benchmarking-sets/__init__.py | 0 .../MGMR121-B3LYP-D3-def2SVP/__init__.py | 0 ash/databases/Saddlepoint-test-sets/__init__.py | 0 ash/databases/__init__.py | 0 ash/databases/basis-sets/__init__.py | 0 ash/databases/basis-sets/cfour/__init__.py | 0 ash/databases/basis-sets/cp2k/__init__.py | 0 ash/databases/forcefields/__init__.py | 0 ash/databases/fragments/__init__.py | 0 ash/interfaces/interface_pyscf.py | 4 +--- ash/knarr/KNARRatom/utilities.py | 1 + 11 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 ash/databases/Benchmarking-sets/__init__.py create mode 100644 ash/databases/Saddlepoint-test-sets/MGMR121-B3LYP-D3-def2SVP/__init__.py create mode 100644 ash/databases/Saddlepoint-test-sets/__init__.py create mode 100644 ash/databases/__init__.py create mode 100644 ash/databases/basis-sets/__init__.py create mode 100644 ash/databases/basis-sets/cfour/__init__.py create mode 100644 ash/databases/basis-sets/cp2k/__init__.py create mode 100644 ash/databases/forcefields/__init__.py create mode 100644 ash/databases/fragments/__init__.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/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/fragments/__init__.py b/ash/databases/fragments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index ba343e68c..5bbb7dfff 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2521,7 +2521,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() @@ -2902,9 +2902,7 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, g = self.mf.Gradients() else: g = self.mf.nuc_grad_method() - print("g object:", g) self.gradient = g.kernel() - print("Debug: QM self.gradient:", self.gradient) print_time_rel(checkpoint, modulename='pyscf_gradient', moduleindex=2) #Applying dispersion gradient last diff --git a/ash/knarr/KNARRatom/utilities.py b/ash/knarr/KNARRatom/utilities.py index c67ebf87f..4986ebea0 100755 --- a/ash/knarr/KNARRatom/utilities.py +++ b/ash/knarr/KNARRatom/utilities.py @@ -274,6 +274,7 @@ def MakeEulerRotation(r, phi, theta, psi): def Convert1To3(ndim, rxyz): + print #Rb. py3 conversion. int instead of float rnew = np.zeros(shape=(int(ndim / 3), 3)) ind = 0 From 9ec6ba8eaaedda43ab4ecf0d0073123c3734f164 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 12 Feb 2026 15:29:34 +0100 Subject: [PATCH 053/134] fix --- ash/knarr/KNARRatom/utilities.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ash/knarr/KNARRatom/utilities.py b/ash/knarr/KNARRatom/utilities.py index 4986ebea0..c67ebf87f 100755 --- a/ash/knarr/KNARRatom/utilities.py +++ b/ash/knarr/KNARRatom/utilities.py @@ -274,7 +274,6 @@ def MakeEulerRotation(r, phi, theta, psi): def Convert1To3(ndim, rxyz): - print #Rb. py3 conversion. int instead of float rnew = np.zeros(shape=(int(ndim / 3), 3)) ind = 0 From 27b03355d50f6b7299c15cbf7db421824ea6e048 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 12 Feb 2026 15:40:52 +0100 Subject: [PATCH 054/134] Renamed basis-sets to be basis_sets to avoid package problems --- ash/databases/{basis-sets => basis_sets}/__init__.py | 0 ash/databases/{basis-sets => basis_sets}/cfour/__init__.py | 0 ash/databases/{basis-sets => basis_sets}/cfour/cc-pV5Z | 0 ash/databases/{basis-sets => basis_sets}/cfour/cc-pVDZ | 0 ash/databases/{basis-sets => basis_sets}/cfour/cc-pVQZ | 0 ash/databases/{basis-sets => basis_sets}/cfour/cc-pVTZ | 0 ash/databases/{basis-sets => basis_sets}/cfour/def2-SVP | 0 ash/databases/{basis-sets => basis_sets}/cfour/def2-TZVP | 0 ash/databases/{basis-sets => basis_sets}/cp2k/BASIS_MOLOPT | 0 ash/databases/{basis-sets => basis_sets}/cp2k/GTH_POTENTIALS | 0 ash/databases/{basis-sets => basis_sets}/cp2k/__init__.py | 0 ash/interfaces/interface_CFour.py | 4 ++-- ash/interfaces/interface_CP2K.py | 4 ++-- 13 files changed, 4 insertions(+), 4 deletions(-) rename ash/databases/{basis-sets => basis_sets}/__init__.py (100%) rename ash/databases/{basis-sets => basis_sets}/cfour/__init__.py (100%) rename ash/databases/{basis-sets => basis_sets}/cfour/cc-pV5Z (100%) rename ash/databases/{basis-sets => basis_sets}/cfour/cc-pVDZ (100%) rename ash/databases/{basis-sets => basis_sets}/cfour/cc-pVQZ (100%) rename ash/databases/{basis-sets => basis_sets}/cfour/cc-pVTZ (100%) rename ash/databases/{basis-sets => basis_sets}/cfour/def2-SVP (100%) rename ash/databases/{basis-sets => basis_sets}/cfour/def2-TZVP (100%) rename ash/databases/{basis-sets => basis_sets}/cp2k/BASIS_MOLOPT (100%) rename ash/databases/{basis-sets => basis_sets}/cp2k/GTH_POTENTIALS (100%) rename ash/databases/{basis-sets => basis_sets}/cp2k/__init__.py (100%) diff --git a/ash/databases/basis-sets/__init__.py b/ash/databases/basis_sets/__init__.py similarity index 100% rename from ash/databases/basis-sets/__init__.py rename to ash/databases/basis_sets/__init__.py diff --git a/ash/databases/basis-sets/cfour/__init__.py b/ash/databases/basis_sets/cfour/__init__.py similarity index 100% rename from ash/databases/basis-sets/cfour/__init__.py rename to ash/databases/basis_sets/cfour/__init__.py 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 similarity index 100% rename from ash/databases/basis-sets/cp2k/__init__.py rename to ash/databases/basis_sets/cp2k/__init__.py 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..a1934aabe 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -434,7 +434,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,7 +445,7 @@ 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') + 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) From 4faff26dbe18d89a4a13464bf1b12cb7c769681d Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 12 Feb 2026 15:57:12 +0100 Subject: [PATCH 055/134] pyproject.toml change to avoid missing files --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4e9d7b2aa..ea046e213 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" = ["**/*"] From a8f359188a4e176c4150c359a00c115ed290f9af Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Feb 2026 13:45:55 +0100 Subject: [PATCH 056/134] - surface calc: Error if runmode parallel but numcores = 1 - CP2K: minor cleanup. basis_method= XTB is now more logical. Skipping basis/ecp section in input for xtb. GFN_TYPE now supported. Only for newer CP2K versions (>2025.2) --- ash/interfaces/interface_CP2K.py | 46 +++++++++++++++++--------------- ash/modules/module_surface.py | 5 +++- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index a1934aabe..cf5b51e4a 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -38,7 +38,7 @@ # '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, xtb_periodic=False, xtb_type='GFN2', 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, @@ -53,19 +53,17 @@ 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": + if basis_method.upper() != "XTB": + 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() #NOTE: We still define a cell even though we may not be periodic #If no cell provided: CONTINUE and guess cell size later if cell_dimensions is None and cell_vectors is None: @@ -150,6 +148,7 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel 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.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 @@ -366,7 +365,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el cell_dimensions=self.cell_dimensions, cell_vectors=self.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, 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, @@ -409,7 +408,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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, periodic_type=self.periodic_type, - xtb_periodic=self.xtb_periodic, + xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type, cell_dimensions=self.cell_dimensions, cell_vectors=self.cell_vectors, basis_file=self.basis_file, potential_file=self.potential_file, @@ -526,7 +525,8 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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', + qm_cell_dims=None, qm_periodic_type=None, xtb_periodic=False, xtb_type='GFN2', + basis_file='BASIS', potential_file='POTENTIAL', psolver='wavelet', wavelet_scf_type=40, ngrids=4, cutoff=250, rel_cutoff=60, coupling='GAUSSIAN', GEEP_num_gauss=6, MM_radius_scaling=1, mm_radii=None, @@ -626,9 +626,12 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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') #NOTE inpfile.write(f' CHECK_ATOMIC_CHARGES F\n') inpfile.write(f' DO_EWALD {xtb_periodic}\n') #NOTE + inpfile.write(f' GFN_TYPE {xtbcode}\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 @@ -725,12 +728,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') diff --git a/ash/modules/module_surface.py b/ash/modules/module_surface.py index cc03831f8..cb116a8e0 100644 --- a/ash/modules/module_surface.py +++ b/ash/modules/module_surface.py @@ -152,7 +152,10 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U ########################### 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 From 6add3edd3338dd6c1801d9c964117e7d1dd9d82a Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Feb 2026 14:26:17 +0100 Subject: [PATCH 057/134] gxtb interface: bugfixes --- ash/interfaces/interface_xtb.py | 46 ++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 39c441b13..ca042d58f 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -22,23 +22,49 @@ # 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 + + # 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() @@ -85,9 +111,11 @@ def __init__(self, xtbdir=None, xtbmethod='GFN1', runmode='inputfile', numcores= use_tblite=False, periodic=False, periodic_cell_dimensions=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 From b357ce279a398a27ad7d53f1d53b6f7a4cf77d3e Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Feb 2026 14:29:17 +0100 Subject: [PATCH 058/134] gxtb: error message --- ash/interfaces/interface_xtb.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index ca042d58f..da06d4c8b 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -55,6 +55,8 @@ def __init__(self, gxtbdir=None, method=None, printlevel=2, numcores=1): # Setting GXTBHOME os.environ['GXTBHOME'] = self.gxtbdir + print("Warning: Interface is hardcoded to look for gxtb executable and .gxtb, .eeq and .basisq files in gxtbdir. Make sure these are present. Interface will exit if not.") + print("gxtbdir:", self.gxtbdir) # Checking if required gxtb files are present in gxtbdir from pathlib import Path if os.path.isfile(f"{self.gxtbdir}/.gxtb") is False: From 4cd33bf59f30b5e78f7d0165dff7b361254e9a4e Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Feb 2026 14:33:37 +0100 Subject: [PATCH 059/134] gxtb: more --- ash/interfaces/interface_xtb.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index da06d4c8b..5f0a495c2 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -52,11 +52,11 @@ def __init__(self, gxtbdir=None, method=None, printlevel=2, numcores=1): else: self.gxtbdir = gxtbdir - # Setting GXTBHOME - os.environ['GXTBHOME'] = self.gxtbdir + # Setting GXTBHOME + os.environ['GXTBHOME'] = self.gxtbdir - print("Warning: Interface is hardcoded to look for gxtb executable and .gxtb, .eeq and .basisq files in gxtbdir. Make sure these are present. Interface will exit if not.") - print("gxtbdir:", 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 if os.path.isfile(f"{self.gxtbdir}/.gxtb") is False: From 13b8ab90b018acef8e6e76dbec46841ce5a54768 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Feb 2026 14:42:38 +0100 Subject: [PATCH 060/134] settings_ash: added more directory names to look up --- ash/settings_ash.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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") From 35c2a3e1ee6944ee33cf7de87587c4cff641fb33 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 18 Feb 2026 10:25:29 +0100 Subject: [PATCH 061/134] cp2k: xtb_tblite option --- ash/interfaces/interface_CP2K.py | 37 ++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index cf5b51e4a..4fc9dea2f 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -38,7 +38,9 @@ # '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, xtb_type='GFN2', cell_dimensions=None, cell_vectors=None, + periodic=False, periodic_type='XYZ', qm_periodic_type=None, + xtb_periodic=False, xtb_type='GFN2', xtb_tblite=False, + 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, @@ -53,8 +55,9 @@ 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 + # 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() @@ -64,8 +67,17 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel if functional is None: print("functional keyword is required for PW andd GPW ") ashexit() - #NOTE: We still define a cell even though we may not be periodic - #If no cell provided: CONTINUE and guess cell size later + else: + print("This is a CP2K xTB theory") + if xtb_tblite: + print("xtb_tblite True. Using tblite version of xTB.") + else: + print("xtb_tblite False. Using built-in version of xTB.") + print("xtb_type:", xtb_type) + print("xtb_periodic:", xtb_periodic) + + # 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") @@ -149,6 +161,7 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel 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 @@ -365,7 +378,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el cell_dimensions=self.cell_dimensions, cell_vectors=self.cell_vectors, qm_cell_dims=self.qm_cell_dims, qm_periodic_type=self.qm_periodic_type, - xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type, + 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, @@ -408,7 +421,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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, periodic_type=self.periodic_type, - xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type, + xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type,xtb_tblite=self.xtb_tblite, cell_dimensions=self.cell_dimensions, cell_vectors=self.cell_vectors, basis_file=self.basis_file, potential_file=self.potential_file, @@ -525,7 +538,8 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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, xtb_type='GFN2', + 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, @@ -628,10 +642,15 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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') #NOTE + 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' GFN_TYPE {xtbcode}\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 From 21bc67fdb4b23cf203c9c2c040e83dfb5d352482 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 18 Feb 2026 10:54:48 +0100 Subject: [PATCH 062/134] CP2K interface: added option for user to provide their own DFT section via keyword user_input_dft (can be multi-line string or filepath) --- ash/interfaces/interface_CP2K.py | 191 ++++++++++++++++++------------- 1 file changed, 111 insertions(+), 80 deletions(-) diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index 4fc9dea2f..f66650ba8 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -40,6 +40,7 @@ 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, xtb_type='GFN2', xtb_tblite=False, + user_input_dft=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', @@ -141,6 +142,29 @@ 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. + 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 @@ -375,6 +399,7 @@ 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, + user_input_dft=self.user_input_dft, cell_dimensions=self.cell_dimensions, cell_vectors=self.cell_vectors, qm_cell_dims=self.qm_cell_dims, qm_periodic_type=self.qm_periodic_type, @@ -418,6 +443,7 @@ 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, + user_input_dft=self.user_input_dft, 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, periodic_type=self.periodic_type, @@ -533,7 +559,7 @@ def run_CP2K(cp2kdir,bin_name,filename,numcores=1, paramethod='MPI', mixed_omp_t 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, 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, @@ -599,85 +625,90 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, ########## #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': - # 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') - - #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') + + #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') #QM/MM if method == 'QMMM': From 81fe1aee0163bb01f5be6cf8316fa0c9f871306f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 18 Feb 2026 13:03:20 +0100 Subject: [PATCH 063/134] dispersion corrections in CP2K interface: vdwpotential --- ash/interfaces/interface_CP2K.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index f66650ba8..622d7c16f 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -40,7 +40,7 @@ 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, xtb_type='GFN2', xtb_tblite=False, - user_input_dft=None, + 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', @@ -146,6 +146,7 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel # 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): @@ -203,6 +204,9 @@ 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 + #Grid stuff self.ngrids=ngrids self.cutoff=cutoff @@ -239,6 +243,7 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel 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) @@ -399,7 +404,7 @@ 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, - user_input_dft=self.user_input_dft, + user_input_dft=self.user_input_dft, vdwpotential=self.vdwpotential, cell_dimensions=self.cell_dimensions, cell_vectors=self.cell_vectors, qm_cell_dims=self.qm_cell_dims, qm_periodic_type=self.qm_periodic_type, @@ -443,7 +448,7 @@ 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, - user_input_dft=self.user_input_dft, + user_input_dft=self.user_input_dft, vdwpotential=self.vdwpotential, 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, periodic_type=self.periodic_type, @@ -558,6 +563,7 @@ 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, user_input_dft=None, charge=None, mult=None, basis_method='GAPW', @@ -704,6 +710,19 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, #XC inpfile.write(f' &XC\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') From 5bf62399f529177de0b2aaef913af3eddda986fa Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 18 Feb 2026 18:08:56 +0100 Subject: [PATCH 064/134] Turbomole interface: bugfixes - better handling of sysname and runcalls - Check for presence of energy file after run - create_control_file: Handling of occupations for closed-shell and open-shells. Handling of MO-files also. MO-read in Opt/MD should now be reliable. --- ash/interfaces/interface_Turbomole.py | 81 +++++++++++++++++++++------ 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/ash/interfaces/interface_Turbomole.py b/ash/interfaces/interface_Turbomole.py index 7c2933bd3..f6b71fa80 100644 --- a/ash/interfaces/interface_Turbomole.py +++ b/ash/interfaces/interface_Turbomole.py @@ -5,6 +5,7 @@ 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 import ash.settings_ash from ash.functions.functions_parallel import check_OpenMPI @@ -145,6 +146,13 @@ 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 @@ -160,8 +168,8 @@ 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.mpi_is_setup=True @@ -173,15 +181,17 @@ 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.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': @@ -197,8 +207,7 @@ def run_turbo(self,filename, exe="ridft", numcores=1, parallelization=None): 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: - #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. @@ -252,10 +261,12 @@ 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, + numelectrons = int(nucchargelist(qm_elems) - charge) + create_control_file(runcalls=self.runcalls, 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, 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) @@ -276,6 +287,13 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # SCF-energy only self.run_turbo(self.filename_scf, exe=self.turbo_scf_exe, parallelization=self.parallelization, numcores=self.numcores) + # Updating runcalls (this will also make sure that mos file is read in next run) + self.runcalls+=1 + # 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) @@ -355,18 +373,48 @@ 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, +def create_control_file(runcalls=None, 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): + 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 +425,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 From 0b47404edad00d871b79e43a5528b67bfca93636 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 18 Feb 2026 18:38:10 +0100 Subject: [PATCH 065/134] turbomole interface: sysname debugging for smp and mpi --- ash/interfaces/interface_Turbomole.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ash/interfaces/interface_Turbomole.py b/ash/interfaces/interface_Turbomole.py index f6b71fa80..ff6e4879a 100644 --- a/ash/interfaces/interface_Turbomole.py +++ b/ash/interfaces/interface_Turbomole.py @@ -172,6 +172,7 @@ def setup_mpi(self,numcores): #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): @@ -183,6 +184,7 @@ def setup_smp(self,numcores): print("PARNODES has been set to ", numcores) 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): From 329cc07b2c2bd4c199eb555826606f7e3ebe3795 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 19 Feb 2026 15:26:09 +0100 Subject: [PATCH 066/134] turbomole interface: catch wrong parallelization keyword if requested --- ash/interfaces/interface_OpenMM.py | 2 +- ash/interfaces/interface_Turbomole.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 0f16152e7..4d47bcd5b 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -6362,7 +6362,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 ff6e4879a..949e6bef7 100644 --- a/ash/interfaces/interface_Turbomole.py +++ b/ash/interfaces/interface_Turbomole.py @@ -208,6 +208,9 @@ 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: 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) From f127bb23b3c5653bd8bc4a127fe7abc40e833ba5 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 20 Feb 2026 11:10:17 +0100 Subject: [PATCH 067/134] plotting: reactionprofile_plot now prints file with relative energies --- ash/modules/module_MM.py | 4 +++- ash/modules/module_plotting.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ash/modules/module_MM.py b/ash/modules/module_MM.py index 6f3337baf..fc198207a 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): diff --git a/ash/modules/module_plotting.py b/ash/modules/module_plotting.py index 54d42ff44..6142fbbcf 100644 --- a/ash/modules/module_plotting.py +++ b/ash/modules/module_plotting.py @@ -282,7 +282,7 @@ 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'): @@ -303,6 +303,9 @@ def reactionprofile_plot(surfacedictionary, finalunit='',label='Label', x_axisla e.append(surfacedictionary[key]) if RelativeEnergy is True: + if finalunit == None: + print("RelativeEnergy is True but finalunit not provided. Exiting.") + ashexit() #List of energies and relenergies here refenergy=float(min(e)) rele=[] @@ -315,6 +318,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 ) From 6057c1077e83cfbf04eefb53940b8c481ecd2751 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 20 Feb 2026 11:47:42 +0100 Subject: [PATCH 068/134] cp2k: disable OT for xtb-tblite --- ash/interfaces/interface_CP2K.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index 622d7c16f..f2d834879 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -72,6 +72,8 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel 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) @@ -195,7 +197,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 From 2bedbb6ce20781807d487f351dfbb6b4c781984d Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 20 Feb 2026 13:32:31 +0100 Subject: [PATCH 069/134] surface_traj.xyz now written by calc_surface --- ash/modules/module_MM.py | 1 + ash/modules/module_surface.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/ash/modules/module_MM.py b/ash/modules/module_MM.py index fc198207a..69831a860 100644 --- a/ash/modules/module_MM.py +++ b/ash/modules/module_MM.py @@ -905,3 +905,4 @@ def LJCoulpy(coords,atomtypes, charges, LJPairpotentials, connectivity=None): final_gradient = LJfinal_gradient + Coulgradient return final_energy,final_gradient + diff --git a/ash/modules/module_surface.py b/ash/modules/module_surface.py index cb116a8e0..42909ebd0 100644 --- a/ash/modules/module_surface.py +++ b/ash/modules/module_surface.py @@ -12,7 +12,7 @@ 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 @@ -509,6 +509,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.xyz") + print_time_rel(module_init_time, modulename='calc_surface', moduleindex=0) result = ASH_Results(label="Surface calc", surfacepoints=surfacedictionary) try: From 423bb20664d46194d23ac9ae5c227945f1b7d54f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 23 Feb 2026 13:10:39 +0100 Subject: [PATCH 070/134] - CP2K: skip MO printing - calc_surface: writing traj in each step for serial calculations --- ash/interfaces/interface_CP2K.py | 6 +++--- ash/modules/module_surface.py | 25 +++++++++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index f2d834879..3b75d0fff 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -705,9 +705,9 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, #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' &MO\n') + #inpfile.write(f' EIGENVALUES .TRUE.\n') + #inpfile.write(f' &END MO\n') inpfile.write(f' &END PRINT\n') #XC diff --git a/ash/modules/module_surface.py b/ash/modules/module_surface.py index 42909ebd0..cf8ba5077 100644 --- a/ash/modules/module_surface.py +++ b/ash/modules/module_surface.py @@ -146,6 +146,11 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U #os.mkdir('surface_fragfiles') os.mkdir('surface_mofiles') + try: + os.remove("surface_traj.xyz") + except: + pass + ########################### # PARALLEL @@ -357,6 +362,9 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U 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) + # 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") @@ -399,6 +407,9 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U 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) + # 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") #fragment.print_system(filename="RC1_"+str(RCvalue1)+".ygg") @@ -456,6 +467,9 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U print("Warning: For hybrid theories, outputfiles and MO-files are not kept") surfacedictionary[(RCvalue1,RCvalue2)] = energy + # 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") @@ -497,11 +511,14 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U print("Warning: For hybrid theories, outputfiles and MO-files are not kept") surfacedictionary[(RCvalue1)] = energy - #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") + # shutil.move("RC1_"+str(RCvalue1)+".ygg", "surface_fragfiles/"+"RC1_"+str(RCvalue1)+".ygg") else: print("RC1 value in dict already. Skipping.") @@ -517,7 +534,7 @@ def combine_xyzfiles_in_directory(xyzdir, outputfilename): with open(xyzfile, 'r') as infile: contents = infile.read() outfile.write(contents) - combine_xyzfiles_in_directory(xyzdir="surface_xyzfiles", outputfilename="surface_traj.xyz") + 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) From 7f86d9c2b998155a2fa71eb797bcf200bcaee805 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 25 Feb 2026 15:23:24 +0100 Subject: [PATCH 071/134] - OpenBabel interface for some basic FFs. not quite ready - MACE interface for future better support of polarMACE etc. - Preliminary Seminario implementation, not yet ready - TurbomoleTheory: support for basic uff via uff Boolean keyword. --- ash/__init__.py | 1 + ash/interfaces/interface_Turbomole.py | 94 ++++++--- ash/interfaces/interface_mace.py | 203 +++++++++++------- ash/interfaces/interface_openbabel.py | 126 +++++++++++ ash/interfaces/interface_xtb.py | 2 +- ash/modules/module_MM.py | 293 ++++++++++++++++++++++++++ 6 files changed, 615 insertions(+), 104 deletions(-) create mode 100644 ash/interfaces/interface_openbabel.py diff --git a/ash/__init__.py b/ash/__init__.py index c994f4357..5f4be3266 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -123,6 +123,7 @@ 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 # MM: external and internal from .interfaces.interface_OpenMM import OpenMMTheory, OpenMM_MD, OpenMM_MDclass, OpenMM_Opt, OpenMM_Modeller, \ diff --git a/ash/interfaces/interface_Turbomole.py b/ash/interfaces/interface_Turbomole.py index 949e6bef7..28a85a243 100644 --- a/ash/interfaces/interface_Turbomole.py +++ b/ash/interfaces/interface_Turbomole.py @@ -12,7 +12,7 @@ # Turbomole Theory object. class TurbomoleTheory: - def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel=2, label="Turbomole", + def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel=2, label="Turbomole", uff=False, numcores=1, parallelization='SMP', functional=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): @@ -38,6 +38,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 @@ -52,12 +53,21 @@ 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("Initializign 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 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 # User controlfile @@ -224,7 +234,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() @@ -232,14 +242,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") @@ -286,21 +296,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) # Updating runcalls (this will also make sure that mos file is read in next run) self.runcalls+=1 - # 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) + 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_uffgradient(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: @@ -312,7 +338,7 @@ 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: + if Grad is True and self.uff is False: print("Running Turbomole-gradient executable") print("self.turbo_exe_grad:", self.turbo_exe_grad) print("self.filename_grad:", self.filename_grad) @@ -491,9 +517,9 @@ def create_control_file(runcalls=None, functional="lh12ct-ssifpw92", gridsize="m 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 @@ -501,9 +527,9 @@ def grab_energy_from_energyfile(column=1): energy = float(line.split()[column]) return energy -def grab_gradient(numatoms): +def grab_gradient(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): @@ -512,7 +538,25 @@ def grab_gradient(numatoms): if i > numatoms+1: gradient[counter] = [float(j.replace('D','E')) for j in line.split()] counter+=1 + return gradient +def grab_uffgradient(numatoms,file="uffgradient"): + 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"): diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 203923be1..8da6413fd 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -13,7 +13,7 @@ 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, + label="MACETheory", numcores=1, device="cpu", return_zero_gradient=False, polarmace=False, default_dtype="float64", energy_weight=None, forces_weight=None, max_num_epochs=None, valid_fraction=None): # Early exits try: @@ -32,10 +32,15 @@ def __init__(self, config_filename="config.yml", self.config_filename=config_filename self.filename = filename self.printlevel = printlevel - + self.properties = {} # Ignore predicted forces and return zero gradient self.return_zero_gradient=return_zero_gradient + # Distinguish between old MACE and polarMACE + self.polarmace=polarmace + + self.default_dtype=default_dtype + # Model attribute is None until we have loaded a model self.model=None @@ -219,12 +224,31 @@ def check_file_exists(self, file): 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) + + if 'polar' in self.model_file.lower(): + print("Model file name contains 'polar'. Assuming this is a polar MACE model. Loading polar mace") + self.polarmace=True + from mace.calculators import mace_polar + self.model = mace_polar( + model=self.model_file, + device=self.device, # or "cuda" + default_dtype=self.default_dtype # use float32 for faster MD + ) + else: + print("Loading regular MACE via Pytorch") + 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, @@ -268,88 +292,111 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # Checking if file exists self.check_file_exists(self.model_file) - #Making sure Grad is True + # Making sure Grad is True 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 - + # Checking that model is loaded if self.model is None: print("Model has not been loaded yet.") self.model_load() - # Simplest to use ase here to create Atoms object - 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: - # Get batch - for batch in data_loader: - batch = batch.to(self.device) - # Run model - try: - output = self.model(batch.to_dict(), compute_stress=False, compute_force=False) - - 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) - 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 - - # Hessian - if Hessian: - print("Running Hessian") - 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) - - # This worked previously + if self.polarmace: + print("This is a polar MACE model. Running using different interface.") + + # Simplest to use ase here to create Atoms object + import ase + atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) + + atoms.info["charge"] = charge + atoms.info["spin"] = mult + # atoms.info["external_field"] = [0.0, 0.0, 0.0] + atoms.calc = self.model + + self.energy = atoms.get_potential_energy() * ash.constants.evtohar + forces = atoms.get_forces() + self.gradient = forces/-51.422067090480645 + # stress = atoms.get_stress() + # TODO: Hessian ? + + # Grab some other attributes + # Charges + self.charges = self.model.results["charges"] + # dipole + #mu = calc.results["dipole"] + self.properties["dipole"] = self.model.results["dipole"] + else: - print("previous regular mode") - for batch in data_loader: + # 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 + + + + # Simplest to use ase here to create Atoms object + import ase + atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) + + # 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) + # + option_1=True + if option_1: + # Get batch + for batch in data_loader: + batch = batch.to(self.device) + # Run model try: - output = self.model(batch.to_dict(), compute_stress=False, compute_force=Grad) + output = self.model(batch.to_dict(), compute_stress=False, compute_force=False) + 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 + output = self.model(batch.to_dict(), compute_stress=False, compute_force=False) + 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 + + # Hessian + if Hessian: + print("Running Hessian") + 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) + + # 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 diff --git a/ash/interfaces/interface_openbabel.py b/ash/interfaces/interface_openbabel.py new file mode 100644 index 000000000..89c416795 --- /dev/null +++ b/ash/interfaces/interface_openbabel.py @@ -0,0 +1,126 @@ +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.functions.functions_parallel import check_OpenMPI + +# 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") + 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: + 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 \ No newline at end of file diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 5f0a495c2..6f5cc8887 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -414,7 +414,7 @@ def Opt(self, fragment=None, Grad=None, Hessian=None, numcores=None, label=None, ash.modules.module_coords.print_internal_coordinate_table(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') diff --git a/ash/modules/module_MM.py b/ash/modules/module_MM.py index 69831a860..e8c1a58bd 100644 --- a/ash/modules/module_MM.py +++ b/ash/modules/module_MM.py @@ -906,3 +906,296 @@ def LJCoulpy(coords,atomtypes, charges, LJPairpotentials, connectivity=None): 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:", 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 From 18e070b1b29b233e1903c420a9887d747e7f60a7 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 25 Feb 2026 15:27:14 +0100 Subject: [PATCH 072/134] bugfix: module_MM --- ash/modules/module_MM.py | 298 +++++++++++++++++++-------------------- 1 file changed, 149 insertions(+), 149 deletions(-) diff --git a/ash/modules/module_MM.py b/ash/modules/module_MM.py index e8c1a58bd..54608e374 100644 --- a/ash/modules/module_MM.py +++ b/ash/modules/module_MM.py @@ -1049,153 +1049,153 @@ def do_angles(list_of_angles, coords_au, hessian, dist_matrix): 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:", 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 +# 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 +# # 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 From b58f90e378af9eb0fce6478006a00eb258bf7896 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 26 Feb 2026 12:22:20 +0100 Subject: [PATCH 073/134] - moved openbabel functions to OpenBabel interface - CP2KTheory: kpoint_settings keyword - calc_surface: surface_results updated in each step --- ash/__init__.py | 5 +- ash/interfaces/interface_CP2K.py | 12 ++ ash/interfaces/interface_openbabel.py | 160 +++++++++++++++++++++++++- ash/modules/module_coords.py | 143 ----------------------- ash/modules/module_surface.py | 11 +- 5 files changed, 181 insertions(+), 150 deletions(-) diff --git a/ash/__init__.py b/ash/__init__.py index 5f4be3266..a262771cf 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -41,7 +41,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 @@ -123,7 +123,8 @@ 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 +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, \ diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index 3b75d0fff..1bf8f1357 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -45,6 +45,7 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel 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, + 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, coupling='GAUSSIAN', GEEP_num_gauss=6, MM_radius_scaling=1, mm_radii=None, @@ -218,6 +219,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 @@ -408,6 +412,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el coordfile=system_xyzfile, user_input_dft=self.user_input_dft, vdwpotential=self.vdwpotential, cell_dimensions=self.cell_dimensions, + kpoint_settings=self.kpoint_settings, cell_vectors=self.cell_vectors, qm_cell_dims=self.qm_cell_dims, qm_periodic_type=self.qm_periodic_type, xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type, xtb_tblite=self.xtb_tblite, @@ -451,6 +456,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el functional=self.functional, restartfile=None, Grad=Grad, filename='cp2k', charge=charge, mult=mult, 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, periodic_type=self.periodic_type, @@ -572,6 +578,7 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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, + 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', @@ -703,6 +710,11 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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') diff --git a/ash/interfaces/interface_openbabel.py b/ash/interfaces/interface_openbabel.py index 89c416795..177842f71 100644 --- a/ash/interfaces/interface_openbabel.py +++ b/ash/interfaces/interface_openbabel.py @@ -12,7 +12,8 @@ # TODO: Move other OpenBabel functionality to this file class OpenBabelTheory(): - def __init__(self, forcefield="UFF", chargemodel=None, label="OpenBabelTheory", printlevel=2, user_atomcharges=None): + def __init__(self, forcefield="UFF", chargemodel=None, label="OpenBabelTheory", + printlevel=2, user_atomcharges=None): self.label = label self.printlevel = printlevel self.theorytype = 'QM' @@ -80,6 +81,8 @@ def set_charges(mol,usercharges): # 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) @@ -102,8 +105,8 @@ def set_charges(mol,usercharges): 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) + #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)): @@ -123,4 +126,153 @@ def set_charges(mol,usercharges): # Returning energy without gradient else: print_time_rel(module_init_time, modulename=f'{self.theorynamelabel} run', moduleindex=2) - return self.energy \ No newline at end of file + 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/modules/module_coords.py b/ash/modules/module_coords.py index 42a8cb296..b3f2351b2 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -3941,149 +3941,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: diff --git a/ash/modules/module_surface.py b/ash/modules/module_surface.py index cf8ba5077..24c3a6b1e 100644 --- a/ash/modules/module_surface.py +++ b/ash/modules/module_surface.py @@ -383,7 +383,8 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U else: print("Warning: For hybrid theories, outputfiles and MO-files are not kept") surfacedictionary[(RCvalue1,RCvalue2)] = 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) @@ -427,6 +428,8 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U else: print("Warning: For hybrid theories, outputfiles and MO-files are not kept") surfacedictionary[(RCvalue1)] = energy + # Write surfacedictionary to file after each step + write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) else: print("RC1 value in dict already. Skipping.") ##################### @@ -467,6 +470,9 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U print("Warning: For hybrid theories, outputfiles and MO-files are not kept") surfacedictionary[(RCvalue1,RCvalue2)] = 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') @@ -511,6 +517,9 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U print("Warning: For hybrid theories, outputfiles and MO-files are not kept") surfacedictionary[(RCvalue1)] = 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') From d776a881fee2ff83519331ce6479c347ddc975b4 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 26 Feb 2026 12:29:12 +0100 Subject: [PATCH 074/134] fixes --- ash/interfaces/interface_openbabel.py | 1 + ash/modules/module_coords.py | 1 + 2 files changed, 2 insertions(+) diff --git a/ash/interfaces/interface_openbabel.py b/ash/interfaces/interface_openbabel.py index 177842f71..32fdc61bb 100644 --- a/ash/interfaces/interface_openbabel.py +++ b/ash/interfaces/interface_openbabel.py @@ -7,6 +7,7 @@ from ash.functions.functions_general import ashexit, BC, print_time_rel,print_line_with_mainheader import ash.settings_ash from ash.functions.functions_parallel import check_OpenMPI +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 diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index b3f2351b2..ca41ed5af 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) From e274fb3eab9c625df2654829a32c4d62c3e3ad0f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 26 Feb 2026 21:49:05 +0100 Subject: [PATCH 075/134] - new_call_crest: more crest options and constraints - OpenMMTheory: openbabel moving fixes -packmol interface: openbabel moving fixes - write_xmlfile_nonbonded: Nonbonded section written even if no parameters now to make sure OpenMM system becomes periodic if we want to - OpenMM_MD: force_periodic and periodic_cell_dimensions options to make sure we OpenMMTheory behaves for periodic QM calculations (mostly trajectory writing things) --- ash/__init__.py | 2 +- ash/interfaces/interface_OpenMM.py | 49 ++++++-- ash/interfaces/interface_crest.py | 164 +++++++++++++++++++++----- ash/interfaces/interface_openbabel.py | 1 - ash/interfaces/interface_packmol.py | 2 +- 5 files changed, 175 insertions(+), 43 deletions(-) diff --git a/ash/__init__.py b/ash/__init__.py index a262771cf..7639a2227 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -41,7 +41,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, + 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 diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 4d47bcd5b..bc1297b0b 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -17,8 +17,8 @@ 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 + from ash.modules.module_MM import UFF_modH_dict, MMforcefield_read from ash.interfaces.interface_xtb import xTBTheory, grabatomcharges_xTB from ash.interfaces.interface_ORCA import ORCATheory, grabatomcharges_ORCA, chargemodel_select @@ -27,6 +27,7 @@ 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: @@ -493,14 +494,12 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo #self.topology = fragment.pdb_topology from ash.modules.module_coords import define_dummy_topology self.topology = define_dummy_topology(fragment.elems) - print("self.topology:", self.topology) - print("self.topology dict:", self.topology.__dict__) # 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) @@ -619,7 +618,7 @@ 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 + # FINAL PRINTING OF SYSTEM PBC VECTORS a, b, c = self.system.getDefaultPeriodicBoxVectors() if self.printlevel > 0: print_line_with_subheader2("Periodic vectors:") @@ -3247,11 +3246,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") @@ -3261,10 +3262,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 @@ -3351,6 +3353,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, @@ -3363,6 +3366,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, @@ -3393,6 +3397,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, @@ -3493,7 +3498,9 @@ def __init__(self, fragment=None, theory=None, charge=None, mult=None, timestep= 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() @@ -3523,7 +3530,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.") @@ -3540,7 +3549,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 @@ -3808,6 +3818,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 @@ -4212,6 +4223,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 diff --git a/ash/interfaces/interface_crest.py b/ash/interfaces/interface_crest.py index d8e712741..e5af39d17 100644 --- a/ash/interfaces/interface_crest.py +++ b/ash/interfaces/interface_crest.py @@ -10,8 +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="imtd-gc", numcores=1, - charge=None, mult=None, energywindow=6.0): +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: @@ -32,42 +36,146 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="imtd-gc", # Write initial XYZ-file fragment.write_xyzfile(xyzfilename="struc.xyz") - # Pickle for serializing theory object + ##################### + # 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 - # 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 - -frag = Fragment(xyzfile="genericinp.xyz", charge={charge},mult={mult}) -#Unpickling theory object -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 + + frag = Fragment(xyzfile="genericinp.xyz", charge={charge},mult={mult}) + #Unpickling theory object + 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) + + 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] -ewin = {energywindow} +{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: @@ -76,7 +184,7 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="imtd-gc", print("CREST run-type:", runtype) if runtype == "imtd-gc": - print(f"Note:Energy window is {energywindow} kcal/mol") + 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']) diff --git a/ash/interfaces/interface_openbabel.py b/ash/interfaces/interface_openbabel.py index 32fdc61bb..bb2dc851a 100644 --- a/ash/interfaces/interface_openbabel.py +++ b/ash/interfaces/interface_openbabel.py @@ -6,7 +6,6 @@ from ash.functions.functions_general import ashexit, BC, print_time_rel,print_line_with_mainheader import ash.settings_ash -from ash.functions.functions_parallel import check_OpenMPI from ash.modules.module_coords import reformat_element # Interface to OpenBabel for running implemented theories (e.g. UFF) 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 From 54a65872af088f7fcf3c1ddbd2cda5add512c27c Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 2 Mar 2026 12:26:07 +0100 Subject: [PATCH 076/134] - subash: small update to copy dcd files - reactionprofile_plot: check for matplotlib proper - ASH-packages.sh: add xtb-python package --- ASH-packages.sh | 1 + ash/modules/module_plotting.py | 4 ++++ scripts/subash.sh | 1 + 3 files changed, 6 insertions(+) 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/ash/modules/module_plotting.py b/ash/modules/module_plotting.py index 6142fbbcf..7adb57b74 100644 --- a/ash/modules/module_plotting.py +++ b/ash/modules/module_plotting.py @@ -289,6 +289,10 @@ def reactionprofile_plot(surfacedictionary, finalunit=None,label='Label', x_axis 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 } diff --git a/scripts/subash.sh b/scripts/subash.sh index 7bca94f70..4b98ce6d3 100644 --- a/scripts/subash.sh +++ b/scripts/subash.sh @@ -291,6 +291,7 @@ 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/*.xtl \$tdir/ 2>/dev/null cp \$SLURM_SUBMIT_DIR/*.ff \$tdir/ 2>/dev/null cp \$SLURM_SUBMIT_DIR/*.ygg \$tdir/ 2>/dev/null From 2f3f16b88011f169d81e46e075ac77bd8bc64d2d Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 2 Mar 2026 12:53:19 +0100 Subject: [PATCH 077/134] xTBTheory: runmode library, bugfix for faulty energy when object used for new fragments with same number of atoms. --- ash/interfaces/interface_xtb.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 6f5cc8887..246b81445 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -581,7 +581,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) @@ -595,11 +595,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) From 77aa43ffe4b8beffd4390c20d509650ec4b03c45 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 2 Mar 2026 22:01:19 +0100 Subject: [PATCH 078/134] - Bug in MACE interface fix: error due to forces (not present in master). Will eventually be redundant once newer MACE library version becomes available. - subash.sh: copy *.model files --- ash/interfaces/interface_mace.py | 79 +++++++++++++------------------- scripts/subash.sh | 1 + 2 files changed, 34 insertions(+), 46 deletions(-) diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 8da6413fd..c470112a0 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -301,6 +301,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el print("Model has not been loaded yet.") self.model_load() + # New simpler MACE interface if self.polarmace: print("This is a polar MACE model. Running using different interface.") @@ -325,7 +326,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # dipole #mu = calc.results["dipole"] self.properties["dipole"] = self.model.results["dipole"] - + # Older interface else: # Call model to get energy from mace.cli.eval_configs import main @@ -335,8 +336,6 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el import torch from mace.modules.utils import compute_hessians_vmap, compute_hessians_loop, compute_forces - - # Simplest to use ase here to create Atoms object import ase atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) @@ -354,49 +353,37 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el shuffle=False, drop_last=False) # - option_1=True - if option_1: - # Get batch - for batch in data_loader: - batch = batch.to(self.device) - # Run model - try: - output = self.model(batch.to_dict(), compute_stress=False, compute_force=False) - - 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) - 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 - - # Hessian - if Hessian: - print("Running Hessian") - 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) - - # 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 + # Get batch + for batch in data_loader: + batch = batch.to(self.device) + # Run model + try: + output = self.model(batch.to_dict(), compute_stress=False, compute_force=False) + + 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) + 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 + + # Hessian + if Hessian: + print("Running Hessian") + 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) if Hessian: self.hessian = hessian*0.010291772 diff --git a/scripts/subash.sh b/scripts/subash.sh index 4b98ce6d3..5dddc3968 100644 --- a/scripts/subash.sh +++ b/scripts/subash.sh @@ -292,6 +292,7 @@ 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 From 2bdfa612687a440c2959401739f490e4aa454b9e Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 4 Mar 2026 14:31:02 +0100 Subject: [PATCH 079/134] - CP2K: stress tensor, fix for cell_vectors vs. dimensions, now both properly allowed - initial cell gradient implementation. not ready - knarr: fix for numpy 2.4 --- ash/functions/functions_optimization.py | 74 +++++++++++++++++++++++ ash/interfaces/interface_CP2K.py | 78 ++++++++++++++++++++----- ash/knarr/KNARRio/output_print.py | 2 +- 3 files changed, 137 insertions(+), 17 deletions(-) diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index a23ab998a..95a40b437 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -442,3 +442,77 @@ def BernyOpt(theory,fragment, charge=None, mult=None): print("Final optimized energy:", fragment.energy) fragment.replace_coords(fragment.elems,geom.coords) blankline() + + +# Preliminary cell-vector optimization routines +# Takes lattice vectors and stress tensor and return gradient +def get_cell_gradients(lattice_matrix, stress_tensor_gpa, external_pressure_gpa=0.0, return_type='matrix'): + """ + Calculates cell gradients from a stress tensor provided in GPa. + + Parameters: + ----------- + lattice_matrix : np.ndarray (3, 3) + The cell matrix where columns are the lattice vectors [a, b, c] in Angstroms. + stress_tensor_gpa : np.ndarray (3, 3) + The symmetric stress tensor in GPa. + external_pressure_gpa : float + External pressure in GPa (subtracted from the internal stress). + return_type : str + 'matrix' -> returns (3, 3) dE/dH in eV/Angstrom + 'parameters' -> returns list [de_da, de_db, de_dc, de_dalpha, de_dbeta, de_dgamma] + """ + + # 1. Conversion Constant: 1 GPa = 0.006241509 eV/Angstrom^3 + GPA_TO_EV_ANG3 = 1.0 / 160.21766208 + + # 2. Convert Stress and Pressure to eV/Angstrom^3 + sigma_ev = stress_tensor_gpa * GPA_TO_EV_ANG3 + p_ext_ev = external_pressure_gpa * GPA_TO_EV_ANG3 + + # 3. Calculate Volume (Omega) in Angstrom^3 + volume = np.linalg.det(lattice_matrix) + + # 4. Total Stress (Internal Stress + External Pressure) + # Note: Optimization minimizes Enthalpy H = E + PV. + # The gradient dH/dh includes the P_ext term. + sigma_tot = sigma_ev + p_ext_ev * np.eye(3) + + # 5. Matrix Gradient: dE/dH = Volume * Stress * (H^-1).T + # Units: [Ang^3] * [eV/Ang^3] * [1/Ang] = eV/Ang + inv_h_t = np.linalg.inv(lattice_matrix).T + de_dh = volume * np.dot(sigma_tot, inv_h_t) + + if return_type == 'matrix': + return de_dh + + elif return_type == 'parameters': + # Extract vectors and lengths + a_vec, b_vec, c_vec = lattice_matrix[:, 0], lattice_matrix[:, 1], lattice_matrix[:, 2] + a, b, c = np.linalg.norm(a_vec), np.linalg.norm(b_vec), np.linalg.norm(c_vec) + + # dE/dL (Length gradients in eV/Angstrom) + de_da = np.dot(de_dh[:, 0], a_vec / a) + de_db = np.dot(de_dh[:, 1], b_vec / b) + de_dc = np.dot(de_dh[:, 2], c_vec / c) + + # Helper for angle gradients (units: eV/radian) + def get_angle_grad(v1, v2, g1, g2): + n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2) + cos_theta = np.clip(np.dot(v1, v2) / (n1 * n2), -1.0, 1.0) + sin_theta = np.sqrt(1.0 - cos_theta**2) + + if sin_theta < 1e-8: + return 0.0 + + # Chain rule: dE/dTheta + dtheta_dv1 = (cos_theta * v1 / n1**2 - v2 / (n1 * n2)) / sin_theta + dtheta_dv2 = (cos_theta * v2 / n2**2 - v1 / (n1 * n2)) / sin_theta + + return np.dot(g1, dtheta_dv1) + np.dot(g2, dtheta_dv2) + + de_dalpha = get_angle_grad(b_vec, c_vec, de_dh[:, 1], de_dh[:, 2]) + de_dbeta = get_angle_grad(a_vec, c_vec, de_dh[:, 0], de_dh[:, 2]) + de_dgamma = get_angle_grad(a_vec, b_vec, de_dh[:, 0], de_dh[:, 1]) + + return [de_da, de_db, de_dc, de_dalpha, de_dbeta, de_dgamma] \ No newline at end of file diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index 1bf8f1357..c2b3c596d 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -38,7 +38,7 @@ # '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, + periodic=False, periodic_type='XYZ', qm_periodic_type=None, stress_tensor=False, stress_tensor_algo="DIAGONAL_ANALYTICAL", xtb_periodic=False, xtb_type='GFN2', xtb_tblite=False, user_input_dft=None, vdwpotential=None, cell_dimensions=None, cell_vectors=None, @@ -210,6 +210,10 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel # 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.cutoff=cutoff @@ -410,6 +414,7 @@ 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, + stress_tensor=self.stress_tensor, stress_tensor_algo=self.stress_tensor_algo, user_input_dft=self.user_input_dft, vdwpotential=self.vdwpotential, cell_dimensions=self.cell_dimensions, kpoint_settings=self.kpoint_settings, @@ -429,7 +434,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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': @@ -455,13 +460,14 @@ 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, periodic_type=self.periodic_type, xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type,xtb_tblite=self.xtb_tblite, - cell_dimensions=self.cell_dimensions, + cell_dimensions=self.cell_dimensions, cell_vectors=self.cell_vectors, basis_file=self.basis_file, potential_file=self.potential_file, psolver=self.psolver, printlevel=self.printlevel, @@ -473,8 +479,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 + #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 + # 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()}") @@ -498,27 +509,30 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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 + # 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("stress:", self.stress) + # 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) @@ -526,7 +540,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 @@ -578,6 +593,7 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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, + 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, @@ -630,11 +646,17 @@ 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') ########## @@ -693,8 +715,8 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=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 + #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 @@ -800,10 +822,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: + 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') - elif cell_vectors != None: + elif 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') @@ -947,3 +969,27 @@ 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 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?") From 374885c7a5ceacbe0892a69e61dd695ea339362c Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 4 Mar 2026 15:05:22 +0100 Subject: [PATCH 080/134] knarr: more changes due to numpy 2.4 being more strict --- ash/knarr/KNARRatom/utilities.py | 1 + ash/knarr/KNARRio/io.py | 10 +++++----- ash/knarr/KNARRjobs/neb.py | 10 +++++----- ash/knarr/KNARRjobs/utilities.py | 6 +++--- 4 files changed, 14 insertions(+), 13 deletions(-) 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/KNARRio/io.py b/ash/knarr/KNARRio/io.py index 90a7003a9..7f26fe5cc 100755 --- a/ash/knarr/KNARRio/io.py +++ b/ash/knarr/KNARRio/io.py @@ -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 diff --git a/ash/knarr/KNARRjobs/neb.py b/ash/knarr/KNARRjobs/neb.py index 77da3c71c..181b8171e 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) diff --git a/ash/knarr/KNARRjobs/utilities.py b/ash/knarr/KNARRjobs/utilities.py index bc4863612..f44f73f56 100755 --- a/ash/knarr/KNARRjobs/utilities.py +++ b/ash/knarr/KNARRjobs/utilities.py @@ -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)): From fe6c16256b792449c6a1c8cb760901e2a9a8e5a5 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 4 Mar 2026 16:16:20 +0100 Subject: [PATCH 081/134] more knarr numpy 2.4 fixes --- ash/knarr/KNARRcalculator/utilities.py | 8 ++++---- ash/knarr/KNARRio/io.py | 14 +++++++------- ash/knarr/KNARRjobs/neb.py | 2 +- ash/knarr/KNARRjobs/path.py | 9 ++++----- ash/knarr/KNARRjobs/utilities.py | 4 ++-- 5 files changed, 18 insertions(+), 19 deletions(-) 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 7f26fe5cc..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 @@ -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/KNARRjobs/neb.py b/ash/knarr/KNARRjobs/neb.py index 181b8171e..4f4415e97 100755 --- a/ash/knarr/KNARRjobs/neb.py +++ b/ash/knarr/KNARRjobs/neb.py @@ -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 f44f73f56..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 From 5f6d53022bb3484789d2606b3c48f8df9feec32f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 9 Mar 2026 15:46:42 +0100 Subject: [PATCH 082/134] - cleaner cell vectors to params conversion functions - General update_cell method for periodic QM codes: added to xtBTheory, tbliteTheory and CP2KTheory - xTBTheory (inputfile): grabbing cell gradient for periodic calcs - tbliteTheory: cell gradient - CP2KTheory: always write cell vectors to inputfile (not parameters, easier), more consistent cell-gradient handling, conversion of stress tensor to cell gradient - preliminary standalone periodic_optimizer - geometricOptimizer: changes to account for possible PBC optimization feature --- ash/__init__.py | 2 +- ash/functions/functions_optimization.py | 204 ++++++++++++++-------- ash/interfaces/interface_CP2K.py | 100 ++++++++--- ash/interfaces/interface_geometric_new.py | 115 ++++++++---- ash/interfaces/interface_xtb.py | 122 +++++++++++-- ash/modules/module_coords.py | 56 ++++++ 6 files changed, 453 insertions(+), 146 deletions(-) diff --git a/ash/__init__.py b/ash/__init__.py index 7639a2227..7f0faaf18 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -173,7 +173,7 @@ from .modules.module_molcrys import molcrys, Fragmenttype # Geometry optimization -from .functions.functions_optimization import SimpleOpt, BernyOpt +from .functions.functions_optimization import SimpleOpt, BernyOpt, periodic_optimizer from .interfaces.interface_dlfind import DLFIND_optimizer # geomeTRIC interface diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index 95a40b437..7aab8231b 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -5,8 +5,9 @@ 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.modules.module_coords import check_charge_mult, cell_vectors_to_params, cell_params_to_vectors, cart_coords_to_fract, fract_coords_to_cart from ash.modules.module_coords import print_coords_for_atoms +from ash.interfaces.interface_geometric_new import geomeTRICOptimizer #import ash @@ -444,75 +445,132 @@ def BernyOpt(theory,fragment, charge=None, mult=None): blankline() -# Preliminary cell-vector optimization routines -# Takes lattice vectors and stress tensor and return gradient -def get_cell_gradients(lattice_matrix, stress_tensor_gpa, external_pressure_gpa=0.0, return_type='matrix'): - """ - Calculates cell gradients from a stress tensor provided in GPa. - - Parameters: - ----------- - lattice_matrix : np.ndarray (3, 3) - The cell matrix where columns are the lattice vectors [a, b, c] in Angstroms. - stress_tensor_gpa : np.ndarray (3, 3) - The symmetric stress tensor in GPa. - external_pressure_gpa : float - External pressure in GPa (subtracted from the internal stress). - return_type : str - 'matrix' -> returns (3, 3) dE/dH in eV/Angstrom - 'parameters' -> returns list [de_da, de_db, de_dc, de_dalpha, de_dbeta, de_dgamma] - """ - - # 1. Conversion Constant: 1 GPa = 0.006241509 eV/Angstrom^3 - GPA_TO_EV_ANG3 = 1.0 / 160.21766208 - - # 2. Convert Stress and Pressure to eV/Angstrom^3 - sigma_ev = stress_tensor_gpa * GPA_TO_EV_ANG3 - p_ext_ev = external_pressure_gpa * GPA_TO_EV_ANG3 - - # 3. Calculate Volume (Omega) in Angstrom^3 - volume = np.linalg.det(lattice_matrix) - - # 4. Total Stress (Internal Stress + External Pressure) - # Note: Optimization minimizes Enthalpy H = E + PV. - # The gradient dH/dh includes the P_ext term. - sigma_tot = sigma_ev + p_ext_ev * np.eye(3) - - # 5. Matrix Gradient: dE/dH = Volume * Stress * (H^-1).T - # Units: [Ang^3] * [eV/Ang^3] * [1/Ang] = eV/Ang - inv_h_t = np.linalg.inv(lattice_matrix).T - de_dh = volume * np.dot(sigma_tot, inv_h_t) - - if return_type == 'matrix': - return de_dh - - elif return_type == 'parameters': - # Extract vectors and lengths - a_vec, b_vec, c_vec = lattice_matrix[:, 0], lattice_matrix[:, 1], lattice_matrix[:, 2] - a, b, c = np.linalg.norm(a_vec), np.linalg.norm(b_vec), np.linalg.norm(c_vec) - - # dE/dL (Length gradients in eV/Angstrom) - de_da = np.dot(de_dh[:, 0], a_vec / a) - de_db = np.dot(de_dh[:, 1], b_vec / b) - de_dc = np.dot(de_dh[:, 2], c_vec / c) - - # Helper for angle gradients (units: eV/radian) - def get_angle_grad(v1, v2, g1, g2): - n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2) - cos_theta = np.clip(np.dot(v1, v2) / (n1 * n2), -1.0, 1.0) - sin_theta = np.sqrt(1.0 - cos_theta**2) - - if sin_theta < 1e-8: - return 0.0 - - # Chain rule: dE/dTheta - dtheta_dv1 = (cos_theta * v1 / n1**2 - v2 / (n1 * n2)) / sin_theta - dtheta_dv2 = (cos_theta * v2 / n2**2 - v1 / (n1 * n2)) / sin_theta - - return np.dot(g1, dtheta_dv1) + np.dot(g2, dtheta_dv2) - - de_dalpha = get_angle_grad(b_vec, c_vec, de_dh[:, 1], de_dh[:, 2]) - de_dbeta = get_angle_grad(a_vec, c_vec, de_dh[:, 0], de_dh[:, 2]) - de_dgamma = get_angle_grad(a_vec, b_vec, de_dh[:, 0], de_dh[:, 1]) - - return [de_da, de_db, de_dc, de_dalpha, de_dbeta, de_dgamma] \ No newline at end of file +# PERIODIC OPTIMIZERS + +# Very basic stupid one +def simple_periodic_optimizer_SD(fragment=None, theory=None, rate=0.5, maxiter=50): + ang2bohr=1.88972612546 + print("Learning rate:", rate) + print("maxiter:", maxiter) + for i in range(0,maxiter): + print("="*40) + print("Cell optimization step", i) + print("="*40) + # Only optimize atom coordinates + res = geomeTRICOptimizer(theory=theory, fragment=fragment) + print("cell vector step:") + # Take cell vector step + cell_vectors_au = theory.periodic_cell_vectors*ang2bohr - (rate * theory.cell_gradient) + cell_vectors = cell_vectors_au / ang2bohr + print("cell_vectors:", cell_vectors) + # Update Theory with new cell vectors + theory.update_cell(periodic_cell_vectors=cell_vectors) + print("theory.periodic_cell_vectors:", theory.periodic_cell_vectors) + +# More advanced periodic cell optimizer +def periodic_optimizer(fragment=None, theory=None, rate=0.5, maxiter=50, tol=1e-3, step_algo="SD", + force_orthorhombic=True, max_step=0.1, momentum=0.5): + 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 + + # Looping + velocity = np.zeros((3, 3)) + 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") + res = geomeTRICOptimizer(theory=theory, fragment=fragment) + + # Check convergence of cell gradient + grad_norm = np.linalg.norm(theory.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") + + # TODO: File-handling. Write POSCAR file or something else? + 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 =="SD": + print("Doing steepest descent step") + delta_au = - (rate * theory.cell_gradient) + elif step_algo == "damped-MD": + print("Doing momentum step") + print("velocity:", velocity) + velocity = (momentum * velocity) - (rate * theory.cell_gradient) + print("velocity:", velocity) + delta_au = velocity + elif step_algo == "nesterov": + # Storing old + velocity_old = velocity.copy() + print("Doing Nesterov momentum step") + velocity = (momentum * velocity) - (rate * theory.cell_gradient) + nesterov_update = -momentum * velocity_old + (1 + momentum) * velocity + delta_au = nesterov_update + elif step_algo == "cg": + print("Doing conjugate gradient step") + if i == 0: + search_dir = theory.cell_gradient + else: + # Polak-Ribière formula for beta + diff = theory.cell_gradient - prev_grad + beta = np.sum(theory.cell_gradient * diff) / np.sum(prev_grad * prev_grad) + beta = max(0, beta) # Standard 'reset' for CG + search_dir = theory.cell_gradient + (beta * search_dir) + + delta_au = - (rate * search_dir) + prev_grad = theory.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 Ang + cell_vectors = cell_vectors_au / ang2bohr + print("cell_vectors:", cell_vectors) + # Update Theory with new cell vectors in Ang + 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 \ No newline at end of file diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index c2b3c596d..f0d25017c 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -39,7 +39,7 @@ 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, stress_tensor=False, stress_tensor_algo="DIAGONAL_ANALYTICAL", - xtb_periodic=False, xtb_type='GFN2', xtb_tblite=False, + 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, @@ -78,33 +78,47 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel else: print("xtb_tblite False. Using built-in version of xTB.") print("xtb_type:", xtb_type) - print("xtb_periodic:", xtb_periodic) # 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 + from ash.modules.module_coords import cell_params_to_vectors + self.periodic_cell_vectors = cell_params_to_vectors(cell_dimensions) + elif cell_vectors is not None: + self.periodic_cell_vectors = cell_vectors + from ash.modules.module_coords import cell_vectors_to_params + 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: @@ -187,7 +201,6 @@ 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) @@ -247,7 +260,6 @@ 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) @@ -275,9 +287,24 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel #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 + + from ash.modules.module_coords import cell_vectors_to_params + 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 + + from ash.modules.module_coords import cell_params_to_vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + # 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, @@ -326,7 +353,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") @@ -353,7 +380,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)") @@ -380,9 +407,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={} @@ -406,7 +433,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, @@ -416,9 +443,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el coordfile=system_xyzfile, stress_tensor=self.stress_tensor, stress_tensor_algo=self.stress_tensor_algo, user_input_dft=self.user_input_dft, vdwpotential=self.vdwpotential, - cell_dimensions=self.cell_dimensions, kpoint_settings=self.kpoint_settings, - cell_vectors=self.cell_vectors, + 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_type=self.xtb_type, xtb_tblite=self.xtb_tblite, basis_file=self.basis_file, @@ -467,8 +493,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el scf_maxiter=self.scf_maxiter, outer_scf_maxiter=self.outer_scf_maxiter, periodic_type=self.periodic_type, xtb_periodic=self.xtb_periodic, xtb_type=self.xtb_type,xtb_tblite=self.xtb_tblite, - cell_dimensions=self.cell_dimensions, - cell_vectors=self.cell_vectors, + cell_vectors=self.periodic_cell_vectors, basis_file=self.basis_file, potential_file=self.potential_file, psolver=self.psolver, printlevel=self.printlevel, OT=self.OT, OT_minimizer=self.OT_minimizer, OT_preconditioner=self.OT_preconditioner, OT_linesearch=self.OT_linesearch, @@ -484,7 +509,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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) + #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: @@ -508,7 +533,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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) + #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) @@ -531,7 +556,10 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # Grab stress tensor if self.stress_tensor is True: self.stress = get_stress_tensor(f"ash-{self.filename}-1_0.stress_tensor") - print("stress:", self.stress) + 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)) @@ -592,7 +620,7 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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, + 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, @@ -715,8 +743,8 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=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 + 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 @@ -822,10 +850,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 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') - elif cell_vectors is not 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') @@ -993,3 +1021,21 @@ def get_stress_tensor(file): 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_geometric_new.py b/ash/interfaces/interface_geometric_new.py index df98daeaa..aa159add1 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -26,7 +26,8 @@ def geomeTRICOptimizer(theory=None, fragment=None, charge=None, mult=None, coord 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, + periodic_cell_opt=False): """ Wrapper function around GeomeTRICOptimizerClass """ @@ -37,13 +38,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, + periodic_cell_opt=periodic_cell_opt) # If NumGrad then we wrap theory object into NumGrad class object if NumGrad: @@ -66,12 +68,12 @@ 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, + periodic_cell_opt=False): self.printlevel=printlevel print_line_with_mainheader("geomeTRICOptimizer initialization") print_if_level("Creating optimizer object", self.printlevel,2) - ############################### # Going through user options ############################### @@ -105,8 +107,18 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', self.TSOpt=TSOpt self.subfrctor=subfrctor - #IRC - self.irc=irc + # PBC + if getattr(theory, "periodic", False): + print("Detected periodicity in Theory object") + print("Activating periodic routines ") + self.PBC=True + if periodic_cell_opt is True: + print("Periodic cell optimization activated by keyword") + else: + print("Theory is not periodic") + self.PBC=False + # IRC + self.irc = irc # Rigid opt self.rigid=rigid # Enforce constraints option @@ -572,7 +584,7 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No 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) + maxiter=self.maxiter, PBC=self.PBC) # Defining args object, containing engine object final_geometric_args=geomeTRICArgsObject(ashengine,self.constraintsfile,coordsys=self.coordsystem, @@ -685,7 +697,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 @@ -714,6 +726,9 @@ def __init__(self,geometric_molf, theory, ActiveRegion=False, actatoms=None,prin self.fragment=fragment self.printlevel=printlevel + # PBC + self.PBC=PBC + def load_guess_files(self,dirname): if self.printlevel >= 1: print("geometric called load_guess_files option for ASHengineclass.") @@ -789,7 +804,6 @@ 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 @@ -798,6 +812,20 @@ def calc(self,coords,tmp, read_data=None, copydir=None): #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") + exit() + 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 @@ -878,31 +906,52 @@ def calc(self,coords,tmp, read_data=None, copydir=None): self.iteration_count=int(iteration) self.EG_count += 1 + return {'energy': E, 'gradient': Grad_act.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=step_lines[-1].split()[1] + self.iteration_count=int(iteration) + self.EG_count += 1 + self.energy = E + return {'energy': E, 'gradient': Grad.flatten()} + + # Test + def PBC_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=step_lines[-1].split()[1] + self.iteration_count=int(iteration) + self.EG_count += 1 + self.energy = E + return {'energy': E, 'gradient': Grad.flatten()} - 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) - # 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=step_lines[-1].split()[1] - self.iteration_count=int(iteration) - self.EG_count += 1 - self.energy = E - return {'energy': E, 'gradient': Grad.flatten()} #Function Convert constraints indices to actatom indices diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 246b81445..865d05c91 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -110,7 +110,8 @@ 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.theorytype="QM" @@ -145,10 +146,26 @@ def __init__(self, xtbdir=None, xtbmethod='GFN1', runmode='inputfile', numcores= 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 + from ash.modules.module_coords import cell_params_to_vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + elif periodic_cell_vectors is not None: + from ash.modules.module_coords import cell_vectors_to_params + self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) # Controlling output in xtb-library if self.printlevel >= 3: @@ -301,8 +318,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) @@ -418,6 +434,21 @@ def Opt(self, fragment=None, Grad=None, Hessian=None, numcores=None, label=None, #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 + + from ash.modules.module_coords import cell_vectors_to_params + 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 + + from ash.modules.module_coords import cell_params_to_vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + 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() @@ -508,9 +539,12 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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) @@ -1042,6 +1076,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"): @@ -1099,7 +1148,8 @@ def grab_bondorder_matrix(numatoms): # 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): + 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) @@ -1135,6 +1185,32 @@ def __init__(self, method=None, printlevel=2, numcores=1, spinpol=False, solvati 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 + from ash.modules.module_coords import cell_params_to_vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) + else: + self.periodic_cell_vectors = periodic_cell_vectors + from ash.modules.module_coords import cell_vectors_to_params + 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: @@ -1145,7 +1221,19 @@ def __init__(self, method=None, printlevel=2, numcores=1, spinpol=False, solvati 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 + + from ash.modules.module_coords import cell_vectors_to_params + 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 + from ash.modules.module_coords import cell_params_to_vectors + self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) 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, @@ -1154,8 +1242,6 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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) @@ -1175,7 +1261,12 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # Creating xtb calculator object # TODO: Update object instead of creating new every time - xtb = tb.Calculator(self.method, qm_elems_numbers, coords_au, charge=charge, uhf=mult-1) + 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) @@ -1208,8 +1299,16 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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") @@ -1224,7 +1323,6 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el if Grad: self.gradient = self.results.get("gradient") - if Grad: print_time_rel(module_init_time, modulename='tblite run', moduleindex=2) return self.energy, self.gradient diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index ca41ed5af..fff8836f5 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -4203,3 +4203,59 @@ def define_dummy_topology(elems,scale=1.0, tol=0.1, resname="MOL"): atomname = f"{el}{atomnames_dict[el]}" pdb_topology.addAtom(atomname, element, residue) return pdb_topology + + +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]]) + print("vectors:", vectors) + 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(coords,cell_vectors): + h_inv = np.linalg.inv(cell_vectors) + fract_coords = np.dot(coords, h_inv.T) + return fract_coords + +def fract_coords_to_cart(fract_coords,cell_vectors): + cart_coords = np.dot(fract_coords, cell_vectors.T) + return cart_coords \ No newline at end of file From 7e560e336ee7379829cc0a96330f769b35156756 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sun, 15 Mar 2026 19:06:08 +0100 Subject: [PATCH 083/134] - fix for flake8 linting error - oniom fix: get_MMboundary (following similar restructure in QMMMTheory) - NEW Cartesian-based PBC-cell optimizers: Periodic_optimizer_cart, periodic_optimizer_alternating - geomeTRICOptimizer: now support for PBC (note: use hdlc) - CP2K: center_coords only True for wavelet not PBC, xc_finer_grid keyword, some small bugfixes --- ash/__init__.py | 10 +- ash/functions/functions_optimization.py | 428 ++++++++++++++++++++-- ash/interfaces/interface_CP2K.py | 19 +- ash/interfaces/interface_geometric_new.py | 266 ++++++++++++-- ash/modules/module_coords.py | 10 +- ash/modules/module_oniom.py | 19 +- job.py | 7 + 7 files changed, 678 insertions(+), 81 deletions(-) create mode 100644 job.py diff --git a/ash/__init__.py b/ash/__init__.py index 7f0faaf18..334fdef4f 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 @@ -152,7 +155,7 @@ 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 #VMD @@ -173,8 +176,7 @@ from .modules.module_molcrys import molcrys, Fragmenttype # Geometry optimization -from .functions.functions_optimization import SimpleOpt, BernyOpt, periodic_optimizer -from .interfaces.interface_dlfind import DLFIND_optimizer +from .functions.functions_optimization import SimpleOpt, BernyOpt, periodic_optimizer_alternating, Periodic_optimizer_cart # geomeTRIC interface from .interfaces.interface_geometric_new import geomeTRICOptimizer,GeomeTRICOptimizerClass @@ -242,3 +244,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/functions/functions_optimization.py b/ash/functions/functions_optimization.py index 7aab8231b..b3083c9ae 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -3,11 +3,12 @@ import os import ash.constants -from ash.functions.functions_general import ashexit, blankline,print_time_rel_and_tot,BC,listdiff +from ash.functions.functions_general import ashexit, blankline,print_time_rel_and_tot,BC,listdiff,print_time_rel from ash.modules.module_coords import write_xyzfile -from ash.modules.module_coords import check_charge_mult, cell_vectors_to_params, cell_params_to_vectors, cart_coords_to_fract, fract_coords_to_cart +from ash.modules.module_coords import check_charge_mult, cell_vectors_to_params, cell_params_to_vectors, cart_coords_to_fract, fract_coords_to_cart, cell_volume from ash.modules.module_coords import print_coords_for_atoms from ash.interfaces.interface_geometric_new import geomeTRICOptimizer +from ash.modules.module_theory import NumGradclass #import ash @@ -257,8 +258,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 @@ -444,32 +443,14 @@ def BernyOpt(theory,fragment, charge=None, mult=None): fragment.replace_coords(fragment.elems,geom.coords) blankline() - +############################# # PERIODIC OPTIMIZERS - -# Very basic stupid one -def simple_periodic_optimizer_SD(fragment=None, theory=None, rate=0.5, maxiter=50): - ang2bohr=1.88972612546 - print("Learning rate:", rate) - print("maxiter:", maxiter) - for i in range(0,maxiter): - print("="*40) - print("Cell optimization step", i) - print("="*40) - # Only optimize atom coordinates - res = geomeTRICOptimizer(theory=theory, fragment=fragment) - print("cell vector step:") - # Take cell vector step - cell_vectors_au = theory.periodic_cell_vectors*ang2bohr - (rate * theory.cell_gradient) - cell_vectors = cell_vectors_au / ang2bohr - print("cell_vectors:", cell_vectors) - # Update Theory with new cell vectors - theory.update_cell(periodic_cell_vectors=cell_vectors) - print("theory.periodic_cell_vectors:", theory.periodic_cell_vectors) - -# More advanced periodic cell optimizer -def periodic_optimizer(fragment=None, theory=None, rate=0.5, maxiter=50, tol=1e-3, step_algo="SD", - force_orthorhombic=True, max_step=0.1, momentum=0.5): +############################# +# 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) @@ -485,16 +466,19 @@ def periodic_optimizer(fragment=None, theory=None, rate=0.5, maxiter=50, tol=1e- 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") - res = geomeTRICOptimizer(theory=theory, fragment=fragment) + # 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 grad_norm = np.linalg.norm(theory.cell_gradient) @@ -533,6 +517,7 @@ def periodic_optimizer(fragment=None, theory=None, rate=0.5, maxiter=50, tol=1e- print("Doing conjugate gradient step") if i == 0: search_dir = theory.cell_gradient + prev_grad=None else: # Polak-Ribière formula for beta diff = theory.cell_gradient - prev_grad @@ -563,14 +548,389 @@ def periodic_optimizer(fragment=None, theory=None, rate=0.5, maxiter=50, tol=1e- # Take step cell_vectors_au += delta_au - # Convert final cell vectors from Bohrs to Ang + # Convert final cell vectors from Bohrs to Å cell_vectors = cell_vectors_au / ang2bohr - print("cell_vectors:", cell_vectors) - # Update Theory with new cell vectors in Ang + 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 \ No newline at end of file + fragment.coords=new_cart_coords + +# Cartesian-based periodic cell optimizer + + +# Wrapper function around Periodic_optimizer_cart_class +def Periodic_optimizer_cart(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): + """ + Wrapper function around Periodic_optimizer_cart_class + """ + timeA=time.time() + + # EARLY EXIT + if theory is None or fragment is None: + print("Periodic_optimizer_cart requires theory and fragment objects provided. Exiting.") + ashexit() + optimizer=Periodic_optimizer_cart_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, + printlevel=printlevel, conv_criteria=conv_criteria) + + result = optimizer.run() + if printlevel >= 1: + print_time_rel(timeA, modulename='Periodic_optimizer_cart', moduleindex=1) + + return result + + +class Periodic_optimizer_cart_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): + + 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.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 + print("Convergence criteria:", self.conv_criteria) + # Max step in bohrs (default = 0.1 Å = 0.188 bohrs) + self.max_step_au = max_step*self.ang2bohr + + 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() + print(f"Initial cell vectors in Theory object: {theory.periodic_cell_vectors} Å") + + self.cell_vectors_au = theory.periodic_cell_vectors*self.ang2bohr + self.cell_vectors = theory.periodic_cell_vectors + self.elems_phys=fragment.elems + + ################ + # INITIAL STUFF + ################ + # 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() + print("H_ref:",self. H_ref) + self.H_ref_inv = np.linalg.inv(self.H_ref) + print("H_ref_inv:", self.H_ref_inv) + + 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_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 + # 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): + # 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 + # 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): + + # Defining initial super coords + currcoords = np.concatenate([ + self.fragment.coords, # (N, 3) + np.zeros((1, 3)), # (1, 3) + self.theory.periodic_cell_vectors, # (3, 3) + ], axis=0) + print("currcoords:", currcoords) + + self.velocity = np.zeros((len(currcoords),3)) + + try: + os.remove("PBC_opt_traj.xyz") + except: + pass + + # LOOP + for iteration in range(0,self.maxiter): + self.iteration=iteration + print("="*40) + print("Periodic optimization step", iteration) + print("="*40) + + ######################################### + # 0. Splitting currcoords into atoms and lattice + # Update and print + ######################################### + currcoords_au = currcoords*self.ang2bohr + R_phys, H_geo = self.split_coords(currcoords) + + # Update coordinates of atoms and cell + self.theory.update_cell(H_geo) + 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="PBC_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("") + print(f"Current cell vectors (Å):{self.theory.periodic_cell_vectors}") + print(f"Current cell volume (Å):{cell_volume(H_geo)}") + + ######################################### + # 1. Compute energy and gradient + ######################################### + energy, supergradient = self.calculate_supergradient(currcoords) + + ######################################### + # 2. Check convergence of cell gradient + ######################################### + #grad_norm = np.linalg.norm(supergradient) + #grad_norm_atoms = np.linalg.norm(supergradient[:-4]) + #grad_norm_atoms_cell = np.linalg.norm(supergradient[-3:]) + #grad_rms = np.sqrt(np.mean(supergradient**2)) + grad_rms_atoms = np.sqrt(np.mean(supergradient[:-4]**2)) + grad_rms_cell = np.sqrt(np.mean(supergradient[-3:]**2)) + #grad_max = abs(max(supergradient.min(), supergradient.max(), key=abs)) + grad_max_atoms = abs(max(supergradient[:-4].min(), supergradient[:-4].max(), key=abs)) + grad_max_cell = abs(max(supergradient[-3:].min(), supergradient[-3:].max(), key=abs)) + #print(f"Current total Gradient Norm: {grad_norm:.6f}") + + 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(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(f"Final energy: {energy} Eh") + break + + ######################################### + # 3. Take step + ######################################### + + # Compute step + delta_au = self.compute_step(supergradient,currcoords) + print("Computed step:", delta_au) + + # 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(f"Lattice-specific scaling applied: {scale_latt:.3f}") + + # Scale down step if required + if np.max(np.abs(delta_au)) > self.max_step_au: + print(f"Step scale down: {np.max(np.abs(delta_au))} > max_step_au: {self.max_step_au})") + delta_au = delta_au * (self.max_step_au / np.max(np.abs(delta_au))) + print("Actual step:", delta_au) + + # Take the step + currcoords_au += delta_au + + # Converting coordinates from Bohr to Angstrom + currcoords = currcoords_au / self.ang2bohr + + 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_CP2K.py b/ash/interfaces/interface_CP2K.py index f0d25017c..8f4e54817 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -44,10 +44,11 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel 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): @@ -229,6 +230,7 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel #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 @@ -283,7 +285,6 @@ 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 @@ -454,7 +455,7 @@ 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: @@ -491,11 +492,12 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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, 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) @@ -627,7 +629,7 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, 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, @@ -774,6 +776,11 @@ def write_CP2K_input(method='QUICKSTEP', jobname='ash-CP2K', center_coords=True, #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') diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index aa159add1..332a458a6 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -8,7 +8,7 @@ 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.modules.module_coords import print_coords_for_atoms,print_internal_coordinate_table,write_XYZ_for_atoms,write_xyzfile,write_coords_all,fract_coords_to_cart,cart_coords_to_fract, cell_volume 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_coords import check_charge_mult, fullindex_to_actindex from ash.modules.module_freq import write_hessian,calc_hessian_xtb, approximate_full_Hessian_from_smaller, read_hessian @@ -18,16 +18,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, - periodic_cell_opt=False): + periodic_cell_opt=False, force_noPBC=False): """ Wrapper function around GeomeTRICOptimizerClass """ @@ -45,7 +43,7 @@ def geomeTRICOptimizer(theory=None, fragment=None, charge=None, mult=None, coord 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, - periodic_cell_opt=periodic_cell_opt) + periodic_cell_opt=periodic_cell_opt, force_noPBC=force_noPBC) # If NumGrad then we wrap theory object into NumGrad class object if NumGrad: @@ -69,7 +67,7 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', 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, - periodic_cell_opt=False): + periodic_cell_opt=False, force_noPBC=False): self.printlevel=printlevel print_line_with_mainheader("geomeTRICOptimizer initialization") @@ -107,16 +105,6 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', self.TSOpt=TSOpt self.subfrctor=subfrctor - # PBC - if getattr(theory, "periodic", False): - print("Detected periodicity in Theory object") - print("Activating periodic routines ") - self.PBC=True - if periodic_cell_opt is True: - print("Periodic cell optimization activated by keyword") - else: - print("Theory is not periodic") - self.PBC=False # IRC self.irc = irc # Rigid opt @@ -142,6 +130,23 @@ 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 + if force_noPBC is True: + print("force_noPBC is True. Turning off PBC") + self.PBC=False + + if periodic_cell_opt is True: + print("Periodic cell optimization activated by keyword") + #self.constraintsfile="constraints.txt" + else: + print("Theory is not periodic") + self.PBC=False + + ###################### #SOME PRINTING of settings ###################### @@ -258,12 +263,17 @@ def define_constraints(self,constraints): dihedralconstraints = constraints['dihedral'] except: dihedralconstraints = None + try: + xyzconstraints = constraints['xyz'] + except: + xyzconstraints = None else: bondconstraints=None angleconstraints=None dihedralconstraints=None + xyzconstraints=None - return bondconstraints, angleconstraints, dihedralconstraints + return bondconstraints, angleconstraints, dihedralconstraints,xyzconstraints def write_constraintsfile(self,frozenatoms,bondconstraints,constrainvalue,angleconstraints,dihedralconstraints): if self.printlevel >= 1: @@ -529,10 +539,12 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No print("constrainvalue: ", constrainvalue) #Getting specific constraints and writing to file - bondconstraints, angleconstraints, dihedralconstraints = self.define_constraints(constraints) + bondconstraints, angleconstraints, dihedralconstraints,xyzconstraints = 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) - if self.constraintsinputfile is not None: print("constraintsinputfile provided:", self.constraintsinputfile) if os.path.isfile(self.constraintsinputfile) is False: @@ -576,7 +588,19 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No print("Actual error message:", e) ashexit(code=9) + # 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. @@ -585,11 +609,15 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No 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, 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) @@ -658,7 +686,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 @@ -667,6 +696,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") @@ -702,6 +732,7 @@ def __init__(self,geometric_molf, theory, ActiveRegion=False, actatoms=None,prin self.MM_PDB_traj_write=MM_PDB_traj_write #Defining M attribute of engine object as geomeTRIC Molecule object self.M=geometric_molf + print("self.M:", self.M.__dict__) #Defining theory from argument self.theory=theory self.ActiveRegion=ActiveRegion @@ -726,8 +757,46 @@ 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 = self.align_to_standard_orientation(self.fragment.coords, theory.periodic_cell_vectors) + print("aligned_atom_coords:",aligned_atom_coords) + print("aligned_vectors:",aligned_vectors) + self.fragment.coords=aligned_atom_coords + self.theory.update_cell(aligned_vectors) + + # Reference + self.H_ref = aligned_vectors.copy() + print("self.H_ref:",self.H_ref) + self.H_ref_inv = np.linalg.inv(self.H_ref) + print("self.H_ref_inv:", self.H_ref_inv) + + + # 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)] + print("1self.M:", self.M.__dict__) + self.M.elem = self.M.elem + ['F','F','F','F'] + print("len self.M.elem", len(self.M.elem)) + #exit() + # Write constraints + # N is 0-indexed count, but geomeTRIC wants 1-based indices + #n_orig = len(self.elems_phys) + 1 + #n_a = len(self.elems_phys) + 2 + #n_b = len(self.elems_phys) + 3 + #constraints = "$freeze\n" + #constraints += f"xyz {n_orig}\n" # Freeze Origin (X,Y,Z) + #constraints += f"yz {n_a} \n" # Freeze a_y and a_z + #constraints += f"z {n_b} \n" # Freeze b_z + #with open("constraints.txt", "w") as f: + # f.write(constraints) + #print("Wrote constraints file") def load_guess_files(self,dirname): if self.printlevel >= 1: @@ -743,8 +812,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): @@ -813,12 +916,20 @@ def calc(self,coords,tmp, read_data=None, copydir=None): timeA=time.time() currcoords=self.M.xyzs[0] + #print("WE ARE INSIDE CALC. we are going to verify gradient") + #self.verify_gradient(currcoords) + #print("done") + #exit() + + + + + # Call method to use if self.ActiveRegion is True: egdict = self.actregion_calc(currcoords) elif self.PBC is True: print("Doing PBC opt-step") - exit() egdict =self.PBC_calc(currcoords) else: egdict = self.regular_calc(currcoords) @@ -930,27 +1041,118 @@ def regular_calc(self,currcoords): self.energy = E return {'energy': E, 'gradient': Grad.flatten()} - # Test def PBC_calc(self,currcoords): - self.full_current_coords=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(currcoords, self.fragment.elems, self.print_atoms_list) + 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) ") - E,Grad=self.theory.run(current_coords=currcoords, elems=self.M.elem, charge=self.charge, mult=self.mult, Grad=True) + 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=step_lines[-1].split()[1] self.iteration_count=int(iteration) - self.EG_count += 1 - self.energy = E - return {'energy': E, 'gradient': Grad.flatten()} + + # 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 + # 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()} + + 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 diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index fff8836f5..a08c0bf8e 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -4258,4 +4258,12 @@ def cart_coords_to_fract(coords,cell_vectors): def fract_coords_to_cart(fract_coords,cell_vectors): cart_coords = np.dot(fract_coords, cell_vectors.T) - return cart_coords \ No newline at end of file + return cart_coords + +def cell_volume(vectors): + a = vectors[0,:] + b = vectors[1,:] + c = vectors[2,:] + V = abs(np.dot(a, np.cross(b, c))) + + return V \ No newline at end of file diff --git a/ash/modules/module_oniom.py b/ash/modules/module_oniom.py index 5f1016c41..a82724166 100644 --- a/ash/modules/module_oniom.py +++ b/ash/modules/module_oniom.py @@ -378,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()) 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 From a6fcfa41c04c0394b2834188f71e5b456ddba7e3 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 16 Mar 2026 15:31:18 +0100 Subject: [PATCH 084/134] - CP2K: stress tensor true by default - geomeTRICOptimizer: removed unused periodic_cell_opt for now, automatically switching coordinate system to HDLC - calc_surface: force_noPBC option - module_coords: writers for POSCAR, XSF and CIF (to be documented) --- ash/functions/functions_optimization.py | 10 +- ash/interfaces/interface_CP2K.py | 2 +- ash/interfaces/interface_geometric_new.py | 12 +-- ash/modules/module_coords.py | 117 +++++++++++++++++++++- ash/modules/module_surface.py | 24 ++--- 5 files changed, 136 insertions(+), 29 deletions(-) diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index b3083c9ae..c71781328 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -487,8 +487,6 @@ def periodic_optimizer_alternating(fragment=None, theory=None, rate=0.5, maxiter 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") - - # TODO: File-handling. Write POSCAR file or something else? break # Convert previously optimized Cart coords to Fract coords @@ -497,23 +495,23 @@ def periodic_optimizer_alternating(fragment=None, theory=None, rate=0.5, maxiter print("b) Will now take cell vector step") # Calculate cell vector step (in Bohrs) - if step_algo =="SD": + if step_algo.lower() =="sd": print("Doing steepest descent step") delta_au = - (rate * theory.cell_gradient) - elif step_algo == "damped-MD": + elif step_algo.lower() == "damped-MD": print("Doing momentum step") print("velocity:", velocity) velocity = (momentum * velocity) - (rate * theory.cell_gradient) print("velocity:", velocity) delta_au = velocity - elif step_algo == "nesterov": + elif step_algo.lower() == "nesterov": # Storing old velocity_old = velocity.copy() print("Doing Nesterov momentum step") velocity = (momentum * velocity) - (rate * theory.cell_gradient) nesterov_update = -momentum * velocity_old + (1 + momentum) * velocity delta_au = nesterov_update - elif step_algo == "cg": + elif step_algo.lower() == "cg": print("Doing conjugate gradient step") if i == 0: search_dir = theory.cell_gradient diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index 8f4e54817..0f14f3b14 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -38,7 +38,7 @@ # '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, stress_tensor=False, stress_tensor_algo="DIAGONAL_ANALYTICAL", + 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, diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index 332a458a6..0c2a3a2d5 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -25,7 +25,7 @@ def geomeTRICOptimizer(theory=None, fragment=None, charge=None, mult=None, coord 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, - periodic_cell_opt=False, force_noPBC=False): + force_noPBC=False): """ Wrapper function around GeomeTRICOptimizerClass """ @@ -43,7 +43,7 @@ def geomeTRICOptimizer(theory=None, fragment=None, charge=None, mult=None, coord 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, - periodic_cell_opt=periodic_cell_opt, force_noPBC=force_noPBC) + force_noPBC=force_noPBC) # If NumGrad then we wrap theory object into NumGrad class object if NumGrad: @@ -67,7 +67,7 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', 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, - periodic_cell_opt=False, force_noPBC=False): + force_noPBC=False): self.printlevel=printlevel print_line_with_mainheader("geomeTRICOptimizer initialization") @@ -135,13 +135,13 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', print("Detected periodicity in Theory object") print("Activating periodic routines ") self.PBC=True + print("Switching coordsystem to hdlc") + self.coordsystem="hdlc" + if force_noPBC is True: print("force_noPBC is True. Turning off PBC") self.PBC=False - if periodic_cell_opt is True: - print("Periodic cell optimization activated by keyword") - #self.constraintsfile="constraints.txt" else: print("Theory is not periodic") self.PBC=False diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index a08c0bf8e..ab48ddb92 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -782,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: @@ -1721,9 +1722,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) @@ -4226,7 +4227,6 @@ def cell_params_to_vectors(parameters): cz = np.sqrt(c**2 - cx**2 - cy**2) vectors = np.array([[ax,ay,az],[bx,by,bz],[cx,cy,cz]]) - print("vectors:", vectors) return vectors def cell_vectors_to_params(vectors): @@ -4266,4 +4266,113 @@ def cell_volume(vectors): c = vectors[2,:] V = abs(np.dot(a, np.cross(b, c))) - return V \ No newline at end of file + 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") + +# 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}") + + +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}") \ No newline at end of file diff --git a/ash/modules/module_surface.py b/ash/modules/module_surface.py index 24c3a6b1e..232b89744 100644 --- a/ash/modules/module_surface.py +++ b/ash/modules/module_surface.py @@ -24,7 +24,7 @@ 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): """Calculate 1D/2D surface @@ -185,7 +185,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) #Shallow copy of fragment newfrag = copy.copy(fragment) newfrag.label = (RCvalue1,RCvalue2) @@ -223,7 +223,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) #Shallow copy of fragment newfrag = copy.copy(fragment) #newfrag.label = str(RCvalue1)+"_"+str(RCvalue2) @@ -244,7 +244,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) print("Warning: Relaxed scans in parallel mode are experimental") ########################### # PARALLEL: RELAXED: DIM 2 @@ -360,7 +360,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, - 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) # Write to trajectory fragment.write_xyzfile(xyzfilename="surface_traj.xyz", writemode='a') @@ -406,7 +406,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, - 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) # Write to trajectory fragment.write_xyzfile(xyzfilename="surface_traj.xyz", writemode='a') @@ -455,7 +455,7 @@ 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) energy = result.energy print("RCvalue1: {} RCvalue2: {} Energy: {}".format(RCvalue1,RCvalue2, energy)) if theory.theorytype == "QM": @@ -502,7 +502,7 @@ 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) energy = result.energy print("RCvalue1: {} Energy: {}".format(RCvalue1, energy)) if theory.theorytype == "QM": @@ -560,7 +560,7 @@ def combine_xyzfiles_in_directory(xyzdir, outputfilename): # 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") @@ -790,7 +790,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')) @@ -855,7 +855,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") @@ -914,7 +914,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") From 8b040eecb4e12ef4f2a351ac9970ebe5342b8855 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 16 Mar 2026 20:12:22 +0100 Subject: [PATCH 085/134] - geomeTRICOptimizer: write PBC files at end of opt. PBC_format_option: (XSF,CIF or POSCAR) - calc_surface: support for PBC in constrained optimizations, writes PBC-files (XSF,CIF or POSCAR) --- ash/interfaces/interface_geometric_new.py | 31 +++++++-- ash/modules/module_coords.py | 5 +- ash/modules/module_surface.py | 82 ++++++++++++++++++----- 3 files changed, 96 insertions(+), 22 deletions(-) diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index 0c2a3a2d5..deb6971aa 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -10,7 +10,7 @@ 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,fract_coords_to_cart,cart_coords_to_fract, cell_volume 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_coords import check_charge_mult, fullindex_to_actindex +from ash.modules.module_coords import check_charge_mult, fullindex_to_actindex, cell_vectors_to_params, write_CIF_file, write_XSF_file, write_POSCAR_file 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 @@ -25,7 +25,7 @@ def geomeTRICOptimizer(theory=None, fragment=None, charge=None, mult=None, coord 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, - force_noPBC=False): + force_noPBC=False, PBC_format_option='CIF'): """ Wrapper function around GeomeTRICOptimizerClass """ @@ -43,7 +43,7 @@ def geomeTRICOptimizer(theory=None, fragment=None, charge=None, mult=None, coord 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, - force_noPBC=force_noPBC) + force_noPBC=force_noPBC, PBC_format_option=PBC_format_option) # If NumGrad then we wrap theory object into NumGrad class object if NumGrad: @@ -67,7 +67,7 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', 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, - force_noPBC=False): + force_noPBC=False, PBC_format_option='CIF'): self.printlevel=printlevel print_line_with_mainheader("geomeTRICOptimizer initialization") @@ -135,13 +135,14 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', 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("force_noPBC is True. Turning off PBC") + print("Warning: force_noPBC set to True. Turning off PBC") self.PBC=False - else: print("Theory is not periodic") self.PBC=False @@ -665,6 +666,24 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') fragment.set_energy(finalenergy) + # 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") diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index ab48ddb92..2ba265948 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -4300,6 +4300,7 @@ def write_POSCAR_file(coords,elems,cellvectors=None, celldimensions=None, filena 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"): @@ -4331,6 +4332,7 @@ def write_XSF_file(coords, elems, cellvectors=None, celldimensions=None, filenam 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"): @@ -4375,4 +4377,5 @@ def write_CIF_file(coords, elems, cellvectors=None, celldimensions=None, filenam # 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}") \ No newline at end of file + print(f"Wrote CIF file: {filename}") + return filename \ No newline at end of file diff --git a/ash/modules/module_surface.py b/ash/modules/module_surface.py index 232b89744..c14d7ff43 100644 --- a/ash/modules/module_surface.py +++ b/ash/modules/module_surface.py @@ -15,7 +15,7 @@ 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 @@ -26,7 +26,7 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U keepoutputfiles=True, keepmofiles=False,runmode='serial', coordsystem='dlc', maxiter=250, NumGrad=False, 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: @@ -122,6 +122,24 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U 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,21 +147,18 @@ 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: @@ -185,12 +200,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, force_noPBC=force_noPBC) + 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) @@ -223,7 +242,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, force_noPBC=force_noPBC) + 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) @@ -231,6 +250,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) @@ -244,7 +267,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, force_noPBC=force_noPBC) + 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 @@ -360,7 +383,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, - charge=charge, mult=mult, ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False, force_noPBC=force_noPBC) + 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') @@ -369,6 +392,8 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U 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: @@ -382,6 +407,12 @@ 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") + + # 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)] = energy # Write surfacedictionary to file after each step write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) @@ -406,7 +437,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, - charge=charge, mult=mult, ActiveRegion=ActiveRegion, actatoms=actatoms, result_write_to_disk=False, force_noPBC=force_noPBC) + 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') @@ -427,6 +458,12 @@ 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") + + # 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)] = energy # Write surfacedictionary to file after each step write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) @@ -455,7 +492,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, force_noPBC=force_noPBC) + 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": @@ -481,6 +519,12 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U #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) @@ -502,7 +546,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, force_noPBC=force_noPBC) + 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": @@ -527,6 +572,12 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U fragment.write_xyzfile(xyzfilename="RC1_"+str(RCvalue1)+".xyz") # fragment.print_system(filename="RC1_"+str(RCvalue1)+".ygg") shutil.move("RC1_"+str(RCvalue1)+".xyz", "surface_xyzfiles/"+"RC1_"+str(RCvalue1)+".xyz") + + # 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.") @@ -560,7 +611,8 @@ def combine_xyzfiles_in_directory(xyzdir, outputfilename): # 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, force_noPBC=False, + 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") From 70d24cf3f119481a54ef964b94c82dec795483f3 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Mar 2026 14:54:51 +0100 Subject: [PATCH 086/134] - DFTBTheory: enabled PBC - pyscftheory: work on PBC, disabled for now --- ash/interfaces/interface_DFTB.py | 129 ++++++++++-- ash/interfaces/interface_geometric_new.py | 20 -- ash/interfaces/interface_pyscf.py | 227 +++++++++++++++++----- 3 files changed, 296 insertions(+), 80 deletions(-) diff --git a/ash/interfaces/interface_DFTB.py b/ash/interfaces/interface_DFTB.py index f8a543501..4754f11b1 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 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_value=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_value=kpoint_value # k-point value: 1 for gamma point + 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,16 @@ 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) + # 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 +175,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_value=self.kpoint_value) print_time_rel(module_init_time, modulename=f'DFTB prep-run', moduleindex=3) # Run DFTB @@ -166,6 +199,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 +217,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_value=1): # Open file f = open("dftb_in.hsd", "w") @@ -188,19 +226,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_value} 0 0"+"\n") + inputlines.append(f"0 {kpoint_value} 0"+"\n") + inputlines.append(f"0 0 {kpoint_value}"+"\n") + inputlines.append("0 0 0"+"\n") + + inputlines.append("}"+"\n") + else: # PC if PC: @@ -216,6 +292,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 +330,19 @@ 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(" 1 0 0"+"\n") + inputlines.append(" 0 1 0"+"\n") + inputlines.append(" 0 0 1"+"\n") + inputlines.append(" 0 0 0"+"\n") + + inputlines.append(" }"+"\n") + #inputlines.append('}\n') + + # Close Hamiltonian + inputlines.append('}\n') #Options optionline="Options { WriteDetailedOut = Yes }\n" @@ -338,3 +427,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_geometric_new.py b/ash/interfaces/interface_geometric_new.py index deb6971aa..f97dee59c 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -751,7 +751,6 @@ def __init__(self,geometric_molf, theory, ActiveRegion=False, actatoms=None,prin self.MM_PDB_traj_write=MM_PDB_traj_write #Defining M attribute of engine object as geomeTRIC Molecule object self.M=geometric_molf - print("self.M:", self.M.__dict__) #Defining theory from argument self.theory=theory self.ActiveRegion=ActiveRegion @@ -786,36 +785,17 @@ def __init__(self,geometric_molf, theory, ActiveRegion=False, actatoms=None,prin self.elems_phys=self.fragment.elems # Align to standard orientation aligned_atom_coords, aligned_vectors = self.align_to_standard_orientation(self.fragment.coords, theory.periodic_cell_vectors) - print("aligned_atom_coords:",aligned_atom_coords) - print("aligned_vectors:",aligned_vectors) self.fragment.coords=aligned_atom_coords self.theory.update_cell(aligned_vectors) # Reference self.H_ref = aligned_vectors.copy() - print("self.H_ref:",self.H_ref) self.H_ref_inv = np.linalg.inv(self.H_ref) - print("self.H_ref_inv:", self.H_ref_inv) # 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)] - print("1self.M:", self.M.__dict__) self.M.elem = self.M.elem + ['F','F','F','F'] - print("len self.M.elem", len(self.M.elem)) - #exit() - # Write constraints - # N is 0-indexed count, but geomeTRIC wants 1-based indices - #n_orig = len(self.elems_phys) + 1 - #n_a = len(self.elems_phys) + 2 - #n_b = len(self.elems_phys) + 3 - #constraints = "$freeze\n" - #constraints += f"xyz {n_orig}\n" # Freeze Origin (X,Y,Z) - #constraints += f"yz {n_a} \n" # Freeze a_y and a_z - #constraints += f"z {n_b} \n" # Freeze b_z - #with open("constraints.txt", "w") as f: - # f.write(constraints) - #print("Wrote constraints file") def load_guess_files(self,dirname): if self.printlevel >= 1: diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 5bbb7dfff..ff050690c 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -1,7 +1,7 @@ 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, 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 import os @@ -46,7 +46,9 @@ 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): self.theorynamelabel="PySCF" self.theorytype="QM" @@ -152,6 +154,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 ? @@ -821,15 +848,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 @@ -1861,9 +1888,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 +1899,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 +1931,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 +1963,51 @@ 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") #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 +2025,77 @@ 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) + 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) + #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)) + + print("mf object:", self.mf) #Probably depreceated. Created mf for GPU. def create_mf_for_gpu(self): @@ -2427,7 +2515,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!") @@ -2452,7 +2544,8 @@ def run_SCF(self,mf=None, dm=None, max_cycle=None): #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) : + print("here:", self.mf) + if isinstance(self.mf, pyscf.scf.hf.RHF) or isinstance(self.mf, pyscf.dft.rks.RKS) or isinstance(self.mf, pyscf.pbc.dft.rks.RKS): self.num_orbs = len(self.mf.mo_occ) # Restricted else: self.num_orbs = len(self.mf.mo_occ[0]) @@ -2548,14 +2641,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") @@ -2563,20 +2656,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) @@ -2628,6 +2736,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 @@ -2647,7 +2761,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 @@ -2892,7 +3011,7 @@ 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") @@ -2901,8 +3020,18 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, 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 @@ -3139,7 +3268,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 From a38288a34b4d6cd5fd7dc276b8983f425651a048 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Mar 2026 21:08:13 +0100 Subject: [PATCH 087/134] - pyscf: small fix - geometric: fix for grep issue - MACETheory: proper support for PBCs, model_name keyword for selecting foundational models, cleanup for new and old interface --- ash/interfaces/interface_geometric_new.py | 6 +- ash/interfaces/interface_mace.py | 307 +++++++++++++++------- ash/interfaces/interface_pyscf.py | 3 +- 3 files changed, 219 insertions(+), 97 deletions(-) diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index f97dee59c..e455679e1 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -1012,7 +1012,7 @@ def actregion_calc(self,currcoords): # 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=step_lines[-1].split()[1] + iteration=int(step_lines[-1].split("Step", 1)[1].split(":", 1)[0].strip()) self.iteration_count=int(iteration) self.EG_count += 1 @@ -1034,7 +1034,7 @@ def regular_calc(self,currcoords): # 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=step_lines[-1].split()[1] + 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 @@ -1084,7 +1084,7 @@ def PBC_calc(self,currcoords): # 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=step_lines[-1].split()[1] + iteration=int(step_lines[-1].split("Step", 1)[1].split(":", 1)[0].strip()) self.iteration_count=int(iteration) # Transformation diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index c470112a0..45aed691d 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -3,26 +3,19 @@ import shutil import os -from ash.modules.module_coords import elemstonuccharges +from ash.modules.module_coords 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 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, polarmace=False, default_dtype="float64", - energy_weight=None, forces_weight=None, max_num_epochs=None, valid_fraction=None): - # 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, + filename="mace.model", model_file=None, printlevel=2, mace_load_dispersion=False, + label="MACETheory", numcores=1, platform="cpu", device=None, return_zero_gradient=False, polarmace=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' @@ -33,16 +26,36 @@ def __init__(self, config_filename="config.yml", self.filename = filename self.printlevel = printlevel self.properties = {} + + 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 # Distinguish between old MACE and polarMACE self.polarmace=polarmace + # + 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.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 # Training parameters self.energy_weight=energy_weight @@ -50,8 +63,33 @@ def __init__(self, config_filename="config.yml", self.max_num_epochs=max_num_epochs self.valid_fraction=valid_fraction - self.model_file=model_file - self.device=device.lower() + # 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) print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") @@ -63,7 +101,18 @@ def cleanup(self): def set_numcores(self,numcores): self.numcores=numcores - def train(self, config_file="config.yml", name="model",model="MACE", device=None, + # 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 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, @@ -86,16 +135,20 @@ def train(self, config_file="config.yml", name="model",model="MACE", device=None self.train_file=train_file self.valid_fraction=valid_fraction - if device is None: - print("Warning: device not passed to train. Using object's device attribute:", self.device) - device=self.device + 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) @@ -120,7 +173,7 @@ def train(self, config_file="config.yml", name="model",model="MACE", device=None 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, @@ -158,11 +211,11 @@ def train(self, config_file="config.yml", name="model",model="MACE", device=None 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 @@ -208,41 +261,84 @@ 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 - # - # 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") - - def model_load(self): + def modelfile_load(self): module_init_time=time.time() if 'polar' in self.model_file.lower(): - print("Model file name contains 'polar'. Assuming this is a polar MACE model. Loading polar mace") + print("Model file name contains 'polar'. Assuming this is a polar MACE model. Loading special calculator") self.polarmace=True + self.new_interface=True from mace.calculators import mace_polar self.model = mace_polar( model=self.model_file, - device=self.device, # or "cuda" - default_dtype=self.default_dtype # use float32 for faster MD - ) + 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 special calculator.") + self.new_interface=True + from mace.calculators import mace_mp + if self.model_name_head is None: + print("Warning: no head provided. You probably need to select head by ASH model_name_head keyword. Will try to continue") + 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) else: print("Loading regular MACE via Pytorch") 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(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): + 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) + 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) + else: + print("MACE-OFF model with modelname_subtype:", self.model_name_subtype) + self.model = mace_off(model=self.model_name_subtype, device=self.platform) + # MACE Materials Project (MP) models + elif self.model_name.lower() in ['mace-mp','medium-mpa-0','mace-mp-0', 'mace_mp']: + 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)") + self.model = mace_mp(model="medium", device=self.platform) + else: + print("MACE-MP model with modelname_subtype:", self.model_name_subtype) + self.model = mace_mp(model=self.model_name_subtype, device=self.platform) + # 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 get_dipole_moment(self): if "dipole" not in self.properties: print("Dipole moment not available") @@ -284,49 +380,68 @@ 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 - # Checking that model is loaded + # 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() - # New simpler MACE interface - if self.polarmace: - print("This is a polar MACE model. Running using different interface.") + # 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.modelname_load() + else: + print("Error: Neither modelfile or modelname was defined.") + ashexit() - # Simplest to use ase here to create Atoms object - import ase + # Creating ASE atoms object (MACE has ASE has dependency anyway) + import ase + 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 - atoms.info["charge"] = charge - atoms.info["spin"] = mult - # atoms.info["external_field"] = [0.0, 0.0, 0.0] + # 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 - # stress = atoms.get_stress() - # TODO: Hessian ? - - # Grab some other attributes - # Charges - self.charges = self.model.results["charges"] - # dipole - #mu = calc.results["dipole"] - self.properties["dipole"] = self.model.results["dipole"] - # Older interface + 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 @@ -334,11 +449,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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 - - # Simplest to use ase here to create Atoms object - import ase - atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) + from mace.modules.utils import compute_hessians_vmap, compute_hessians_loop, compute_forces, compute_forces_virials # Charge and spin: only makes sense for mace_polar atoms.info["charge"] = charge @@ -355,27 +466,31 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # # 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: + 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) + # 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 + #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 # Hessian if Hessian: @@ -417,7 +532,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # 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',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, @@ -456,4 +571,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_pyscf.py b/ash/interfaces/interface_pyscf.py index ff050690c..9f4e337f3 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -3841,7 +3841,8 @@ 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) + from ash import Singlepoint + 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) From 7e513d103304f7647ecf2dfbb971bf6493fa6bfa Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Mar 2026 21:58:29 +0100 Subject: [PATCH 088/134] - pyscftheory: some namespacing fixes - MACETheory: some fixes --- ash/interfaces/interface_mace.py | 23 +++++++++---------- ash/interfaces/interface_pyscf.py | 37 ++++++++++++++++++------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 45aed691d..7929bf151 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -12,8 +12,8 @@ class MACETheory(): def __init__(self, config_filename="config.yml", model_name=None, model_name_subtype=None, model_name_head=None, - filename="mace.model", model_file=None, printlevel=2, mace_load_dispersion=False, - label="MACETheory", numcores=1, platform="cpu", device=None, return_zero_gradient=False, polarmace=False, default_dtype="float64", + model_file=None, printlevel=2, mace_load_dispersion=False, + 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): @@ -23,7 +23,6 @@ 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 = {} @@ -449,7 +448,6 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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, compute_forces_virials # Charge and spin: only makes sense for mace_polar atoms.info["charge"] = charge @@ -486,22 +484,21 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el self.cell_gradient = stress_to_grad(stress_ev_ang3,atoms.get_volume(), atoms.get_cell()) print("Cell gradient:",self.cell_gradient) - # 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 - # 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) - - if Hessian: - self.hessian = hessian*0.010291772 + self.hessian = hessian*0.010291772 + print(f"Single-point {self.theorynamelabel} energy:", self.energy) print(BC.OKBLUE, BC.BOLD, f"------------ENDING {self.theorynamelabel} INTERFACE-------------", BC.END) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 9f4e337f3..45f91b897 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -4,6 +4,7 @@ from ash.modules.module_coords import nucchargelist, create_coords_string, 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 @@ -1231,9 +1232,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") @@ -1874,9 +1876,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) @@ -3045,8 +3048,8 @@ def actualrun(self, current_coords=None, current_MM_coords=None, MMcharges=None, if PC is True: if self.printlevel >=1: print("Calculating pointcharge gradient") - - 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() if self.PC_gradient_code == "new": @@ -3190,20 +3193,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 @@ -3305,7 +3310,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) @@ -3321,7 +3326,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) @@ -3805,13 +3811,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: @@ -3841,7 +3847,6 @@ 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 - from ash import Singlepoint 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 From 8387b49b90fed89a420709786a5201b2f7171b91 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 17 Mar 2026 22:04:55 +0100 Subject: [PATCH 089/134] macetheory: small fixes --- ash/interfaces/interface_mace.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 7929bf151..73210e5fd 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -39,10 +39,10 @@ def __init__(self, config_filename="config.yml", # Ignore predicted forces and return zero gradient self.return_zero_gradient=return_zero_gradient - # Distinguish between old MACE and polarMACE - self.polarmace=polarmace + # Polarmace (activated later if detected) + self.polarmace=False - # + # New interface: activated later if needed self.new_interface=False self.default_dtype=default_dtype From d768b156a4bc26e9144ef4309ca5852acc685c94 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 18 Mar 2026 13:40:41 +0100 Subject: [PATCH 090/134] -changes to periodic cell handling in optimizations: now theory.get_cell_gradient methods (was necessary for OpenMMTheory) - OpenMMTHeory: added periodic_cell_vectors and update_cell and get_cell_gradient methods. also a numerical compute_cell_gradient_fd method --- ash/functions/functions_optimization.py | 22 ++-- ash/interfaces/interface_CP2K.py | 3 + ash/interfaces/interface_DFTB.py | 3 + ash/interfaces/interface_OpenMM.py | 150 +++++++++++++++++----- ash/interfaces/interface_geometric_new.py | 3 +- ash/interfaces/interface_mace.py | 2 + ash/interfaces/interface_pyscf.py | 3 + ash/interfaces/interface_xtb.py | 6 + 8 files changed, 148 insertions(+), 44 deletions(-) diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index c71781328..af2b52580 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -481,7 +481,8 @@ def periodic_optimizer_alternating(fragment=None, theory=None, rate=0.5, maxiter res = geomeTRICOptimizer(theory=theory, fragment=fragment, force_noPBC=True, convergence_setting=atoms_tolsetting, maxiter=atom_opt_maxiter) # Check convergence of cell gradient - grad_norm = np.linalg.norm(theory.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)") @@ -497,34 +498,34 @@ def periodic_optimizer_alternating(fragment=None, theory=None, rate=0.5, maxiter # Calculate cell vector step (in Bohrs) if step_algo.lower() =="sd": print("Doing steepest descent step") - delta_au = - (rate * theory.cell_gradient) + delta_au = - (rate * cell_gradient) elif step_algo.lower() == "damped-MD": print("Doing momentum step") print("velocity:", velocity) - velocity = (momentum * velocity) - (rate * theory.cell_gradient) + 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 * theory.cell_gradient) + 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 = theory.cell_gradient + search_dir = cell_gradient prev_grad=None else: # Polak-Ribière formula for beta - diff = theory.cell_gradient - prev_grad - beta = np.sum(theory.cell_gradient * diff) / np.sum(prev_grad * prev_grad) + 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 = theory.cell_gradient + (beta * search_dir) + search_dir = cell_gradient + (beta * search_dir) delta_au = - (rate * search_dir) - prev_grad = theory.cell_gradient.copy() + prev_grad = cell_gradient.copy() else: print("Unknown step_algo") ashexit() @@ -751,7 +752,8 @@ def calculate_supergradient(self,supercoords): # Lattice gradient and masking # Total lattice gradient: current theory cell-gradient + convection - grad_latt_total = self.theory.cell_gradient + #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([ diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index 0f14f3b14..0ccbb130d 100644 --- a/ash/interfaces/interface_CP2K.py +++ b/ash/interfaces/interface_CP2K.py @@ -306,6 +306,9 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): from ash.modules.module_coords import cell_params_to_vectors 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, diff --git a/ash/interfaces/interface_DFTB.py b/ash/interfaces/interface_DFTB.py index 4754f11b1..02c719060 100644 --- a/ash/interfaces/interface_DFTB.py +++ b/ash/interfaces/interface_DFTB.py @@ -123,6 +123,9 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=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, diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index bc1297b0b..021b0588f 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,8 +16,8 @@ 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 - + change_origin_to_centroid, get_centroid, check_charge_mult, check_gradient_for_bad_atoms, get_molecule_members_loop_np2, \ + cell_params_to_vectors, cell_vectors_to_params, define_dummy_topology from ash.modules.module_MM import UFF_modH_dict, MMforcefield_read from ash.interfaces.interface_xtb import xTBTheory, grabatomcharges_xTB from ash.interfaces.interface_ORCA import ORCATheory, grabatomcharges_ORCA, chargemodel_select @@ -39,7 +38,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', @@ -195,7 +194,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 @@ -234,6 +233,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: @@ -341,10 +347,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 @@ -492,7 +498,6 @@ def __init__(self, printlevel=2, platform='CPU', numcores=1, topoforce=False, fo #Creating new #fragment.define_topology() #self.topology = fragment.pdb_topology - from ash.modules.module_coords import define_dummy_topology self.topology = define_dummy_topology(fragment.elems) # Create dummy XML file @@ -543,13 +548,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': @@ -618,13 +623,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()) @@ -936,29 +937,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 @@ -973,10 +974,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") @@ -1037,7 +1037,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: @@ -1077,6 +1077,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 @@ -1195,7 +1244,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") @@ -1244,7 +1293,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)") @@ -1622,12 +1671,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 @@ -1699,6 +1748,41 @@ 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, diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index e455679e1..3c38789d2 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -1097,7 +1097,8 @@ def PBC_calc(self,currcoords): # Lattice gradient and masking #Total lattice gradient: current theory cell-gradient + convection - grad_latt_total = self.theory.cell_gradient + #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([ diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 73210e5fd..d4c677753 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -110,6 +110,8 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=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, diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 45f91b897..f6aeca141 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2006,6 +2006,9 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): 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): diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 865d05c91..cd102547f 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -449,6 +449,9 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): from ash.modules.module_coords import cell_params_to_vectors 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() @@ -1235,6 +1238,9 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): from ash.modules.module_coords import cell_params_to_vectors 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): From c0b37eafdb4fc8cdda8fa72e5843ad34b526437e Mon Sep 17 00:00:00 2001 From: madhursharmaa Date: Wed, 18 Mar 2026 15:06:01 +0100 Subject: [PATCH 091/134] support for getting spin-density from orbital file using multiwfn interface --- ash/functions/functions_elstructure.py | 6 ++++++ ash/interfaces/interface_multiwfn.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/ash/functions/functions_elstructure.py b/ash/functions/functions_elstructure.py index 6f14aaec0..34de6a19e 100644 --- a/ash/functions/functions_elstructure.py +++ b/ash/functions/functions_elstructure.py @@ -1820,6 +1820,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) 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() From 3bb4c9c995838ba489c37f09eba3eba94897cf4b Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 18 Mar 2026 21:43:07 +0100 Subject: [PATCH 092/134] - MACETheory: now actually obeying numcores, D3 dispersion for MACE-MP models, mace-omol via name - FairChemTHeory: PBC enabled, using platform instead of cpu --- ash/interfaces/interface_fairchem.py | 80 +++++++++++++++++++++++----- ash/interfaces/interface_mace.py | 62 +++++++++++++++------ 2 files changed, 115 insertions(+), 27 deletions(-) diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index 868aca971..63c9c2acb 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -1,8 +1,9 @@ import time import os -from ash.modules.module_coords import elemstonuccharges +from ash.modules.module_coords import elemstonuccharges, 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 @@ -19,8 +20,8 @@ #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, - printlevel=2): + 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 @@ -46,30 +47,58 @@ def __init__(self, model_name=None, model_file=None, task_name=None, device="cud 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 + # 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.device.lower() == 'cpu': + 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.device) + 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") + #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.device, + task_name=self.task_name, device=self.platform, seed=self.seed) else: print("Error:Neither model or model_file was set") @@ -79,6 +108,20 @@ def __init__(self, model_name=None, model_file=None, task_name=None, device="cud 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): @@ -94,18 +137,27 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el else: qm_elems = elems + import ase if self.runcalls == 0: print("First runcall. Creating atoms object") - import ase - self.atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) + 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") - import ase - self.atoms = ase.atoms.Atoms(qm_elems,positions=current_coords) + 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 @@ -122,6 +174,10 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el if Grad: 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: diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index d4c677753..b050c0dec 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -12,7 +12,7 @@ class MACETheory(): 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, + 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): @@ -26,6 +26,11 @@ def __init__(self, config_filename="config.yml", 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: @@ -55,7 +60,7 @@ def __init__(self, config_filename="config.yml", 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 @@ -267,7 +272,7 @@ def modelfile_load(self): module_init_time=time.time() if 'polar' in self.model_file.lower(): - print("Model file name contains 'polar'. Assuming this is a polar MACE model. Loading special calculator") + 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 @@ -276,15 +281,19 @@ def modelfile_load(self): 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 special calculator.") + 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("Warning: no head provided. You probably need to select head by ASH model_name_head keyword. Will try to continue") - self.model = mace_mp(model=self.model_file, default_dtype=self.default_dtype, device=self.platform) + 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) + 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 @@ -296,30 +305,52 @@ def modelfile_load(self): # 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) + 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) + 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) + 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','medium-mpa-0','mace-mp-0', 'mace_mp']: + 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)") - self.model = mace_mp(model="medium", device=self.platform) + 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) - self.model = mace_mp(model=self.model_name_subtype, device=self.platform) + 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 @@ -385,6 +416,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el if Hessian: Grad=True + print("Running on platform/device:", self.platform) # Checking if model is alreadyloaded if self.model is None: print("A model has not been loaded yet.") @@ -402,7 +434,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el #Load model self.modelfile_load() elif self.model_name is not None: - print("Loading via model_name") + print("Loading via model_name:", self.model_name) self.modelname_load() else: print("Error: Neither modelfile or modelname was defined.") @@ -417,7 +449,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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: From e95374812783176d5b145e5ddc788cb3f0dd06ea Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 19 Mar 2026 11:05:24 +0100 Subject: [PATCH 093/134] torchtheory: PBC --- ash/interfaces/interface_torch.py | 50 +++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_torch.py b/ash/interfaces/interface_torch.py index 6b3621583..1093f9ff0 100644 --- a/ash/interfaces/interface_torch.py +++ b/ash/interfaces/interface_torch.py @@ -1,9 +1,10 @@ import time import numpy as np -from ash.modules.module_coords import elemstonuccharges +from ash.modules.module_coords import elemstonuccharges, 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 +15,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=None, train=False, aimnet_mode="new", + periodic=False, periodic_cell_vectors=None, periodic_cell_dimensions=None): # Early exits try: import torch @@ -52,6 +54,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 ################################ @@ -105,6 +129,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") @@ -261,7 +297,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 +320,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 From bc9cce17a973d86ddafade41d34016a2b985458b Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 19 Mar 2026 17:56:07 +0100 Subject: [PATCH 094/134] - Rewrite and restructuring of module_surface: new one is module_surface_new, old one in module_surface_old. calc_surface and calc_surface_from_XYZ can now handle N-dimensions. - reactionprofile_plot: can now handle if key is tuple instead of float - torchtheory: platform, device fixes - bugfix in cart_to_fract - Periodic_optimizer_cart now writes CIF,XSF or POSCAR files - DFTB: kpoints for case DFTB --- ash/__init__.py | 2 +- ash/functions/functions_optimization.py | 24 +- ash/interfaces/interface_DFTB.py | 11 +- ash/interfaces/interface_geometric_new.py | 2 +- ash/interfaces/interface_torch.py | 19 +- ash/modules/module_coords.py | 15 +- ash/modules/module_plotting.py | 2 +- ash/modules/module_surface_new.py | 806 ++++++++++++++++++ ...odule_surface.py => module_surface_old.py} | 14 +- 9 files changed, 861 insertions(+), 34 deletions(-) create mode 100644 ash/modules/module_surface_new.py rename ash/modules/{module_surface.py => module_surface_old.py} (99%) diff --git a/ash/__init__.py b/ash/__init__.py index 334fdef4f..53def31ba 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -85,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 # # QMcode interfaces from .interfaces.interface_ORCA import ORCATheory, counterpoise_calculation_ORCA, ORCA_External_Optimizer, run_orca_plot, MolecularOrbitalGrab, \ diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index af2b52580..14086a509 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -6,7 +6,7 @@ from ash.functions.functions_general import ashexit, blankline,print_time_rel_and_tot,BC,listdiff,print_time_rel from ash.modules.module_coords import write_xyzfile from ash.modules.module_coords import check_charge_mult, cell_vectors_to_params, cell_params_to_vectors, cart_coords_to_fract, fract_coords_to_cart, cell_volume -from ash.modules.module_coords import print_coords_for_atoms +from ash.modules.module_coords import print_coords_for_atoms,write_CIF_file,write_XSF_file, write_POSCAR_file from ash.interfaces.interface_geometric_new import geomeTRICOptimizer from ash.modules.module_theory import NumGradclass #import ash @@ -568,7 +568,7 @@ def Periodic_optimizer_cart(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): + printlevel=2, conv_criteria=None, PBC_format_option="CIF"): """ Wrapper function around Periodic_optimizer_cart_class """ @@ -580,7 +580,7 @@ def Periodic_optimizer_cart(fragment=None, theory=None, rate=2.0, ashexit() optimizer=Periodic_optimizer_cart_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, + max_step=max_step, momentum=momentum, PBC_format_option=PBC_format_option, printlevel=printlevel, conv_criteria=conv_criteria) result = optimizer.run() @@ -593,7 +593,8 @@ def Periodic_optimizer_cart(fragment=None, theory=None, rate=2.0, class Periodic_optimizer_cart_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): + max_step=0.25, momentum=0.5, printlevel=2, conv_criteria=None, + PBC_format_option="CIF"): self.fragment = fragment self.theory = theory @@ -604,6 +605,7 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m self.max_step=max_step self.momentum=momentum self.printlevel=printlevel + self.PBC_format_option=PBC_format_option self.ang2bohr=1.88972612546 @@ -903,6 +905,20 @@ def run(self): 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(f"Final energy: {energy} Eh") + + 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}") + break ######################################### diff --git a/ash/interfaces/interface_DFTB.py b/ash/interfaces/interface_DFTB.py index 02c719060..467a655dd 100644 --- a/ash/interfaces/interface_DFTB.py +++ b/ash/interfaces/interface_DFTB.py @@ -336,12 +336,13 @@ def write_DFTB_input(hamiltonian,xtbmethod,xyzfilename, elems,coords,charge,mult #PBC: k-points if periodic: inputlines.append(" KPointsAndWeights = SupercellFolding {"+"\n") - inputlines.append(" 1 0 0"+"\n") - inputlines.append(" 0 1 0"+"\n") - inputlines.append(" 0 0 1"+"\n") - inputlines.append(" 0 0 0"+"\n") + inputlines.append("KPointsAndWeights = SupercellFolding {"+"\n") + inputlines.append(f"{kpoint_value} 0 0"+"\n") + inputlines.append(f"0 {kpoint_value} 0"+"\n") + inputlines.append(f"0 0 {kpoint_value}"+"\n") + inputlines.append("0 0 0"+"\n") - inputlines.append(" }"+"\n") + inputlines.append("}"+"\n") #inputlines.append('}\n') # Close Hamiltonian diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index 3c38789d2..f96adf1bf 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -8,7 +8,7 @@ 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,fract_coords_to_cart,cart_coords_to_fract, cell_volume +from ash.modules.module_coords import print_coords_for_atoms,print_internal_coordinate_table,write_XYZ_for_atoms,write_xyzfile,write_coords_all, cell_volume 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_coords import check_charge_mult, fullindex_to_actindex, cell_vectors_to_params, write_CIF_file, write_XSF_file, write_POSCAR_file from ash.modules.module_freq import write_hessian,calc_hessian_xtb, approximate_full_Hessian_from_smaller, read_hessian diff --git a/ash/interfaces/interface_torch.py b/ash/interfaces/interface_torch.py index 1093f9ff0..fe80e25bf 100644 --- a/ash/interfaces/interface_torch.py +++ b/ash/interfaces/interface_torch.py @@ -15,7 +15,7 @@ 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: @@ -42,9 +42,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.") @@ -96,7 +97,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 @@ -159,7 +160,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 @@ -191,10 +192,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() @@ -215,6 +216,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") diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index 2ba265948..f700f0d54 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -4251,21 +4251,20 @@ def cell_vectors_to_params(vectors): 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(coords,cell_vectors): - h_inv = np.linalg.inv(cell_vectors) - fract_coords = np.dot(coords, h_inv.T) - return fract_coords +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,cell_vectors): - cart_coords = np.dot(fract_coords, cell_vectors.T) - return cart_coords +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 diff --git a/ash/modules/module_plotting.py b/ash/modules/module_plotting.py index 7adb57b74..5f7fe46b6 100644 --- a/ash/modules/module_plotting.py +++ b/ash/modules/module_plotting.py @@ -303,7 +303,7 @@ def reactionprofile_plot(surfacedictionary, finalunit=None,label='Label', x_axis #Sorting keys dictionary before grabbing so that line-plot is correct for key in sorted(surfacedictionary.keys()): - coords.append(key) + coords.append(float(key[0])) #Making sure we add a float,not a tuple e.append(surfacedictionary[key]) if RelativeEnergy is True: diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py new file mode 100644 index 000000000..96f40f2eb --- /dev/null +++ b/ash/modules/module_surface_new.py @@ -0,0 +1,806 @@ +import os +import glob +import shutil +import copy +import time +import itertools +#import ash +from ash.functions.functions_general import frange, BC, natural_sort, print_line_with_mainheader,print_line_with_subheader1,print_time_rel, ashexit +import ash.functions.functions_parallel +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 + + +# New rewritten calc_surface function +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, 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") + + # -- 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 " + "geomeTRIC 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) + print("allconstraints:", allconstraints) + 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, force_noPBC=force_noPBC, + PBC_format_option=PBC_format_option, + ) + newfrag = copy.copy(fragment) + newfrag.label = key + 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") + optimizer = GeomeTRICOptimizerClass( + maxiter=maxiter, coordsystem=coordsystem, + convergence_setting=convergence_setting, conv_criteria=conv_criteria, + subfrctor=subfrctor, ActiveRegion=ActiveRegion, actatoms=actatoms, + force_noPBC=force_noPBC, PBC_format_option=PBC_format_option, + ) + 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) + print("allconstraints:", allconstraints) + newfrag = copy.copy(fragment) + 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): + pointcount += 1 + key = _point_key(rc_values) + label = _point_label(rc_values) + + print("=" * 50) + print(f"Surfacepoint: {pointcount} / {totalnumpoints}") + print(f" {label}") + if scantype.upper() == 'UNRELAXED': + print(" Unrelaxed scan: using ZeroTheory + geomeTRIC to set geometry.") + 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) + print("allconstraints:", allconstraints) + + if scantype.upper() == 'UNRELAXED': + 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, force_noPBC=force_noPBC, + PBC_format_option=PBC_format_option, + ) + result = ash.Singlepoint( + fragment=fragment, theory=theory, charge=charge, mult=mult, + ) + + else: # RELAXED + 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, force_noPBC=force_noPBC, + PBC_format_option=PBC_format_option, + ) + + 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) + _handle_pbc(theory, fragment, label, convert_to_pbcfile) + + surfacedictionary[key] = float(energy) + write_surfacedict_to_file(surfacedictionary, resultfile, dimension=dimension) + + 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, + 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, + keepoutputfiles=True, force_noPBC=False, + 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") + + # -- 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): + if not RC_list: + return {} + return set_constraints_nd(RC_list, rc_vals, extraconstraints) + + # ----------------------------------------------------------------------- + # 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) + 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 = GeomeTRICOptimizerClass( + maxiter=maxiter, coordsystem=coordsystem, + convergence_setting=convergence_setting, conv_criteria=conv_criteria, + subfrctor=subfrctor, result_write_to_disk=False, force_noPBC=force_noPBC, + ) + 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) + print("allconstraints:", allconstraints) + 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, + force_noPBC=force_noPBC, + ) + 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) + 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 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): + """Write surface dictionary to resultfile. + + Each line: RC1_val [RC2_val ...] energy + """ + with open(resultfile, 'w') as f: + f.write("# Surface scan results\n") + f.write("# RC1 [RC2 ...] Energy\n") + print("surfacedictionary.items():", surfacedictionary.items()) + for key, energy in sorted(surfacedictionary.items()): + if isinstance(key, tuple): + rc_str = ' '.join(str(v) for v in key) + else: + rc_str = str(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): + """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 + + Returns: + dict suitable for geomeTRICOptimizer's ``constraints`` argument + """ + allconstraints = {} + 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 k, v in extraconstraints.items(): + allconstraints.setdefault(k, []).extend(v) + return allconstraints + +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): + """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("Theory has no outputfile, probably. ignoring") + pass + except FileNotFoundError: + pass + if keepmofiles: + try: + shutil.copyfile( + theory.filename + '.gbw', + f'surface_mofiles/{theory.filename}_{pointlabel}.gbw', + ) + except FileNotFoundError: + pass \ No newline at end of file diff --git a/ash/modules/module_surface.py b/ash/modules/module_surface_old.py similarity index 99% rename from ash/modules/module_surface.py rename to ash/modules/module_surface_old.py index c14d7ff43..12ea5ea2c 100644 --- a/ash/modules/module_surface.py +++ b/ash/modules/module_surface_old.py @@ -119,7 +119,6 @@ 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 @@ -413,7 +412,7 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U 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)] = energy + surfacedictionary[(RCvalue1,RCvalue2)] = float(energy) # Write surfacedictionary to file after each step write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) else: @@ -464,7 +463,7 @@ def calc_surface(fragment=None, theory=None, charge=None, mult=None, scantype='U 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)] = energy + surfacedictionary[(RCvalue1)] = float(energy) # Write surfacedictionary to file after each step write_surfacedict_to_file(surfacedictionary,resultfile, dimension=dimension) else: @@ -506,7 +505,7 @@ 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) @@ -560,7 +559,7 @@ 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) @@ -921,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("") @@ -978,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("") @@ -1123,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') + From eced46c400b48b32b2ea9f38a23578dcdb93f799 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sat, 21 Mar 2026 11:58:54 +0100 Subject: [PATCH 095/134] - DFTB: kpoint handling changed - Turbomole : dispersion corrections - Turbomole: PBC via riper (only for HF and pure DFT ) - 3d volume plots in module_plotting - Periodic_optimizer_cart: initial constraints (not well tested) --- README.md | 5 + ash/__init__.py | 2 +- ash/functions/functions_optimization.py | 282 +++++++++++++++++++++++- ash/interfaces/interface_DFTB.py | 22 +- ash/interfaces/interface_Turbomole.py | 158 +++++++++++-- ash/modules/module_plotting.py | 89 +++++++- 6 files changed, 513 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index d636f18ee..93cbdf222 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. +**Cite us** +If ASH is useful in your research cite us: +ASH: a multi-scale, multi-theory modelling program +R. Bjornsson*, J. Comput. Chem 2026, accepted. ChemRxiv preprint: https://chemrxiv.org/doi/full/10.26434/chemrxiv.10001640/v1 + **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 53def31ba..3dfc4ce65 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -209,7 +209,7 @@ # 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 # Other import ash.interfaces.interface_crest diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index 14086a509..dfa9c8297 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -568,7 +568,8 @@ def Periodic_optimizer_cart(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, PBC_format_option="CIF"): + printlevel=2, conv_criteria=None, PBC_format_option="CIF", + constraints=None, frozen_atoms=None): """ Wrapper function around Periodic_optimizer_cart_class """ @@ -581,7 +582,7 @@ def Periodic_optimizer_cart(fragment=None, theory=None, rate=2.0, optimizer=Periodic_optimizer_cart_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, - printlevel=printlevel, conv_criteria=conv_criteria) + printlevel=printlevel, conv_criteria=conv_criteria, constraints=constraints, frozen_atoms=frozen_atoms) result = optimizer.run() if printlevel >= 1: @@ -594,7 +595,7 @@ class Periodic_optimizer_cart_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, - PBC_format_option="CIF"): + PBC_format_option="CIF", constraints=None, frozen_atoms=None): self.fragment = fragment self.theory = theory @@ -606,6 +607,14 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m self.momentum=momentum self.printlevel=printlevel self.PBC_format_option=PBC_format_option + # Constraints + self.constraints = constraints if constraints is not None else [] + # Default force constant for soft restraints (eV/Ų or Eh/Ų — match your units) + self.default_k = 10.0 + # 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 @@ -615,6 +624,10 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m else: self.conv_criteria=conv_criteria print("Convergence criteria:", self.conv_criteria) + print("Constraints:", self.constraints) + for con in self.constraints: + print("con:",con) + # Max step in bohrs (default = 0.1 Å = 0.188 bohrs) self.max_step_au = max_step*self.ang2bohr @@ -645,6 +658,258 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m self.H_ref_inv = np.linalg.inv(self.H_ref) print("H_ref_inv:", self.H_ref_inv) + def apply_frozen_atoms(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 + """ + if not self.frozen_atoms: + return gradient + + grad_out = gradient.copy() + + if isinstance(self.frozen_atoms, (list, tuple)): + for idx in self.frozen_atoms: + grad_out[idx] = 0.0 + + elif isinstance(self.frozen_atoms, dict): + component_map = {'x': 0, 'y': 1, 'z': 2} + for idx, components in self.frozen_atoms.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): + """ + 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 + + for c in self.constraints: + if c.get('type') != 'bond': + continue + print("Applying bond constraint") + i, j = c['atoms'] + r0 = c['target'] # target bond length in Å + method = c.get('method', 'hard') + k = c.get('k', self.default_k) # only used for soft + + # Current bond vector and length + rij = coords[i] - coords[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 Å + + if 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 * k * delta**2 + grad_out[i] += k * delta * e_ij + grad_out[j] -= k * delta * e_ij + if self.printlevel >= 2: + print(f" Soft constraint ({i},{j}): d={d:.4f} Å target={r0:.4f} Å " + f"delta={delta:.4f} Å penalty={0.5*k*delta**2:.6f}") + + elif 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 '{method}'. Use 'hard' or 'soft'.") + + return energy_out, grad_out + + def apply_angle_constraints(self, coords, gradient, energy): + """ + 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 + + for c in self.constraints: + if c.get('type') != 'angle': + continue + print("Applying angle constraint") + i, j, k = c['atoms'] # centre atom is j + theta0 = np.deg2rad(c['target']) + method = c.get('method', 'hard') + kf = c.get('k', self.default_k) + + # Bond vectors pointing away from centre j + u = coords[i] - coords[j] + v = coords[k] - coords[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 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 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_torsion_constraints(self, coords, gradient, energy): + """ + Torsion (dihedral) constraints for quartets (i, j, k, l). + Target angle in degrees, range (-180, 180]. + Uses the Blondel & Karplus (1996) analytical gradient. + """ + if not self.constraints: + return energy, gradient + + grad_out = gradient.copy() + energy_out = energy + + for c in self.constraints: + if c.get('type') != 'torsion': + continue + print("Applying torsion constraint") + i, j, k, l = c['atoms'] + phi0 = np.deg2rad(c['target']) + method = c.get('method', 'hard') + kf = c.get('k', self.default_k) + + # Bond vectors along the chain + b1 = coords[j] - coords[i] + b2 = coords[k] - coords[j] + b3 = coords[l] - coords[k] + + # Normal vectors to the two planes + 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 or lb2 < 1e-8: + print(f"Warning: degenerate torsion {i}-{j}-{k}-{l}. Skipping.") + continue + + # Torsion angle via atan2 (gives correct sign and full -π..π range) + 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) + + # Deviation — wrap to (-π, π] + delta = phi - phi0 + delta = (delta + np.pi) % (2 * np.pi) - np.pi + + # Blondel & Karplus gradient + # ∂φ/∂r_i = -|b2|/|n1|² * n1 + # ∂φ/∂r_l = |b2|/|n2|² * n2 + # ∂φ/∂r_j and ∂φ/∂r_k from chain rule (see B&K eq. 27) + lb2_sq = lb2**2 + dphi_dri = -(lb2 / ln1**2) * n1 + dphi_drl = (lb2 / ln2**2) * n2 + dphi_drj = ( (np.dot(b1, b2) / lb2_sq - 1.0) * (lb2 / ln1**2) * n1 + - (np.dot(b3, b2) / lb2_sq) * (lb2 / ln2**2) * n2 ) + dphi_drk = ( (np.dot(b3, b2) / lb2_sq - 1.0) * (lb2 / ln2**2) * n2 * (-1) # sign: b2 direction + - (np.dot(b1, b2) / lb2_sq) * (lb2 / ln1**2) * n1 * (-1) ) + # Compact form consistent with B&K sign convention: + dphi_drj = ( -(1.0 - np.dot(b1,b2)/lb2_sq) * (lb2/ln1**2) * n1 + +( np.dot(b3,b2)/lb2_sq) * (lb2/ln2**2) * n2 ) + dphi_drk = ( (1.0 - np.dot(b3,b2)/lb2_sq) * (lb2/ln2**2) * n2 + -( np.dot(b1,b2)/lb2_sq) * (lb2/ln1**2) * n1 ) + + if method == 'soft': + energy_out += 0.5 * kf * delta**2 + grad_out[i] += kf * delta * dphi_dri + grad_out[j] += kf * delta * dphi_drj + grad_out[k] += kf * delta * dphi_drk + grad_out[l] += kf * delta * dphi_drl + if self.printlevel >= 2: + print(f" Soft torsion ({i},{j},{k},{l}): φ={np.rad2deg(phi):.3f}° " + f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}° " + f"penalty={0.5*kf*delta**2:.6f}") + + elif method == 'hard': + for idx, dphi_dr in [(i, dphi_dri), (j, dphi_drj), + (k, dphi_drk), (l, dphi_drl)]: + norm = np.linalg.norm(dphi_dr) + if norm > 1e-10: + n_hat = dphi_dr / norm + grad_out[idx] -= np.dot(grad_out[idx], n_hat) * n_hat + if self.printlevel >= 2: + print(f" Hard torsion ({i},{j},{k},{l}): φ={np.rad2deg(phi):.3f}° " + f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}°") + + return energy_out, grad_out + def align_to_standard_orientation(self, fragment_coords, cell_vectors): """ Rotates the entire system (atoms and cell) into the standard @@ -882,6 +1147,16 @@ def run(self): ######################################### energy, supergradient = self.calculate_supergradient(currcoords) + # 1b. Apply all constraints + if self.constraints: + print("Applying constraints...") + energy, supergradient = self.apply_bond_constraints(R_phys, supergradient, energy) + energy, supergradient = self.apply_angle_constraints(R_phys, supergradient, energy) + energy, supergradient = self.apply_torsion_constraints(R_phys, supergradient, energy) + # 1c. Apply frozen atoms + if self.frozen_atoms: + supergradient = self.apply_frozen_atoms(supergradient) + ######################################### # 2. Check convergence of cell gradient ######################################### @@ -918,7 +1193,6 @@ def run(self): file_ext='POSCAR' convert_to_pbcfile(self.fragment.coords,self.fragment.elems,cellvectors=self.theory.periodic_cell_vectors, filename=f"Fragment-optimized.{file_ext}") - break ######################################### diff --git a/ash/interfaces/interface_DFTB.py b/ash/interfaces/interface_DFTB.py index 467a655dd..22a239241 100644 --- a/ash/interfaces/interface_DFTB.py +++ b/ash/interfaces/interface_DFTB.py @@ -16,7 +16,7 @@ def __init__(self, dftbdir=None, hamiltonian="XTB", xtb_method="GFN2-xTB", print 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, periodic=False, periodic_cell_vectors=None, - periodic_cell_dimensions=None, kpoint_value=1): + periodic_cell_dimensions=None, kpoint_values=[1,1,1]): self.theorynamelabel="DFTB" self.label=label @@ -56,7 +56,7 @@ def __init__(self, dftbdir=None, hamiltonian="XTB", xtb_method="GFN2-xTB", print # PBC self.periodic=periodic self.periodic_cell_vectors=None # initially - self.kpoint_value=kpoint_value # k-point value: 1 for gamma point + 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: @@ -179,7 +179,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el 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, periodic=self.periodic, - periodic_cell_vectors=self.periodic_cell_vectors, kpoint_value=self.kpoint_value) + 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 @@ -221,7 +221,7 @@ 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, - periodic=False, periodic_cell_vectors=None, kpoint_value=1): + periodic=False, periodic_cell_vectors=None, kpoint_values=[1,1,1]): # Open file f = open("dftb_in.hsd", "w") @@ -273,9 +273,9 @@ def write_DFTB_input(hamiltonian,xtbmethod,xyzfilename, elems,coords,charge,mult #PBC: k-points if periodic: inputlines.append("KPointsAndWeights = SupercellFolding {"+"\n") - inputlines.append(f"{kpoint_value} 0 0"+"\n") - inputlines.append(f"0 {kpoint_value} 0"+"\n") - inputlines.append(f"0 0 {kpoint_value}"+"\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") @@ -337,13 +337,13 @@ def write_DFTB_input(hamiltonian,xtbmethod,xyzfilename, elems,coords,charge,mult if periodic: inputlines.append(" KPointsAndWeights = SupercellFolding {"+"\n") inputlines.append("KPointsAndWeights = SupercellFolding {"+"\n") - inputlines.append(f"{kpoint_value} 0 0"+"\n") - inputlines.append(f"0 {kpoint_value} 0"+"\n") - inputlines.append(f"0 0 {kpoint_value}"+"\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") - #inputlines.append('}\n') + # Close Hamiltonian inputlines.append('}\n') diff --git a/ash/interfaces/interface_Turbomole.py b/ash/interfaces/interface_Turbomole.py index 28a85a243..068eb5bd9 100644 --- a/ash/interfaces/interface_Turbomole.py +++ b/ash/interfaces/interface_Turbomole.py @@ -5,7 +5,7 @@ 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 import nucchargelist, cell_vectors_to_params, cell_params_to_vectors import ash.settings_ash from ash.functions.functions_parallel import check_OpenMPI @@ -13,9 +13,11 @@ class TurbomoleTheory: def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel=2, label="Turbomole", uff=False, - numcores=1, parallelization='SMP', functional=None, gridsize="m4", scfconv=7, symmetry="c1", rij=True, + 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 @@ -26,6 +28,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 @@ -64,12 +67,36 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= print("Initializing Turbomole QM") # QM controfile or Basis set check if controlfile is None: - print("No controlfile provided. This requires basis to be provided") + 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: @@ -101,7 +128,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" @@ -118,7 +152,11 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= ashexit() else: self.jbasis=jbasis - print("self.turbo_scf_exe:", self.turbo_scf_exe) + # 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") @@ -162,15 +200,28 @@ def __init__(self, TURBODIR=None, turbomoledir=None, filename='XXX', printlevel= # 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) @@ -277,8 +328,10 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el print("Creating controlfile") numelectrons = int(nucchargelist(qm_elems) - charge) - create_control_file(runcalls=self.runcalls, 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, + 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, numelectrons=numelectrons) @@ -316,7 +369,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el self.energy = grab_energy_from_energyfile(file="uffenergy") print("UFF Energy:", self.energy) # Gradient - self.gradient = grab_uffgradient(len(current_coords), file="uffgradient") + self.gradient = grab_gradient(len(current_coords), file="uffgradient") print("self.gradient:", self.gradient) else: @@ -339,12 +392,22 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el # GRADIENT if Grad is True and self.uff is False: - 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)) + + # 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)) @@ -404,8 +467,9 @@ 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(runcalls=None, 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, +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 @@ -468,12 +532,34 @@ def create_control_file(runcalls=None, functional="lh12ct-ssifpw92", gridsize="m $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 @@ -527,12 +613,14 @@ def grab_energy_from_energyfile(file="energy", column=1): energy = float(line.split()[column]) return energy -def grab_gradient(numatoms,file="gradient"): +# 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(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: @@ -540,7 +628,7 @@ def grab_gradient(numatoms,file="gradient"): counter+=1 return gradient -def grab_uffgradient(numatoms,file="uffgradient"): +def grab_gradient(numatoms,file="gradient"): gradient = np.zeros((numatoms,3)) with open(file, 'r') as gradfile: gradlines = gradfile.readlines() @@ -584,4 +672,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/modules/module_plotting.py b/ash/modules/module_plotting.py index 5f7fe46b6..86c291eac 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' @@ -293,9 +297,6 @@ def reactionprofile_plot(surfacedictionary, finalunit=None,label='Label', x_axis 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=[] @@ -357,9 +358,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=[] @@ -645,3 +644,81 @@ 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', + 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) + 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"surface.{imageformat}") + + # Save HTML + fig.write_html("surface.html") + + if plot_in_browser: + fig.show() \ No newline at end of file From 29e29c274058b0ac56560bbed9b8a32e64d2081d Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sun, 22 Mar 2026 17:42:08 +0100 Subject: [PATCH 096/134] calc_surface: bugfixes when using extraconstraints --- ash/interfaces/interface_geometric_new.py | 1 - ash/interfaces/interface_veloxchem.py | 0 ash/modules/module_surface_new.py | 128 +++++++++++++++++++--- 3 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 ash/interfaces/interface_veloxchem.py diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index f96adf1bf..0a1dfdba2 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -335,7 +335,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: diff --git a/ash/interfaces/interface_veloxchem.py b/ash/interfaces/interface_veloxchem.py new file mode 100644 index 000000000..e69de29bb diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py index 96f40f2eb..a15c56508 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -145,7 +145,7 @@ def calc_surface( print(f"======= Surfacepoint {pointcount}/{totalnumpoints}: {label} =======") if key in surfacedictionary: continue - allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints) + allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) print("allconstraints:", allconstraints) geomeTRICOptimizer( fragment=fragment, theory=zerotheory, maxiter=maxiter, @@ -185,7 +185,7 @@ def calc_surface( print(f"======= Surfacepoint {pointcount}/{totalnumpoints}: {label} =======") if key in surfacedictionary: continue - allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints) + allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) print("allconstraints:", allconstraints) newfrag = copy.copy(fragment) newfrag.label = key @@ -241,7 +241,7 @@ def calc_surface( print(f"{label} already in dict. Skipping.") continue - allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints) + allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) print("allconstraints:", allconstraints) if scantype.upper() == 'UNRELAXED': @@ -488,10 +488,10 @@ def parse_rc_values(relfile): # ----------------------------------------------------------------------- # Helper: build geomeTRIC constraints for a given point # ----------------------------------------------------------------------- - def build_constraints(rc_vals): + def build_constraints(rc_vals, frag): if not RC_list: return {} - return set_constraints_nd(RC_list, rc_vals, extraconstraints) + return set_constraints_nd(RC_list, rc_vals, extraconstraints, fragment=frag) # ----------------------------------------------------------------------- # PARALLEL @@ -510,7 +510,7 @@ def build_constraints(rc_vals): continue newfrag = ash.Fragment(xyzfile=file, label=key, charge=charge, mult=mult) if scantype.upper() == 'RELAXED': - newfrag.constraints = build_constraints(rc_vals) + newfrag.constraints = build_constraints(rc_vals,newfrag) surfacepointfragments_list.append(newfrag) if scantype.upper() == 'UNRELAXED': @@ -581,8 +581,7 @@ def build_constraints(rc_vals): ) else: # RELAXED - allconstraints = build_constraints(rc_vals) - print("allconstraints:", allconstraints) + allconstraints = build_constraints(rc_vals,mol) result = geomeTRICOptimizer( fragment=mol, theory=theory, maxiter=maxiter, coordsystem=coordsystem, constraints=allconstraints, @@ -746,28 +745,125 @@ 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): +def set_constraints_nd(RC_list, rc_values, extraconstraints=None, fragment=None): """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 - + 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 k, v in extraconstraints.items(): - allconstraints.setdefault(k, []).extend(v) + 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: + 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) + 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 + 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 + """ + import numpy as np + 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): From 3483d2d465aba46926690978adad26ef9f790d31 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 23 Mar 2026 15:40:17 +0100 Subject: [PATCH 097/134] - DLFIND Optimizer: not global - openmm: added openMM_cpu_threads also to be safe --- ash/__init__.py | 3 +++ ash/interfaces/interface_OpenMM.py | 1 + ash/interfaces/interface_geometric_new.py | 19 +++---------------- ash/modules/module_coords.py | 5 +++-- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/ash/__init__.py b/ash/__init__.py index 3dfc4ce65..669e68a31 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -211,6 +211,9 @@ import ash.modules.module_plotting 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 + # Other import ash.interfaces.interface_crest from .interfaces.interface_crest import call_crest, call_crest_entropy, get_crest_conformers, new_call_crest diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 021b0588f..1e42307a9 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -60,6 +60,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 diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index 0a1dfdba2..a7ff02c42 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -68,7 +68,8 @@ def __init__(self,theory=None, charge=None, mult=None, coordsystem='tric', 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, 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) @@ -537,7 +538,6 @@ 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,xyzconstraints = self.define_constraints(constraints) if xyzconstraints is not None: @@ -553,7 +553,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.") @@ -571,7 +570,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) @@ -587,7 +585,6 @@ 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) - # bondorders # generally unused, except PBC self.bothre=0.0 @@ -632,6 +629,7 @@ 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) @@ -910,19 +908,8 @@ def calc(self,coords,tmp, read_data=None, copydir=None): #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] - #print("WE ARE INSIDE CALC. we are going to verify gradient") - #self.verify_gradient(currcoords) - #print("done") - #exit() - - - - - # Call method to use if self.ActiveRegion is True: egdict = self.actregion_calc(currcoords) diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index f700f0d54..40188936c 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -578,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]) @@ -834,7 +834,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 @@ -868,6 +868,7 @@ 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): From 9d68f11cb431471ff0ea012f0c66f35eec40efa8 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 24 Mar 2026 11:45:02 +0100 Subject: [PATCH 098/134] - DLFIND interface: some fixes to constraint handling - calc_surface: now supports either geometric or dlfind as optimizer. Use of DLFIND required new routines to set geometry - fixes for read and write surfacedict --- ash/interfaces/interface_dlfind.py | 30 +- ash/interfaces/interface_veloxchem.py | 96 +++++ ash/modules/module_surface_new.py | 557 +++++++++++++++++++++++--- 3 files changed, 617 insertions(+), 66 deletions(-) diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 7fe07208e..469cd48b0 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -25,7 +25,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 +43,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 +68,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() @@ -186,6 +189,7 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char # Residues for HDLC self.residues=residues + #Constraints self.constraints=constraints @@ -199,11 +203,28 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char 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=[] + + # First dentify possible frozen constraints defined in constraints dict + if self.constraints is not None: + if 'xyz' in self.constraints: + print("XYZ constraints found in constraints dict.", self.constraints['xyz']) + print("Adding to frozenatoms list") + if frozenatoms is None: + frozenatoms=[] + frozenatoms = self.constraints['xyz'] + if actatoms is not None: print("Actatoms provided:", actatoms) + if frozenatoms is not None: + if len(frozenatoms) > 0: + print("frozenatoms:", frozenatoms) + print("Error: actatoms and frozenatoms can not both be defined") + ashexit() print("All atoms:", fragment.allatoms) + for i in fragment.allatoms: if i in actatoms: if self.residues is not None: @@ -215,6 +236,7 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char 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) @@ -255,7 +277,7 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char b = [2,x[0]+1,x[1]+1,x[2]+1,0] conlist += b self.numcons+=1 - elif k == 'dihedral': + elif k == 'dihedral' or k == 'torsion': 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] diff --git a/ash/interfaces/interface_veloxchem.py b/ash/interfaces/interface_veloxchem.py index e69de29bb..a0279b25b 100644 --- a/ash/interfaces/interface_veloxchem.py +++ b/ash/interfaces/interface_veloxchem.py @@ -0,0 +1,96 @@ +import subprocess as sp +import os +import shutil +import time +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, 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) + + # Grad + + if Grad: + return self.energy,self.gradient + + else: + return self.energy \ No newline at end of file diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py index a15c56508..053223297 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -4,18 +4,20 @@ 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 import ash.functions.functions_parallel 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.interfaces.interface_dlfind import DLFIND_optimizer, DLFIND_optimizerClass from ash.modules.module_theory import NumGradclass # New rewritten calc_surface function def calc_surface( - fragment=None, theory=None, charge=None, mult=None, + fragment=None, theory=None, charge=None, mult=None, optimizer='geometric', scantype='UNRELAXED', resultfile='surface_results.txt', keepoutputfiles=True, keepmofiles=False, runmode='serial', coordsystem='dlc', maxiter=250, @@ -71,6 +73,33 @@ def calc_surface( module_init_time = time.time() print_line_with_mainheader("CALC_SURFACE FUNCTION") + 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, + 'ActiveRegion': ActiveRegion, + 'constrainvalue':True, 'result_write_to_disk':False, + } + elif optimizer.lower() in ['dlfind','dl-find']: + print("Optimizer to use for surface scan: DL-FIND") + Optimizer=DLFIND_optimizer + Optimizerclass=DLFIND_optimizerClass + opt_arguments={'maxcycle':maxiter,'iopt':3, 'icoord':1} + # Build connectivity once + conn = _build_connectivity(fragment.coords, fragment.elems) + else: + print("Wrong optimizer option chosen. Valid options are: geometric and dlfind") + ashexit() + + # -- NumGrad wrapping --------------------------------------------------- if NumGrad: print("NumGrad flag detected. Wrapping theory object into NumGrad class") @@ -114,7 +143,7 @@ def calc_surface( if getattr(theory, "periodic", False): print( "Warning: Theory is periodic. Constrained geometry optimizations by " - "geomeTRIC Optimizer will optimize both atom and cell parameters" + "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}") @@ -147,14 +176,11 @@ def calc_surface( continue allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) print("allconstraints:", allconstraints) - 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, force_noPBC=force_noPBC, - PBC_format_option=PBC_format_option, + Optimizer( + fragment=fragment, theory=zerotheory, + constraints=allconstraints, + actatoms=actatoms, + **opt_arguments, ) newfrag = copy.copy(fragment) newfrag.label = key @@ -171,12 +197,8 @@ def calc_surface( elif scantype.upper() == 'RELAXED': print("Warning: Relaxed scans in parallel mode are experimental") - optimizer = GeomeTRICOptimizerClass( - maxiter=maxiter, coordsystem=coordsystem, - convergence_setting=convergence_setting, conv_criteria=conv_criteria, - subfrctor=subfrctor, ActiveRegion=ActiveRegion, actatoms=actatoms, - force_noPBC=force_noPBC, PBC_format_option=PBC_format_option, - ) + optimizer = Optimizerclass( + actatoms=actatoms, **opt_arguments) pointcount = 0 for rc_values in itertools.product(*RC_value_lists): pointcount += 1 @@ -188,6 +210,10 @@ def calc_surface( allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) print("allconstraints:", allconstraints) newfrag = copy.copy(fragment) + if optimizer == "dlfind": + print("For DL-FIND we need to modify geometry first to set constraints.") + _set_geometry_direct(newfrag, RC_list, rc_values, conn=conn) + _verify_geometry(newfrag, RC_list, rc_values) newfrag.label = key newfrag.constraints = allconstraints surfacepointfragments_list.append(newfrag) @@ -232,7 +258,7 @@ def calc_surface( print(f"Surfacepoint: {pointcount} / {totalnumpoints}") print(f" {label}") if scantype.upper() == 'UNRELAXED': - print(" Unrelaxed scan: using ZeroTheory + geomeTRIC to set geometry.") + print(" Unrelaxed scan: using ZeroTheory + Optimizer to set geometry.") else: print(" Relaxed scan: relaxing geometry with theory + constraints.") print("=" * 50) @@ -245,30 +271,30 @@ def calc_surface( print("allconstraints:", allconstraints) if scantype.upper() == 'UNRELAXED': - geomeTRICOptimizer( - fragment=fragment, theory=zerotheory, maxiter=maxiter, - coordsystem=coordsystem, constraints=allconstraints, - constrainvalue=True, convergence_setting=convergence_setting, - conv_criteria=conv_criteria, subfrctor=subfrctor, + Optimizer( + fragment=fragment, theory=zerotheory, + constraints=allconstraints, charge=charge, mult=mult, - ActiveRegion=ActiveRegion, actatoms=actatoms, - result_write_to_disk=False, force_noPBC=force_noPBC, - PBC_format_option=PBC_format_option, + actatoms=actatoms, **opt_arguments, ) result = ash.Singlepoint( fragment=fragment, theory=theory, charge=charge, mult=mult, ) else: # RELAXED - result = geomeTRICOptimizer( - fragment=fragment, theory=theory, maxiter=maxiter, - coordsystem=coordsystem, constraints=allconstraints, - constrainvalue=True, convergence_setting=convergence_setting, - conv_criteria=conv_criteria, subfrctor=subfrctor, + # If optimizer is DL-FIND then we have to first modify geometry + if optimizer == "dlfind": + print("For DL-FIND we need to modify geometry first to set constraints.") + #timeA=time.time() + _set_geometry_direct(fragment, RC_list, rc_values, conn=conn) + _verify_geometry(fragment, RC_list, rc_values) + #print(f"Time to set constraint-geometry: {time.time()-timeA} seconds") + print("Now running Relaxed Optimization") + result = Optimizer( + fragment=fragment, theory=theory, + constraints=allconstraints, charge=charge, mult=mult, - ActiveRegion=ActiveRegion, actatoms=actatoms, - result_write_to_disk=False, force_noPBC=force_noPBC, - PBC_format_option=PBC_format_option, + actatoms=actatoms,**opt_arguments, ) energy = float(result.energy) @@ -316,13 +342,13 @@ def calc_surface( # FROM XYZ def calc_surface_fromXYZ( - xyzdir=None, multixyzfile=None, theory=None, charge=None, mult=None, + xyzdir=None, multixyzfile=None, theory=None, charge=None, mult=None, optimizer="geometric", 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, - keepoutputfiles=True, force_noPBC=False, + keepoutputfiles=True, force_noPBC=False, PBC_format_option="CIF", keepmofiles=False, read_mofiles=False, mofilesdir=None, # New ND interface: RC_list=None, @@ -375,6 +401,25 @@ def calc_surface_fromXYZ( module_init_time = time.time() print_line_with_mainheader("CALC_SURFACE_FROMXYZ FUNCTION") + 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={} + + # -- NumGrad wrapping --------------------------------------------------- if NumGrad: print("NumGrad flag detected. Wrapping theory object into NumGrad class") @@ -524,10 +569,10 @@ def build_constraints(rc_vals, frag): results = ash.functions.functions_parallel.Job_parallel(**kwargs) else: # RELAXED - optimizer = GeomeTRICOptimizerClass( - maxiter=maxiter, coordsystem=coordsystem, - convergence_setting=convergence_setting, conv_criteria=conv_criteria, - subfrctor=subfrctor, result_write_to_disk=False, force_noPBC=force_noPBC, + optimizer = Optimizerclass( + maxiter=maxiter, + convergence_setting=convergence_setting, + **opt_arguments, ) kwargs = dict( fragments=surfacepointfragments_list, @@ -582,13 +627,11 @@ def build_constraints(rc_vals, frag): else: # RELAXED allconstraints = build_constraints(rc_vals,mol) - result = geomeTRICOptimizer( + result = Optimizer( 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, - force_noPBC=force_noPBC, + constraints=allconstraints, + convergence_setting=convergence_setting, + charge=charge, mult=mult, **opt_arguments, ) xyzname = f"{label}.xyz" mol.write_xyzfile(xyzfilename=xyzname) @@ -639,30 +682,28 @@ def read_surfacedict_from_file(resultfile, dimension=None): 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 + #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): - """Write surface dictionary to resultfile. - - Each line: RC1_val [RC2_val ...] energy - """ with open(resultfile, 'w') as f: f.write("# Surface scan results\n") f.write("# RC1 [RC2 ...] Energy\n") - print("surfacedictionary.items():", surfacedictionary.items()) - for key, energy in sorted(surfacedictionary.items()): - if isinstance(key, tuple): - rc_str = ' '.join(str(v) for v in key) - else: - rc_str = str(key) + # 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") @@ -828,7 +869,6 @@ def _measure_constraint(fragment, constraint_type, indices): Returns: float — bond length in Å, angle or dihedral in degrees """ - import numpy as np coords = np.array(fragment.coords) # shape (natoms, 3) ct = constraint_type.lower() @@ -899,4 +939,397 @@ def _handle_output_files(theory, pointlabel, keepoutputfiles, keepmofiles): f'surface_mofiles/{theory.filename}_{pointlabel}.gbw', ) except FileNotFoundError: - pass \ No newline at end of file + pass + +def _preset_geometry(fragment, RC_list, rc_values, extraconstraints, + coordsystem, maxiter, ActiveRegion, actatoms, + force_noPBC, PBC_format_option): + """Use ZeroTheory + geomeTRIC to move fragment to the target RC values. + + This is required before calling any optimizer that only freezes the + current geometry value (e.g. DL-FIND) rather than constraining to a + specified target value. + """ + zerotheory = ash.ZeroTheory() + allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) + geomeTRICOptimizer( + fragment=fragment, theory=zerotheory, + constraints=allconstraints, constrainvalue=True, + coordsystem=coordsystem, maxiter=maxiter, + ActiveRegion=ActiveRegion, actatoms=actatoms, + result_write_to_disk=False, + force_noPBC=force_noPBC, PBC_format_option=PBC_format_option, + ) + + + + + + + + +# --------------------------------------------------------------------------- +# 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) + + +# --------------------------------------------------------------------------- +# Top-level dispatcher +# --------------------------------------------------------------------------- + +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 == 'dihedral': + 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 + + +# --------------------------------------------------------------------------- +# Verification helper (optional, useful for debugging) +# --------------------------------------------------------------------------- + +def _verify_geometry(fragment, RC_list, rc_values, tol=1e-3): + coords = np.array(fragment.coords) + print(" RC pre-set verification:") + 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 "" + print( + f" RC{i+1} {rc_type} {indices}: " + f"target={target:.4f} achieved={achieved:.4f} " + f"dev={deviation:.4f}{flag}" + ) \ No newline at end of file From 9cf8f035a796244928820d0e96202c83c440557e Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 24 Mar 2026 12:44:12 +0100 Subject: [PATCH 099/134] fix for veloxchem interface (interface not ready) --- ash/interfaces/interface_veloxchem.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ash/interfaces/interface_veloxchem.py b/ash/interfaces/interface_veloxchem.py index a0279b25b..73ad26400 100644 --- a/ash/interfaces/interface_veloxchem.py +++ b/ash/interfaces/interface_veloxchem.py @@ -4,6 +4,7 @@ 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, cell_vectors_to_params, cell_params_to_vectors import ash.settings_ash @@ -36,9 +37,9 @@ def __init__(self, scf_type="restricted", xcfun=None, basis=None): # basis name self.basis=basis - if xcfun is not None: - print("Setting xcfun to:", xcfun) - scf_drv.xcfun = xcfun + #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, @@ -86,7 +87,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el scf_results = self.scf_drv.compute(molecule, basis) print("scf_results:",scf_results) - + self.energy=None + self.gradient=None # Grad if Grad: From c2a51a48566172f3defc96df881321e4cb397177 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 25 Mar 2026 13:04:30 +0100 Subject: [PATCH 100/134] - Implemented RestraintTheory (currently stored in module_surface_new.py). Globally available. - calc_surface : now can prepare geometry using restraints (set_geometry_via_restraint Boolean). Uses _preset_geometry_restraint and RestraintTHeory --- ash/__init__.py | 2 +- ash/modules/module_surface_new.py | 305 ++++++++++++++++++++++++++++-- 2 files changed, 292 insertions(+), 15 deletions(-) diff --git a/ash/__init__.py b/ash/__init__.py index 669e68a31..d73246d44 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -85,7 +85,7 @@ from .modules.module_oniom import ONIOMTheory # Surface -from .modules.module_surface_new 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 # # QMcode interfaces from .interfaces.interface_ORCA import ORCATheory, counterpoise_calculation_ORCA, ORCA_External_Optimizer, run_orca_plot, MolecularOrbitalGrab, \ diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py index 053223297..48cae1f09 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -13,7 +13,7 @@ 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 # New rewritten calc_surface function def calc_surface( @@ -22,6 +22,7 @@ def calc_surface( 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, @@ -211,13 +212,21 @@ def calc_surface( print("allconstraints:", allconstraints) newfrag = copy.copy(fragment) if optimizer == "dlfind": - print("For DL-FIND we need to modify geometry first to set constraints.") - _set_geometry_direct(newfrag, RC_list, rc_values, conn=conn) + print("For DL-FIND we need to modify geometry first to the desired constraint value.") + print("set_geometry_via_restraint keyword is:", set_geometry_via_restraint) + if set_geometry_via_restraint is True: + print("Modifying geometry to get constraint value via DL-FIND restraint optimization") + _preset_geometry_restraint(newfrag, RC_list, rc_values, optimizer, + opt_arguments, charge, mult,printlevel=1, + force_constant=10000.0) + else: + print("Modifying geometry to get constraint value via coordinate manipulation") + _set_geometry_direct(newfrag, RC_list, rc_values, conn=conn) _verify_geometry(newfrag, RC_list, rc_values) 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, @@ -232,7 +241,7 @@ def calc_surface( f"surface_xyzfiles/{label}.xyz", ) surfacedictionary = result_surface.energies_dict - + print("Parallel calculation done!") print("surfacedictionary:", surfacedictionary) if len(surfacedictionary) != totalnumpoints: @@ -240,7 +249,7 @@ def calc_surface( f"Warning: Dictionary incomplete! " f"Got {len(surfacedictionary)}, expected {totalnumpoints}" ) - + # ----------------------------------------------------------------------- # SERIAL MODE # ----------------------------------------------------------------------- @@ -248,7 +257,7 @@ def calc_surface( print("Serial runmode") zerotheory = ash.ZeroTheory() pointcount = 0 - + for rc_values in itertools.product(*RC_value_lists): pointcount += 1 key = _point_key(rc_values) @@ -282,12 +291,19 @@ def calc_surface( ) else: # RELAXED - # If optimizer is DL-FIND then we have to first modify geometry if optimizer == "dlfind": print("For DL-FIND we need to modify geometry first to set constraints.") - #timeA=time.time() - _set_geometry_direct(fragment, RC_list, rc_values, conn=conn) + if set_geometry_via_restraint is True: + print("Modifying geometry to set constraints via DL-FIND restraint optimization") + # NOTE: passing extraconstraints if any + _preset_geometry_restraint(fragment, RC_list, rc_values, Optimizer, + opt_arguments, charge, mult,printlevel=1, extraconstraints=extraconstraints, + force_constant=10000.0) + else: + print("Modifying geometry to set constraints via coordinate manipulation") + _set_geometry_direct(fragment, RC_list, rc_values, conn=conn) _verify_geometry(fragment, RC_list, rc_values) + #print(f"Time to set constraint-geometry: {time.time()-timeA} seconds") print("Now running Relaxed Optimization") result = Optimizer( @@ -299,7 +315,7 @@ def calc_surface( 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" @@ -1255,7 +1271,7 @@ def _arbitrary_perpendicular(v): # --------------------------------------------------------------------------- -# Top-level dispatcher +# A function to set the geometry directly # --------------------------------------------------------------------------- def _set_geometry_direct(fragment, RC_list, rc_values, conn=None): @@ -1306,7 +1322,7 @@ def _set_geometry_direct(fragment, RC_list, rc_values, conn=None): # --------------------------------------------------------------------------- -# Verification helper (optional, useful for debugging) +# Verifying the set geometry constraint # --------------------------------------------------------------------------- def _verify_geometry(fragment, RC_list, rc_values, tol=1e-3): @@ -1332,4 +1348,265 @@ def _verify_geometry(fragment, RC_list, rc_values, tol=1e-3): f" RC{i+1} {rc_type} {indices}: " f"target={target:.4f} achieved={achieved:.4f} " f"dev={deviation:.4f}{flag}" - ) \ No newline at end of file + ) + + +# --------------------------------------------------------------------------- +# Implementation of a RestraintTheory: alternative way of setting restraints +# --------------------------------------------------------------------------- + +def _preset_geometry_restraint(fragment, RC_list, rc_values, optimizer, + opt_arguments, charge, mult,printlevel=1, extraconstraints=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 + 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}, + {'type': 'angle', 'indices': [1, 0, 2], 'target': 104.5}, + {'type': 'dihedral', 'indices': [0,1,2,3], 'target': 180.0}] + force_constant : harmonic force constant k. 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_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 _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'] + 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 == '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 == 'dihedral': + # 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 From 7f20661afbec1d48b34ebde18f1fba1f1a4492ed Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Wed, 25 Mar 2026 15:37:09 +0100 Subject: [PATCH 101/134] macetheory: seed keyword in MaceTheory.run method (default value 42) --- ash/interfaces/interface_mace.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index b050c0dec..ebe5789b3 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -121,7 +121,7 @@ def get_cell_gradient(self): 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", @@ -164,6 +164,7 @@ def train(self, config_file="config.yml", name="model",model="MACE", platform=No 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) @@ -184,7 +185,7 @@ def train(self, config_file="config.yml", name="model",model="MACE", platform=No 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) @@ -562,7 +563,6 @@ 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", platform='cpu',device=None, valid_fraction=0.1, train_file="train_data_mace.xyz",E0s=None, energy_key='energy_REF', forces_key='forces_REF', From eb518479184448eff22dd1a1ef518a766c8412fb Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 26 Mar 2026 10:35:23 +0100 Subject: [PATCH 102/134] - fix for inertia and dipole in module_freq, linear cases - also pyscf --- ash/interfaces/interface_pyscf.py | 12 ++++---- ash/modules/module_freq.py | 46 +++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index f6aeca141..1d7d5cef1 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -2492,7 +2492,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() @@ -2542,18 +2541,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: - print("here:", self.mf) - if isinstance(self.mf, pyscf.scf.hf.RHF) or isinstance(self.mf, pyscf.dft.rks.RKS) or isinstance(self.mf, pyscf.pbc.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: diff --git a/ash/modules/module_freq.py b/ash/modules/module_freq.py index 5cae17db3..9715a0bb5 100644 --- a/ash/modules/module_freq.py +++ b/ash/modules/module_freq.py @@ -440,7 +440,9 @@ def NumFreq(fragment=None, theory=None, charge=None, mult=None, npoint=2, displa #IR #IR intensities if dipoles available if len(displacement_dipole_dictionary) > 0: - if len(displacement_dipole_dictionary[lookup_string_pos]) > 0: + if None in displacement_dipole_dictionary.values(): + 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 +486,9 @@ 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: + if None in displacement_dipole_dictionary.values(): + 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 +862,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 +1278,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 +1991,9 @@ 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)] + print("rinertia:", rinertia) #Checking if rinertia contains an almost zero-value if any([abs(i) < threshold for i in rinertia]) is True: #print("Small value detected: ", rinertia) From a91b0f799b60740808528f87d60b2b3f707172b5 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sat, 28 Mar 2026 18:13:10 +0100 Subject: [PATCH 103/134] - DLFIND interface: allow fragment to be provided at run. Updates self.fragment, completely changed interface to allow more easily creating object and then running, separate methods for constraints etc. - fix for Numfreq: accounts for scenarios where no dipole-dictionary, None in dipole-dictionary and actually dipole dictionary with Numpy arrays. - OpenMMTheory.run (qm_elems to allow compatibility with WrapTheory) - calc_surface: now using optimizer objects throughout. optimizer keyword now takes options geometric or dlfind as strings but can also take geomeTRICOptimizerClass and DLFINDOptimizerClass objects. Confirmed to work - calc_surface and optimizers now obey print-levels better. Useful for running really long surface scans with printlevel=0 to prevent outputfiles from becomin too big. - plotting functions: minor improvements - tblite: printlevel obeying - geometric and dlinf: fix for constraints not recognizing torsion (only dihedral) --- ash/__init__.py | 4 +- ash/databases/fragments/3fgaba.xyz | 18 + ash/interfaces/interface_OpenMM.py | 6 +- ash/interfaces/interface_dlfind.py | 382 ++++++------ ash/interfaces/interface_geometric_new.py | 15 +- ash/interfaces/interface_xtb.py | 8 +- ash/modules/module_freq.py | 9 +- ash/modules/module_plotting.py | 13 +- ash/modules/module_results.py | 36 +- ash/modules/module_surface_new.py | 681 ++++++++++++++++------ 10 files changed, 794 insertions(+), 378 deletions(-) create mode 100644 ash/databases/fragments/3fgaba.xyz diff --git a/ash/__init__.py b/ash/__init__.py index d73246d44..0960de064 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -85,7 +85,7 @@ from .modules.module_oniom import ONIOMTheory # Surface -from .modules.module_surface_new import calc_surface, calc_surface_fromXYZ, read_surfacedict_from_file, write_surfacedict_to_file, RestraintTheory +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, \ @@ -212,7 +212,7 @@ 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 +from ash.interfaces.interface_dlfind import DLFIND_optimizer,DLFIND_optimizerClass # Other import ash.interfaces.interface_crest 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/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 1e42307a9..f0fabca5e 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -1785,9 +1785,9 @@ def get_cell_gradient(self): 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 diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 469cd48b0..d2ca19e23 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -8,7 +8,7 @@ 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.functions.functions_general import ashexit, blankline,BC,print_time_rel,print_line_with_mainheader,listdiff,search_list_of_lists_for_index,print_if_level from ash.modules.module_coords import check_charge_mult, fullindex_to_actindex,print_internal_coordinate_table,write_xyzfile,elemstonuccharges from ash.modules.module_theory import NumGradclass from ash.modules.module_results import ASH_Results @@ -90,9 +90,9 @@ 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") @@ -107,7 +107,7 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char iopt=3 elif jobtype == "tsopt" or jobtype == "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 @@ -132,21 +132,8 @@ 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 ############# #HESSIAN @@ -189,134 +176,13 @@ 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=[] - - # First dentify possible frozen constraints defined in constraints dict - if self.constraints is not None: - if 'xyz' in self.constraints: - print("XYZ constraints found in constraints dict.", self.constraints['xyz']) - print("Adding to frozenatoms list") - if frozenatoms is None: - frozenatoms=[] - frozenatoms = self.constraints['xyz'] - - if actatoms is not None: - print("Actatoms provided:", actatoms) - if frozenatoms is not None: - if len(frozenatoms) > 0: - print("frozenatoms:", frozenatoms) - print("Error: actatoms and frozenatoms can not both be defined") - ashexit() - 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' or k == 'torsion': - 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 @@ -329,12 +195,15 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char self.NEB_energies_dict={} self.NEB_geometries={} + self.runcounter=0 + + # 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) + energy, gradient = self.theory.run(current_coords=coordinates_ang, elems=self.fragment.elems, charge=self.fragment.charge, mult=self.fragment.mult, Grad=True) # NEB: Storing current geometry for each image # Note: spawned climbing image will be number nimage @@ -381,10 +250,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") @@ -394,8 +263,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) @@ -455,30 +324,175 @@ def store_results(a,nvar,switch, energy, coordinates, iam): #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}") + 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 # 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) + + 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(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(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=[] + + # First dentify possible frozen constraints defined in constraints dict + if self.constraints is not None: + if 'xyz' in self.constraints: + print_if_level(f"XYZ constraints found in constraints dict. {self.constraints['xyz']}", self.printlevel,2 ) + print_if_level("Adding to frozenatoms list", self.printlevel,2) + if self.frozenatoms is None: + frozenatoms=[] + frozenatoms = self.constraints['xyz'] + + if self.actatoms is not None: + print_if_level("Actatoms provided:", self.actatoms) + if self.frozenatoms is not None: + if len(self.frozenatoms) > 0: + print("frozenatoms:", self.frozenatoms) + print("Error: actatoms and frozenatoms can not both be defined") + ashexit() + print_if_level(f"All atoms: {self.fragment.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 ) + + for i in self.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_if_level("Case: no actatoms or frozenatoms provided. All atoms will be active.", self.printlevel,2) + print_if_level(f"All atoms: {self.fragment.allatoms}", self.printlevel,2) + if self.residues is None: + self.spec=[1 for i in list(range(self.fragment.numatoms))] + else: + print_if_level("Residues provided:", self.residues, self.printlevel,2) + for i in self.fragment.allatoms: + resid = search_list_of_lists_for_index(i,self.residues) + self.spec.append(resid+1) + + # Nuclear charges + nuccharges = elemstonuccharges(self.fragment.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: {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 + self.spec=self.spec+[1 for i in list(range(self.fragment.numatoms))] #? + + self.nspec=len(self.spec) + + + def prepare_run(self): + + from libdlfind.callback import make_dlf_get_params self.traj_energies = [] self.current_geo = [] positions = self.fragment.coords * 1.88972612546 + + # 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, @@ -486,41 +500,59 @@ def store_results(a,nvar,switch, energy, coordinates, iam): 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: {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: + # Update self fragment if a run fragment was provided + if fragment is not None: + self.fragment=fragment + + if fragment2 is None and self.fragment2 is None: nvarin=self.fragment.numatoms * 3 nvarin2=0 - else: - # Fragment 1 and 2 + 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 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 + + if self.runcounter == 0: + self.print_settings() + + # Prepare run, including constraints etc. + self.prepare_run() + + charge, 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") dl_find( @@ -555,7 +587,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) if self.result_write_to_disk is True: - result.write_to_disk(filename="DLFIND_optimizer.result") + result.write_to_disk(filename="DLFIND_optimizer.result", printlevel=self.printlevel) return result elif self.icoord >= 100 and self.icoord < 150: diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index a7ff02c42..8f4034f23 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -243,7 +243,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) @@ -262,7 +262,12 @@ 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: @@ -350,7 +355,6 @@ 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') - def cleanup(self): #Clean-up before we begin tmpfiles=['geometric_OPTtraj.log','geometric_OPTtraj.xyz','geometric_OPTtraj_Full.xyz','geometric_OPTtraj_QMregion.xyz', 'optimization_energies.log', @@ -509,11 +513,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") diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index cd102547f..c73c649ac 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -1298,8 +1298,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el #Run if self.autostart is True and self.results is not None: - print("Auto-starting tblite calculation using previous results object") - print("Warning: if this leads to problems, set autostart=False in tbliteTheory") + 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") @@ -1330,8 +1330,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el self.gradient = self.results.get("gradient") if Grad: - print_time_rel(module_init_time, modulename='tblite run', moduleindex=2) + 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) + 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/modules/module_freq.py b/ash/modules/module_freq.py index 9715a0bb5..6a456f94f 100644 --- a/ash/modules/module_freq.py +++ b/ash/modules/module_freq.py @@ -437,10 +437,11 @@ 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 None in displacement_dipole_dictionary.values(): + # 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]) @@ -486,7 +487,9 @@ 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 None in displacement_dipole_dictionary.values(): + # 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]) diff --git a/ash/modules/module_plotting.py b/ash/modules/module_plotting.py index 86c291eac..f8ec8b03b 100644 --- a/ash/modules/module_plotting.py +++ b/ash/modules/module_plotting.py @@ -308,9 +308,8 @@ def reactionprofile_plot(surfacedictionary, finalunit=None,label='Label', x_axis e.append(surfacedictionary[key]) if RelativeEnergy is True: - if finalunit == None: - print("RelativeEnergy is True but finalunit not provided. Exiting.") - ashexit() + print("RelativeEnergy option. Using finalunit:", finalunit) + print("Other options are:", conversionfactor) #List of energies and relenergies here refenergy=float(min(e)) rele=[] @@ -380,6 +379,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: @@ -645,7 +645,7 @@ def MOplot_vertical(mos_dict, pointsize=4000, linewidth=2, label="Label", yrange print("Created plot:", label+"."+imageformat) -def volumeplot(surfacedictionary, x_axislabel='X', y_axislabel='Y', z_axislabel='Z', +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", @@ -668,6 +668,7 @@ def volumeplot(surfacedictionary, x_axislabel='X', y_axislabel='Y', z_axislabel= #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) ────────────── @@ -715,10 +716,10 @@ def volumeplot(surfacedictionary, x_axislabel='X', y_axislabel='Y', z_axislabel= ) # Save PNG - fig.write_image(f"surface.{imageformat}") + fig.write_image(f"{filename}.{imageformat}") # Save HTML - fig.write_html("surface.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 01071baaf..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,38 +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 try: f.write(json.dumps(newdict, allow_nan=True)) except TypeError as e: - print("Error writing ASH_Results to disk:", e) - print("Skipping writing to disk") + 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 index 48cae1f09..5a669c6fe 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -6,7 +6,8 @@ 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 +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, write_CIF_file, write_POSCAR_file, write_XSF_file from ash.modules.module_results import ASH_Results @@ -17,7 +18,7 @@ # New rewritten calc_surface function def calc_surface( - fragment=None, theory=None, charge=None, mult=None, optimizer='geometric', + 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, @@ -73,44 +74,78 @@ def calc_surface( """ module_init_time = time.time() print_line_with_mainheader("CALC_SURFACE FUNCTION") - - 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, - 'ActiveRegion': ActiveRegion, - 'constrainvalue':True, 'result_write_to_disk':False, - } - elif optimizer.lower() in ['dlfind','dl-find']: - print("Optimizer to use for surface scan: DL-FIND") - Optimizer=DLFIND_optimizer - Optimizerclass=DLFIND_optimizerClass - opt_arguments={'maxcycle':maxiter,'iopt':3, 'icoord':1} - # Build connectivity once - conn = _build_connectivity(fragment.coords, fragment.elems) + + # NOW SETTING UP OPTIMIZER + + 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") + #Optimizer=DLFIND_optimizer + #Optimizerclass=DLFIND_optimizerClass + 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 + 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 + 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 else: - print("Wrong optimizer option chosen. Valid options are: geometric and dlfind") + 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( @@ -128,18 +163,18 @@ def calc_surface( 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( @@ -149,10 +184,10 @@ def calc_surface( 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 # ----------------------------------------------------------------------- @@ -161,9 +196,9 @@ def calc_surface( 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() @@ -175,16 +210,30 @@ def calc_surface( print(f"======= Surfacepoint {pointcount}/{totalnumpoints}: {label} =======") if key in surfacedictionary: continue - allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) - print("allconstraints:", allconstraints) - Optimizer( - fragment=fragment, theory=zerotheory, - constraints=allconstraints, - actatoms=actatoms, - **opt_arguments, - ) + 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}") @@ -198,8 +247,6 @@ def calc_surface( elif scantype.upper() == 'RELAXED': print("Warning: Relaxed scans in parallel mode are experimental") - optimizer = Optimizerclass( - actatoms=actatoms, **opt_arguments) pointcount = 0 for rc_values in itertools.product(*RC_value_lists): pointcount += 1 @@ -208,21 +255,25 @@ def calc_surface( print(f"======= Surfacepoint {pointcount}/{totalnumpoints}: {label} =======") if key in surfacedictionary: continue - allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) - print("allconstraints:", allconstraints) + 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) - if optimizer == "dlfind": - print("For DL-FIND we need to modify geometry first to the desired constraint value.") - print("set_geometry_via_restraint keyword is:", set_geometry_via_restraint) + 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("Modifying geometry to get constraint value via DL-FIND restraint optimization") - _preset_geometry_restraint(newfrag, RC_list, rc_values, optimizer, - opt_arguments, charge, mult,printlevel=1, + 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("Modifying geometry to get constraint value via coordinate manipulation") + 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) + _verify_geometry(newfrag, RC_list, rc_values, printlevel=printlevel) newfrag.label = key newfrag.constraints = allconstraints surfacepointfragments_list.append(newfrag) @@ -262,57 +313,69 @@ def calc_surface( 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: using ZeroTheory + Optimizer to set geometry.") + 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) - print("allconstraints:", allconstraints) - + + 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': - Optimizer( - fragment=fragment, theory=zerotheory, - constraints=allconstraints, - charge=charge, mult=mult, - actatoms=actatoms, **opt_arguments, - ) + # 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 optimizer == "dlfind": - print("For DL-FIND we need to modify geometry first to set constraints.") + if presetting_geometry_required: + print_if_level(f"For DL-FIND we need to modify geometry first to set constraints.", printlevel,2) if set_geometry_via_restraint is True: - print("Modifying geometry to set constraints via DL-FIND restraint optimization") + 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, Optimizer, + _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("Modifying geometry to set constraints via coordinate manipulation") + 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) + _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) + # Running optimizer object + + #Running optimizer object, passing theory, fragment, constraints and possible extra kws + result = optimizerobj.run(theory=theory,fragment=fragment, constraints=allconstraints, **extraoopt_run_kws) - #print(f"Time to set constraint-geometry: {time.time()-timeA} seconds") - print("Now running Relaxed Optimization") - result = Optimizer( - fragment=fragment, theory=theory, - constraints=allconstraints, - charge=charge, mult=mult, - actatoms=actatoms,**opt_arguments, - ) - energy = float(result.energy) print(f" {label} Energy: {energy}") @@ -321,7 +384,7 @@ def calc_surface( xyzname = f"{label}.xyz" fragment.write_xyzfile(xyzfilename=xyzname) shutil.move(xyzname, f"surface_xyzfiles/{xyzname}") - _handle_output_files(theory, label, keepoutputfiles, keepmofiles) + _handle_output_files(theory, label, keepoutputfiles, keepmofiles, printlevel=printlevel) _handle_pbc(theory, fragment, label, convert_to_pbcfile) surfacedictionary[key] = float(energy) @@ -416,24 +479,33 @@ def calc_surface_fromXYZ( """ module_init_time = time.time() print_line_with_mainheader("CALC_SURFACE_FROMXYZ FUNCTION") - - 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 + 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 --------------------------------------------------- @@ -585,11 +657,14 @@ def build_constraints(rc_vals, frag): results = ash.functions.functions_parallel.Job_parallel(**kwargs) else: # RELAXED - optimizer = Optimizerclass( - maxiter=maxiter, - convergence_setting=convergence_setting, - **opt_arguments, - ) + if obt_object is True: + print("An optimizer object was provided.") + else: + optimizer = Optimizerclass( + maxiter=maxiter, + convergence_setting=convergence_setting, + **opt_arguments, + ) kwargs = dict( fragments=surfacepointfragments_list, theories=[theory], @@ -655,7 +730,7 @@ def build_constraints(rc_vals, frag): energy = float(result.energy) print(f"Energy of {relfile}: {energy} Eh") - _handle_output_files(theory, label, keepoutputfiles, keepmofiles) + _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) @@ -802,7 +877,7 @@ 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): +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: @@ -848,25 +923,28 @@ def set_constraints_nd(RC_list, rc_values, extraconstraints=None, fragment=None) elif expected_natoms is not None and len(entry) == expected_natoms: # No value — measure from current geometry or error if fragment is None: - 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." - ) + 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) - print( - f"extraconstraint '{constraint_type}' {entry}: " - f"no value provided, using current geometry value {val:.6f}" - ) + 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 - print( - f"Warning: cannot determine whether value is present for " - f"extraconstraint type '{constraint_type}', entry {entry}. " - f"Appending as-is." - ) + 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: @@ -931,7 +1009,7 @@ def _handle_pbc(theory, fragment, pointlabel, convert_to_pbcfile): ext = pbcfile.split('.')[-1] shutil.move(pbcfile, f"surface_pbcfiles/{pointlabel}.{ext}") -def _handle_output_files(theory, pointlabel, keepoutputfiles, keepmofiles): +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: @@ -944,7 +1022,7 @@ def _handle_output_files(theory, pointlabel, keepoutputfiles, keepmofiles): f'surface_outfiles/{theory.filename}_{pointlabel}.out', ) except TypeError: - print("Theory has no outputfile, probably. ignoring") + print_if_level("Theory has no outputfile, probably. ignoring", printlevel,2) pass except FileNotFoundError: pass @@ -957,26 +1035,6 @@ def _handle_output_files(theory, pointlabel, keepoutputfiles, keepmofiles): except FileNotFoundError: pass -def _preset_geometry(fragment, RC_list, rc_values, extraconstraints, - coordsystem, maxiter, ActiveRegion, actatoms, - force_noPBC, PBC_format_option): - """Use ZeroTheory + geomeTRIC to move fragment to the target RC values. - - This is required before calling any optimizer that only freezes the - current geometry value (e.g. DL-FIND) rather than constraining to a - specified target value. - """ - zerotheory = ash.ZeroTheory() - allconstraints = set_constraints_nd(RC_list, rc_values, extraconstraints, fragment=fragment) - geomeTRICOptimizer( - fragment=fragment, theory=zerotheory, - constraints=allconstraints, constrainvalue=True, - coordsystem=coordsystem, maxiter=maxiter, - ActiveRegion=ActiveRegion, actatoms=actatoms, - result_write_to_disk=False, - force_noPBC=force_noPBC, PBC_format_option=PBC_format_option, - ) - @@ -1307,7 +1365,7 @@ def _set_geometry_direct(fragment, RC_list, rc_values, conn=None): i, j, k = indices _set_angle(coords, i, j, k, float(target), conn) - elif rc_type == 'dihedral': + elif rc_type in ('dihedral', 'torsion'): i, j, k, l = indices _set_dihedral(coords, i, j, k, l, float(target), conn) @@ -1325,9 +1383,9 @@ def _set_geometry_direct(fragment, RC_list, rc_values, conn=None): # Verifying the set geometry constraint # --------------------------------------------------------------------------- -def _verify_geometry(fragment, RC_list, rc_values, tol=1e-3): +def _verify_geometry(fragment, RC_list, rc_values, tol=1e-3, printlevel=2): coords = np.array(fragment.coords) - print(" RC pre-set verification:") + 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']: @@ -1344,19 +1402,21 @@ def _verify_geometry(fragment, RC_list, rc_values, tol=1e-3): else: continue flag = " <-- WARNING" if deviation > tol else "" - print( - f" RC{i+1} {rc_type} {indices}: " - f"target={target:.4f} achieved={achieved:.4f} " - f"dev={deviation:.4f}{flag}" - ) + 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, optimizer, +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 = [] @@ -1377,11 +1437,13 @@ def _preset_geometry_restraint(fragment, RC_list, rc_values, optimizer, 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 - optimizer( - fragment=fragment, theory=restraint_theory, constraints=extraconstraints, - charge=charge, mult=mult, printlevel=printlevel, - **preset_args, - ) + 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, + #) @@ -1409,10 +1471,11 @@ def __init__(self, fragment=None, printlevel=None, numcores=1, label=None, 'target' : target value (Å for bonds, degrees for angles/dihedrals) Example: - [{'type': 'bond', 'indices': [0, 1], 'target': 1.2}, - {'type': 'angle', 'indices': [1, 0, 2], 'target': 104.5}, - {'type': 'dihedral', 'indices': [0,1,2,3], 'target': 180.0}] - force_constant : harmonic force constant k. Defaults to 10000.0. + [{'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. @@ -1568,6 +1631,7 @@ def run(self, current_coords=None, elems=None, Grad=False, PC=False, 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'): @@ -1592,7 +1656,7 @@ def run(self, current_coords=None, elems=None, Grad=False, PC=False, dqdX *= np.pi / 180.0 # → rad/Bohr gradient += k * dq * dqdX # Eh/Bohr - elif rtype == 'dihedral': + 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 @@ -1610,3 +1674,292 @@ def run(self, current_coords=None, elems=None, Grad=False, PC=False, 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 From abda82ae131c56fe257fdea79ccd90993404551c Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sat, 28 Mar 2026 18:32:30 +0100 Subject: [PATCH 104/134] fix flake8 complaints --- ash/interfaces/interface_dlfind.py | 4 ++-- ash/modules/module_surface_new.py | 15 ++++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index d2ca19e23..2f5c25476 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -356,11 +356,11 @@ def print_settings(self): #Print-atoms list not specified. What to do: if self.actatoms is not None: #If QM/MM object then QM-region: - if isinstance(theory,QMMMTheory): + 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(theory,ONIOMTheory): + 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] diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py index 5a669c6fe..4acadf2d5 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -422,7 +422,7 @@ def calc_surface( # FROM XYZ def calc_surface_fromXYZ( xyzdir=None, multixyzfile=None, theory=None, charge=None, mult=None, optimizer="geometric", - dimension=None, resultfile='surface_results.txt', + 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, @@ -657,14 +657,11 @@ def build_constraints(rc_vals, frag): results = ash.functions.functions_parallel.Job_parallel(**kwargs) else: # RELAXED - if obt_object is True: - print("An optimizer object was provided.") - else: - optimizer = Optimizerclass( - maxiter=maxiter, - convergence_setting=convergence_setting, - **opt_arguments, - ) + optimizer = Optimizerclass( + maxiter=maxiter, + convergence_setting=convergence_setting, + **opt_arguments, + ) kwargs = dict( fragments=surfacepointfragments_list, theories=[theory], From fac4346a98ec0a3a7a7eea636e1baac3f59b470f Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sun, 29 Mar 2026 22:14:35 +0200 Subject: [PATCH 105/134] - Restructuring: moved all PBC coord functions to module_coords_PBC, updated imports - DL-FIND optimizer: support for PBC - Proper internal-coordinate table - Renamed Periodic_optimizer_cart to Cart_optimizer. Now supports both PBC and non-PBC. - calc_surface: now supporting Cart_optimizer (but not working yet) --- ash/__init__.py | 2 +- ash/functions/functions_optimization.py | 405 ++++++++++------ ash/interfaces/interface_CP2K.py | 8 +- ash/interfaces/interface_DFTB.py | 2 +- ash/interfaces/interface_GPAW.py | 2 +- ash/interfaces/interface_ORCA.py | 4 +- ash/interfaces/interface_OpenMM.py | 5 +- ash/interfaces/interface_Turbomole.py | 3 +- ash/interfaces/interface_dlfind.py | 284 +++++++++--- ash/interfaces/interface_fairchem.py | 3 +- ash/interfaces/interface_geometric_new.py | 51 +- ash/interfaces/interface_mace.py | 2 +- ash/interfaces/interface_pyscf.py | 3 +- ash/interfaces/interface_sella.py | 2 +- ash/interfaces/interface_torch.py | 3 +- ash/interfaces/interface_veloxchem.py | 3 +- ash/interfaces/interface_xtb.py | 13 +- ash/modules/module_coords.py | 542 ++++++++-------------- ash/modules/module_coords_PBC.py | 244 ++++++++++ ash/modules/module_surface_new.py | 28 +- 20 files changed, 996 insertions(+), 613 deletions(-) create mode 100644 ash/modules/module_coords_PBC.py diff --git a/ash/__init__.py b/ash/__init__.py index 0960de064..3bdfa994c 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -176,7 +176,7 @@ from .modules.module_molcrys import molcrys, Fragmenttype # Geometry optimization -from .functions.functions_optimization import SimpleOpt, BernyOpt, periodic_optimizer_alternating, Periodic_optimizer_cart +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 diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index dfa9c8297..6c2ac8146 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -4,13 +4,12 @@ import ash.constants from ash.functions.functions_general import ashexit, blankline,print_time_rel_and_tot,BC,listdiff,print_time_rel -from ash.modules.module_coords import write_xyzfile -from ash.modules.module_coords import check_charge_mult, cell_vectors_to_params, cell_params_to_vectors, cart_coords_to_fract, fract_coords_to_cart, cell_volume -from ash.modules.module_coords import print_coords_for_atoms,write_CIF_file,write_XSF_file, write_POSCAR_file +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 from ash.interfaces.interface_geometric_new import geomeTRICOptimizer -from ash.modules.module_theory import NumGradclass -#import ash - +from ash.modules.module_results import ASH_Results #Root mean square of numpy array, e.g. gradient def RMS_G(grad): @@ -563,39 +562,41 @@ def periodic_optimizer_alternating(fragment=None, theory=None, rate=0.5, maxiter # Cartesian-based periodic cell optimizer -# Wrapper function around Periodic_optimizer_cart_class -def Periodic_optimizer_cart(fragment=None, theory=None, rate=2.0, +# 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, printlevel=2, conv_criteria=None, PBC_format_option="CIF", - constraints=None, frozen_atoms=None): + constraints=None, frozen_atoms=None, result_write_to_disk=True): """ - Wrapper function around Periodic_optimizer_cart_class + Wrapper function around Cart_optimizer_class """ timeA=time.time() # EARLY EXIT if theory is None or fragment is None: - print("Periodic_optimizer_cart requires theory and fragment objects provided. Exiting.") + print("Cart_optimizer requires theory and fragment objects provided. Exiting.") ashexit() - optimizer=Periodic_optimizer_cart_class(fragment=fragment, theory=theory, rate=rate, scaling_rate_cell=scaling_rate_cell, + 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, - printlevel=printlevel, conv_criteria=conv_criteria, constraints=constraints, frozen_atoms=frozen_atoms) + printlevel=printlevel, conv_criteria=conv_criteria, constraints=constraints, + frozen_atoms=frozen_atoms, result_write_to_disk=result_write_to_disk) result = optimizer.run() if printlevel >= 1: - print_time_rel(timeA, modulename='Periodic_optimizer_cart', moduleindex=1) + print_time_rel(timeA, modulename='Cart_optimizer', moduleindex=1) return result -class Periodic_optimizer_cart_class: +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, - PBC_format_option="CIF", constraints=None, frozen_atoms=None): + max_step=0.25, momentum=0.5, printlevel=2, conv_criteria=None, print_atoms_list=None, + PBC_format_option="CIF", constraints=None, constrain_method='hard', + frozen_atoms=None, result_write_to_disk=True): self.fragment = fragment self.theory = theory @@ -607,8 +608,11 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m 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' # Default force constant for soft restraints (eV/Ų or Eh/Ų — match your units) self.default_k = 10.0 # Frozen atoms @@ -636,27 +640,38 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m print("Maxiter:", self.maxiter) print(f"Max step size {self.max_step} Å") print() - print(f"Initial cell vectors in Theory object: {theory.periodic_cell_vectors} Å") - self.cell_vectors_au = theory.periodic_cell_vectors*self.ang2bohr - self.cell_vectors = theory.periodic_cell_vectors - self.elems_phys=fragment.elems + 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 - ################ - # INITIAL STUFF - ################ + #---- 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) + 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() - print("H_ref:",self. H_ref) self.H_ref_inv = np.linalg.inv(self.H_ref) - print("H_ref_inv:", self.H_ref_inv) def apply_frozen_atoms(self, gradient): """ @@ -700,18 +715,16 @@ def apply_bond_constraints(self, coords, gradient, energy): # 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: - if c.get('type') != 'bond': - continue + for c in self.constraints['bond']: print("Applying bond constraint") - i, j = c['atoms'] - r0 = c['target'] # target bond length in Å - method = c.get('method', 'hard') - k = c.get('k', self.default_k) # only used for soft - + i, j, r0 = c + #print("i, j:", i, j) + #print("r0:", r0) + #k = c.get('k', self.default_k) # only used for soft # Current bond vector and length - rij = coords[i] - coords[j] # (3,) + 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.") @@ -720,7 +733,7 @@ def apply_bond_constraints(self, coords, gradient, energy): delta = d - r0 # signed deviation in Å - if method == 'soft': + 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 @@ -731,7 +744,7 @@ def apply_bond_constraints(self, coords, gradient, energy): print(f" Soft constraint ({i},{j}): d={d:.4f} Å target={r0:.4f} Å " f"delta={delta:.4f} Å penalty={0.5*k*delta**2:.6f}") - elif method == 'hard': + 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 @@ -745,7 +758,7 @@ def apply_bond_constraints(self, coords, gradient, energy): 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 '{method}'. Use 'hard' or 'soft'.") + print(f"Unknown constraint method '{self.constrain_method}'. Use 'hard' or 'soft'.") return energy_out, grad_out @@ -759,19 +772,18 @@ def apply_angle_constraints(self, coords, gradient, energy): grad_out = gradient.copy() energy_out = energy + coords_au = coords * self.ang2bohr - for c in self.constraints: - if c.get('type') != 'angle': - continue + for c in self.constraints['angle']: print("Applying angle constraint") - i, j, k = c['atoms'] # centre atom is j - theta0 = np.deg2rad(c['target']) - method = c.get('method', 'hard') - kf = c.get('k', self.default_k) + i, j, k, theta0_deg = c # centre atom is j + print("theta0_deg:", theta0_deg) + theta0 = np.deg2rad(theta0_deg) + #kf = c.get('k', self.default_k) # Bond vectors pointing away from centre j - u = coords[i] - coords[j] - v = coords[k] - coords[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) @@ -799,7 +811,7 @@ def apply_angle_constraints(self, coords, gradient, energy): delta = theta - theta0 # deviation in radians - if method == 'soft': + 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 @@ -809,7 +821,7 @@ def apply_angle_constraints(self, coords, gradient, energy): f"target={np.rad2deg(theta0):.3f}° delta={np.rad2deg(delta):.3f}° " f"penalty={0.5*kf*delta**2:.6f}") - elif method == 'hard': + 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) @@ -833,20 +845,19 @@ def apply_torsion_constraints(self, coords, gradient, energy): grad_out = gradient.copy() energy_out = energy - - for c in self.constraints: - if c.get('type') != 'torsion': - continue + coords_au = coords * self.ang2bohr + for c in self.constraints['dihedral']: + print("c:", c) print("Applying torsion constraint") - i, j, k, l = c['atoms'] - phi0 = np.deg2rad(c['target']) - method = c.get('method', 'hard') - kf = c.get('k', self.default_k) + i, j, k, l, phi0_deg = c + print("i, j, k, l:", i, j, k, l) + print("phi0_deg:", phi0_deg) + phi0 = np.deg2rad(phi0_deg) # Bond vectors along the chain - b1 = coords[j] - coords[i] - b2 = coords[k] - coords[j] - b3 = coords[l] - coords[k] + b1 = coords_au[j] - coords_au[i] + b2 = coords_au[k] - coords_au[j] + b3 = coords_au[l] - coords_au[k] # Normal vectors to the two planes n1 = np.cross(b1, b2) @@ -886,7 +897,7 @@ def apply_torsion_constraints(self, coords, gradient, energy): dphi_drk = ( (1.0 - np.dot(b3,b2)/lb2_sq) * (lb2/ln2**2) * n2 -( np.dot(b1,b2)/lb2_sq) * (lb2/ln1**2) * n1 ) - if method == 'soft': + if self.constrain_method == 'soft': energy_out += 0.5 * kf * delta**2 grad_out[i] += kf * delta * dphi_dri grad_out[j] += kf * delta * dphi_drj @@ -897,16 +908,37 @@ def apply_torsion_constraints(self, coords, gradient, energy): f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}° " f"penalty={0.5*kf*delta**2:.6f}") - elif method == 'hard': - for idx, dphi_dr in [(i, dphi_dri), (j, dphi_drj), - (k, dphi_drk), (l, dphi_drl)]: - norm = np.linalg.norm(dphi_dr) - if norm > 1e-10: - n_hat = dphi_dr / norm - grad_out[idx] -= np.dot(grad_out[idx], n_hat) * n_hat - if self.printlevel >= 2: - print(f" Hard torsion ({i},{j},{k},{l}): φ={np.rad2deg(phi):.3f}° " - f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}°") + elif self.constrain_method == 'hard': + # Tolerance within which we switch from soft-drive to hard-project + tol = np.deg2rad(2.0) # default 2° + + if abs(delta) > tol: + print("not yet target") + # Not yet at target: drive toward it with a stiff soft penalty + #kf_drive = c.get('k_drive', 50.0) # Eh/rad², stiff + kf_drive = 0.05 + energy_out += 0.5 * kf_drive * delta**2 + grad_out[i] += kf_drive * delta * dphi_dri + grad_out[j] += kf_drive * delta * dphi_drj + grad_out[k] += kf_drive * delta * dphi_drk + grad_out[l] += kf_drive * delta * dphi_drl + if self.printlevel >= 2: + print(f" Hard torsion ({i},{j},{k},{l}): φ={np.rad2deg(phi):.3f}° " + f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}° " + f"[driving, |delta| > tol={np.rad2deg(tol):.1f}°]") + else: + print(" Within tolerance for hard torsion constraint, switching to projection.") + # Close enough: project out the torsion-changing gradient component + for idx, dphi_dr in [(i, dphi_dri), (j, dphi_drj), + (k, dphi_drk), (l, dphi_drl)]: + norm = np.linalg.norm(dphi_dr) + if norm > 1e-10: + n_hat = dphi_dr / norm + grad_out[idx] -= np.dot(grad_out[idx], n_hat) * n_hat + if self.printlevel >= 2: + print(f" Hard torsion ({i},{j},{k},{l}): φ={np.rad2deg(phi):.3f}° " + f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}° " + f"[projecting, |delta| <= tol={np.rad2deg(tol):.1f}°]") return energy_out, grad_out @@ -1002,7 +1034,12 @@ def split_coords(self,supercoords): 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) @@ -1096,119 +1133,205 @@ def compute_step(self,gradient,currcoords): return delta_au - def run(self): + def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=None): - # Defining initial super coords - currcoords = np.concatenate([ - self.fragment.coords, # (N, 3) - np.zeros((1, 3)), # (1, 3) - self.theory.periodic_cell_vectors, # (3, 3) - ], axis=0) - print("currcoords:", currcoords) + # 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 + + 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("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("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)) - try: - os.remove("PBC_opt_traj.xyz") - except: - pass + for file in ["Fragment-currentgeo.xyz", "PBC_opt_traj.xyz", "NonPBC_opt_traj.xyz"]: + try: + os.remove(file) + except: + pass # LOOP for iteration in range(0,self.maxiter): self.iteration=iteration print("="*40) - print("Periodic optimization step", iteration) + print(f"{opt_type_label} optimization step", iteration) print("="*40) - ######################################### - # 0. Splitting currcoords into atoms and lattice - # Update and print - ######################################### - currcoords_au = currcoords*self.ang2bohr - R_phys, H_geo = self.split_coords(currcoords) + 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.theory.update_cell(H_geo) 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="PBC_opt_traj.xyz",writemode="a") + 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("") - print(f"Current cell vectors (Å):{self.theory.periodic_cell_vectors}") - print(f"Current cell volume (Å):{cell_volume(H_geo)}") + if self.PBC: + print(f"Current cell vectors (Å):{self.theory.periodic_cell_vectors}") + print(f"Current cell volume (Å):{cell_volume(H_geo)}") ######################################### # 1. Compute energy and gradient ######################################### - energy, supergradient = self.calculate_supergradient(currcoords) - + if self.PBC: + energy, supergradient = self.calculate_supergradient(currcoords) + else: + energy, supergradient = self.calculate_reg_gradient(currcoords) + print("1 energy:", energy) + print("1 supergradient:", supergradient) + prev_supgrad = supergradient.copy() # 1b. Apply all constraints if self.constraints: print("Applying constraints...") - energy, supergradient = self.apply_bond_constraints(R_phys, supergradient, energy) - energy, supergradient = self.apply_angle_constraints(R_phys, supergradient, energy) - energy, supergradient = self.apply_torsion_constraints(R_phys, supergradient, energy) + print("self.constraints:", self.constraints) + if 'bond' in self.constraints or 'distance' in self.constraints: + energy, supergradient = self.apply_bond_constraints(R_phys, supergradient, energy) + if 'angle' in self.constraints: + energy, supergradient = self.apply_angle_constraints(R_phys, supergradient, energy) + if 'torsion' in self.constraints or 'dihedral' in self.constraints: + if 'torsion' in self.constraints: + # Renaming for clarity + self.constraints['dihedral'] = self.constraints.pop('torsion') + energy, supergradient = self.apply_torsion_constraints(R_phys, supergradient, energy) + print("2 energy:", energy) + print("2 supergradient:", supergradient) + + print("delta gradient after constraints:", supergradient-prev_supgrad) + # 1c. Apply frozen atoms if self.frozen_atoms: supergradient = self.apply_frozen_atoms(supergradient) ######################################### - # 2. Check convergence of cell gradient + # 2. Check convergence ######################################### - #grad_norm = np.linalg.norm(supergradient) - #grad_norm_atoms = np.linalg.norm(supergradient[:-4]) - #grad_norm_atoms_cell = np.linalg.norm(supergradient[-3:]) - #grad_rms = np.sqrt(np.mean(supergradient**2)) grad_rms_atoms = np.sqrt(np.mean(supergradient[:-4]**2)) - grad_rms_cell = np.sqrt(np.mean(supergradient[-3:]**2)) - #grad_max = abs(max(supergradient.min(), supergradient.max(), key=abs)) grad_max_atoms = abs(max(supergradient[:-4].min(), supergradient[:-4].max(), key=abs)) - grad_max_cell = abs(max(supergradient[-3:].min(), supergradient[-3:].max(), key=abs)) - #print(f"Current total Gradient Norm: {grad_norm:.6f}") - - 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(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(f"Final energy: {energy} Eh") - - 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}") - break + + if self.PBC: + 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: + 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") + + # 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: + 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 delta_au = self.compute_step(supergradient,currcoords) print("Computed step:", delta_au) - # 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(f"Lattice-specific scaling applied: {scale_latt:.3f}") + 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(f"Lattice-specific scaling applied: {scale_latt:.3f}") # Scale down step if required if np.max(np.abs(delta_au)) > self.max_step_au: diff --git a/ash/interfaces/interface_CP2K.py b/ash/interfaces/interface_CP2K.py index 0ccbb130d..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 @@ -106,11 +108,9 @@ def __init__(self, cp2kdir=None, cp2k_bin_name=None, filename='cp2k', printlevel print("periodic_cell_dimensions:", cell_dimensions) self.periodic_cell_dimensions = cell_dimensions # Convert to cell vectors - from ash.modules.module_coords import cell_params_to_vectors self.periodic_cell_vectors = cell_params_to_vectors(cell_dimensions) elif cell_vectors is not None: self.periodic_cell_vectors = cell_vectors - from ash.modules.module_coords import cell_vectors_to_params self.periodic_cell_dimensions = cell_vectors_to_params(cell_vectors) else: print("Periodic is False") @@ -298,12 +298,10 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): if periodic_cell_vectors is not None: self.periodic_cell_vectors = periodic_cell_vectors - from ash.modules.module_coords import cell_vectors_to_params 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 - from ash.modules.module_coords import cell_params_to_vectors self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) def get_cell_gradient(self): diff --git a/ash/interfaces/interface_DFTB.py b/ash/interfaces/interface_DFTB.py index 22a239241..bf991079f 100644 --- a/ash/interfaces/interface_DFTB.py +++ b/ash/interfaces/interface_DFTB.py @@ -6,7 +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 import cell_params_to_vectors, cell_vectors_to_params +from ash.modules.module_coords_PBC import cell_params_to_vectors, cell_vectors_to_params import ash.settings_ash # Basic interface to DFTB+ 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..086a81054 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 @@ -361,7 +361,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) diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index f0fabca5e..f088322d9 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -16,8 +16,9 @@ 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, \ - cell_params_to_vectors, cell_vectors_to_params, define_dummy_topology + change_origin_to_centroid, get_centroid, check_charge_mult, check_gradient_for_bad_atoms, get_molecule_members_loop_np2, define_dummy_topology + +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_ORCA import ORCATheory, grabatomcharges_ORCA, chargemodel_select diff --git a/ash/interfaces/interface_Turbomole.py b/ash/interfaces/interface_Turbomole.py index 068eb5bd9..5cd66cb90 100644 --- a/ash/interfaces/interface_Turbomole.py +++ b/ash/interfaces/interface_Turbomole.py @@ -5,7 +5,8 @@ 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, cell_vectors_to_params, cell_params_to_vectors +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 diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 2f5c25476..45998a2ca 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -8,8 +8,9 @@ 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,print_if_level -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 @@ -135,6 +136,10 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char self.fragment2=fragment2 self.theory=theory + # Periodic + self.PBC_format_option=PBC_format_option + self.PBC=False # False by default unless detected in theory + ############# #HESSIAN ############# @@ -203,15 +208,99 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char def ash_e_g_func(coordinates, iimage, kiter, theory): self.dlfind_eg_calls+=1 coordinates_ang = coordinates*0.5291772109303 - energy, gradient = self.theory.run(current_coords=coordinates_ang, elems=self.fragment.elems, charge=self.fragment.charge, mult=self.fragment.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_eg_calls} (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_eg_calls} (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: @@ -280,7 +369,6 @@ 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): @@ -349,6 +437,25 @@ def store_results(a,nvar,switch, energy, coordinates, iam): 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 @@ -372,7 +479,7 @@ def print_settings(self): self.print_atoms_list=self.fragment.allatoms def setup_constraints_act_frozen(self): - + ######################################## # ACTIVE/FROZEN AND RESIDUE HANDLING ######################################## @@ -384,7 +491,17 @@ def setup_constraints_act_frozen(self): # What to optimize etc. self.spec=[] - # First dentify possible frozen constraints defined in constraints dict + 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: if 'xyz' in self.constraints: print_if_level(f"XYZ constraints found in constraints dict. {self.constraints['xyz']}", self.printlevel,2 ) @@ -395,12 +512,19 @@ def setup_constraints_act_frozen(self): 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 can not both be defined") ashexit() - print_if_level(f"All atoms: {self.fragment.allatoms}", self.printlevel,2 ) + print_if_level(f"All atoms: {allatoms}", self.printlevel,2 ) for i in self.fragment.allatoms: if i in self.actatoms: @@ -414,7 +538,7 @@ def setup_constraints_act_frozen(self): print_if_level(f"Frozenatoms provided: {self.frozenatoms}", self.printlevel,2 ) print_if_level(f"All atoms: {self.fragment.allatoms}", self.printlevel,2 ) - for i in self.fragment.allatoms: + for i in allatoms: if i in frozenatoms: self.spec.append(-1) else: @@ -424,17 +548,18 @@ def setup_constraints_act_frozen(self): 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: {self.fragment.allatoms}", self.printlevel,2) + print_if_level(f"All atoms: {allatoms}", self.printlevel,2) if self.residues is None: - self.spec=[1 for i in list(range(self.fragment.numatoms))] + self.spec=[1 for i in list(range(numatoms))] else: print_if_level("Residues provided:", self.residues, self.printlevel,2) - for i in self.fragment.allatoms: + for i in allatoms: resid = search_list_of_lists_for_index(i,self.residues) self.spec.append(resid+1) # Nuclear charges - nuccharges = elemstonuccharges(self.fragment.elems) + nuccharges = elemstonuccharges(elems) + print("nuccharges:", nuccharges) self.spec=self.spec + nuccharges # Constraints. should be dict: constraints={'bond':[[0,1]], 'angle':[[98,99,100]]} @@ -469,17 +594,26 @@ def setup_constraints_act_frozen(self): self.numcons=0 # Spec - self.spec=self.spec+[1 for i in list(range(self.fragment.numatoms))] #? + 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: @@ -496,7 +630,7 @@ def prepare_run(self): 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) @@ -515,7 +649,7 @@ def prepare_run(self): 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: {self.spec}", 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) @@ -526,21 +660,40 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char if fragment is not None: self.fragment=fragment - 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 self theory if a run fragment was provided if theory is not None: self.theory=theory - # Update constraints if provided + # Check if PBCs used by theory + if getattr(self.theory, "periodic", False): + print("Detected periodicity in Theory object") + 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: + 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 @@ -550,7 +703,7 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char # Prepare run, including constraints etc. self.prepare_run() - charge, mult = check_charge_mult(charge, mult, self.theory.theorytype, self.fragment, + 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 @@ -571,24 +724,48 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char # 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) + 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", printlevel=self.printlevel) - return result elif self.icoord >= 100 and self.icoord < 150: # NEB job complete @@ -601,7 +778,7 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char # 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: @@ -617,19 +794,22 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char # 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) + print_internal_coordinate_table(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 diff --git a/ash/interfaces/interface_fairchem.py b/ash/interfaces/interface_fairchem.py index 63c9c2acb..2a9c021b6 100644 --- a/ash/interfaces/interface_fairchem.py +++ b/ash/interfaces/interface_fairchem.py @@ -1,6 +1,7 @@ import time import os -from ash.modules.module_coords import elemstonuccharges, cell_params_to_vectors, cell_vectors_to_params +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 diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index 8f4034f23..89ca3a289 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, cell_volume +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_coords import check_charge_mult, fullindex_to_actindex, cell_vectors_to_params, write_CIF_file, write_XSF_file, write_POSCAR_file + 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 @@ -670,6 +673,10 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') fragment.set_energy(finalenergy) + print("Final geometry") + fragment.print_coords() + print() + # PBC if self.PBC: print("PBC True. Writing final optimized geometry in PBC-format") @@ -697,7 +704,7 @@ 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) + print_internal_coordinate_table_new(fragment,actatoms=self.print_atoms_list) blankline() #Now returning final Results object @@ -788,7 +795,7 @@ def __init__(self,geometric_molf, theory, ActiveRegion=False, actatoms=None,prin # Real elements self.elems_phys=self.fragment.elems # Align to standard orientation - aligned_atom_coords, aligned_vectors = self.align_to_standard_orientation(self.fragment.coords, theory.periodic_cell_vectors) + 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) @@ -1111,41 +1118,7 @@ def PBC_calc(self,currcoords): return {'energy': E, 'gradient': mod_gradient.flatten()} - 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 + diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index ebe5789b3..0c62b61fe 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -3,7 +3,7 @@ import shutil import os -from ash.modules.module_coords import cell_params_to_vectors, cell_vectors_to_params +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 import ash.constants diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 1d7d5cef1..63f04fa32 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -1,7 +1,8 @@ import time from ash.functions.functions_general import ashexit, BC,print_time_rel, print_line_with_mainheader,listdiff -from ash.modules.module_coords import nucchargelist, create_coords_string, cell_vectors_to_params, cell_params_to_vectors +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 diff --git a/ash/interfaces/interface_sella.py b/ash/interfaces/interface_sella.py index da99d556a..6572f628c 100644 --- a/ash/interfaces/interface_sella.py +++ b/ash/interfaces/interface_sella.py @@ -2,7 +2,7 @@ import copy import shutil import time -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.modules.module_coords import print_coords_for_atoms,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 check_charge_mult, fullindex_to_actindex from ash.modules.module_results import ASH_Results diff --git a/ash/interfaces/interface_torch.py b/ash/interfaces/interface_torch.py index fe80e25bf..6ee33240c 100644 --- a/ash/interfaces/interface_torch.py +++ b/ash/interfaces/interface_torch.py @@ -1,7 +1,8 @@ import time import numpy as np -from ash.modules.module_coords import elemstonuccharges, cell_vectors_to_params, cell_params_to_vectors +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 diff --git a/ash/interfaces/interface_veloxchem.py b/ash/interfaces/interface_veloxchem.py index 73ad26400..2b40f9a8c 100644 --- a/ash/interfaces/interface_veloxchem.py +++ b/ash/interfaces/interface_veloxchem.py @@ -6,7 +6,8 @@ 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, cell_vectors_to_params, cell_params_to_vectors +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 diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index c73c649ac..fc02ec818 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -8,17 +8,18 @@ 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): @@ -161,10 +162,8 @@ def __init__(self, xtbdir=None, xtbmethod='GFN1', runmode='inputfile', numcores= elif periodic_cell_dimensions is not None: print("periodic_cell_dimensions:", periodic_cell_dimensions) # Convert to cell vectors - from ash.modules.module_coords import cell_params_to_vectors self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) elif periodic_cell_vectors is not None: - from ash.modules.module_coords import cell_vectors_to_params self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) # Controlling output in xtb-library @@ -427,7 +426,7 @@ 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 @@ -441,12 +440,10 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): if periodic_cell_vectors is not None: self.periodic_cell_vectors = periodic_cell_vectors - from ash.modules.module_coords import cell_vectors_to_params 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 - from ash.modules.module_coords import cell_params_to_vectors self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) def get_cell_gradient(self): @@ -1206,11 +1203,9 @@ def __init__(self, method=None, printlevel=2, numcores=1, spinpol=False, solvati elif periodic_cell_dimensions is not None: print("periodic_cell_dimensions:", periodic_cell_dimensions) # Convert to cell vectors - from ash.modules.module_coords import cell_params_to_vectors self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) else: self.periodic_cell_vectors = periodic_cell_vectors - from ash.modules.module_coords import cell_vectors_to_params self.periodic_cell_dimensions = cell_vectors_to_params(periodic_cell_vectors) # Note: using cell vectors print("Cell vectors (Å)", self.periodic_cell_vectors) @@ -1230,12 +1225,10 @@ def update_cell(self,periodic_cell_vectors=None, periodic_cell_dimensions=None): if periodic_cell_vectors is not None: self.periodic_cell_vectors = periodic_cell_vectors - from ash.modules.module_coords import cell_vectors_to_params 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 - from ash.modules.module_coords import cell_params_to_vectors self.periodic_cell_vectors = cell_params_to_vectors(periodic_cell_dimensions) def get_cell_gradient(self): diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index 40188936c..8f1069031 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -489,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]) @@ -874,21 +874,12 @@ def write_xyzfile(self, xyzfilename="Fragment-xyzfile.xyz", writemode='w', write 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): @@ -932,16 +923,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)) @@ -1161,7 +1142,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") @@ -1380,23 +1452,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 @@ -1433,6 +1494,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): @@ -3026,6 +3090,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): @@ -3316,120 +3477,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 @@ -3977,33 +4024,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): @@ -4207,175 +4227,3 @@ def define_dummy_topology(elems,scale=1.0, tol=0.1, resname="MOL"): return pdb_topology -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 \ No newline at end of file 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_surface_new.py b/ash/modules/module_surface_new.py index 4acadf2d5..4eedb9c3d 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -9,12 +9,14 @@ 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, write_CIF_file, write_POSCAR_file, write_XSF_file +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( @@ -100,8 +102,6 @@ def calc_surface( elif optimizer.lower() in ['dlfind','dl-find']: print("Optimizer to use for surface scan: DL-FIND") - #Optimizer=DLFIND_optimizer - #Optimizerclass=DLFIND_optimizerClass opt_arguments={'maxcycle':maxiter,'iopt':3, 'icoord':1, 'printlevel':printlevel} # Creating optimizer object @@ -109,6 +109,15 @@ def calc_surface( 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() @@ -126,6 +135,13 @@ def calc_surface( extraoopt_run_kws={} # DL-FIND: need to be preset presetting_geometry_required=True + 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 else: print("optimizer keyword should either be a string (geometric or dlfind) or an Optimizer object (GeomeTRICOptimizerClass or DLFIND_optimizerClass)") ashexit() @@ -376,6 +392,9 @@ def calc_surface( #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}") @@ -1111,8 +1130,7 @@ def _build_connectivity(coords, elems): 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 From a00b4c9d8c2b68ca559749952939a481e97a5cf8 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sun, 29 Mar 2026 22:21:01 +0200 Subject: [PATCH 106/134] fixes: cart_opt --- ash/functions/functions_optimization.py | 3 +++ ash/interfaces/interface_dlfind.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index 6c2ac8146..c7a904225 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -723,6 +723,7 @@ def apply_bond_constraints(self, coords, gradient, energy): #print("i, j:", i, j) #print("r0:", r0) #k = c.get('k', self.default_k) # only used for soft + k = self.default_k # temp # Current bond vector and length rij = coords_au[i] - coords_au[j] # (3,) d = np.linalg.norm(rij) @@ -779,6 +780,7 @@ def apply_angle_constraints(self, coords, gradient, energy): i, j, k, theta0_deg = c # centre atom is j print("theta0_deg:", theta0_deg) theta0 = np.deg2rad(theta0_deg) + kf=self.default_k #temp #kf = c.get('k', self.default_k) # Bond vectors pointing away from centre j @@ -852,6 +854,7 @@ def apply_torsion_constraints(self, coords, gradient, energy): i, j, k, l, phi0_deg = c print("i, j, k, l:", i, j, k, l) print("phi0_deg:", phi0_deg) + kf=self.default_k #temp phi0 = np.deg2rad(phi0_deg) # Bond vectors along the chain diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 45998a2ca..ed00f9a29 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -802,7 +802,7 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char # Printing internal coordinate table if self.printlevel >= 2: - print_internal_coordinate_table(self.fragment,actatoms=self.print_atoms_list) + print_internal_coordinate_table_new(self.fragment,actatoms=self.print_atoms_list) print() # Results object From bfd058a988801374d8bda2f293f5b4d52faef889 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 30 Mar 2026 09:55:26 +0200 Subject: [PATCH 107/134] - DL-FIND PBC: separate residue for dummy atoms - README: ASH citation --- README.md | 8 ++--- ash/interfaces/interface_dlfind.py | 49 +++++++++++++++++++----------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 93cbdf222..099789642 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ 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. -**Cite us** -If ASH is useful in your research cite us: -ASH: a multi-scale, multi-theory modelling program -R. Bjornsson*, J. Comput. Chem 2026, accepted. ChemRxiv preprint: https://chemrxiv.org/doi/full/10.26434/chemrxiv.10001640/v1 +**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/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index ed00f9a29..96642bad2 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -139,7 +139,8 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char # Periodic self.PBC_format_option=PBC_format_option self.PBC=False # False by default unless detected in theory - + self.force_noPBC=force_noPBC + ############# #HESSIAN ############# @@ -550,7 +551,15 @@ def setup_constraints_act_frozen(self): 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: - self.spec=[1 for i in list(range(numatoms))] + # 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] + print("self.spec after adding dummy atoms:", self.spec) else: print_if_level("Residues provided:", self.residues, self.printlevel,2) for i in allatoms: @@ -667,22 +676,26 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char # Check if PBCs used by theory if getattr(self.theory, "periodic", False): print("Detected periodicity in Theory object") - 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 + 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: if fragment2 is None and self.fragment2 is None: nvarin=self.fragment.numatoms * 3 From 6070599b24aa2c7d197d4e12638f2c8385eec051 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 30 Mar 2026 16:25:15 +0200 Subject: [PATCH 108/134] - orcatheory: fix for ORCA running MM, energy grab - reactionprofile_plot: minor fix --- ash/interfaces/interface_ORCA.py | 5 ++++- ash/interfaces/interface_dlfind.py | 3 +-- ash/modules/module_plotting.py | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ash/interfaces/interface_ORCA.py b/ash/interfaces/interface_ORCA.py index 086a81054..9804c559c 100644 --- a/ash/interfaces/interface_ORCA.py +++ b/ash/interfaces/interface_ORCA.py @@ -1006,7 +1006,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) diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 96642bad2..cc8002069 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -559,7 +559,6 @@ def setup_constraints_act_frozen(self): if self.PBC: print("PBC detected. Adding 4 dummy atoms as a separate residue") self.spec = self.spec + [2,2,2,2] - print("self.spec after adding dummy atoms:", self.spec) else: print_if_level("Residues provided:", self.residues, self.printlevel,2) for i in allatoms: @@ -568,7 +567,6 @@ def setup_constraints_act_frozen(self): # Nuclear charges nuccharges = elemstonuccharges(elems) - print("nuccharges:", nuccharges) self.spec=self.spec + nuccharges # Constraints. should be dict: constraints={'bond':[[0,1]], 'angle':[[98,99,100]]} @@ -665,6 +663,7 @@ def prepare_run(self): def run(self, theory=None, fragment=None, fragment2=None, constraints=None, charge=None, mult=None): from libdlfind import dl_find + # Update self fragment if a run fragment was provided if fragment is not None: self.fragment=fragment diff --git a/ash/modules/module_plotting.py b/ash/modules/module_plotting.py index f8ec8b03b..b73f848a8 100644 --- a/ash/modules/module_plotting.py +++ b/ash/modules/module_plotting.py @@ -304,7 +304,11 @@ def reactionprofile_plot(surfacedictionary, finalunit=None,label='Label', x_axis #Sorting keys dictionary before grabbing so that line-plot is correct for key in sorted(surfacedictionary.keys()): - coords.append(float(key[0])) #Making sure we add a float,not a tuple + 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: From 4ae114d44fb3d4e97c976c9a48cfa5a38dfc2494 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 30 Mar 2026 18:57:53 +0200 Subject: [PATCH 109/134] added define_residues function to DLFIND file --- ash/interfaces/interface_dlfind.py | 66 +++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index cc8002069..b577c1ea7 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -560,7 +560,7 @@ def setup_constraints_act_frozen(self): print("PBC detected. Adding 4 dummy atoms as a separate residue") self.spec = self.spec + [2,2,2,2] else: - print_if_level("Residues provided:", self.residues, self.printlevel,2) + 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) @@ -825,3 +825,67 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char 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 From ca466fbbe84a0817ea0c6b1181071c42faa8e6c7 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 31 Mar 2026 10:34:20 +0200 Subject: [PATCH 110/134] - calc_surface: extraconstraints now also grabs any constraints in optimizer object - pyscftheory: support for DFT+U Hubbard RKSpU and UKSpU mf types - DL-FIND: support for Cartesian constraints (x,y,x and xy,xz and yz),. Not confirmed to work yet. sigint handling so that DL-FIND actually sends kill signal upon Ctrl-C --- ash/interfaces/interface_dlfind.py | 57 +++++++++++++++++++++++------- ash/interfaces/interface_pyscf.py | 26 ++++++++++++-- ash/modules/module_surface_new.py | 23 +++++++++++- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index b577c1ea7..719e72ce5 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -5,6 +5,8 @@ import numpy as np from numpy.ctypeslib import as_array from numpy.typing import ArrayLike +import signal +import os import os import time @@ -240,7 +242,7 @@ def ash_e_g_func(coordinates, iimage, kiter, theory): #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_eg_calls} (print_atoms_list region)") + 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("") @@ -288,7 +290,7 @@ def ash_e_g_func(coordinates, iimage, kiter, theory): #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_eg_calls} (print_atoms_list region)") + 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("") @@ -504,13 +506,23 @@ def setup_constraints_act_frozen(self): # First identify possible frozen constraints defined in constraints dict if self.constraints is not None: - if 'xyz' in self.constraints: - print_if_level(f"XYZ constraints found in constraints dict. {self.constraints['xyz']}", self.printlevel,2 ) - print_if_level("Adding to frozenatoms list", self.printlevel,2) + 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: - frozenatoms=[] - frozenatoms = self.constraints['xyz'] - + 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) @@ -523,7 +535,7 @@ def setup_constraints_act_frozen(self): if self.frozenatoms is not None: if len(self.frozenatoms) > 0: print("frozenatoms:", self.frozenatoms) - print("Error: actatoms and frozenatoms can not both be defined") + print("Error: actatoms and frozenatoms cannot both be defined") ashexit() print_if_level(f"All atoms: {allatoms}", self.printlevel,2 ) @@ -538,10 +550,24 @@ def setup_constraints_act_frozen(self): 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 frozenatoms: + 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) @@ -714,12 +740,19 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char # 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, diff --git a/ash/interfaces/interface_pyscf.py b/ash/interfaces/interface_pyscf.py index 63f04fa32..3486180ae 100644 --- a/ash/interfaces/interface_pyscf.py +++ b/ash/interfaces/interface_pyscf.py @@ -50,7 +50,8 @@ def __init__(self, printsetting=False, printlevel=2, numcores=1, label="pyscf", PBC_lattice_vectors=None,rcut_ewald=8, rcut_hcore=6, radii=None, neo=False, nuc_basis=None, periodic=False, periodic_cell_vectors=None, periodic_cell_dimensions=None, - ke_cutoff=None, kpoints=None): + ke_cutoff=None, kpoints=None, + hubbard_U=None): self.theorynamelabel="PySCF" self.theorytype="QM" @@ -251,6 +252,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 @@ -2084,6 +2090,20 @@ def create_mf(self): self.mf = scf.GHF(self.molcellobject) elif self.scf_type == 'GKS': 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)) @@ -2101,7 +2121,9 @@ def create_mf(self): 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. diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py index 4eedb9c3d..e682af80a 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -78,7 +78,8 @@ def calc_surface( 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") @@ -128,6 +129,8 @@ def calc_surface( 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 @@ -135,6 +138,8 @@ def calc_surface( 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 @@ -142,6 +147,8 @@ def calc_surface( 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() @@ -770,6 +777,20 @@ def build_constraints(rc_vals, frag): # 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. From e545253a1df9453ba05af06d07bf0d65dd9f31e0 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 31 Mar 2026 11:28:51 +0200 Subject: [PATCH 111/134] geometrIc: now obeys partial cart constraints --- ash/interfaces/interface_geometric_new.py | 88 +++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index 89ca3a289..f8d90ec3e 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -277,15 +277,50 @@ def define_constraints(self,constraints): 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,xyzconstraints - 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") @@ -358,6 +393,49 @@ 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 tmpfiles=['geometric_OPTtraj.log','geometric_OPTtraj.xyz','geometric_OPTtraj_Full.xyz','geometric_OPTtraj_QMregion.xyz', 'optimization_energies.log', @@ -548,13 +626,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,xyzconstraints = 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: From 6acc89eea750cb771958df88207be3661aa1d8f7 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Fri, 3 Apr 2026 12:56:34 +0200 Subject: [PATCH 112/134] - calc_surface: Timings for surfacepoint, Resetting Hessian for Cart_optimizer before each surfacepoint - Nonbondedtheory: minor fixes - Cart_optimizer: cartesian constraints, torsion, bond and angle constraints: soft constraints now work, constrain_method keyword (default soft), hard constraints not yet working --- ash/functions/functions_optimization.py | 351 +++++++++++++++--------- ash/interfaces/interface_dlfind.py | 4 +- ash/modules/module_MM.py | 7 +- ash/modules/module_coords.py | 9 +- ash/modules/module_surface_new.py | 13 +- 5 files changed, 255 insertions(+), 129 deletions(-) diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index c7a904225..7181f0675 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -566,7 +566,7 @@ def periodic_optimizer_alternating(fragment=None, theory=None, rate=0.5, maxiter 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, + 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): """ @@ -581,6 +581,7 @@ def Cart_optimizer(fragment=None, theory=None, rate=2.0, 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) @@ -595,7 +596,7 @@ 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='hard', + PBC_format_option="CIF", constraints=None, constrain_method='soft', frozen_atoms=None, result_write_to_disk=True): self.fragment = fragment @@ -613,7 +614,7 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m # Constraints self.constraints = constraints if constraints is not None else [] self.constrain_method = constrain_method # 'hard' or 'soft' - # Default force constant for soft restraints (eV/Ų or Eh/Ų — match your units) + # Default force constant for soft restraints self.default_k = 10.0 # Frozen atoms self.frozen_atoms = frozen_atoms if frozen_atoms is not None else [] @@ -632,7 +633,7 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m for con in self.constraints: print("con:",con) - # Max step in bohrs (default = 0.1 Å = 0.188 bohrs) + # Max step in bohrs (default = 0.25 Å = 0.472 bohrs) self.max_step_au = max_step*self.ang2bohr print("Rate (atoms):", self.rate) @@ -673,7 +674,7 @@ def setup_PBC(self): self.H_ref = aligned_vectors.copy() self.H_ref_inv = np.linalg.inv(self.H_ref) - def apply_frozen_atoms(self, gradient): + 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 @@ -681,21 +682,17 @@ def apply_frozen_atoms(self, gradient): frozen_atoms=[0, 1, 5] # freeze all xyz frozen_atoms={0: 'xyz', 3: 'xz', 7: 'y'} # freeze specific components """ - if not self.frozen_atoms: - return gradient - grad_out = gradient.copy() - if isinstance(self.frozen_atoms, (list, tuple)): - for idx in self.frozen_atoms: - grad_out[idx] = 0.0 + #if isinstance(self.frozen_atoms, (list, tuple)): + # for idx in self.frozen_atoms: + # grad_out[idx] = 0.0 - elif isinstance(self.frozen_atoms, dict): - component_map = {'x': 0, 'y': 1, 'z': 2} - for idx, components in self.frozen_atoms.items(): - for c in components.lower(): - if c in component_map: - grad_out[idx, component_map[c]] = 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 @@ -719,7 +716,9 @@ def apply_bond_constraints(self, coords, gradient, energy): for c in self.constraints['bond']: print("Applying bond constraint") - i, j, r0 = c + i, j, r0_ang = c + + r0 = r0_ang * self.ang2bohr # convert target bond length to Bohrs #print("i, j:", i, j) #print("r0:", r0) #k = c.get('k', self.default_k) # only used for soft @@ -778,7 +777,6 @@ def apply_angle_constraints(self, coords, gradient, energy): for c in self.constraints['angle']: print("Applying angle constraint") i, j, k, theta0_deg = c # centre atom is j - print("theta0_deg:", theta0_deg) theta0 = np.deg2rad(theta0_deg) kf=self.default_k #temp #kf = c.get('k', self.default_k) @@ -836,114 +834,176 @@ def apply_angle_constraints(self, coords, gradient, energy): return energy_out, grad_out - def apply_torsion_constraints(self, coords, gradient, energy): + def apply_dihedral_constraints(self, coords, gradient, energy, kf=0.5): """ - Torsion (dihedral) constraints for quartets (i, j, k, l). - Target angle in degrees, range (-180, 180]. - Uses the Blondel & Karplus (1996) analytical gradient. + 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. """ - if not self.constraints: + + #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 - for c in self.constraints['dihedral']: - print("c:", c) - print("Applying torsion constraint") - i, j, k, l, phi0_deg = c - print("i, j, k, l:", i, j, k, l) - print("phi0_deg:", phi0_deg) - kf=self.default_k #temp - phi0 = np.deg2rad(phi0_deg) - # Bond vectors along the chain - b1 = coords_au[j] - coords_au[i] - b2 = coords_au[k] - coords_au[j] - b3 = coords_au[l] - coords_au[k] + def dihedral_phi(ca, i, j, k, l): + """Signed dihedral angle in radians.""" + r1 = ca[i] + r2 = ca[j] + r3 = ca[k] + r4 = ca[l] + + b1 = r2 - r1 + b2 = r3 - r2 + b3 = r4 - r3 - # Normal vectors to the two planes 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) + 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("Applying torsion constraint") + 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 or lb2 < 1e-8: - print(f"Warning: degenerate torsion {i}-{j}-{k}-{l}. Skipping.") - continue + if ln1 < 1e-8 or ln2 < 1e-8: + break - # Torsion angle via atan2 (gives correct sign and full -π..π range) 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) - # Deviation — wrap to (-π, π] - delta = phi - phi0 - delta = (delta + np.pi) % (2 * np.pi) - np.pi - - # Blondel & Karplus gradient - # ∂φ/∂r_i = -|b2|/|n1|² * n1 - # ∂φ/∂r_l = |b2|/|n2|² * n2 - # ∂φ/∂r_j and ∂φ/∂r_k from chain rule (see B&K eq. 27) - lb2_sq = lb2**2 - dphi_dri = -(lb2 / ln1**2) * n1 - dphi_drl = (lb2 / ln2**2) * n2 - dphi_drj = ( (np.dot(b1, b2) / lb2_sq - 1.0) * (lb2 / ln1**2) * n1 - - (np.dot(b3, b2) / lb2_sq) * (lb2 / ln2**2) * n2 ) - dphi_drk = ( (np.dot(b3, b2) / lb2_sq - 1.0) * (lb2 / ln2**2) * n2 * (-1) # sign: b2 direction - - (np.dot(b1, b2) / lb2_sq) * (lb2 / ln1**2) * n1 * (-1) ) - # Compact form consistent with B&K sign convention: - dphi_drj = ( -(1.0 - np.dot(b1,b2)/lb2_sq) * (lb2/ln1**2) * n1 - +( np.dot(b3,b2)/lb2_sq) * (lb2/ln2**2) * n2 ) - dphi_drk = ( (1.0 - np.dot(b3,b2)/lb2_sq) * (lb2/ln2**2) * n2 - -( np.dot(b1,b2)/lb2_sq) * (lb2/ln1**2) * n1 ) + #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 self.constrain_method == 'soft': - energy_out += 0.5 * kf * delta**2 - grad_out[i] += kf * delta * dphi_dri - grad_out[j] += kf * delta * dphi_drj - grad_out[k] += kf * delta * dphi_drk - grad_out[l] += kf * delta * dphi_drl - if self.printlevel >= 2: - print(f" Soft torsion ({i},{j},{k},{l}): φ={np.rad2deg(phi):.3f}° " - f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}° " - f"penalty={0.5*kf*delta**2:.6f}") + if abs(delta) < tol: + break - elif self.constrain_method == 'hard': - # Tolerance within which we switch from soft-drive to hard-project - tol = np.deg2rad(2.0) # default 2° - - if abs(delta) > tol: - print("not yet target") - # Not yet at target: drive toward it with a stiff soft penalty - #kf_drive = c.get('k_drive', 50.0) # Eh/rad², stiff - kf_drive = 0.05 - energy_out += 0.5 * kf_drive * delta**2 - grad_out[i] += kf_drive * delta * dphi_dri - grad_out[j] += kf_drive * delta * dphi_drj - grad_out[k] += kf_drive * delta * dphi_drk - grad_out[l] += kf_drive * delta * dphi_drl - if self.printlevel >= 2: - print(f" Hard torsion ({i},{j},{k},{l}): φ={np.rad2deg(phi):.3f}° " - f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}° " - f"[driving, |delta| > tol={np.rad2deg(tol):.1f}°]") - else: - print(" Within tolerance for hard torsion constraint, switching to projection.") - # Close enough: project out the torsion-changing gradient component - for idx, dphi_dr in [(i, dphi_dri), (j, dphi_drj), - (k, dphi_drk), (l, dphi_drl)]: - norm = np.linalg.norm(dphi_dr) - if norm > 1e-10: - n_hat = dphi_dr / norm - grad_out[idx] -= np.dot(grad_out[idx], n_hat) * n_hat - if self.printlevel >= 2: - print(f" Hard torsion ({i},{j},{k},{l}): φ={np.rad2deg(phi):.3f}° " - f"target={np.rad2deg(phi0):.3f}° delta={np.rad2deg(delta):.3f}° " - f"[projecting, |delta| <= tol={np.rad2deg(tol):.1f}°]") + # 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 - return energy_out, grad_out + 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): """ @@ -1087,12 +1147,15 @@ def calculate_supergradient(self,supercoords): return energy, mod_gradient def compute_step(self,gradient,currcoords): - # 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 + 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") @@ -1137,7 +1200,9 @@ def compute_step(self,gradient,currcoords): 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 @@ -1182,6 +1247,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No 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 @@ -1213,6 +1279,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No 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 ######################################### @@ -1223,35 +1290,64 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No print("1 energy:", energy) print("1 supergradient:", supergradient) 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("Applying constraints...") print("self.constraints:", self.constraints) if 'bond' in self.constraints or 'distance' in self.constraints: energy, supergradient = self.apply_bond_constraints(R_phys, supergradient, energy) + 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) if 'torsion' in self.constraints or 'dihedral' in self.constraints: - if 'torsion' in self.constraints: - # Renaming for clarity - self.constraints['dihedral'] = self.constraints.pop('torsion') - energy, supergradient = self.apply_torsion_constraints(R_phys, supergradient, energy) - print("2 energy:", energy) - print("2 supergradient:", supergradient) - - print("delta gradient after constraints:", supergradient-prev_supgrad) - + energy, supergradient = self.apply_dihedral_constraints(R_phys, supergradient, energy) + 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: - supergradient = self.apply_frozen_atoms(supergradient) + if self.frozen_atoms or len(self.all_cartesian_constraints)>0: + print("We have frozen atoms or cartesian constraints, applying them to the gradient...") + # 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("All Cartesian constraints", self.all_cartesian_constraints) + + 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[:-4]**2)) - grad_max_atoms = abs(max(supergradient[:-4].min(), supergradient[:-4].max(), key=abs)) + 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} Å") @@ -1303,6 +1399,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No 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') @@ -1325,6 +1422,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No # 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("Computed step:", delta_au) @@ -1341,12 +1439,19 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No print(f"Step scale down: {np.max(np.abs(delta_au))} > max_step_au: {self.max_step_au})") delta_au = delta_au * (self.max_step_au / np.max(np.abs(delta_au))) print("Actual step:", delta_au) + # Take the step currcoords_au += delta_au - + print("Cart_opt: time until after step:", time.time()-self.run_init_time, "seconds") # 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_dlfind.py b/ash/interfaces/interface_dlfind.py index 719e72ce5..304ef741b 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -597,7 +597,7 @@ def setup_constraints_act_frozen(self): # Constraints. should be dict: constraints={'bond':[[0,1]], 'angle':[[98,99,100]]} if self.constraints is not None: - print_if_level(f"Constraints passed: {self.constraints}", self.printlevel,2) + 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(): @@ -626,7 +626,7 @@ def setup_constraints_act_frozen(self): print_if_level("No constraints present", self.printlevel,2) self.numcons=0 - # Spec + # Spec (microterative) not used for now self.spec=self.spec+[1 for i in list(range(numatoms))] #? self.nspec=len(self.spec) diff --git a/ash/modules/module_MM.py b/ash/modules/module_MM.py index 54608e374..34d05aefd 100644 --- a/ash/modules/module_MM.py +++ b/ash/modules/module_MM.py @@ -269,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...") @@ -424,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 self.MMEnergy, self.MMGradient + else: + return self.MMEnergy # MMAtomobject used to store LJ parameter and possibly charge for MM atom with atomtype, e.g. OT diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index 8f1069031..188fad356 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -804,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) @@ -821,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"): diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py index e682af80a..59a1692bb 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -333,6 +333,7 @@ def calc_surface( 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) @@ -379,7 +380,7 @@ def calc_surface( ) else: # RELAXED if presetting_geometry_required: - print_if_level(f"For DL-FIND we need to modify geometry first to set constraints.", printlevel,2) + 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 @@ -394,8 +395,16 @@ def calc_surface( 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) @@ -415,6 +424,8 @@ def calc_surface( 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) From 9b1f9a3875e4f0b15e8fc698c0d078fb161ea6c8 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 6 Apr 2026 13:44:45 +0200 Subject: [PATCH 113/134] - module_surface: handle_output_files fix - mace interface: print fix - freezing string interface: preliminary, not ready - dl-find: putting cycles counter at beginning of run - crest: fixes for return, indent issue - cart_optimizer: removed printing lines --- ash/__init__.py | 3 + ash/functions/functions_optimization.py | 22 +++-- ash/interfaces/interface_crest.py | 27 +++--- ash/interfaces/interface_dlfind.py | 24 ++--- ash/interfaces/interface_fsm.py | 115 ++++++++++++++++++++++++ ash/interfaces/interface_mace.py | 2 - ash/modules/module_surface_new.py | 4 + 7 files changed, 157 insertions(+), 40 deletions(-) create mode 100644 ash/interfaces/interface_fsm.py diff --git a/ash/__init__.py b/ash/__init__.py index 3bdfa994c..32e84b1f0 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -158,6 +158,9 @@ # 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 diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index 7181f0675..e92e14dbb 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -1201,8 +1201,8 @@ def compute_step(self,gradient,currcoords): 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") + #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 @@ -1247,7 +1247,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No except: pass - print("Cart_opt: time until LOOP:", time.time()-self.run_init_time, "seconds") + #print("Cart_opt: time until LOOP:", time.time()-self.run_init_time, "seconds") # LOOP for iteration in range(0,self.maxiter): self.iteration=iteration @@ -1279,7 +1279,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No 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") + #print("Cart_opt: time until e+g step:", time.time()-self.run_init_time, "seconds") ######################################### # 1. Compute energy and gradient ######################################### @@ -1287,10 +1287,8 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No energy, supergradient = self.calculate_supergradient(currcoords) else: energy, supergradient = self.calculate_reg_gradient(currcoords) - print("1 energy:", energy) - print("1 supergradient:", supergradient) prev_supgrad = supergradient.copy() - print("Cart_opt: time until after e+g step:", time.time()-self.run_init_time, "seconds") + #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: @@ -1298,13 +1296,13 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No print("self.constraints:", self.constraints) if 'bond' in self.constraints or 'distance' in self.constraints: energy, supergradient = self.apply_bond_constraints(R_phys, supergradient, energy) - print("Cart_opt: time until after bondcon step:", time.time()-self.run_init_time, "seconds") + #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) if 'torsion' in self.constraints or 'dihedral' in self.constraints: energy, supergradient = self.apply_dihedral_constraints(R_phys, supergradient, energy) - print("supergradient after dihedral constraints:", supergradient) - print("Cart_opt: time until after dihedralcon step:", time.time()-self.run_init_time, "seconds") + #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']: @@ -1337,7 +1335,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No print("All Cartesian constraints", self.all_cartesian_constraints) supergradient = self.apply_cartesian_constraints(supergradient) - print("Cart_opt: time until after cartesiancon step:", time.time()-self.run_init_time, "seconds") + #print("Cart_opt: time until after cartesiancon step:", time.time()-self.run_init_time, "seconds") ######################################### # 2. Check convergence @@ -1422,7 +1420,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No # 3. Take step ######################################### # Compute step - print("Cart_opt: time until before compute step:", time.time()-self.run_init_time, "seconds") + #print("Cart_opt: time until before compute step:", time.time()-self.run_init_time, "seconds") delta_au = self.compute_step(supergradient,currcoords) print("Computed step:", delta_au) diff --git a/ash/interfaces/interface_crest.py b/ash/interfaces/interface_crest.py index e5af39d17..1cbee98a2 100644 --- a/ash/interfaces/interface_crest.py +++ b/ash/interfaces/interface_crest.py @@ -134,17 +134,16 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="imtd-gc", 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 - - frag = Fragment(xyzfile="genericinp.xyz", charge={charge},mult={mult}) - #Unpickling theory object - theory = pickle.load(open(\"../{theoryfilename}\", \"rb\" )) - result = Singlepoint(theory=theory, fragment=frag, Grad=True) - print_gradient_in_ORCAformat(result.energy,result.gradient,"genericinp", extrabasename="") - """ + ashinput=f"""from ash import * +from ash.interfaces.interface_ORCA import print_gradient_in_ORCAformat +import pickle + +frag = Fragment(xyzfile="genericinp.xyz", charge={charge},mult={mult}) +#Unpickling theory object +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) @@ -193,10 +192,10 @@ def new_call_crest(fragment=None, theory=None, crestdir=None, runtype="imtd-gc", # 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 diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 304ef741b..8a61f9c79 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -193,18 +193,6 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char 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={} - - self.runcounter=0 - # Create function to calculate energies and gradients @dlf_get_gradient_wrapper @@ -690,6 +678,18 @@ def run(self, theory=None, fragment=None, fragment2=None, constraints=None, char from libdlfind import dl_find + #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 diff --git a/ash/interfaces/interface_fsm.py b/ash/interfaces/interface_fsm.py new file mode 100644 index 000000000..2becf011d --- /dev/null +++ b/ash/interfaces/interface_fsm.py @@ -0,0 +1,115 @@ +from ash.functions.functions_general import ashexit, print_line_with_mainheader +from ash.modules.module_singlepoint import Singlepoint +from ash.constants import hartoeV +import numpy as np +import copy + +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="cart", + 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 + except: + print("Error import ase. Check if installed") + ashexit() + + # ASH Fragments (or ASE atoms) + #self.reactant=reactant + #self.product + self.reactant_ase = ase.atoms.Atoms(reactant.elems,positions=reactant.coords) + self.product_ase = ase.atoms.Atoms(product.elems,positions=product.coords) + + 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}") diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 0c62b61fe..620c0f73a 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -95,8 +95,6 @@ def __init__(self, config_filename="config.yml", print("Cell vectors:", self.periodic_cell_vectors) print("Cell dimensions:", self.periodic_cell_dimensions) - print_line_with_mainheader(f"{self.theorynamelabel}Theory initialization") - self.training_done=False def cleanup(self): diff --git a/ash/modules/module_surface_new.py b/ash/modules/module_surface_new.py index 59a1692bb..bfc1bbd72 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -1073,6 +1073,10 @@ def _handle_output_files(theory, pointlabel, keepoutputfiles, keepmofiles, print 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: From ace05aa1d4debc87126e02b4b94c2a58fdb3d400 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 6 Apr 2026 14:31:03 +0200 Subject: [PATCH 114/134] ORCA: fix for grabbing grab_polarizability_tensor --- ash/interfaces/interface_ORCA.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ash/interfaces/interface_ORCA.py b/ash/interfaces/interface_ORCA.py index 9804c559c..981248553 100644 --- a/ash/interfaces/interface_ORCA.py +++ b/ash/interfaces/interface_ORCA.py @@ -370,7 +370,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, @@ -1120,26 +1123,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 From 1502981029a5eda918ba7314a64b4a1b80da74ac Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 9 Apr 2026 22:05:24 +0200 Subject: [PATCH 115/134] DDEC_calc: chargemol fix --- ash/functions/functions_elstructure.py | 11 +++++------ ash/interfaces/interface_fsm.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/ash/functions/functions_elstructure.py b/ash/functions/functions_elstructure.py index 34de6a19e..ba34f3b41 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 + + + chargemoldir = check_program_location(None,None, "chargemol") #Checking if we can proceed if chargemolbinarydir is None: diff --git a/ash/interfaces/interface_fsm.py b/ash/interfaces/interface_fsm.py index 2becf011d..6ce71b44c 100644 --- a/ash/interfaces/interface_fsm.py +++ b/ash/interfaces/interface_fsm.py @@ -4,6 +4,7 @@ import numpy as np import copy +# Simpler ASH-ASE calculator class ASH_ASE_calculator: def __init__(self, theory=None, fragment=None): self.theory = theory @@ -34,7 +35,6 @@ def FSM(reactant=None, product=None, theory=None, method="L-BFGS-B", optcoords=" interpolate=interpolate, maxiter=maxiter, maxls=maxls, dmax=dmax, outdir=outdir, verbose=verbose) fsm.run() - class FreezingString_class: From e7c7665793dde4b5da57c1e36369e31f578088d0 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 9 Apr 2026 22:06:25 +0200 Subject: [PATCH 116/134] fix --- ash/functions/functions_elstructure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ash/functions/functions_elstructure.py b/ash/functions/functions_elstructure.py index ba34f3b41..8391faf10 100644 --- a/ash/functions/functions_elstructure.py +++ b/ash/functions/functions_elstructure.py @@ -677,7 +677,7 @@ def DDEC_calc(elems=None, theory=None, gbwfile=None, numcores=1, DDECmodel='DDEC print("Searching for molden2aim and chargemol in PATH") - chargemoldir = check_program_location(None,None, "chargemol") + chargemolbinarydir = check_program_location(None,None, "chargemol") #Checking if we can proceed if chargemolbinarydir is None: From 675dc5a4fed4bcb0133752baee2c5d2ee03941a0 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sat, 11 Apr 2026 12:14:23 +0200 Subject: [PATCH 117/134] Cart_optimizer: fix for inconsistent sign of dihedral, better printing of constraints, kf values can now be modified, now obeying printlevel --- ash/functions/functions_optimization.py | 109 +++++++++++++----------- ash/interfaces/interface_xtb.py | 2 +- 2 files changed, 60 insertions(+), 51 deletions(-) diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index e92e14dbb..35b595f74 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -3,7 +3,7 @@ import os import ash.constants -from ash.functions.functions_general import ashexit, blankline,print_time_rel_and_tot,BC,listdiff,print_time_rel +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 @@ -568,7 +568,8 @@ def Cart_optimizer(fragment=None, theory=None, rate=2.0, 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): + 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 """ @@ -583,7 +584,8 @@ def Cart_optimizer(fragment=None, theory=None, rate=2.0, 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) + 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: @@ -597,8 +599,10 @@ 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): + 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 @@ -614,8 +618,10 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m # Constraints self.constraints = constraints if constraints is not None else [] self.constrain_method = constrain_method # 'hard' or 'soft' - # Default force constant for soft restraints - self.default_k = 10.0 + # 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: @@ -628,21 +634,27 @@ def __init__(self,fragment=None, theory=None, rate=2.0, scaling_rate_cell=1.0, m self.conv_criteria = {'convergence_grms':1e-4, 'convergence_gmax':3e-4} else: self.conv_criteria=conv_criteria - print("Convergence criteria:", self.conv_criteria) - print("Constraints:", self.constraints) - for con in self.constraints: - print("con:",con) # 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 + self.PBC = False ####################### # INITITAL SETUP @@ -696,7 +708,7 @@ def apply_cartesian_constraints(self, gradient): return grad_out - def apply_bond_constraints(self, coords, gradient, energy): + def apply_bond_constraints(self, coords, gradient, energy, kf=10.0): """ Apply bond-length constraints to gradient (and energy for soft mode). @@ -715,14 +727,12 @@ def apply_bond_constraints(self, coords, gradient, energy): coords_au = coords * self.ang2bohr for c in self.constraints['bond']: - print("Applying bond constraint") + 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 - #print("i, j:", i, j) - #print("r0:", r0) - #k = c.get('k', self.default_k) # only used for soft - k = self.default_k # temp + # Current bond vector and length rij = coords_au[i] - coords_au[j] # (3,) d = np.linalg.norm(rij) @@ -731,18 +741,18 @@ def apply_bond_constraints(self, coords, gradient, energy): continue e_ij = rij / d # unit vector i→j - delta = d - r0 # signed deviation in Å + 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 * k * delta**2 - grad_out[i] += k * delta * e_ij - grad_out[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:.4f} Å target={r0:.4f} Å " - f"delta={delta:.4f} Å penalty={0.5*k*delta**2:.6f}") + 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 @@ -762,7 +772,7 @@ def apply_bond_constraints(self, coords, gradient, energy): return energy_out, grad_out - def apply_angle_constraints(self, coords, gradient, energy): + 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. @@ -775,11 +785,11 @@ def apply_angle_constraints(self, coords, gradient, energy): coords_au = coords * self.ang2bohr for c in self.constraints['angle']: - print("Applying angle constraint") + 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 - #kf = c.get('k', self.default_k) + #kf = self.default_k #temp # Bond vectors pointing away from centre j u = coords_au[i] - coords_au[j] @@ -860,11 +870,7 @@ def apply_dihedral_constraints(self, coords, gradient, energy, kf=0.5): def dihedral_phi(ca, i, j, k, l): """Signed dihedral angle in radians.""" - r1 = ca[i] - r2 = ca[j] - r3 = ca[k] - r4 = ca[l] - + r1, r2, r3, r4 = ca[i], ca[j], ca[k], ca[l] b1 = r2 - r1 b2 = r3 - r2 b3 = r4 - r3 @@ -884,7 +890,8 @@ def dihedral_phi(ca, i, j, k, l): 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, 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): @@ -897,7 +904,8 @@ def torsion_restraint_energy(ca, i, j, k, l, phi0_rad, kf_local): h = 1.0e-4 # Bohr finite-difference step for c in condict: - print("Applying torsion constraint") + 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) @@ -1221,11 +1229,14 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No 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("Running periodic optimization in Cartesian coordinates with cell optimization") + print("Cart_optimizer: Running periodic optimization in Cartesian coordinates with cell optimization") self.setup_PBC() currcoords = np.concatenate([ self.fragment.coords, # (N, 3) @@ -1234,7 +1245,7 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No ], axis=0) opt_type_label="PBC" else: - print("Running non-periodic optimization in Cartesian coordinates") + print("Cart_optimizer: Running non-periodic optimization in Cartesian coordinates") currcoords = self.fragment.coords opt_type_label="NonPBC" @@ -1292,15 +1303,14 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No # 1b. Apply all constraints self.all_cartesian_constraints={} if self.constraints: - print("Applying constraints...") - print("self.constraints:", 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) + 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) + 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) + 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 @@ -1327,12 +1337,12 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No self.all_cartesian_constraints[i] = 'xz' # 1c. Apply frozen atoms if self.frozen_atoms or len(self.all_cartesian_constraints)>0: - print("We have frozen atoms or cartesian constraints, applying them to the gradient...") + 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("All Cartesian constraints", self.all_cartesian_constraints) + 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") @@ -1421,8 +1431,8 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No ######################################### # 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("Computed step:", delta_au) + 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) @@ -1430,18 +1440,17 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No 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(f"Lattice-specific scaling applied: {scale_latt:.3f}") + 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(f"Step scale down: {np.max(np.abs(delta_au))} > max_step_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("Actual step:", delta_au) - + print_if_level(f"Actual step: {delta_au}", self.printlevel,2) # Take the step currcoords_au += delta_au - print("Cart_opt: time until after step:", time.time()-self.run_init_time, "seconds") + 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 diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index fc02ec818..00f0cf449 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -1212,7 +1212,7 @@ def __init__(self, method=None, printlevel=2, numcores=1, spinpol=False, solvati try: import tblite except Exception as e: - print("Problem importing xTtbliteB library. Have you installed tblite properly ?") + 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") From 0bce34dbb6f48731c3485b40e9147c0da8364fbe Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 13 Apr 2026 11:10:31 +0200 Subject: [PATCH 118/134] - Sella: working interface - dlfind: minor fixes, ts-opt job now reporting correct number of cycles - mace: printlevel behaviour - OpenMMTHeory: fix for MD Periodic vs. periodic inconsistency --- ash/__init__.py | 3 + ash/interfaces/interface_OpenMM.py | 20 +-- ash/interfaces/interface_dlfind.py | 43 +++-- ash/interfaces/interface_mace.py | 10 +- ash/interfaces/interface_sella.py | 258 +++++++++++++++++++++-------- 5 files changed, 237 insertions(+), 97 deletions(-) diff --git a/ash/__init__.py b/ash/__init__.py index 32e84b1f0..347385ba6 100644 --- a/ash/__init__.py +++ b/ash/__init__.py @@ -217,6 +217,9 @@ # 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 from .interfaces.interface_crest import call_crest, call_crest_entropy, get_crest_conformers, new_call_crest diff --git a/ash/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index f088322d9..8a77edfea 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -2579,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() @@ -3685,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: @@ -4187,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() @@ -4254,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: @@ -4358,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") @@ -4454,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") @@ -4555,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") @@ -4668,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") @@ -4778,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") @@ -4863,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) diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 8a61f9c79..123f93758 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -101,30 +101,30 @@ def __init__(self,jobtype=None, fragment=None, fragment2=None, theory=None, char 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=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 @@ -363,6 +363,7 @@ def hess_func(coords): # 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 @@ -397,15 +398,27 @@ 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_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 + 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") @@ -763,7 +776,7 @@ def _sigint_handler(signum, frame): # 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 diff --git a/ash/interfaces/interface_mace.py b/ash/interfaces/interface_mace.py index 620c0f73a..d627f52a2 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -4,7 +4,7 @@ import os 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 ashexit, BC, print_if_level,print_time_rel from ash.functions.functions_general import print_line_with_mainheader import ash.constants @@ -392,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: @@ -415,7 +413,7 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el if Hessian: Grad=True - print("Running on platform/device:", self.platform) + print_if_level("Running on platform/device:", self.printlevel, 2) # Checking if model is alreadyloaded if self.model is None: print("A model has not been loaded yet.") @@ -531,8 +529,8 @@ def run(self, current_coords=None, current_MM_coords=None, MMcharges=None, qm_el print("hessian:", hessian) print_time_rel(module_init_time, modulename=f'MACE run - after hessian', moduleindex=2) 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 diff --git a/ash/interfaces/interface_sella.py b/ash/interfaces/interface_sella.py index 6572f628c..c9ba1c454 100644 --- a/ash/interfaces/interface_sella.py +++ b/ash/interfaces/interface_sella.py @@ -2,7 +2,7 @@ import copy import shutil import time -from ash.modules.module_coords import print_coords_for_atoms,write_XYZ_for_atoms,write_xyzfile,write_coords_all +from ash.modules.module_coords import 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,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 @@ -10,9 +10,16 @@ from ash.modules.module_singlepoint import Singlepoint from ash.constants import hartoeV, bohr2ang -def SellaOptimizer(theory=None, fragment=None, charge=None, mult=None, printlevel=2, NumGrad=False): +# 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, gamma=0.03): """ - Wrapper function around SellaptimizerClass + Wrapper function around SellaoptimizerClass """ timeA=time.time() @@ -21,7 +28,9 @@ def SellaOptimizer(theory=None, fragment=None, charge=None, mult=None, printleve print("SellaOptimizer requires theory and fragment objects provided. Exiting.") ashexit() # NOTE: Class does not take fragment and theory - optimizer=SellaptimizerClass(charge=charge, mult=mult) + optimizer = SellaoptimizerClass(charge=charge, mult=mult, convergence_gmax=convergence_gmax, + printlevel=printlevel, maxiter=maxiter, result_write_to_disk=result_write_to_disk, + constraints=constraints, gamma=gamma) # If NumGrad then we wrap theory object into NumGrad class object if NumGrad: @@ -29,88 +38,205 @@ def SellaOptimizer(theory=None, fragment=None, charge=None, mult=None, printleve print("This enables numerical-gradient calculation for theory") theory = NumGradclass(theory=theory) - # Providing theory and fragment to run method. Also constraints - result = optimizer.run(theory=theory, fragment=fragment, charge=charge, mult=mult, - printlevel=printlevel) + # 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 SellaptimizerClass: - def __init__(self,theory=None, charge=None, mult=None, printlevel=2): +class SellaoptimizerClass: + def __init__(self,theory=None, charge=None, mult=None, printlevel=2, constraints=None, + convergence_gmax=3e-4, maxiter=150, result_write_to_disk=False, + gamma=0.4): self.printlevel=printlevel print_line_with_mainheader("SellaOptimizer initialization") print_if_level("Creating optimizer object", self.printlevel,2) - def run(self, theory=None, fragment=None, charge=None, mult=None,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 + self.gamma = gamma + + + 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) + + def setup_constraints(self, atoms, constraints): + from sella import Constraints + sellacons = Constraints(atoms) + + # Bonds + 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 + if 'xyz' in constraints: + for xyzcon in constraints['xyz']: + sellacons.fix_translation(xyzcon) + # TODO: partial XYZ constraints + print("sellacons:", sellacons) + return sellacons + + def run(self, theory=None, fragment=None, charge=None, mult=None,printlevel=2, constraints=None): + + print_line_with_subheader1("Running Sella optimization") from sella import Sella import ase + if constraints is None: + constraints = self.constraints + print("constraints:", constraints) + # Creating ASE object + fragment.printlevel=0 atoms = ase.atoms.Atoms(fragment.elems,positions=fragment.coords) - # + + # Setup constraints for Sella + sella_constraints=None + if self.constraints is not None: + sella_constraints = self.setup_constraints(atoms, constraints) + print("sella_constraints:", sella_constraints) + + # Attaching calculator print("Creating ASH-ASE calculator") - atoms.calc = ASHcalc(fragment=fragment, theory=theory, charge=charge, mult=mult) + atoms.calc = ASH_ASE_calculator(theory=theory, fragment=fragment) # Set up a Sella Dynamics object dyn = Sella( - atoms, - trajectory='sella.traj') - - dyn.run(1e-3, 1) - - -class ASHcalc(): - def __init__(self, fragment=None, theory=None, charge=None, mult=None): - self.gradientcalls=0 - self.fragment=fragment - self.theory=theory - self.results={} - self.name='ash' - self.parameters={} - self.atoms=None - self.forces=[] - self.charge=charge - self.mult=mult + atoms, constraints=sella_constraints, + gamma=self.gamma) + + def write_traj(a=atoms, trajname="sella_optim"): + fragment.coords = copy.copy(a.get_positions()) + fragment.write_xyzfile(xyzfilename=trajname+'.xyz', writemode='a') + + # Attaching traj function + #dyn.attach(print_step, interval=1) + dyn.attach(write_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) + + print("Final geometry") + fragment.print_coords() + print() + + + # TODO active region + #Active region XYZ-file + #if self.ActiveRegion is True: + # 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: + 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): + self.theory = theory + # Used for elems, charge and mult + self.fragment = fragment + self.forcecalls = 0 + self.forces = None + self.energycalls = 0 + self.energy_eH = None + self.energy_eV = None + self.gradient = None + self.coords=fragment.coords + def get_potential_energy(self, atomsobj): - return self.potenergy - def get_forces(self, atomsobj): - timeA = time.time() - print("Called ASHcalc get_forces") - # Check if coordinates have changed. If not, return old forces - if np.array_equal(atomsobj.get_positions(), self.fragment.coords) == True: - #coordinates have not changed - print("Coordinates unchanged.") - if len(self.forces)==0: - print("No forces available (1st step?). Will do calulation") + #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("Returning old forces") - print_time_rel(timeA, modulename="get_forces: returning old forces") + 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 - print("Will calculate new forces") - - self.gradientcalls+=1 - - # Copy ASE coords into ASH fragment - self.fragment.coords=copy.copy(atomsobj.positions) - print("atomsobj.positions:", atomsobj.positions) - # Calculate E+G - result = Singlepoint(theory=self.theory, fragment=self.fragment, Grad=True, charge=self.charge, mult=self.mult) - energy = result.energy - gradient = result.gradient - # Converting E and G from Eh and Eh/Bohr to ASE units: eV and eV/Angstrom - self.potenergy = energy * hartoeV - print("gradient:", gradient) - self.forces = -1 * gradient * hartoeV / bohr2ang - print("Forces:", self.forces) - # Adding forces to results also (sometimes called) - self.results['forces'] = self.forces - # print("potenergy:", self.potenergy) - - print("ASHcalc get_forces done") - print_time_rel(timeA, modulename="get_forces") - return self.forces \ No newline at end of file + else: + print("Running first E+G calculation") + print("Note: following Sella printout units are in eV and eV/Ang") + #print("Will calculate new forces") + + self.coords = copy.copy(atomsobj.positions) + energy, gradient = self.theory.run(current_coords=self.coords, elems=self.fragment.elems, + charge=self.fragment.charge, mult=self.fragment.mult, Grad=True) + #print("New energy:", energy) + self.energy_eH = energy + self.energy_eV = energy*hartoeV + self.gradient=gradient + self.forces = -gradient * 51.4220674763 + return self.forces From 27810d829983094b536165b2f03d77f2f4a993d8 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 14 Apr 2026 13:48:50 +0200 Subject: [PATCH 119/134] - freezing string method: almost working - MACE: fix for not obeying platform - ORCA: now check for missing ! - subash.sh: now using NTASKS instead to set number of NUM_CORES, used by multiwalker feature --- ash/interfaces/interface_ORCA.py | 3 ++ ash/interfaces/interface_fsm.py | 52 ++++++++++++++++++++++++++++++-- ash/interfaces/interface_mace.py | 4 +-- scripts/subash.sh | 7 +++-- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/ash/interfaces/interface_ORCA.py b/ash/interfaces/interface_ORCA.py index 981248553..6038d7aff 100644 --- a/ash/interfaces/interface_ORCA.py +++ b/ash/interfaces/interface_ORCA.py @@ -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 diff --git a/ash/interfaces/interface_fsm.py b/ash/interfaces/interface_fsm.py index 6ce71b44c..78e73a6f2 100644 --- a/ash/interfaces/interface_fsm.py +++ b/ash/interfaces/interface_fsm.py @@ -1,5 +1,6 @@ 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 @@ -28,7 +29,7 @@ def get_forces(self, atomsobj): return self.forces -def FSM(reactant=None, product=None, theory=None, method="L-BFGS-B", optcoords="cart", +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, @@ -44,16 +45,23 @@ def __init__(self,reactant=None, product=None, theory=None, method="L-BFGS-B", o print_line_with_mainheader("Freezing String calculation initialized") try: import ase + from mlfsm.geom import project_trans_rot except: - print("Error import ase. Check if installed") + 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}) @@ -113,3 +121,43 @@ def run(self): 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_energies, label="FSM Path") + ax.scatter(s[ts_idx], all_energies[ts_idx], color="red", label="TS Guess") + ax.scatter(s[0], all_energies[0], color="black", label="Reactant/Product") + ax.scatter(s[-1], all_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_mace.py b/ash/interfaces/interface_mace.py index d627f52a2..56f55c8a1 100644 --- a/ash/interfaces/interface_mace.py +++ b/ash/interfaces/interface_mace.py @@ -559,7 +559,7 @@ 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. -def write_mace_config(config_file="config.yml", name="model",model="MACE", platform='cpu',device=None, +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, @@ -586,7 +586,7 @@ def write_mace_config(config_file="config.yml", name="model",model="MACE", platf 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, diff --git a/scripts/subash.sh b/scripts/subash.sh index 5dddc3968..af6f6d942 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 From 350f85247ee444e245fc4ea5e6a07257893fa20a Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 16 Apr 2026 11:55:33 +0200 Subject: [PATCH 120/134] create_ML_training_data: theory cleanup before calling. Allows calling function multiple times --- ash/modules/module_machine_learning.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index 8c1437d05..11329cad9 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -189,6 +189,11 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No gradients=[] fragments=[] labels=[] + + # Removing old files if present + theory_1.cleanup() + theory_2.cleanup() + if runmode=="serial": print("Runmode is serial!") print("Will now loop over XYZ-files") From 0e00188e1fa95366733b2125821d629401ec418e Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 20 Apr 2026 20:57:59 +0200 Subject: [PATCH 121/134] - subash.sh: fix for plumed multi-walker jobs - sella: support for frozenatoms, and ActiveRegion - create_ML_training_data: random_seed_set keyword option. Default None, can be set to an integer seed. - print_internal_coordinate_table_new: only do for small systems --- ash/functions/functions_optimization.py | 6 +- ash/interfaces/interface_dlfind.py | 6 +- ash/interfaces/interface_geometric_new.py | 15 +- ash/interfaces/interface_sella.py | 227 +++++++++++++++++----- ash/modules/module_machine_learning.py | 20 +- scripts/subash.sh | 5 + 6 files changed, 214 insertions(+), 65 deletions(-) diff --git a/ash/functions/functions_optimization.py b/ash/functions/functions_optimization.py index 35b595f74..c43c6e353 100644 --- a/ash/functions/functions_optimization.py +++ b/ash/functions/functions_optimization.py @@ -1378,7 +1378,8 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No self.fragment.print_coords() print() if self.printlevel >= 2: - print_internal_coordinate_table_new(self.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() print("PBC_format_option:", self.PBC_format_option) @@ -1417,7 +1418,8 @@ def run(self, theory=None, fragment=None, constraints=None, charge=None, mult=No self.fragment.print_coords() print() if self.printlevel >= 2: - print_internal_coordinate_table_new(self.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 diff --git a/ash/interfaces/interface_dlfind.py b/ash/interfaces/interface_dlfind.py index 123f93758..a51f13342 100644 --- a/ash/interfaces/interface_dlfind.py +++ b/ash/interfaces/interface_dlfind.py @@ -816,7 +816,8 @@ def _sigint_handler(signum, frame): # Printing internal coordinate table if self.printlevel >= 2: - print_internal_coordinate_table_new(self.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() # Results object @@ -860,7 +861,8 @@ def _sigint_handler(signum, frame): # Printing internal coordinate table if self.printlevel >= 2: - print_internal_coordinate_table_new(self.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() # Results object diff --git a/ash/interfaces/interface_geometric_new.py b/ash/interfaces/interface_geometric_new.py index f8d90ec3e..4646b07f7 100644 --- a/ash/interfaces/interface_geometric_new.py +++ b/ash/interfaces/interface_geometric_new.py @@ -555,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) @@ -579,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() @@ -751,8 +750,9 @@ def run(self, theory=None, fragment=None, charge=None, mult=None, constraints=No fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') fragment.set_energy(finalenergy) - print("Final geometry") - fragment.print_coords() + if self.ActiveRegion is not True: + print("Final geometry") + fragment.print_coords() print() # PBC @@ -782,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_new(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 diff --git a/ash/interfaces/interface_sella.py b/ash/interfaces/interface_sella.py index c9ba1c454..c0ff86b0d 100644 --- a/ash/interfaces/interface_sella.py +++ b/ash/interfaces/interface_sella.py @@ -2,13 +2,14 @@ import copy import shutil import time -from ash.modules.module_coords import 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,print_time_rel,print_line_with_mainheader,print_line_with_subheader1,print_if_level +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 @@ -17,20 +18,22 @@ 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, gamma=0.03): + 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() + #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(charge=charge, mult=mult, convergence_gmax=convergence_gmax, + optimizer = SellaoptimizerClass(convergence_gmax=convergence_gmax, printlevel=printlevel, maxiter=maxiter, result_write_to_disk=result_write_to_disk, - constraints=constraints, gamma=gamma) + constraints=constraints, actatoms=actatoms, frozenatoms=frozenatoms, + gamma=gamma, eta=eta) # If NumGrad then we wrap theory object into NumGrad class object if NumGrad: @@ -47,9 +50,10 @@ def SellaOptimizer(theory=None, fragment=None, charge=None, mult=None, printleve # Class for optimization. class SellaoptimizerClass: - def __init__(self,theory=None, charge=None, mult=None, printlevel=2, constraints=None, + def __init__(self,printlevel=2, convergence_gmax=3e-4, maxiter=150, result_write_to_disk=False, - gamma=0.4): + constraints=None, actatoms=None, frozenatoms=None, + gamma=0.03, eta=1e-4): self.printlevel=printlevel print_line_with_mainheader("SellaOptimizer initialization") @@ -62,7 +66,17 @@ def __init__(self,theory=None, charge=None, mult=None, printlevel=2, constraints 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) @@ -70,67 +84,148 @@ def __init__(self,theory=None, charge=None, mult=None, printlevel=2, constraints 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) - def setup_constraints(self, atoms, constraints): + # 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 '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 - if 'xyz' in constraints: - for xyzcon in constraints['xyz']: - sellacons.fix_translation(xyzcon) - # TODO: partial XYZ constraints - print("sellacons:", sellacons) + 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,printlevel=2, constraints=None): + 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(fragment.elems,positions=fragment.coords) + 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: - sella_constraints = self.setup_constraints(atoms, constraints) + 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=fragment) + 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) + gamma=self.gamma, eta=self.eta) def write_traj(a=atoms, trajname="sella_optim"): - fragment.coords = copy.copy(a.get_positions()) - fragment.write_xyzfile(xyzfilename=trajname+'.xyz', writemode='a') + 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): @@ -162,22 +257,26 @@ def write_traj(a=atoms, trajname="sella_optim"): fragment.write_xyzfile(xyzfilename='Fragment-optimized.xyz') fragment.set_energy(finalenergy) - print("Final geometry") - fragment.print_coords() - print() + if self.actatoms is None: + print("Final geometry:") + fragment.print_coords() + print() - # TODO active region #Active region XYZ-file - #if self.ActiveRegion is True: - # write_XYZ_for_atoms(fragment.coords, fragment.elems, self.actatoms, "Fragment-optimized_Active") + 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") + 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: - print_internal_coordinate_table_new(fragment,actatoms=fragment.allatoms) + if fragment.numatoms < 50: + print_internal_coordinate_table_new(fragment,actatoms=fragment.allatoms) print() # Now returning final Results object @@ -191,16 +290,19 @@ def write_traj(a=atoms, trajname="sella_optim"): # Simpler ASH-ASE calculator class ASH_ASE_calculator: - def __init__(self, theory=None, fragment=None): + 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): @@ -231,12 +333,33 @@ def get_forces(self, atomsobj): 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) - energy, gradient = self.theory.run(current_coords=self.coords, elems=self.fragment.elems, - charge=self.fragment.charge, mult=self.fragment.mult, Grad=True) - #print("New energy:", energy) + # 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 - self.gradient=gradient - self.forces = -gradient * 51.4220674763 + return self.forces diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index 11329cad9..04ac3da0f 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -18,7 +18,7 @@ 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, - energies_atoms_dict=None): + energies_atoms_dict=None, random_seed_set=None): print("-"*50) print("create_ML_training_data function") print("-"*50) @@ -64,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.") @@ -104,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.") @@ -128,6 +136,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.") @@ -166,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.") @@ -190,7 +206,7 @@ def create_ML_training_data(xyz_dir=None, dcd_trajectory=None, xyz_trajectory=No fragments=[] labels=[] - # Removing old files if present + # Removing theory_1.cleanup() theory_2.cleanup() diff --git a/scripts/subash.sh b/scripts/subash.sh index af6f6d942..39aa1dd37 100644 --- a/scripts/subash.sh +++ b/scripts/subash.sh @@ -362,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 From 60b4255fe8d720861cb147a787c71c616457dbff Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 21 Apr 2026 12:48:13 +0200 Subject: [PATCH 122/134] RestraintTheory: bond-difference restraint implemented --- ash/modules/module_machine_learning.py | 6 ++++ ash/modules/module_surface_new.py | 43 +++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ash/modules/module_machine_learning.py b/ash/modules/module_machine_learning.py index 04ac3da0f..3c675c38d 100644 --- a/ash/modules/module_machine_learning.py +++ b/ash/modules/module_machine_learning.py @@ -735,3 +735,9 @@ def move_chosen_files(chosen,dirname): 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_surface_new.py b/ash/modules/module_surface_new.py index bfc1bbd72..2232cda70 100644 --- a/ash/modules/module_surface_new.py +++ b/ash/modules/module_surface_new.py @@ -1554,6 +1554,13 @@ def __init__(self, fragment=None, printlevel=None, numcores=1, label=None, 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] @@ -1590,6 +1597,31 @@ def _bond_gradient(coords, i, j): 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. @@ -1694,7 +1726,16 @@ def run(self, current_coords=None, elems=None, Grad=False, PC=False, 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 From ba1523ecc28226f592fbac3cf0f1f465755906a2 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 21 Apr 2026 12:54:02 +0200 Subject: [PATCH 123/134] fsm: bugfixes --- ash/interfaces/interface_fsm.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ash/interfaces/interface_fsm.py b/ash/interfaces/interface_fsm.py index 78e73a6f2..f73816a68 100644 --- a/ash/interfaces/interface_fsm.py +++ b/ash/interfaces/interface_fsm.py @@ -148,10 +148,10 @@ def run(self): s = calculate_arc_length(np.array(path)) import matplotlib.pyplot as plt fig, ax = plt.subplots() - ax.plot(s, all_energies, label="FSM Path") - ax.scatter(s[ts_idx], all_energies[ts_idx], color="red", label="TS Guess") - ax.scatter(s[0], all_energies[0], color="black", label="Reactant/Product") - ax.scatter(s[-1], all_energies[-1], color="black") + 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() From ee294b08fde1ef2c76b73987618329fab1024e2c Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 21 Apr 2026 15:44:58 +0200 Subject: [PATCH 124/134] Fix for linearity check inside project_rot_and_trans, now less strict. Previous value caused HCN angle of 179.98 to behave non-linearly and cause problems --- ash/modules/module_freq.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ash/modules/module_freq.py b/ash/modules/module_freq.py index 6a456f94f..ec732ec1b 100644 --- a/ash/modules/module_freq.py +++ b/ash/modules/module_freq.py @@ -2218,7 +2218,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): From 08c73148d8cac2423131e75abd9c1d657cb648e1 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 21 Apr 2026 17:29:25 +0200 Subject: [PATCH 125/134] - fix: nonbondedtheory. Grad flag was needed in QM/MM after previous tweak to Nonbondedtheory --- ash/modules/module_MM.py | 4 ++-- ash/modules/module_QMMM.py | 2 +- ash/modules/module_freq.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ash/modules/module_MM.py b/ash/modules/module_MM.py index 34d05aefd..eb5306564 100644 --- a/ash/modules/module_MM.py +++ b/ash/modules/module_MM.py @@ -425,9 +425,9 @@ def run(self, current_coords=None, elems=None, charges=None, connectivity=None, print(BC.OKBLUE, BC.BOLD, "------------ENDING NONBONDED MM CODE-------------", BC.END) print_time_rel(module_init_time, modulename='NonbondedTheory run', moduleindex=2) if Grad: - return self.MMEnergy, self.MMGradient + return float(self.MMEnergy), self.MMGradient else: - return self.MMEnergy + return float(self.MMEnergy) # MMAtomobject used to store LJ parameter and possibly charge for MM atom with atomtype, e.g. OT diff --git a/ash/modules/module_QMMM.py b/ash/modules/module_QMMM.py index 7df8aed21..b85631b72 100644 --- a/ash/modules/module_QMMM.py +++ b/ash/modules/module_QMMM.py @@ -1359,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: diff --git a/ash/modules/module_freq.py b/ash/modules/module_freq.py index ec732ec1b..e8115a608 100644 --- a/ash/modules/module_freq.py +++ b/ash/modules/module_freq.py @@ -1996,7 +1996,6 @@ def detect_linear(fragment=None, coords=None, elems=None, threshold=1e-4): center = get_center(coords,elems=elems) #rinertia = list(inertia(elems,coords,center)) rinertia = [float(i) for i in inertia(elems,coords,center)] - print("rinertia:", rinertia) #Checking if rinertia contains an almost zero-value if any([abs(i) < threshold for i in rinertia]) is True: #print("Small value detected: ", rinertia) From 08a2ecd78bde6b0ec66f0550d57bc09d5889deb0 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 23 Apr 2026 10:21:55 +0200 Subject: [PATCH 126/134] g-xTB via xtB interface --- ash/interfaces/interface_xtb.py | 10 +++++++++- ash/modules/module_PES_rewrite.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/ash/interfaces/interface_xtb.py b/ash/interfaces/interface_xtb.py index 00f0cf449..84aeb18c3 100644 --- a/ash/interfaces/interface_xtb.py +++ b/ash/interfaces/interface_xtb.py @@ -804,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="" @@ -823,6 +824,7 @@ 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(): @@ -831,7 +833,9 @@ def run_xtb_SP(xtbdir, xtbmethod, coordfile, charge, mult, Grad=False, Opt=False xtbflag = 0 elif 'GFNFF' in xtbmethod.upper(): print("GFN-FF has been chosen") - #exit() + 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() @@ -854,6 +858,10 @@ def run_xtb_SP(xtbdir, xtbmethod, coordfile, charge, mult, Grad=False, Opt=False 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] diff --git a/ash/modules/module_PES_rewrite.py b/ash/modules/module_PES_rewrite.py index 5e319ec54..547c4a3e6 100644 --- a/ash/modules/module_PES_rewrite.py +++ b/ash/modules/module_PES_rewrite.py @@ -1044,7 +1044,10 @@ def create_SCF_configurations(self): print(f"\nMultiplicity: {fstate.mult}. Creating BETA-hole") for i in range(fstate.numionstates): occ_beta = copy.copy(self.stateI.occupations_beta) + print("occ_beta:", occ_beta) reverse_ind_counter=-1-i + print("reverse_ind_counter:", reverse_ind_counter) + print("occ_beta:", occ_beta) occ_beta[reverse_ind_counter]=0 print("New BETA Configuration:", occ_beta) SCF_CFG_betahole.append([self.stateI.occupations_alpha,occ_beta]) @@ -3081,11 +3084,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 +3104,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 +3150,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 From 2814ece4e87a4611ab60ad626e3d92736f811bdb Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Thu, 23 Apr 2026 11:56:58 +0200 Subject: [PATCH 127/134] PES: fix for OODFT for F2, --- ash/modules/module_PES_rewrite.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ash/modules/module_PES_rewrite.py b/ash/modules/module_PES_rewrite.py index 547c4a3e6..fd8b04a53 100644 --- a/ash/modules/module_PES_rewrite.py +++ b/ash/modules/module_PES_rewrite.py @@ -1038,18 +1038,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) - print("occ_beta:", occ_beta) reverse_ind_counter=-1-i - print("reverse_ind_counter:", reverse_ind_counter) - print("occ_beta:", occ_beta) 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 From 79c08f4f6ba2a98c8fba732536e7f4e9ddd84747 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Sun, 26 Apr 2026 20:46:46 +0200 Subject: [PATCH 128/134] - UFF XML file: not fully checked yet - PES and run_orca_plot: bugfix for TDDFT densities and spin densities. Needed fix for ORCA 6. --- ash/databases/forcefields/uff_mod.xml | 689 ++++++++++++++++++++++++++ ash/interfaces/interface_ORCA.py | 4 +- ash/interfaces/interface_OpenMM.py | 1 + ash/modules/module_PES_rewrite.py | 80 +-- 4 files changed, 737 insertions(+), 37 deletions(-) create mode 100644 ash/databases/forcefields/uff_mod.xml diff --git a/ash/databases/forcefields/uff_mod.xml b/ash/databases/forcefields/uff_mod.xml new file mode 100644 index 000000000..0753c309d --- /dev/null +++ b/ash/databases/forcefields/uff_mod.xml @@ -0,0 +1,689 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ash/interfaces/interface_ORCA.py b/ash/interfaces/interface_ORCA.py index 6038d7aff..bd7d2af06 100644 --- a/ash/interfaces/interface_ORCA.py +++ b/ash/interfaces/interface_ORCA.py @@ -2194,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 8a77edfea..5de9ceb9e 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -6492,3 +6492,4 @@ def write_xmlfile_parmed(topology,system,xmlfilename): ww.write(xmlfilename) print("Wrote XML-file:", xmlfilename) + diff --git a/ash/modules/module_PES_rewrite.py b/ash/modules/module_PES_rewrite.py index fd8b04a53..7648a652a 100644 --- a/ash/modules/module_PES_rewrite.py +++ b/ash/modules/module_PES_rewrite.py @@ -415,12 +415,16 @@ 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' ) + densityfilename=densityfilename ) os.rename(self.theory.filename + '.spindens.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' @@ -2087,39 +2091,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) From daadb7c901f8395206aa71a05646ac83c237ad5b Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 27 Apr 2026 12:29:11 +0200 Subject: [PATCH 129/134] PES: run_tddft_densities, bugfix for spindens --- ash/modules/module_PES_rewrite.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ash/modules/module_PES_rewrite.py b/ash/modules/module_PES_rewrite.py index 7648a652a..0c391eb80 100644 --- a/ash/modules/module_PES_rewrite.py +++ b/ash/modules/module_PES_rewrite.py @@ -418,7 +418,8 @@ def run_tddft_densities(self,fragment): 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=densityfilename ) - os.rename(self.theory.filename + '.spindens.cube', 'Final_State_mult' + str(fstate.mult)+'TDDFTstate_'+str(tddftstate)+'.spindens.cube') + # 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}" From 03b3f530597ade5b7b9872e68bb4bfa7e2c0b3d4 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Mon, 27 Apr 2026 12:50:48 +0200 Subject: [PATCH 130/134] PES: MRCI SOC bugfix --- ash/modules/module_PES_rewrite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ash/modules/module_PES_rewrite.py b/ash/modules/module_PES_rewrite.py index 0c391eb80..a68cdc40c 100644 --- a/ash/modules/module_PES_rewrite.py +++ b/ash/modules/module_PES_rewrite.py @@ -3042,7 +3042,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: From 31f26cd24fcee56d60ab2342fa5aefb9362fc4c6 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 28 Apr 2026 13:53:55 +0200 Subject: [PATCH 131/134] - write_pdbfile: minor fix - define_uff and lookup_UFF_atomname functions --- ash/databases/fragments/methane.xyz | 2 +- ash/interfaces/interface_OpenMM.py | 180 +++++++++++++++++++++++++++- ash/modules/module_PES_rewrite.py | 10 +- ash/modules/module_coords.py | 6 +- 4 files changed, 187 insertions(+), 11 deletions(-) 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/interfaces/interface_OpenMM.py b/ash/interfaces/interface_OpenMM.py index 5de9ceb9e..f324088af 100644 --- a/ash/interfaces/interface_OpenMM.py +++ b/ash/interfaces/interface_OpenMM.py @@ -16,11 +16,11 @@ 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, define_dummy_topology + 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 @@ -6493,3 +6493,179 @@ def write_xmlfile_parmed(topology,system,xmlfilename): print("Wrote XML-file:", xmlfilename) + +def lookup_UFF_atomname(el, atomindex, bom): + """ + Guess a UFF atom type from element + bond order matrix. + + Parameters + ---------- + el : str + Element symbol (e.g. "C", "N", "O") + atomindex : int + Index of atom + bom : np.ndarray + Bond-order matrix + + Returns + ------- + uffatomtype : str + UFF atom type string + """ + + print("inside: lookup_UFF_atomname") + print("Element:", el) + print("Atomindex:", atomindex) + print("BOM:", bom) + + # Get bond orders for this atom + bondorders = bom[atomindex] + print("bondorders:", bondorders) + + # Only bonded atoms + bonded_bos = [bo for bo in bondorders if bo > 0.1] + + print("bonded_bos:", bonded_bos) + + coordination = len(bonded_bos) + max_bo = max(bonded_bos) if bonded_bos else 0.0 + + print("coordination:", coordination) + print("max bond order:", max_bo) + + # -------------------------------------------------- + # Basic UFF atom type assignment rules + # -------------------------------------------------- + + # HYDROGEN + if el == "H": + uffatomtype = "H_" + + # CARBON + elif el == "C": + if max_bo >= 2.5: + # triple bond / sp + uffatomtype = "C_1" + elif max_bo >= 1.5: + # double bond / aromatic / sp2 + uffatomtype = "C_2" + else: + # single bond / sp3 + uffatomtype = "C_3" + + # NITROGEN + elif el == "N": + if max_bo >= 2.5: + uffatomtype = "N_1" # sp + elif max_bo >= 1.5: + uffatomtype = "N_2" # sp2 + else: + uffatomtype = "N_3" # sp3 + + # OXYGEN + elif el == "O": + if max_bo >= 1.5: + uffatomtype = "O_2" # sp2 oxygen (carbonyl etc.) + else: + uffatomtype = "O_3" # sp3 oxygen (alcohol, water, ether) + + # SULFUR + elif el == "S": + if max_bo >= 1.5: + uffatomtype = "S_2" + else: + uffatomtype = "S_3" + + # PHOSPHORUS + elif el == "P": + uffatomtype = "P_3" + + # HALOGENS + elif el == "F": + uffatomtype = "F_" + elif el == "Cl": + uffatomtype = "Cl" + elif el == "Br": + uffatomtype = "Br" + elif el == "I": + uffatomtype = "I_" + + # SILICON + elif el == "Si": + uffatomtype = "Si3" + + # BORON + elif el == "B": + uffatomtype = "B_3" + + # fallback + else: + print(f"WARNING: No specific UFF rule for element {el}") + print("Using generic fallback") + uffatomtype = el + + print("Assigned UFF atom type:", uffatomtype) + + return uffatomtype + + +def define_uff(fragment=None, scale=1.0, tol=0.1, bom=None): + + print("Computing connectivity") + fragment.calc_connectivity() + # Get dictionary of connected atoms + connatomsdict = get_connected_atoms_dict(fragment.coords, fragment.elems, scale, tol) + print("Dict of connected atoms:", connatomsdict) + # Determining bond order matrix + if bom is None: + print("No input bondorder matrix provided (bom keyword)") + print("Will calculate Bond-order matrix via tblite (requires tblite to be installed)") + tb = tbliteTheory(method="GFN2-xTB", grab_BOs=True) + Singlepoint(theory=tb, fragment=fragment) + bom = tb.BOs + print("Bond order matrix:", bom) + + # Create uff_residues.xml file + UFFatomtypeslist=[] + atomnames=[] + with open("uff_residues.xml", 'w') as f: + f.write("\n") + f.write("\n") + f.write("\n") + + for i,mol in enumerate(fragment.connectivity): + print("mol:", mol) + molname=f"MOL{i}" + f.write(f" \n") + for i,at in enumerate(mol): + # Determining atom type + el = fragment.elems[at] + uff_at = lookup_UFF_atomname(el,at,bom) + # Atom name + atomname=f'{el}_{i}' + atomnames.append(atomname) + # Write Atom line + f.write(f" "+'\n') + # Now writing bonding + seen_pairs = set() + for k,connats in connatomsdict.items(): + for c in connats: + # Skipping already seen pairs + if tuple(sorted((k, c))) in seen_pairs: + continue + seen_pairs.add(tuple(sorted((k, c)))) + # Add Bond line + f.write(f" "+'\n') + f.write("\n") + f.write(" \n") + f.write("\n") + f.write("\n") + + # Full internal XML definition + uff_full = ashpath + "/databases/forcefields/"+"uff_mod.xml" + print("uff_full", uff_full) + + # PDB-file + pdbfile = fragment.write_pdbfile_openmm() + + return [uff_full, "uff_residues.xml"], pdbfile \ No newline at end of file diff --git a/ash/modules/module_PES_rewrite.py b/ash/modules/module_PES_rewrite.py index a68cdc40c..1b52ad4c9 100644 --- a/ash/modules/module_PES_rewrite.py +++ b/ash/modules/module_PES_rewrite.py @@ -45,12 +45,12 @@ def PhotoElectron(theory=None, fragment=None, method=None, vibrational_option=No 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, diff --git a/ash/modules/module_coords.py b/ash/modules/module_coords.py index 188fad356..4840b43be 100644 --- a/ash/modules/module_coords.py +++ b/ash/modules/module_coords.py @@ -2392,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 From 3fa8be88e1e648789986415756dbfc26b63510b9 Mon Sep 17 00:00:00 2001 From: RagnarB83 Date: Tue, 5 May 2026 10:21:24 +0200 Subject: [PATCH 132/134] - UFF: updated XML file, not fully tested yet - PES: deltaSCFkeyword option --- ash/databases/forcefields/uff_mod.xml | 309 ++++++++++++++++---------- ash/interfaces/interface_Turbomole.py | 5 +- ash/interfaces/interface_openbabel.py | 3 +- ash/modules/module_PES_rewrite.py | 12 +- 4 files changed, 201 insertions(+), 128 deletions(-) diff --git a/ash/databases/forcefields/uff_mod.xml b/ash/databases/forcefields/uff_mod.xml index 0753c309d..d41ef7253 100644 --- a/ash/databases/forcefields/uff_mod.xml +++ b/ash/databases/forcefields/uff_mod.xml @@ -1,4 +1,79 @@ + @@ -143,133 +218,121 @@ - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +