Skip to content

Commit 3385d7f

Browse files
committed
Allow user to specify counterion.
1 parent 1e67e0b commit 3385d7f

4 files changed

Lines changed: 192 additions & 14 deletions

File tree

src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,8 @@
4040
"rubidium": ("RB", 1),
4141
"cs": ("CS", 1),
4242
"cesium": ("CS", 1),
43-
"f": ("F", -1),
44-
"fluoride": ("F", -1),
4543
"cl": ("CL", -1),
4644
"chloride": ("CL", -1),
47-
"br": ("BR", -1),
48-
"bromide": ("BR", -1),
4945
"mg": ("MG", 2),
5046
"magnesium": ("MG", 2),
5147
"ca": ("CA", 2),
@@ -78,6 +74,7 @@ def addIons(
7874
ion_conc=0,
7975
is_neutral=True,
8076
preserved_waters=None,
77+
counter_ion=None,
8178
work_dir=None,
8279
property_map={},
8380
):
@@ -128,6 +125,15 @@ def addIons(
128125
that genion cannot select them for replacement. Useful for
129126
protecting crystallographic or binding-site water molecules.
130127
128+
counter_ion : str
129+
The name of the ion to use for the opposite charge slot in the
130+
genion call. By default genion uses Na\\ :sup:`+` as the counter
131+
cation and Cl\\ :sup:`-` as the counter anion. Use this parameter
132+
to override the counter-ion species, e.g. ``"br"`` when adding a
133+
cation. The counter-ion must have the opposite charge sign to
134+
``ion``. Requires ``is_neutral=True`` (otherwise no counter-ions
135+
are added and the parameter has no effect).
136+
131137
work_dir : str
132138
The working directory for the process.
133139
@@ -235,6 +241,35 @@ def addIons(
235241
"'preserved_waters' items must be int indices or Molecule objects."
236242
)
237243

244+
# Validate and resolve counter_ion.
245+
counter_ion_name = None
246+
counter_ion_charge = None
247+
if counter_ion is not None:
248+
if not isinstance(counter_ion, str):
249+
raise TypeError("'counter_ion' must be of type 'str'")
250+
counter_key = counter_ion.strip().lower()
251+
if counter_key not in _ion_map:
252+
raise ValueError(
253+
f"Unsupported counter_ion '{counter_ion}'. Supported ions are: {ions()}"
254+
)
255+
counter_ion_name, counter_ion_charge = _ion_map[counter_key]
256+
# The counter-ion must have the opposite charge sign to the requested ion.
257+
if ion_charge > 0 and counter_ion_charge >= 0:
258+
raise ValueError(
259+
f"'counter_ion' must be negatively charged when 'ion' is positively "
260+
f"charged. '{counter_ion}' has charge {counter_ion_charge:+d}."
261+
)
262+
if ion_charge < 0 and counter_ion_charge <= 0:
263+
raise ValueError(
264+
f"'counter_ion' must be positively charged when 'ion' is negatively "
265+
f"charged. '{counter_ion}' has charge {counter_ion_charge:+d}."
266+
)
267+
if not is_neutral:
268+
raise ValueError(
269+
"'counter_ion' has no effect when 'is_neutral=False'. Set "
270+
"'is_neutral=True' to enable counter-ion addition."
271+
)
272+
238273
if work_dir is not None and not isinstance(work_dir, str):
239274
raise TypeError("'work_dir' must be of type 'str'")
240275

@@ -280,6 +315,8 @@ def addIons(
280315
num_ions,
281316
is_neutral,
282317
preserved_mols,
318+
counter_ion_name,
319+
counter_ion_charge,
283320
work_dir,
284321
property_map,
285322
)
@@ -292,6 +329,8 @@ def _add_ions(
292329
num_ions,
293330
is_neutral,
294331
preserved_mols=None,
332+
counter_ion_name=None,
333+
counter_ion_charge=None,
295334
work_dir=None,
296335
property_map={},
297336
):
@@ -322,6 +361,13 @@ def _add_ions(
322361
between the non-water solute and the new water+ions in the result,
323362
mirroring the ordering used by ``_solvate`` for crystal waters.
324363
364+
counter_ion_name : str
365+
The GROMACS residue name of the counter-ion (e.g. ``"BR"``), or
366+
``None`` to use genion's default (NA/CL).
367+
368+
counter_ion_charge : int
369+
The integer charge of the counter-ion, or ``None``.
370+
325371
work_dir : str
326372
The working directory for the process.
327373
@@ -481,22 +527,29 @@ def _add_ions(
481527
# Build the genion command.
482528
# genion supports one positive ion type (-pname/-pq/-np) and one
483529
# negative ion type (-nname/-nq/-nn). We use the appropriate slot
484-
# for the requested ion. When is_neutral=True, genion adds the
485-
# default NA/CL as counter-ions to bring the total charge to zero.
530+
# for the requested ion. When is_neutral=True, genion adds counter-ions
531+
# to bring the total charge to zero; counter_ion_name overrides the
532+
# default counter-ion species (NA for cations, CL for anions).
486533
if ion_charge > 0:
487534
command = (
488535
"%s genion -s ions.tpr -o ions_out.gro -p system.top"
489536
" -pname %s -pq %d" % (_gmx_exe, ion_name, ion_charge)
490537
)
491538
if num_ions > 0:
492539
command += " -np %d" % num_ions
540+
# Override the default Cl- counter-ion if requested.
541+
if counter_ion_name is not None:
542+
command += " -nname %s -nq %d" % (counter_ion_name, counter_ion_charge)
493543
else:
494544
command = (
495545
"%s genion -s ions.tpr -o ions_out.gro -p system.top"
496546
" -nname %s -nq %d" % (_gmx_exe, ion_name, ion_charge)
497547
)
498548
if num_ions > 0:
499549
command += " -nn %d" % num_ions
550+
# Override the default Na+ counter-ion if requested.
551+
if counter_ion_name is not None:
552+
command += " -pname %s -pq %d" % (counter_ion_name, counter_ion_charge)
500553

501554
if is_neutral:
502555
command += " -neutral"

src/BioSimSpace/Solvent/_ions.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,8 @@
4040
"rubidium": ("RB", 1),
4141
"cs": ("CS", 1),
4242
"cesium": ("CS", 1),
43-
"f": ("F", -1),
44-
"fluoride": ("F", -1),
4543
"cl": ("CL", -1),
4644
"chloride": ("CL", -1),
47-
"br": ("BR", -1),
48-
"bromide": ("BR", -1),
4945
"mg": ("MG", 2),
5046
"magnesium": ("MG", 2),
5147
"ca": ("CA", 2),
@@ -78,6 +74,7 @@ def addIons(
7874
ion_conc=0,
7975
is_neutral=True,
8076
preserved_waters=None,
77+
counter_ion=None,
8178
work_dir=None,
8279
property_map={},
8380
):
@@ -128,6 +125,15 @@ def addIons(
128125
that genion cannot select them for replacement. Useful for
129126
protecting crystallographic or binding-site water molecules.
130127
128+
counter_ion : str
129+
The name of the ion to use for the opposite charge slot in the
130+
genion call. By default genion uses Na\\ :sup:`+` as the counter
131+
cation and Cl\\ :sup:`-` as the counter anion. Use this parameter
132+
to override the counter-ion species, e.g. ``"br"`` when adding a
133+
cation. The counter-ion must have the opposite charge sign to
134+
``ion``. Requires ``is_neutral=True`` (otherwise no counter-ions
135+
are added and the parameter has no effect).
136+
131137
work_dir : str
132138
The working directory for the process.
133139
@@ -235,6 +241,35 @@ def addIons(
235241
"'preserved_waters' items must be int indices or Molecule objects."
236242
)
237243

244+
# Validate and resolve counter_ion.
245+
counter_ion_name = None
246+
counter_ion_charge = None
247+
if counter_ion is not None:
248+
if not isinstance(counter_ion, str):
249+
raise TypeError("'counter_ion' must be of type 'str'")
250+
counter_key = counter_ion.strip().lower()
251+
if counter_key not in _ion_map:
252+
raise ValueError(
253+
f"Unsupported counter_ion '{counter_ion}'. Supported ions are: {ions()}"
254+
)
255+
counter_ion_name, counter_ion_charge = _ion_map[counter_key]
256+
# The counter-ion must have the opposite charge sign to the requested ion.
257+
if ion_charge > 0 and counter_ion_charge >= 0:
258+
raise ValueError(
259+
f"'counter_ion' must be negatively charged when 'ion' is positively "
260+
f"charged. '{counter_ion}' has charge {counter_ion_charge:+d}."
261+
)
262+
if ion_charge < 0 and counter_ion_charge <= 0:
263+
raise ValueError(
264+
f"'counter_ion' must be positively charged when 'ion' is negatively "
265+
f"charged. '{counter_ion}' has charge {counter_ion_charge:+d}."
266+
)
267+
if not is_neutral:
268+
raise ValueError(
269+
"'counter_ion' has no effect when 'is_neutral=False'. Set "
270+
"'is_neutral=True' to enable counter-ion addition."
271+
)
272+
238273
if work_dir is not None and not isinstance(work_dir, str):
239274
raise TypeError("'work_dir' must be of type 'str'")
240275

@@ -280,6 +315,8 @@ def addIons(
280315
num_ions,
281316
is_neutral,
282317
preserved_mols,
318+
counter_ion_name,
319+
counter_ion_charge,
283320
work_dir,
284321
property_map,
285322
)
@@ -292,6 +329,8 @@ def _add_ions(
292329
num_ions,
293330
is_neutral,
294331
preserved_mols=None,
332+
counter_ion_name=None,
333+
counter_ion_charge=None,
295334
work_dir=None,
296335
property_map={},
297336
):
@@ -322,6 +361,13 @@ def _add_ions(
322361
between the non-water solute and the new water+ions in the result,
323362
mirroring the ordering used by ``_solvate`` for crystal waters.
324363
364+
counter_ion_name : str
365+
The GROMACS residue name of the counter-ion (e.g. ``"BR"``), or
366+
``None`` to use genion's default (NA/CL).
367+
368+
counter_ion_charge : int
369+
The integer charge of the counter-ion, or ``None``.
370+
325371
work_dir : str
326372
The working directory for the process.
327373
@@ -481,22 +527,29 @@ def _add_ions(
481527
# Build the genion command.
482528
# genion supports one positive ion type (-pname/-pq/-np) and one
483529
# negative ion type (-nname/-nq/-nn). We use the appropriate slot
484-
# for the requested ion. When is_neutral=True, genion adds the
485-
# default NA/CL as counter-ions to bring the total charge to zero.
530+
# for the requested ion. When is_neutral=True, genion adds counter-ions
531+
# to bring the total charge to zero; counter_ion_name overrides the
532+
# default counter-ion species (NA for cations, CL for anions).
486533
if ion_charge > 0:
487534
command = (
488535
"%s genion -s ions.tpr -o ions_out.gro -p system.top"
489536
" -pname %s -pq %d" % (_gmx_exe, ion_name, ion_charge)
490537
)
491538
if num_ions > 0:
492539
command += " -np %d" % num_ions
540+
# Override the default Cl- counter-ion if requested.
541+
if counter_ion_name is not None:
542+
command += " -nname %s -nq %d" % (counter_ion_name, counter_ion_charge)
493543
else:
494544
command = (
495545
"%s genion -s ions.tpr -o ions_out.gro -p system.top"
496546
" -nname %s -nq %d" % (_gmx_exe, ion_name, ion_charge)
497547
)
498548
if num_ions > 0:
499549
command += " -nn %d" % num_ions
550+
# Override the default Na+ counter-ion if requested.
551+
if counter_ion_name is not None:
552+
command += " -pname %s -pq %d" % (counter_ion_name, counter_ion_charge)
500553

501554
if is_neutral:
502555
command += " -neutral"

tests/Sandpit/Exscientia/Solvent/test_ions.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_ions():
3838
assert isinstance(ion_list, list)
3939
assert len(ion_list) > 0
4040
# Check a representative selection of expected ions are present.
41-
for ion in ["br", "ca", "cl", "cs", "f", "k", "li", "mg", "na", "rb", "zn"]:
41+
for ion in ["ca", "cl", "cs", "k", "li", "mg", "na", "rb", "zn"]:
4242
assert ion in ion_list
4343

4444

@@ -163,3 +163,39 @@ def test_add_ions_with_existing_ions(solvated_system_with_ions):
163163

164164
assert num_na_after == num_na_before
165165
assert num_cl_after == num_cl_before
166+
167+
168+
@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed")
169+
def test_add_ions_counter_ion(solvated_system):
170+
"""
171+
Test that counter_ion overrides the default Na+ counter-ion.
172+
173+
Adding CL- with counter_ion="k" should result in K+ ions being added
174+
for neutralisation instead of the default NA+.
175+
"""
176+
num_ions = 2
177+
178+
result = BSS.Solvent.addIons(
179+
solvated_system, "cl", num_ions=num_ions, is_neutral=True, counter_ion="k"
180+
)
181+
182+
# Check that CL ions were added.
183+
try:
184+
cl_molecules = result.search("resname CL").molecules()
185+
except Exception:
186+
pytest.fail("No CL ions found in the result system.")
187+
assert len(cl_molecules) == num_ions
188+
189+
# K+ counter-ions should be present (2 Cl- → -2 charge → 2 K+).
190+
try:
191+
k_molecules = result.search("resname K").molecules()
192+
except Exception:
193+
pytest.fail("No K counter-ions found in the result system.")
194+
assert len(k_molecules) == num_ions
195+
196+
# The default NA counter-ion should NOT be present.
197+
try:
198+
na_count = len(result.search("resname NA").molecules())
199+
except Exception:
200+
na_count = 0
201+
assert na_count == 0

tests/Solvent/test_ions.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_ions():
3838
assert isinstance(ion_list, list)
3939
assert len(ion_list) > 0
4040
# Check a representative selection of expected ions are present.
41-
for ion in ["br", "ca", "cl", "cs", "f", "k", "li", "mg", "na", "rb", "zn"]:
41+
for ion in ["ca", "cl", "cs", "k", "li", "mg", "na", "rb", "zn"]:
4242
assert ion in ion_list
4343

4444

@@ -163,3 +163,39 @@ def test_add_ions_with_existing_ions(solvated_system_with_ions):
163163

164164
assert num_na_after == num_na_before
165165
assert num_cl_after == num_cl_before
166+
167+
168+
@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed")
169+
def test_add_ions_counter_ion(solvated_system):
170+
"""
171+
Test that counter_ion overrides the default Na+ counter-ion.
172+
173+
Adding CL- with counter_ion="k" should result in K+ ions being added
174+
for neutralisation instead of the default NA+.
175+
"""
176+
num_ions = 2
177+
178+
result = BSS.Solvent.addIons(
179+
solvated_system, "cl", num_ions=num_ions, is_neutral=True, counter_ion="k"
180+
)
181+
182+
# Check that CL ions were added.
183+
try:
184+
cl_molecules = result.search("resname CL").molecules()
185+
except Exception:
186+
pytest.fail("No CL ions found in the result system.")
187+
assert len(cl_molecules) == num_ions
188+
189+
# K+ counter-ions should be present (2 Cl- → -2 charge → 2 K+).
190+
try:
191+
k_molecules = result.search("resname K").molecules()
192+
except Exception:
193+
pytest.fail("No K counter-ions found in the result system.")
194+
assert len(k_molecules) == num_ions
195+
196+
# The default NA counter-ion should NOT be present.
197+
try:
198+
na_count = len(result.search("resname NA").molecules())
199+
except Exception:
200+
na_count = 0
201+
assert na_count == 0

0 commit comments

Comments
 (0)