Getting Started with the GPE#

We start with an example of a 1D quantum gas in a periodic potential. In the next document Getting Started in Higher Dimensions, we explore higher dimensions

For our purposes, we consider the following model for a dilute Bose-Einstein condensate (BEC) of neutral \({}^{78}\)Rb atoms in a 1D periodic potential with the Gross-Pitaevski equation (GPE):

\[\begin{gather*} \I\hbar \dot{\psi} = \left(\frac{-\hbar^2 \nabla^2}{2m} + V(x, t) - \mu + g n\right), \qquad n = \abs{\psi}^2. \end{gather*}\]

Dimensions/Units#

We start by a simple dimensional analysis

\[\begin{gather*} [\hbar] = \frac{ML^2}{T}, \qquad [n] = \frac{1}{L^{d}}, \qquad [\psi] = \frac{1}{L^{d/2}}, \qquad [\mu] = \frac{ML^2}{T^2}, \qquad [g] = \frac{ML^{2+d}}{T^2}. \end{gather*}\]

To derive others, we note that \(\hbar/m\) and \(g/m\) are independent of mass. These can then be combined to find length and time scales:

\[\begin{gather*} \left[\frac{(\hbar/m)^{2}}{g/m}=\frac{\hbar^2}{mg}\right]^{\frac{1}{2-d}} = L, \qquad \left[\frac{(\hbar/m)^{2+d}}{(g/m)^{2}}\right]^{\frac{1}{2-d}} = T. \end{gather*}\]

Note that this fails in \(d=2\) dimensions and that the 2D GPE famously has a scaling symmetries.

Before implementing something numerically, we should determine the scales in our problem. We start with a theory perspective: the relevant length scales for this system are:

  • Density \(n\): The length-scale here is the interparticle spacing, the relation of which to density depends on dimension. In 1D, we have \([n] = 1/L\). This is often expressed in terms of the interparticle spacing \(n^{-1/d}\) where \(d\) is the dimension.

  • Healing length \(h\): This is the length scale over which the gas will go from the background density \(n_0 = \mu/g\) to zero and represents a balance between the kinetic energy and the non-linear interactions:

    \[\begin{gather*} \frac{\hbar^2}{2mh^2} \approx \mu = gn_0, \qquad h = \sqrt{\frac{\hbar^2}{2m\mu}}= \frac{\hbar}{\sqrt{2m g n_0}}. \end{gather*}\]
  • Speed of Sound \(c\): Related to these is the speed of sound. This can be calculated by looking at the Phonon Dispersion from the Linear Response of a homogeneous system to small perturbations:

    \[\begin{gather*} c = \sqrt{\frac{gn_0}{m}}. \end{gather*}\]

The density \(n\) and healing length \(h\) are intrinsic properties of the system. They represent physics and we must choose relevant scales here. To simulate systems numerically, we must consider some additional length scales:

  • Box size \(L_x\): This represents the extent of our system and is sometimes called the infrared- or IR-scale* since we cannot simulate features with wavelengths longer than this. Note: this might be physical if we have a trapped gas, where the box size must be larger than the size of the trapped cloud. However, it might also be a numerical artifact when studying homogeneous or periodic systems where we would really like to know what happens in the IR limit (a.k.a. thermodynamics limit) \(L_x \rightarrow \infty\).

  • Lattice spacing \(\d{x}\): This represents the smallest feature we can resolve in our system, sometimes called the ultraviolet- or UV-scale since we cannot simulate features with wavelengths smaller than this. This is almost never physical, and instead represents a numerical limitation. We almost always want to know what the results are in the UV-limit \(\d{x} \rightarrow 0\).

To reliably simulate physics with our numerical implementation of the GPE we need:

  1. Spatial resolution (UV convergence): The lattice spacing must be smaller than the healing length. When using spectral methods, it often suffices to have \(\d{x}\) a few times smaller. For finite-difference methods, UV convergence is much more difficult to obtain.

    \[\begin{gather*} \d{x} < h. \end{gather*}\]
  2. IR convergence: The box must be big enough to contain the relevant physics. Together, these set the cost of our simulation – we need \(N_x \geq L_x/\d{x}\) lattice points.

  3. Diluteness: This is not explicitly built into the previous parameters, but the GPE is not valid if the interactions are too strong or if the range of the interaction \(r_0\) is too large. Formally, we must have both

    \[\begin{gather*} r_0 \ll n^{-1/d} \ll h. \end{gather*}\]

    If the healing length is comparable to the interparticle spacing, then there will be an appreciable non-condensed fraction that must be considered, while if the effective range is too large, then the interaction terms \(gn\) must be modified to include three-body interactions etc.

To enable us to play, we allow the user to set the physical parameters:

  • healing_length_micron: The healing length \(h\) in microns (micrometers μm = m\(^{-6}\)).

  • dx_healing_length: The lattice spacing in units of the healing length.

  • Lx_microns: The box size in microns.

  • n0_micron: The background/central density in units of inverse microns.

From these, and physical constants such as the particles mass \(m\) and \(\hbar\), we can fix the other physical parameters, such as the chemical potential, and coupling constant.

\[\begin{gather*} \mu = \frac{\hbar^2}{2mh^2}, \qquad g = \frac{\mu}{n_0}. \end{gather*}\]

Defining a Problem#

To set up a simulation, we must create a State class that implements the gpe.interfaces.IStateGPE interface. Here we define all the parameters in a subclass. We will also include an external potential, which we will take to be a (periodic) gaussian

\[\begin{gather*} V(x) \approx V_0 \exp\left(-\frac{x^2}{2\sigma^2}\right), \end{gather*}\]

which introduces two new parameters

  • V0_mu: \(V_0\) expressed in units of the chemical potential \(\mu\).

  • sigma_healing_length: \(\sigma\) expressed in units of the healing length.

import numpy as np
import matplotlib.pyplot as plt

from gpe.bec import StateGPEBase, u
from gpe.utils import get_good_N


class State(StateGPEBase):
    healing_length_micron = 1.0 # Healing length in microns
    dx_healing_length = 0.5     # Maximum lattice spacing in units of the healing length
    Lx_micron = 10.0            # Box length in units of microns
    V0_mu = -1.0                # Depth of the potential in units of the chemical potential.
    sigma_healing_length = 1.0  # Width of the potential in healing lengths
    n0_micron = 1.0             # Background density
    
    def __init__(self, **kw):
        for key in list(kw):
            if hasattr(self, key):
                setattr(self, key, kw.pop(key))

        m = u.m_Rb87
        hbar = u.hbar
        self.healing_length = self.healing_length_micron * u.micron
        Lx = self.Lx_micron * u.micron
        dx = self.dx_healing_length * self.healing_length
        n0 = self.n0_micron / u.micron
        
        # Select a good Nx that will perform well - small prime factors.
        Nx = get_good_N(Lx / dx)

        # Calculate the chemical potential and g
        mu = hbar**2 / 2 / m / self.healing_length
        g = mu / n0
        kw.update(hbar=hbar, m=m, Lxyz=(Lx,), Nxyz=(Nx,), mu=mu, g=g)
        super().__init__(**kw)

    def init(self):
        """Perform any initializations before the simulation begins."""
        # Units
        self.V0 = self.V0_mu * self.mu
        self.sigma = self.sigma_healing_length * self.healing_length
        super().init()

    def get_Vext(self):
        x, = self.get_xyz()
        V_ext = self.V0 * np.exp(-(x/self.sigma)**2/2)
        return V_ext 

s = State()
s.plot();
/home/docs/checkouts/readthedocs.org/user_builds/gpe/conda/latest/lib/python3.14/site-packages/gpe/utils.py:2755: PerformanceWarning: Default State.get_Vext_GPU() delegates to non-GPU version.

This is a potential performance issue, even if not using the GPU.  To ensure optimal
performance, you should make sure that you overload get_Vext_GPU() instead.

  warnings.warn(
../_images/5e7c2e6805bafb8f55cbdee7916f7bddf38b40d74142dde7fecf6a29b657f86d.png

The state is populated using the Thomas-Fermi approximation

\[\begin{gather*} n_{TF}(x) = \frac{\mu - V(x)}{g}. \end{gather*}\]

We can evolve this and see how close we are to the ground state of the system. Since we want to explore the dynamics, we code this in a function:

from pytimeode.evolvers import EvolverABM
from mmfutils.contexts import FPS

def plot_states(states):
    # Always plot dimensionless quantities...
    t_unit = u.ms
    x_unit = u.micron
    n_unit = 1 / x_unit

    plot_Nlr = hasattr(states[0], 'get_Nlr')
    
    s = states[-1]
    ns = np.array([s.get_density() for s in states])
    Es = np.array([s.get_energy() for s in states])
    Ns = np.array([s.get_N() for s in states])
    if plot_Nlr:
        Nlr = np.transpose([s.get_Nlr() for s in states])
    ts = np.array([s.t for s in states])
    xs = np.ravel(s.xyz[0])

    kw = dict(sharex=True,  gridspec_kw=dict(hspace=0))
    if plot_Nlr:
        fig, axs = plt.subplots(3, 1, height_ratios=(0.2, 0.5, 1), **kw)
    else:
        fig, axs = plt.subplots(2, 1, height_ratios=(0.2, 1), **kw)

    ax = axs[0]
    E, N = Es[0], Ns[0]
    ax.plot(ts / t_unit, Es / E - 1, label="$E$")
    ax.plot(ts / t_unit, Ns / N - 1, label="$N$")
    ax.set(ylabel="Rel. change", title=f"{E=:.4g}, {N=:.4g}")
    ax.legend()

    if plot_Nlr:
        ax = axs[1]
        Nl, Nr = Nlr
        ax.plot(ts / t_unit, Nl/Nl[0] - 1, label="$N_l$")
        ax.plot(ts / t_unit, Nr/Nl[0] - 1, label="$N_r$")
        ax.set(ylabel="Rel. change in $N_{lr}$")
        ax.legend()

    ax = axs[-1]
    mesh = ax.pcolormesh(ts / t_unit, xs / x_unit, ns.T / n_unit)

    # Label your axes...
    ax.set(ylabel="$x$ [μm]", xlabel="$t$ [ms]")

    # ... and your colorbars.
    fig.colorbar(mesh, ax=axs.ravel().tolist(), label="$n$ [1/μm]")


def evolve(s, T_ms=20, Nt=200, dt_t_scale=0.4, timeout_s=60):
    """Evolve the state s to time T and plot.
    
    Arguments
    ---------
    s : State
        Initial state.
    T_ms : float
        Final time in ms.
    Nt : int
        Number of frames to plot.
    dt_t_scale : float
        Step size for evolve in units of t_scale = hbar/maximum resolvable energy.
    timeout_s : float
         Timeout (in seconds).
    """
    T = T_ms * u.ms
    dT = T / Nt

    # Note: From experience, if you have chosen dx correctly, you should
    # get highly converged results if you use a time-step dt between 0.2 and 0.5 times
    # s.t_scale.  If you can go much larger, you are probably wasting effort.  If need
    # to go much smaller, you probably have an incorrect lattice spacing.
    dt = dt_t_scale*s.t_scale
    
    # Make sure we can take integer steps...
    steps = max(int(np.ceil(dT / dt)), 2)
    dt = dT / steps
    
    ev = EvolverABM(s, dt=dt)
    states = [ev.get_y()]
    for frame in FPS(frames=Nt, timeout=timeout_s):
        ev.evolve(steps)
        states.append(ev.get_y())


    plot_states(states)
    return states

states = evolve(s)
../_images/0e52298bc225dda9d4c1bcb2606fb12370ff0da159a64761aaf9b3839bc6d909.png

We see some fluctuations, but overall the energy \(E\) and particle number \(N\) are conserved to 9 digits. Here is what might happen if we choose too large of a time-step:

evolve(s, dt_t_scale=1.0);
../_images/4c7cd426cddf45bbc4f19eae60d50fae083b11e2ccc7d7fd6a1689133be2ba46.png

The energy is not conserved here: going larger, the code might crash with ABM.

Minimization#

To get a high-quality initial state, we can run a minimizer:

from gpe.minimize import MinimizeState

s0 = State()
s = MinimizeState(s0, fix_N=False).minimize(use_scipy=True)
evolve(s);
../_images/787b0bdf181472acde202a7306385bd6b959075e3a3d9da0d504832415c760b6.png

Now the state is static to high precision – the fluctuations that you see are on the order of \(10^{-15}\).

Josephson Oscillations#

Stationary states are pretty boring. Let’s do something more exciting. Here we consider inducing real-space Josephson oscillations as follows:

  1. Place the atoms in a harmonic trap. This needs a new parameter, the trap frequency \(\omega_x\):

    \[\begin{gather*} V_{HO}(x) = \frac{m\omega_x^2x^2}{2}. \end{gather*}\]
  2. Find the ground state with a repulsive barrier \(V_0 < 0\).

  1. Jump on a linear potential. For a harmonic oscillator, this simply shifts the location of the minimum, so instead, we just use

    \[\begin{gather*} V_{HO}(x) = \frac{m\omega_x^2(x-x_0)^2}{2}. \end{gather*}\]

What do you think will happen?

Parameters#

If you are familiar with experiments, you might know what good trap frequencies are, and for a particular species, you can find the mass \(m\) and coupling constant

\[\begin{gather*} g = \frac{4\pi \hbar^2 a_s}{m} \end{gather*}\]

where \(a_s\) is the S-wave scattering length. Otherwise, it might be better to specify the Thomas-Fermi radius of the trap \(x_{TF}\) where

\[\begin{gather*} \frac{m\omega_x^2x_{TF}^2}{2} = \mu, \qquad \omega_x = \frac{\sqrt{2\mu/m}}{x_{TF}}. \end{gather*}\]

If we then choose a scale for the central density \(n_0 = g\mu\), this fixes the coupling constant, trap frequencies, etc.

class StateJosephson(State):
    x_TF_micron = 50.0
    x0_micron = 20.0
    Lx_micron = 200.0   # Need bigger box
    V0_mu = 2.0         # Repulsive barrier
    
    def init(self):
        """Perform any initializations before the simulation begins."""
        # Units
        self.x0 = self.x0_micron * u.micron
        x_TF = self.x_TF_micron * u.micron
        self.wx = np.sqrt(2*self.mu/self.m)/x_TF
        super().init()
    
    def get_Nlr(self):
        """Return the number of particles on the left and right respectively."""
        x, = self.xyz
        n = self.get_density()
        N = self.integrate(np.where(x<0, 1, 1j) * n)
        Nl, Nr = N.real, N.imag
        return Nl, Nr

    def get_Vext(self):
        x, = self.get_xyz()
        
        V_ext = super().get_Vext()
        if (self.initializing or self.t < 0):
            x0 = 0.0
        else:
            x0 = self.x0
        V_ext += self.m * (self.wx*(x-x0))**2/2
        return V_ext

s0 = StateJosephson()
%time s = MinimizeState(s0, fix_N=False).minimize()
s.plot()
CPU times: user 1e+03 ms, sys: 5.95 ms, total: 1.01 s
Wall time: 554 ms
/home/docs/checkouts/readthedocs.org/user_builds/gpe/conda/latest/lib/python3.14/site-packages/gpe/utils.py:2755: PerformanceWarning: Default StateJosephson.get_Vext_GPU() delegates to non-GPU version.

This is a potential performance issue, even if not using the GPU.  To ensure optimal
performance, you should make sure that you overload get_Vext_GPU() instead.

  warnings.warn(
[<Axes: >]
../_images/9eae4f04874843405b2bbbd7455a02f430142483a734f4993eb9063887da77c8.png
%time evolve(s, T_ms=1000);
CPU times: user 21 s, sys: 186 ms, total: 21.2 s
Wall time: 10.7 s
../_images/b7844d261f3be6708203880105cdea6beedacd9b6697aa58977a9a4d96ce3f1d.png
s0 = StateJosephson(V0_mu=4.0)
%time s = MinimizeState(s0, fix_N=False).minimize()
%time evolve(s, T_ms=1000);
CPU times: user 562 ms, sys: 0 ns, total: 562 ms
Wall time: 282 ms
CPU times: user 21.2 s, sys: 177 ms, total: 21.4 s
Wall time: 10.7 s
../_images/0abecc99d54e7f9363bfcc63085dbcdf672d1778e4372c630c608a033ae428c9.png

This is fun, but I think we are mixing several different bits of the physics. I suggest you now take the time to review the Josephson effect.

The Josephson Effect

The essence of the Josephson effect is a current

\[\begin{gather*} I = I_c \sin \phi_{-}, \qquad \dot \phi_{-} = \frac{2\delta V}{\hbar}, \end{gather*}\]

where \(\phi_{-} = \phi_R - \phi_L\) is the phase difference between the BECs on the right and left of the barrier.

DC Josephson Effect#

To generate a DC current, we simply imprint the desired phase difference \(\phi_-\) on the initial state:

class StateJosephsonDC(StateJosephson):
    x0_micron = 0.0  # No shift now
    dphi = np.pi/2   # Josephson phase difference.

    def initialize_state(self):
        self.t = 0
        s = MinimizeState(self, fix_N=False).minimize(use_scipy=True)
        x = s.xyz[0]
        phi_x = self.dphi / 2 * np.sign(x)
        psi = np.sqrt(s.get_density()) * np.exp(1j*phi_x)
        self.set_psi(psi)

s = StateJosephsonDC()
%time s.initialize_state()
%time states = evolve(s, T_ms=200)
CPU times: user 819 ms, sys: 984 μs, total: 820 ms
Wall time: 412 ms
CPU times: user 5.16 s, sys: 29 ms, total: 5.19 s
Wall time: 2.62 s
../_images/fcb6133146152185597c20e11af0a62ae74617e20f114b11afba0171ee92cfc7.png

Notice that we start with a constant DC current. After some time, however, this current causes a density difference on either side. The mean-field interaction therefore induces a potential difference \(\delta V \approx g \delta n\) that eventually changes the phase, arresting and reversing the flow.

AC Josephson Effect#

If we instead fix a potential difference difference \(\delta V\), then the phase will wind, generating an alternating current:

class StateJosephsonAC(StateJosephson):
    x0_micron = 0.0     # No shift now
    dV_mu = 0.2         # Josephson "voltage" difference 
    
    def init(self):
        self.x0 = self.x0_micron * u.micron
        x_TF = self.x_TF_micron * u.micron
        self.wx = np.sqrt(2*self.mu/self.m)/x_TF
        self.dV = self.dV_mu * self.mu
        super().init()
    
    def get_Vext(self):
        x, = self.get_xyz()
        
        V_ext = super().get_Vext()
        if (self.initializing or self.t < 0):
            pass
        else:
            V_ext += self.dV * np.sign(x)
        return V_ext
    

s0 = StateJosephsonAC()
%time s = MinimizeState(s0, fix_N=False).minimize(use_scipy=True)
%time states=evolve(s, T_ms=1000)
CPU times: user 744 ms, sys: 0 ns, total: 744 ms
Wall time: 375 ms
CPU times: user 21.6 s, sys: 213 ms, total: 21.8 s
Wall time: 11 s
/home/docs/checkouts/readthedocs.org/user_builds/gpe/conda/latest/lib/python3.14/site-packages/gpe/utils.py:2755: PerformanceWarning: Default StateJosephsonAC.get_Vext_GPU() delegates to non-GPU version.

This is a potential performance issue, even if not using the GPU.  To ensure optimal
performance, you should make sure that you overload get_Vext_GPU() instead.

  warnings.warn(
../_images/c11f634eb5aec7ee9556e83bcda448f6f596026b6b25a2af03113677e3892275.png

Do it!

Is this the appropriate/expected DC Josephson effect? Check that the corresponding frequencies match. Try playing with the parameters – does the Josephson current respond appropriately?

Note: you might see some deviations from the Josephson formulae. Where might these come from? E.g. what role does the trap play? What about the non-linear interactions? How could you adjust your simulation to test for and minimize these effects?

Finally, how can you adjust the parameters to maximize the signal?