from __future__ import annotations
from atomiq.components.primitives import Component, Switchable, Parametrizable, Measurable
from atomiq.components.lock import Lock, DetunableLock
from atomiq.components.optoelectronics.lightmodulator import LightModulator
from atomiq.components.optoelectronics.photodiode import Photodiode
from atomiq.components.optimizers import Optimizer
from atomiq.helper import identity_float, replace_member
from artiq.experiment import portable, kernel, delay
from artiq.language.types import TFloat, TStr, TBool, TInt32, TList
from artiq.language.units import ms, us
import numpy as np
[docs]
class LaserSource(Component):
"""Free-running laser source
This class represents a generic (free-running) laser source that emits light
of a specified frequency
Args:
frequency: Frequency of the emitted light in Hz
power: Emitted light power in W
"""
def __init__(self, frequency: TFloat, power: TFloat = 1e-3, *args, **kwargs):
Component.__init__(self, *args, **kwargs)
self._frequency = frequency
self._power = power
[docs]
@portable
def get_frequency(self):
return self._frequency
[docs]
@portable
def set_frequency(self, frequency: TFloat):
self.experiment.log.error("The component " + self.identifier + " doesn't support setting the frequency")
[docs]
@portable
def get_power(self):
return self._power
[docs]
@portable
def set_power(self, power: TFloat):
self.experiment.log.error("The component " + self.identifier + " doesn't support setting the power")
[docs]
class LockedLaserSource(LaserSource):
"""Locked laser source
This class represents a generic locked laser source that emits light
of a specified frequency
Args:
lock: lock component that locks the laser source
"""
kernel_invariants = {"lock"}
def __init__(self, lock: Lock, *args, **kwargs):
self.lock = lock
LaserSource.__init__(
self, frequency=self.get_frequency(), *args, **kwargs)
[docs]
@portable
def get_frequency(self):
return self.lock.get_frequency()
[docs]
@portable
def set_frequency(self, frequency: TFloat):
self.lock.set_frequency(frequency)
[docs]
class Laser(LaserSource):
"""Laser light that can be modulated
Since we inherit from LaserSource here, a Laser object can itself again be
a LaserSource for another Laser object. This way one can describe consecutive
manipulations from a LaserSource to the final Laser object.
Args:
laser_source: The laser source that this laser is taken from
zero_freq: absolute frequency in Hz that should be considered zero. This allows
to reference the laser frequency relative to e.g. an atomic transition.
If not set, the detuning will be relative to the lock point of the laser source
"""
kernel_invariants = {"laser_source", "zero_freq"}
def __init__(self, laser_source: LaserSource, zero_freq: TFloat = None, *args, **kwargs):
kwargs["frequency"] = self.get_frequency()
LaserSource.__init__(self, *args, **kwargs)
self.laser_source = laser_source
self.zero_freq = zero_freq if zero_freq is not None else self.laser_source.get_frequency()
if not (isinstance(laser_source, LockedLaserSource) and isinstance(laser_source.lock, DetunableLock)) \
and not isinstance(laser_source, Laser):
replace_member(self, "set_frequency", "_set_frequency_dummy")
[docs]
@portable
def get_frequency(self):
return self.laser_source.get_frequency()
[docs]
@kernel
def set_frequency(self, frequency: TFloat):
"""Set the absolute frequency of the light after modulation
Args:
frequency: Absolute frequency of the light after modulation in Hz
"""
self.experiment.log.info(self.identifier + ": Setting frequency to {0:.6f} THz via " +
self.laser_source.identifier, [frequency/1e12])
self.laser_source.set_frequency(frequency)
def _set_frequency_dummy(self, frequency: TFloat):
self.experiment.log.warning(self.identifier + " cannot be detuned!")
[docs]
@kernel
def set_detuning(self, detuning: TFloat):
"""
Set the detuning of the light from the frequency defined by `zero_freq`
.. note::
If you are using a modulator (e.g. AOM) the center frequency is not
taken into account. If you want to set the detuning from the center
frequency of the modulator use `self.modulator.set_detuning`
Args:
detuning: Detuning in Hz
"""
self.set_frequency(self.zero_freq + detuning)
[docs]
@kernel
def detune(self, frequency: TFloat):
"""
Alias for :meth:`set_detuning`
"""
self.set_detuning(frequency)
[docs]
class SwitchableLaser(Laser, Switchable):
"""A laser that can be switched on and off
This class represents a laser that can be switched on and off, e.g. by a mechanical shutter.
However, every `Switchable` can be used to switch the laser.
Args:
laser_source: The laser source that this laser is taken from
switch: The component that switches the laser light
"""
kernel_invariants = {"switch"}
def __init__(self, switch: Switchable, *args, **kwargs):
self.laser_source = kwargs["laser_source"]
Laser.__init__(self, *args, **kwargs)
Switchable.__init__(self, ["switch"])
self.switch = switch
[docs]
@kernel
def on(self):
self.switch.on()
[docs]
@kernel
def off(self):
self.switch.off()
[docs]
class ModulatedLaser(SwitchableLaser, Parametrizable):
"""A laser that can be changed in frequency and amplitude
This class represents a laser that is altered by an acusto-optic modular (AOM).
The AOM can be used the modulate the amplitude and the frequency of the light.
Args:
laser_source: The laser source that this laser is taken from
switch: The component that switches the laser light. If none is given, the AOM is used to switch the light.
modulator: component that does the amplitude/frequency/phase modulation of the laser. E.g. an AOM, EOM, etc.
zero_freq: absolute frequency in Hz that should be considered zero. This allows
to reference the laser frequency relative to e.g. an atomic transition.
If not set, the detuning will be relative to the lock point of the laser source
fm_device: Choose whether the frequency should be set via the modulator (mod) or the laser source (src).
(default: mod)
am_device: Choose whether the power should be set via the modulator (mod) or the laser source (src).
(default: mod)
src_transmission: Transmission of the light from the laser source to behind the modulator [0..1] (default 1)
"""
kernel_invariants = {"modulator", "switch", "fm_device", "am_device", "src_transmission"}
def __init__(self,
modulator: LightModulator,
switch: Switchable = None,
fm_device: TStr = "mod",
am_device: TStr = "mod",
src_transmission: TFloat = 1.0,
*args, **kwargs):
self.modulator = modulator
self.laser_source = kwargs["laser_source"]
# TODO: I have no guarantee that modulator is switchable
kwargs["switch"] = switch if switch is not None else modulator
SwitchableLaser.__init__(self, *args, **kwargs)
Parametrizable.__init__(self, ["detuning", "frequency", "amplitude"])
if fm_device == "mod":
replace_member(self, "set_frequency", "_set_frequency_through_modulator")
else:
replace_member(self, "set_frequency", "_set_frequency_through_source")
self.fm_device = fm_device
self.am_device = am_device
self.src_transmission = src_transmission
[docs]
@kernel
def set_amplitude(self, amplitude: TFloat):
self.modulator.set_amplitude(amplitude)
[docs]
@portable
def get_frequency(self) -> TFloat:
return self.laser_source.get_frequency() + self.modulator.get_frequency()
[docs]
@kernel
def set_frequency(self, frequency: TFloat):
"""Set the absolute frequency of the light after modulation
Args:
frequency: Absolute frequency of the light after modulation in Hz
"""
pass
@kernel
def _set_frequency_through_modulator(self, frequency: TFloat):
self.modulator.set_frequency(frequency - self.laser_source.get_frequency())
@kernel
def _set_frequency_through_source(self, frequency: TFloat):
self.experiment.log.info("Setting detuning via source for " + self.identifier)
self.laser_source.set_frequency(frequency - self.modulator.get_frequency())
[docs]
@kernel
def ramp(
self,
duration: TFloat,
frequency_start: TFloat = -1.0,
frequency_end: TFloat = -1.0,
amplitude_start: TFloat = -1.0,
amplitude_end: TFloat = -1.0,
power_start: TFloat = -1.0,
power_end: TFloat = -1.0,
ramp_timestep: TFloat = -1.0,
ramp_steps: TInt32 = -1
):
"""Ramp frequency and/or power/amplitude over a given duration.
Parameters default to -1 to indicate no change. If the start frequency/amplitude is set to -1, the ramp starts
from the last frequency/amplitude which was set.
Either power or amplitude can be given to ramp the intensity of the laser. If power is given, it overwrites the
value for the amplitude
This method advances the timeline by ´duration´
Args:
duration: ramp duration [s]
frequency_start: initial frequency [Hz]
frequency_end: end frequency [Hz]
amplitude_start: initial amplitude [Hz]
amplitude_end: end amplitude [Hz]
"""
if power_start >= 0 and power_end >= 0:
amplitude_start = self._amplitude_from_power(power_start)
amplitude_end = self._amplitude_from_power(power_end)
if self.am_device == "mod" and self.fm_device == "mod" \
or (self.am_device == "mod" and self.fm_device == "src" and (amplitude_end + amplitude_start < 0)) \
or (self.am_device == "src" and self.fm_device == "mod" and (frequency_end + frequency_start < 0)):
self.modulator.ramp(duration, frequency_start, frequency_end, amplitude_start, amplitude_end,
ramp_timestep, ramp_steps)
else:
self.experiment.log.error(self.identifier + "Ramping frequency or power via laser source not supported")
[docs]
@kernel
def arb(self,
duration: TFloat,
samples_amp: TList(TFloat) = [],
samples_power: TList(TFloat) = [],
samples_freq: TList(TFloat) = [],
samples_det: TList(TFloat) = [],
samples_phase: TList(TFloat) = [],
repetitions: TInt32 = 1,
prepare_only: TBool = False,
run_prepared: TBool = False,
transform_amp=identity_float,
transform_freq=identity_float,
transform_phase=identity_float
):
"""Play Arbitrary Samples from a List
This method allows to set the output amplitude, frequency an phase according to the values specified in
respective lists. The whole sequence is played in the specified duration. The pattern store in the sample
list can also be repeated.
We supports a scheme to prepare the arb function before it is actually used. If that is needed, run this
function with `prepare_only = True` when the arb should be prepared and with `run_only = True` when the
prepared arb should be played. In both calls the other parameters have to be passed.
Args:
samples_amp: List of amplitude samples. If this list is empty (default), the amplitude is not modified.
samples_power: List of power samples. If this list is empty (default), the amplitude is not modified.
This overwrites `samples_amp`.
samples_freq: List of frequency samples. If this list is empty (default), the frequency is not modified.
samples_det: List of frequency samples relative to the zero frequency. If this list is empty (default),
the frequency is not modified. This overwrites `samples_frequency`
samples_phase: List of phase samples. If this list is empty (default), the phase is not modified.
duration: The time in which the whole sequence of samples should be played back [s].
repetitions: Number of times the sequence of all samples should be played. (default 1)
"""
_transform_power = identity_float
_transform_freq = identity_float
_transform_det = identity_float
if (self.am_device == "src" and len(samples_det) > 0) \
or (self.fm_device == "src" and (len(samples_freq) > 0 or len(samples_det) > 0)):
self.experiment.log.error(self.identifier + ": ARB for frequency or power via laser source not supported")
return
def _transform_power(x):
return self._amplitude_from_power(transform_amp(x))
if len(samples_det) > 0:
def _transform_det(x):
return self.zero_freq + transform_freq(x) - self.laser_source.get_frequency()
if len(samples_power) > 0:
self.modulator.arb(duration, samples_power, samples_det, [], samples_phase, repetitions, prepare_only,
run_prepared, _transform_power, _transform_det, transform_phase)
else:
self.modulator.arb(duration, samples_amp, samples_det, [], samples_phase, repetitions, prepare_only,
run_prepared, transform_amp, _transform_det, transform_phase)
else:
def _transform_freq(x):
return transform_freq(x) - self.laser_source.get_frequency()
if len(samples_power) > 0:
self.modulator.arb(duration, samples_power, samples_freq, [], samples_phase, repetitions, prepare_only,
run_prepared, _transform_power, _transform_freq, transform_phase)
else:
self.modulator.arb(duration, samples_amp, samples_freq, [], samples_phase, repetitions, prepare_only,
run_prepared, transform_amp, _transform_freq, transform_phase)
[docs]
@portable
def get_power(self) -> TFloat:
return self.laser_source.get_power() * self.modulator.get_amplitude()
@portable
def _amplitude_from_power(self, power: TFloat) -> TFloat:
return power / self.src_transmission / self.laser_source.get_power()
[docs]
@kernel
def set_power(self, power: TFloat):
"""Set the absolute power of the light after modulation
Args:
power: Absolute power of the light after modulation in W
"""
if self.am_device == "mod":
self.modulator.set_amplitude(self._amplitude_from_power(power))
if self.am_device == "src":
self.experiment.log.info("Setting power via source for " + self.identifier)
self.laser_source.set_power(power / self.src_transmission / self.modulator.get_amplitude())
[docs]
class StabilizedModulatedLaser(ModulatedLaser, Measurable):
"""A power-stabilized modulated laser
With some means of measuring the laser power after the amplitude modulator and a feedback,
the laser power can be stabilized. This is an abstract class to represent this kind of setups.
To monitor the power of the laser after the modulator, a photodiode is required.
Args:
photodiode: The photodiode that monitors the laser power
"""
kernel_invariants = {"photodiode"}
def __init__(self, photodiode: Photodiode, *args, **kwargs):
ModulatedLaser.__init__(self, *args, **kwargs)
Measurable.__init__(self, ["power"])
self.photodiode = photodiode
[docs]
@kernel
def get_power(self) -> TFloat:
return self.photodiode.get_power()
[docs]
@portable
def stabilize(self, enable: TBool):
self._stabilize(enable)
def _stabilize(self, enable: TBool):
raise NotImplementedError()
[docs]
class AutoCalibratedModulatedLaser(StabilizedModulatedLaser):
"""A laser that uses a Measurable to auto-calibrate its power
With some means of measuring the laser power after the amplitude modulator and a feedback,
the amplitude for the modulator to achieve a certain power in the beam can be auto-calibrated.
To achieve this, this laser measures a configurable number of points and makes a linear
interpolation between them if a power in between is requested.
Args:
points: number of points to measure for the auto-calibration. (default 6)
amp_min: minimum amplitude to test in the auto-calibration
amp_max: maximum amplitude to test in the auto-calibration
"""
kernel_invariants = {"points", "amp_min", "amp_max"}
def __init__(
self,
points: TInt32 = 6,
*args,
amp_min: TFloat = 0.0,
amp_max: TFloat = 1.0,
timestep: TFloat = 325 * us,
**kwargs,
):
StabilizedModulatedLaser.__init__(self, *args, **kwargs)
self.points = points
self.amp_min = amp_min
self.amp_max = amp_max
self.timestep = timestep
# the cache carries entries of the form [power, amplitude]
self.cache = np.array([[-1.0, -1.0]] * (points + 1))
self.cache_filled = False
[docs]
@portable
def clear_cache(self):
self.cache_filled = False
@kernel(flags={"fast-math"})
def _cache_closest_idx(self, power: TFloat) -> TInt32:
min_dist = 1.8e308
min_idx = -1
for i in range(len(self.cache)):
dist = abs(power - self.cache[i][0])
if dist < min_dist:
min_idx = i
min_dist = dist
return min_idx
[docs]
@portable(flags={"fast-math"})
def amplitude_from_power(self, power: TFloat) -> TFloat:
if self.cache_filled:
idx = self._cache_closest_idx(power)
if power > self.cache[idx][0]:
idl = idx
idh = idl + 1
else:
idl = idx - 1
idh = idx
try:
amp = (self.cache[idl][1] + (power - self.cache[idl][0]) / (self.cache[idh][0] - self.cache[idl][0]) *
(self.cache[idh][1] - self.cache[idl][1]))
except ZeroDivisionError:
amp = self.cache[idx][1] / self.cache[idx][0] * power
return amp if amp > 0 else 0.0
else:
self.experiment.log.error(
self.identifier
+ ": Cannot determine amplitude from power since cache is empty"
)
return 0.0
[docs]
@kernel
def autocalibrate(self):
stepsize = (self.amp_max - self.amp_min) / self.points
self.on()
delay(10 * us)
for i in range(self.points + 1):
_amp = self.amp_min + i * stepsize
self.set_amplitude(_amp)
delay(self.timestep)
_power = self.get_power()
delay(1 * ms)
self.cache[i][0] = _power
self.cache[i][1] = _amp
delay(self.timestep)
self.off()
self.cache_filled = True
[docs]
@kernel
def set_power(self, power: TFloat):
"""Set the output power of the laser
If no autocalibration has been done before in this kernel, this will immediately perform the calibration
and set the modulator according to the result. If a calibration is present in the cache, the cache
values are used to calculate an interpolation.
Args:
power: the power the laser should be stabilized to. If the photodiode is calibrated the power should be
given in the calibrated units (e.g. mW).
"""
if not self.cache_filled:
self.autocalibrate()
self.modulator.set_amplitude(self.amplitude_from_power(power))
[docs]
class OptimizerStabilizedModulatedLaser(StabilizedModulatedLaser):
"""A modulated laser power-stabilized through an optimizer
This class integrates an optimizer to find the best setting of the amplitude modulator to achieve the desired power
on the monitoring photodiode. This can be used to create "poor man's PID". Upon calling :func:set_power() it is
checked whether an optimization result for the requested power already exists. If not the optimizer is called to
find the best setting. This result is applied to the modulator and stored in the cache.
To find the best setting, the optimizer will (multiple times) switch on the laser, change its power and measure the
photodiode. When switching on the light interferes with the experimental sequence, make sure to call
:func:set_power() for your desired power for the first time before the experiment starts. Then the optimization will
be done when it does not interfere with your experiment and at a later time the optimization result can be read from
the cache, i.e. not further optimization is possible. Of course this assumes that the laser source power is reasonably
stable during the time between the optimization the the time the value is recalled from the cache.
Args:
optimizer: Optimizer to use for finding the proper setting of the modulator. Make sure that the
`actor_component` of the optimizer is identical to the the lasers `modulator` and the optimizer's
`monitor_component` is identical to the lasers `photodiode`.
cache_size: number of optimization results that can be stored. (default 8)
"""
kernel_invariants = {"optimizer", "cache_size"}
def __init__(self, optimizer: Optimizer, cache_size: TInt32 = 8, *args, **kwargs):
StabilizedModulatedLaser.__init__(self, *args, **kwargs)
# the cache carries entries of the form [power, amplitude]
self.cache = np.array([[-1.0, -1.0]]*cache_size)
self.cache_size = cache_size
self.clear_cache()
self.optimizer = optimizer
if self.optimizer.actor != self.modulator:
self.experiment.log.warning(self.identifier + ": The optimizer " + self.optimizer.identifier +
" does not act on my modulator " + self.modulator.identifier)
if self.optimizer.monitor != self.photodiode:
self.experiment.log.warning(self.identifier + ": The optimizer " + self.optimizer.identifier +
" does not monitor my photodiode " + self.photodiode.identifier)
@kernel
def _cache_idx(self, power: TFloat) -> TInt32:
for i in range(self.cache_len):
if self.cache[i][0] == power:
return i
return -1
[docs]
@portable
def clear_cache(self):
self.cache_len = 0
self.cache_count = 0
[docs]
@kernel
def set_power(self, power: TFloat):
"""Set the output power of the laser
If the requested power is not already in the cache, this will immediately perform the optimization and set the
result on the modulator. If the requested power is present in the cache, the cache value is used.
Args:
power: the power the laser should be stabilized to. If the photodiode is calibrated the power should be
given in the calibrated units (e.g. mW).
"""
cache_idx = self._cache_idx(power)
if cache_idx < 0:
cache_idx = self.cache_count % self.cache_size
self.cache[cache_idx][0] = power
self.cache[cache_idx][1] = self.optimizer.optimize(power)
self.cache_count += 1
self.cache_len = min(self.cache_len + 1, self.cache_size)
self.modulator.set_amplitude(self.cache[cache_idx][1])
[docs]
class ContinuouslyStabilizedModulatedLaser(StabilizedModulatedLaser):
"""A modulated laser power-stabilized through a continuous servo loop
If a laser is power-stabilized by an external (hardware) servo loop, it is represented by this abstract
class.
Args:
start_stabilized: Should the servo loop be activated on startup? (default True)
"""
def __init__(self, start_stabilized: TBool = True, *args, **kwargs):
StabilizedModulatedLaser.__init__(self, *args, **kwargs)
self.stabilized = start_stabilized
@kernel
def _prerun(self):
self.stabilize(self.stabilized)