LCOV - code coverage report
Current view: top level - kenref - KEnRefBias.cpp (source / functions) Hit Total Coverage
Test: plumed test coverage Lines: 116 120 96.7 %
Date: 2026-11-05 10:19:04 Functions: 12 13 92.3 %

          Line data    Source code
       1             : /* +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
       2             : Copyright (c) 2023-2026 of Amr Alhossary and the KEnRef Authors.
       3             : 
       4             : The kenref module is free software: you can redistribute it and/or modify
       5             : it under the terms of the GNU Lesser General Public License as published by
       6             : the Free Software Foundation, either version 3 of the License, or
       7             : (at your option) any later version.
       8             : 
       9             : The kenref module is distributed in the hope that it will be useful,
      10             : but WITHOUT ANY WARRANTY; without even the implied warranty of
      11             : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
      12             : GNU Lesser General Public License for more details.
      13             : 
      14             : You should have received a copy of the GNU Lesser General Public License
      15             : along with plumed.  If not, see <http://www.gnu.org/licenses/>.
      16             : 
      17             : --------------------------------------------------------------------------
      18             : 
      19             : This module is only compiled when PLUMED is configured with --enable-kenref,
      20             : in which case it links against the external KEnRef core library
      21             : (https://github.com/Smith-Group/KEnRef), which is distributed separately under
      22             : the BSD 3-Clause License. That licence is permissive and imposes no additional
      23             : restriction on this module or on PLUMED; see LICENSE_CORE.txt in the KEnRef
      24             : distribution for its text.
      25             : +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ */
      26             : /*
      27             :  * KEnRefBias.cpp — PLUMED kenref module (frozen frame)
      28             :  *
      29             :  * KEnRefBias is a PLUMED Bias/ActionAtomistic that restrains kinetic-ensemble observables (interproton
      30             :  * relaxation / order parameters) computed by the KEnRef library. It implements kenref::EngineAdapter
      31             :  * and delegates the whole per-step refinement pipeline (no-jump -> Kabsch fit -> replica gather ->
      32             :  * energy/derivative -> scatter -> inverse-fit -> force) to kenref::KEnRefDriver, implementing here only
      33             :  * the handful of PLUMED-specific callbacks (positions, box, force application, inter-replica MPI).
      34             :  *
      35             :  * This file holds the STABLE parts of the action (declaration is in KEnRefBias.h via the KEnRef repo):
      36             :  * registerKeywords, the PLUMED<->Eigen glue, calculate(), the lock/unlock boilerplate, and the action
      37             :  * registration. The one-time constructor (model + sub-indexing + driver setup) evolves with the KEnRef
      38             :  * model abstraction, so it is hosted in the KEnRef repository and compiled in via KEnRefBias_setup.cpp.
      39             :  */
      40             : 
      41             : // The whole module is optional: PLUMED can be configured with --enable-modules=all on a machine that
      42             : // has no KEnRef installed, in which case configure leaves __PLUMED_HAS_KENREF undefined and this
      43             : // translation unit compiles to nothing. Same pattern as src/pytorch and the ttsketch/ITensor module.
      44             : #ifdef __PLUMED_HAS_KENREF
      45             : 
      46             : #include "core/ActionRegister.h"
      47             : #include "core/PlumedMain.h"
      48             : #include "tools/Vector.h"
      49             : #include "tools/Tensor.h"
      50             : #include "tools/Communicator.h"
      51             : #include "tools/OpenMP.h"
      52             : 
      53             : #include "core/ModelRegistry.h"   // registerKeywords iterates the model registry's schemas
      54             : 
      55             : // Unprefixed: kenref_plumed.pc puts the directory holding this header directly on the include path.
      56             : // A "plumedinterface/..." form would look to plumedcheck like a cross-module include, and
      57             : // plumedinterface is a KEnRef directory, not a PLUMED module.
      58             : #include "KEnRefBias.h"
      59             : 
      60             : #include <chrono>
      61             : #include <set>
      62             : 
      63             : // Written as two nested namespaces rather than `namespace PLMD::kenref` because plumedcheck matches
      64             : // each of them line-by-line ("^ *namespace *PLMD" and "^ *namespace *kenref"); the C++17 nested form
      65             : // satisfies only the first. This is also the form every other module uses.
      66             : namespace PLMD {
      67             : namespace kenref {
      68             : 
      69             : //+PLUMEDOC BIAS KENREF
      70             : /*
      71             : Restrains an ensemble of replicas against NMR observables (kinetic ensemble refinement).
      72             : 
      73             : KENREF computes an NMR observable as an average over the whole ensemble of replicas, compares it with
      74             : experimental data, and applies the resulting restraint force to the atoms. Unlike most biases it acts on
      75             : coordinates rather than on collective variables, so it takes **no** `ARG`.
      76             : 
      77             : The energy model is selected by name with `MODEL`:
      78             : 
      79             : | `MODEL`    | restrains                                          |
      80             : |:-----------|:---------------------------------------------------|
      81             : | `SIGMA`    | cross-relaxation rates (interproton NOE build-up)  |
      82             : | `PLATEAUS` | NOE plateau values                                 |
      83             : | `RELAX`    | longitudinal / transverse relaxation               |
      84             : 
      85             : Because the observable is an ensemble average, `RELAX` needs at least two replicas (run with `--multi`);
      86             : `SIGMA` and `PLATEAUS` also work with a single replica.
      87             : 
      88             : `EXP_DATA_FOLDER` holds the experimental data and the atom-pair definitions. `ATOMNAME_MAPPING` is a PDB
      89             : that maps atom names to indices, and is also used as the reference structure when `REF` is omitted. When
      90             : `FIT_TO_REFERENCE` is set the coordinates are Kabsch-fitted onto the reference, using `GUIDE_ATOMS`,
      91             : before the observable is computed, and the derivatives are rotated back afterwards. `SATURATE_FORCES`
      92             : clamps the resulting force to `MAX_FORCE`.
      93             : 
      94             : The restraint needs whole molecules: the fit uses all `GUIDE_ATOMS` at once and the atom-pair distances
      95             : have no cutoff. At every step KENREF therefore moves each atom it reads to the periodic image nearest its
      96             : neighbour in a spanning tree built from the reference structure. The reference must be whole itself; if
      97             : one edge of that tree is not shorter than half the smallest box width, KENREF stops with an error.
      98             : Coordinates that are already whole are left unchanged.
      99             : 
     100             : The action outputs its restraint energy as `.energy` (equal to `.bias`) and, as a diagnostic, the RMSD
     101             : from the reference as `.rmsd`.
     102             : 
     103             : This module needs the external KEnRef library and is not compiled by default; see the module
     104             : documentation for how to build it.
     105             : 
     106             : ## Examples
     107             : 
     108             : Restraining a fragment of GB3 against cross-relaxation rates:
     109             : 
     110             : ```plumed
     111             : #SETTINGS MOLFILE=regtest/kenref/rt-kenref-sigma/gb3_frag.pdb
     112             : kenref: KENREF ...
     113             :   MODEL=SIGMA
     114             :   K=1.0
     115             :   N=0.25
     116             :   PROTON_MHZ=700.0
     117             :   EXP_DATA_FOLDER=regtest/kenref/rt-kenref-sigma/
     118             :   REF=regtest/kenref/rt-kenref-sigma/gb3_frag.pdb
     119             :   ATOMNAME_MAPPING=regtest/kenref/rt-kenref-sigma/gb3_frag.pdb
     120             :   GUIDE_ATOMS=1,5,18,20,22,35,37,39,56,58,60,78,80,82,97
     121             :   MAX_FORCE=999
     122             :   FIT_TO_REFERENCE
     123             :   SATURATE_FORCES
     124             : ...
     125             : 
     126             : PRINT ARG=kenref.bias,kenref.energy,kenref.rmsd FILE=colvar
     127             : ```
     128             : 
     129             : */
     130             : //+ENDPLUMEDOC
     131             : 
     132             : PLUMED_REGISTER_ACTION(KEnRefBias, "KENREF")
     133             : 
     134             : // ============================================================
     135             : //  registerKeywords
     136             : // ============================================================
     137          19 : void KEnRefBias::registerKeywords(Keywords &keys) {
     138          19 :   Bias::registerKeywords(keys);
     139          19 :   ActionAtomistic::registerKeywords(keys);
     140          38 :   keys.setDisplayName("KENREF");
     141             : 
     142             :   // KENREF is atom-based: it biases on coordinates, never on a CV, so it takes no ARG. PLUMED
     143             :   // 2.11-dev made ARG compulsory for every Bias, which forced input files to carry an empty
     144             :   // `ARG=` on master while 2.10 required it to be ABSENT -- the same input could not serve both.
     145             :   // Dropping the keyword makes one input file work everywhere. Guarded by exists() so this stays
     146             :   // correct on branches whose Bias never added ARG in the first place.
     147          19 :   if (keys.exists("ARG")) {
     148          38 :     keys.remove("ARG");
     149             :   }
     150             : 
     151             :   // ---- General (framework-owned) keywords ----
     152          19 :   keys.add("compulsory", "MODEL", "SIGMA", "The energy model to use (e.g. SIGMA or PLATEAUS)");
     153          19 :   keys.add("compulsory", "K", "1.0", "Force constant");
     154          19 :   keys.add("compulsory", "N", "0.25", "Power scaling factor");
     155          19 :   keys.add("optional", "MAX_FORCE", "Maximum force magnitude (default 9999)");
     156          19 :   keys.add("atoms", "GUIDE_ATOMS", "Atoms used for alignment to reference");
     157          19 :   keys.add("optional", "REF", "Reference structure PDB for alignment");
     158          19 :   keys.add("compulsory", "ATOMNAME_MAPPING", "undefined",
     159             :            "PDB file with atom-name -> atom-index mapping (also used as reference if REF is omitted)");
     160          19 :   keys.addFlag("FIT_TO_REFERENCE", false, "Fit coordinates to reference before calculating energy");
     161          19 :   keys.addFlag("SATURATE_FORCES", false, "Clamp forces to MAX_FORCE");
     162             : 
     163             :   // ---- Model-specific keywords (the union across every registered KEnRef model, optional; the
     164             :   //      selected model validates required-ness in buildCache). Deduplicated by key. ----
     165          19 :   ::kenref::bootstrapModels();
     166             :   std::set<std::string> seen;
     167          76 :   for (const auto &[name, schema]: ::kenref::ModelRegistry<KEnRef_Real_t>::allSchemas()) {
     168         171 :     for (const auto &spec: schema.specs()) {
     169         114 :       if (seen.insert(spec.key).second) {
     170          76 :         keys.add("optional", spec.key, spec.doc);
     171             :       }
     172             :     }
     173          19 :   }
     174             : 
     175             :   // ---- Output components ----
     176          38 :   keys.addOutputComponent("energy", "default", "scalar", "Total KEnRef restraint energy");
     177          38 :   keys.addOutputComponent("rmsd", "default", "scalar", "RMSD from reference (after fitting)");
     178             : 
     179          19 :   keys.use("RESTART");
     180          19 :   keys.use("UPDATE_FROM");
     181          19 :   keys.use("UPDATE_UNTIL");
     182          19 : }
     183             : 
     184             : // ============================================================
     185             : //  Helpers: current PLUMED positions (nm) -> Angstrom Eigen
     186             : // ============================================================
     187          30 : CoordsMatrixType<KEnRef_Real_t> KEnRefBias::getGuideAtomsX() const {
     188             :   // Periodically repaired positions, not getPositions(): see makeRequestedAtomsWhole().
     189             :   const std::vector<Vector> &pos = wholePositions_;
     190          30 :   const int nGuide = static_cast<int>(guideAtoms_.size());
     191          30 :   CoordsMatrixType<KEnRef_Real_t> gx(nGuide, 3);
     192         480 :   for (int i = 0; i < nGuide; ++i) {
     193         450 :     const int li = serial_to_localIdx_.at(guideAtoms_[i].serial());
     194         450 :     gx(i, 0) = to<KEnRef_Real_t>(pos[li][0]) * 10;
     195         450 :     gx(i, 1) = to<KEnRef_Real_t>(pos[li][1]) * 10;
     196         450 :     gx(i, 2) = to<KEnRef_Real_t>(pos[li][2]) * 10;
     197             :   }
     198          30 :   return gx;
     199             : }
     200             : 
     201          15 : void KEnRefBias::fillSubAtomsX(CoordsMatrixType<KEnRef_Real_t> &out) const {
     202             :   // Periodically repaired positions, not getPositions(): see makeRequestedAtomsWhole().
     203             :   const std::vector<Vector> &pos = wholePositions_;
     204         495 :   for (int i = 0; i < out.rows(); ++i) {
     205         480 :     const int serial = sub0Id_to_global1Serial_[i];
     206         480 :     const int li = serial_to_localIdx_.at(serial);
     207         480 :     out(i, 0) = to<KEnRef_Real_t>(pos[li][0]) * 10; // nm -> Angstrom
     208         480 :     out(i, 1) = to<KEnRef_Real_t>(pos[li][1]) * 10;
     209         480 :     out(i, 2) = to<KEnRef_Real_t>(pos[li][2]) * 10;
     210             :   }
     211          15 : }
     212             : 
     213             : // ============================================================
     214             : //  kenref::EngineAdapter implementation (PLUMED-specific I/O + replica MPI)
     215             : // ============================================================
     216          39 : std::optional<std::string> KEnRefBias::getRawParam(const std::string &key) const {
     217             :   const auto it = modelParams_.find(key);
     218          39 :   if (it == modelParams_.end() || it->second.empty()) {
     219          15 :     return std::nullopt;
     220             :   }
     221          24 :   return it->second;
     222             : }
     223             : 
     224          15 : int KEnRefBias::numOmpThreads() const {
     225          15 :   return static_cast<int>(OpenMP::getNumThreads());
     226             : }
     227             : 
     228          15 : void KEnRefBias::getLocalModelX(int /*localModel*/, CoordsMatrixType<KEnRef_Real_t> &guideX,
     229             :                                 CoordsMatrixType<KEnRef_Real_t> &subX,
     230             :                                 Eigen::Matrix<KEnRef_Real_t, 3, 3> &box) const {
     231             :   /* Repair first, for the WHOLE requested set, before a single coordinate is read. KEnRef restrains a
     232             :    * whole molecule -- global Kabsch fit, pair distances with no cutoff -- so a molecule split across a
     233             :    * periodic boundary would give a different energy and different forces. */
     234          15 :   makeRequestedAtomsWhole();
     235          15 :   guideX = getGuideAtomsX();
     236          15 :   subX.resize(numSubAtoms_, 3);
     237          15 :   fillSubAtomsX(subX);
     238             :   // PLUMED box Tensor (nm) -> Eigen 3x3 (raw; the driver scales it to Angstrom via toAngstrom).
     239          15 :   const Tensor &b = getPbc().getBox();
     240          60 :   for (int i = 0; i < 3; ++i)
     241         180 :     for (int j = 0; j < 3; ++j) {
     242         135 :       box(i, j) = to<KEnRef_Real_t>(b[i][j]);
     243             :     }
     244          15 : }
     245             : 
     246          15 : void KEnRefBias::addLocalModelDerivatives(int /*localModel*/, const CoordsMatrixType<KEnRef_Real_t> &derivs) {
     247             :   // derivs are already inverse-fitted, unit-scaled and saturated by the driver. KEnRef returns
     248             :   // dE/dx; the applied force is -dE/dx, so we negate.
     249         495 :   for (int i = 0; i < derivs.rows(); ++i) {
     250         480 :     const int serial = sub0Id_to_global1Serial_[i];
     251         480 :     const int li = serial_to_localIdx_.at(serial);
     252             :     const Vector d(static_cast<double>(derivs(i, 0)),
     253             :                    static_cast<double>(derivs(i, 1)),
     254         480 :                    static_cast<double>(derivs(i, 2)));
     255         480 :     addForce(getValueIndices(getAbsoluteIndex(li)), -d);
     256             :   }
     257          15 : }
     258             : 
     259          15 : void KEnRefBias::gatherFittedSubAtomsX(const std::vector<CoordsMatrixType<KEnRef_Real_t>> &localFitted,
     260             :                                        std::vector<CoordsMatrixType<KEnRef_Real_t>> &all) const {
     261          15 :   if (!isMultiSim_) {
     262           3 :     all = localFitted; // single replica owns the whole ensemble
     263           3 :     return;
     264             :   }
     265             :   /* ONLY a replica's main rank may touch multi_sim_comm: PLUMED populates it under
     266             :    * `if (MAIN(cr))`, and elsewhere it is MPI_COMM_SELF, whose Get_size() silently returns 1. A
     267             :    * collective over it from a non-main rank is meaningless rather than diagnosable (PLUMED #1443;
     268             :    * #1484 makes debug builds say so). Every rank of a replica fits the same atoms -- the request
     269             :    * list, and therefore getPositions(), is complete on every rank -- so localFitted is identical
     270             :    * across the replica and gathering it from the main rank alone loses nothing.
     271             :    *
     272             :    * Returning early is safe because `all` is read only where simulationIndex() == 0, which is a
     273             :    * main rank by construction. */
     274          12 :   if (!isReplicaMainRank_) {
     275             :     return;
     276             :   }
     277             :   const auto &fitted = localFitted[0];
     278           8 :   const int subSize = static_cast<int>(fitted.size()); // rows*3
     279           8 :   multi_sim_comm.Gather(const_cast<KEnRef_Real_t *>(fitted.data()), subSize,
     280             :                         allSimulationsSubAtomsX_buffer_.data(), subSize, 0);
     281           8 :   if (simulationIndex() == 0) {
     282             :     const Eigen::Index nSub = fitted.rows();
     283           4 :     all.resize(numSimulations_);
     284          12 :     for (int m = 0; m < numSimulations_; ++m) { // zero-copy view materialised by the assignment
     285           8 :       all[m] = CoordsMapTypeConst<KEnRef_Real_t>(&allSimulationsSubAtomsX_buffer_[m * nSub * 3], nSub, 3);
     286             :     }
     287             :   }
     288             : }
     289             : 
     290          15 : void KEnRefBias::scatterModelDerivatives(const std::vector<CoordsMatrixType<KEnRef_Real_t>> &allPerModel,
     291             :     std::vector<CoordsMatrixType<KEnRef_Real_t>> &localPerModel) const {
     292             :   /* EVERY RANK NEEDS THE FINISHED DERIVATIVES, even though only one rank computed them.
     293             :    * addLocalModelDerivatives() walks every restrained atom and calls addForce for it; PLUMED then
     294             :    * writes those forces through g2l, so a rank's contribution lands only on the atoms it homes
     295             :    * (DomainDecomposition keeps `unique` local-only, and apply() indexes through g2l). That makes
     296             :    * the redundant call on every rank harmless -- but it also means a rank with no derivatives
     297             :    * silently contributes nothing for its own atoms. Hence the broadcast down `comm` in both
     298             :    * branches below: it is what makes the forces complete, not an optimisation. */
     299          15 :   const int subSize = numSubAtoms_ * 3;
     300             : 
     301          15 :   if (!isMultiSim_) {
     302             :     /* One replica, but possibly several ranks over it. Only simulationIndex() == 0 -- this
     303             :      * replica's main rank -- ran the model, so allPerModel is populated there and EMPTY elsewhere;
     304             :      * copying it would leave the other ranks indexing an empty vector. */
     305           3 :     localPerModel.resize(1);
     306           3 :     if (simulationIndex() == 0) {
     307           3 :       std::copy_n(allPerModel[0].data(), subSize, derivatives_buffer_.data());
     308             :     }
     309           3 :     if (comm.Get_size() > 1) {
     310           0 :       comm.Bcast(derivatives_buffer_.data(), subSize, 0);
     311             :     }
     312           3 :     localPerModel[0] = CoordsMapType<KEnRef_Real_t>(derivatives_buffer_.data(), numSubAtoms_, 3);
     313           3 :     return;
     314             :   }
     315             : 
     316             :   /* Several replicas. Only the replica main ranks are in multi_sim_comm, so only they may pack and
     317             :    * scatter; the ensemble master is the one that also has replicaIndex_ == 0. */
     318          12 :   if (isReplicaMainRank_) {
     319           8 :     if (simulationIndex() == 0)
     320          12 :       for (int m = 0; m < numSimulations_; ++m) {
     321           8 :         std::copy_n(allPerModel[m].data(), subSize, &allDerivatives_buffer_[m * subSize]);
     322             :       }
     323           8 :     multi_sim_comm.Scatter(allDerivatives_buffer_.data(), subSize, derivatives_buffer_.data(), subSize, 0);
     324             :   }
     325             :   // Push this replica's block from its main rank down to the rest of the replica's ranks.
     326          12 :   if (comm.Get_size() > 1) {
     327           8 :     comm.Bcast(derivatives_buffer_.data(), subSize, 0);
     328             :   }
     329          12 :   localPerModel.resize(1);
     330          12 :   localPerModel[0] = CoordsMapType<KEnRef_Real_t>(derivatives_buffer_.data(), numSubAtoms_, 3);
     331             : }
     332             : 
     333             : // ============================================================
     334             : //  calculate()
     335             : // ============================================================
     336          15 : void KEnRefBias::calculate() {
     337          15 :   const auto begin = std::chrono::high_resolution_clock::now();
     338          15 :   const long step = getStep();
     339             : 
     340             :   /* The replica state is NOT refreshed here any more. It used to be read from multi_sim_comm every
     341             :    * step, which is wrong on every rank that was never given that communicator: unset means
     342             :    * MPI_COMM_SELF, so Get_size() returns 1 and Get_rank() 0 in silence, and each non-main rank
     343             :    * restrained its own domain against a one-replica ensemble (PLUMED #1443). It now comes from
     344             :    * getNumberOfReplicas()/getReplicaIndex() at setup -- correct on every rank, communication-free,
     345             :    * and fixed for the whole run, so there is nothing to refresh. */
     346             : 
     347             :   // Run the whole engine-agnostic pipeline (no-jump -> fit -> gather -> compute -> scatter ->
     348             :   // inverse-fit -> unit-scale -> saturate -> addForce) through the adapter callbacks above.
     349          15 :   const KEnRef_Real_t energy = driver_->step(*this, step % 10 == 0);
     350             : 
     351             :   // ---- RMSD (crude diagnostic; current guide atoms vs reference, Angstrom -> nm) ----
     352             :   double rmsd = 0.0;
     353          15 :   if (guideAtomsReferenceCoords_.rows() > 0) {
     354          30 :     const CoordsMatrixType<KEnRef_Real_t> diff = getGuideAtomsX() - guideAtomsReferenceCoords_;
     355          15 :     rmsd = std::sqrt(diff.rowwise().squaredNorm().mean()) * 0.1;
     356             :   }
     357             : 
     358             :   // ---- output components ----
     359             :   const double bias_value = energy;
     360          15 :   getPntrToComponent("bias")->set(bias_value);
     361          15 :   getPntrToComponent("energy")->set(bias_value);
     362          15 :   getPntrToComponent("rmsd")->set(rmsd);
     363          15 :   setBias(bias_value);
     364             : 
     365          15 :   const auto end = std::chrono::high_resolution_clock::now();
     366          15 :   calculate_time_ += std::chrono::duration_cast<std::chrono::nanoseconds>(end - begin).count();
     367             :   // One line per job, not one per domain: simulationIndex() is 0 on exactly one rank in the run.
     368          11 :   if (simulationIndex() == 0 && step % 10 == 0) {
     369          14 :     std::cout << "  Step " << step << "  KEnRef energy = " << energy << std::endl;
     370             :   }
     371          15 : }
     372             : 
     373             : // ============================================================
     374             : //  Ambiguity resolution (lock both Atoms and Arguments/CVs)
     375             : // ============================================================
     376          15 : void KEnRefBias::lockRequests() {
     377             :   ActionAtomistic::lockRequests();
     378             :   Bias::lockRequests();
     379          15 : }
     380             : 
     381          15 : void KEnRefBias::unlockRequests() {
     382             :   ActionAtomistic::unlockRequests();
     383             :   Bias::unlockRequests();
     384          15 : }
     385             : 
     386           0 : void KEnRefBias::calculateNumericalDerivatives(ActionWithValue *a) {
     387           0 :   ActionAtomistic::calculateNumericalDerivatives(a);
     388           0 : }
     389             : 
     390             : } // namespace kenref
     391             : } // namespace PLMD
     392             : 
     393             : #endif // __PLUMED_HAS_KENREF

Generated by: LCOV version 1.16