View Javadoc
1   // ******************************************************************************
2   //
3   // Title:       Force Field X.
4   // Description: Force Field X - Software for Molecular Biophysics.
5   // Copyright:   Copyright (c) Michael J. Schnieders 2001-2025.
6   //
7   // This file is part of Force Field X.
8   //
9   // Force Field X is free software; you can redistribute it and/or modify it
10  // under the terms of the GNU General Public License version 3 as published by
11  // the Free Software Foundation.
12  //
13  // Force Field X is distributed in the hope that it will be useful, but WITHOUT
14  // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
15  // FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
16  // details.
17  //
18  // You should have received a copy of the GNU General Public License along with
19  // Force Field X; if not, write to the Free Software Foundation, Inc., 59 Temple
20  // Place, Suite 330, Boston, MA 02111-1307 USA
21  //
22  // Linking this library statically or dynamically with other modules is making a
23  // combined work based on this library. Thus, the terms and conditions of the
24  // GNU General Public License cover the whole combination.
25  //
26  // As a special exception, the copyright holders of this library give you
27  // permission to link this library with independent modules to produce an
28  // executable, regardless of the license terms of these independent modules, and
29  // to copy and distribute the resulting executable under terms of your choice,
30  // provided that you also meet, for each linked independent module, the terms
31  // and conditions of the license of that module. An independent module is a
32  // module which is not derived from or based on this library. If you modify this
33  // library, you may extend this exception to your version of the library, but
34  // you are not obligated to do so. If you do not wish to do so, delete this
35  // exception statement from your version.
36  //
37  // ******************************************************************************
38  package ffx.potential.cli;
39  
40  import edu.rit.pj.ParallelTeam;
41  import ffx.numerics.Potential;
42  import ffx.numerics.switching.MultiplicativeSwitch;
43  import ffx.numerics.switching.PowerSwitch;
44  import ffx.numerics.switching.SquaredTrigSwitch;
45  import ffx.numerics.switching.UnivariateSwitchingFunction;
46  import ffx.potential.DualTopologyEnergy;
47  import ffx.potential.MolecularAssembly;
48  import ffx.potential.QuadTopologyEnergy;
49  import ffx.potential.bonded.Atom;
50  import picocli.CommandLine.ArgGroup;
51  import picocli.CommandLine.Option;
52  
53  import javax.annotation.Nullable;
54  import java.util.Arrays;
55  import java.util.Collections;
56  import java.util.HashSet;
57  import java.util.List;
58  import java.util.Set;
59  import java.util.logging.Level;
60  import java.util.logging.Logger;
61  import java.util.regex.Matcher;
62  import java.util.stream.Collectors;
63  
64  import static java.lang.Double.parseDouble;
65  import static java.lang.String.format;
66  
67  /**
68   * Represents command line options for scripts that utilize multiple physical topologies.
69   *
70   * @author Michael J. Schnieders
71   * @author Jacob M. Litman
72   * @since 1.0
73   */
74  public class TopologyOptions {
75  
76    /**
77     * The logger for this class.
78     */
79    public static final Logger logger = Logger.getLogger(TopologyOptions.class.getName());
80  
81    /**
82     * The ArgGroup keeps the TopologyOptions together when printing help.
83     */
84    @ArgGroup(heading = "%n Alchemical Options for Dual and Quad Topologies%n", validate = false)
85    private final TopologyOptionGroup group = new TopologyOptionGroup();
86  
87    /**
88     * --ac2 or --alchemicalAtoms2 Specify alchemical atoms [ALL, NONE, Range(s): 1-3,6-N].
89     *
90     * @return Returns alchemical atoms for the 2nd topology.
91     */
92    public String getAlchemicalAtoms2() {
93      return group.alchemicalAtoms2;
94    }
95  
96    /**
97     * --acRes2 or --alchemicalResidues2 Specify alchemical residues by chain and residue number [e.g. A4,B21].
98     *
99     * @return Returns alchemical residues for the 2nd topology.
100    */
101   public String getAlchemicalResidues2() {
102     return group.alchemicalResidues2;
103   }
104 
105   /**
106    * --uc2 or --unchargedAtoms2 Specify atoms without electrostatics [ALL, NONE, Range(s): 1-3,6-N].
107    *
108    * @return Returns atoms without electrostatics for the 2nd topology.
109    */
110   public String getUnchargedAtoms2() {
111     return group.unchargedAtoms2;
112   }
113 
114   /**
115    * --np or --nParallel sets the number of topologies to evaluate in parallel; currently 1, 2, or 4.
116    *
117    * @return Returns Number of topologies to evaluate in parallel.
118    */
119   public int getTopologiesInParallel() {
120     return group.nTopologiesInParallel;
121   }
122 
123   /**
124    * --uaA or --unsharedA sets atoms unique to the A dual-topology, as period-separated hyphenated
125    * ranges or singletons.
126    *
127    * @return Return atoms unique to the A dual-topology.
128    */
129   public String getUnsharedA() {
130     return group.unsharedA;
131   }
132 
133   /**
134    * --uaB or --unsharedB sets atoms unique to the A dual-topology, as period-separated hyphenated
135    * ranges or singletons.
136    *
137    * @return Return atoms unique to the B dual-topology.
138    */
139   public String getUnsharedB() {
140     return group.unsharedB;
141   }
142 
143   /**
144    * -sf or --switchingFunction
145    *
146    * <p>Sets the switching function to be used by dual topologies.
147    *
148    * <ul>
149    *   <li>TRIG produces the function sin^2(pi/2*lambda)*E1(lambda) + cos^2(pi/2*lambda)*E2(1-lambda)</li>
150    *   <li>MULT uses a 5th-order polynomial switching function with zero first and second derivatives at
151    *   the end (same function as used for van der Waals switch)</li>
152    *   <li>A number uses the original function, of l^beta*E1(lambda) + (1-lambda)^beta*E2(1-lambda).</li>
153    * </ul>
154    *
155    * <p>All of these are generalizations of <code>Udt = f(l)*E1(l) + f(1-l)*E2(1-lambda)</code>,
156    * where f(l) is a continuous switching function such that f(0) = 0, f(1) = 1, and 0 &lt;= f(l) &lt;= 1
157    * for lambda 0-1. The trigonometric switch can be restated thusly, since cos^2(pi/2*lambda) is
158    * identical to sin^2(pi/2*(1-lambda)), f(1-l).
159    *
160    * @return Returns the lambda function.
161    */
162   public String getLambdaFunction() {
163     return group.lambdaFunction;
164   }
165 
166   /**
167    * The number of topologies to run in parallel.
168    *
169    * @param nTopologies The number of topologies.
170    * @return Number of topologies to run in parallel.
171    */
172   public int getTopologiesInParallel(int nTopologies) {
173     int nThreads = ParallelTeam.getDefaultThreadCount();
174     int numParallel = getTopologiesInParallel();
175     if (nThreads % numParallel != 0) {
176       logger.warning(format(
177           " Number of threads available %d not evenly divisible by np %d; reverting to sequential.",
178           nThreads, numParallel));
179       numParallel = 1;
180     } else if (nTopologies % numParallel != 0) {
181       logger.warning(
182           format(" Number of topologies %d not evenly divisible by np %d; reverting to sequential.",
183               nTopologies, numParallel));
184       numParallel = 1;
185     }
186     return numParallel;
187   }
188 
189   /**
190    * The number of threads per topology.
191    *
192    * @param nTopologies The number of topologies.
193    * @return Number of threads per topology.
194    */
195   public int getThreadsPerTopology(int nTopologies) {
196     int nThreads = ParallelTeam.getDefaultThreadCount();
197     int numParallel = getTopologiesInParallel(nTopologies);
198     return nThreads / numParallel;
199   }
200 
201   /**
202    * Validate the number of topologies.
203    *
204    * @param topologyFilenames List of topology filenames.
205    * @return The number of topologies.
206    */
207   public int getNumberOfTopologies(@Nullable List<String> topologyFilenames) {
208     if (topologyFilenames == null || topologyFilenames.isEmpty()) {
209       logger.severe(" No filenames were provided.");
210       return 0;
211     } else if (topologyFilenames.size() != 1
212         && topologyFilenames.size() != 2
213         && topologyFilenames.size() != 4) {
214       logger.severe(" Either 1, 2, or 4 filenames must be provided.!");
215       return 0;
216     }
217     return topologyFilenames.size();
218   }
219 
220   /**
221    * Performs the bulk of the work of setting up a multi-topology system.
222    *
223    * <p>The sb StringBuilder is often something like "Timing energy and gradients for". The method
224    * will append the exact type of Potential being assembled.
225    *
226    * @param assemblies Opened MolecularAssembly(s).
227    * @param sb         A StringBuilder describing what is to be done.
228    * @return a {@link ffx.numerics.Potential} object.
229    */
230   public Potential assemblePotential(MolecularAssembly[] assemblies, StringBuilder sb) {
231     int nargs = assemblies.length;
232     int numPar = getTopologiesInParallel(nargs);
233     UnivariateSwitchingFunction sf = nargs > 1 ? getSwitchingFunction() : null;
234     List<Integer> uniqueA;
235     List<Integer> uniqueB;
236     if (assemblies.length >= 4) {
237       uniqueA = getUniqueAtomsA(assemblies[0]);
238       uniqueB = getUniqueAtomsB(assemblies[2]);
239     } else {
240       uniqueA = Collections.emptyList();
241       uniqueB = Collections.emptyList();
242     }
243     return getTopology(assemblies, sf, uniqueA, uniqueB, numPar, sb);
244   }
245 
246   /**
247    * Return the switching function between topology energies.
248    *
249    * @return the switching function.
250    */
251   public UnivariateSwitchingFunction getSwitchingFunction() {
252     UnivariateSwitchingFunction sf;
253     String lambdaFunction = getLambdaFunction();
254     if (!lambdaFunction.equalsIgnoreCase("1.0")) {
255       String lf = lambdaFunction.toUpperCase();
256       switch (lf) {
257         case "TRIG" -> sf = new SquaredTrigSwitch(false);
258         case "MULT" -> sf = new MultiplicativeSwitch(1.0, 0.0);
259         default -> {
260           try {
261             double beta = parseDouble(lf);
262             sf = new PowerSwitch(1.0, beta);
263           } catch (NumberFormatException ex) {
264             logger.warning(format(
265                 "Argument to option -sf %s could not be properly parsed; using default linear switch",
266                 lambdaFunction));
267             sf = new PowerSwitch(1.0, 1.0);
268           }
269         }
270       }
271     } else {
272       sf = new PowerSwitch(1.0, 1.0);
273     }
274     return sf;
275   }
276 
277   /**
278    * Collect unique atoms for the A dual-topology.
279    *
280    * @param topology A MolecularAssembly from dual-topology A.
281    * @return A List of Integers.
282    */
283   public List<Integer> getUniqueAtomsA(MolecularAssembly topology) {
284     return getUniqueAtoms(topology, "A", getUnsharedA());
285   }
286 
287   /**
288    * Configure a Dual-, Quad- or Oct- Topology.
289    *
290    * @param topologies  The topologies.
291    * @param sf          The Potential switching function.
292    * @param uniqueA     The unique atoms of topology A.
293    * @param uniqueB     The unique atoms of topology B.
294    * @param numParallel The number of energies to evaluate in parallel.
295    * @param sb          A StringBuilder for logging.
296    * @return The Potential for the Topology.
297    */
298   public Potential getTopology(MolecularAssembly[] topologies, UnivariateSwitchingFunction sf,
299                                List<Integer> uniqueA, List<Integer> uniqueB, int numParallel, StringBuilder sb) {
300     Potential potential = null;
301     switch (topologies.length) {
302       case 1 -> {
303         sb.append("Single Topology ");
304         potential = topologies[0].getPotentialEnergy();
305       }
306       case 2 -> {
307         sb.append("Dual Topology ");
308         DualTopologyEnergy dte = DualTopologyEnergy.energyFactory(topologies[0], topologies[1], sf);
309         if (numParallel == 2) {
310           dte.setParallel(true);
311         }
312         potential = dte;
313       }
314       case 4 -> {
315         sb.append("Quad Topology ");
316         DualTopologyEnergy dta = new DualTopologyEnergy(topologies[0], topologies[1], sf);
317         DualTopologyEnergy dtb = new DualTopologyEnergy(topologies[3], topologies[2], sf);
318         QuadTopologyEnergy qte = new QuadTopologyEnergy(dta, dtb, uniqueA, uniqueB);
319         if (numParallel >= 2) {
320           qte.setParallel(true);
321           if (numParallel == 4) {
322             dta.setParallel(true);
323             dtb.setParallel(true);
324           }
325         }
326         potential = qte;
327       }
328       default -> logger.severe(" Must have 2, 4, or 8 topologies!");
329     }
330     sb.append(Arrays.stream(topologies).map(MolecularAssembly::toString)
331         .collect(Collectors.joining(", ", " [", "] ")));
332     return potential;
333   }
334 
335   /**
336    * Collect unique atoms for a dual-topology. List MUST be sorted at the end.
337    *
338    * @param assembly A MolecularAssembly from the dual topology.
339    * @param label    Either 'A' or 'B'.
340    * @param unshared Atoms this dual topology isn't sharing.
341    * @return A sorted List of Integers.
342    */
343   public static List<Integer> getUniqueAtoms(MolecularAssembly assembly, String label,
344                                              String unshared) {
345     if (!unshared.isEmpty()) {
346       logger.info(" Finding unique atoms for dual topology " + label);
347       Set<Integer> indices = new HashSet<>();
348       String[] toks = unshared.split("\\.");
349       Atom[] atoms1 = assembly.getAtomArray();
350       for (String range : toks) {
351         Matcher m = AlchemicalOptions.rangeRegEx.matcher(range);
352         if (m.find()) {
353           int rangeStart = Integer.parseInt(m.group(1));
354           int rangeEnd = (m.group(2) != null) ? Integer.parseInt(m.group(2)) : rangeStart;
355           if (rangeStart > rangeEnd) {
356             logger.severe(format(" Range %s was invalid start was greater than end", range));
357           }
358           logger.info(
359               format(" Range %s for %s, start %d end %d", range, label, rangeStart, rangeEnd));
360           if (logger.isLoggable(Level.FINE)) {
361             logger.fine(format(" First atom in range: %s", atoms1[rangeStart - 1]));
362             if (rangeEnd > rangeStart) {
363               logger.fine(format(" Last atom in range: %s", atoms1[rangeEnd - 1]));
364             }
365           }
366           for (int i = rangeStart; i <= rangeEnd; i++) {
367             indices.add(i - 1);
368           }
369         }
370       }
371       int counter = 0;
372       Set<Integer> adjustedIndices = new HashSet<>(); // Indexed by common variables in dtA.
373       int atomLength = atoms1.length;
374       for (int i = 0; i < atomLength; i++) {
375         Atom ai = atoms1[i];
376         if (indices.contains(i)) {
377           if (ai.applyLambda()) {
378             logger.warning(format(
379                 " Ranges defined in %s should not overlap with ligand atoms they are assumed to not be shared.",
380                 label));
381           } else {
382             logger.fine(format(" Unshared %s: %d variables %d-%d", label, i, counter, counter + 2));
383             for (int j = 0; j < 3; j++) {
384               adjustedIndices.add(counter + j);
385             }
386           }
387         }
388         if (!ai.applyLambda()) {
389           counter += 3;
390         }
391       }
392       return adjustedIndices.stream().sorted().collect(Collectors.toList());
393     } else {
394       return Collections.emptyList();
395     }
396   }
397 
398   /**
399    * Collect unique atoms for the B dual-topology.
400    *
401    * @param topology A MolecularAssembly from dual-topology B.
402    * @return A List of Integers.
403    */
404   public List<Integer> getUniqueAtomsB(MolecularAssembly topology) {
405     return getUniqueAtoms(topology, "B", getUnsharedB());
406   }
407 
408   /**
409    * If any softcore Atoms have been detected.
410    *
411    * @return Presence of softcore Atoms.
412    */
413   public boolean hasSoftcore() {
414     String alchemicalAtoms2 = getAlchemicalAtoms2();
415     return (alchemicalAtoms2 != null && !alchemicalAtoms2.equalsIgnoreCase("NONE")
416         && !alchemicalAtoms2.equalsIgnoreCase(""));
417   }
418 
419   /**
420    * Set the alchemical atoms for this topology.
421    *
422    * @param topology a {@link ffx.potential.MolecularAssembly} object.
423    */
424   public void setSecondSystemAlchemistry(MolecularAssembly topology) {
425     String alchemicalAtoms2 = getAlchemicalAtoms2();
426     String alchemicalResidues2 = getAlchemicalResidues2();
427     AlchemicalOptions.setAlchemicalAtoms(topology, alchemicalAtoms2, alchemicalResidues2);
428   }
429 
430   /**
431    * Set uncharged atoms for this topology.
432    *
433    * @param topology a {@link ffx.potential.MolecularAssembly} object.
434    */
435   public void setSecondSystemUnchargedAtoms(MolecularAssembly topology) {
436     String unchargedAtoms2 = getUnchargedAtoms2();
437     AlchemicalOptions.setUnchargedAtoms(topology, unchargedAtoms2);
438   }
439 
440   /**
441    * Relative free energies via the DualTopologyEnergy class require different
442    * default OST parameters than absolute free energies.
443    *
444    * @param numberOfTopologies The number of topologies.
445    */
446   public void setAlchemicalProperties(int numberOfTopologies) {
447     if (numberOfTopologies >= 2) {
448       // Ligand vapor electrostatics are not calculated. This cancels when the
449       // difference between protein and water environments is considered.
450       System.setProperty("ligand-vapor-elec", "false");
451       // Turn on calculation of lambda dependent terms.
452       if (hasSoftcore()) {
453         System.setProperty("lambdaterm", "true");
454       }
455     }
456   }
457 
458   /**
459    * Collection of Topology Options.
460    */
461   private static class TopologyOptionGroup {
462 
463     /**
464      * --ac2 or --alchemicalAtoms2 Specify alchemical atoms [ALL, NONE, Range(s): 1-3,6-N].
465      */
466     @Option(names = {"--ac2", "--alchemicalAtoms2"}, paramLabel = "<selection>", defaultValue = "",
467         description = "Specify alchemical atoms for the 2nd topology [ALL, NONE, Range(s): 1-3,6-N].")
468     String alchemicalAtoms2 = "";
469 
470     /**
471      * --acRes2 or --alchemicalResidues2 Specify alchemical residues by chain and residue number for the 2nd topology.
472      */
473     @Option(
474         names = {"--acRes2", "--alchemicalResidues2"},
475         paramLabel = "<selection>",
476         defaultValue = "",
477         description = "Specify alchemical residues by chain and residue number for the 2nd topology [A4,B21]")
478     String alchemicalResidues2 = "";
479 
480     /**
481      * --uc2 or --unchargedAtoms2 Specify atoms without electrostatics [ALL, NONE, Range(s):
482      * 1-3,6-N]."
483      */
484     @Option(names = {"--uc2", "--unchargedAtoms2"}, paramLabel = "<selection>", defaultValue = "",
485         description = "Specify atoms without electrostatics for the 2nd topology [ALL, NONE, Range(s): 1-3,6-N].")
486     String unchargedAtoms2 = "";
487 
488     /**
489      * -np or --nParallel sets the number of topologies to evaluate in parallel; currently 1, 2, or
490      * 4.
491      */
492     @Option(names = {"--np", "--nParallel"}, paramLabel = "1",
493         description = "Number of topologies to evaluate in parallel")
494     int nTopologiesInParallel = 1;
495 
496     /**
497      * --uaA or --unsharedA sets atoms unique to the A dual-topology, as period-separated hyphenated
498      * ranges or singletons.
499      */
500     @Option(names = {"--uaA", "--unsharedA"}, paramLabel = "-1",
501         description = "Unshared atoms in the A dual topology (e.g. 1-24.32-65).")
502     String unsharedA = null;
503 
504     /**
505      * --uaB or --unsharedB sets atoms unique to the B dual-topology, as period-separated hyphenated
506      * ranges or singletons.
507      */
508     @Option(names = {"--uaB", "--unsharedB"}, paramLabel = "-1",
509         description = "Unshared atoms in the B dual topology (e.g. 1-24.32-65).")
510     String unsharedB = null;
511 
512     /**
513      * -sf or --switchingFunction
514      *
515      * <p>Sets the switching function to be used by dual topologies.
516      *
517      * <p>
518      *
519      * <ul>
520      *   TRIG produces the function sin^2(pi/2*lambda)*E1(lambda) + cos^2(pi/2*lambda)*E2(1-lambda)
521      * </ul>
522      *
523      * <ul>
524      *   MULT uses a 5th-order polynomial switching function with zero first and second derivatives at
525      *   the end (same function as used for van der Waals switch)
526      * </ul>
527      *
528      * <ul>
529      *   A number uses the original function, of l^beta*E1(lambda) + (1-lambda)^beta*E2(1-lambda).
530      * </ul>
531      *
532      * <p>All of these are generalizations of <code>Udt = f(l)*E1(l) + f(1-l)*E2(1-lambda)</code>,
533      * where f(l) is a continuous switching function such that f(0) = 0, f(1) = 1, and 0 <= f(l) <= 1
534      * for lambda 0-1. The trigonometric switch can be restated thusly, since cos^2(pi/2*lambda) is
535      * identical to sin^2(pi/2*(1-lambda)), f(1-l).
536      */
537     @Option(names = {"--sf", "--switchingFunction"}, paramLabel = "1.0",
538         description = "Switching function to use for dual topology: options are TRIG, MULT, or a number (original behavior with specified lambda exponent)")
539     String lambdaFunction = "1.0";
540   }
541 }