Spaces:
Running
Running
File size: 7,450 Bytes
2583376 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | """MD force field / solvent configuration with startup verification.
The exposed menu of force fields and implicit-solvent models is not a static
list: every (force field x solvent) combination is verified at import time by
running a real OpenMM ``createSystem()`` on an embedded alanine-dipeptide
structure (the canonical test system), using the exact same construction path
as the simulation engine (PDBFile -> Modeller -> addHydrogens -> createSystem
with a non-periodic 2.0 nm cutoff). Only combinations that build successfully
are exposed through ``GET /api/md/forcefields`` and accepted by the run
endpoints. An unknown or unverified combination raises an explicit ValueError
instead of silently falling back to AMBER14/OBC2.
"""
from __future__ import annotations
import io
import logging
logger = logging.getLogger(__name__)
# User-facing force field keys -> bundled OpenMM XML force field files.
FF_XML: dict[str, str] = {
"amber14": "amber14-all.xml", # legacy all-residue AMBER14
"ff14sb": "amber14/protein.ff14SB.xml",
"ff15ipq": "amber14/protein.ff15ipq.xml",
"ff19sb": "amber19/protein.ff19SB.xml",
"amberfb15": "amberfb15.xml",
"charmm36": "charmm36.xml",
}
FF_LABELS: dict[str, str] = {
"amber14": "AMBER14 (ff14SB)",
"ff14sb": "AMBER ff14SB",
"ff15ipq": "AMBER ff15ipq",
"ff19sb": "AMBER ff19SB",
"amberfb15": "AMBER ff15 (amberfb15)",
"charmm36": "CHARMM36",
}
# Implicit-solvent (Generalized Born) models -> bundled XML files.
SOLVENT_XML: dict[str, str] = {
"obc1": "implicit/obc1.xml",
"obc2": "implicit/obc2.xml",
"gbn2": "implicit/gbn2.xml",
}
DEFAULT_FORCEFIELD = "amber14"
DEFAULT_SOLVENT = "obc2"
# Heavy-atom alanine dipeptide (ALA-ALA with C-terminal OXT) used as the
# startup verification probe. It exercises the same Modeller/addHydrogens
# construction path the real simulation uses, is fully self-contained, and
# needs no network access. Its geometry is approximate; the probe only checks
# that createSystem() builds a valid System, so this is acceptable.
_DIPEPTIDE_PDB = """\
REMARK 1 CREATED WITH OPENMM 8.5.2
CRYST1 40.960 18.650 22.520 90.00 90.77 90.00 P 1 1
ATOM 1 N ALA A 1 17.047 14.099 3.625 1.00 0.00 N
ATOM 2 CA ALA A 1 16.967 12.784 4.338 1.00 0.00 C
ATOM 3 C ALA A 1 15.685 12.755 5.133 1.00 0.00 C
ATOM 4 O ALA A 1 15.268 13.825 5.594 1.00 0.00 O
ATOM 5 CB ALA A 1 18.170 12.703 5.337 1.00 0.00 C
ATOM 6 N ALA A 2 15.115 11.555 5.265 1.00 0.00 N
ATOM 7 CA ALA A 2 13.856 11.469 6.066 1.00 0.00 C
ATOM 8 C ALA A 2 14.164 10.785 7.379 1.00 0.00 C
ATOM 9 O ALA A 2 14.993 9.862 7.443 1.00 0.00 O
ATOM 10 CB ALA A 2 12.732 10.711 5.261 1.00 0.00 C
ATOM 11 OXT ALA A 2 13.343 11.699 7.316 1.00 0.00 O
TER 11 ALA A 2
END
"""
_VERIFIED: dict[str, tuple[str, ...]] | None = None
_VERIFY_ERRORS: dict[tuple[str, str], str] | None = None
def _build_dipeptide_system(forcefield: str, solvent: str) -> None:
"""Run the real construction path on the dipeptide (raises on failure)."""
from openmm.app import PDBFile, ForceField, Modeller, CutoffNonPeriodic
from openmm import unit
pdb = PDBFile(io.StringIO(_DIPEPTIDE_PDB))
forcefield_xml = ForceField(FF_XML[forcefield], SOLVENT_XML[solvent])
modeller = Modeller(pdb.topology, pdb.positions)
modeller.addHydrogens(forcefield_xml)
forcefield_xml.createSystem(
modeller.topology,
nonbondedMethod=CutoffNonPeriodic,
nonbondedCutoff=2.0 * unit.nanometer,
)
def verify_ff_solvent_combos() -> dict[str, tuple[str, ...]]:
"""Verify every (force field x solvent) combo with a real createSystem().
Returns a dict mapping each passing force field to the tuple of solvents
it supports. Results are cached after the first call. When OpenMM is not
installed, returns an empty mapping (MD degrades to structural analysis).
"""
global _VERIFIED, _VERIFY_ERRORS
if _VERIFIED is not None:
return _VERIFIED
verified: dict[str, list[str]] = {ff: [] for ff in FF_XML}
errors: dict[tuple[str, str], str] = {}
try:
import openmm # noqa: F401
except Exception as e:
logger.warning("OpenMM unavailable — MD force field verification skipped: %s", e)
_VERIFIED = {}
_VERIFY_ERRORS = {}
return _VERIFIED
for ff in FF_XML:
for solvent in SOLVENT_XML:
try:
_build_dipeptide_system(ff, solvent)
verified[ff].append(solvent)
except Exception as exc:
errors[(ff, solvent)] = f"{type(exc).__name__}: {exc}"
logger.warning(
"MD combo %s x %s FAILED verification: %s: %s",
ff, solvent, type(exc).__name__, exc,
)
_VERIFIED = {ff: tuple(ff_solvents) for ff, ff_solvents in verified.items()}
_VERIFY_ERRORS = errors
logger.info(
"MD force field verification complete: %d force fields, combos=%s",
len(_VERIFIED), {ff: list(s) for ff, s in _VERIFIED.items()},
)
return _VERIFIED
def get_forcefields_menu() -> dict:
"""Menu payload for GET /api/md/forcefields (verified combos only)."""
verified = verify_ff_solvent_combos()
return {
"forcefields": [
{"value": ff, "label": FF_LABELS.get(ff, ff)}
for ff in FF_XML
if verified.get(ff)
],
"solvents": [{"value": s, "label": f"Implicit · {s.upper()}"} for s in SOLVENT_XML],
"combos": {ff: list(solvents) for ff, solvents in verified.items()},
"defaults": {"forcefield": DEFAULT_FORCEFIELD, "solvent": DEFAULT_SOLVENT},
"probe": {
"system": "alanine dipeptide (ALA-ALA)",
"forcefields_tested": len(FF_XML),
"solvents_tested": len(SOLVENT_XML),
},
}
def resolve_combo(forcefield: str | None, solvent: str | None) -> tuple[str, str]:
"""Normalize and validate a requested (forcefield, solvent) pair.
Raises ValueError with a clear message when the force field or solvent is
unknown, or when the specific combination did not pass startup
verification. No silent fallback: an invalid request is an explicit error.
"""
ff_key = (forcefield or DEFAULT_FORCEFIELD).strip().lower()
sol_key = (solvent or DEFAULT_SOLVENT).strip().lower()
if ff_key not in FF_XML:
raise ValueError(
f"Unsupported force field: {forcefield!r}. Choose from: "
+ ", ".join(FF_XML)
)
if sol_key not in SOLVENT_XML:
raise ValueError(
f"Unsupported solvent model: {solvent!r}. Choose from: "
+ ", ".join(SOLVENT_XML)
)
verified = verify_ff_solvent_combos()
if verified and sol_key not in verified.get(ff_key, ()):
raise ValueError(
f"Force field '{ff_key}' is not available with solvent '{sol_key}' "
f"on this deployment. Supported solvents for '{ff_key}': "
+ (", ".join(verified.get(ff_key, ())) if verified.get(ff_key) else "none")
)
return ff_key, sol_key
|