Quickstart
mwave is a python library designed to help explore new matterwave interferometer geometries. mwave also provides functions to numerically solve the Hamiltonian that describes Bragg diffraction and Bloch oscillations.
Installation
Stable releases of mwave can be downloaded from Github or via:
pip install git+https://github.com/jc-roth/mwave@v3.0.0
A first example
Defining an interferometer geometry
mwave is losely broken up into three parts: a module for symbolically defining arbitrary interferometer geometries, a module for numerically defining arbitrary interferometer geometries, and a module for numerically integrating Bragg and Bloch processes. As an example we will use the library to explore a Mach-Zender interferometer geometry.
First we will import the required libraries
from mwave import symbolic as msym
from mwave import integrate as mint
import numpy as np
import sympy as sp
from matplotlib import pyplot as plt
mwave internally uses the sympy library to build symbolic expressions of
the interferometer phase. Therefore we must define the symbols we want
to use symbolically
m, c, hbar, k, g, delta, n, T, t_traj = sp.symbols('m c hbar k g delta n T t_traj', real=True)
k_eff = 2*k
Performing certain operations in mwave requires the user to set certain
constants. In this example we will need to set the mass, speed of light,
the Planck constant, and another variable called t_traj.
msym.set_constants(m=m, c=c, hbar=hbar, t_traj=t_traj)
The unitary operators that we will use to build the interferometer
require these constants. If they are not provided an error will be
thrown. At first glance it makes sense that we need to define constants representing the mass, speed of
light, and the Planck constant in order to define our unitary operators. mwave also provides the ability to compute the position of each interferometer path as a function of time, and t_traj is used to parameterize this time.
Now we are ready to define unitary operators representing the beamsplitter, free evolution, and mirror that make up the Mach-Zender interferometer:
beamsplitter = msym.Beamsplitter(0, n, delta, k_eff, include_half_pi_shift=True)
mirror = msym.Mirror(0, n, delta, k_eff)
free = msym.FreeEv(T, gravity=g)
Now that we have defined our unitary operators we can define a new interferometer object and apply our unitary operators to it.
ifr = msym.Interferometer()
ifr.apply(beamsplitter)
ifr.apply(free)
ifr.apply(beamsplitter)
ifr.apply(free)
ifr.apply(beamsplitter)
Thats it! We have defined the full Mach-Zender interferometer! Note that
unitary operators can also be applied using the matrix multiplication
operator, i.e. free @ (beamsplitter @ ifr).
Now lets compute the phase of the interferometer. To do this we must
first call the mwave.symbolic.Interferometer.interfere() method to
check which interferometer paths interfere.
interfering_paths = ifr.interfere()
print(f'Found {len(interfering_paths)} interfering paths.')
Found 2 interfering paths.
This is expected as a Mach-Zender interferometer has two output ports.
Now that we have computed the interfering paths we can compute the phase difference between each interfering output:
phase_differences = ifr.phases()
for phase_difference in phase_differences:
print(sp.simplify(phase_difference))
2*T**2*g*k*n - pi
2*T**2*g*k*n
See the Interferometer Geometries section for more in-depth examples of how to define and analyze interferometer geometries.
Simulating Bragg beamsplitters
Next we can use the mwave.integrate.propagate() function to integrate some initial momentum state through a Bragg diffraction beamsplitter and mirror. We will just eyeball the effective Rabi frequencies for each.
propagate() takes the momentum grid, initial state, final time, two-photon detuning, and callable functions for the Rabi envelope and drive phase. For a Gaussian pulse we use the built-in omega_fnc_gaussian() and phase_fnc_constant().
n0, nf = 0, 4
sigma = 0.5
omega_bs = 16.1
omega_mirror = 21
kvec, n0_idx, nf_idx = mint.make_kvec(n0, nf)
# Simulate beamsplitter (pi/2 pulse)
result = mint.propagate(kvec, mint.make_phi(kvec, n0), 6*sigma, 4*(n0+nf),
mint.omega_fnc_gaussian, np.array([omega_bs, sigma, 3*sigma]),
mint.phase_fnc_constant, np.array([0.0]))
result.plot()
plt.show()
# Simulate mirror (pi pulse)
result = mint.propagate(kvec, mint.make_phi(kvec, n0), 6*sigma, 4*(n0+nf),
mint.omega_fnc_gaussian, np.array([omega_mirror, sigma, 3*sigma]),
mint.phase_fnc_constant, np.array([0.0]))
result.plot()
plt.show()
The plot() method produces a three-panel figure showing the population in each momentum state, the Rabi frequency profile, and the drive phase as a function of time.
The population at any specific momentum state can also be queried with population():
print(f"Population in n={nf}: {result.population(2*nf):.4f}")
That seems to have worked well enough!
See the Integrating the Bloch Hamiltonian section for other examples of evolving wavefunctions under the Bloch Hamiltonian.
Combining the interferometer model with simulation
Note
This section describes how to integrate numeric calculations with the symbolic interferometer module. This is slightly awkward. To simulate interferometers numerically with a more natural interface please see the numeric interferometer example.
Lets say that we want to study the systematics introduced by the Bragg diffraction process in our Mach-Zender geometry. To do this we need to combine the numerical computation we’ve made using mwave.integrate.propagate() with our symbolic representation of the interferometer geometry. This is accomplished in a straightforward way by defining custom mwave.symbolic.Unitary classes that inherit from the mwave.symbolic.Beamsplitter and mwave.symbolic.Mirror classes.
Our beamsplitters will couple momentum states \(0\) and \(n\)
class BraggBeamsplitter(msym.Beamsplitter):
def gen_numeric(self, node, subs={}):
delta = msym.eval_sympy_var(self.delta, subs)
kvec, _, _ = mint.make_kvec(msym.eval_sympy_var(self.n1, subs), msym.eval_sympy_var(self.n2, subs))
n_idx = np.argmin(np.abs(2*msym.eval_sympy_var(node.n, subs) - kvec))
n_parent = msym.eval_sympy_var(node.parent.n, subs)
def fnc(v):
result = mint.propagate(kvec, mint.make_phi(kvec, n_parent), 6*sigma, delta + 4*v,
mint.omega_fnc_gaussian, np.array([omega_bs, sigma, 3*sigma]),
mint.phase_fnc_constant, np.array([0.0]))
return result.phi_final[n_idx]
return fnc
class BraggMirror(msym.Mirror):
def gen_numeric(self, node, subs={}):
delta = msym.eval_sympy_var(self.delta, subs)
kvec, _, _ = mint.make_kvec(msym.eval_sympy_var(self._n1, subs), msym.eval_sympy_var(self._n2, subs))
n_idx = np.argmin(np.abs(2*msym.eval_sympy_var(node.n, subs) - kvec))
n_parent = msym.eval_sympy_var(node.parent.n, subs)
def fnc(v):
result = mint.propagate(kvec, mint.make_phi(kvec, n_parent), 6*sigma, delta + 4*v,
mint.omega_fnc_gaussian, np.array([omega_mirror, sigma, 3*sigma]),
mint.phase_fnc_constant, np.array([0.0]))
return result.phi_final[n_idx]
return fnc
Now we can define new unitary operators using these definitions and apply them to an interferometer.
bragg_beamsplitter = BraggBeamsplitter(0, n, delta, k_eff)
bragg_mirror = BraggMirror(0, n, delta, k_eff)
ifr_num = msym.Interferometer()
ifr_num.apply(bragg_beamsplitter)
ifr_num.apply(free)
ifr_num.apply(bragg_mirror)
ifr_num.apply(free)
ifr_num.apply(bragg_beamsplitter)
Now we will make use of the mwave.symbolic.Interferometer.get_ports()
function to automatically map the interferometer outputs into their
respective ports.
ifr_num.interfere()
port_dict, junk_port, no_port = ifr_num.get_ports({n: 'upper', 0: 'lower'})
Now we can take the mwave.symbolic.InterferometerNode objects in
each port and generate functions that numerically calculate their
respective complex amplitudes. To generate these functions we must also
set our symbolic variables to numeric values.
subs = {hbar:1, k:1, m: 1, g: 1, n: 4, delta: 4*n, T:5}
u1 = port_dict['upper'][0].gen_numeric_wf_func(subs)
u2 = port_dict['upper'][1].gen_numeric_wf_func(subs)
l1 = port_dict['lower'][0].gen_numeric_wf_func(subs)
l2 = port_dict['lower'][1].gen_numeric_wf_func(subs)
# Create a function to compute the phase between the output wavefunctions
def calc_phase(v):
uphase, lphase = np.angle(u1(v)/u2(v)), np.angle(l1(v)/l2(v))
return uphase, lphase
Note that these numerically calculated complex amplitudes do not include any of the analytically derived phases.
Finally we can compute and plot how the populations vary as a function of the input particle velocity
vs = np.linspace(-0.5, 0.5, 50)
upper = np.full_like(vs, np.nan)
lower = np.full_like(vs, np.nan)
for i in range(len(vs)):
upper[i], lower[i] = calc_phase(vs[i])
fig, [ax1, ax2] = plt.subplots(nrows=2, sharex=True)
ax1.plot(vs, upper)
ax2.plot(vs, lower)
ax2.set_xlabel('initial velocity')
ax1.set_ylabel('upper interferometer phase [rad]')
ax2.set_ylabel('lower interferometer phase [rad]')
plt.show()
The paths that interfere in the upper output port of the interferometer do not experience the same set of beamsplitter/mirror pulses, and so they experience a phase dependent on the input velocity. The paths that interfere in the lower output port of the interferometer experience the same set of beamsplitters, and mirrors that are equivalent via a frame transformation. Therefore the imperfect phase imprinted by the Bragg process cancels out in this output port.
See the Simultaneous Conjugate Interferometer with realistic beamsplitters section for a more detailed example of how to implement the mwave.symbolic.Unitary.gen_numeric() function.
Sometimes we might be interested in having more direct control over the phases
that contribute to this numerical calculation. To help with this mwave provides the code generation function mwave.symbolic.Interferometer.generate_code_outline():
port_dict, junk_port, no_port = ifr.get_ports({n: 'upper', 0: 'lower'})
print(ifr.generate_code_outline(port_dict))
def args_lookup(x, y, v):
return (np.ones_like(x), )
def bs(ni, nf, *args):
if ni == nf:
return (0.5+0j)*args[0]
else:
return (0+0.5j)*args[0]
def calc_populations(x0, y0, v0, ncopies):
# Compute the ones array
ones = np.ones(ncopies)
# Compute positions at each beamsplitter
x1, y1 = x0 + vx*T, y0 + vy*T
x2, y2 = x1 + vx*T, y1 + vy*T
# Compute velocity at each beamsplitter
v1 = v0
v2 = v1
# Compute arguments at each beamsplitter
args0 = args_lookup(x0, y0, v0)
args1 = args_lookup(x1, y1, v1)
args2 = args_lookup(x2, y2, v2)
# Compute wavefunctions
portlower_1 = bs(0,0,*args0)*bs(0,n,*args1)*bs(n,0,*args2)
portlower_2 = bs(0,n,*args0)*bs(n,0,*args1)*bs(0,0,*args2)
portupper_1 = bs(0,0,*args0)*bs(0,n,*args1)*bs(n,n,*args2)
portupper_2 = bs(0,n,*args0)*bs(n,0,*args1)*bs(0,n,*args2)
# Interfere
portlower = np.einsum('i,j->ij', portlower_1, ones) + np.einsum('i,j->ij', portlower_2, ones)
portupper = np.einsum('i,j->ij', portupper_1, ones) + np.einsum('i,j->ij', portupper_2, ones)
# Compute populations
poplower = np.sum(np.abs(portlower)**2, axis=0)
popupper = np.sum(np.abs(portupper)**2, axis=0)
# Return
return poplower, popupper
We can see that this outline computes the wavefunctions output by the interferometer. We can use this outline to start incorporating additional interferometer effects.