"""
@author: Matthias Quintern
"""
from devctrl.devices import LockInAmp, Amplifier, Monochromator, Shutter
from devctrl.utility.time import duration_to_string
from devctrl.utility.threading import FakeThread, ThreadWithExceptionSupport
from ..data.prsdata import PrsData
from ..data.functions import eV_to_nm, bandwidth_eV_to_nm, nm_to_eV
from .settings import PrsSettings, LockInSettings, MeasurementSettings
from mqutil.dict_util import get_subdict
import time
import datetime
from multiprocessing import Queue
import numpy as np
import threading
import logging
log = logging.getLogger(__name__)
import pyvisa
[docs]
def get_time():
return datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
[docs]
class LockInHandler:
"""
Wrapper class for a lock-in amplifier, that abstracts some model-specific things like setting up a buffer,
where the number of quantities, max. number of values and the available sample rates depend on the model.
"""
def __init__(self, mode: str, lockin: LockInAmp, lockin_settings: LockInSettings):
self.mode:str = mode
self.lockin:LockInAmp = lockin
self.lockin_settings: LockInSettings = lockin_settings
# the lock-in aux port where the DC signal is connected to
log.debug(f"[mode={mode}]: Set aux_DC={self.lockin_settings.aux_DC} for lockin {lockin}")
# which lock-in type is used
# use strings to avoid having to import the modules, which might not be available
self.SR830 = "SR830" in str(lockin)
self.Model7260 = "7260" in str(lockin)
self.n_try = 2 # how often to try to recover from a communication error
self.sample_rate_Hz = -1
self.n_bins = -1 # number of data points to capture in the buffer
# use h_ prefix (for handler) to avoid any name conflicts with LockInAmp class
[docs]
def set_measurement_time(self, measurement_time_s, target_sample_rate_Hz=None):
"""
Convert the target sample rate to one that is compatible with the device.
Also calculates the number of values to be recorded for a given measurement time
:param target_sample_rate_Hz: targeted sample rate, or 'max' to use the maximum for the given measurement time
"""
n_vals = 2 if self.SR830 else 3 # R, Aux for SR830, R, theta, aux else
max_len = self.lockin.buffer_get_max_size(n_vals)
if not target_sample_rate_Hz:
target_sample_rate_Hz = self.lockin_settings.sample_rate_Hz
if target_sample_rate_Hz == "max":
target_sample_rate_Hz = max_len/measurement_time_s # perfect sample rate to fully fill the buffer in the measurement time
self.sample_rate_Hz = self.lockin.get_next_valid_sample_rate_Hz(target_sample_rate_Hz)
self.n_bins = int(measurement_time_s * self.sample_rate_Hz)
log.info(f"[mode={self.mode}]: Lock-In={self.lockin.get_device_name()}, target_sample_rate={target_sample_rate_Hz} Hz, sample_rate={self.sample_rate_Hz} Hz, for t = {measurement_time_s} => n_bins={self.n_bins}")
return self.sample_rate_Hz, self.n_bins
def __getattr__(self, name):
"""
This allows any LockInAmp method to be called on an LockInHandler object.
A wrapper is added, so that if an IO error occurs during that method, a recovery attempt is performed.
"""
try:
attr = getattr(self.lockin, name)
except AttributeError:
raise AttributeError(f"LockInHandler or LockInAmp do not have attribute '{name}'")
if callable(attr):
def method_wrapper(*args, **kwargs):
com_success = self.n_try
err = None
while com_success > 0:
try:
return attr(*args, **kwargs)
except pyvisa.VisaIOError as er:
err = er
# TODO: retry if status bit is set
self.lockin.try_recover_from_communication_error(er)
com_success -= 1
raise RuntimeError(f"[mode={self.mode}]: pyvisa.VisaIOError: {err}")
return method_wrapper
else:
return attr
[docs]
class MeasurementHandler:
def __init__(self,
measurement_settings: MeasurementSettings,
wavelengths: list[str|int|float],
data_queue: Queue,
command_queue: Queue,
monochromators: Monochromator|list[Monochromator],
shutter: Shutter,
add_measurement_info_to_metadata: bool=True,
compress_data: bool=True,
):
"""
Class that performs the actual measurement.
:param monochromator:
:param shutter:
:param measurement_params:
:param signal_to_noise_f: A function that takes the PrsData and the current wavelength and determines whether the
signal to noise ratio is already satisfying. If the function returns True, the measurement is stopped early.
:param command_queue: A queue used to receive a "stop" or other signals. Useful when `run()` is run in another thread
:param data_queue: A queue where the data of a certain wavelength is put in after it has been measured.
Useful for live monitoring when `run()` is run in another thread
:param add_measurement_info_to_metadata:
:param compress_data: Whether to use gzip compression on full data file
"""
self.measurement_settings = measurement_settings
self.data_queue:Queue = data_queue
self.command_queue:Queue = command_queue
self.modes: dict[str, tuple[LockInHandler, PrsData, Amplifier|None]] = {}
if isinstance(monochromators, Monochromator):
self.monochromators = [monochromators]
else:
self.monochromators = monochromators
self.shutter = shutter
self.add_measurement_info_to_metadata = add_measurement_info_to_metadata
self.compress_data = compress_data
self.auto_gain = measurement_settings.auto_gain
self.auto_sensitivity = measurement_settings.auto_sensitivity
self.wait_time_s = -1
self.measurement_time_s = -1
self.timeout_s = -1
self.timeout_interval = -1
self.wavelengths = wavelengths
self.target_I_range_V: tuple[float, float] = (0.6, 9.5)
#
# PREPARATION
#
[docs]
def add_measurement_mode(self, mode: str, lockin: LockInAmp, lockin_settings: LockInSettings, data: PrsData, pre_amp: Amplifier|None=None):
"""
Add a Lock-In that records data using the measurement.
:param mode: key of the measurement mode, eg 'ref' or 'tra'
:param lockin:
:param data: PrsData object with no data initialized (it may already contain metadata). Each mode needs its own PrsData object
:param aux_DC: The name of auxiliary input channel the DC signal is connected to on the lock-in
"""
handler = LockInHandler(mode, lockin, lockin_settings)
self.modes[mode] = (handler, data, pre_amp)
[docs]
def prepare(self):
"""
Setup some settings, filling in defaults for missing parameters.
"""
# unfortunately, some stuff is lock-in specific
# use string checks to avoid having to import the modules
self.wait_time_s = self.measurement_settings.wait_time_s
self.measurement_time_s = self.measurement_settings.measurement_time_s
for (handler, data, _) in self.modes.values():
sample_rate_Hz, n_bins = handler.set_measurement_time(self.measurement_time_s)
# add sample rate that is actually going to be used to metadata
print(data.metadata)
if self.add_measurement_info_to_metadata:
get_subdict(data.metadata, "lock-in-amp")["sample_rate_Hz"] = sample_rate_Hz
self.timeout_s = 3 * self.measurement_time_s
self.timeout_interval = 0.25
[docs]
def auto_determine_gain(self,
min_DC: float=0.8,
max_DC: float=9.95,
N_test: int=6,
wait_time_s: float=0.2,
):
"""
Auto-determine the optimum gain value for each pre-amplifier.
Sweeps a few points in the spectrum and adjust the gain so that the DC value does not exceed `max_DC` V, but if possible, stays above `min_DC` mV.
This function must be called after `prepare`, all relevant parameters (especially the monochromator bandwidth) must have been set on the devices already.
:param min_DC: The minimum voltage for the DC signal.
:param max_DC: The maximum voltage for the DC signal. If any value crosses this threshold, the gain is reduced, even if this causes other wavelengths to o below `min_DC`.
:param wait_time_s: Time to wait after setting the wavelength. Small values should be enough since only the DC value is measured.
:param N_test: Target number of wavelengths for which to check the DC value
:raises RuntimeError: If `prepare()` was not called first
"""
if self.measurement_time_s < 0:
raise RuntimeError(f"You must call `prepare()` before `auto_determine_gain()`")
# filter offsets
wls = [wl for wl in self.wavelengths if isinstance(wl, float)]
N_wls = min(N_test, len(wls))
test_wavelengths = np.linspace(wls[0], wls[-1], N_wls)
# sort to start by shortest wavelength
# least DC signal to be expected at low wavelengths because of weak lamp emission and strong sample absorption
test_wavelengths = np.sort(test_wavelengths)
log.debug(f"Testing {N_wls} wavelengths: {test_wavelengths}")
DC_values_modes: dict[str, list[float]] = {key: [] for key in self.modes.keys()}
self.shutter.open()
i = 0
while i < len(test_wavelengths):
wl = test_wavelengths[i]
log.info(f"Measuring at lambda={wl} nm")
for m in self.monochromators:
m.set_wavelength_nm(wl)
time.sleep(wait_time_s)
# Adjust the gain for each mode
for mode_idx, (mode_key, (lockin, data, pre_amp)) in enumerate(self.modes.items()):
if pre_amp is None: continue
DC_values = DC_values_modes[mode_key]
val = lockin.lockin.read_value(lockin.lockin_settings.aux_DC)
# INCREASE if value below threshold and all other values still below max after change
while val < min_DC and all([v * 10 <= max_DC for v in DC_values]):
try:
gain = pre_amp.increase_gain()
log.debug(f"Increased gain for mode='{mode_key}' to 10^{gain} V/A (value was {val:.3f} V)")
for j in range(len(DC_values)):
DC_values[j] *= 10
time.sleep(wait_time_s)
val = lockin.lockin.read_value(lockin.lockin_settings.aux_DC)
except ValueError as e:
log.error(f"Can not decrease gain anymore ({type(e)}: {e})")
break
# DECREASE if above max -> dont check if other values become too small
while val >= max_DC:
try:
gain = pre_amp.decrease_gain()
log.debug(f"Decreased gain for mode='{mode_key}' to 10^{gain} V/A (value was {val:.3f} V)")
for j in range(len(DC_values)):
DC_values[j] *= 0.1
time.sleep(wait_time_s)
val = lockin.lockin.read_value(lockin.lockin_settings.aux_DC)
except ValueError as e:
log.error(f"Can not decrease gain anymore ({type(e)}: {e})")
break
DC_values.append(val)
i += 1
self.shutter.close()
# Readout final gain
for mode_idx, (mode_key, (lockin, data, pre_amp)) in enumerate(self.modes.items()):
try:
gain = pre_amp.get_gain()
except Exception as e:
log.error(e)
gain = None
log.debug(f"Final gain for mode='{mode_key}' is 10^{gain} V/A")
# TODO: this is ugly, the value belongs to the measurement settings per mode, which are not available here
# self.lockin. = gain
return wls, DC_values_modes
#
# RUN
#
[docs]
def run(self):
"""
Run the measurement. You must call `prepare()` first.
`run()` may be run a separate thread.
"""
if self.measurement_time_s < 0:
raise RuntimeError(f"You must call `prepare()` before `run()`")
t_start = get_time()
t_start_t = time.time()
for k, (lockin, data, _) in self.modes.items():
if self.add_measurement_info_to_metadata:
data.metadata["measurement_time_start"] = t_start
data.metadata["reference_freq_Hz_start"] = lockin.get_frequency_Hz()
# write metadata to disk
data.write_metadata()
data.metadata["messages"] = []
try:
self._run()
except KeyboardInterrupt:
log.info("Keyboard interrupt, stopping measurement")
except Exception as e:
log.critical(f"Unexpected error, stopping measurement. Error: {e}")
if self.command_queue is not None:
self.command_queue.put(("exception", e))
raise RuntimeError(f"Unexpected error, stopping measurement") from e
for k, (lockin, data, _) in self.modes.items():
if self.add_measurement_info_to_metadata:
t_stop = get_time()
t_stop_t = time.time()
data.metadata["measurement_time_stop"] = t_stop
data.metadata["measurement_time_duration"] = duration_to_string(t_stop_t - t_start_t)
data.metadata["reference_freq_Hz_stop"] = lockin.get_frequency_Hz()
# Write again after having updated the stop time
data.write_metadata()
data.write_full_file(compress=self.compress_data)
log.info("Spectrum measurement done")
#
# INTERNAL RUN-UTILITIES
#
def _auto_sensitivity(self, wl):
"""
Check for a lock-in output overload and increase the sensitivity value until the overload is gone
"""
for k, (lockin, data, _) in self.modes.items():
mode = lockin.mode
# reset status register
lockin.start_check_overloads()
# Check if the sensitivity is out of range by testing for an overload of the output
overload = "output" in lockin.check_overloads().lower()
n_tries = 10
while overload and n_tries > 0:
n_tries -= 1
try:
new_sensi = lockin.increase_full_scale_sensitivity_volt()
msg = f"{wl} nm: lock-in reports output overload, increasing sensitivity to {new_sensi} V"
log.warning(f"[mode={mode}]:" + msg)
data.metadata["messages"].append(msg)
self.data_queue.put(("warning", msg))
time.sleep(5)
lockin.start_check_overloads()
time.sleep(5)
overload = "output" in lockin.check_overloads().lower()
except Exception as e:
log.error(f"[mode={mode}]: Failed to increase sensitivity: {e}")
def _is_gain_modification_needed_all(self) -> bool:
"""
Determine if any measurement mode requires a modification of the amplifier gain setting.
:return: True if modification is needed
"""
is_needed = False
for k, (lockin, data, pre_amp) in self.modes.items():
if pre_amp is None: continue
if self._is_gain_modification_needed(lockin):
is_needed = True
return is_needed
def _is_gain_modification_needed(self, lockin: LockInHandler):
I: float = lockin.lockin.read_value(lockin.lockin_settings.aux_DC)
if I < self.target_I_range_V[0]:
return True
elif I > self.target_I_range_V[1]:
return True
return False
def _auto_gain_all(self, wl_nm):
"""
Increase or decrease the pre-amp gain if the DC value goes out of the target range.
:note: Gain is only increased or decreased by max. 1 step during this function! Do not use it to set the initial gain.
"""
for k, (lockin, data, pre_amp) in self.modes.items():
if pre_amp is None: continue
while self._auto_gain(wl_nm, lockin, data, pre_amp):
time.sleep(0.1)
def _auto_gain(self, wl_nm, lockin: LockInHandler, data: PrsData, pre_amp:Amplifier|None) -> bool:
if pre_amp is None: return False
mode = lockin.mode
I: float = lockin.lockin.read_value(lockin.lockin_settings.aux_DC)
log.debug(f"[mode={mode}]: _auto_gain. I={I} V")
gain = None
old_gain = None
if I < self.target_I_range_V[0]:
try:
old_gain = pre_amp.get_gain()
gain = pre_amp.increase_gain()
except Exception as e:
log.error(f"[mode={mode}]: Failed to increase gain: {type(e)}: {e}")
return False
elif I > self.target_I_range_V[1]:
try:
old_gain = pre_amp.get_gain()
gain = pre_amp.decrease_gain()
except Exception as e:
log.error(f"[mode={mode}]: Failed to decrease gain: {type(e)}: {e}")
return False
if gain is not None and old_gain is not None:
if gain > old_gain:
msg = f"{wl_nm} nm: I={I:.3f} V outside of target range, decreased gain from 10^{old_gain} to 10^{gain}"
else:
msg = f"{wl_nm} nm: I={I:.3f} V outside of target range, increased gain from 10^{old_gain} to 10^{gain}"
log.info(f"[mode={mode}]: " + msg)
data.metadata["messages"].append(msg)
self.data_queue.put(("warning", msg))
return True
return False
def _set_wavelength_and_wait(self, wl_nm):
"""
If the wavelength is a number, set the monochromator to that number and make sure the shutter is open.
If it is not a number, close the shutter.
After that, wait for the wait_time_s until the signal should be stable
"""
try:
if type(wl_nm) == int or type(wl_nm) == float:
self.data_queue.put(("set_wavelength", wl_nm))
if not self.shutter.is_open(): self.shutter.open()
for mcm in self.monochromators:
mcm.set_wavelength_nm(wl_nm)
else:
self.data_queue.put(("close_shutter", wl_nm))
if self.shutter.is_open(): self.shutter.close()
# wait the wait time
self.data_queue.put(("wait_stable", self.wait_time_s))
time.sleep(self.wait_time_s)
except Exception as ex:
log.critical(f"Exception occurred while setting wavelength and waiting: {ex}")
def _get_data(self, wl:int|float|str, mode_key: str, lockin: LockInHandler, data: PrsData, measurement_duration: int):
"""
Transfer the data from the lock-in, add it to the PrsData object, calculate the spectrum data and send it via the data queue
"""
overload = lockin.check_overloads() # check must occur before setting new wavelength, as that may lead to overload
self.data_queue.put(("get_data", mode_key))
lockin.buffer_stop_fill()
lock_in_data = lockin.buffer_get_data(start=0, n_points=lockin.n_bins) # indices in the order of the "what" param during buffer_setup
data[wl]["lock-in-R_raw"] = lock_in_data[0,:]
data[wl]["lock-in-aux_raw"] = lock_in_data[1,:]
if lockin.SR830:
# convert to np array
data[wl]["lock-in-theta_raw"] = np.array(data[wl]["lock-in-theta_raw"])
else:
data[wl]["lock-in-theta_raw"] = lock_in_data[2,:]
if overload:
msg = f"[{mode_key}]: Overload of {overload} at {wl} nm"
log.warning(msg)
data.metadata["messages"].append(msg)
# this measurement time is likely greatly exaggerated, because it also includes the time to transmit and process the data
data[wl]["measurement_duration_s"] = measurement_duration
# save the spectrum data and send the results for wl through the queue
# using default spectrum columns
spec_data, cols = data.get_spectrum_data(wavelengths=[wl])
data.write_partial_file(wl)
# if wl is a string, write the result of R and theta to metadata make it compatible with versions < 0.4
if type(wl) == str:
# for offsets, lock-in-R = dI and lock-in-aux = I
R = float(spec_data[0, cols.index("lock-in-R")])
theta = float(spec_data[0, cols.index("theta")])
I = float(spec_data[0, cols.index("lock-in-aux")])
data.metadata[wl] = {
data.get_column_label_with_unit("dI"): R,
data.get_column_label_with_unit("I"): I,
data.get_column_label_with_unit("theta"): theta,
}
data.write_metadata()
self.data_queue.put(("offset", mode_key, R, theta))
elif self.data_queue is not None:
self.data_queue.put(("data", mode_key, spec_data))
def _run(self):
"""
Run a spectrum measurement.
self.wavelengths if a list of wavelengths and offsets to measure.
Parallelization in the form of multithreading is used to save time:
setting the wavelength+waiting and the data transfer+processing are run in separate threads, when all are done the next measurement phase begins.
One measurement includes:
- Setting the wavelength
- Wait until the signal should be stable
- Initializing empty data in PrsData objects
- Auto-increase sensitivity if necessary
- Measure using the lock-in buffer for <measurement time>, for models where only 2values can be stored in the buffer, the phase is measured separately in regular intervals (leading to much less data points)
- Transfer the data from the lock-in
- Calculate means and errors, send them to the parent thread via the data queue
- Check the command for commands
"""
i_wl = 0
wl = self.wavelengths[i_wl]
sub_thread_q = Queue()
thread_set_wl = ThreadWithExceptionSupport(queue=sub_thread_q, target=self._set_wavelength_and_wait, args=[wl])
thread_set_wl.start()
lockin_threads: dict[str, threading.Thread] = {}
thread_join_timeout = max(90, 2*self.wait_time_s)
err = None
try:
while i_wl < len(self.wavelengths):
wl = self.wavelengths[i_wl]
log.info(f"Measuring at lambda={wl} nm")
# join get-data threads
for k, thread in lockin_threads.items():
thread.join(timeout=thread_join_timeout) # getting data should never take longer than 90 seconds
if thread.is_alive():
raise RuntimeError(f"Timed out while waiting lock-in thread for '{k}' to finish")
# check errors
if sub_thread_q.qsize() > 0:
err, trace = sub_thread_q.get()
msg = f"A thread in _run had an error: {type(err)}: {err}\nTraceback: {trace}"
raise RuntimeError(msg) from err
# setup new measurement
for k, (lockin, data, _) in self.modes.items():
if lockin.SR830:
lockin.buffer_setup(what=["R", lockin.lockin_settings.aux_DC], length=lockin.n_bins, sample_rate_Hz=lockin.sample_rate_Hz)
else:
lockin.buffer_setup(what=["R", lockin.lockin_settings.aux_DC, "theta"], length=lockin.n_bins, sample_rate_Hz=lockin.sample_rate_Hz)
# while setting wavelength, prepare measurement
data[wl] = {}
data[wl]["lock-in-R_raw"] = np.empty(lockin.n_bins, dtype=float)
data[wl]["lock-in-aux_raw"] = np.empty(lockin.n_bins, dtype=float)
data[wl]["lock-in-theta_raw"] = []
def measure_phase():
for k, (lockin, data, _) in self.modes.items():
if lockin.SR830:
data[wl]["lock-in-theta_raw"].append(lockin.read_value("theta"))
else:
pass
# wait until signal is stable
thread_set_wl.join(timeout=thread_join_timeout)
if thread_set_wl.is_alive():
raise RuntimeError(f"Timed out while waiting for the thread setting the wavelength to finish")
if sub_thread_q.qsize() > 0:
err, trace = sub_thread_q.get()
msg = f"A thread in _run had an error: {err}"
raise RuntimeError(msg) from err
self.data_queue.put(("measuring", ))
# auto sensitivity could be sped up by parallelization
if self.auto_sensitivity: self._auto_sensitivity(wl)
for k, (_, data, _) in self.modes.items():
data[wl]["timestamp_start"] = time.time()
measure_phase()
for k, (lockin, _, _) in self.modes.items():
lockin.buffer_start_fill()
t_start = time.time()
# check if its done
t = self.timeout_s
while t > 0:
t -= self.timeout_interval
time.sleep(self.timeout_interval)
measure_phase()
all_done = True
# ideally, both should finish at the same time, but since the measurements be be started a few hundred ms apart
# they might not finish at exactly the same time
for k, (lockin, _, _) in self.modes.items():
n_points: int = lockin.buffer_get_n_points()
if n_points < lockin.n_bins: # not done
all_done = False
if all_done:
break
if t < 0: raise RuntimeError(f"Timed out waiting for measurement on wavelength {wl} nm to finish")
# measurement time up, get the data and set next wavelength
t_stop = time.time()
# acquire data, MUST be started before setting wavelength since the overload check occurs there,
# and setting a wavelength might cause an overload
for k, (lockin, data, _) in self.modes.items():
lockin_threads[k] = ThreadWithExceptionSupport(queue=sub_thread_q, target=self._get_data, args=[wl, k, lockin, data, t_stop-t_start])
lockin_threads[k].start()
if i_wl < len(self.wavelengths) - 1: # save time by already setting the wavelength now
next_wl = self.wavelengths[i_wl+1]
thread_set_wl = ThreadWithExceptionSupport(queue=sub_thread_q, target=self._set_wavelength_and_wait, args=[next_wl])
thread_set_wl.start()
# if a pipe was given, check for messages
if self.command_queue is not None and self.command_queue.qsize() > 0:
recv = self.command_queue.get(block=False)
if recv == "stop":
log.info(f"Received 'stop', stopping measurement")
break
elif type(recv) == tuple and recv[0] == "metadata":
log.info(f"Received 'metadata', updating metadata")
for k, (_, data, _) in self.modes.items():
data.metadata |= recv[1]
data.write_metadata()
else:
log.error(f"Received invalid message: '{recv}'")
i_wl += 1
except Exception as e:
err = e
finally:
if thread_set_wl.is_alive():
log.debug(f"Joining thread: 'set_wl'")
thread_set_wl.join(timeout=thread_join_timeout)
for k, thread in lockin_threads.items():
if thread.is_alive():
log.debug(f"Joining thread: 'get-data-{k}'")
thread.join()
if err:
raise err