photoreflectance/prsctrl/prsctrl_interactive.py
JohannesDittloff b1ec523aaa add f sweep
2025-05-09 10:39:47 +02:00

415 lines
15 KiB
Python

"""
run this before using this library:
ipython -i prctrl_interactive.py
"""
from toolz import frequencies
version = "0.1"
import numpy as np
import matplotlib.pyplot as plt
import time
from datetime import datetime as dtime
from os import path, makedirs
import threading as mt
import multiprocessing as mp
import argparse
if __name__ == "__main__":
import sys
if __package__ is None:
# make relative imports work as described here: https://peps.python.org/pep-0366/#proposed-change
__package__ = "prsctrl"
from os import path
filepath = path.realpath(path.abspath(__file__))
sys.path.insert(0, path.dirname(path.dirname(filepath)))
# import device modules
from .devices import shutter as mod_shutter
from .devices import lock_in as mod_lock_in
from .devices import lamp as mod_lamp
from .devices import monochromator as mod_monochromator
# import base classes
from .devices.lock_in import LockInAmp
from .devices.shutter import Shutter
from .devices.lamp import Lamp
from .devices.monochromator import Monochromator
from .devices.lock_in.impl.sr830 import set_measurement_params
from .measurement import measure_spectrum as _measure_spectrum, set_offsets_laser_only, get_wavelengths_values
from .utility.prsdata import PrsData, plot_spectrum
from .utility.config_file import ConfigFile
from .utility.device_select import select_device_interactive, connect_device_from_config_or_interactive
from .update_funcs import Monitor
import logging
log = logging.getLogger(__name__)
# CONFIGURATION
_runtime_vars = {
"last-measurement": ""
}
# defaults, these may be overridden by a config file
settings = {
"datadir": path.expanduser("~/Desktop/PR/data"),
"name": "interactive-test",
"flush_after": 3000,
"use_buffer": False,
}
cfilename: str = "photoreflectance.yaml"
config_path: str = ""
config_file: ConfigFile = ConfigFile("")
test = False
# DEVICES
# global variable for the instrument/client returned by pyvisa/bleak
lockin: LockInAmp | None = None
shutter: Shutter|None = None
lamp: Lamp|None = None
mcm: Monochromator|None = None
t0 = 0
data = None
md = None
from .test_measurement import _measure_both_sim
def measure_both_sim(**kwargs):
return _measure_both_sim(mcm, lockin, shutter, **kwargs)
default_lockin_params = {
"time_constant_s": 10,
# "time_constant_s": 300e-3,
"sensitivity_volt": 500e-6,
"filter_slope": 12,
"sync_filter": 1,
"reserve": "Normal",
"reference": "Internal",
"reference_trigger": "Falling Edge",
"frequency_Hz": 173,
}
default_measurement_params = {
"measurement_time_s": 30,
"sample_rate_Hz": 512,
"wait_time_s": 0,
"wavelengths_nm": (550, 555, 1),
}
def get_time_estimate(lockin_params: dict, measurement_params: dict, offset_with_laser_only=True, extra_wait_time_s=10) -> float:
global lockin
lockin_params = default_lockin_params | lockin_params
measurement_params = default_measurement_params | {"wait_time_s": lockin.get_wait_time_s(lockin_params["time_constant_s"], lockin_params["filter_slope"]) + extra_wait_time_s} | measurement_params
wls = get_wavelengths_values(measurement_params["wavelengths_nm"])
wait_time_s = measurement_params["wait_time_s"]
measurement_time_s = measurement_params["measurement_time_s"]
t = 0
if offset_with_laser_only:
t += 2 * (wait_time_s + 10) # 2 * (at beginning and end), 10 for auto functions
t += len(wls) * (5 + wait_time_s + measurement_time_s + 10) # 5 for setting wavelength, 10 for data transfers
return t
def measure_spectrum(metadata:dict={},
lockin_params={},
measurement_params={},
aux_DC="Aux In 4",
offset_with_laser_only=True,
extra_wait_time_s=10,
name:str|None=None,
dirname=None,
save_spectrum=True
):
global _runtime_vars
global data, md
global lockin, shutter, lamp, mcm
_runtime_vars["last_measurement"] = dtime.now().isoformat()
lockin_params = default_lockin_params | lockin_params
set_measurement_params(lockin, lockin_params)
# must come after lockin settings, since its using get_wait_time_s
measurement_params = default_measurement_params | {"wait_time_s": lockin.get_wait_time_s() + extra_wait_time_s} | measurement_params
# get the frequency, if not given
refkey = "frequency_Hz"
if not refkey in measurement_params:
measurement_params[refkey] = lockin.get_frequency_Hz()
# trigger on the falling edge, since the light comes through when the ref signal is low
# could of course also trigger on rising and apply 180° shift
lockin.run("RSLP 2")
# set metadata
metadata["lock-in_settings"] = lockin_params
metadata["measurement_parameters"] = measurement_params
if name is None:
name = settings["name"]
metadata["name"] = name
print(f"Starting measurement with: Use <C-c> to stop. Save the data using 'data.save_csv()' afterwards.")
plt.ion()
plt_monitor = Monitor(r"$\lambda$ [nm]", [
dict(ax=0, ylabel=r"$\Delta R$", color="green"),
dict(ax=1, ylabel=r"$\sigma_{\Delta R}$", color="green"),
dict(ax=2, ylabel=r"$R$", color="blue"),
dict(ax=3, ylabel=r"$\sigma_R$", color="blue"),
dict(ax=4, ylabel=r"$\Delta R/R$", color="red"),
dict(ax=5, ylabel=r"$\sigma_{\Delta R/R}$", color="red"),
dict(ax=6, ylabel=r"$\theta$", color="aqua"),
dict(ax=7, ylabel=r"$\sigma_{\theta}$", color="aqua"),
])
plt_monitor.set_fig_title(f"Turn on laser and plug detector into A and {aux_DC} ")
data = PrsData(data={}, metadata=metadata, write_data_path=settings["datadir"], write_data_name=settings["name"], write_dirname=dirname)
# measure/set offset
full_scale_voltage = lockin_params["sensitivity_volt"]
def set_offsets(name):
shutter.close()
plt_monitor.set_fig_title(f"Measuring baseline with lamp off")
R_offset_fs, phase_offset_deg = set_offsets_laser_only(lockin, shutter, measurement_params["wait_time_s"])
R_offset_volt = R_offset_fs * full_scale_voltage
data.metadata[f"R_offset_volt_{name}"] = R_offset_volt
data.metadata[f"phase_offset_deg_{name}"] = phase_offset_deg
print(f"R_offset_volt_{name} {R_offset_volt}")
print(f"phase_offset_deg_{name}: {phase_offset_deg}")
if offset_with_laser_only: set_offsets("before")
# data_collector.clear()
data_queue = mp.Queue()
command_queue = mp.Queue()
# Argument order must match the definition
proc_measure = mt.Thread(target=_measure_spectrum, args=(
mcm,
lockin,
shutter,
data,
measurement_params,
aux_DC,
command_queue,
data_queue,
True, # add metadata
))
proc_measure.start()
try:
while proc_measure.is_alive():
while not data_queue.empty():
# print(data_queue.qsize(), "\n\n")
msg = data_queue.get(block=False)
if msg[0] == "data":
# data is as returned by PrsData.get_spectrum_data()
plt_monitor.update(*msg[1][0,:])
elif msg[0] == "set_wavelength":
plt_monitor.set_ax_title("Setting wavelength...")
plt_monitor.set_fig_title(f"$\\lambda = {msg[1]}$ nm")
elif msg[0] == "wait_stable":
plt_monitor.set_ax_title("Waiting until signal is stable...")
elif msg[0] == "measuring":
plt_monitor.set_ax_title("Measuring...")
else:
log.error(f"Invalid tuple received from measurement thread: {msg[0]}")
time.sleep(0.01)
except KeyboardInterrupt:
pass
command_queue.put("stop")
proc_measure.join()
plt_monitor.set_fig_title("")
plt_monitor.set_ax_title("")
try:
if offset_with_laser_only: set_offsets("before")
data["reference_freq_Hz_after"] = lockin.get_frequency_Hz()
except Exception as e:
print(e)
print("Measurement stopped" + " "*50)
if save_spectrum:
plt.ioff()
fig = plot_spectrum(data, title=name, what=["dR_R", "theta"])
fig_path = path.join(data.path, data.dirname + ".pdf")
fig.savefig(fig_path)
return fig
def sweep_ref():
wavelenghts = [500, 550, 650, 660, 670, 680]
frequencies = list(range(27, 500, 5))
frequencies = [111, 444]
lockin_params = {
"time_constant_s": 10,
}
measurement_params = {
"wavelengths_nm": wavelenghts,
}
time_est = len(frequencies) * get_time_estimate(lockin_params=lockin_params, measurement_params=measurement_params, offset_with_laser_only=True, extra_wait_time_s=10)
print(f"Estimated time: {time_est}")
return
for f in frequencies:
dirname = f"2025-05-07_f-scan_f={f}_Hz"
lockin_params["frequency"] = f
measure_spectrum(lockin_params=lockin_params, measurement_params=measurement_params, dirname=dirname, name="Frequency scan $f = {f}$ Hz")
plt.close('all')
# DATA
def data_load(dirname:str) -> tuple[np.ndarray, dict]:
"""
Load data in directory <dirname> in the data directory as numpy array.
Sets the `data` and `md` variables
Parameters
----------
dirname : str
Absolute path to the directory containing the measurement or directory name in the data directory.
"""
global data, md
if path.isabs(dirname):
dirpath = dirname
else:
dirpath = path.join(settings["datadir"], dirname)
data, md = PrsData.load_data_from_dir(dirpath, verbose=True)
# SETTINGS
def set(setting, value):
global settings, config_path
if setting in settings:
if type(value) != type(settings[setting]):
print(f"set: setting '{setting}' currently holds a value of type '{type(settings[setting])}'")
return
settings[setting] = value
config_file.set(setting, value)
def name(s:str):
global settings
settings["name"] = s
def save_settings():
global settings
for k, v in settings.items():
config_file.set(k, v)
config_file.save()
def load_settings():
global settings, config_path
settings = config_file.get_values()
settings["datadir"] = path.expanduser(settings["datadir"]) # replace ~
def help(topic=None):
if topic == None:
print("""
Functions:
monitor - take measurements with live monitoring in a matplotlib window
data_load - load data from a directory
data_plot - plot a data array
Run 'help(function)' to see more information on a function
Available topics:
imports
settings
Run 'help("topic")' to see more information on a topic""")
elif topic in [settings, "settings"]:
print(f"""Settings:
name: str - name of the measurement, determines filename
led: str - name/model of the LED that is being used
datadir: str - output directory for the csv files
interval: int - interval (inverse frequency) of the measurements, in seconds
beep: bool - whether the device should beep or not
Functions:
name("<name>") - short for set("name", "<name>")
set("setting", value) - set a setting to a value
save_settings() - store the settings in the config file
load_settings() - load settings from a file
Upon startup, settings are loaded from the config file.
The global variable 'config_path' determines the path used by save/load_settings. Use -c '<path>' to set another path.
The search path is:
<working-dir>/{cfilename}
$XDG_CONFIG_HOME/{cfilename}
~/.config/photoreflectance/{cfilename}
The current file path is:
{config_path}
""")
elif topic == "imports":
print("""Imports:
numpy as np
pandas as pd
matplotlib.pyplot as plt
os.path """)
else:
print(topic.__doc__.strip(" ").strip("\n"))
def connect_devices():
global lockin, shutter, lamp, mcm
lockin = mod_lock_in.connect_device(*select_device_interactive(mod_lock_in.list_devices(), "Select Lock-In-Amplifier: "))
shutter = mod_shutter.connect_device(*select_device_interactive(mod_shutter.list_devices(), "Select Shutter: "))
lamp = mod_lamp.connect_device(*select_device_interactive(mod_lamp.list_devices(), "Select Lamp: "))
mcm = mod_monochromator.connect_device(*select_device_interactive(mod_monochromator.list_devices(), "Select Monochromator: "))
def init():
global lockin, shutter, lamp, mcm, settings, config_path, config_file
print(r""" __ .__
_____________ ______ _____/ |________| |
\____ \_ __ \/ ___// ___\ __\_ __ \ |
| |_> > | \/\___ \\ \___| | | | \/ |__
| __/|__| /____ >\___ >__| |__| |____/
|__| \/ \/ """ + f"""{version}
Interactive Shell for Photoreflectance measurements
---
Enter 'help()' for a list of commands""")
parser = argparse.ArgumentParser(
prog="prsctrl",
description="measure photoreflectance",
)
backend_group = parser.add_mutually_exclusive_group(required=False)
parser.add_argument("-c", "--config", action="store", help="alternate path to config file")
args = vars(parser.parse_args())
from os import environ
# Load config file
if path.isfile(cfilename):
config_path = cfilename
elif 'XDG_CONFIG_HOME' in environ.keys():
config_path = path.join(environ["XDG_CONFIG_HOME"], "prsctrl", cfilename)
else:
config_path = path.join(path.expanduser("~/.config/prsctrl"), cfilename)
if args["config"]:
config_path = args["config"]
config_file = ConfigFile(config_path, init_values=settings)
load_settings()
# setup logging
log_path = path.expanduser(config_file.get_or("path_log", "~/.cache/prsctrl-interactive.log"))
makedirs(path.dirname(log_path), exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] [%(name)s] %(message)s",
handlers=[
logging.FileHandler(log_path),
logging.StreamHandler()
]
)
if not path.isdir(settings["datadir"]):
makedirs(settings["datadir"])
# init the devices
shutter = connect_device_from_config_or_interactive(config_file, "shutter", "Shutter", mod_shutter, log=log)
lockin = connect_device_from_config_or_interactive(config_file, "lock-in", "Lock-In Amplifier", mod_lock_in, log=log)
lamp = connect_device_from_config_or_interactive(config_file, "lamp", "Lamp", mod_lamp, log=log)
mcm = connect_device_from_config_or_interactive(config_file, "monochromator", "Monochromator", mod_monochromator, log=log)
# atexit.register(_backend.exit, dev)
if __name__ == "__main__":
init()