######################################################################## EMLE-Engine: https://github.com/chemle/emle-engine## Copyright: 2023-2025## Authors: Lester Hedges <lester.hedges@gmail.com># Kirill Zinovjev <kzinovjev@gmail.com>## EMLE-Engine is free software: you can redistribute it and/or modify# it under the terms of the GNU General Public License as published by# the Free Software Foundation, either version 2 of the License, or# (at your option) any later version.## EMLE-Engine is distributed in the hope that it will be useful,# but WITHOUT ANY WARRANTY; without even the implied warranty of# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the# GNU General Public License for more details.## You should have received a copy of the GNU General Public License# along with EMLE-Engine. If not, see <http://www.gnu.org/licenses/>.#####################################################################"""SQM in-vacuo backend implementation."""__all__=["SQM"]importnumpyas_npimportosas_osimportshlexas_shleximportsubprocessas_subprocessimporttempfileas_tempfilefrom.._unitsimport_KCAL_MOL_TO_HARTREE,_BOHR_TO_ANGSTROMfrom._backendimportBackendas_Backend
[docs]classSQM(_Backend):""" SQM in-vacuo backend implementation. """def__init__(self,parm7,theory="DFTB3",qm_charge=0):""" Constructor. Parameters ---------- parm7: str The path to the AMBER topology file for the QM region. theory: str The SQM theory to use. qm_charge: int The charge on the QM region. """# Make sure a topology file has been set.ifparm7isNone:raiseValueError("'parm7' must be specified when using the SQM backend")try:fromsanderimportAmberParmas_AmberParmamber_parm=_AmberParm(parm7)except:raiseIOError(f"Unable to load AMBER topology file: '{parm7}'")self._parm7=parm7ifnotisinstance(theory,str):raiseTypeError("'theory' must be of type 'str'.")# Store the atom names for the QM region.self._atom_names=[atom.nameforatominamber_parm.atoms]# Strip whitespace.self._theory=theory.replace(" ","")# Validate the QM charge.ifnotisinstance(qm_charge,int):raiseTypeError("'qm_charge' must be of type 'int'.")self._qm_charge=qm_charge
[docs]defcalculate(self,atomic_numbers,xyz,forces=True,qm_charge=None):""" Compute the energy and forces. Parameters ---------- atomic_numbers: numpy.ndarray The atomic numbers of the atoms in the QM region. xyz: numpy.ndarray The coordinates of the atoms in the QM region in Angstrom. forces: bool Whether to calculate and return forces. qm_charge: int The charge on the QM region. Returns ------- energy: float The in-vacuo energy in Eh. forces: numpy.ndarray The in-vacuo gradient in Eh/Bohr. """ifnotisinstance(xyz,_np.ndarray):raiseTypeError("'xyz' must be of type 'numpy.ndarray'")ifxyz.dtype!=_np.float64:raiseTypeError("'xyz' must have dtype 'float64'.")ifnotisinstance(atomic_numbers,_np.ndarray):raiseTypeError("'atomic_numbers' must be of type 'numpy.ndarray'")ifqm_chargeisNone:qm_charge=self._qm_chargeelse:ifnotisinstance(qm_charge,int):raiseTypeError("'qm_charge' must be of type 'int'.")iflen(atomic_numbers)!=len(xyz):raiseValueError(f"Length of 'atomic_numbers' ({len(atomic_numbers)}) does not "f"match length of 'xyz' ({len(xyz)})")# Convert to batched NumPy arrays.iflen(atomic_numbers.shape)==1:atomic_numbers=_np.expand_dims(atomic_numbers,axis=0)xyz=_np.expand_dims(xyz,axis=0)# Lists to store results.results_energy=[]results_forces=[]# Loop over batches.fori,(atomic_numbers_i,xyz_i)inenumerate(zip(atomic_numbers,xyz)):iflen(atomic_numbers_i)!=len(xyz_i):raiseValueError(f"Length of 'atomic_numbers' ({len(atomic_numbers_i)}) does not "f"match length of 'xyz' ({len(xyz_i)}) for index {i}")# Store the number of QM atoms.num_qm=len(atomic_numbers_i)# Create a temporary working directory.with_tempfile.TemporaryDirectory()astmp:# Work out the name of the input files.inp_name=f"{tmp}/sqm.in"out_name=f"{tmp}/sqm.out"# Write the input file.withopen(inp_name,"w")asf:# Write the header.f.write("Run semi-empirical minimization\n")f.write(" &qmmm\n")f.write(f" qm_theory='{self._theory}',\n")f.write(f" qmcharge={qm_charge},\n")f.write(" maxcyc=0,\n")f.write(" verbosity=4,\n")f.write(f" /\n")# Write the QM region coordinates.fornum,name,xyz_qminzip(atomic_numbers_i,self._atom_names,xyz_i):x,y,z=xyz_qmf.write(f" {num}{name}{x:.4f}{y:.4f}{z:.4f}\n")# Create the SQM command.command=f"sqm -i {inp_name} -o {out_name}"# Run the command as a sub-process.proc=_subprocess.run(_shlex.split(command),shell=False,stdout=_subprocess.PIPE,stderr=_subprocess.PIPE,)ifproc.returncode!=0:raiseRuntimeError("SQM job failed!")ifnot_os.path.isfile(out_name):raiseIOError(f"Unable to locate SQM output file: {out_name}")withopen(out_name,"r")asf:is_converged=Falseis_force=Falsenum_forces=0forces=[]forlineinf:# Skip lines prior to convergence.ifline.startswith(" QMMM SCC-DFTB: SCC-DFTB for step 0 converged"):is_converged=Truecontinue# Now process the final energy and force records.ifis_converged:ifline.startswith(" Total SCF energy"):try:energy=float(line.split()[4])except:raiseIOError(f"Unable to parse SCF energy record: {line}")elifline.startswith("QMMM: Forces on QM atoms from SCF calculation"):# Flag that force records are coming.is_force=Trueelifis_force:try:force=[float(x)forxinline.split()[3:6]]except:raiseIOError(f"Unable to parse SCF gradient record: {line}")# Update the forces.forces.append(force)num_forces+=1# Exit if we've got all the forces.ifnum_forces==num_qm:is_force=Falsebreakifnum_forces!=num_qm:raiseIOError("Didn't find force records for all QM atoms in the SQM output!")# Convert units.results_energy.append(energy*_KCAL_MOL_TO_HARTREE)# Convert the gradient to a NumPy array and reshape. Misleading comment# in sqm output, the "forces" are actually gradients so need to multiply by -1results_forces.append(-_np.array(forces)*_KCAL_MOL_TO_HARTREE*_BOHR_TO_ANGSTROM)# Convert to NumPy arrays.results_energy=_np.array(results_energy)results_forces=_np.array(results_forces)returnresults_energy,results_forcesifforceselseresults_energy