Skip to content

Add graph-longrange PolarMACE electrostatic embedding - #27

Open
CheukHinHoJerry wants to merge 22 commits into
mainfrom
feat/graph-longrange-polarmace-embedding
Open

CheukHinHoJerry wants to merge 22 commits into
mainfrom
feat/graph-longrange-polarmace-embedding

Conversation

@CheukHinHoJerry

@CheukHinHoJerry CheukHinHoJerry commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Summary

Integrate PolarMACE electrostatic ML/MM embedding with the reusable external-source APIs from graph-longrange.

This PR is one squashed integration commit targeting this fork's updated main. The fork's main has first been synchronized with official OpenMM-ML code through upstream commit 2296f12, so upstream synchronization changes are no longer mixed into this PR diff.

The sole exception is official upstream's CI workflow update: the current GitHub credential lacks workflow scope, so .github/workflows/CI.yml remains at the fork's previous version. No runtime or library code was omitted.

Dependency

This PR requires the external_field branch of graph-longrange, which provides the external-source energy and feature blocks:

pip install 'git+https://github.com/WillBaldwin0/graph_electrostatics.git@external_field'

devtools/requirements/mace-aimnet-torchmd.txt is pinned to that branch (it previously pinned v0.4.0, which predates the external-source work and does not provide these blocks). graph-longrange owns the source-to-target electrostatic field and GTOElectrostaticCrossEnergy; OpenMM-ML does not duplicate those electrostatic kernels.

Integration

  • Load a stock PolarMACE checkpoint.
  • Install an eager external-source adapter at load time.
  • Read MM positions and charges from the OpenMM system.
  • Use graph-longrange for the MM-to-ML field and damped ML/MM cross energy.
  • Differentiate the same total energy with respect to ML and MM positions.
  • Scatter the returned MM reaction forces back into the full OpenMM force array.
  • Reject unsupported models instead of silently dropping ML/MM electrostatics.

All of this lives in openmmml/models/macepotential.py: the external-source adapter is installed by _enable_polarmace_external_sources, MM preparation by _prepareMMEmbedding, and the energy/force path by _computeMACE. OpenMM selection, force-field surgery, input preparation, and force scattering are in the same module.

Normalization

  • MM scalar charges pass through the checkpoint's own multipole normalization transform.
  • Public Cartesian dipoles are reordered to graph/e3nn order before normalization.
  • The external potential is split equally over PolarMACE's alpha and beta channels.
  • Cross-energy damping and PBC finite-size corrections are supplied by graph-longrange.

Verification

Against the pinned external_field branch (6a86de5, v0.4.4):

git clone --branch external_field https://github.com/WillBaldwin0/graph_electrostatics.git
PYTHONPATH=/path/to/graph_electrostatics:/path/to/openmm-ml \
  pytest -q test/TestPolarMACEElectrostaticEmbedding.py test/TestElectrostaticEmbeddingPME.py

Result: 10 passed in TestPolarMACEElectrostaticEmbedding.py and 18 passed in TestElectrostaticEmbeddingPME.py.

Coverage includes nonperiodic and periodic execution, energy changes relative to mechanical embedding, zero-charge equivalence, MM back-reaction, whole-box translation invariance, link-charge schemes, and finite-difference force consistency.

Current limitations

  • External sources currently support eager/PythonForce energy and Cartesian forces.
  • Virials, stress, Hessians, edge forces, atomic stress, and TorchScript export are not implemented for the external-source path.
  • The MACE-specific electrostatic link-record path and upstream's generic mechanical link-atom path are both retained. Consolidating them should be reviewed separately.

Not yet rebased on upstream main

Upstream has moved to a7fb40e (1.8). Upstream PR openmm#167 ("Use setParticles() to restrict atoms") removes the manual indices slice/scatter from _computeMACE and restricts the state to the ML atoms instead. This PR's electrostatic path reads full-system positions from the same state (positions_full[mmInfo["mm_atoms"]]), so merging a7fb40e conflicts in macepotential.py and needs a decision on how the external-source path obtains MM positions once setParticles() is in use. Tracked before retargeting upstream.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4ed71717c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +157 to +163
needExclusions = False
for force in system.getForces():
if isinstance(force, openmm.NonbondedForce):
force.addParticle(0.0, 0.0, 0.0)
elif isinstance(force, openmm.CustomNonbondedForce):
force.addParticle([0] * force.getNumPerParticleParameters())
needExclusions = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Populate every per-particle force for added link sites

When a covalent ML/MM boundary is used with an implicit-solvent or polarizable MM system, adding a particle only to NonbondedForce and CustomNonbondedForce leaves forces such as CustomGBForce, GBSAOBCForce, or AmoebaMultipoleForce with fewer particle parameter records than the System. OpenMM then rejects the resulting system during Context creation. Either add appropriate parameters for every supported per-particle force or reject unsupported force types before adding the link site.

Useful? React with 👍 / 👎.

systemList = [system, newSystem]
else:
systemList = [newSystem]
capIndices, oldToNew = utilities.addLinkAtomSites(newTopology, systemList, linkBonds, args.get("linkAtomDistances", []))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add link-site particles to the PME exclusion force

For periodic mechanical embedding with a long-range ML model and at least one bond crossing the ML/MM boundary, excludeForce is created with the original particle count, while this call adds link-site particles only to newSystem (and optionally system). The excludeForce is subsequently installed as a CustomCVForce collective variable, whose internal NonbondedForce no longer matches the containing system's particle count, so Context construction fails. Add a zero-charge particle to excludeForce for every generated cap as well.

Useful? React with 👍 / 👎.

CheukHinHoJerry and others added 15 commits September 10, 2026 13:47
…polarmace-embedding

# Conflicts:
#	doc/userguide.md
#	openmmml/models/macepotential.py
#	test/TestMACEPotential.py
#	test/TestMechanicalEmbedding.py
The MACE backend requirements still pinned graph_electrostatics v0.4.0, which
predates the external-source work: installing from it yields a graph_longrange
without external_source_energy / external_source_features, so PolarMACE
electrostatic embedding fails at import.

Pin the external_field branch instead, and name it (with the install command) in
the ImportError raised when the blocks are missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TXm1UZZ1hYRkFHZgVFwAN
…rostatic path

Upstream openmm#167 restricts PythonForce to the ML atoms via setParticles(), so the
callback state no longer carries MM coordinates. PolarMACE electrostatic
embedding needs them in the same forward pass (the MM field polarises the ML
density) and returns MM back-reaction forces, so that path keeps the explicit
index slice/scatter and does not call setParticles(). The plain ML path uses
upstream's restriction unchanged; with indices=None and mmInfo=None
_computeMACE is behaviourally identical to upstream.

Same pattern as the EMLE embedding PR (openmm#159): a full-system PythonForce that
indexes ML and MM regions itself.

Verified on OpenMM 8.6.1 (isolated env) with graph_longrange@external_field:
TestPolarMACEElectrostaticEmbedding + TestElectrostaticEmbeddingPME +
TestMechanicalEmbedding: 104 passed, 6 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CheukHinHoJerry
CheukHinHoJerry changed the base branch from main to feat/polar-mace-mm-embedding September 17, 2026 01:32
@CheukHinHoJerry
CheukHinHoJerry changed the base branch from feat/polar-mace-mm-embedding to main September 17, 2026 01:32
CheukHinHoJerry and others added 5 commits September 17, 2026 02:24
No behaviour change. Verified by fingerprinting the mixed System built before and
after (every NonbondedForce parameter and exception, CustomNonbondedForce
exclusions and parameters, plus energy and forces from a Reference Context at
fixed positions, PBC and non-PBC, sharing one saved PolarMACE model): the two
fingerprints are byte-identical, energies -84.29790229105993 and
-56.26526271299811 kJ/mol in both.

- The `embedding != "electrostatic"` guard is unreachable: MLPotential routes an
  embedding to this method only when it appears in getSupportedEmbeddings(), and
  MACE never advertises "mechanical", so that case already falls back to the
  generic embedding plugin. Kept as an explicit internal invariant and commented
  as such rather than left looking like user-facing validation. addForces() still
  handles embedding="mechanical", because the generic plugin calls it.

- Model support was validated twice, in createMixedSystem and again in
  addForces() via _should_use_mm_embedding(). The helper is now the single owner
  of that message and is called early, so an unsupported model is rejected before
  any force-field surgery.

- Dropped dead work in the NonbondedForce block: the loop zeroing chargeProd for
  every exception touching an ML atom was followed by an unconditional
  addException(..., replace=True) over all ML-ML pairs, which overwrote half of
  it. It now handles ML-MM exceptions only.

- New _customNonbondedChargeIndex() resolves the named charge parameter and
  raises if absent, used both by the early validation and by the force loop, so
  the message has one source and no force indices are carried across systems.

- Copied upstream's comment onto setExceptionsUsePeriodicBoundaryConditions() and
  ordered the gates cheap-first: interpolation, model, force-field structure,
  then surgery. Precision parsing moved ahead of model loading so an invalid
  value fails without loading the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TXm1UZZ1hYRkFHZgVFwAN
- Revert the precision-parsing reorder from the previous commit: the block is
  byte-identical to how it was before the cleanup, parsed after the model is
  loaded as upstream does.

- Restore the sentence this branch had dropped, verbatim from upstream:
  "According to the MACE documentation, 'single' precision is recommended for MD
  (faster but less accurate), while 'double' precision is recommended for
  geometry optimization." It was replaced by a shorter paraphrase in a8b89e1;
  the diff should not silently reword upstream documentation.

Fingerprint of the mixed System (all NonbondedForce parameters and exceptions,
CustomNonbondedForce exclusions and parameters, plus energy and forces from a
Reference Context at fixed positions, PBC and non-PBC) is still byte-identical to
the pre-cleanup baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TXm1UZZ1hYRkFHZgVFwAN
…ents

Style alignment with openmmml/embeddings/emleembedding.py (upstream PR openmm#159), which is the
reference for how an ML/MM embedding is written here. No behaviour change: the mixed System
fingerprint (every NonbondedForce parameter and exception, CustomNonbondedForce exclusions and
parameters, plus energy and forces from a Reference Context at fixed positions, PBC and non-PBC,
against one saved PolarMACE model) is byte-identical to before, energies -84.29790229105993 and
-56.26526271299811 kJ/mol. Tests: 104 passed, 6 skipped.

- camelCase for identifiers we own: modelDevice, positionsFull, includedAtoms, useMMEmbedding,
  mlAtoms/mmAtoms/mmCharges/mmPositions/mmForces, and the private helpers _supportsMMEmbedding,
  _prepareExternalSources, _enablePolarMACEExternalSources. External API names are untouched and
  must stay snake_case: the MACE input/result dict keys ("mm_positions", "mm_charges",
  "mm_source_batch", "external_field", "node_attrs", "fermi_level", "mm_forces"), the compute_*
  and requires_grad kwargs, and the mace_off/mace_mp/mace_polar model-family strings.

- The mmInfo dict no longer crosses into the callback. _prepareMMEmbedding is unpacked at the call
  site and the values are bound by name, as emleembedding.py does with mlIndices/mmIndices/
  mmCharges, so _computeMACE's signature documents its own inputs:

      def _computeMACE(state, model, ptr, nodeAttrs, batch, pbc, returnEnergyType, charge,
                       multiplicity, periodic, mlIndices=None, mmIndices=None, mmCharges=None)

  The electrostatic path is selected by `mmIndices is not None` exactly where it previously used
  `mmInfo is not None`, and still does not call setParticles().

- _shouldUseMMEmbedding both raised and returned a flag, and one call site discarded the flag, so
  it read as a no-op. It is now _validateMMEmbedding, which only raises; the flag is derived where
  it is used (`useMMEmbedding = embedding == "electrostatic"`). Its "unsupported mode" message now
  names what MACE implements and what it delegates.

- Wrapped the four error messages this branch added that ran to 122-187 characters, and split the
  _computeMACE signature over two lines. Upstream's own long lines and the KNOWN_MODELS URL table
  are left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TXm1UZZ1hYRkFHZgVFwAN
Removes the two test files this branch added (TestPolarMACEElectrostaticEmbedding.py,
TestElectrostaticEmbeddingPME.py) and restores test/TestMACEPotential.py and
test/TestMechanicalEmbedding.py to upstream. The test/ tree is now byte-identical to upstream and
the PR is source-only: macepotential.py plus the requirements pin.

Tests will be added back once their scope is agreed; the previous set was ~1200 lines across four
files, most of it single-assertion checks on the force-field surgery at a granularity the rest of
this repository does not use.

This also fixes a real breakage: TestMACEPotential.py imported _should_use_mm_embedding, which the
camelCase pass renamed, so that file failed at collection. It was not in the set I had been running,
so the "104 passed" figure reported earlier never covered it. Restoring the upstream file removes
the stale import; 129 tests now collect from the two upstream files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TXm1UZZ1hYRkFHZgVFwAN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant