Skip to content

0.6.0 — reusable ligand parameters, protonation you choose, and placement that measures

One key is retired; nothing else is removed or renamed. input.remove is refused by name, with the migration given under Structures: deleting a crystallisation additive is an edit to your own file now, not a build setting. Every other 0.5.4 configuration resolves unchanged, and every command keeps its flags. One default moves — solute.parameters was absent and is now search — and it changes what a build does only when a catalog is present to search.

Reusable ligand parameter packages

Parameterising a ligand is the slowest part of a small-molecule build, and until now every build paid it again. A parameter package — molecule.sdf, parameters.ffxml, metadata.json and a browsable parameter.config — records one parameterisation so later builds can reuse it.

solute:
  kind: complex
  parameters: search            # the new default: look, reuse on a match, parameterise otherwise
ligands:
  - {resname: TYL, parameter: ../catalog/CHEMBL112/param_e932f4c4f371}
ligand_catalog:
  path: ../catalog              # optional; $MD_DATA/parameters/ligands is searched after it

A ligand is selected by residue name, and a package may be named or pointed at. parameter is a PATH to a package directory — absolute, or relative to the configuration that names it — so a build works from a package folder sitting beside it with nothing registered anywhere. parameters instead takes a <compound>/<parameter> reference, resolved through the catalogs; one or the other, never both. A resname entry maps every residue of that name, on the reasoning that copies of one compound take the same parameters; to select a single copy, name it by {chain, resid, insertion_code}, which must match exactly one residue.

Registering a package under $MD_DATA/parameters/ligands is therefore a convenience — it is what lets a configuration say parameters: CHEMBL112/param_e932f4c4f371 with no path, from anywhere on the machine — and never a prerequisite for reuse.

solute.parameters takes four kinds of value: search (the default), an explicit <compound>/<parameter> reference, a PATH to a package directory — absolute, or relative to the configuration naming it, so a build works from build-top --parameterize output beside it with no catalog at all — or generate.

A match is decided on four things together — the heavy-atom skeleton, the exact protonation state, the charge method with the implementation that produced it, and the force field resource. Charges are not interchangeable: AM1-BCC through AmberTools' sqm, through OpenEye and through NAGL's graph model are three different results, so a package that cannot say which produced it can be named and used, but can never be found by a search. built.log records which candidate matched and why each other did not.

Charge software versions are recorded and deliberately not compared, so an AmberTools point release does not invalidate a catalog.

Packages are made by build-top --parameterize, the command's third mode beside the build and --rest2-scaler:

md-openmm build-top --parameterize -i TYL.smi --resname TYL \
    -op build/parameter/TYL.pdb -os build/parameter/TYL.xml \
    -log build/parameter/TYL.log [--config PATH] [--register]

It writes one directory holding the package, with the readable TYL.sdf and TYL.pdb copies named for --resname beside it — no box, no solvent, no System to integrate. The input must carry bond orders (.sdf, .mol2 or .smi, a SMILES stating the chemistry with the conformer embedded); a .pdb is refused, because bond orders cannot be recovered from coordinates. --register adds the finished package to $MD_DATA/parameters/ligands.

It takes solute.kind: ligand only. A package records parameters and no kind, and nothing a build matches against depends on one: peptide-like is the same whole-molecule route, with the same force field and the same charges, plus a validated peptide-chemistry map built over the result — a property of the BUILD, applied when a System is made, which is what drives the residue-keyed mbondi3 corrections under implicit solvent. So a peptide-like solute is parameterised here as ligand, and the package it produces is reused by a kind: peptide-like build unchanged.

Set solute.aliases before you register. They are the searchable names stored with a package this build CREATES — aliases: [paracetamol, acetaminophen] — and they are names, not identities: they do not enter the parameter id, so the same molecule with and without them gives the same package. A package that is REUSED keeps its own aliases, so stating them against a catalog that already holds the molecule does not change them; the build says so on stderr and records the names in built.log as stated_aliases_not_applied. Registering an aliased copy of a package already in the catalog reports already registered, kept, because registration is write-once and compares identity rather than names, and a NOTE on stderr lists the aliases that were not added. data-register --find-ligand searches the compound id, the residue name and the canonical SMILES as well, so a package with no aliases is still findable by CHEMBL112 or TYL — just not as "paracetamol".

See ligand packages and build-top.

Protonation is a stated choice, with PROPKA3 available

protonation:
  method: propka                # or `openmm`, the previous behaviour and still the default
  ph: 7.0
  overrides:
    - select: {chain: A, resid: "102", insertion_code: ""}
      variant: HIE

openmm keeps what md-tools always did — Modeller.addHydrogens(pH) chooses. propka predicts pKa values on the prepared structure and assigns variants by one stated rule: ASP/GLU protonated when pKa > pH, LYS neutral when pKa < pH, HIS doubly protonated when pKa > pH and otherwise neutral, with OpenMM's hydrogen-bond heuristic picking HID or HIE because PROPKA does not resolve that tautomer. A predicted state no supported variant can build is reported and the standard state kept, never silently approximated. PROPKA missing or failing is an error, never a fall back. Everything lands in build/protonation.json.

Either way the states are fixed for the run: this is not constant-pH MD.

Structures: assemblies and missing atoms

input:
  assembly: "1"                 # expand an mmCIF biological assembly into distinct chains
  missing_atoms: add            # or `refuse`, the default

Biological assemblies expand into distinct chains, with on-axis ions and waters kept exactly once. Crystal waters and ions are kept after the solute. Missing heavy atoms are refused by default, or built when you ask.

Deleting a crystallisation additive is an edit to your own file, not a build setting. Strip the residues before building and pass the edited structure to -i:

grep -v -E "^HETATM .* (EDO|SCN) " deposited.cif > prepared.cif

That edits the deposited ids, before any input.assembly expansion, so a residue present in several copies goes from all of them — usually what you want for an additive. Record in your own notes what you removed and why: the build no longer does. A configuration carrying the retired input.remove key is refused with this migration rather than with "unknown key".

The prepared structure is saved beside the System as built.prepared.pdb, so a rebuild — and a reference bundle — starts from exactly what was read.

Concurrent CUDA: placement by measurement, and MPS

Workers are no longer dealt round-robin over devices. Placement measures each visible device with this run's own System and maximises the slowest worker's share, because a synchronous ladder runs at the pace of its slowest rank. A device that measures slow is given fewer workers or none.

Measured, at 23,659 particles: four workers on four cards are 1.32x faster than the serial ladder, and MPS makes four workers sharing one card 1.26x faster than unmanaged sharing. At 1,760 particles concurrency costs time — the crossover is real. Sharing one card is still not faster than using it serially: MPS buys that a ladder runs with fewer cards than replicas, not that it runs faster.

A shared GPU without verified MPS is refused before anything is written, and the refusal now says what is shared, where your rank stands, and the rates behind the decision:

REST2: this launch shares device 3 between 2 workers. NVIDIA MPS is absent for this process; this
rank is alone on device 1, but the launch cannot proceed unverified: a ladder is synchronous, so
the shared device sets the pace for every rank.
  Workers were placed by: measured throughput (balanced). Measured steps/s at preflight: device 0
  13258, device 1 110828 ... Device 0 measured 9.4x slower than the fastest, so it was given fewer
  workers or none. A device measures slow because it is slower OR because something else on this
  machine is using it, and this measurement cannot tell those apart — check the card before
  concluding anything about the hardware.

That last sentence is there because it caught us: a card measuring 9.4x slow turned out to be busy with an unrelated job. Idle, it was within 1.06x of its neighbours. Placement is a property of the host at preflight time, contention included — two identical launches minutes apart can be placed differently, and one refused. The rates are in every refusal and in acceleration.placement.measurement.

AIS

λ schedules gain tau-linear, and evenly spaced frames now cover the whole window rather than stopping short of it.

Tutorials

Every page was executed exactly as written, and every number on it copied from the files that run produced.

Where ligand parameters come from, and the ways to name a package:

  • paracetamol in explicit water — the parameterisation page. build-top --parameterize from a SMILES string writes one package; the box is then built from that folder by path, with nothing registered and no catalog configured.
  • paracetamol by REST2 — the same parameters named by identity out of the shared catalog (parameters: CHEMBL112/param_e932f4c4f371), so the ladder and the cMD box rest on identical numbers and a comparison between them is a comparison of sampling. It also documents running a four-replica ladder on one GPU under MPS.
  • bromodomain with paracetamol (4A9K) — protein–ligand, the same package again, selected by residue name.
  • paracetamol by AIS — the same package again, by identity. Its build drops from 1.5 min, almost all of it the partial-charge calculation, to 3.1 s, which is the reuse feature stated as plainly as it can be stated.

Protein runs:

  • barnase–barstar (1BRS) (tutorial moved to On hold in 0.6.5) — protein–protein, from the deposited crystal structure
  • chignolin and chignolin by REST2

The REST2 chignolin page measures why tau_max is 0.3 rather than 0.5 on a 138-atom peptide: the same ladder at 0 → 0.5 accepts 0.010, at 0 → 0.3 it accepts 0.133, for identical cost.

The paracetamol pages are the reuse feature end to end: one AM1-BCC calculation, then a water box, a REST2 ladder, a protein pocket and a set of AIS switching paths that all load those same numbers and generate no charges.

Fixed

  • An umbrella window no longer opens on an unrelaxed structure. The resume test interrupted inside the checkpoint transaction, then demanded a resume the contract correctly refuses.
  • A ligand refusal names the input you typed, not the staged copy in a temporary directory that no longer exists by the time you read it.
  • A package written before parameter.config existed still loads, and one whose declaration was written under an older schema is re-derived rather than read as a contradiction. A declaration under the current schema that disagrees with its parameters still refuses — that check is what catches an edited catalog.
  • The umbrella platform guard asks whether a device exists, not whether CUDA is registered.
  • data-register reads a run directory's method instead of discarding it. The default layout is <method>-run<N>/, but the manifest drafter matched names exactly, so cMD-run1 was not cMD and every production directory registered as a reference component claiming no method — silently, on a public command. Run directories now resolve for cMD, REST2, AIS, umbrella and the archived rREST2, with US accepted as a directory name and recorded as umbrella, since build_md.PROTOCOLS is the one authority for the spelling. A name that is neither a method nor a fixed component — a stream directory, or a variant nobody declared — is still reference with no method claimed. Datasets registered before this carry the old labels; the data are unaffected, and re-registering restores them.
  • A REST2 --extend-from onto different saved states is refused before anything is written. The group file is compared with the saved states the parent's solute.yaml records it integrating, state by state, before -odir exists. The comparison with the parent's identity used to happen only in the driver, after _protocol.py, solute.yaml and the logs of the new directory were written, so the refusal landed in the new REST2.out and the directory read as a started extension. The parent was never modified. The driver's full identity comparison still runs after this check.
  • Ligand packages build under OPC water. The 1-4 scale check compared a package's exact 5/6 with the force field's value bit for bit, and amber14/opc.xml and amber19/opc.xml write 0.833333, so every package was refused in every OPC build, including the documented ff19SB + OPC pairing. The check now uses OpenMM's own merge tolerance (1e-5) and records the applied scale beside the package's. The consequence is stated rather than hidden: under OPC, and only there, a ligand's 1-4 Coulomb pairs carry 0.833333 instead of the package's 5/6, a factor of about 4e-7; everything else about the ligand is identical, and built.log names the applied value under forcefield_record.ligand.nonbonded_compatibility. The load order is unchanged, so an existing OPC System is exactly what it was. Found by S2 (0.7.0 topology work).

Testing

The GPU lane needs six visible devices for test_multi_rank_rest2_ladders_of_several_sizes[6]; everything else needs at most four. Run it in two parts rather than exposing a mixed set, and give the MPS tests the daemon's own client-side card numbering:

# six idle cards, ~4m30s
CUDA_VISIBLE_DEVICES=0,2,4,5,6,7 pytest tests/test_cuda_coverage_matrix.py \
    -k test_multi_rank_rest2_ladders_of_several_sizes

# four cards plus a scoped MPS daemon, ~14m30s
CUDA_VISIBLE_DEVICES=4,5,6,7 MD_TOOLS_TEST_CARDS=4,5,6,7 \
MD_TOOLS_TEST_MPS_PIPE_DIRECTORY=<the daemon's pipe dir> MD_TOOLS_TEST_MPS_CARDS=0,1,2,3 \
    pytest tests -m gpu -rs \
    --deselect tests/test_cuda_coverage_matrix.py::test_multi_rank_rest2_ladders_of_several_sizes

Use -rs: a lane whose tests all skip still exits 0, and the count is the only thing that says so.

What the two parts should come to, because a count you cannot predict is a count you cannot check:

111 gpu tests collected
  six-card part      3 passed    -- the -k matches THREE parametrised cases, not just [6]
  four-card part   107 passed, 1 skipped
                   ------------------------
                   110 passed, 1 skipped, 0 failed

The --deselect removes all three parametrised test_multi_rank_rest2_ladders_of_several_sizes cases, which is the easiest thing to get wrong: predicting 110 for the second part and seeing 107 sends you looking for three tests that were never going to run. The single skip names itself — no --cuda-evidence=<path> given — and is the only one that should appear.

All four MPS-specific tests must RUN, not skip: the throughput measurement, the refusal without MPS, four workers sharing one card, and twelve workers across four. They skip rather than fail when the daemon is unreachable, the card count is short, or MD_TOOLS_TEST_MPS_CARDS is unset — so a misconfigured phase reports success having tested nothing, which is the failure -rs exists to expose.

Two things about the daemon, both learned the hard way. Its pipe directory must be short: a Unix control socket path caps at 108 characters, and a daemon started under a long scratch path dies with nothing useful in its log. And it must be scoped — the default pipe directory serves every card on the host, so a daemon started without one silently puts other people's GPUs behind it. Start it yourself and shut it down afterwards; the suite never does, because a test that brings a shared service up and down is changing the machine to make itself pass.

The slow lane needs a visible card too, which is easy to miss from its name. -m "slow and not gpu" excludes tests about the GPU — placement, MPS, the coverage matrix — not tests that integrate one. Its stages launch as subprocesses, and CUDA is mandatory with no automatic fall back, so with no device visible around sixty of them refuse at preflight with CUDA_ERROR_NO_DEVICE, mostly as fixture errors. That looks like a broken tree and is only a missing card. One card is enough.

Measured on one tree, changing nothing but the card:

one card visible    425 passed,   7 skipped,  0 failed,  0 errors   20:25
no card visible      18 failed, 362 passed, 45 errors               28:45   all CUDA_ERROR_NO_DEVICE

Identical code, identical commit — an EARLIER one than the gate counts above, which is why its passing total differs from theirs. The requirement above is easy to read past; the pair of numbers is not, which is why both are here.