@@ -566,6 +566,33 @@ def __init__(
566566 except Exception as e :
567567 raise ValueError (f"Could not prepare the system for GCMC sampling: { e } " )
568568
569+ # Compute per-molecule virtual site information. Virtual sites are
570+ # appended after the real atoms of each molecule in the OpenMM system,
571+ # so all subsequent molecules have their OpenMM particle indices shifted
572+ # by the cumulative number of virtual sites in preceding molecules.
573+ (
574+ self ._total_vsites ,
575+ self ._vsite_atom_offsets ,
576+ self ._mol_vsite_charges ,
577+ ) = self ._get_vsite_offsets (self ._system )
578+
579+ if self ._total_vsites > 0 :
580+ # Offset water oxygen indices from Sire atom indices to OpenMM
581+ # particle indices.
582+ self ._water_indices = (
583+ self ._water_indices + self ._vsite_atom_offsets [self ._water_indices ]
584+ )
585+
586+ # Apply the same correction to the reference atom indices.
587+ if self ._reference is not None :
588+ self ._reference_indices = (
589+ self ._reference_indices
590+ + self ._vsite_atom_offsets [self ._reference_indices ]
591+ )
592+
593+ # Update the total atom count to include virtual site particles.
594+ self ._num_atoms = self ._system .num_atoms () + self ._total_vsites
595+
569596 # Validate the platform parameter.
570597 valid_platforms = {"auto" , "cuda" , "opencl" }
571598
@@ -1102,6 +1129,13 @@ def reset(self) -> None:
11021129 self ._num_accepted_insertions = 0
11031130 self ._num_accepted_deletions = 0
11041131
1132+ # Clear the forces.
1133+ self ._nonbonded_force = None
1134+ self ._custom_nonbonded_force = None
1135+
1136+ # Clear the OpenMM context.
1137+ self ._openmm_context = None
1138+
11051139 def restore_stats (self , stats : dict ) -> None :
11061140 """
11071141 Restore sampler statistics from a dictionary.
@@ -1140,17 +1174,11 @@ def get_stats(self) -> dict:
11401174 "num_accepted_deletions" : self ._num_accepted_deletions ,
11411175 }
11421176
1143- # Clear the forces.
1144- self ._nonbonded_force = None
1145- self ._custom_nonbonded_force = None
1146-
1147- # Clear the OpenMM context.
1148- self ._openmm_context = None
1149-
11501177 def ghost_residues (self ) -> _np .ndarray :
11511178 """
1152- Return the current indices of the ghost water residues in the OpenMM
1153- context.
1179+ Return the residue indices of the current ghost waters in the input
1180+ topology. These are Sire/BioSimSpace residue indices and do not
1181+ include any virtual site particles that were added on context creation.
11541182
11551183 Returns
11561184 -------
@@ -1785,6 +1813,75 @@ def _get_box_information(self, space):
17851813
17861814 return cell_matrix , cell_matrix_inverse , M
17871815
1816+ @staticmethod
1817+ def _get_vsite_offsets (system ):
1818+ """
1819+ Compute per-atom OpenMM index offsets due to virtual sites.
1820+
1821+ In OpenMM, virtual site particles are appended after the real atoms
1822+ of each molecule. Molecules that appear after a molecule with virtual
1823+ sites therefore have their OpenMM particle indices shifted relative
1824+ to their Sire atom indices.
1825+
1826+ Parameters
1827+ ----------
1828+
1829+ system: sire.system.System
1830+ The molecular system.
1831+
1832+ Returns
1833+ -------
1834+
1835+ total_vsites: int
1836+ Total number of virtual site particles in the system.
1837+
1838+ atom_offsets: numpy.ndarray
1839+ Array of shape (num_sire_atoms,) where entry i is the
1840+ cumulative number of virtual sites in all molecules that
1841+ precede the molecule containing Sire atom i. Adding this
1842+ offset to a Sire atom index yields the corresponding OpenMM
1843+ particle index.
1844+
1845+ mol_vsite_charges: dict
1846+ Mapping from molecule number to a list of virtual site charges in
1847+ units of elementary charge. Only molecules that carry virtual sites
1848+ appear as keys; all other molecules are absent from the dict.
1849+ """
1850+ n_sire_atoms = system .num_atoms ()
1851+ atom_offsets = _np .zeros (n_sire_atoms , dtype = _np .int32 )
1852+ mol_vsite_charges = {}
1853+ total_vsites = 0
1854+
1855+ try :
1856+ vsite_mols = system ["property n_virtual_sites" ].molecules ()
1857+ except Exception :
1858+ # No molecules carry virtual sites.
1859+ return 0 , atom_offsets , mol_vsite_charges
1860+
1861+ all_atoms = system .atoms ()
1862+
1863+ for mol in vsite_mols :
1864+ n_vs = int (mol .property ("n_virtual_sites" ))
1865+ if n_vs <= 0 :
1866+ continue
1867+
1868+ # Locate where this molecule's atoms sit in the global index space,
1869+ # then shift every subsequent atom's offset by n_vs in one operation.
1870+ mol_start = int (_np .array (all_atoms .find (mol .atoms ()))[0 ])
1871+ mol_end = mol_start + mol .num_atoms ()
1872+ atom_offsets [mol_end :] += n_vs
1873+ total_vsites += n_vs
1874+
1875+ try :
1876+ raw_charges = mol .property ("vs_charges" )
1877+ vs_charges = [float (raw_charges [k ]) for k in range (n_vs )]
1878+ except Exception :
1879+ vs_charges = [0.0 ] * n_vs
1880+
1881+ mol_vsite_charges [mol .number ()] = vs_charges
1882+
1883+ return total_vsites , atom_offsets , mol_vsite_charges
1884+
17881885 @staticmethod
17891886 def _get_reference_indices (system , reference ):
17901887 """
@@ -1937,6 +2034,10 @@ def _initialise_gpu_memory(self):
19372034 for q in mol .property ("charge" ):
19382035 charges [i ] = q .value ()
19392036 i += 1
2037+ # Append virtual site charges (zero LJ, non-zero charge).
2038+ for vc in self ._mol_vsite_charges .get (mol .number (), []):
2039+ charges [i ] = vc
2040+ i += 1
19402041
19412042 # Convert to a GPU array.
19422043 charges = self ._backend .to_gpu (charges .astype (_np .float32 ))
@@ -1954,6 +2055,12 @@ def _initialise_gpu_memory(self):
19542055 sigmas [i ] = lj .sigma ().value ()
19552056 epsilons [i ] = lj .epsilon ().value ()
19562057 i += 1
2058+ # Virtual sites have zero LJ. Use sigma=1.0 Å as a
2059+ # nominal placeholder (epsilon=0 so it has no effect).
2060+ for _ in self ._mol_vsite_charges .get (mol .number (), []):
2061+ sigmas [i ] = 1.0
2062+ epsilons [i ] = 0.0
2063+ i += 1
19572064
19582065 # Convert to GPU arrays.
19592066 sigmas = self ._backend .to_gpu (sigmas .astype (_np .float32 ))
@@ -2063,8 +2170,9 @@ def _initialise_gpu_memory(self):
20632170
20642171 # This is a null LJ parameter.
20652172 if _np .isclose (lj .epsilon ().value (), 0.0 ):
2066- idx = atoms .find (atom )
2067- is_ghost_fep [idx ] = 1
2173+ sire_idx = atoms .find (atom )
2174+ omm_idx = sire_idx + int (self ._vsite_atom_offsets [sire_idx ])
2175+ is_ghost_fep [omm_idx ] = 1
20682176
20692177 # The charge at the perturbed state is zero.
20702178 elif _np .isclose (charge1 , 0.0 ):
@@ -2073,8 +2181,9 @@ def _initialise_gpu_memory(self):
20732181
20742182 # This is a null LJ parameter.
20752183 if _np .isclose (lj .epsilon ().value (), 0.0 ):
2076- idx = atoms .find (atom )
2077- is_ghost_fep [idx ] = 1
2184+ sire_idx = atoms .find (atom )
2185+ omm_idx = sire_idx + int (self ._vsite_atom_offsets [sire_idx ])
2186+ is_ghost_fep [omm_idx ] = 1
20782187
20792188 # Convert to GPU array.
20802189 is_ghost_fep = self ._backend .to_gpu (is_ghost_fep .astype (_np .int32 ))
0 commit comments