@@ -86,22 +86,24 @@ def test_openmm_cmap_energy(tmpdir, multichain_cmap, openmm_platform):
8686 "openmm" not in sr .convert .supported_formats (),
8787 reason = "openmm support is not available" ,
8888)
89- def test_openmm_cmap_perturbable (multichain_cmap , openmm_platform ):
89+ def test_openmm_cmap_perturbable (openmm_platform ):
9090 """
91- Verify that CMAPTorsionForce grids are correctly handled for a perturbable
92- molecule. The pre-merged stream file merged_molecule_cmap.s3 contains a
93- perturbable molecule whose two end states are identical (an identity
94- perturbation of a CHARMM protein chain), so both end states carry the same
95- CMAP backbone correction terms. The test checks that the perturbable code
96- path correctly applies the same grids at all lambda values and that
97- set_lambda does not corrupt the force parameters.
91+ Verify that CMAPTorsionForce grids are correctly updated by the lambda lever
92+ for a perturbable molecule with a genuine single-residue mutation.
93+
94+ The pre-merged stream file merged_molecule_cmap.s3 contains a perturbable
95+ ubiquitin chain with a T9A mutation. cmap0 and cmap1 differ for exactly
96+ one torsion (the one centred on the mutated residue). The test checks that:
97+
98+ - CMAP torsions are present at both end states.
99+ - At least one torsion grid differs between lambda=0 and lambda=1.
100+ - Most torsion grids are unchanged (only the mutated residue is affected).
98101 """
99102 import openmm
103+ import openmm .unit
100104
101105 platform_name = openmm_platform or "CPU"
102106
103- mol0 = multichain_cmap [0 ]
104-
105107 mols_pert = sr .load_test_files ("merged_molecule_cmap.s3" )
106108 mols_pert = sr .morph .link_to_reference (mols_pert )
107109
@@ -113,14 +115,8 @@ def test_openmm_cmap_perturbable(multichain_cmap, openmm_platform):
113115 }
114116
115117 def get_cmap_torsion_grids (context ):
116- """
117- Return list of (size, grid) for each CMAP torsion, dereferencing the
118- map index. Grid values are returned as plain floats (kJ/mol) so that
119- pytest.approx can compare them. This is map-count-agnostic: the
120- non-perturbable path deduplicates maps while the perturbable path
121- allocates one map per torsion, but the per-torsion grid values must
122- agree.
123- """
118+ """Return list of (size, grid) for each CMAP torsion, dereferencing
119+ the map index. Grid values are plain floats (kJ/mol)."""
124120 system = context .getSystem ()
125121 for force in system .getForces ():
126122 if isinstance (force , openmm .CMAPTorsionForce ):
@@ -138,50 +134,37 @@ def get_cmap_torsion_grids(context):
138134 return result
139135 return []
140136
141- def unique_grids (torsion_grids , decimals = 3 ):
142- """
143- Return the sorted set of unique (size, rounded-grid) tuples.
144-
145- Torsion ordering can differ between the perturbable and non-perturbable
146- code paths, so we compare the sets of unique grid shapes rather than
147- comparing torsion-by-torsion.
148- """
149- seen = set ()
150- result = []
151- for size , grid in torsion_grids :
152- key = (size , tuple (round (v , decimals ) for v in grid ))
153- if key not in seen :
154- seen .add (key )
155- result .append (key )
156- return sorted (result )
157-
158- # Reference: non-perturbable molecule.
159- mols_ref = sr .system .System ()
160- mols_ref .add (mol0 )
161- omm_ref = sr .convert .to (mols_ref , "openmm" , map = omm_map )
162- ref_torsion_grids = get_cmap_torsion_grids (omm_ref )
163-
164- assert len (ref_torsion_grids ) > 0 , "Reference context has no CMAP torsions"
165- ref_unique = unique_grids (ref_torsion_grids )
166-
167- # Perturbable context — one context, lambda changed in place.
168137 omm_pert = sr .convert .to (mols_pert , "openmm" , map = omm_map )
169138
170- # At both lambda=0 and lambda=1 the set of unique CMAP grids must match the
171- # non-perturbable reference (cmap0 == cmap1 for an identity perturbation).
172- # We compare sets of unique grids because the perturbable and non-perturbable
173- # code paths may order torsions differently.
174- for lam in (0.0 , 1.0 ):
175- omm_pert .set_lambda (lam )
176- pert_torsion_grids = get_cmap_torsion_grids (omm_pert )
177-
178- assert len (pert_torsion_grids ) == len (ref_torsion_grids ), (
179- f"Wrong number of CMAP torsions at lambda={ lam } : "
180- f"{ len (pert_torsion_grids )} != { len (ref_torsion_grids )} "
181- )
182-
183- pert_unique = unique_grids (pert_torsion_grids )
184- assert pert_unique == ref_unique , (
185- f"Set of unique CMAP grids differs from reference at lambda={ lam } : "
186- f"{ len (pert_unique )} unique grids vs { len (ref_unique )} in reference"
187- )
139+ omm_pert .set_lambda (0.0 )
140+ grids_lam0 = get_cmap_torsion_grids (omm_pert )
141+
142+ omm_pert .set_lambda (1.0 )
143+ grids_lam1 = get_cmap_torsion_grids (omm_pert )
144+
145+ assert len (grids_lam0 ) > 0 , "No CMAP torsions at lambda=0"
146+ assert len (grids_lam1 ) == len (
147+ grids_lam0
148+ ), f"Torsion count differs between end states: { len (grids_lam0 )} vs { len (grids_lam1 )} "
149+
150+ differing = sum (
151+ 1
152+ for (s0 , g0 ), (s1 , g1 ) in zip (grids_lam0 , grids_lam1 )
153+ if s0 != s1 or any (round (a , 3 ) != round (b , 3 ) for a , b in zip (g0 , g1 ))
154+ )
155+
156+ assert differing > 0 , (
157+ "Expected at least one torsion grid to differ between lambda=0 and lambda=1 "
158+ "for a genuine single-residue mutation"
159+ )
160+ assert differing < len (
161+ grids_lam0
162+ ), "Expected most torsion grids to be unchanged for a single-residue mutation"
163+
164+ # Verify that changed_cmaps() correctly identifies the same set of
165+ # differing torsions as the direct grid comparison above.
166+ p_omm = mols_pert .molecule (0 ).perturbation ().to_openmm (map = omm_map )
167+ changed = p_omm .changed_cmaps ()
168+ assert (
169+ len (changed ) == differing
170+ ), f"changed_cmaps() returned { len (changed )} torsions but expected { differing } "
0 commit comments