Adaptive quantum thermal bath (openmm/openmm#4995)

I'm implementing the adaptive quantum thermal bath (adQTB) algorithm.  This is a fast method of simulating nuclear quantum effects.  I've written the reference implementation so far.

Implements #4225.

Adaptive quantum thermal bath

The Adaptive Quantum Thermal Bath (adQTB) algorithm is not currently available in the software, limiting the ability to efficiently simulate nuclear quantum effects in molecular systems. This creates a gap for users who require a fast and accurate method to approximate these effects, particularly in systems where quantum behavior significantly impacts the dynamics.

The expected behavior is that the software provides an integrator implementing the adQTB algorithm. This integrator should enable simulations that approximate nuclear quantum effects by applying a frequency-dependent Langevin thermostat, with automatic adjustments to prevent zero-point energy leakage. Users should be able to utilize this feature seamlessly within the existing framework to perform simulations with near-classical computational efficiency while capturing quantum effects.

## IMPORTANT INSTRUCTIONS

1. If you edit a python file, you MUST run 'make PythonInstall' inside the build directory
2. If you edit a c++ file, you MUST run 'make -j4 && make install" inside the build directory

diff --git a/docs-source/api-c++/integrators.rst b/docs-source/api-c++/integrators.rst
index ba5701dc41..23692a8a1b 100644
--- a/docs-source/api-c++/integrators.rst
+++ b/docs-source/api-c++/integrators.rst
@@ -57,6 +57,17 @@ The RPMD integrator implements Ring Polymer MD.
     generated/RPMDIntegrator

+Quantum Thermal Bath integrators
+================================
+
+The QTB integrator implements the adaptive quantum thermal bath (adQTB) method.
+
+.. toctree::
+    :maxdepth: 2
+
+    generated/QTBIntegrator
+
+
 Dissipative Particle Dynamics integrators
 =========================================

diff --git a/docs-source/usersguide/references.bib b/docs-source/usersguide/references.bib
index c3275b26c6..24130b299f 100644
--- a/docs-source/usersguide/references.bib
+++ b/docs-source/usersguide/references.bib
@@ -346,6 +346,17 @@ @article{Maier2015
    type = {Journal Article}
 }

+@article{Mangaud2019
+    author = {Mangaud, Etienne and Huppert, Simon and Pl{\'e}, Thomas and Depondt, Philippe and Bonella, Sara and Finocchi, Fabio},
+    title = {The Fluctuation–Dissipation Theorem as a Diagnosis and Cure for Zero-Point Energy Leakage in Quantum Thermal Bath Simulations},
+    journal = {Journal of Chemical Theory and Computation},
+    volume = {15},
+    number = {5},
+    pages = {2863-2880},
+    year = {2019},
+    doi = {10.1021/acs.jctc.8b01164},
+}
+
 @article{Marinari1992,
     doi = {10.1209/0295-5075/19/6/002},
     year = 1992,
@@ -378,6 +389,17 @@ @article{Markland2008
    type = {Journal Article}
 }

+@article{Mauger2021
+    author = {Mauger, Nastasia and Pl{\'e}, Thomas and Lagardère, Louis and Bonella, Sara and Mangaud, {\'E}tienne and Piquemal, Jean-Philip and Huppert, Simon},
+    title = {Nuclear Quantum Effects in Liquid Water at Near Classical Computational Cost Using the Adaptive Quantum Thermal Bath},
+    journal = {The Journal of Physical Chemistry Letters},
+    volume = {12},
+    number = {34},
+    pages = {8285-8291},
+    year = {2021},
+    doi = {10.1021/acs.jpclett.1c01722},
+}
+
 @article{Mongan2007
    author = {Mongan, John and Simmerling, Carlos and McCammon, J. Andrew and Case, David A. and Onufriev, Alexey},
    title = {Generalized {Born} model with a simple, robust molecular volume correction},
diff --git a/docs-source/usersguide/theory/04_integrators.rst b/docs-source/usersguide/theory/04_integrators.rst
index 94ac6a7694..e3dfaad365 100644
--- a/docs-source/usersguide/theory/04_integrators.rst
+++ b/docs-source/usersguide/theory/04_integrators.rst
@@ -294,3 +294,102 @@ on the types of the particles.

 The integration is done using the same LFMiddle discretization used for
 LangevinIntegrator. :cite:`Zhang2019`
+
+QTBIntegrator
+*************
+
+This integrator implements the Adaptive Quantum Thermal Bath (adQTB) algorithm.
+:cite:`Mangaud2019`  This is a fast method for approximating nuclear quantum
+effects by applying a Langevin thermostat whose random force varies with
+frequency to match the expected energy of a quantum harmonic oscillator.
+
+It integrates the Langevin equation
+
+.. math::
+   m_i\frac{d\mathbf{v}_i}{dt}=\mathbf{f}_i-\gamma_f m_i \mathbf{v}_i+\mathbf{R}_i
+
+Unlike LangevinIntegrator, for which the random noise force :math:`\mathbf{R}_i`
+is uncorrelated white noise, in this case its cross correlation function is given
+by
+
+.. math::
+   C(\omega) = 2 m_i \gamma_r \theta(\omega, T)
+
+where
+
+.. math::
+   \theta(\omega, T) = \hbar \omega \left( \frac{1}{2} + \frac{1}{e^{\hbar \omega / k_B T} - 1} \right)
+
+In the limit of high temperature, :math:`\theta(\omega, T) \approx k_B T`,
+reproducing the classical behavior.  In the limit of low temperature,
+:math:`\theta(\omega, T) \approx \hbar \omega / 2`, which is the ground state
+energy of a quantum harmonic oscillator with frequency :math:`\omega`.
+
+A problem that can arise with this method is zero-point energy leakage.  The
+thermostat drives the system toward the desired quantum energy distribution, but
+the classical dynamics of the system causes it to continuously relax toward the
+classical distribution.  The result is too much energy in the low frequency modes
+and too little energy in the high frequency modes.  A solution is to allow the
+two friction coefficients to differ.  The coefficient :math:`\gamma_f` appearing
+in the friction term of the Langevin equation is fixed as a constant.  The
+coefficient :math:`\gamma_r` that determines the magnitude of the random force
+becomes frequency dependent, :math:`\gamma_r(\omega)`.  Its spectrum is dynamically
+adjusted to produce the correct distribution of energy between modes.
+
+In practice, the simulation is divided into short segments, typically on the
+order of 1000 time steps.  At the end of each segment, we calculate the velocity
+autocorrelation function :math:`C_{vv}(\omega)` of each degree of freedom, as
+well as the correlation :math:`C_{vR}(\omega)` between the velocity and random
+force :math:`\mathbf{R}`.  From these we can calculate the amount by which the
+fluctuation-dissipation theorem is violated at each frequency:
+
+.. math::
+   \Delta_{FDT}(\omega) = \mathrm{Re}[C_{vR}(\omega)] - m \gamma_r(\omega) C_{vv}(\omega)
+
+We then select new friction coefficients according to
+
+.. math::
+   \gamma_r^{k+1}(\omega) = \gamma_r^k(\omega) - A \Delta_{FDT}^k(\omega)
+
+where :math:`k` is the index of the segment and the adaptation rate :math:`A`
+may vary between degrees of freedom.  Random noise for the next segment is
+generated based on the new spectrum, and the simulation continues.  The value of
+:math:`A` generally needs to be determined by trial and error to find a value
+that produces fast adaptation and good convergence.
+
+This process is much more robust if data is pooled over all degrees of freedom
+that are expected to be equivalent.  One specifies a type index for each
+particle.  :math:`\Delta_{FDT}(\omega)` is computed as the average over all
+three coordinates of all particles with the same type.  The more particles that
+are averaged over, the larger :math:`A` can be, leading to faster adaptation.
+
+When running simulations with adQTB, it is critical to equilibrate the system
+long enough for :math:`\gamma_r(\omega)` to converge.  Until it does, the
+simulation does not explore the correct distribution of states.
+
+Simulations with adQTB tend to require a larger friction coefficient
+:math:`\gamma_f` than is usual in fully classical simulations, typically 10
+ps\ :sup:`-1` or more.  If the friction is too low, it is impossible to fully
+compensate for zero-point energy leakage.  Even reducing the random force to
+zero may still leave too much energy in low frequency modes.  It is important
+to check the converged spectrum of :math:`\gamma_r(\omega)`.  If it is close to
+zero at some frequencies, that suggests :math:`\gamma_f` needs to be increased.
+
+A possible consequence of the large friction coefficient is unwanted broadening
+of spectral peaks, leading to a less accurate energy spectrum.  This is
+compensated through a deconvolution procedure. :cite:`Mauger2021`  The target
+spectrum :math:`\theta(\omega, T)` is replaced with a deconvolved version
+:math:`\tilde{\theta}(\omega, T)` related to it by
+
+.. math::
+   \frac{\theta(\omega_0, T)}{2} = \int \frac{d\omega}{\pi} \frac{\gamma \omega_0^2}{(\omega^2-\omega_0^2)^2 + \gamma^2\omega^2} \tilde{\theta}(\omega, T)
+
+An iterative procedure is used to solve for :math:`\tilde{\theta}(\omega, T)`,
+which is then used in place of :math:`\theta(\omega, T)` when computing the
+random force.
+
+One must be very careful when trying to compute velocity dependent thermodynamic
+quantities, such as the instantaneous temperature or instantaneous pressure.
+The standard calculations for these quantities assume the velocities follow a
+classical distribution.  They do not produce correct results for an adQTB
+simulation, in which the velocities follow a quantum distribution.
diff --git a/olla/include/openmm/kernels.h b/olla/include/openmm/kernels.h
index 9b9a5a7656..bae1996a4d 100644
--- a/olla/include/openmm/kernels.h
+++ b/olla/include/openmm/kernels.h
@@ -57,6 +57,7 @@
 #include "openmm/KernelImpl.h"
 #include "openmm/MonteCarloBarostat.h"
 #include "openmm/PeriodicTorsionForce.h"
+#include "openmm/QTBIntegrator.h"
 #include "openmm/RBTorsionForce.h"
 #include "openmm/RMSDForce.h"
 #include "openmm/NonbondedForce.h"
@@ -1435,6 +1436,72 @@ class IntegrateDPDStepKernel : public KernelImpl {
     virtual double computeKineticEnergy(ContextImpl& context, const DPDIntegrator& integrator) = 0;
 };

+/**
+ * This kernel is invoked by QTBIntegrator to take one time step.
+ */
+class IntegrateQTBStepKernel : public KernelImpl {
+public:
+    static std::string Name() {
+        return "IntegrateQTBStep";
+    }
+    IntegrateQTBStepKernel(std::string name, const Platform& platform) : KernelImpl(name, platform) {
+    }
+    /**
+     * Initialize the kernel.
+     *
+     * @param system     the System this kernel will be applied to
+     * @param integrator the QTBIntegrator this kernel will be used for
+     */
+    virtual void initialize(const System& system, const QTBIntegrator& integrator) = 0;
+    /**
+     * Execute the kernel.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param integrator the QTBIntegrator this kernel is being used for
+     */
+    virtual void execute(ContextImpl& context, const QTBIntegrator& integrator) = 0;
+    /**
+     * Compute the kinetic energy.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param integrator the QTBIntegrator this kernel is being used for
+     */
+    virtual double computeKineticEnergy(ContextImpl& context, const QTBIntegrator& integrator) = 0;
+    /**
+     * Get the adapted friction coefficients for a particle.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param particle   the index of the particle for which to get the friction
+     * @param friction   the adapted friction coefficients used in generating the
+     *                   random force
+     */
+    virtual void getAdaptedFriction(ContextImpl& context, int particle, std::vector<double>& friction) const = 0;
+    /**
+     * Set the adapted friction coefficients for a particle.  This affects the
+     * specified particle, and all others that have the same type.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param particle   the index of the particle for which to get the friction
+     * @param friction   the adapted friction coefficients used in generating the
+     *                   random force
+     */
+    virtual void setAdaptedFriction(ContextImpl& context, int particle, const std::vector<double>& friction) = 0;
+    /**
+     * Write the adapted friction to a checkpoint.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param stream     the stream to write the checkpoint to
+     */
+    virtual void createCheckpoint(ContextImpl& context, std::ostream& stream) const = 0;
+    /**
+     * Load the adapted friction from a checkpoint.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param stream     the stream to read the checkpoint from
+     */
+    virtual void loadCheckpoint(ContextImpl& context, std::istream& stream) = 0;
+};
+
 /**
  * This kernel is invoked by AndersenThermostat at the start of each time step to adjust the particle velocities.
  */
diff --git a/openmmapi/include/OpenMM.h b/openmmapi/include/OpenMM.h
index 1c4f7db991..df064cbc28 100644
--- a/openmmapi/include/OpenMM.h
+++ b/openmmapi/include/OpenMM.h
@@ -68,6 +68,7 @@
 #include "openmm/Context.h"
 #include "openmm/OpenMMException.h"
 #include "openmm/PeriodicTorsionForce.h"
+#include "openmm/QTBIntegrator.h"
 #include "openmm/RBTorsionForce.h"
 #include "openmm/RMSDForce.h"
 #include "openmm/State.h"
diff --git a/openmmapi/include/openmm/QTBIntegrator.h b/openmmapi/include/openmm/QTBIntegrator.h
new file mode 100644
index 0000000000..93065ad4a8
--- /dev/null
+++ b/openmmapi/include/openmm/QTBIntegrator.h
@@ -0,0 +1,283 @@
+#ifndef OPENMM_QTBINTEGRATOR_H_
+#define OPENMM_QTBINTEGRATOR_H_
+
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "Integrator.h"
+#include "openmm/Kernel.h"
+#include "internal/windowsExport.h"
+
+namespace OpenMM {
+
+/**
+ * This integrator implements the Adaptive Quantum Thermal Bath (adQTB) algorithm
+ * as described in https://doi.org/10.1021/acs.jctc.8b01164.  This is a fast method
+ * for approximating nuclear quantum effects by applying a Langevin thermostat whose
+ * random force varies with frequency to match the expected energy of a quantum
+ * harmonic oscillator.
+ *
+ * To compensate for zero point energy leakage, the spectrum of the random force
+ * is adjusted automatically.  The trajectory is divided into short segments.  At
+ * the end of each segment, the distribution of energy across frequencies is evaluated,
+ * and the friction coefficients used in generating the random force are adjusted
+ * to better match the target distribution.  You can monitor this process by calling
+ * getAdaptedFriction().  It is important to equilibrate the simulation long enough
+ * for the friction coefficients to converge before beginning the production part of
+ * the simulation.
+ *
+ * To make this process more robust, it is recommended to average the data over all
+ * particles that are expected to behave identically.  To do this, you can optionally
+ * call setParticleType() to define a particle as having a particular type.  The
+ * data for all particles of the same type is averaged and they are adjusted together.
+ * You also can set a different adaptation rate for each particle type.  The more
+ * particles that are being averaged over, the higher the adaptation rate can reasonably
+ * be set, leading to faster adaptation.
+ *
+ * Most properties of this integrator are fixed at Context creation time and can
+ * only be changed after that by reinitializing the Context.  That includes the
+ * step size, friction coefficient, segment length, cutoff frequency, particle types,
+ * and adaptation rates.  The only property of the integrator that can be freely
+ * changed in the middle of a simulation is the temperature, and even that requires
+ * some care.  The new temperature will not take effect until the beginning of the
+ * next segment.  Furthermore, changing the temperature is a potentially expensive
+ * operation, since it requires performing a calculation whose cost scales as the
+ * cube of the number of time steps in a segment.
+ *
+ * One must be very careful when trying to compute velocity dependent thermodynamic
+ * quantities, such as the instantaneous temperature or instantaneous pressure.
+ * The standard calculations for these quantities assume the velocities follow a
+ * classical distribution.  They do not produce correct results for an adQTB
+ * simulation, in which the velocities follow a quantum distribution.
+ */
+class OPENMM_EXPORT QTBIntegrator : public Integrator {
+public:
+    /**
+     * Create a QTBIntegrator.
+     *
+     * @param temperature    the temperature of the heat bath (in Kelvin)
+     * @param frictionCoeff  the friction coefficient which couples the system to the heat bath (in inverse picoseconds)
+     * @param stepSize       the step size with which to integrate the system (in picoseconds)
+     */
+    QTBIntegrator(double temperature, double frictionCoeff, double stepSize);
+    /**
+     * Get the temperature of the heat bath (in Kelvin).
+     *
+     * @return the temperature of the heat bath, measured in Kelvin
+     */
+    double getTemperature() const {
+        return temperature;
+    }
+    /**
+     * Set the temperature of the heat bath (in Kelvin).
+     *
+     * @param temp    the temperature of the heat bath, measured in Kelvin
+     */
+    void setTemperature(double temp);
+    /**
+     * Get the friction coefficient which determines how strongly the system is coupled to
+     * the heat bath (in inverse ps).
+     *
+     * @return the friction coefficient, measured in 1/ps
+     */
+    double getFriction() const {
+        return friction;
+    }
+    /**
+     * Set the friction coefficient which determines how strongly the system is coupled to
+     * the heat bath (in inverse ps).
+     *
+     * @param coeff    the friction coefficient, measured in 1/ps
+     */
+    void setFriction(double coeff);
+    /**
+     * Get the length of each segment used for adapting the noise spectrum.
+     *
+     * @return the segment length, measured in ps
+     */
+    double getSegmentLength() const;
+    /**
+     * Set the length of each segment used for adapting the noise spectrum.
+     *
+     * @param length    the segment length, measured in ps
+     */
+    void setSegmentLength(double length);
+    /**
+     * Get the cutoff frequency applied to the colored noise.
+     *
+     * @return the cutoff frequency, measured in 1/ps
+     */
+    double getCutoffFrequency() const;
+    /**
+     * Set the cutoff frequency applied to the colored noise.
+     *
+     * @param cutoff    the cutoff frequency, measured in 1/ps
+     */
+    void setCutoffFrequency(double cutoff);
+    /**
+     * Get a map whose keys are particle indices and whose values are particle types.  This
+     * contains only the particles that have been specifically set with setParticleType().
+     */
+    const std::map<int, int>& getParticleTypes() const;
+    /**
+     * Set the type of a particle.  This is an arbitrary integer.
+     */
+    void setParticleType(int index, int type);
+    /**
+     * Get the default adaptation rate.  This is the rate used for particles whose
+     * rate has not been otherwise specified with setTypeAdaptationRate().  It
+     * determines how much the noise spectrum changes after each segment.
+     */
+    double getDefaultAdaptationRate() const;
+    /**
+     * Set the default adaptation rate.  This is the rate used for particles whose
+     * rate has not been otherwise specified with setTypeAdaptationRate().  It
+     * determines how much the noise spectrum changes after each segment.
+     */
+    void setDefaultAdaptationRate(double rate);
+    /**
+     * Get a map whose keys are particle types and whose values are adaptation rates.
+     * These are the rates used for particles whose rates have been specified with
+     * setTypeAdaptationRate().  The rate determines how much the noise spectrum
+     * changes after each segment.
+     */
+    const std::map<int, double>& getTypeAdaptationRates() const;
+    /**
+     * Set the adaptation rate to use for particles of a given type.  The rate
+     * determines how much the noise spectrum changes after each segment.
+     */
+    void setTypeAdaptationRate(int type, double rate);
+    /**
+     * Get the adapted friction coefficients for a particle.  The return value is
+     * a vector of length numFreq = (3*n+1)/2, where n is the number of time steps
+     * in a segment.  Element i is the friction coefficient used in generating
+     * the random force with frequency i*pi/(numFreq*stepSize).
+     *
+     * This method is guaranteed to return identical results for particles that
+     * have the same type.
+     *
+     * @param particle    the index of the particle for which to get the friction
+     * @param friction    the adapted friction coefficients used in generating the
+     *                    random force.
+     */
+    void getAdaptedFriction(int particle, std::vector<double>& friction) const;
+    /**
+     * Get the random number seed.  See setRandomNumberSeed() for details.
+     */
+    int getRandomNumberSeed() const {
+        return randomNumberSeed;
+    }
+    /**
+     * Set the random number seed.  The precise meaning of this parameter is undefined, and is left up
+     * to each Platform to interpret in an appropriate way.  It is guaranteed that if two simulations
+     * are run with different random number seeds, the sequence of random forces will be different.  On
+     * the other hand, no guarantees are made about the behavior of simulations that use the same seed.
+     * In particular, Platforms are permitted to use non-deterministic algorithms which produce different
+     * results on successive runs, even if those runs were initialized identically.
+     *
+     * If seed is set to 0 (which is the default value assigned), a unique seed is chosen when a Context
+     * is created from this Integrator. This is done to ensure that each Context receives unique random seeds
+     * without you needing to set them explicitly.
+     */
+    void setRandomNumberSeed(int seed) {
+        randomNumberSeed = seed;
+    }
+    /**
+     * Advance a simulation through time by taking a series of time steps.
+     *
+     * @param steps   the number of time steps to take
+     */
+    void step(int steps);
+protected:
+    /**
+     * This will be called by the Context when it is created.  It informs the Integrator
+     * of what context it will be integrating, and gives it a chance to do any necessary initialization.
+     * It will also get called again if the application calls reinitialize() on the Context.
+     */
+    void initialize(ContextImpl& context);
+    /**
+     * This will be called by the Context when it is destroyed to let the Integrator do any necessary
+     * cleanup.  It will also get called again if the application calls reinitialize() on the Context.
+     */
+    void cleanup();
+    /**
+     * Get the names of all Kernels used by this Integrator.
+     */
+    std::vector<std::string> getKernelNames();
+    /**
+     * Compute the kinetic energy of the system at the current time.
+     */
+    double computeKineticEnergy();
+    /**
+     * Computing kinetic energy for this integrator does not require forces.
+     */
+    bool kineticEnergyRequiresForce() const;
+    /**
+     * Get the time interval by which velocities are offset from positions.  This is used to
+     * adjust velocities when setVelocitiesToTemperature() is called on a Context.
+     */
+    double getVelocityTimeOffset() const {
+        return getStepSize()/2;
+    }
+    /**
+     * This is called while writing checkpoints.  It gives the integrator a chance to write
+     * its own data.
+     */
+    void createCheckpoint(std::ostream& stream) const;
+    /**
+     * This is called while loading a checkpoint.  The integrator should read in whatever
+     * data it wrote in createCheckpoint() and update its internal state accordingly.
+     */
+    void loadCheckpoint(std::istream& stream);
+    /**
+     * This is called while creating a State.  The Integrator should store the values
+     * of all time-varying parameters into the SerializationNode so they can be saved
+     * as part of the state.
+     */
+    void serializeParameters(SerializationNode& node) const;
+    /**
+     * This is called when loading a previously saved State.  The Integrator should
+     * load the values of all time-varying parameters from the SerializationNode.  If
+     * the node contains parameters that are not defined for this Integrator, it should
+     * throw an exception.
+     */
+    void deserializeParameters(const SerializationNode& node);
+private:
+    double temperature, friction, segmentLength, defaultAdaptationRate, cutoffFrequency;
+    std::map<int, int> particleType;
+    std::map<int, double> typeAdaptationRates;
+    int randomNumberSeed;
+    Kernel kernel;
+};
+
+} // namespace OpenMM
+
+#endif /*OPENMM_QTBINTEGRATOR_H_*/
diff --git a/openmmapi/include/openmm/internal/QTBIntegratorUtilities.h b/openmmapi/include/openmm/internal/QTBIntegratorUtilities.h
new file mode 100644
index 0000000000..30c68de541
--- /dev/null
+++ b/openmmapi/include/openmm/internal/QTBIntegratorUtilities.h
@@ -0,0 +1,76 @@
+#ifndef OPENMM_QTBINTEGRATORUTILITIES_H_
+#define OPENMM_QTBINTEGRATORUTILITIES_H_
+
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/QTBIntegrator.h"
+#include "openmm/System.h"
+#include "openmm/internal/ThreadPool.h"
+#include <vector>
+
+namespace OpenMM {
+
+/**
+ * This class defines a set of utility functions that are useful in implementing QTBIntegrator.
+ */
+
+class OPENMM_EXPORT QTBIntegratorUtilities {
+public:
+    /**
+     * Identify unique particle types, and record the sets of particles with the same type.
+     *
+     * @param system              the System being simulated
+     * @param integrator          the integrator defining the particle types
+     * @param particleType        on exit, element i is the type index of particle i
+     * @param typeParticles       on exit, element i contains the indices of all particles with type i
+     * @param typeMass            on exit, element i contains the mass of particles with type i
+     * @param typeAdaptationRate  on exit, element i contains the adaptation rate of particles with type i
+     */
+    static void findTypes(const System& system, const QTBIntegrator& integrator, std::vector<int>& particleType,
+            std::vector<std::vector<int> >& typeParticles, std::vector<double>& typeMass, std::vector<double>& typeAdaptationRate);
+    /**
+     * Calculate the target noise spectrum.
+     *
+     * @param temperature  the target temperature, in Kelvin
+     * @param friction     the friction coefficient in 1/ps
+     * @param dt           the integration step size, in ps
+     * @param numFreq      the number of frequency bins in the spectrum
+     * @param theta        the standard version of the spectrum is stored into this
+     * @param thetad       the deconvolved version of the spectrum is stored into this
+     * @param threads      a ThreadPool to use for parallelization
+     */
+    static void calculateSpectrum(double temperature, double friction, double dt, int numFreq, std::vector<double>& theta, std::vector<double>& thetad, ThreadPool& threads);
+};
+
+} // namespace OpenMM
+
+#endif /*OPENMM_QTBINTEGRATORUTILITIES_H_*/
diff --git a/openmmapi/src/QTBIntegrator.cpp b/openmmapi/src/QTBIntegrator.cpp
new file mode 100644
index 0000000000..10e4768ed3
--- /dev/null
+++ b/openmmapi/src/QTBIntegrator.cpp
@@ -0,0 +1,189 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/QTBIntegrator.h"
+#include "openmm/Context.h"
+#include "openmm/OpenMMException.h"
+#include "openmm/internal/ContextImpl.h"
+#include "openmm/kernels.h"
+#include <set>
+#include <string>
+
+using namespace OpenMM;
+using std::map;
+using std::set;
+using std::string;
+using std::vector;
+
+QTBIntegrator::QTBIntegrator(double temperature, double frictionCoeff, double stepSize) {
+    setTemperature(temperature);
+    setFriction(frictionCoeff);
+    setSegmentLength(1.0);
+    setCutoffFrequency(500.0);
+    setDefaultAdaptationRate(0.01);
+    setStepSize(stepSize);
+    setConstraintTolerance(1e-5);
+    setRandomNumberSeed(0);
+}
+
+void QTBIntegrator::initialize(ContextImpl& contextRef) {
+    if (owner != NULL && &contextRef.getOwner() != owner)
+        throw OpenMMException("This Integrator is already bound to a context");
+    context = &contextRef;
+    owner = &contextRef.getOwner();
+    kernel = context->getPlatform().createKernel(IntegrateQTBStepKernel::Name(), contextRef);
+    kernel.getAs<IntegrateQTBStepKernel>().initialize(contextRef.getSystem(), *this);
+}
+
+void QTBIntegrator::setTemperature(double temp) {
+    if (temp < 0)
+        throw OpenMMException("Temperature cannot be negative");
+    temperature = temp;
+}
+
+void QTBIntegrator::setFriction(double coeff) {
+    if (coeff < 0)
+        throw OpenMMException("Friction cannot be negative");
+    friction = coeff;
+}
+
+double QTBIntegrator::getSegmentLength() const {
+    return segmentLength;
+}
+
+void QTBIntegrator::setSegmentLength(double length) {
+    segmentLength = length;
+}
+
+double QTBIntegrator::getCutoffFrequency() const {
+    return cutoffFrequency;
+}
+
+void QTBIntegrator::setCutoffFrequency(double cutoff) {
+    cutoffFrequency = cutoff;
+}
+
+const map<int, int>& QTBIntegrator::getParticleTypes() const {
+    return particleType;
+}
+
+void QTBIntegrator::setParticleType(int index, int type) {
+    particleType[index] = type;
+}
+
+double QTBIntegrator::getDefaultAdaptationRate() const {
+    return defaultAdaptationRate;
+}
+
+void QTBIntegrator::setDefaultAdaptationRate(double rate) {
+    defaultAdaptationRate = rate;
+}
+
+const map<int, double>& QTBIntegrator::getTypeAdaptationRates() const {
+    return typeAdaptationRates;
+}
+
+void QTBIntegrator::setTypeAdaptationRate(int type, double rate) {
+    typeAdaptationRates[type] = rate;
+}
+
+void QTBIntegrator::getAdaptedFriction(int particle, vector<double>& friction) const {
+    kernel.getAs<IntegrateQTBStepKernel>().getAdaptedFriction(*context, particle, friction);
+}
+
+void QTBIntegrator::cleanup() {
+    kernel = Kernel();
+}
+
+vector<string> QTBIntegrator::getKernelNames() {
+    vector<std::string> names;
+    names.push_back(IntegrateQTBStepKernel::Name());
+    return names;
+}
+
+double QTBIntegrator::computeKineticEnergy() {
+    return kernel.getAs<IntegrateQTBStepKernel>().computeKineticEnergy(*context, *this);
+}
+
+bool QTBIntegrator::kineticEnergyRequiresForce() const {
+    return false;
+}
+
+void QTBIntegrator::step(int steps) {
+    if (context == NULL)
+        throw OpenMMException("This Integrator is not bound to a context!");
+    for (int i = 0; i < steps; ++i) {
+        context->updateContextState();
+        context->calcForcesAndEnergy(true, false, getIntegrationForceGroups());
+        kernel.getAs<IntegrateQTBStepKernel>().execute(*context, *this);
+    }
+}
+
+void QTBIntegrator::createCheckpoint(std::ostream& stream) const {
+    kernel.getAs<IntegrateQTBStepKernel>().createCheckpoint(*context, stream);
+}
+
+void QTBIntegrator::loadCheckpoint(std::istream& stream) {
+    kernel.getAs<IntegrateQTBStepKernel>().loadCheckpoint(*context, stream);
+}
+
+void QTBIntegrator::serializeParameters(SerializationNode& node) const {
+    node.setIntProperty("version", 1);
+    vector<int> particles;
+    set<int> types;
+    for (int i = 0; i < context->getSystem().getNumParticles(); i++) {
+        if (particleType.find(i) == particleType.end())
+            particles.push_back(i);
+        else if (types.find(particleType.at(i)) == types.end()) {
+            particles.push_back(i);
+            types.insert(particleType.at(i));
+        }
+    }
+    vector<double> friction;
+    for (int i : particles) {
+        SerializationNode& particle = node.createChildNode("Friction").setIntProperty("particle", i);
+        getAdaptedFriction(i, friction);
+        for (double f : friction)
+            particle.createChildNode("F").setDoubleProperty("v", f);
+    }
+}
+
+void QTBIntegrator::deserializeParameters(const SerializationNode& node) {
+    if (node.getIntProperty("version") != 1)
+        throw OpenMMException("Unsupported version number");
+    for (const SerializationNode& child : node.getChildren()) {
+        int particle = child.getIntProperty("particle");
+        vector<double> friction;
+        for (const SerializationNode& f : child.getChildren())
+            friction.push_back(f.getDoubleProperty("v"));
+        kernel.getAs<IntegrateQTBStepKernel>().setAdaptedFriction(*context, particle, friction);
+    }
+}
diff --git a/openmmapi/src/QTBIntegratorUtilities.cpp b/openmmapi/src/QTBIntegratorUtilities.cpp
new file mode 100644
index 0000000000..ec57b3ef85
--- /dev/null
+++ b/openmmapi/src/QTBIntegratorUtilities.cpp
@@ -0,0 +1,148 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/internal/QTBIntegratorUtilities.h"
+#include "SimTKOpenMMRealType.h"
+#include <map>
+
+using namespace OpenMM;
+using namespace std;
+
+void QTBIntegratorUtilities::findTypes(const System& system, const QTBIntegrator& integrator, vector<int>& particleType,
+        vector<std::vector<int> >& typeParticles, vector<double>& typeMass, vector<double>& typeAdaptationRate) {
+    // Record information about groups defined by particle types.
+
+    particleType.resize(system.getNumParticles());
+    map<int, int> typeIndex;
+    map<int, double> massTable;
+    const auto& types = integrator.getParticleTypes();
+    double defaultAdaptationRate = integrator.getDefaultAdaptationRate();
+    for (auto particle : types) {
+        int type = particle.second;
+        double mass = system.getParticleMass(particle.first);
+        if (typeIndex.find(type) == typeIndex.end()) {
+            int index = typeIndex.size();
+            typeIndex[type] = index;
+            double rate = defaultAdaptationRate;
+            const auto& typeRates = integrator.getTypeAdaptationRates();
+            if (typeRates.find(type) != typeRates.end())
+                rate = typeRates.at(type);
+            typeAdaptationRate.push_back(rate);
+            typeParticles.push_back(vector<int>());
+            typeMass.push_back(mass);
+            massTable[type] = mass;
+        }
+        if (mass != massTable[type])
+            throw OpenMMException("QTBIntegrator: All particles of the same type must have the same mass");
+        particleType[particle.first] = typeIndex[type];
+        typeParticles[typeIndex[type]].push_back(particle.first);
+    }
+    for (int i = 0; i < system.getNumParticles(); i++)
+        if (types.find(i) == types.end()) {
+            // This particle's type isn't set, so define a new type for it.
+            particleType[i] = typeParticles.size();
+            typeAdaptationRate.push_back(defaultAdaptationRate);
+            typeParticles.push_back({i});
+            typeMass.push_back(system.getParticleMass(i));
+        }
+}
+
+void QTBIntegratorUtilities::calculateSpectrum(double temperature, double friction, double dt, int numFreq, vector<double>& theta, vector<double>& thetad, ThreadPool& threads) {
+    // Compute the standard spectrum.
+
+    double hbar = 1.054571628e-34*AVOGADRO/(1000*1e-12);
+    double kT = BOLTZ*temperature;
+    theta.resize(numFreq);
+    theta[0] = kT;
+    for (int i = 1; i < numFreq; i++) {
+        double w = M_PI*i/(numFreq*dt);
+        theta[i] = hbar*w*(0.5+1/(exp(hbar*w/kT)-1));
+    }
+
+    // Compute the deconvolved version.  The algorithm is described in the supplementary
+    // information in https://doi.org/10.1021/acs.jpclett.1c01722.
+
+    auto C = [&](double w0, double w) {
+        double t = w*w-w0*w0;
+        return (friction/M_PI)*w0*w0/(t*t+friction*friction*w*w);
+    };
+    double dw = M_PI/(numFreq*dt);
+
+    // Normalize the kernel to reduce error at low frequencies.
+
+    vector<double> sum(numFreq, 0.0), scale(numFreq);
+    for (int i = 0; i < numFreq; i++) {
+        double wi = M_PI*(i+0.5)/(numFreq*dt);
+        for (int j = 0; j < numFreq; j++) {
+            double wj = M_PI*(j+0.5)/(numFreq*dt);
+            sum[i] += C(wi, wj)*dw;
+        }
+    }
+    for (int i = 0; i < numFreq; i++)
+        scale[i] = 0.5/sum[i];
+
+    // Compute intermediate quantities.
+
+    vector<vector<double> > D(numFreq, vector<double>(numFreq));
+    vector<double> h(numFreq);
+    vector<double> fcurrent(numFreq), fnext(numFreq);
+    for (int i = 0; i < numFreq; i++)
+        fcurrent[i] = 0.5*theta[i];
+    threads.execute([&] (ThreadPool& threads, int threadIndex) {
+        for (int i = threadIndex; i < numFreq; i += threads.getNumThreads()) {
+            double wi = M_PI*(i+0.5)/(numFreq*dt);
+            h[i] = 0.0;
+            for (int j = 0; j < numFreq; j++) {
+                double wj = M_PI*(j+0.5)/(numFreq*dt);
+                h[i] += dw*C(wj, wi)*fcurrent[j]*scale[j];
+                D[i][j] = 0.0;
+                for (int k = 0; k < numFreq; k++) {
+                    double wk = M_PI*(k+0.5)/(numFreq*dt);
+                    D[i][j] += dw*C(wk, wi)*C(wk, wj)*scale[k]*scale[k];
+                }
+            }
+        }
+    });
+    threads.waitForThreads();
+
+    // Perform the iteration.
+
+    for (int iteration = 0; iteration < 20; iteration++) {
+        for (int i = 0; i < numFreq; i++) {
+            double denom = 0.0;
+            for (int j = 0; j < numFreq; j++)
+                denom += dw*D[i][j]*fcurrent[j];
+            fnext[i] = fcurrent[i]*h[i]/denom;
+        }
+        fcurrent = fnext;
+    }
+    thetad = fnext;
+}
diff --git a/platforms/common/include/openmm/common/CommonIntegrateQTBStepKernel.h b/platforms/common/include/openmm/common/CommonIntegrateQTBStepKernel.h
new file mode 100644
index 0000000000..ce1588fbda
--- /dev/null
+++ b/platforms/common/include/openmm/common/CommonIntegrateQTBStepKernel.h
@@ -0,0 +1,113 @@
+#ifndef OPENMM_COMMONINTEGRATEQTBSTEPKERNEL_H_
+#define OPENMM_COMMONINTEGRATEQTBSTEPKERNEL_H_
+
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU Lesser General Public License as published   *
+ * by the Free Software Foundation, either version 3 of the License, or       *
+ * (at your option) any later version.                                        *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU Lesser General Public License for more details.                        *
+ *                                                                            *
+ * You should have received a copy of the GNU Lesser General Public License   *
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.      *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/common/ComputeArray.h"
+#include "openmm/common/ComputeContext.h"
+#include "openmm/Platform.h"
+#include "openmm/kernels.h"
+
+namespace OpenMM {
+
+/**
+ * This kernel is invoked by QTBIntegrator to take one time step.
+ */
+class CommonIntegrateQTBStepKernel : public IntegrateQTBStepKernel {
+public:
+    CommonIntegrateQTBStepKernel(std::string name, const Platform& platform, ComputeContext& cc) : IntegrateQTBStepKernel(name, platform), cc(cc),
+        hasInitializedKernels(false) {
+    }
+    /**
+     * Initialize the kernel.
+     *
+     * @param system     the System this kernel will be applied to
+     * @param integrator the QTBIntegrator this kernel will be used for
+     */
+    void initialize(const System& system, const QTBIntegrator& integrator);
+    /**
+     * Execute the kernel.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param integrator the QTBIntegrator this kernel is being used for
+     */
+    void execute(ContextImpl& context, const QTBIntegrator& integrator);
+    /**
+     * Compute the kinetic energy.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param integrator the QTBIntegrator this kernel is being used for
+     */
+    double computeKineticEnergy(ContextImpl& context, const QTBIntegrator& integrator);
+    /**
+     * Get the adapted friction coefficients for a particle.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param particle   the index of the particle for which to get the friction
+     * @param friction   the adapted friction coefficients used in generating the
+     *                   random force
+     */
+    void getAdaptedFriction(ContextImpl& context, int particle, std::vector<double>& friction) const;
+    /**
+     * Set the adapted friction coefficients for a particle.  This affects the
+     * specified particle, and all others that have the same type.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param particle   the index of the particle for which to get the friction
+     * @param friction   the adapted friction coefficients used in generating the
+     *                   random force
+     */
+    void setAdaptedFriction(ContextImpl& context, int particle, const std::vector<double>& friction);
+    /**
+     * Write the adapted friction to a checkpoint.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param stream     the stream to write the checkpoint to
+     */
+    void createCheckpoint(ContextImpl& context, std::ostream& stream) const;
+    /**
+     * Load the adapted friction from a checkpoint.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param stream     the stream to read the checkpoint from
+     */
+    void loadCheckpoint(ContextImpl& context, std::istream& stream);
+private:
+    std::string createFFT(int size, int inputIndex, int& outputIndex, bool forward);
+    ComputeContext& cc;
+    int segmentLength, stepIndex, numFreq, numTypes;
+    double prevTemp, dt, friction;
+    std::vector<int> particleTypeVec;
+    ComputeArray oldDelta, noise, randomForce, segmentVelocity, thetad, cutoffFunction, workspace;
+    ComputeArray particleType, typeParticleCount, typeAdaptationRate, adaptedFriction, dfdt;
+    ComputeKernel kernel1, kernel2, kernel3, noiseKernel, forceKernel, adapt1Kernel, adapt2Kernel;
+    bool hasInitializedKernels;
+};
+
+} // namespace OpenMM
+
+#endif /*OPENMM_COMMONINTEGRATEQTBSTEPKERNEL_H_*/
diff --git a/platforms/common/src/CommonIntegrateQTBStepKernel.cpp b/platforms/common/src/CommonIntegrateQTBStepKernel.cpp
new file mode 100644
index 0000000000..a444b83db9
--- /dev/null
+++ b/platforms/common/src/CommonIntegrateQTBStepKernel.cpp
@@ -0,0 +1,481 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * This program is free software: you can redistribute it and/or modify       *
+ * it under the terms of the GNU Lesser General Public License as published   *
+ * by the Free Software Foundation, either version 3 of the License, or       *
+ * (at your option) any later version.                                        *
+ *                                                                            *
+ * This program is distributed in the hope that it will be useful,            *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ * GNU Lesser General Public License for more details.                        *
+ *                                                                            *
+ * You should have received a copy of the GNU Lesser General Public License   *
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.      *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/common/CommonIntegrateQTBStepKernel.h"
+#include "openmm/common/CommonKernelUtilities.h"
+#include "openmm/common/ContextSelector.h"
+#include "openmm/Context.h"
+#include "openmm/internal/ContextImpl.h"
+#include "openmm/internal/QTBIntegratorUtilities.h"
+#include "CommonKernelSources.h"
+#include "SimTKOpenMMUtilities.h"
+
+using namespace OpenMM;
+using namespace std;
+
+void CommonIntegrateQTBStepKernel::initialize(const System& system, const QTBIntegrator& integrator) {
+    cc.initializeContexts();
+    ContextSelector selector(cc);
+    cc.getIntegrationUtilities().initRandomNumberGenerator(integrator.getRandomNumberSeed());
+    int numParticles = system.getNumParticles();
+    dt = integrator.getStepSize();
+    friction = integrator.getFriction();
+    segmentLength = (int) ceil(integrator.getSegmentLength()/integrator.getStepSize());
+    numFreq = (3*segmentLength+1)/2;
+    noise.initialize<float>(cc, 3*3*segmentLength*numParticles, "noise");
+    SimTKOpenMMUtilities::setRandomNumberSeed(integrator.getRandomNumberSeed());
+    vector<float> noiseVec(noise.getSize());
+    for (int i = 0; i < noiseVec.size(); i++)
+        noiseVec[i] = (float) SimTKOpenMMUtilities::getNormallyDistributedRandomNumber();
+    noise.upload(noiseVec);
+    int elementSize = (cc.getUseMixedPrecision() || cc.getUseDoublePrecision() ? sizeof(double) : sizeof(float));
+    randomForce.initialize(cc, 3*segmentLength*numParticles, elementSize, "randomForce");
+    segmentVelocity.initialize(cc, 3*segmentLength*numParticles, elementSize, "segmentVelocity");
+    oldDelta.initialize(cc, cc.getPaddedNumAtoms(), 4*elementSize, "oldDelta");
+    thetad.initialize(cc, numFreq, elementSize, "thetad");
+    workspace.initialize(cc, 18*segmentLength*cc.getNumThreadBlocks(), elementSize, "workspace");
+    cutoffFunction.initialize(cc, numFreq, elementSize, "cutoffFunction");
+    vector<double> cf(numFreq);
+    double cutoff = integrator.getCutoffFrequency();
+    double cutoffWidth = cutoff/100;
+    for (int i = 0; i < numFreq; i++) {
+        double w = M_PI*i/(numFreq*dt);
+        cf[i] = 1.0/(1.0+exp((w-cutoff)/cutoffWidth));
+    }
+    cutoffFunction.upload(cf, true);
+    vector<double> typeMassVec, typeAdaptationRateVec;
+    vector<vector<int> > typeParticles;
+    QTBIntegratorUtilities::findTypes(system, integrator, particleTypeVec, typeParticles, typeMassVec, typeAdaptationRateVec);
+    particleType.initialize<int>(cc, particleTypeVec.size(), "particleType");
+    particleType.upload(particleTypeVec);
+    typeAdaptationRate.initialize<float>(cc, typeAdaptationRateVec.size(), "typeAdaptationRate");
+    typeAdaptationRate.upload(typeAdaptationRateVec, true);
+    vector<int> typeParticleCountVec(typeParticles.size());
+    for (int i = 0; i < typeParticleCountVec.size(); i++)
+        typeParticleCountVec[i] = typeParticles[i].size();
+    typeParticleCount.initialize<int>(cc, typeParticleCountVec.size(), "typeParticleCount");
+    typeParticleCount.upload(typeParticleCountVec);
+    numTypes = typeParticles.size();
+    adaptedFriction.initialize(cc, numFreq*numTypes, elementSize, "adaptedFriction");
+    vector<double> adaptedFrictionVec(adaptedFriction.getSize(), friction);
+    adaptedFriction.upload(adaptedFrictionVec, true);
+    dfdt.initialize(cc, adaptedFriction.getSize(), sizeof(long long), "dfdt");
+
+    // Create the kernels.
+
+    map<string, string> defines, replacements;
+    defines["M_PI"] = cc.doubleToString(M_PI);
+    int outputIndex;
+    replacements["FFT_FORWARD"] = createFFT(3*segmentLength, 0, outputIndex, true);
+    replacements["RECIP_DATA"] = (outputIndex == 0 ? "data0" : "data1");
+    replacements["FFT_BACKWARD"] = createFFT(3*segmentLength, outputIndex, outputIndex, false);
+    replacements["ADAPTATION_FFT"] = createFFT(segmentLength, 0, outputIndex, true);
+    replacements["ADAPTATION_RECIP"] = (outputIndex == 0 ? "data0" : "data1");
+    ComputeProgram program = cc.compileProgram(cc.replaceStrings(CommonKernelSources::qtb, replacements), defines);
+    kernel1 = program->createKernel("integrateQTBPart1");
+    kernel2 = program->createKernel("integrateQTBPart2");
+    kernel3 = program->createKernel("integrateQTBPart3");
+    noiseKernel = program->createKernel("generateNoise");
+    forceKernel = program->createKernel("generateRandomForce");
+    adapt1Kernel = program->createKernel("adaptFrictionPart1");
+    adapt2Kernel = program->createKernel("adaptFrictionPart2");
+    stepIndex = 0;
+    prevTemp = -1.0;
+}
+
+void CommonIntegrateQTBStepKernel::execute(ContextImpl& context, const QTBIntegrator& integrator) {
+    ContextSelector selector(cc);
+    IntegrationUtilities& integration = cc.getIntegrationUtilities();
+    int numAtoms = cc.getNumAtoms();
+    int paddedNumAtoms = cc.getPaddedNumAtoms();
+    double temperature = integrator.getTemperature();
+    if (!hasInitializedKernels) {
+        hasInitializedKernels = true;
+        bool useDouble = cc.getUseDoublePrecision() || cc.getUseMixedPrecision();
+        kernel1->addArg(numAtoms);
+        kernel1->addArg(paddedNumAtoms);
+        if (useDouble)
+            kernel1->addArg(dt);
+        else
+            kernel1->addArg((float) dt);
+        kernel1->addArg();
+        kernel1->addArg(cc.getVelm());
+        kernel1->addArg(cc.getLongForceBuffer());
+        kernel1->addArg(segmentVelocity);
+        kernel1->addArg(cc.getAtomIndexArray());
+        kernel2->addArg(numAtoms);
+        if (useDouble) {
+            kernel2->addArg(dt);
+            kernel2->addArg(friction);
+        }
+        else {
+            kernel2->addArg((float) dt);
+            kernel2->addArg((float) friction);
+        }
+        kernel2->addArg();
+        kernel2->addArg(cc.getVelm());
+        kernel2->addArg(integration.getPosDelta());
+        kernel2->addArg(oldDelta);
+        kernel2->addArg(randomForce);
+        kernel2->addArg(cc.getAtomIndexArray());
+        kernel3->addArg(numAtoms);
+        if (useDouble)
+            kernel3->addArg(dt);
+        else
+            kernel3->addArg((float) dt);
+        kernel3->addArg(cc.getPosq());
+        kernel3->addArg(cc.getVelm());
+        kernel3->addArg(integration.getPosDelta());
+        kernel3->addArg(oldDelta);
+        if (cc.getUseMixedPrecision())
+            kernel3->addArg(cc.getPosqCorrection());
+        noiseKernel->addArg(numAtoms);
+        noiseKernel->addArg(segmentLength);
+        noiseKernel->addArg(noise);
+        noiseKernel->addArg(integration.getRandom());
+        noiseKernel->addArg(); // Random index will be set just before it is executed.
+        forceKernel->addArg(numAtoms);
+        forceKernel->addArg(segmentLength);
+        if (useDouble) {
+            forceKernel->addArg(dt);
+            forceKernel->addArg(friction);
+        }
+        else {
+            forceKernel->addArg((float) dt);
+            forceKernel->addArg((float) friction);
+        }
+        forceKernel->addArg(noise);
+        forceKernel->addArg(randomForce);
+        forceKernel->addArg(cc.getVelm());
+        forceKernel->addArg(thetad);
+        forceKernel->addArg(cutoffFunction);
+        forceKernel->addArg(particleType);
+        forceKernel->addArg(adaptedFriction);
+        forceKernel->addArg(workspace);
+        adapt1Kernel->addArg(numAtoms);
+        adapt1Kernel->addArg(segmentLength);
+        adapt1Kernel->addArg(cc.getVelm());
+        adapt1Kernel->addArg(particleType);
+        adapt1Kernel->addArg(randomForce);
+        adapt1Kernel->addArg(segmentVelocity);
+        adapt1Kernel->addArg(adaptedFriction);
+        adapt1Kernel->addArg(dfdt);
+        adapt1Kernel->addArg(workspace);
+        adapt2Kernel->addArg(numTypes);
+        adapt2Kernel->addArg(segmentLength);
+        if (useDouble)
+            adapt2Kernel->addArg(dt);
+        else
+            adapt2Kernel->addArg((float) dt);
+        adapt2Kernel->addArg(typeParticleCount);
+        adapt2Kernel->addArg(typeAdaptationRate);
+        adapt2Kernel->addArg(adaptedFriction);
+        adapt2Kernel->addArg(dfdt);
+    }
+    cc.getIntegrationUtilities().setNextStepSize(dt);
+
+    // Update the random force at the start of each segment.
+
+    if (stepIndex%segmentLength == 0) {
+        if (temperature != prevTemp) {
+            vector<double> thetaVec, thetadVec;
+            QTBIntegratorUtilities::calculateSpectrum(temperature, friction, dt, numFreq, thetaVec, thetadVec, cc.getThreadPool());
+            thetad.upload(thetadVec, true);
+            prevTemp = temperature;
+        }
+        cc.clearBuffer(dfdt);
+        adapt1Kernel->execute(3*numAtoms*128, 128);
+        adapt2Kernel->execute(numTypes*128, 128);
+        noiseKernel->setArg(4, integration.prepareRandomNumbers(3*numAtoms*segmentLength/4+1));
+        noiseKernel->execute(3*numAtoms*128, 128);
+        forceKernel->execute(3*numAtoms*128, 128);
+        stepIndex = 0;
+    }
+
+    // Perform the integration.
+
+    kernel1->setArg(3, stepIndex);
+    kernel2->setArg(3, stepIndex);
+    kernel1->execute(numAtoms);
+    integration.applyVelocityConstraints(integrator.getConstraintTolerance());
+    kernel2->execute(numAtoms);
+    integration.applyConstraints(integrator.getConstraintTolerance());
+    kernel3->execute(numAtoms);
+    integration.computeVirtualSites();
+
+    // Update the time and step count.
+
+    cc.setTime(cc.getTime()+dt);
+    cc.setStepCount(cc.getStepCount()+1);
+    stepIndex++;
+    cc.reorderAtoms();
+
+    // Reduce UI lag.
+
+    flushPeriodically(cc);
+}
+
+double CommonIntegrateQTBStepKernel::computeKineticEnergy(ContextImpl& context, const QTBIntegrator& integrator) {
+    return cc.getIntegrationUtilities().computeKineticEnergy(0.0);
+}
+
+void CommonIntegrateQTBStepKernel::getAdaptedFriction(ContextImpl& context, int particle, std::vector<double>& friction) const {
+    ASSERT_VALID_INDEX(particle, particleTypeVec);
+    int type = particleTypeVec[particle];
+    friction.resize(numFreq);
+    ContextSelector selector(cc);
+    if (cc.getUseMixedPrecision() || cc.getUseDoublePrecision()) {
+        vector<double> adaptedFrictionVec;
+        adaptedFriction.download(adaptedFrictionVec);
+        for (int i = 0; i < numFreq; i++)
+            friction[i] = adaptedFrictionVec[type*numFreq+i];
+    }
+    else {
+        vector<float> adaptedFrictionVec;
+        adaptedFriction.download(adaptedFrictionVec);
+        for (int i = 0; i < numFreq; i++)
+            friction[i] = adaptedFrictionVec[type*numFreq+i];
+    }
+}
+
+void CommonIntegrateQTBStepKernel::setAdaptedFriction(ContextImpl& context, int particle, const std::vector<double>& friction) {
+    ASSERT_VALID_INDEX(particle, particleTypeVec);
+    int type = particleTypeVec[particle];
+    ContextSelector selector(cc);
+    if (cc.getUseMixedPrecision() || cc.getUseDoublePrecision()) {
+        vector<double> adaptedFrictionVec;
+        adaptedFriction.download(adaptedFrictionVec);
+        for (int i = 0; i < numFreq; i++)
+            adaptedFrictionVec[type*numFreq+i] = friction[i];
+        adaptedFriction.upload(adaptedFrictionVec);
+    }
+    else {
+        vector<float> adaptedFrictionVec;
+        adaptedFriction.download(adaptedFrictionVec);
+        for (int i = 0; i < numFreq; i++)
+            adaptedFrictionVec[type*numFreq+i] = friction[i];
+        adaptedFriction.upload(adaptedFrictionVec);
+    }
+}
+
+void CommonIntegrateQTBStepKernel::createCheckpoint(ContextImpl& context, ostream& stream) const {
+    ContextSelector selector(cc);
+    stream.write((char*) &stepIndex, sizeof(int));
+    vector<float> f;
+    noise.download(f);
+    stream.write((char*) f.data(), sizeof(float)*f.size());
+    if (cc.getUseMixedPrecision() || cc.getUseDoublePrecision()) {
+        vector<double> d;
+        randomForce.download(d);
+        stream.write((char*) d.data(), sizeof(double)*d.size());
+        segmentVelocity.download(d);
+        stream.write((char*) d.data(), sizeof(double)*d.size());
+        adaptedFriction.download(d);
+        stream.write((char*) d.data(), sizeof(double)*d.size());
+    }
+    else {
+        randomForce.download(f);
+        stream.write((char*) f.data(), sizeof(float)*f.size());
+        segmentVelocity.download(f);
+        stream.write((char*) f.data(), sizeof(float)*f.size());
+        adaptedFriction.download(f);
+        stream.write((char*) f.data(), sizeof(float)*f.size());
+    }
+}
+
+void CommonIntegrateQTBStepKernel::loadCheckpoint(ContextImpl& context, istream& stream) {
+    ContextSelector selector(cc);
+    stream.read((char*) &stepIndex, sizeof(int));
+    vector<float> f(noise.getSize());
+    stream.read((char*) f.data(), sizeof(float)*noise.getSize());
+    noise.upload(f);
+    if (cc.getUseMixedPrecision() || cc.getUseDoublePrecision()) {
+        vector<double> d;
+        d.resize(randomForce.getSize());
+        stream.read((char*) d.data(), sizeof(double)*d.size());
+        randomForce.upload(d);
+        d.resize(segmentVelocity.getSize());
+        stream.read((char*) d.data(), sizeof(double)*d.size());
+        segmentVelocity.upload(d);
+        d.resize(adaptedFriction.getSize());
+        stream.read((char*) d.data(), sizeof(double)*d.size());
+        adaptedFriction.upload(d);
+    }
+    else {
+        f.resize(randomForce.getSize());
+        stream.read((char*) f.data(), sizeof(float)*f.size());
+        randomForce.upload(f);
+        f.resize(segmentVelocity.getSize());
+        stream.read((char*) f.data(), sizeof(float)*f.size());
+        segmentVelocity.upload(f);
+        f.resize(adaptedFriction.getSize());
+        stream.read((char*) f.data(), sizeof(float)*f.size());
+        adaptedFriction.upload(f);
+    }
+}
+
+string CommonIntegrateQTBStepKernel::createFFT(int size, int inputIndex, int& outputIndex, bool forward) {
+    stringstream source;
+    int stage = 0;
+    int L = size;
+    int m = 1;
+    string sign = (forward ? "1" : "-1");
+    string mult = (forward ? "multiplyComplex" : "multiplyComplexConj");
+
+    // Factor size, generating an appropriate block of code for each factor.
+
+    while (L > 1) {
+        int input = (inputIndex+stage)%2;
+        int output = 1-input;
+        int radix;
+        if (L%7 == 0)
+            radix = 7;
+        else if (L%5 == 0)
+            radix = 5;
+        else if (L%4 == 0)
+            radix = 4;
+        else if (L%3 == 0)
+            radix = 3;
+        else if (L%2 == 0)
+            radix = 2;
+        else
+            throw OpenMMException("QTBIntegrator: Number of steps in a segment must be a multiple of powers of 2, 3, 5, and 7");
+        source<<"{\n";
+        L = L/radix;
+        source<<"// Pass "<<(stage+1)<<" (radix "<<radix<<")\n";
+        source<<"for (int i = LOCAL_ID; i < "<<(L*m)<<"; i += LOCAL_SIZE) {\n";
+        source<<"int base = i;\n";
+        source<<"int j = i/"<<m<<";\n";
+        if (radix == 7) {
+            source<<"mixed2 c0 = data"<<input<<"[base];\n";
+            source<<"mixed2 c1 = data"<<input<<"[base+"<<(L*m)<<"];\n";
+            source<<"mixed2 c2 = data"<<input<<"[base+"<<(2*L*m)<<"];\n";
+            source<<"mixed2 c3 = data"<<input<<"[base+"<<(3*L*m)<<"];\n";
+            source<<"mixed2 c4 = data"<<input<<"[base+"<<(4*L*m)<<"];\n";
+            source<<"mixed2 c5 = data"<<input<<"[base+"<<(5*L*m)<<"];\n";
+            source<<"mixed2 c6 = data"<<input<<"[base+"<<(6*L*m)<<"];\n";
+            source<<"mixed2 d0 = c1+c6;\n";
+            source<<"mixed2 d1 = c1-c6;\n";
+            source<<"mixed2 d2 = c2+c5;\n";
+            source<<"mixed2 d3 = c2-c5;\n";
+            source<<"mixed2 d4 = c4+c3;\n";
+            source<<"mixed2 d5 = c4-c3;\n";
+            source<<"mixed2 d6 = d2+d0;\n";
+            source<<"mixed2 d7 = d5+d3;\n";
+            source<<"mixed2 b0 = c0+d6+d4;\n";
+            source<<"mixed2 b1 = "<<cc.doubleToString((cos(2*M_PI/7)+cos(4*M_PI/7)+cos(6*M_PI/7))/3-1)<<"*(d6+d4);\n";
+            source<<"mixed2 b2 = "<<cc.doubleToString((2*cos(2*M_PI/7)-cos(4*M_PI/7)-cos(6*M_PI/7))/3)<<"*(d0-d4);\n";
+            source<<"mixed2 b3 = "<<cc.doubleToString((cos(2*M_PI/7)-2*cos(4*M_PI/7)+cos(6*M_PI/7))/3)<<"*(d4-d2);\n";
+            source<<"mixed2 b4 = "<<cc.doubleToString((cos(2*M_PI/7)+cos(4*M_PI/7)-2*cos(6*M_PI/7))/3)<<"*(d2-d0);\n";
+            source<<"mixed2 b5 = -("<<sign<<")*"<<cc.doubleToString((sin(2*M_PI/7)+sin(4*M_PI/7)-sin(6*M_PI/7))/3)<<"*(d7+d1);\n";
+            source<<"mixed2 b6 = -("<<sign<<")*"<<cc.doubleToString((2*sin(2*M_PI/7)-sin(4*M_PI/7)+sin(6*M_PI/7))/3)<<"*(d1-d5);\n";
+            source<<"mixed2 b7 = -("<<sign<<")*"<<cc.doubleToString((sin(2*M_PI/7)-2*sin(4*M_PI/7)-sin(6*M_PI/7))/3)<<"*(d5-d3);\n";
+            source<<"mixed2 b8 = -("<<sign<<")*"<<cc.doubleToString((sin(2*M_PI/7)+sin(4*M_PI/7)+2*sin(6*M_PI/7))/3)<<"*(d3-d1);\n";
+            source<<"mixed2 t0 = b0+b1;\n";
+            source<<"mixed2 t1 = b2+b3;\n";
+            source<<"mixed2 t2 = b4-b3;\n";
+            source<<"mixed2 t3 = -b2-b4;\n";
+            source<<"mixed2 t4 = b6+b7;\n";
+            source<<"mixed2 t5 = b8-b7;\n";
+            source<<"mixed2 t6 = -b8-b6;\n";
+            source<<"mixed2 t7 = t0+t1;\n";
+            source<<"mixed2 t8 = t0+t2;\n";
+            source<<"mixed2 t9 = t0+t3;\n";
+            source<<"mixed2 t10 = make_mixed2(t4.y+b5.y, -(t4.x+b5.x));\n";
+            source<<"mixed2 t11 = make_mixed2(t5.y+b5.y, -(t5.x+b5.x));\n";
+            source<<"mixed2 t12 = make_mixed2(t6.y+b5.y, -(t6.x+b5.x));\n";
+            source<<"data"<<output<<"[base+6*j*"<<m<<"] = b0;\n";
+            source<<"data"<<output<<"[base+(6*j+1)*"<<m<<"] = "<<mult<<"(w[j*"<<size<<"/"<<(7*L)<<"], t7-t10);\n";
+            source<<"data"<<output<<"[base+(6*j+2)*"<<m<<"] = "<<mult<<"(w[j*"<<(2*size)<<"/"<<(7*L)<<"], t9-t12);\n";
+            source<<"data"<<output<<"[base+(6*j+3)*"<<m<<"] = "<<mult<<"(w[j*"<<(3*size)<<"/"<<(7*L)<<"], t8+t11);\n";
+            source<<"data"<<output<<"[base+(6*j+4)*"<<m<<"] = "<<mult<<"(w[j*"<<(4*size)<<"/"<<(7*L)<<"], t8-t11);\n";
+            source<<"data"<<output<<"[base+(6*j+5)*"<<m<<"] = "<<mult<<"(w[j*"<<(5*size)<<"/"<<(7*L)<<"], t9+t12);\n";
+            source<<"data"<<output<<"[base+(6*j+6)*"<<m<<"] = "<<mult<<"(w[j*"<<(6*size)<<"/"<<(7*L)<<"], t7+t10);\n";
+        }
+        else if (radix == 5) {
+            source<<"mixed2 c0 = data"<<input<<"[base];\n";
+            source<<"mixed2 c1 = data"<<input<<"[base+"<<(L*m)<<"];\n";
+            source<<"mixed2 c2 = data"<<input<<"[base+"<<(2*L*m)<<"];\n";
+            source<<"mixed2 c3 = data"<<input<<"[base+"<<(3*L*m)<<"];\n";
+            source<<"mixed2 c4 = data"<<input<<"[base+"<<(4*L*m)<<"];\n";
+            source<<"mixed2 d0 = c1+c4;\n";
+            source<<"mixed2 d1 = c2+c3;\n";
+            source<<"mixed2 d2 = "<<cc.doubleToString(sin(0.4*M_PI))<<"*(c1-c4);\n";
+            source<<"mixed2 d3 = "<<cc.doubleToString(sin(0.4*M_PI))<<"*(c2-c3);\n";
+            source<<"mixed2 d4 = d0+d1;\n";
+            source<<"mixed2 d5 = "<<cc.doubleToString(0.25*sqrt(5.0))<<"*(d0-d1);\n";
+            source<<"mixed2 d6 = c0-0.25f*d4;\n";
+            source<<"mixed2 d7 = d6+d5;\n";
+            source<<"mixed2 d8 = d6-d5;\n";
+            string coeff = cc.doubleToString(sin(0.2*M_PI)/sin(0.4*M_PI));
+            source<<"mixed2 d9 = "<<sign<<"*make_mixed2(d2.y+"<<coeff<<"*d3.y, -d2.x-"<<coeff<<"*d3.x);\n";
+            source<<"mixed2 d10 = "<<sign<<"*make_mixed2("<<coeff<<"*d2.y-d3.y, d3.x-"<<coeff<<"*d2.x);\n";
+            source<<"data"<<output<<"[base+4*j*"<<m<<"] = c0+d4;\n";
+            source<<"data"<<output<<"[base+(4*j+1)*"<<m<<"] = "<<mult<<"(w[j*"<<size<<"/"<<(5*L)<<"], d7+d9);\n";
+            source<<"data"<<output<<"[base+(4*j+2)*"<<m<<"] = "<<mult<<"(w[j*"<<(2*size)<<"/"<<(5*L)<<"], d8+d10);\n";
+            source<<"data"<<output<<"[base+(4*j+3)*"<<m<<"] = "<<mult<<"(w[j*"<<(3*size)<<"/"<<(5*L)<<"], d8-d10);\n";
+            source<<"data"<<output<<"[base+(4*j+4)*"<<m<<"] = "<<mult<<"(w[j*"<<(4*size)<<"/"<<(5*L)<<"], d7-d9);\n";
+        }
+        else if (radix == 4) {
+            source<<"mixed2 c0 = data"<<input<<"[base];\n";
+            source<<"mixed2 c1 = data"<<input<<"[base+"<<(L*m)<<"];\n";
+            source<<"mixed2 c2 = data"<<input<<"[base+"<<(2*L*m)<<"];\n";
+            source<<"mixed2 c3 = data"<<input<<"[base+"<<(3*L*m)<<"];\n";
+            source<<"mixed2 d0 = c0+c2;\n";
+            source<<"mixed2 d1 = c0-c2;\n";
+            source<<"mixed2 d2 = c1+c3;\n";
+            source<<"mixed2 d3 = "<<sign<<"*make_mixed2(c1.y-c3.y, c3.x-c1.x);\n";
+            source<<"data"<<output<<"[base+3*j*"<<m<<"] = d0+d2;\n";
+            source<<"data"<<output<<"[base+(3*j+1)*"<<m<<"] = "<<mult<<"(w[j*"<<size<<"/"<<(4*L)<<"], d1+d3);\n";
+            source<<"data"<<output<<"[base+(3*j+2)*"<<m<<"] = "<<mult<<"(w[j*"<<(2*size)<<"/"<<(4*L)<<"], d0-d2);\n";
+            source<<"data"<<output<<"[base+(3*j+3)*"<<m<<"] = "<<mult<<"(w[j*"<<(3*size)<<"/"<<(4*L)<<"], d1-d3);\n";
+        }
+        else if (radix == 3) {
+            source<<"mixed2 c0 = data"<<input<<"[base];\n";
+            source<<"mixed2 c1 = data"<<input<<"[base+"<<(L*m)<<"];\n";
+            source<<"mixed2 c2 = data"<<input<<"[base+"<<(2*L*m)<<"];\n";
+            source<<"mixed2 d0 = c1+c2;\n";
+            source<<"mixed2 d1 = c0-0.5f*d0;\n";
+            source<<"mixed2 d2 = "<<sign<<"*"<<cc.doubleToString(sin(M_PI/3.0))<<"*make_mixed2(c1.y-c2.y, c2.x-c1.x);\n";
+            source<<"data"<<output<<"[base+2*j*"<<m<<"] = c0+d0;\n";
+            source<<"data"<<output<<"[base+(2*j+1)*"<<m<<"] = "<<mult<<"(w[j*"<<size<<"/"<<(3*L)<<"], d1+d2);\n";
+            source<<"data"<<output<<"[base+(2*j+2)*"<<m<<"] = "<<mult<<"(w[j*"<<(2*size)<<"/"<<(3*L)<<"], d1-d2);\n";
+        }
+        else if (radix == 2) {
+            source<<"mixed2 c0 = data"<<input<<"[base];\n";
+            source<<"mixed2 c1 = data"<<input<<"[base+"<<(L*m)<<"];\n";
+            source<<"data"<<output<<"[base+j*"<<m<<"] = c0+c1;\n";
+            source<<"data"<<output<<"[base+(j+1)*"<<m<<"] = "<<mult<<"(w[j*"<<size<<"/"<<(2*L)<<"], c0-c1);\n";
+        }
+        source<<"}\n";
+        m = m*radix;
+        source<<"SYNC_THREADS\n";
+        source<<"}\n";
+        ++stage;
+    }
+    outputIndex = (inputIndex+stage)%2;
+    return source.str();
+}
diff --git a/platforms/common/src/IntegrationUtilities.cpp b/platforms/common/src/IntegrationUtilities.cpp
index 8d40361afa..b93e6ee2c9 100644
--- a/platforms/common/src/IntegrationUtilities.cpp
+++ b/platforms/common/src/IntegrationUtilities.cpp
@@ -917,6 +917,8 @@ void IntegrationUtilities::createCheckpoint(ostream& stream) {
     if (!random.isInitialized())
         return;
     stream.write((char*) &randomPos, sizeof(int));
+    int numRandom = random.getSize();
+    stream.write((char*) &numRandom, sizeof(int));
     vector<mm_float4> randomVec;
     random.download(randomVec);
     stream.write((char*) &randomVec[0], sizeof(mm_float4)*random.getSize());
@@ -929,6 +931,12 @@ void IntegrationUtilities::loadCheckpoint(istream& stream) {
     if (!random.isInitialized())
         return;
     stream.read((char*) &randomPos, sizeof(int));
+    int numRandom;
+    stream.read((char*) &numRandom, sizeof(int));
+    if (numRandom != random.getSize()) {
+        random.resize(numRandom);
+        randomKernel->setArg(0, numRandom);
+    }
     vector<mm_float4> randomVec(random.getSize());
     stream.read((char*) &randomVec[0], sizeof(mm_float4)*random.getSize());
     random.upload(randomVec);
diff --git a/platforms/common/src/kernels/qtb.cc b/platforms/common/src/kernels/qtb.cc
new file mode 100644
index 0000000000..d173add59f
--- /dev/null
+++ b/platforms/common/src/kernels/qtb.cc
@@ -0,0 +1,217 @@
+/**
+ * Perform the first part of integration: velocity step.
+ */
+KERNEL void integrateQTBPart1(int numAtoms, int paddedNumAtoms, mixed dt, int stepIndex, GLOBAL mixed4* RESTRICT velm,
+        GLOBAL const mm_long* RESTRICT force, GLOBAL mixed* RESTRICT segmentVelocity, GLOBAL const int* RESTRICT atomOrder) {
+    mixed fscale = dt/(mixed) 0x100000000;
+    for (int atom = GLOBAL_ID; atom < numAtoms; atom += GLOBAL_SIZE) {
+        int atomIndex = atomOrder[atom];
+        mixed4 velocity = velm[atom];
+        segmentVelocity[3*numAtoms*stepIndex + atomIndex] = velocity.x;
+        segmentVelocity[3*numAtoms*stepIndex + numAtoms + atomIndex] = velocity.y;
+        segmentVelocity[3*numAtoms*stepIndex + 2*numAtoms + atomIndex] = velocity.z;
+        if (velocity.w != 0.0) {
+            velocity.x += fscale*velocity.w*force[atom];
+            velocity.y += fscale*velocity.w*force[atom+paddedNumAtoms];
+            velocity.z += fscale*velocity.w*force[atom+paddedNumAtoms*2];
+            velm[atom] = velocity;
+        }
+    }
+}
+
+/**
+ * Perform the second part of integration: position half step, then interact with heat bath,
+ * then another position half step.
+ */
+KERNEL void integrateQTBPart2(int numAtoms, mixed dt, mixed friction, int stepIndex, GLOBAL mixed4* RESTRICT velm,
+        GLOBAL mixed4* RESTRICT posDelta, GLOBAL mixed4* RESTRICT oldDelta, GLOBAL const mixed* RESTRICT randomForce,
+        GLOBAL const int* RESTRICT atomOrder) {
+    mixed halfdt = 0.5f*dt;
+    mixed vscale = EXP(-dt*friction);
+    for (int atom = GLOBAL_ID; atom < numAtoms; atom += GLOBAL_SIZE) {
+        int atomIndex = atomOrder[atom];
+        mixed4 velocity = velm[atom];
+        if (velocity.w != 0.0) {
+            mixed4 delta = make_mixed4(halfdt*velocity.x, halfdt*velocity.y, halfdt*velocity.z, 0);
+            mixed fscale = dt*velocity.w;
+            velocity.x = vscale*velocity.x + fscale*randomForce[3*numAtoms*stepIndex + atomIndex];
+            velocity.y = vscale*velocity.y + fscale*randomForce[3*numAtoms*stepIndex + numAtoms + atomIndex];
+            velocity.z = vscale*velocity.z + fscale*randomForce[3*numAtoms*stepIndex + 2*numAtoms + atomIndex];
+            velm[atom] = velocity;
+            delta += make_mixed4(halfdt*velocity.x, halfdt*velocity.y, halfdt*velocity.z, 0);
+            posDelta[atom] = delta;
+            oldDelta[atom] = delta;
+        }
+    }
+}
+
+/**
+ * Perform the third part of integration: apply constraint forces to velocities, then record
+ * the constrained positions.
+ */
+KERNEL void integrateQTBPart3(int numAtoms, mixed dt, GLOBAL real4* RESTRICT posq, GLOBAL mixed4* RESTRICT velm,
+         GLOBAL const mixed4* RESTRICT posDelta, GLOBAL const mixed4* RESTRICT oldDelta
+#ifdef USE_MIXED_PRECISION
+        , GLOBAL real4* RESTRICT posqCorrection
+#endif
+        ) {
+    mixed invDt = 1/dt;
+    for (int index = GLOBAL_ID; index < numAtoms; index += GLOBAL_SIZE) {
+        mixed4 velocity = velm[index];
+        if (velocity.w != 0.0) {
+            mixed4 delta = posDelta[index];
+            velocity.x += (delta.x-oldDelta[index].x)*invDt;
+            velocity.y += (delta.y-oldDelta[index].y)*invDt;
+            velocity.z += (delta.z-oldDelta[index].z)*invDt;
+            velm[index] = velocity;
+#ifdef USE_MIXED_PRECISION
+            real4 pos1 = posq[index];
+            real4 pos2 = posqCorrection[index];
+            mixed4 pos = make_mixed4(pos1.x+(mixed)pos2.x, pos1.y+(mixed)pos2.y, pos1.z+(mixed)pos2.z, pos1.w);
+#else
+            real4 pos = posq[index];
+#endif
+            pos.x += delta.x;
+            pos.y += delta.y;
+            pos.z += delta.z;
+#ifdef USE_MIXED_PRECISION
+            posq[index] = make_real4((real) pos.x, (real) pos.y, (real) pos.z, (real) pos.w);
+            posqCorrection[index] = make_real4(pos.x-(real) pos.x, pos.y-(real) pos.y, pos.z-(real) pos.z, 0);
+#else
+            posq[index] = pos;
+#endif
+        }
+    }
+}
+
+/**
+ * Update the buffer of white noise.
+ */
+KERNEL void generateNoise(int numAtoms, int segmentLength, GLOBAL float* RESTRICT noise, GLOBAL const float* RESTRICT random, unsigned int randomIndex) {
+    randomIndex = 4*randomIndex; // Interpret it as float instead of float4
+    int fftLength = 3*segmentLength;
+    for (int i = GROUP_ID; i < 3*numAtoms; i += NUM_GROUPS) {
+        // Copy segment 2 over to segment 1
+
+        for (int j = LOCAL_ID; j < segmentLength; j += LOCAL_SIZE)
+            noise[i*fftLength+j] = noise[i*fftLength+j+segmentLength];
+        SYNC_THREADS
+
+        // Copy segment 3 over to segment 2
+
+        for (int j = LOCAL_ID; j < segmentLength; j += LOCAL_SIZE)
+            noise[i*fftLength+j+segmentLength] = noise[i*fftLength+j+2*segmentLength];
+        SYNC_THREADS
+
+        // Fill segment 3 with new random values.
+
+        for (int j = LOCAL_ID; j < segmentLength; j += LOCAL_SIZE)
+            noise[i*fftLength+j+2*segmentLength] = random[randomIndex+i*segmentLength+j];
+        SYNC_THREADS
+    }
+}
+
+DEVICE mixed2 multiplyComplex(mixed2 c1, mixed2 c2) {
+    return make_mixed2(c1.x*c2.x-c1.y*c2.y, c1.x*c2.y+c1.y*c2.x);
+}
+
+DEVICE mixed2 multiplyComplexConj(mixed2 c1, mixed2 c2) {
+    return make_mixed2(c1.x*c2.x+c1.y*c2.y, c1.x*c2.y-c1.y*c2.x);
+}
+
+/**
+ * Generate the random force for the next segment.
+ */
+KERNEL void generateRandomForce(int numAtoms, int segmentLength, mixed dt, mixed friction, GLOBAL float* RESTRICT noise,
+        GLOBAL mixed* RESTRICT randomForce, GLOBAL mixed4* RESTRICT velm, GLOBAL mixed* RESTRICT thetad,
+        GLOBAL mixed* RESTRICT cutoffFunction, GLOBAL int* RESTRICT particleType, GLOBAL mixed* RESTRICT adaptedFriction,
+        GLOBAL mixed2* RESTRICT workspace) {
+    const int fftLength = 3*segmentLength;
+    const int numFreq = (fftLength+1)/2;
+    GLOBAL mixed2* data0 = &workspace[GROUP_ID*3*fftLength];
+    GLOBAL mixed2* data1 = &data0[fftLength];
+    GLOBAL mixed2* w = &data1[fftLength];
+    for (int i = LOCAL_ID; i < fftLength; i += LOCAL_SIZE)
+        w[i] = make_mixed2(cos(-i*2*M_PI/fftLength), sin(-i*2*M_PI/fftLength));
+    for (int i = GROUP_ID; i < 3*numAtoms; i += NUM_GROUPS) {
+        int atom = i/3;
+        int axis = i%3;
+        mixed invMass = velm[atom].w;
+        int type = particleType[atom];
+        if (invMass != 0) {
+            for (int j = LOCAL_ID; j < fftLength; j += LOCAL_SIZE)
+                data0[j] = make_mixed2(noise[i*fftLength+j], 0);
+            SYNC_THREADS
+            FFT_FORWARD
+            for (int j = LOCAL_ID; j < numFreq; j += LOCAL_SIZE) {
+                mixed f = M_PI*j/(numFreq*dt);
+                mixed gamma = adaptedFriction[type*numFreq+j];
+                mixed cw = (1 - 2*EXP(-dt*friction)*COS(f*dt) + EXP(-2*friction*dt)) / ((friction*friction+f*f)*dt*dt);
+                mixed2 d = RECIP_DATA[j] * SQRT(cutoffFunction[j]*thetad[j]*cw*gamma/friction);
+                RECIP_DATA[j] = d;
+                if (j != 0 && j*2 != fftLength)
+                    RECIP_DATA[fftLength-j] = make_mixed2(d.x, -d.y);
+            }
+            SYNC_THREADS
+            FFT_BACKWARD
+            const mixed scale = SQRT(2*friction/(dt*invMass))/fftLength;
+            for (int j = LOCAL_ID; j < segmentLength; j += LOCAL_SIZE)
+                randomForce[numAtoms*(3*j+axis)+atom] = scale*data0[segmentLength+j].x;
+            SYNC_THREADS
+        }
+    }
+}
+
+/**
+ * Update the friction rates used for generating noise, part 1: compute the error in the
+ * fluctuation dissipation theorem.
+ */
+KERNEL void adaptFrictionPart1(int numAtoms, int segmentLength, GLOBAL const mixed4* RESTRICT velm, GLOBAL const int* RESTRICT particleType,
+        GLOBAL const mixed* RESTRICT randomForce, GLOBAL const mixed* RESTRICT segmentVelocity, GLOBAL const mixed* RESTRICT adaptedFriction,
+        GLOBAL mm_ulong* RESTRICT dfdt, GLOBAL mixed2* RESTRICT workspace) {
+    const int numFreq = (segmentLength+1)/2;
+    GLOBAL mixed2* data0 = &workspace[GROUP_ID*3*segmentLength];
+    GLOBAL mixed2* data1 = &data0[segmentLength];
+    GLOBAL mixed2* w = &data1[segmentLength];
+    for (int i = LOCAL_ID; i < segmentLength; i += LOCAL_SIZE)
+        w[i] = make_mixed2(cos(-i*2*M_PI/segmentLength), sin(-i*2*M_PI/segmentLength));
+    for (int i = GROUP_ID; i < 3*numAtoms; i += NUM_GROUPS) {
+        int atom = i/3;
+        int axis = i%3;
+        int type = particleType[atom];
+        mixed invMass = velm[atom].w;
+        if (invMass != 0) {
+            // Pack the velocities and forces together so we can transform both at once.
+            for (int j = LOCAL_ID; j < segmentLength; j += LOCAL_SIZE)
+                data0[j] = make_mixed2(segmentVelocity[numAtoms*(3*j+axis)+atom], randomForce[numAtoms*(3*j+axis)+atom]);
+            SYNC_THREADS
+            ADAPTATION_FFT
+            for (int j = LOCAL_ID; j < numFreq; j += LOCAL_SIZE) {
+                mixed2 d1 = ADAPTATION_RECIP[j];
+                mixed2 d2 = (j == 0 ? ADAPTATION_RECIP[0] : ADAPTATION_RECIP[segmentLength-j]);
+                mixed2 v = 0.5f*make_mixed2(d1.x+d2.x, d1.y-d2.y);
+                mixed2 f = 0.5f*make_mixed2(d1.y+d2.y, -d1.x+d2.x);
+                mixed cvv = v.x*v.x + v.y*v.y;
+                mixed2 cvf = multiplyComplexConj(f, v);
+                mixed dfdtinc = adaptedFriction[type*numFreq+j]*cvv/invMass - cvf.x;
+                ATOMIC_ADD(&dfdt[type*numFreq+j], (mm_ulong) realToFixedPoint(dfdtinc));
+            }
+        }
+    }
+}
+
+/**
+ * Update the friction rates used for generating noise, part 2: update the friction based
+ * on the error.
+ */
+KERNEL void adaptFrictionPart2(int numTypes, int segmentLength, mixed dt, GLOBAL const int* RESTRICT typeParticleCount,
+        GLOBAL const float* RESTRICT typeAdaptationRate, GLOBAL mixed* RESTRICT adaptedFriction, GLOBAL const mm_long* RESTRICT dfdt) {
+    int numFreq = (3*segmentLength+1)/2;
+    for (int type = GROUP_ID; type < numTypes; type += NUM_GROUPS) {
+        mixed scale = dt*typeAdaptationRate[type]/(3*typeParticleCount[type]*segmentLength)/(mixed) 0x100000000;
+        for (int i = LOCAL_ID; i < numFreq; i += LOCAL_SIZE) {
+            mixed delta = -scale*dfdt[type*numFreq+i];
+            adaptedFriction[type*numFreq+i] = max((mixed) 0, adaptedFriction[type*numFreq+i]+delta);
+        }
+    }
+}
\ No newline at end of file
diff --git a/platforms/cuda/src/CudaKernelFactory.cpp b/platforms/cuda/src/CudaKernelFactory.cpp
index 0a15aa454b..01f493fcc9 100644
--- a/platforms/cuda/src/CudaKernelFactory.cpp
+++ b/platforms/cuda/src/CudaKernelFactory.cpp
@@ -36,6 +36,7 @@
 #include "openmm/common/CommonCalcCustomNonbondedForceKernel.h"
 #include "openmm/common/CommonIntegrateCustomStepKernel.h"
 #include "openmm/common/CommonIntegrateNoseHooverStepKernel.h"
+#include "openmm/common/CommonIntegrateQTBStepKernel.h"
 #include "openmm/internal/ContextImpl.h"
 #include "openmm/OpenMMException.h"

@@ -142,6 +143,8 @@ KernelImpl* CudaKernelFactory::createKernelImpl(std::string name, const Platform
         return new CommonIntegrateCustomStepKernel(name, platform, cu);
     if (name == IntegrateDPDStepKernel::Name())
         return new CommonIntegrateDPDStepKernel(name, platform, cu);
+    if (name == IntegrateQTBStepKernel::Name())
+        return new CommonIntegrateQTBStepKernel(name, platform, cu);
     if (name == ApplyAndersenThermostatKernel::Name())
         return new CommonApplyAndersenThermostatKernel(name, platform, cu);
     if (name == IntegrateNoseHooverStepKernel::Name())
diff --git a/platforms/cuda/src/CudaPlatform.cpp b/platforms/cuda/src/CudaPlatform.cpp
index 7c6e6675a9..6adc21fb23 100644
--- a/platforms/cuda/src/CudaPlatform.cpp
+++ b/platforms/cuda/src/CudaPlatform.cpp
@@ -105,6 +105,7 @@ CudaPlatform::CudaPlatform() {
     registerKernelFactory(IntegrateVariableLangevinStepKernel::Name(), factory);
     registerKernelFactory(IntegrateCustomStepKernel::Name(), factory);
     registerKernelFactory(IntegrateDPDStepKernel::Name(), factory);
+    registerKernelFactory(IntegrateQTBStepKernel::Name(), factory);
     registerKernelFactory(ApplyAndersenThermostatKernel::Name(), factory);
     registerKernelFactory(ApplyMonteCarloBarostatKernel::Name(), factory);
     registerKernelFactory(RemoveCMMotionKernel::Name(), factory);
diff --git a/platforms/hip/src/HipKernelFactory.cpp b/platforms/hip/src/HipKernelFactory.cpp
index aa499b871b..16d2f7b782 100644
--- a/platforms/hip/src/HipKernelFactory.cpp
+++ b/platforms/hip/src/HipKernelFactory.cpp
@@ -37,6 +37,7 @@
 #include "openmm/common/CommonCalcCustomNonbondedForceKernel.h"
 #include "openmm/common/CommonIntegrateCustomStepKernel.h"
 #include "openmm/common/CommonIntegrateNoseHooverStepKernel.h"
+#include "openmm/common/CommonIntegrateQTBStepKernel.h"
 #include "openmm/internal/ContextImpl.h"
 #include "openmm/OpenMMException.h"

@@ -141,6 +142,8 @@ KernelImpl* HipKernelFactory::createKernelImpl(std::string name, const Platform&
         return new CommonIntegrateCustomStepKernel(name, platform, cu);
     if (name == IntegrateDPDStepKernel::Name())
         return new CommonIntegrateDPDStepKernel(name, platform, cu);
+    if (name == IntegrateQTBStepKernel::Name())
+        return new CommonIntegrateQTBStepKernel(name, platform, cu);
     if (name == ApplyAndersenThermostatKernel::Name())
         return new CommonApplyAndersenThermostatKernel(name, platform, cu);
     if (name == IntegrateNoseHooverStepKernel::Name())
diff --git a/platforms/hip/src/HipPlatform.cpp b/platforms/hip/src/HipPlatform.cpp
index deb02907c5..dc0f9c2d52 100644
--- a/platforms/hip/src/HipPlatform.cpp
+++ b/platforms/hip/src/HipPlatform.cpp
@@ -105,6 +105,7 @@ HipPlatform::HipPlatform() {
     registerKernelFactory(IntegrateVariableLangevinStepKernel::Name(), factory);
     registerKernelFactory(IntegrateCustomStepKernel::Name(), factory);
     registerKernelFactory(IntegrateDPDStepKernel::Name(), factory);
+    registerKernelFactory(IntegrateQTBStepKernel::Name(), factory);
     registerKernelFactory(ApplyAndersenThermostatKernel::Name(), factory);
     registerKernelFactory(ApplyMonteCarloBarostatKernel::Name(), factory);
     registerKernelFactory(RemoveCMMotionKernel::Name(), factory);
diff --git a/platforms/opencl/src/OpenCLKernelFactory.cpp b/platforms/opencl/src/OpenCLKernelFactory.cpp
index 3510dc1ef0..c758df70c7 100644
--- a/platforms/opencl/src/OpenCLKernelFactory.cpp
+++ b/platforms/opencl/src/OpenCLKernelFactory.cpp
@@ -34,6 +34,7 @@
 #include "openmm/common/CommonCalcCustomNonbondedForceKernel.h"
 #include "openmm/common/CommonIntegrateCustomStepKernel.h"
 #include "openmm/common/CommonIntegrateNoseHooverStepKernel.h"
+#include "openmm/common/CommonIntegrateQTBStepKernel.h"
 #include "openmm/internal/ContextImpl.h"
 #include "openmm/OpenMMException.h"

@@ -140,6 +141,8 @@ KernelImpl* OpenCLKernelFactory::createKernelImpl(std::string name, const Platfo
         return new CommonIntegrateCustomStepKernel(name, platform, cl);
     if (name == IntegrateDPDStepKernel::Name())
         return new CommonIntegrateDPDStepKernel(name, platform, cl);
+    if (name == IntegrateQTBStepKernel::Name())
+        return new CommonIntegrateQTBStepKernel(name, platform, cl);
     if (name == ApplyAndersenThermostatKernel::Name())
         return new CommonApplyAndersenThermostatKernel(name, platform, cl);
     if (name == IntegrateNoseHooverStepKernel::Name())
diff --git a/platforms/opencl/src/OpenCLPlatform.cpp b/platforms/opencl/src/OpenCLPlatform.cpp
index 0e93cdc5dc..872def0968 100644
--- a/platforms/opencl/src/OpenCLPlatform.cpp
+++ b/platforms/opencl/src/OpenCLPlatform.cpp
@@ -96,6 +96,7 @@ OpenCLPlatform::OpenCLPlatform() {
     registerKernelFactory(IntegrateVariableLangevinStepKernel::Name(), factory);
     registerKernelFactory(IntegrateCustomStepKernel::Name(), factory);
     registerKernelFactory(IntegrateDPDStepKernel::Name(), factory);
+    registerKernelFactory(IntegrateQTBStepKernel::Name(), factory);
     registerKernelFactory(ApplyAndersenThermostatKernel::Name(), factory);
     registerKernelFactory(ApplyMonteCarloBarostatKernel::Name(), factory);
     registerKernelFactory(RemoveCMMotionKernel::Name(), factory);
diff --git a/platforms/reference/include/ReferenceKernels.h b/platforms/reference/include/ReferenceKernels.h
index 1c039e9bcc..c19f5a6bd1 100644
--- a/platforms/reference/include/ReferenceKernels.h
+++ b/platforms/reference/include/ReferenceKernels.h
@@ -69,6 +69,7 @@ class ReferenceVariableVerletDynamics;
 class ReferenceVerletDynamics;
 class ReferenceCustomDynamics;
 class ReferenceDPDDynamics;
+class ReferenceQTBDynamics;

 /**
  * This kernel is invoked at the beginning and end of force and energy computations.  It gives the
@@ -1546,6 +1547,76 @@ class ReferenceIntegrateDPDStepKernel : public IntegrateDPDStepKernel {
     std::vector<double> masses;
 };

+/**
+ * This kernel is invoked by QTBIntegrator to take one time step.
+ */
+class ReferenceIntegrateQTBStepKernel : public IntegrateQTBStepKernel {
+public:
+    ReferenceIntegrateQTBStepKernel(std::string name, const Platform& platform, ReferencePlatform::PlatformData& data) : IntegrateQTBStepKernel(name, platform),
+        data(data), dynamics(NULL), hasInitialized(false) {
+    }
+    ~ReferenceIntegrateQTBStepKernel();
+    /**
+     * Initialize the kernel.
+     *
+     * @param system     the System this kernel will be applied to
+     * @param integrator the QTBIntegrator this kernel will be used for
+     */
+    void initialize(const System& system, const QTBIntegrator& integrator);
+    /**
+     * Execute the kernel.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param integrator the QTBIntegrator this kernel is being used for
+     */
+    void execute(ContextImpl& context, const QTBIntegrator& integrator);
+    /**
+     * Compute the kinetic energy.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param integrator the QTBIntegrator this kernel is being used for
+     */
+    double computeKineticEnergy(ContextImpl& context, const QTBIntegrator& integrator);
+    /**
+     * Get the adapted friction coefficients for a particle.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param particle   the index of the particle for which to get the friction
+     * @param friction   the adapted friction coefficients used in generating the
+     *                   random force
+     */
+    void getAdaptedFriction(ContextImpl& context, int particle, std::vector<double>& friction) const;
+    /**
+     * Set the adapted friction coefficients for a particle.  This affects the
+     * specified particle, and all others that have the same type.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param particle   the index of the particle for which to get the friction
+     * @param friction   the adapted friction coefficients used in generating the
+     *                   random force
+     */
+    void setAdaptedFriction(ContextImpl& context, int particle, const std::vector<double>& friction);
+    /**
+     * Write the adapted friction to a checkpoint.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param stream     the stream to write the checkpoint to
+     */
+    void createCheckpoint(ContextImpl& context, std::ostream& stream) const;
+    /**
+     * Load the adapted friction from a checkpoint.
+     *
+     * @param context    the context in which to execute this kernel
+     * @param stream     the stream to read the checkpoint from
+     */
+    void loadCheckpoint(ContextImpl& context, std::istream& stream);
+private:
+    ReferencePlatform::PlatformData& data;
+    ReferenceQTBDynamics* dynamics;
+    std::vector<double> masses;
+    bool hasInitialized;
+};
+
 /**
  * This kernel is invoked by AndersenThermostat at the start of each time step to adjust the particle velocities.
  */
diff --git a/platforms/reference/include/ReferenceQTBDynamics.h b/platforms/reference/include/ReferenceQTBDynamics.h
new file mode 100644
index 0000000000..8e1381e5cf
--- /dev/null
+++ b/platforms/reference/include/ReferenceQTBDynamics.h
@@ -0,0 +1,131 @@
+/* Portions copyright (c) 2025 Stanford University and the Authors.
+ * Authors: Peter Eastman
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject
+ * to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included
+ * in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+#ifndef __ReferenceQTBDynamics_H__
+#define __ReferenceQTBDynamics_H__
+
+#include "ReferenceDynamics.h"
+#include "openmm/QTBIntegrator.h"
+#include "openmm/internal/ContextImpl.h"
+#include "openmm/internal/ThreadPool.h"
+#include "openmm/internal/windowsExport.h"
+#include <complex>
+
+namespace OpenMM {
+
+class OPENMM_EXPORT ReferenceQTBDynamics : public ReferenceDynamics {
+protected:
+    double friction, lastTemperature;
+    int segmentLength, stepIndex, numFreq;
+    std::vector<OpenMM::Vec3> xPrime, oldx, randomForce, segmentVelocity;
+    std::vector<double> inverseMasses, typeAdaptationRate, typeMass;
+    std::vector<double> noise, theta, thetad, cutoffFunction;
+    std::vector<int> particleType;
+    std::vector<std::vector<int> > typeParticles;
+    std::vector<std::vector<double> > adaptedFriction;
+
+public:
+    /**
+     * Constructor
+     *
+     * @param system       the system to simulate
+     * @param integrator   the integrator being used
+     */
+    ReferenceQTBDynamics(const System& system, const QTBIntegrator& integrator);
+
+    /**
+     * Destructor
+     */
+    ~ReferenceQTBDynamics();
+
+    /**
+     * Perform a time step, updating the positions and velocities.
+     *
+     * @param context             the context this integrator is updating
+     * @param system              the System to be integrated
+     * @param atomCoordinates     atom coordinates
+     * @param velocities          velocities
+     * @param masses              atom masses
+     * @param tolerance           the constraint tolerance
+     * @param boxVectors          the current periodic box vectors
+     * @param threads      a ThreadPool to use for parallelization
+     */
+    void update(OpenMM::ContextImpl& context, std::vector<OpenMM::Vec3>& atomCoordinates,
+                std::vector<OpenMM::Vec3>& velocities, std::vector<double>& masses, double tolerance, const Vec3* boxVectors, ThreadPool& threads);
+
+    /**
+     * The first stage of the update algorithm.
+     */
+    virtual void updatePart1(int numParticles, std::vector<OpenMM::Vec3>& velocities, std::vector<OpenMM::Vec3>& forces);
+
+    /**
+     * The second stage of the update algorithm.
+     */
+    virtual void updatePart2(int numParticles, std::vector<OpenMM::Vec3>& atomCoordinates, std::vector<OpenMM::Vec3>& velocities,
+                             std::vector<OpenMM::Vec3>& xPrime);
+
+    /**
+     * The third stage of the update algorithm.
+     */
+    virtual void updatePart3(OpenMM::ContextImpl& context, int numParticles, std::vector<OpenMM::Vec3>& atomCoordinates,
+                             std::vector<OpenMM::Vec3>& velocities, std::vector<OpenMM::Vec3>& xPrime);
+    /**
+     * Get the adapted friction coefficients for a particle.
+     *
+     * @param particle   the index of the particle for which to get the friction
+     * @param friction   the adapted friction coefficients used in generating the
+     *                   random force.
+     */
+    void getAdaptedFriction(int particle, std::vector<double>& friction) const;
+    /**
+     * Set the adapted friction coefficients for a particle.  This affects the
+     * specified particle, and all others that have the same type.
+     *
+     * @param particle   the index of the particle for which to get the friction
+     * @param friction   the adapted friction coefficients used in generating the
+     *                   random force.
+     */
+    void setAdaptedFriction(int particle, const std::vector<double>& friction);
+    /**
+     * Write the adapted friction to a checkpoint.
+     */
+    void createCheckpoint(std::ostream& stream) const;
+    /**
+     * Load the adapted friction from a checkpoint.
+     */
+    void loadCheckpoint(std::istream& stream);
+
+private:
+    /**
+     * Generate noise for the next segment.
+     */
+    void generateNoise(int numParticles, std::vector<double>& masses, ThreadPool& threads);
+    /**
+     * Update the friction rates used for generating noise.
+     */
+    void adaptFriction(ThreadPool& threads);
+};
+
+} // namespace OpenMM
+
+#endif // __ReferenceQTBDynamics_H__
diff --git a/platforms/reference/src/ReferenceKernelFactory.cpp b/platforms/reference/src/ReferenceKernelFactory.cpp
index 8c1cdcde2a..dbe1421596 100644
--- a/platforms/reference/src/ReferenceKernelFactory.cpp
+++ b/platforms/reference/src/ReferenceKernelFactory.cpp
@@ -106,6 +106,8 @@ KernelImpl* ReferenceKernelFactory::createKernelImpl(std::string name, const Pla
         return new ReferenceIntegrateCustomStepKernel(name, platform, data);
     if (name == IntegrateDPDStepKernel::Name())
         return new ReferenceIntegrateDPDStepKernel(name, platform, data);
+    if (name == IntegrateQTBStepKernel::Name())
+        return new ReferenceIntegrateQTBStepKernel(name, platform, data);
     if (name == ApplyAndersenThermostatKernel::Name())
         return new ReferenceApplyAndersenThermostatKernel(name, platform);
     if (name == ApplyMonteCarloBarostatKernel::Name())
diff --git a/platforms/reference/src/ReferenceKernels.cpp b/platforms/reference/src/ReferenceKernels.cpp
index 7896f13359..17bdd9fec2 100644
--- a/platforms/reference/src/ReferenceKernels.cpp
+++ b/platforms/reference/src/ReferenceKernels.cpp
@@ -60,6 +60,7 @@
 #include "ReferenceNoseHooverDynamics.h"
 #include "ReferencePointFunctions.h"
 #include "ReferenceProperDihedralBond.h"
+#include "ReferenceQTBDynamics.h"
 #include "ReferenceRbDihedralBond.h"
 #include "ReferenceRMSDForce.h"
 #include "ReferenceTabulatedFunction.h"
@@ -2899,6 +2900,55 @@ double ReferenceIntegrateDPDStepKernel::computeKineticEnergy(ContextImpl& contex
     return computeShiftedKineticEnergy(context, masses, 0.0);
 }

+ReferenceIntegrateQTBStepKernel::~ReferenceIntegrateQTBStepKernel() {
+    if (dynamics != NULL)
+        delete dynamics;
+}
+
+void ReferenceIntegrateQTBStepKernel::initialize(const System& system, const QTBIntegrator& integrator) {
+    int numParticles = system.getNumParticles();
+    masses.resize(numParticles);
+    for (int i = 0; i < numParticles; ++i)
+        masses[i] = system.getParticleMass(i);
+    SimTKOpenMMUtilities::setRandomNumberSeed((unsigned int) integrator.getRandomNumberSeed());
+    dynamics = new ReferenceQTBDynamics(system, integrator);
+}
+
+void ReferenceIntegrateQTBStepKernel::execute(ContextImpl& context, const QTBIntegrator& integrator) {
+    double stepSize = integrator.getStepSize();
+    vector<Vec3>& posData = extractPositions(context);
+    vector<Vec3>& velData = extractVelocities(context);
+    if (!hasInitialized) {
+        hasInitialized = true;
+        dynamics->setReferenceConstraintAlgorithm(&extractConstraints(context));
+        dynamics->setVirtualSites(extractVirtualSites(context));
+    }
+    dynamics->setTemperature(integrator.getTemperature());
+    dynamics->update(context, posData, velData, masses, integrator.getConstraintTolerance(), extractBoxVectors(context), extractThreadPool(context));
+    data.time += stepSize;
+    data.stepCount++;
+}
+
+double ReferenceIntegrateQTBStepKernel::computeKineticEnergy(ContextImpl& context, const QTBIntegrator& integrator) {
+    return computeShiftedKineticEnergy(context, masses, 0.0);
+}
+
+void ReferenceIntegrateQTBStepKernel::getAdaptedFriction(ContextImpl& context, int particle, std::vector<double>& friction) const {
+    dynamics->getAdaptedFriction(particle, friction);
+}
+
+void ReferenceIntegrateQTBStepKernel::setAdaptedFriction(ContextImpl& context, int particle, const std::vector<double>& friction) {
+    dynamics->setAdaptedFriction(particle, friction);
+}
+
+void ReferenceIntegrateQTBStepKernel::createCheckpoint(ContextImpl& context, ostream& stream) const {
+    dynamics->createCheckpoint(stream);
+}
+
+void ReferenceIntegrateQTBStepKernel::loadCheckpoint(ContextImpl& context, istream& stream) {
+    dynamics->loadCheckpoint(stream);
+}
+
 ReferenceApplyAndersenThermostatKernel::~ReferenceApplyAndersenThermostatKernel() {
     if (thermostat)
         delete thermostat;
diff --git a/platforms/reference/src/ReferencePlatform.cpp b/platforms/reference/src/ReferencePlatform.cpp
index 04394254d4..70fc071ec4 100644
--- a/platforms/reference/src/ReferencePlatform.cpp
+++ b/platforms/reference/src/ReferencePlatform.cpp
@@ -76,6 +76,7 @@ ReferencePlatform::ReferencePlatform() {
     registerKernelFactory(IntegrateVariableVerletStepKernel::Name(), factory);
     registerKernelFactory(IntegrateCustomStepKernel::Name(), factory);
     registerKernelFactory(IntegrateDPDStepKernel::Name(), factory);
+    registerKernelFactory(IntegrateQTBStepKernel::Name(), factory);
     registerKernelFactory(ApplyAndersenThermostatKernel::Name(), factory);
     registerKernelFactory(ApplyMonteCarloBarostatKernel::Name(), factory);
     registerKernelFactory(RemoveCMMotionKernel::Name(), factory);
diff --git a/platforms/reference/src/SimTKReference/ReferenceQTBDynamics.cpp b/platforms/reference/src/SimTKReference/ReferenceQTBDynamics.cpp
new file mode 100644
index 0000000000..ed9b114529
--- /dev/null
+++ b/platforms/reference/src/SimTKReference/ReferenceQTBDynamics.cpp
@@ -0,0 +1,264 @@
+/* Portions copyright (c) 2025 Stanford University and the Authors.
+ * Authors: Peter Eastman
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject
+ * to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included
+ * in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+#include "SimTKOpenMMUtilities.h"
+#include "ReferenceQTBDynamics.h"
+#include "ReferencePlatform.h"
+#include "ReferenceVirtualSites.h"
+#include "openmm/OpenMMException.h"
+#include "openmm/internal/ContextImpl.h"
+#include "openmm/internal/QTBIntegratorUtilities.h"
+#include <algorithm>
+#include <cmath>
+#include <map>
+
+#ifdef _MSC_VER
+  #define POCKETFFT_NO_VECTORS
+#endif
+#include "pocketfft_hdronly.h"
+
+using namespace OpenMM;
+using namespace std;
+
+ReferenceQTBDynamics::ReferenceQTBDynamics(const System& system, const QTBIntegrator& integrator) :
+            ReferenceDynamics(system.getNumParticles(), integrator.getStepSize(), integrator.getTemperature()), friction(integrator.getFriction()), stepIndex(0) {
+    segmentLength = (int) ceil(integrator.getSegmentLength()/integrator.getStepSize());
+    int numParticles = system.getNumParticles();
+    xPrime.resize(numParticles);
+    oldx.resize(numParticles);
+    noise.resize(3*3*segmentLength*numParticles);
+    randomForce.resize(segmentLength*numParticles);
+    segmentVelocity.resize(segmentLength*numParticles);
+    for (int i = 0; i < noise.size(); i++)
+        noise[i] = SimTKOpenMMUtilities::getNormallyDistributedRandomNumber();
+    QTBIntegratorUtilities::findTypes(system, integrator, particleType, typeParticles, typeMass, typeAdaptationRate);
+
+    // Calculate the target energy distribution.
+
+    numFreq = (3*segmentLength+1)/2;
+    cutoffFunction.resize(numFreq);
+    double cutoff = integrator.getCutoffFrequency();
+    double cutoffWidth = cutoff/100;
+    for (int i = 0; i < numFreq; i++) {
+        double w = M_PI*i/(numFreq*getDeltaT());
+        cutoffFunction[i] = 1.0/(1.0+exp((w-cutoff)/cutoffWidth));
+    }
+
+    // Allocate space for adaptation.
+
+    int numTypes = typeParticles.size();
+    adaptedFriction.resize(numTypes, vector<double>(numFreq, friction));
+}
+
+ReferenceQTBDynamics::~ReferenceQTBDynamics() {
+}
+
+void ReferenceQTBDynamics::updatePart1(int numParticles, vector<Vec3>& velocities, vector<Vec3>& forces) {
+    for (int i = 0; i < numParticles; i++) {
+        segmentVelocity[i*segmentLength+stepIndex] = velocities[i];
+        if (inverseMasses[i] != 0.0)
+            velocities[i] += (getDeltaT()*inverseMasses[i])*forces[i];
+    }
+}
+
+void ReferenceQTBDynamics::updatePart2(int numParticles, vector<Vec3>& atomCoordinates,
+                                         vector<Vec3>& velocities, vector<Vec3>& xPrime) {
+    const double dt = getDeltaT();
+    const double halfdt = 0.5*dt;
+    const double vscale = exp(-dt*friction);
+
+    for (int i = 0; i < numParticles; i++) {
+        if (inverseMasses[i] != 0.0) {
+            xPrime[i] = atomCoordinates[i] + velocities[i]*halfdt;
+            velocities[i] = vscale*velocities[i] + inverseMasses[i]*dt*randomForce[segmentLength*i+stepIndex];
+            xPrime[i] = xPrime[i] + velocities[i]*halfdt;
+            oldx[i] = xPrime[i];
+        }
+    }
+}
+
+void ReferenceQTBDynamics::updatePart3(OpenMM::ContextImpl& context, int numParticles, vector<Vec3>& atomCoordinates,
+                                         vector<Vec3>& velocities, vector<Vec3>& xPrime) {
+    for (int i = 0; i < numParticles; i++) {
+        if (inverseMasses[i] != 0.0) {
+            velocities[i] += (xPrime[i]-oldx[i])/getDeltaT();
+            atomCoordinates[i] = xPrime[i];
+        }
+    }
+}
+
+void ReferenceQTBDynamics::update(ContextImpl& context, vector<Vec3>& atomCoordinates, vector<Vec3>& velocities,
+            vector<double>& masses, double tolerance, const Vec3* boxVectors, ThreadPool& threads) {
+    int numParticles = context.getSystem().getNumParticles();
+    ReferenceConstraintAlgorithm* referenceConstraintAlgorithm = getReferenceConstraintAlgorithm();
+    if (inverseMasses.size() == 0) {
+        // Invert masses
+
+        inverseMasses.resize(numParticles);
+        for (int ii = 0; ii < numParticles; ii++) {
+            if (masses[ii] == 0.0)
+                inverseMasses[ii] = 0.0;
+            else
+                inverseMasses[ii] = 1.0/masses[ii];
+        }
+    }
+    ReferencePlatform::PlatformData* data = reinterpret_cast<ReferencePlatform::PlatformData*>(context.getPlatformData());
+    vector<Vec3>& forces = *data->forces;
+    if (stepIndex%segmentLength == 0) {
+        if (lastTemperature != getTemperature() || thetad.size() == 0) {
+            QTBIntegratorUtilities::calculateSpectrum(getTemperature(), friction, getDeltaT(), numFreq, theta, thetad, threads);
+            lastTemperature = getTemperature();
+        }
+        adaptFriction(threads);
+        generateNoise(numParticles, masses, threads);
+        stepIndex = 0;
+    }
+
+    // 1st update
+
+    updatePart1(numParticles, velocities, forces);
+    if (referenceConstraintAlgorithm)
+        referenceConstraintAlgorithm->applyToVelocities(atomCoordinates, velocities, inverseMasses, tolerance);
+
+    // 2nd update
+
+    updatePart2(numParticles, atomCoordinates, velocities, xPrime);
+    if (referenceConstraintAlgorithm)
+        referenceConstraintAlgorithm->apply(atomCoordinates, xPrime, inverseMasses, tolerance);
+
+    // 3rd update
+
+    updatePart3(context, numParticles, atomCoordinates, velocities, xPrime);
+
+    getVirtualSites().computePositions(context.getSystem(), atomCoordinates, boxVectors);
+    incrementTimeStep();
+    stepIndex += 1;
+}
+
+void ReferenceQTBDynamics::generateNoise(int numParticles, vector<double>& masses, ThreadPool& threads) {
+    // Update the buffer of white noise.
+
+    for (int base = 0; base < noise.size(); base += 3*segmentLength) {
+        for (int i = 0; i < 2*segmentLength; i++)
+            noise[base+i] = noise[base+i+segmentLength];
+        for (int i = 0; i < segmentLength; i++)
+            noise[base+2*segmentLength+i] = SimTKOpenMMUtilities::getNormallyDistributedRandomNumber();
+    }
+
+    // Generate the random force for the next segment.
+
+    double dt = getDeltaT();
+    vector<ptrdiff_t> realStride = {(ptrdiff_t) sizeof(double)};
+    vector<ptrdiff_t> complexStride = {(ptrdiff_t) sizeof(complex<double>)};
+    vector<size_t> shape = {(size_t) 3*segmentLength}, axes = {0};
+    threads.execute([&] (ThreadPool& threads, int threadIndex) {
+        vector<complex<double> > recipData(3*segmentLength);
+        vector<double> force(3*segmentLength);
+        for (int particle = threadIndex; particle < numParticles; particle += threads.getNumThreads()) {
+            int type = particleType[particle];
+            for (int axis = 0; axis < 3; axis++) {
+                double* data = &noise[(3*particle+axis)*3*segmentLength];
+                pocketfft::r2c(shape, realStride, complexStride, axes, true, data, recipData.data(), 1.0, 1);
+                for (int i = 0; i < numFreq; i++) {
+                    double w = M_PI*i/(numFreq*dt);
+                    double gamma = adaptedFriction[type][i];
+                    double cw = (1 - 2*exp(-dt*friction)*cos(w*dt) + exp(-2*friction*dt)) / ((friction*friction+w*w)*dt*dt);
+                    recipData[i] *= sqrt(cutoffFunction[i]*thetad[i]*cw*gamma/friction);
+                }
+                pocketfft::c2r(shape, complexStride, realStride, axes, false, recipData.data(), force.data(), 1.0/(3*segmentLength), 1);
+                for (int i = 0; i < segmentLength; i++)
+                    randomForce[particle*segmentLength+i][axis] = sqrt(2*masses[particle]*friction/dt)*force[segmentLength+i];
+            }
+        }
+    });
+    threads.waitForThreads();
+}
+
+void ReferenceQTBDynamics::adaptFriction(ThreadPool& threads) {
+    vector<ptrdiff_t> realStride = {(ptrdiff_t) sizeof(double)};
+    vector<ptrdiff_t> complexStride = {(ptrdiff_t) sizeof(complex<double>)};
+    vector<size_t> shape = {(size_t) 3*segmentLength}, axes = {0};
+    threads.execute([&] (ThreadPool& threads, int threadIndex) {
+        vector<double> vel(3*segmentLength, 0.0), force(3*segmentLength, 0.0);
+        vector<complex<double> > recipVel(3*segmentLength), recipForce(3*segmentLength);
+        vector<double> dfdt(numFreq);
+        for (int type = threadIndex; type < typeParticles.size(); type += threads.getNumThreads()) {
+            for (int i = 0; i < dfdt.size(); i++)
+                dfdt[i] = 0;
+            for (int particle : typeParticles[type]) {
+                for (int axis = 0; axis < 3; axis++) {
+                    // Compute the Fourier transformed velocity and force.
+
+                    for (int i = 0; i < segmentLength; i++) {
+                        vel[i] = segmentVelocity[particle*segmentLength+i][axis];
+                        force[i] = randomForce[particle*segmentLength+i][axis];
+                    }
+                    pocketfft::r2c(shape, realStride, complexStride, axes, true, vel.data(), recipVel.data(), 1.0, 1);
+                    pocketfft::r2c(shape, realStride, complexStride, axes, true, force.data(), recipForce.data(), 1.0, 1);
+
+                    // Compute the error in the fluctuation dissipation theorem.
+
+                    double mass = typeMass[type];
+                    for (int i = 0; i < numFreq; i++) {
+                        double cvv = norm(recipVel[i]);
+                        complex<double> cvf = recipVel[i]*conj(recipForce[i]);
+                        dfdt[i] += mass*adaptedFriction[type][i]*cvv - cvf.real();
+                    }
+                }
+            }
+
+            // Average over particles and axes, and update the friction.
+
+            double scale = getDeltaT()/(3*typeParticles[type].size()*segmentLength);
+            for (int i = 0; i < adaptedFriction[type].size(); i++)
+                adaptedFriction[type][i] = max(0.0, adaptedFriction[type][i]-scale*typeAdaptationRate[type]*dfdt[i]);
+        }
+    });
+    threads.waitForThreads();
+}
+
+void ReferenceQTBDynamics::getAdaptedFriction(int particle, vector<double>& friction) const {
+    friction = adaptedFriction[particleType[particle]];
+}
+
+void ReferenceQTBDynamics::setAdaptedFriction(int particle, const std::vector<double>& friction) {
+    adaptedFriction[particleType[particle]] = friction;
+}
+
+void ReferenceQTBDynamics::createCheckpoint(std::ostream& stream) const {
+    stream.write((char*) &stepIndex, sizeof(int));
+    stream.write((char*) noise.data(), sizeof(double)*noise.size());
+    stream.write((char*) randomForce.data(), sizeof(Vec3)*randomForce.size());
+    stream.write((char*) segmentVelocity.data(), sizeof(Vec3)*segmentVelocity.size());
+    for (int i = 0; i < adaptedFriction.size(); i++)
+        stream.write((char*) adaptedFriction[i].data(), sizeof(double)*adaptedFriction[i].size());
+}
+
+void ReferenceQTBDynamics::loadCheckpoint(std::istream& stream) {
+    stream.read((char*) &stepIndex, sizeof(int));
+    stream.read((char*) noise.data(), sizeof(double)*noise.size());
+    stream.read((char*) randomForce.data(), sizeof(Vec3)*randomForce.size());
+    stream.read((char*) segmentVelocity.data(), sizeof(Vec3)*segmentVelocity.size());
+    for (int i = 0; i < adaptedFriction.size(); i++)
+        stream.read((char*) adaptedFriction[i].data(), sizeof(double)*adaptedFriction[i].size());
+}
diff --git a/serialization/include/openmm/serialization/QTBIntegratorProxy.h b/serialization/include/openmm/serialization/QTBIntegratorProxy.h
new file mode 100644
index 0000000000..4d5c0abeb2
--- /dev/null
+++ b/serialization/include/openmm/serialization/QTBIntegratorProxy.h
@@ -0,0 +1,17 @@
+#ifndef OPENMM_QTB_INTEGRATOR_PROXY_H_
+#define OPENMM_QTB_INTEGRATOR_PROXY_H_
+
+#include "openmm/serialization/XmlSerializer.h"
+
+namespace OpenMM {
+
+class QTBIntegratorProxy : public SerializationProxy {
+public:
+    QTBIntegratorProxy();
+    void serialize(const void* object, SerializationNode& node) const;
+    void* deserialize(const SerializationNode& node) const;
+};
+
+}
+
+#endif /*OPENMM_QTB_INTEGRATOR_PROXY_H_*/
\ No newline at end of file
diff --git a/serialization/src/QTBIntegratorProxy.cpp b/serialization/src/QTBIntegratorProxy.cpp
new file mode 100644
index 0000000000..23eb8e69ad
--- /dev/null
+++ b/serialization/src/QTBIntegratorProxy.cpp
@@ -0,0 +1,80 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/serialization/QTBIntegratorProxy.h"
+#include <OpenMM.h>
+
+using namespace std;
+using namespace OpenMM;
+
+QTBIntegratorProxy::QTBIntegratorProxy() : SerializationProxy("QTBIntegrator") {
+}
+
+void QTBIntegratorProxy::serialize(const void* object, SerializationNode& node) const {
+    node.setIntProperty("version", 1);
+    const QTBIntegrator& integrator = *reinterpret_cast<const QTBIntegrator*>(object);
+    node.setDoubleProperty("stepSize", integrator.getStepSize());
+    node.setDoubleProperty("constraintTolerance", integrator.getConstraintTolerance());
+    node.setDoubleProperty("temperature", integrator.getTemperature());
+    node.setDoubleProperty("friction", integrator.getFriction());
+    node.setDoubleProperty("segmentLength", integrator.getSegmentLength());
+    node.setDoubleProperty("cutoffFrequency", integrator.getCutoffFrequency());
+    node.setDoubleProperty("defaultAdaptationRate", integrator.getDefaultAdaptationRate());
+    node.setIntProperty("randomSeed", integrator.getRandomNumberSeed());
+    node.setIntProperty("integrationForceGroups", integrator.getIntegrationForceGroups());
+    SerializationNode& particleTypes = node.createChildNode("ParticleTypes");
+    for (auto type : integrator.getParticleTypes())
+        particleTypes.createChildNode("Particle").setIntProperty("index", type.first).setIntProperty("type", type.second);
+    SerializationNode& adaptationRates = node.createChildNode("TypeAdaptationRates");
+    for (auto type : integrator.getTypeAdaptationRates())
+        adaptationRates.createChildNode("Type").setIntProperty("index", type.first).setDoubleProperty("rate", type.second);
+}
+
+void* QTBIntegratorProxy::deserialize(const SerializationNode& node) const {
+    if (node.getIntProperty("version") != 1)
+        throw OpenMMException("Unsupported version number");
+    QTBIntegrator *integrator = new QTBIntegrator(node.getDoubleProperty("temperature"),
+                                                  node.getDoubleProperty("friction"),
+                                                  node.getDoubleProperty("stepSize"));
+    integrator->setConstraintTolerance(node.getDoubleProperty("constraintTolerance"));
+    integrator->setSegmentLength(node.getDoubleProperty("segmentLength"));
+    integrator->setCutoffFrequency(node.getDoubleProperty("cutoffFrequency"));
+    integrator->setDefaultAdaptationRate(node.getDoubleProperty("defaultAdaptationRate"));
+    integrator->setRandomNumberSeed(node.getIntProperty("randomSeed"));
+    integrator->setIntegrationForceGroups(node.getIntProperty("integrationForceGroups", 0xFFFFFFFF));
+    const SerializationNode& particleTypes = node.getChildNode("ParticleTypes");
+    for (auto& particle : particleTypes.getChildren())
+        integrator->setParticleType(particle.getIntProperty("index"), particle.getIntProperty("type"));
+    const SerializationNode& adaptationRates = node.getChildNode("TypeAdaptationRates");
+    for (auto& type : adaptationRates.getChildren())
+        integrator->setTypeAdaptationRate(type.getIntProperty("index"), type.getDoubleProperty("rate"));
+    return integrator;
+}
\ No newline at end of file
diff --git a/serialization/src/SerializationProxyRegistration.cpp b/serialization/src/SerializationProxyRegistration.cpp
index ad734c1d76..cfeb746aca 100644
--- a/serialization/src/SerializationProxyRegistration.cpp
+++ b/serialization/src/SerializationProxyRegistration.cpp
@@ -62,6 +62,7 @@
 #include "openmm/NonbondedForce.h"
 #include "openmm/NoseHooverIntegrator.h"
 #include "openmm/PeriodicTorsionForce.h"
+#include "openmm/QTBIntegrator.h"
 #include "openmm/RBTorsionForce.h"
 #include "openmm/RMSDForce.h"
 #include "openmm/System.h"
@@ -104,6 +105,7 @@
 #include "openmm/serialization/NonbondedForceProxy.h"
 #include "openmm/serialization/NoseHooverIntegratorProxy.h"
 #include "openmm/serialization/PeriodicTorsionForceProxy.h"
+#include "openmm/serialization/QTBIntegratorProxy.h"
 #include "openmm/serialization/RBTorsionForceProxy.h"
 #include "openmm/serialization/RMSDForceProxy.h"
 #include "openmm/serialization/StateProxy.h"
@@ -168,6 +170,7 @@ extern "C" void registerSerializationProxies() {
     SerializationProxy::registerProxy(typeid(NoseHooverIntegrator), new NoseHooverIntegratorProxy());
     SerializationProxy::registerProxy(typeid(PeriodicTorsionForce), new PeriodicTorsionForceProxy());
     SerializationProxy::registerProxy(typeid(RBTorsionForce), new RBTorsionForceProxy());
+    SerializationProxy::registerProxy(typeid(QTBIntegrator), new QTBIntegratorProxy());
     SerializationProxy::registerProxy(typeid(RMSDForce), new RMSDForceProxy());
     SerializationProxy::registerProxy(typeid(System), new SystemProxy());
     SerializationProxy::registerProxy(typeid(State), new StateProxy());
diff --git a/wrappers/generateWrappers.py b/wrappers/generateWrappers.py
index 7f6a8564f8..0709bd18d2 100644
--- a/wrappers/generateWrappers.py
+++ b/wrappers/generateWrappers.py
@@ -95,7 +95,9 @@ def __init__(self, inputDirname, output):
                             'bool OpenMM::Discrete1DFunction::operator!=',
                             'bool OpenMM::Discrete2DFunction::operator!=',
                             'bool OpenMM::Discrete3DFunction::operator!=',
-                            'const std::map<int, int>& OpenMM::DPDIntegrator::getParticleTypes'
+                            'const std::map<int, int>& OpenMM::DPDIntegrator::getParticleTypes',
+                            'const std::map<int, int>& OpenMM::QTBIntegrator::getParticleTypes',
+                            'const std::map<int, double>& OpenMM::QTBIntegrator::getTypeAdaptationRates'
                            ]
         self.skipMethods = [s.replace(' ', '') for s in self.skipMethods]
         self.hideClasses = ['Kernel', 'KernelImpl', 'KernelFactory', 'ContextImpl', 'SerializationNode', 'SerializationProxy']
diff --git a/wrappers/python/src/swig_doxygen/swigInputConfig.py b/wrappers/python/src/swig_doxygen/swigInputConfig.py
index 65a08feede..9a70fc0391 100755
--- a/wrappers/python/src/swig_doxygen/swigInputConfig.py
+++ b/wrappers/python/src/swig_doxygen/swigInputConfig.py
@@ -512,6 +512,14 @@
 ("DPDIntegrator", "getDefaultCutoff") : ("unit.nanometer", ()),
 ("DPDIntegrator", "setDefaultCutoff") : (None, ("unit.nanometer",)),
 ("DPDIntegrator", "getParticleTypes") : (None, ()),
+("QTBIntegrator", "getSegmentLength") : ("unit.picosecond", ()),
+("QTBIntegrator", "setSegmentLength") : (None, ("unit.picosecond",)),
+("QTBIntegrator", "getDefaultAdaptationRate") : (None, ()),
+("QTBIntegrator", "setDefaultAdaptationRate") : (None, (None,)),
+("QTBIntegrator", "getTypeAdaptationRates") : (None, ()),
+("QTBIntegrator", "getParticleTypes") : (None, ()),
+("QTBIntegrator", "getCutoffFrequency") : ("unit.picosecond**-1", ()),
+("QTBIntegrator", "setCutoffFrequency") : (None, ("unit.picosecond**-1",)),
 ("LocalEnergyMinimizer", "minimize") : (None, (None, "unit.kilojoules_per_mole/unit.nanometer", None)),
 ("ATMForce", "getForce") : (None, ()),
 ("ATMForce", "getPerturbationEnergy") :  ('unit.kilojoule_per_mole', ()),

diff --git a/platforms/cuda/tests/TestCudaQTBIntegrator.cpp b/platforms/cuda/tests/TestCudaQTBIntegrator.cpp
new file mode 100644
index 0000000000..2d6816c1d9
--- /dev/null
+++ b/platforms/cuda/tests/TestCudaQTBIntegrator.cpp
@@ -0,0 +1,36 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "CudaTests.h"
+#include "TestQTBIntegrator.h"
+
+void runPlatformTests() {
+}
diff --git a/platforms/hip/tests/TestHipQTBIntegrator.cpp b/platforms/hip/tests/TestHipQTBIntegrator.cpp
new file mode 100644
index 0000000000..ee8d9acd23
--- /dev/null
+++ b/platforms/hip/tests/TestHipQTBIntegrator.cpp
@@ -0,0 +1,36 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "HipTests.h"
+#include "TestQTBIntegrator.h"
+
+void runPlatformTests() {
+}
diff --git a/platforms/opencl/tests/TestOpenCLQTBIntegrator.cpp b/platforms/opencl/tests/TestOpenCLQTBIntegrator.cpp
new file mode 100644
index 0000000000..5bdc749dc0
--- /dev/null
+++ b/platforms/opencl/tests/TestOpenCLQTBIntegrator.cpp
@@ -0,0 +1,36 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "OpenCLTests.h"
+#include "TestQTBIntegrator.h"
+
+void runPlatformTests() {
+}
diff --git a/platforms/reference/tests/TestReferenceQTBIntegrator.cpp b/platforms/reference/tests/TestReferenceQTBIntegrator.cpp
new file mode 100644
index 0000000000..4ff579a047
--- /dev/null
+++ b/platforms/reference/tests/TestReferenceQTBIntegrator.cpp
@@ -0,0 +1,36 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "ReferenceTests.h"
+#include "TestQTBIntegrator.h"
+
+void runPlatformTests() {
+}
diff --git a/serialization/tests/TestSerializeQTBIntegrator.cpp b/serialization/tests/TestSerializeQTBIntegrator.cpp
new file mode 100644
index 0000000000..1e07e9b762
--- /dev/null
+++ b/serialization/tests/TestSerializeQTBIntegrator.cpp
@@ -0,0 +1,83 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/internal/AssertionUtilities.h"
+#include "openmm/QTBIntegrator.h"
+#include "openmm/serialization/XmlSerializer.h"
+#include <iostream>
+#include <sstream>
+#include <stdlib.h>
+
+#include <fstream>
+
+using namespace OpenMM;
+using namespace std;
+
+void testSerialization() {
+    QTBIntegrator integ(310.0, 10.0, 0.002);
+    integ.setSegmentLength(0.5);
+    integ.setCutoffFrequency(600.0);
+    integ.setDefaultAdaptationRate(0.02);
+    integ.setRandomNumberSeed(10);
+    integ.setIntegrationForceGroups(3);
+    for (int i = 0; i < 5; i++)
+        integ.setParticleType(i, i%3);
+    for (int i = 0; i < 3; i++)
+        integ.setTypeAdaptationRate(i, 0.1*i);
+    stringstream ss;
+    XmlSerializer::serialize<Integrator>(&integ, "QTBIntegrator", ss);
+    QTBIntegrator *copy = dynamic_cast<QTBIntegrator*>(XmlSerializer::deserialize<Integrator>(ss));
+    QTBIntegrator& integ2 = *copy;
+    ASSERT_EQUAL(integ.getConstraintTolerance(), integ2.getConstraintTolerance());
+    ASSERT_EQUAL(integ.getStepSize(), integ2.getStepSize());
+    ASSERT_EQUAL(integ.getTemperature(), integ2.getTemperature());
+    ASSERT_EQUAL(integ.getFriction(), integ2.getFriction());
+    ASSERT_EQUAL(integ.getSegmentLength(), integ2.getSegmentLength());
+    ASSERT_EQUAL(integ.getCutoffFrequency(), integ2.getCutoffFrequency());
+    ASSERT_EQUAL(integ.getDefaultAdaptationRate(), integ2.getDefaultAdaptationRate());
+    ASSERT_EQUAL(integ.getRandomNumberSeed(), integ2.getRandomNumberSeed());
+    ASSERT_EQUAL(integ.getIntegrationForceGroups(), integ2.getIntegrationForceGroups());
+    ASSERT_EQUAL_CONTAINERS(integ.getParticleTypes(), integ2.getParticleTypes());
+    ASSERT_EQUAL_CONTAINERS(integ.getTypeAdaptationRates(), integ2.getTypeAdaptationRates());
+    delete copy;
+}
+
+int main() {
+    try {
+        testSerialization();
+    }
+    catch(const exception& e) {
+        cout << "exception: " << e.what() << endl;
+        return 1;
+    }
+    cout << "Done" << endl;
+    return 0;
+}
diff --git a/tests/TestQTBIntegrator.h b/tests/TestQTBIntegrator.h
new file mode 100644
index 0000000000..a1623e3ec9
--- /dev/null
+++ b/tests/TestQTBIntegrator.h
@@ -0,0 +1,497 @@
+/* -------------------------------------------------------------------------- *
+ *                                   OpenMM                                   *
+ * -------------------------------------------------------------------------- *
+ * This is part of the OpenMM molecular simulation toolkit originating from   *
+ * Simbios, the NIH National Center for Physics-Based Simulation of           *
+ * Biological Structures at Stanford, funded under the NIH Roadmap for        *
+ * Medical Research, grant U54 GM072970. See https://simtk.org.               *
+ *                                                                            *
+ * Portions copyright (c) 2025 Stanford University and the Authors.           *
+ * Authors: Peter Eastman                                                     *
+ * Contributors:                                                              *
+ *                                                                            *
+ * Permission is hereby granted, free of charge, to any person obtaining a    *
+ * copy of this software and associated documentation files (the "Software"), *
+ * to deal in the Software without restriction, including without limitation  *
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,   *
+ * and/or sell copies of the Software, and to permit persons to whom the      *
+ * Software is furnished to do so, subject to the following conditions:       *
+ *                                                                            *
+ * The above copyright notice and this permission notice shall be included in *
+ * all copies or substantial portions of the Software.                        *
+ *                                                                            *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR *
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,   *
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL    *
+ * THE AUTHORS, CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,    *
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR      *
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE  *
+ * USE OR OTHER DEALINGS IN THE SOFTWARE.                                     *
+ * -------------------------------------------------------------------------- */
+
+#include "openmm/internal/AssertionUtilities.h"
+#include "openmm/Context.h"
+#include "openmm/CustomBondForce.h"
+#include "openmm/CustomExternalForce.h"
+#include "openmm/CustomNonbondedForce.h"
+#include "openmm/HarmonicBondForce.h"
+#include "openmm/NonbondedForce.h"
+#include "openmm/System.h"
+#include "openmm/QTBIntegrator.h"
+#include "SimTKOpenMMRealType.h"
+#include "sfmt/SFMT.h"
+#include <iostream>
+#include <vector>
+
+using namespace OpenMM;
+using namespace std;
+
+void testHarmonic() {
+    // Create a collection of uncoupled harmonic oscillators.
+
+    int numFrequencies = 10;
+    int numReplicas = 10;
+    int numParticles = numFrequencies*numReplicas;
+    double temperature = 300.0;
+    double mass = 1.0;
+    System system;
+    vector<Vec3> positions(numParticles);
+    vector<double> k;
+    CustomExternalForce* force = new CustomExternalForce("0.5*k*(x*x+y*y+z*z)");
+    system.addForce(force);
+    force->addPerParticleParameter("k");
+    QTBIntegrator integrator(temperature, 10.0, 0.001);
+    integrator.setCutoffFrequency(500.0);
+    for (int j = 0; j < numReplicas; j++) {
+        int base = system.getNumParticles();
+        for (int i = 0; i < numFrequencies; i++) {
+            system.addParticle(mass);
+            k.push_back(1000.0*(i+1)*(i+1));
+            force->addParticle(i+base, {k[i]});
+            integrator.setParticleType(i+base, i);
+        }
+    }
+    Context context(system, integrator, platform);
+    context.setPositions(positions);
+    context.setVelocitiesToTemperature(temperature);
+
+    // Compute the average energy of each particle over a simulation.
+
+    integrator.step(10000);
+    vector<double> energy(numParticles, 0.0);
+    int numSteps = 10000;
+    for (int i = 0; i < numSteps; i++) {
+        integrator.step(10);
+        State state = context.getState(State::Positions);
+        for (int j = 0; j < numParticles; j++) {
+            Vec3 p = state.getPositions()[j];
+            energy[j%numFrequencies] += 0.5*k[j]*p.dot(p);
+        }
+    }
+    for (int i = 0; i < numParticles; i++)
+        energy[i] /= numSteps*numReplicas;
+
+    // Compare to the expected distribution.
+
+    for (int i = 0; i < numFrequencies; i++) {
+        double w = sqrt(k[i]/mass);
+        double hbar = 1.054571628e-34*AVOGADRO/(1000*1e-12);
+        double kT = BOLTZ*temperature;
+        double expected = 1.5*hbar*w*(0.5+1/(exp(hbar*w/kT)-1));
+        ASSERT_USUALLY_EQUAL_TOL(expected, energy[i], 0.07);
+    }
+}
+
+void testCoupledHarmonic() {
+    // Create a collection of weakly coupled harmonic oscillators.
+
+    int numFrequencies = 8;
+    int numReplicas = 10;
+    int numParticles = numFrequencies*numReplicas;
+    double temperature = 10.0;
+    double mass = 1.0;
+    System system;
+    vector<Vec3> positions(numParticles);
+    vector<double> k;
+    CustomExternalForce* force = new CustomExternalForce("0.5*k*(x*x+y*y+z*z)");
+    system.addForce(force);
+    force->addPerParticleParameter("k");
+    CustomBondForce* bonds = new CustomBondForce("sin(100*r)");
+    system.addForce(bonds);
+    QTBIntegrator integrator(temperature, 50.0, 0.001);
+    integrator.setCutoffFrequency(500.0);
+    integrator.setDefaultAdaptationRate(1.0);
+    for (int j = 0; j < numReplicas; j++) {
+        int base = system.getNumParticles();
+        for (int i = 0; i < numFrequencies; i++) {
+            system.addParticle(mass);
+            k.push_back(1000.0*(i+1)*(i+1));
+            force->addParticle(i+base, {k[i]});
+            integrator.setParticleType(i+base, i);
+        }
+        for (int i = 0; i < numFrequencies; i++)
+            for (int k = 0; k < i; k++)
+                bonds->addBond(i+base, k+base);
+    }
+    Context context(system, integrator, platform);
+    context.setPositions(positions);
+    context.setVelocitiesToTemperature(temperature);
+
+    // Equilibrate with a high adaptation rate to let the spectrum converge.
+
+    for (int i = 0; i < 15; i++) {
+        integrator.step(10000);
+        // OpenCL on Mac hangs if we go too long without downloading anything.  I don't know why.
+        context.getState(State::Positions);
+    }
+    integrator.setDefaultAdaptationRate(0.05);
+    context.reinitialize(true);
+
+    // Compute the average energy of each particle over a simulation.
+
+    vector<double> energy(numFrequencies, 0.0);
+    int numSteps = 20000;
+    for (int i = 0; i < numSteps; i++) {
+        integrator.step(10);
+        State state = context.getState(State::Positions);
+        for (int j = 0; j < numParticles; j++) {
+            Vec3 p = state.getPositions()[j];
+            energy[j%numFrequencies] += 0.5*k[j]*p.dot(p);
+        }
+    }
+    for (int i = 0; i < numFrequencies; i++)
+        energy[i] /= numSteps*numReplicas;
+
+    // Compare to the expected distribution.
+
+    for (int i = 0; i < numFrequencies; i++) {
+        double w = sqrt(k[i]/mass);
+        double hbar = 1.054571628e-34*AVOGADRO/(1000*1e-12);
+        double kT = BOLTZ*temperature;
+        double expected = 1.5*hbar*w*(0.5+1/(exp(hbar*w/kT)-1));
+        ASSERT_USUALLY_EQUAL_TOL(expected, energy[i], 0.15);
+    }
+}
+
+void testParaHydrogen() {
+    const int numParticles = 32;
+    const double temperature = 25.0;
+    const double mass = 2.0;
+    const double boxSize = 1.1896;
+    const int numSteps = 2000;
+    const int numBins = 200;
+    const double reference[] = {
+        0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
+        0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
+        0.0, 0.0, 0.0, 4.932814042206152e-5, 1.244331241336431e-4, 4.052316284060125e-4,
+        1.544810863683946e-3, 4.376197806690222e-3, 1.025847561714293e-2, 2.286702037465422e-2,
+        4.371052180263602e-2, 7.518538770734748e-2, 0.122351534531647, 0.185758975626622,
+        0.266399984652322, 0.363380262153250, 0.473696401293219, 0.595312098494172,
+        0.726049519422861, 0.862264551954547, 0.991102029379444, 1.1147503922535,
+        1.23587006992066, 1.33495411932817, 1.42208208736987, 1.49273884004107,
+        1.54633319690403, 1.58714702233941, 1.60439217751355, 1.61804190608902,
+        1.60680198476058, 1.58892222973695, 1.56387607986781, 1.52629494593350,
+        1.48421439018970, 1.43656176771959, 1.38752775598872, 1.33310695719931,
+        1.28363477223121, 1.23465642750248, 1.18874848666326, 1.14350496170519,
+        1.10292486009936, 1.06107270157688, 1.02348927970441, 0.989729345271297,
+        0.959273446941802, 0.932264875865758, 0.908818658748942, 0.890946420768315,
+        0.869332737718165, 0.856401736350349, 0.842370069917020, 0.834386614237393,
+        0.826268072171045, 0.821547250199453, 0.818786865315836, 0.819441757028076,
+        0.819156933383128, 0.822275325148621, 0.828919078023881, 0.837233720599450,
+        0.846961908186718, 0.855656955481099, 0.864520333201247, 0.876082425547566,
+        0.886950044046000, 0.900275658318995
+    };
+
+    // Create a box of para-hydrogen.
+
+    System system;
+    for (int i = 0; i < numParticles; i++)
+        system.addParticle(mass);
+    system.setDefaultPeriodicBoxVectors(Vec3(boxSize,0,0), Vec3(0,boxSize,0), Vec3(0,0,boxSize));
+    CustomNonbondedForce* nb = new CustomNonbondedForce("2625.49963*(exp(1.713-1.5671*p-0.00993*p*p)-(12.14/p^6+215.2/p^8-143.1/p^9+4813.9/p^10)*(step(rc-p)*exp(-(rc/p-1)^2)+1-step(rc-p))); p=r/0.05291772108; rc=8.32");
+    nb->setNonbondedMethod(CustomNonbondedForce::CutoffPeriodic);
+    nb->setCutoffDistance(boxSize/2);
+    vector<double> params;
+    for (int i = 0; i < numParticles; i++)
+        nb->addParticle(params);
+    system.addForce(nb);
+    QTBIntegrator integ(temperature, 40.0, 0.001);
+    for (int i = 0; i < numParticles; i++)
+        integ.setParticleType(i, 0);
+    integ.setDefaultAdaptationRate(0.5);
+    integ.setSegmentLength(0.5);
+    Context context(system, integ, platform);
+    OpenMM_SFMT::SFMT sfmt;
+    init_gen_rand(0, sfmt);
+    vector<Vec3> positions(numParticles);
+    for (int i = 0; i < numParticles; i++)
+        positions[i] = Vec3(boxSize*genrand_real2(sfmt), boxSize*genrand_real2(sfmt), boxSize*genrand_real2(sfmt));
+    context.setPositions(positions);
+    integ.step(50000);
+
+    // Simulate it.
+
+    vector<int> counts(numBins, 0);
+    const double invBoxSize = 1.0/boxSize;
+    for (int step = 0; step < numSteps; step++) {
+        integ.step(20);
+        State state = context.getState(State::Positions);
+
+        // Record the radial distribution function.
+
+        const vector<Vec3>& pos = state.getPositions();
+        for (int j = 0; j < numParticles; j++)
+            for (int k = 0; k < j; k++) {
+                Vec3 delta = pos[j]-pos[k];
+                delta[0] -= floor(delta[0]*invBoxSize+0.5)*boxSize;
+                delta[1] -= floor(delta[1]*invBoxSize+0.5)*boxSize;
+                delta[2] -= floor(delta[2]*invBoxSize+0.5)*boxSize;
+                double dist = sqrt(delta.dot(delta));
+                int bin = (int) (numBins*(dist/boxSize));
+                counts[bin]++;
+            }
+    }
+
+    // Check against expected values.
+
+    double scale = (boxSize*boxSize*boxSize)/(numSteps*0.5*numParticles*numParticles);
+    for (int i = 0; i < numBins/2; i++) {
+        double r1 = i*boxSize/numBins;
+        double r2 = (i+1)*boxSize/numBins;
+        double volume = (4.0/3.0)*M_PI*(r2*r2*r2-r1*r1*r1);
+        ASSERT_USUALLY_EQUAL_TOL(reference[i], scale*counts[i]/volume, 0.2);
+    }
+}
+
+void testConstraints() {
+    const int numParticles = 8;
+    const int numConstraints = 5;
+    const double temp = 100.0;
+    System system;
+    QTBIntegrator integrator(temp, 2.0, 0.01);
+    integrator.setConstraintTolerance(1e-5);
+    NonbondedForce* forceField = new NonbondedForce();
+    for (int i = 0; i < numParticles; ++i) {
+        system.addParticle(10.0);
+        forceField->addParticle((i%2 == 0 ? 0.2 : -0.2), 0.5, 5.0);
+    }
+    system.addConstraint(0, 1, 1.0);
+    system.addConstraint(1, 2, 1.0);
+    system.addConstraint(2, 3, 1.0);
+    system.addConstraint(4, 5, 1.0);
+    system.addConstraint(6, 7, 1.0);
+    system.addForce(forceField);
+    Context context(system, integrator, platform);
+    vector<Vec3> positions(numParticles);
+    vector<Vec3> velocities(numParticles);
+    OpenMM_SFMT::SFMT sfmt;
+    init_gen_rand(0, sfmt);
+
+    for (int i = 0; i < numParticles; ++i) {
+        positions[i] = Vec3(i/2, (i+1)/2, 0);
+        velocities[i] = Vec3(genrand_real2(sfmt)-0.5, genrand_real2(sfmt)-0.5, genrand_real2(sfmt)-0.5);
+    }
+    context.setPositions(positions);
+    context.setVelocities(velocities);
+
+    // Simulate it and see whether the constraints remain satisfied.
+
+    for (int i = 0; i < 1000; ++i) {
+        State state = context.getState(State::Positions);
+        for (int j = 0; j < numConstraints; ++j) {
+            int particle1, particle2;
+            double distance;
+            system.getConstraintParameters(j, particle1, particle2, distance);
+            Vec3 p1 = state.getPositions()[particle1];
+            Vec3 p2 = state.getPositions()[particle2];
+            double dist = std::sqrt((p1[0]-p2[0])*(p1[0]-p2[0])+(p1[1]-p2[1])*(p1[1]-p2[1])+(p1[2]-p2[2])*(p1[2]-p2[2]));
+            ASSERT_EQUAL_TOL(distance, dist, 1e-4);
+        }
+        integrator.step(1);
+    }
+}
+
+void testConstrainedMasslessParticles() {
+    System system;
+    system.addParticle(0.0);
+    system.addParticle(1.0);
+    system.addConstraint(0, 1, 1.5);
+    vector<Vec3> positions(2);
+    positions[0] = Vec3(-1, 0, 0);
+    positions[1] = Vec3(1, 0, 0);
+    QTBIntegrator integrator(300.0, 2.0, 0.01);
+    bool failed = false;
+    try {
+        // This should throw an exception.
+
+        Context context(system, integrator, platform);
+    }
+    catch (exception& ex) {
+        failed = true;
+    }
+    ASSERT(failed);
+
+    // Now make both particles massless, which should work.
+
+    system.setParticleMass(1, 0.0);
+    Context context(system, integrator, platform);
+    context.setPositions(positions);
+    context.setVelocitiesToTemperature(300.0);
+    integrator.step(1);
+    State state = context.getState(State::Velocities);
+    ASSERT_EQUAL(0.0, state.getVelocities()[0][0]);
+}
+
+void testRandomSeed() {
+    const int numParticles = 8;
+    const double temp = 100.0;
+    System system;
+    QTBIntegrator integrator(temp, 2.0, 0.01);
+    NonbondedForce* forceField = new NonbondedForce();
+    for (int i = 0; i < numParticles; ++i) {
+        system.addParticle(2.0);
+        forceField->addParticle((i%2 == 0 ? 1.0 : -1.0), 1.0, 5.0);
+    }
+    system.addForce(forceField);
+    vector<Vec3> positions(numParticles);
+    vector<Vec3> velocities(numParticles);
+    for (int i = 0; i < numParticles; ++i) {
+        positions[i] = Vec3((i%2 == 0 ? 2 : -2), (i%4 < 2 ? 2 : -2), (i < 4 ? 2 : -2));
+        velocities[i] = Vec3(0, 0, 0);
+    }
+
+    // Try twice with the same random seed.
+
+    integrator.setRandomNumberSeed(5);
+    Context context(system, integrator, platform);
+    context.setPositions(positions);
+    context.setVelocities(velocities);
+    integrator.step(10);
+    State state1 = context.getState(State::Positions);
+    context.reinitialize();
+    context.setPositions(positions);
+    context.setVelocities(velocities);
+    integrator.step(10);
+    State state2 = context.getState(State::Positions);
+
+    // Try twice with a different random seed.
+
+    integrator.setRandomNumberSeed(10);
+    context.reinitialize();
+    context.setPositions(positions);
+    context.setVelocities(velocities);
+    integrator.step(10);
+    State state3 = context.getState(State::Positions);
+    context.reinitialize();
+    context.setPositions(positions);
+    context.setVelocities(velocities);
+    integrator.step(10);
+    State state4 = context.getState(State::Positions);
+
+    // Compare the results.
+
+    for (int i = 0; i < numParticles; i++) {
+        for (int j = 0; j < 3; j++) {
+            ASSERT_EQUAL_TOL(state1.getPositions()[i][j], state2.getPositions()[i][j], 1e-6);
+            ASSERT_EQUAL_TOL(state3.getPositions()[i][j], state4.getPositions()[i][j], 1e-6);
+            ASSERT(state1.getPositions()[i][j] != state3.getPositions()[i][j]);
+        }
+    }
+}
+
+void testInitialTemperature() {
+    // Check temperature initialization for a collection of randomly placed particles
+    const int numParticles = 50000;
+    const int nDoF = 3 * numParticles;
+    const double targetTemperature = 300;
+    System system;
+    OpenMM_SFMT::SFMT sfmt;
+    init_gen_rand(0, sfmt);
+    std::vector<Vec3> positions(numParticles);
+
+    for (int i = 0; i < numParticles; i++) {
+        system.addParticle(1.0);
+        positions[i][0] = genrand_real2(sfmt);
+        positions[i][1] = genrand_real2(sfmt);
+        positions[i][2] = genrand_real2(sfmt);
+    }
+
+    QTBIntegrator integrator(300, 25, 0.001);
+    Context context(system, integrator, platform);
+    context.setPositions(positions);
+    context.setVelocitiesToTemperature(targetTemperature);
+    auto velocities = context.getState(State::Velocities).getVelocities();
+    double kineticEnergy = 0;
+    for(const auto &v : velocities) kineticEnergy += 0.5 * v.dot(v);
+    double temperature = (2*kineticEnergy / (nDoF*BOLTZ));
+    ASSERT_USUALLY_EQUAL_TOL(targetTemperature, temperature, 0.01);
+}
+
+void testSerializeParameters() {
+    // Test serializing the integrator's parameters.
+
+    int numParticles = 10;
+    double temperature = 300.0;
+    double mass = 2.0;
+    System system;
+    vector<Vec3> positions(numParticles);
+    CustomExternalForce* force = new CustomExternalForce("0.5*k*(x*x+y*y+z*z)");
+    system.addForce(force);
+    force->addPerParticleParameter("k");
+    QTBIntegrator integrator(temperature, 10.0, 0.001);
+    integrator.setCutoffFrequency(500.0);
+    integrator.setDefaultAdaptationRate(0.1);
+    for (int i = 0; i < numParticles; i++) {
+        system.addParticle(mass);
+        force->addParticle(i, {1000.0*(i+1)*(i+1)});
+        if (i < 5)
+            integrator.setParticleType(i, i%3);
+    }
+    Context context(system, integrator, platform);
+    context.setPositions(positions);
+    context.setVelocitiesToTemperature(temperature);
+
+    // Run for a little while, then record a State.
+
+    integrator.step(5000);
+    State state = context.getState(State::IntegratorParameters);
+
+    // Create a new Integrator and Context, set the State, and see if the adapted
+    // friction coefficients were set correctly.
+
+    QTBIntegrator integrator2(temperature, 10.0, 0.001);
+    for (auto type : integrator.getParticleTypes())
+        integrator2.setParticleType(type.first, type.second);
+    Context context2(system, integrator2, platform);
+    context2.setPositions(positions);
+    context2.setState(state);
+    for (int i = 0; i < numParticles; i++) {
+        vector<double> f1, f2;
+        integrator.getAdaptedFriction(i, f1);
+        integrator2.getAdaptedFriction(i, f2);
+        ASSERT_EQUAL_CONTAINERS(f1, f2);
+    }
+}
+
+void runPlatformTests();
+
+int main(int argc, char* argv[]) {
+    try {
+        initializeTests(argc, argv);
+        testHarmonic();
+        testCoupledHarmonic();
+        testParaHydrogen();
+        testConstraints();
+        testConstrainedMasslessParticles();
+        testRandomSeed();
+        testInitialTemperature();
+        testSerializeParameters();
+        runPlatformTests();
+    }
+    catch(const exception& e) {
+        cout << "exception: " << e.what() << endl;
+        return 1;
+    }
+    cout << "Done" << endl;
+    return 0;
+}
diff --git a/wrappers/python/tests/TestAPIUnits.py b/wrappers/python/tests/TestAPIUnits.py
index 63c877bbbb..ec125ff81f 100644
--- a/wrappers/python/tests/TestAPIUnits.py
+++ b/wrappers/python/tests/TestAPIUnits.py
@@ -1089,5 +1089,22 @@ def testCustomIntegrator(self):
         integrator = CustomIntegrator(1.0*femtoseconds)
         self.assertEqual(integrator.getStepSize(), 1.0*femtoseconds)

+    def testQTBIntegrator(self):
+        """ Tests the LangevinIntegrator API features """
+        integrator = QTBIntegrator(300, 0.1, 0.001)
+        self.assertEqual(integrator.getTemperature(), 300*kelvin)
+        self.assertEqual(integrator.getFriction(), 0.1/picosecond)
+        self.assertEqual(integrator.getStepSize(), 0.001*picosecond)
+
+        integrator = QTBIntegrator(300*kelvin, 0.1/microsecond, 1*femtosecond)
+        self.assertEqual(integrator.getTemperature(), 300*kelvin)
+        self.assertAlmostEqualUnit(integrator.getFriction(), 0.1/microsecond)
+        self.assertEqual(integrator.getStepSize(), 1*femtosecond)
+
+        integrator.setSegmentLength(0.5)
+        self.assertEqual(integrator.getSegmentLength(), 0.5*picosecond)
+        integrator.setSegmentLength(100*femtosecond)
+        self.assertEqual(integrator.getSegmentLength(), 0.1*picosecond)
+
 if __name__ == '__main__':
     unittest.main()