Allow `Topology.set_positions(None)`
mattwthompson opened this issue · 3 comments
Is your feature request related to a problem? Please describe.
I'm dealing with a use case in which I want to modify a topology's positions but reset them to what they were before. The getter and setter introduced in #1368 makes this clean and first-class except for the (common, I'd argue) case in which there aren't originally positions at all.
Here is an example of how this fails and what it takes to work around it:
import numpy
from openff.toolkit import Molecule, Quantity, unit
from openff.toolkit.utils.exceptions import IncompatibleUnitError
def temporarily_modify(topology, new_positions):
"""Modify a topology's positions, but then reset them."""
original_positions = topology.get_positions()
topology.set_positions(new_positions)
print(f"Modified positions mean: {numpy.mean(topology.get_positions().m)}")
topology.set_positions(original_positions)
print(f"Reset positions mean: {numpy.mean(topology.get_positions().m)}")
topology = Molecule.from_smiles("CCO").to_topology()
ones = Quantity(
numpy.ones(shape=(topology.n_atoms, 3)),
unit.nanometer,
)
try:
temporarily_modify(topology, 3 * ones)
except IncompatibleUnitError as error:
# Fails because the original positions (None) can't be set
print("Can't reset positions: " + str(error))
# Prints
# Can't reset positions: array should be an OpenFF Quantity with dimensions of length
# Can work around this by only calling it on a topology with positions
molecule = Molecule.from_smiles("CCO")
molecule._conformers = [0 * ones]
topology_with_positions = molecule.to_topology()
# Works; prints 4.0 and 0.0
temporarily_modify(topology_with_positions, 4.0 * ones)
# or can work around by special-casing downstream, which is not desirable
def workaround(topology, new_positions):
"""Modify a topology's positions, but then reset them."""
original_positions = topology.get_positions()
topology.set_positions(new_positions)
print(f"Modified positions mean: {numpy.mean(topology.get_positions().m)}")
if original_positions is None:
for molecule in topology.molecules:
molecule._conformers = None
print(f"Reset positions mean: {topology.get_positions()}")
else:
topology.set_positions(original_positions)
print(f"Reset positions mean: {numpy.mean(topology.get_positions().m)}")
topology = Molecule.from_smiles("CCO").to_topology()
# Works; prints 5.0 and 0.0
workaround(topology, 5.0 * ones)
Running it gives the output:
$ python modify.py
Modified positions mean: 3.0
Can't reset positions: array should be an OpenFF Quantity with dimensions of length
Modified positions mean: 4.0
Reset positions mean: 0.0
Modified positions mean: 5.0
Reset positions mean: None
Describe the solution you'd like
Just let array=None
, which is processed internally along the lines of [molecule._conformers = None for molecule in self.molecules]
.
Describe alternatives you've considered
See above and below.
Additional context
I think it's reasonable for users to expect to be able to clear positions. Molecules
allow positions to be "un-set" via removing conformers and Interchange.positions
allows None
to be passed. I'm an advocate against polymorphic inputs but I think this is a case in which it's intended. To keep the API cleaner, something line Topology.clear_positions
could be introduced that does the same thing but ensures the array
argument cannot be None
. I'm happy to implement either solution.
Note also that get_positions
allows None
as an output:
In [1]: from openff.toolkit import Molecule
In [2]: assert Molecule.from_smiles("CCO").to_topology().get_positions() is None
# no error
Thanks for the writeup. I'm in favor of allowing Topology.set_positions(None)
. Optionally Topology.clear_positions()
would be fine in addition. Happy to take a PR for these :-)
Awesome, I've opened #1827 which does this as simply as I can think of
For my own memory, this stems from https://github.com/openforcefield/openff-interchange/pull/883/files#diff-a3087103ffe505eaa0ffb73f557bffdae70a0862de3d0b802a8207167c09814fR328-R334
I decided for Topology.clear_positions()
in #1827, which has the benefit of being less ambiguous about input types and the debatable downside of adding a new method. Since I'm the person who suggested Topology.set_positions(None)
I'm also the person who can take it back