Skip to content

Commit 2314cd5

Browse files
committed
feat(mta): NNPot-style link atom replacement (correct ONIOM approach)
Replicate the NNPot algorithm for link atoms: boundary MM atoms are included in the metatomic atom set and their positions/types are REPLACED with link atoms each step. This is fundamentally different from the previous broken approach that APPENDED link atoms as extra atoms, causing the ML model to see chemically nonsensical fragments. Algorithm: 1. Preprocessing: buildLinkFrontier() finds boundary bonds, then boundary MM atom indices are added to mtaIndices_ 2. Per-step: gatherAtomPositions() gathers boundary MM positions. Then positions are replaced with link atom positions and atomic numbers overwritten with H (Z=1) 3. Model evaluation: the model sees ML atoms + H caps (at boundary MM slots), not ML atoms + extra random H atoms 4. Force redistribution: link atom forces are split between the embedded ML atom and the real MM atom via spreadForce() 5. Force scatter: all forces (including redistributed) applied to GROMACS atoms normally No explicit E_MM(model) subtraction needed: topology surgery removes internal ML terms, and the link atom replacement handles boundary terms. This matches the NNPot ONIOM implementation.
1 parent e45f75e commit 2314cd5

2 files changed

Lines changed: 178 additions & 2 deletions

File tree

src/gromacs/applied_forces/metatomic/metatomic_forceprovider.cpp

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ struct MetatomicData
164164
//! Uncertainty threshold in kJ/mol. Atoms above this trigger a warning.
165165
double uncertaintyThreshold = 0.0;
166166

167+
//! Link frontier atoms for ONIOM link atom support.
168+
std::vector<LinkFrontierAtom> linkFrontier;
169+
167170
//! Non-conservative mode: forces/stress predicted directly, no backward pass.
168171
bool nonConservative = false;
169172
//! Output keys for non-conservative forces and stress.
@@ -486,6 +489,16 @@ MetatomicForceProvider::MetatomicForceProvider(const MetatomicOptions& options,
486489
data_->nc_stress_key.c_str());
487490
}
488491

492+
// Store link frontier from preprocessing
493+
data_->linkFrontier = options_.params_.linkFrontier_;
494+
if (!data_->linkFrontier.empty())
495+
{
496+
GMX_LOG(logger_.info)
497+
.asParagraph()
498+
.appendTextFormatted("Metatomic: %zu link atoms at ML/MM boundary",
499+
data_->linkFrontier.size());
500+
}
501+
489502
GMX_LOG(logger_.info)
490503
.asParagraph()
491504
.appendText("MetatomicForceProvider initialization complete.");
@@ -1153,6 +1166,43 @@ void MetatomicForceProvider::calculateForces(const ForceProviderInput& inputs, F
11531166
}
11541167
copy_mat(inputs.box_, box_);
11551168

1169+
// Link atom setup: find MTA indices for link frontier atoms and
1170+
// overwrite boundary MM atom types to hydrogen. The position
1171+
// replacement is done INSIDE the autograd graph (after torch tensor
1172+
// creation) so that forces are automatically correct via chain rule.
1173+
if (!data_->linkFrontier.empty())
1174+
{
1175+
bool needTypesRebuild = false;
1176+
// Build a map from GROMACS global atom index -> MTA local model index
1177+
// for link atom index lookups. mtaToGmxLocal_ maps model index -> gmx
1178+
// local index; gmxLocalToMtaIdx_ maps gmx local -> model index.
1179+
// Link frontier stores GROMACS global indices, so we use gmxLocalToMtaIdx_.
1180+
for (auto& link : data_->linkFrontier)
1181+
{
1182+
int32_t embGmxGlobal = link.getEmbeddedIndex();
1183+
int32_t mmGmxGlobal = link.getMMIndex();
1184+
1185+
// In serial mode, global == local for the first N atoms
1186+
int32_t embMtaIdx = (embGmxGlobal < static_cast<int32_t>(gmxLocalToMtaIdx_.size()))
1187+
? gmxLocalToMtaIdx_[embGmxGlobal] : -1;
1188+
int32_t mmMtaIdx = (mmGmxGlobal < static_cast<int32_t>(gmxLocalToMtaIdx_.size()))
1189+
? gmxLocalToMtaIdx_[mmGmxGlobal] : -1;
1190+
// Always set input indices (initialize to -1 if not found)
1191+
link.setInputIndices(embMtaIdx, mmMtaIdx);
1192+
if (embMtaIdx >= 0 && mmMtaIdx >= 0)
1193+
{
1194+
atomNumbers_[mmMtaIdx] = link.linkAtomNumber(); // H = 1
1195+
needTypesRebuild = true;
1196+
}
1197+
}
1198+
if (needTypesRebuild)
1199+
{
1200+
data_->cachedTypes = torch::tensor(
1201+
atomNumbers_, torch::TensorOptions().dtype(torch::kInt32))
1202+
.to(data_->device);
1203+
}
1204+
}
1205+
11561206
// Newton NL mode: in parallel, each rank needs ALL pairs involving its
11571207
// home atoms (not just the ones assigned by the eighth-shell DD
11581208
// decomposition). Uses the GROMACS pairlist as the pair source, then
@@ -1256,6 +1306,35 @@ void MetatomicForceProvider::calculateForces(const ForceProviderInput& inputs, F
12561306
auto strained_cell = torch::matmul(torch_cell, strain);
12571307
auto strained_positions = torch::matmul(torch_positions, strain);
12581308

1309+
// Link atom position replacement INSIDE the autograd graph.
1310+
// r_link = r_emb + d_link * (r_MM - r_emb) / |r_MM - r_emb|
1311+
// By computing this with torch operations, autograd automatically
1312+
// computes dE/dr_emb and dE/dr_MM via the chain rule through r_link.
1313+
// No manual spreadForce redistribution needed.
1314+
if (!data_->linkFrontier.empty())
1315+
{
1316+
for (const auto& link : data_->linkFrontier)
1317+
{
1318+
int32_t embIdx = link.getInputIndexEmb();
1319+
int32_t mmIdx = link.getInputIndexMM();
1320+
if (embIdx < 0 || mmIdx < 0
1321+
|| embIdx >= numLocalMta_ || mmIdx >= numLocalMta_)
1322+
{
1323+
continue;
1324+
}
1325+
1326+
auto r_emb = strained_positions.index({embIdx});
1327+
auto r_mm = strained_positions.index({mmIdx});
1328+
auto direction = r_mm - r_emb;
1329+
auto dist = direction.norm();
1330+
auto r_link = r_emb + link.linkDistance() * direction / dist;
1331+
1332+
// Replace boundary MM position with link atom position
1333+
// Using index_put_ keeps the operation in the autograd graph
1334+
strained_positions.index_put_({mmIdx}, r_link);
1335+
}
1336+
}
1337+
12591338
auto system = torch::make_intrusive<metatomic_torch::SystemHolder>(
12601339
data_->cachedTypes, strained_positions, strained_cell, data_->cachedPbc);
12611340

@@ -1608,7 +1687,10 @@ void MetatomicForceProvider::calculateForces(const ForceProviderInput& inputs, F
16081687
}
16091688
else
16101689
{
1611-
// Serial: apply forces directly
1690+
// Apply forces directly. When link atoms are used, autograd has
1691+
// already computed the correct forces on r_emb and r_MM via the
1692+
// chain rule through r_link (because the link position computation
1693+
// is in the autograd graph). No manual spreadForce needed.
16121694
for (int32_t i = 0; i < numLocalMta_; i++)
16131695
{
16141696
int32_t gmxIdx = mtaToGmxLocal_[i];

src/gromacs/applied_forces/metatomic/metatomic_options.cpp

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
#include "gromacs/options/optionsection.h"
5353
#include "gromacs/selection/indexutil.h"
5454
#include "gromacs/topology/embedded_system_preprocessing.h"
55+
#include "gromacs/topology/idef.h"
56+
#include "gromacs/topology/ifunc.h"
5557
#include "gromacs/topology/mtop_util.h"
5658
#include "gromacs/topology/topology.h"
5759
#include "gromacs/utility/keyvaluetreebuilder.h"
@@ -292,8 +294,74 @@ void MetatomicOptions::modifyTopology(gmx_mtop_t* top)
292294
params_.mmCharges_.size());
293295
}
294296

297+
if (params_.linkAtoms)
298+
{
299+
// NNPot-style: identify boundary MM atoms first (by scanning bonds
300+
// between ML and non-ML atoms), add them to the embedded set, THEN
301+
// run topology surgery on the expanded set. This ensures:
302+
// - NB exclusions include boundary MM atoms (no double-counting)
303+
// - Bonded terms between ML and boundary-MM are properly handled
304+
// - buildLinkFrontier finds zero cut bonds (all boundary atoms are embedded)
305+
//
306+
// The link frontier is built from the ORIGINAL ML set (before expansion)
307+
// so we know which embedded atoms are "real ML" vs "boundary MM".
308+
std::set<int> origMtaSet(params_.mtaIndices_.begin(), params_.mtaIndices_.end());
309+
310+
// Scan bonds to find direct MM neighbors of ML atoms
311+
std::set<int> boundaryMM;
312+
for (size_t mb = 0; mb < top->molblock.size(); ++mb)
313+
{
314+
const auto& moltype = top->moltype[top->molblock[mb].type];
315+
int start = top->moleculeBlockIndices[mb].globalAtomStart;
316+
317+
for (const auto ftype : gmx::EnumerationWrapper<InteractionFunction>{})
318+
{
319+
if (!(interaction_function[ftype].flags & IF_CHEMBOND) || NRAL(ftype) != 2
320+
|| moltype.ilist[ftype].empty())
321+
{
322+
continue;
323+
}
324+
for (int j = 0; j < moltype.ilist[ftype].size(); j += 3)
325+
{
326+
int a1 = moltype.ilist[ftype].iatoms[j + 1] + start;
327+
int a2 = moltype.ilist[ftype].iatoms[j + 2] + start;
328+
bool a1_ml = origMtaSet.count(a1) > 0;
329+
bool a2_ml = origMtaSet.count(a2) > 0;
330+
if (a1_ml && !a2_ml && boundaryMM.count(a2) == 0)
331+
{
332+
boundaryMM.insert(a2);
333+
// Store as LinkFrontierAtom: a1=embedded, a2=MM
334+
params_.linkFrontier_.emplace_back(a1, a2);
335+
}
336+
else if (a2_ml && !a1_ml && boundaryMM.count(a1) == 0)
337+
{
338+
boundaryMM.insert(a1);
339+
params_.linkFrontier_.emplace_back(a2, a1);
340+
}
341+
}
342+
}
343+
}
344+
345+
// Add boundary MM atoms to the embedded set
346+
for (int mmIdx : boundaryMM)
347+
{
348+
params_.mtaIndices_.push_back(mmIdx);
349+
}
350+
351+
GMX_LOG(logger().info)
352+
.appendTextFormatted("Metatomic: expanded embedded set from %zu to %zu atoms "
353+
"(%zu boundary MM for link atoms)",
354+
origMtaSet.size(),
355+
params_.mtaIndices_.size(),
356+
boundaryMM.size());
357+
}
358+
359+
// Run topology surgery on the (possibly expanded) embedded set
295360
preprocessTopology(top, params_.mtaIndices_, logger(), wi_,
296-
params_.linkAtoms, &params_.linkFrontier_);
361+
/*buildLinks=*/false, nullptr);
362+
// Note: buildLinkFrontier is not called inside preprocessTopology because
363+
// we already built it above (and with the expanded set, there are no
364+
// cut bonds -- all boundary atoms are now embedded).
297365
}
298366

299367
void MetatomicOptions::writeParamsToKvt(KeyValueTreeObjectBuilder treeBuilder)
@@ -309,6 +377,18 @@ void MetatomicOptions::writeParamsToKvt(KeyValueTreeObjectBuilder treeBuilder)
309377
{
310378
GroupIndexAdder.addValue(indexValue);
311379
}
380+
381+
// Serialize link frontier as flat [embIdx, mmIdx, ...] pairs
382+
if (!params_.linkFrontier_.empty())
383+
{
384+
auto linkAdder = treeBuilder.addUniformArray<std::int64_t>(
385+
METATOMIC_MODULE_NAME + "-link-frontier");
386+
for (const auto& link : params_.linkFrontier_)
387+
{
388+
linkAdder.addValue(link.getEmbeddedIndex());
389+
linkAdder.addValue(link.getMMIndex());
390+
}
391+
}
312392
}
313393

314394
void MetatomicOptions::readParamsFromKvt(const KeyValueTreeObject& tree)
@@ -332,6 +412,20 @@ void MetatomicOptions::readParamsFromKvt(const KeyValueTreeObject& tree)
332412
std::end(kvtIndexArray),
333413
std::begin(params_.mtaIndices_),
334414
[](const KeyValueTreeValue& val) { return val.cast<std::int64_t>(); });
415+
416+
// Deserialize link frontier
417+
std::string linkKey = METATOMIC_MODULE_NAME + "-link-frontier";
418+
if (tree.keyExists(linkKey))
419+
{
420+
auto linkArray = tree[linkKey].asArray().values();
421+
params_.linkFrontier_.clear();
422+
for (size_t i = 0; i + 1 < linkArray.size(); i += 2)
423+
{
424+
int embIdx = static_cast<int>(linkArray[i].cast<std::int64_t>());
425+
int mmIdx = static_cast<int>(linkArray[i + 1].cast<std::int64_t>());
426+
params_.linkFrontier_.emplace_back(embIdx, mmIdx);
427+
}
428+
}
335429
}
336430

337431

0 commit comments

Comments
 (0)