1 #!/bin/env python 2 # 3 # File: OpenFECalculatePartialCharges.py 4 # Author: Manish Sud <msud@san.rr.com> 5 # 6 # Copyright (C) 2026 Manish Sud. All rights reserved. 7 # 8 # The functionality available in this script is implemented using OpenFE, an 9 # open source package for alchemical free energy calculations. 10 # 11 # This file is part of MayaChemTools. 12 # 13 # MayaChemTools is free software; you can redistribute it and/or modify it under 14 # the terms of the GNU Lesser General Public License as published by the Free 15 # Software Foundation; either version 3 of the License, or (at your option) any 16 # later version. 17 # 18 # MayaChemTools is distributed in the hope that it will be useful, but without 19 # any warranty; without even the implied warranty of merchantability of fitness 20 # for a particular purpose. See the GNU Lesser General Public License for more 21 # details. 22 # 23 # You should have received a copy of the GNU Lesser General Public License 24 # along with MayaChemTools; if not, see <http://www.gnu.org/licenses/> or 25 # write to the Free Software Foundation Inc., 59 Temple Place, Suite 330, 26 # Boston, MA, 02111-1307, USA. 27 # 28 29 from __future__ import print_function 30 31 import os 32 import sys 33 import time 34 import re 35 import logging 36 37 # OpenFE imports... 38 try: 39 import openfe 40 except ImportError as ErrMsg: 41 sys.stderr.write("\nFailed to import OpenFE related module/package: %s\n" % ErrMsg) 42 sys.stderr.write("Check/update your OpenFE environment and try again.\n\n") 43 sys.exit(1) 44 45 # RDKit imports... 46 try: 47 from rdkit import rdBase 48 except ImportError as ErrMsg: 49 sys.stderr.write("\nFailed to import RDKit module/package: %s\n" % ErrMsg) 50 sys.stderr.write("Check/update your RDKit environment and try again.\n\n") 51 sys.exit(1) 52 53 # MayaChemTools imports... 54 sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), "..", "lib", "Python")) 55 try: 56 from docopt import docopt 57 import MiscUtil 58 import RDKitUtil 59 import OpenFEUtil 60 except ImportError as ErrMsg: 61 sys.stderr.write("\nFailed to import MayaChemTools module/package: %s\n" % ErrMsg) 62 sys.stderr.write("Check/update your MayaChemTools environment and try again.\n\n") 63 sys.exit(1) 64 65 ScriptName = os.path.basename(sys.argv[0]) 66 Options = {} 67 OptionsInfo = {} 68 69 70 def main(): 71 """Start execution of the script.""" 72 73 MiscUtil.PrintInfo( 74 "\n%s (OpenFE v%s; OpenMM v%s; RDKit v%s; MayaChemTools v%s; %s): Starting...\n" 75 % ( 76 ScriptName, 77 openfe.version("openfe"), 78 openfe.version("openmm"), 79 rdBase.rdkitVersion, 80 MiscUtil.GetMayaChemToolsVersion(), 81 time.asctime(), 82 ) 83 ) 84 85 (WallClockTime, ProcessorTime) = MiscUtil.GetWallClockAndProcessorTime() 86 87 # Retrieve command line arguments and options... 88 RetrieveOptions() 89 90 # Process and validate command line arguments and options... 91 ProcessOptions() 92 93 # Perform actions required by the script... 94 CalculatePartialCharges() 95 96 MiscUtil.PrintInfo("\n%s: Done...\n" % ScriptName) 97 MiscUtil.PrintInfo("Total time: %s" % MiscUtil.GetFormattedElapsedTime(WallClockTime, ProcessorTime)) 98 99 100 def CalculatePartialCharges(): 101 """Calculate partial charges and write them out to a SD file.""" 102 103 # Process molecules... 104 Mols = ProcessMolecules() 105 106 # Calculate charges... 107 ChargedMols = CalculateCharges(Mols) 108 109 # Write charges... 110 WriteCharges(ChargedMols) 111 112 113 def CalculateCharges(Mols): 114 """Calculate partial charges.""" 115 116 MiscUtil.PrintInfo("\nCalculating partial atomic charges (%s)..." % OptionsInfo["Charge"]) 117 118 ChargedMols, Status = OpenFEUtil.CalculatePartialCharges(Mols, OptionsInfo["Charge"], OptionsInfo["ChargeParams"]) 119 120 if not Status: 121 MiscUtil.PrintError("Failed to calculate partial atomic charges.") 122 123 return ChargedMols 124 125 126 def WriteCharges(ChargedMols): 127 """Write charges.""" 128 129 Writer = RDKitUtil.MoleculesWriter(OptionsInfo["Outfile"], **OptionsInfo["OutfileParams"]) 130 if Writer is None: 131 MiscUtil.PrintError("Failed to setup a writer for output fie %s " % OptionsInfo["Outfile"]) 132 MiscUtil.PrintInfo("\nGenerating file %s..." % OptionsInfo["Outfile"]) 133 134 RDKitChargedMols = [openfe.SmallMoleculeComponent.to_rdkit(Mol) for Mol in ChargedMols] 135 136 for Mol in RDKitChargedMols: 137 FormatPartialCharges(Mol) 138 Writer.write(Mol) 139 Writer.close() 140 141 142 def FormatPartialCharges(Mol): 143 """Format partial charges.""" 144 145 Precision = OptionsInfo["ChargeParams"]["Precision"] 146 LineSize = OptionsInfo["ChargeParams"]["LineSize"] 147 148 # RDkit uses Chem.CreateAtomDoublePropertyList(rdmol, "PartialCharge") to set 149 # up 'atom.dprop.PartialCharge' employing "\n" as new line delimiter. 150 PropName = OpenFEUtil.GetPartialChargePropName() 151 LineDelim = "\n" 152 153 ChargesString = Mol.GetProp(PropName) 154 155 FormattedChargesLines = [] 156 CurrentLine = None 157 CurrentLineSize = 0 158 159 for ChargesLine in ChargesString.split(LineDelim): 160 for Value in ChargesLine.split(): 161 FormattedValue = "%.*f" % (Precision, float(Value)) 162 FormattedValueSize = len(FormattedValue) 163 164 if (FormattedValueSize + CurrentLineSize + 1) >= LineSize: 165 if CurrentLine is not None: 166 FormattedChargesLines.append(" ".join(CurrentLine)) 167 168 CurrentLine = [FormattedValue] 169 CurrentLineSize = FormattedValueSize 170 else: 171 CurrentLineSize += FormattedValueSize 172 if CurrentLine is None: 173 CurrentLine = [FormattedValue] 174 else: 175 # Increment line size to account for space delimiter.... 176 CurrentLineSize += 1 177 CurrentLine.append(FormattedValue) 178 179 if CurrentLine is not None: 180 FormattedChargesLines.append(" ".join(CurrentLine)) 181 182 FormattedChargesString = LineDelim.join(FormattedChargesLines) 183 Mol.SetProp(PropName, FormattedChargesString) 184 185 186 def ProcessMolecules(): 187 """Process molecules.""" 188 189 MiscUtil.PrintInfo("\nProcessing file %s..." % OptionsInfo["Infile"]) 190 Mols, MolCount, ValidMolCount = OpenFEUtil.ReadAndValidateMolecules( 191 OptionsInfo["InfilePath"], **OptionsInfo["InfileParams"] 192 ) 193 194 MiscUtil.PrintInfo("\nTotal number of molecules: %d" % MolCount) 195 MiscUtil.PrintInfo("Number of valid molecules: %d" % ValidMolCount) 196 MiscUtil.PrintInfo("Number of ignored molecules: %d" % (MolCount - ValidMolCount)) 197 198 if ValidMolCount == 0: 199 MiscUtil.PrintInfo("") 200 MiscUtil.PrintError("No valid molecules found in input file.\n") 201 202 return Mols 203 204 205 def ConfigureLogging(): 206 """Configure logging.""" 207 208 OptionsInfo["LoggingLevel"] = Options["--loggingLevel"] 209 210 if re.match("^Warning$", OptionsInfo["LoggingLevel"], re.I): 211 LoggingLevel = logging.WARNING 212 else: 213 LoggingLevel = logging.INFO 214 215 logging.basicConfig(format="%(levelname)s: %(message)s", level=LoggingLevel) 216 217 218 def ProcessOptions(): 219 """Process and validate command line arguments and options.""" 220 221 MiscUtil.PrintInfo("Processing options...") 222 223 # Validate options... 224 ValidateOptions() 225 226 # Configure logging... 227 ConfigureLogging() 228 229 OptionsInfo["Infile"] = Options["--infile"] 230 OptionsInfo["InfilePath"] = os.path.abspath(OptionsInfo["Infile"]) 231 232 ParamsDefaultInfoOverride = {"RemoveHydrogens": False} 233 OptionsInfo["InfileParams"] = MiscUtil.ProcessOptionInfileParameters( 234 "--infileParams", 235 Options["--infileParams"], 236 InfileName=Options["--infile"], 237 ParamsDefaultInfo=ParamsDefaultInfoOverride, 238 ) 239 240 OptionsInfo["Outfile"] = Options["--outfile"] 241 OptionsInfo["OutfileParams"] = MiscUtil.ProcessOptionOutfileParameters( 242 "--outfileParams", Options["--outfileParams"] 243 ) 244 245 OptionsInfo["Charge"] = OpenFEUtil.ProcessOptionOpenFECharge("-c, --charge", Options["--charge"]) 246 OptionsInfo["ChargeParams"] = OpenFEUtil.ProcessOptionOpenFEChargeParameters( 247 "--chargeParams", Options["--chargeParams"], OptionsInfo["Charge"] 248 ) 249 250 OptionsInfo["LoggingLevel"] = Options["--loggingLevel"] 251 252 OptionsInfo["Overwrite"] = Options["--overwrite"] 253 254 255 def RetrieveOptions(): 256 """Retrieve command line arguments and options.""" 257 258 # Get options... 259 global Options 260 Options = docopt(_docoptUsage_) 261 262 # Set current working directory to the specified directory... 263 WorkingDir = Options["--workingdir"] 264 if WorkingDir: 265 os.chdir(WorkingDir) 266 267 # Handle examples option... 268 if "--examples" in Options and Options["--examples"]: 269 MiscUtil.PrintInfo(MiscUtil.GetExamplesTextFromDocOptText(_docoptUsage_)) 270 sys.exit(0) 271 272 273 def ValidateOptions(): 274 """Validate option values.""" 275 276 MiscUtil.ValidateOptionFilePath("-i, --infile", Options["--infile"]) 277 MiscUtil.ValidateOptionFileExt("-i, --infile", Options["--infile"], "sdf sd mol") 278 279 MiscUtil.ValidateOptionFileExt("-o, --outfile", Options["--outfile"], "sdf sd") 280 MiscUtil.ValidateOptionsOutputFileOverwrite( 281 "-o, --outfile", Options["--outfile"], "--overwrite", Options["--overwrite"] 282 ) 283 MiscUtil.ValidateOptionsDistinctFileNames( 284 "-i, --infile", Options["--infile"], "-o, --outfile", Options["--outfile"] 285 ) 286 287 MiscUtil.ValidateOptionTextValue( 288 "-c, --charge", Options["--charge"], "AM1BCC AM1-Mulliken Espaloma Gasteiger MMFF94 NAGL" 289 ) 290 291 MiscUtil.ValidateOptionTextValue("--loggingLevel", Options["--loggingLevel"], "Info Warning") 292 293 294 # Setup a usage string for docopt... 295 _docoptUsage_ = """ 296 OpenFECalculatePartialCharges.py - Generate ligand network 297 298 Usage: 299 OpenFECalculatePartialCharges.py [--charge <text>] [--chargeParams <Name,Value,..>] 300 [--infileParams <Name,Value,...>] [--loggingLevel <Info or Warning>] 301 [--outfileParams <Name,Value,...>] [--overwrite] [-w <dir>] -i <infile> -o <outfile> 302 OpenFECalculatePartialCharges.py -h | --help | -e | --examples 303 304 Description: 305 Calculate partial atomic charges for molecules in an input file and write 306 them out to a SD file. 307 308 The partial charges are written to SD file as values of the data field label 309 'atom.dprop.PartialCharge'. These values are automatically processed by 310 RDKit during the loading of a SD file and assigned to atoms. You may retrieve 311 these value using RDKit method Atom.GetDoubleProp('PartialCharge') 312 313 You must specify a valid input file containing 3D coordinates for all 314 molecules. In addition, the hydrogens must be present for all molecules 315 in the input file. 316 317 The supported input file formats are: Mol (.mol), SD (.sdf, .sd) 318 319 The supported output file formats is: SD (.sdf, .sd) 320 321 Options: 322 -e, --examples 323 Print examples. 324 -h, --help 325 Print this help message. 326 -i, --infile <infile> 327 Input file name. 328 -c, --charge <text> [default: AM1BCC] 329 Type of partial atomic charges to calculate. Possible values: AM1BCC, 330 AM1-Mulliken, Espaloma, Gasteiger, MMFF94, or NAGL. 331 --chargeParams <Name,Value,..> [default: auto] 332 A comma delimited list of parameter name and value pairs for calculating 333 partial aromic charges. 334 335 The supported parameter names along with their default values are 336 337 naglModel, auto [ Possible value: A valid NAGL model name. By 338 default, it corresponds to the latest AM1BCC production model ] 339 toolkit, auto [ Possible values: RDKit or AmberTools. Default value: 340 RDKit for Gasteiger and MMFF94; AmberTools for AM1BCC and 341 AM1-Mulliken; Not used for Espaloma and NAGL. ] 342 343 numProcessors, 1 [ Only used for AM1BCC, AM1-Mulliken, Espaloma, 344 and NAGL ] 345 346 precision, 4 347 lineSize, 90 348 349 useConformer, auto [ Use current conformer. Possible values: yes or 350 no. Default value: no for Gasteiger using AmberToolkit; 351 otherwise, yes. ] 352 353 A brief description of parameters is provided below: 354 355 naglModel: NAGL model name. The latest AM1BCC NAGL production 356 model is used by default. You must specify it explicitly in case no 357 production model is available. 358 toolkit: Toolkit name. RDKit for Gasteiger and MMFF94; AmberTools 359 for AM1BCC, AM1-Mulliken, and Gasteiger. 360 361 numProcessors: Number of processors. This is only used during the 362 calculation of AM1BCC, AM1-Mulliken, Espaloma, and NAGL 363 employing OpenFE method bulk_assign_partial_charges(). 364 365 precision: Floating point precision for writing the calculated 366 partial atomic charges. 367 lineSize: Line size for writing the calculated partial aromic 368 charges to SD file as a string value for data field label 369 'atom.dprop.PartialCharge'. 370 371 useConformer: Use current conformer. The current conformer is 372 always used to calculate AM1BCC, Espaloma abd NAGL charges 373 using OpenFE method bulk_assign_partial_charges() and this 374 option is ignored. In addition, the option value is passed to 375 OpenFF method assign_partial_charges() during the calculation 376 of AM1-Mulliken, Gasteiger and MMFF94 charges employing 377 AmberTools or RDKit. The RDKit functions, however, ignore the 378 conformer during the calculation of Gasteiger and MMFF94 379 charges. The current conformer appears not used to calculate 380 Gasteiger charges employing AmberTools. 381 382 --infileParams <Name,Value,...> [default: auto] 383 A comma delimited list of parameter name and value pairs for reading 384 molecules from files. The supported parameter names for different file 385 formats, along with their default values, are shown below: 386 387 SD,MOL: removeHydrogens,no,sanitize,yes,strictParsing,yes 388 389 --loggingLevel <Info or Warning> [default: Warning] 390 Logging level to configure the 'root logger' via logging.basicConfig() 391 function. The default logging level is changed from 'logging.INFO' to 392 'logging.WARNING'. Otherwise, OpenFE and its associated modules 393 may generate a lot of informational messages. 394 -o, --outfile <outfile> 395 Output file name. 396 --outfileParams <Name,Value,...> [default: auto] 397 A comma delimited list of parameter name and value pairs for writing 398 molecules to files. The supported parameter names for different file 399 formats, along with their default values, are shown below: 400 401 SD: kekulize,yes,forceV3000,no 402 403 --overwrite 404 Overwrite existing files. 405 -w, --workingdir <dir> 406 Location of working directory which defaults to the current directory. 407 408 Examples: 409 To calculate AM1BCC partial atomic charges for molecules in a SD file and 410 file and write them out to a file, type: 411 412 % OpenFECalculatePartialCharges.py -i SampleTyk2Ligands.sdf 413 -o SampleTyk2LigandsAM1BCCOut.sdf 414 415 To run the first example for calculating AM1-Mulliken charges using 416 AmberTools, type: 417 418 % OpenFECalculatePartialCharges.py -i SampleTyk2Ligands.sdf 419 -o SampleTyk2LigandsAM1MullikenOut.sdf -c AM1-Mulliken 420 421 To run the first example for calculating Gasteiger charges using RDKit, 422 type: 423 424 % OpenFECalculatePartialCharges.py -i SampleTyk2Ligands.sdf 425 -o SampleTyk2LigandsGasteigerRDKit.sdf -c Gasteiger 426 427 To run the first example for calculating Gasteiger charges using 428 AmberTools, type: 429 430 % OpenFECalculatePartialCharges.py -i SampleTyk2Ligands.sdf 431 -o SampleTyk2LigandsasteigerAmberTools.sdf -c Gasteiger 432 --chargeParams "toolkit,AmberTools" 433 434 To run the first example for calculating NAGL charges using the default 435 production AM1BCC model, type: 436 437 % OpenFECalculatePartialCharges.py -i SampleTyk2Ligands.sdf 438 -o SampleTyk2LigandsNAGL.sdf -c NAGL 439 440 To run the first example for calculating NAGL charges using a specific 441 model, type: 442 443 % OpenFECalculatePartialCharges.py -i SampleTyk2Ligands.sdf 444 -o SampleTyk2LigandsNAGL.sdf -c NAGL 445 --chargeParams "naglmodel, openff-gnn-am1bcc-0.1.0-rc.3.pt" 446 447 To run the first example by specifying explicit values for various parameters, 448 type: 449 450 % OpenFECalculatePartialCharges.py -i SampleTyk2Ligands.sdf 451 -o SampleTyk2LigandsAM1BCCOut.sdf -c AM1BCC 452 --chargeParams "numProcessors, 4, precision, 4, lineSize, 90" 453 --loggingLevel Warning 454 455 Author: 456 Manish Sud(msud@san.rr.com) 457 458 See also: 459 OpenFECalculateAbsoluteHydrationFreeEnergy.py, OpenFEGenerateLigandNetwork.py, 460 OpenFECalculateRelativeBindingFreeEnergy.py, 461 OpenFECalculateRelativeHydrationFreeEnergy.py 462 463 Copyright: 464 Copyright (C) 2026 Manish Sud. All rights reserved. 465 466 The functionality available in this script is implemented using OpenFE, an 467 open source molecuar for alchemical free energy calculations. 468 469 This file is part of MayaChemTools. 470 471 MayaChemTools is free software; you can redistribute it and/or modify it under 472 the terms of the GNU Lesser General Public License as published by the Free 473 Software Foundation; either version 3 of the License, or (at your option) any 474 later version. 475 476 """ 477 478 if __name__ == "__main__": 479 main()