Custom Updater#

Overview#

Questions#

  • How can I modify the state of a system in a custom updater?

Objectives#

  • Show an example of a non-trival custom updater.

Boilerplate Code#

[1]:
import numbers

import hoomd
import numpy

cpu = hoomd.device.CPU()
simulation = hoomd.Simulation(device=cpu, seed=1)

# Create a simple cubic configuration of particles
N = 5  # particles per box direction
box_L = 20  # box dimension

snap = hoomd.Snapshot(cpu.communicator)
snap.configuration.box = [box_L] * 3 + [0, 0, 0]
snap.particles.N = N**3
x, y, z = numpy.meshgrid(
    *(numpy.linspace(-box_L / 2, box_L / 2, N, endpoint=False),) * 3
)
positions = numpy.array((x.ravel(), y.ravel(), z.ravel())).T
snap.particles.position[:] = positions
snap.particles.types = ['A']
snap.particles.typeid[:] = 0

simulation.create_state_from_snapshot(snap)
rng = numpy.random.default_rng(1245)

Problem#

In this section, we will show how to create a custom updater that modifies the system state. To show this, we will create a custom updater that adds a prescribed amount of energy to a single particle simulating the bombardment of radioactive material into our system. For this problem, we pick a random particle and modify its velocity according to the radiation energy in a random direction.

[2]:
class InsertEnergyUpdater(hoomd.custom.Action):
    def __init__(self, energy):
        self.energy = energy

    def act(self, timestep):
        snap = self._state.get_snapshot()
        if snap.communicator.rank == 0:
            particle_i = rng.integers(snap.particles.N)
            mass = snap.particles.mass[particle_i]
            direction = self._get_direction()
            magnitude = numpy.sqrt(2 * self.energy / mass)
            velocity = direction * magnitude
            old_velocity = snap.particles.velocity[particle_i]
            new_velocity = old_velocity + velocity
            snap.particles.velocity[particle_i] = new_velocity
        self._state.set_snapshot(snap)

    @staticmethod
    def _get_direction():
        theta, z = rng.random(2)
        theta *= 2 * numpy.pi
        z = 2 * (z - 0.5)
        return numpy.array(
            [
                numpy.sqrt(1 - (z * z)) * numpy.cos(theta),
                numpy.sqrt(1 - (z * z)) * numpy.sin(theta),
                z,
            ]
        )

We will now use our custom updater with an NVE integrator. Particles will interact via a Lennard-Jones potential. Using the Table writer and a hoomd.logging.Logger, we will monitor the energy, which should be increasing as we are adding energy to the system. We will also thermalize our system to a kT == 1.

[3]:
simulation.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=1.0)

lj = hoomd.md.pair.LJ(nlist=hoomd.md.nlist.Cell(buffer=0.4))
lj.params[('A', 'A')] = {'epsilon': 1.0, 'sigma': 1.0}
lj.r_cut[('A', 'A')] = 2.5
integrator = hoomd.md.Integrator(
    methods=[hoomd.md.methods.ConstantVolume(hoomd.filter.All())], forces=[lj], dt=0.005
)

thermo = hoomd.md.compute.ThermodynamicQuantities(hoomd.filter.All())
logger = hoomd.logging.Logger(categories=['scalar'])
logger.add(thermo, ['kinetic_energy', 'potential_energy'])
logger['total_energy'] = (
    lambda: thermo.kinetic_energy + thermo.potential_energy,
    'scalar',
)

table = hoomd.write.Table(100, logger, max_header_len=1)

simulation.operations += integrator
simulation.operations += thermo
simulation.operations += table

# Create and add our custom updater
energy_operation = hoomd.update.CustomUpdater(
    action=InsertEnergyUpdater(10.0), trigger=100
)

simulation.operations += energy_operation
[4]:
simulation.run(1000)
 kinetic_energy  potential_energy   total_energy
   187.44685         -0.13298        187.31387
   195.16975         -5.51636        189.65338
   209.79330         -7.55467        202.23864
   219.90712         -8.52161        211.38551
   229.98365        -10.48876        219.49489
   239.84999         -8.49731        231.35268
   246.07432        -10.46920        235.60512
   246.31498        -10.08009        236.23489
   253.43790         -6.76563        246.67227
   269.87772         -9.59299        260.28473

As we can see the total energy of the system is indeed increasing. The energy isn’t increasing by 10 every time since we are adding the velocity in a random direction which may be against the current velocity.

Improving upon our Custom Action#

Maybe we want to allow for the energy to be from a distribution. HOOMD-blue has a concept called a variant which allows for quantities that vary over time. Let’s change the InsertEnergyupdater to use variants and create a custom variant that grabs a random number from a Gaussian distribution. (If you don’t understand the variant code, that is fine. We are just using this to showcase how you can iteratively improve custom actions).

Note:

Variant objects model a parameter as a function of the timestep, so to get the value for a particular timestep we have to call the variant. For more information see the documentation for hoomd.variant.

[5]:
class InsertEnergyUpdater(hoomd.custom.Action):
    def __init__(self, energy):
        self._energy = energy

    @property
    def energy(self):
        """A `hoomd.variant.Variant` object."""
        return self._energy

    @energy.setter
    def energy(self, new_energy):
        if isinstance(new_energy, numbers.Number):
            self._energy = hoomd.variant.Constant(new_energy)
        elif isinstance(new_energy, hoomd.variant.Variant):
            self._energy = new_energy
        else:
            message = 'energy must be a variant or real number.'
            raise ValueError(message)

    def act(self, timestep):
        snap = self._state.get_snapshot()
        if snap.communicator.rank == 0:
            particle_i = rng.integers(snap.particles.N)
            mass = snap.particles.mass[particle_i]
            direction = self._get_direction()
            magnitude = numpy.sqrt(2 * self.energy(timestep) / mass)
            velocity = direction * magnitude
            old_velocity = snap.particles.velocity[particle_i]
            new_velocity = old_velocity + velocity
            snap.particles.velocity[particle_i] = new_velocity
        self._state.set_snapshot(snap)

    @staticmethod
    def _get_direction():
        theta, z = rng.random(2)
        theta *= 2 * numpy.pi
        z = 2 * (z - 0.5)
        return numpy.array(
            [
                numpy.sqrt(1 - (z * z)) * numpy.cos(theta),
                numpy.sqrt(1 - (z * z)) * numpy.sin(theta),
                z,
            ]
        )


class GaussianVariant(hoomd.variant.Variant):
    def __init__(self, mean, std):
        hoomd.variant.Variant.__init__(self)
        self.mean = mean
        self.std = std

    def __call__(self, timestep):
        return rng.normal(self.mean, self.std)

We briefly show that the Gaussian Variant works.

[6]:
energy = GaussianVariant(mean=10.0, std=2.0)
sample_energies = numpy.array([energy(0) for _ in range(1000)])
f'Mean: {sample_energies.mean()}, std. dev. {sample_energies.std()}'
[6]:
'Mean: 10.069550459202723, std. dev. 1.9965744919420398'

We now use the updated InsertEnergyUpdater in the simulation.

[7]:
simulation.operations.updaters.remove(energy_operation)
# Create and add our custom updater
energy_operation = hoomd.update.CustomUpdater(
    action=InsertEnergyUpdater(energy), trigger=100
)
simulation.operations.updaters.append(energy_operation)
simulation.run(1000)
   285.96876        -10.45450        275.51427
   303.84204         -8.40413        295.43791
   320.12541         -6.86564        313.25977
   333.13502        -12.74600        320.38903
   346.94076        -13.49587        333.44489
   359.78511        -13.30828        346.47683
   363.05476         -5.18245        357.87231
   385.26368         -9.41100        375.85268
   384.85984         -2.13054        382.72930
   400.94032         -9.80655        391.13377

We could continue to improve upon this updater and the execution of this operation. However, this suffices to showcase the ability of non-trivial updaters to affect the simulation state.