Source code for prsctrl_gui.measurement.pump_power_calibration
""":class:`.PowerCalibrationWidget` manages pump light power calibration.
"""
from numpy.typing import NDArray
from matplotlib.pylab import sca
from PyQt6 import QtCore
from PyQt6.QtGui import QIcon, QPixmap
from PyQt6.QtWidgets import QComboBox, QDialog, QGroupBox, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
from devctrl.gui.widgets import SettingsWidget
from devctrl.gui.widgets.settings import FieldWidget, FieldWidgetNone
from devctrl.utility.file_io import get_date_for_filename
from prsctrl import device_keys as dk
from prsctrl.data.plot import nm_to_eV
from prsctrl.measurement.pump_power_calibration import PowerCalibration, PowerMeter, Laser, LedController, measure_light_intensity
from devctrl.gui.widgets.settings_db import SettingsDbWidget, SettingsDb
from pyqtgraph.Qt.QtCore import Qt
from prsctrl_gui.resources import get_resource_path, get_resource_path_from
from ._new_power_calibration import NewPowerCalibration
from prsctrl_gui.utility.config import AppCache, AppConfig
from ..app_state import power_calibration_db
from ..app_state import device_manager
import logging
log = logging.getLogger(__name__)
from devctrl.utility.threading import ThreadWithExceptionSupport, Queue
import time
from pyqtgraph import PlotItem, GraphicsLayoutWidget, ScatterPlotItem, PlotCurveItem
import numpy as np
FieldWidget.add_field_override(PowerCalibration, "calibration_data", FieldWidgetNone)
[docs]
class PowerCalibrationPlot(GraphicsLayoutWidget):
"""Plots a `.PowerCalibration` instance.
The widget shows two plots:
1. Power setting (led current in A or laser power in mW) vs power density in mW/cm^2
2. Powr density in mW/cm^2 vs power setting.
For each plot, the data points are scattered and a solid line shows values calculated using the configured conversion method (interpolate and polyfit).
While the data in 2. is just a reverse of 1., the conversion function may use a different fit and thus the calculated values may not be the exact inverse.
:param power_calib:
"""
[docs]
def __init__(self, power_calibration: PowerCalibration, parent=None):
super().__init__(parent)
self.power_calib = power_calibration
self.plot_1 = self.plot_data_points(power_calibration)
self.plot_rev = self.plot_reverse(power_calibration)
self.addItem(self.plot_1, 0, 0)
self.addItem(self.plot_rev, 0, 1)
[docs]
def update_plot(self, power_calibration: PowerCalibration):
"""Redraw the plot. Call this when the calculation method changed.
:param power_calibration:
"""
self.power_calib = power_calibration
self.removeItem(self.plot_1)
self.removeItem(self.plot_rev)
self.plot_1 = self.plot_data_points(power_calibration)
self.plot_rev = self.plot_reverse(power_calibration)
self.addItem(self.plot_1, 0, 0)
self.addItem(self.plot_rev, 0, 1)
[docs]
@classmethod
def get_labels(cls, power_calibration: PowerCalibration) -> tuple[str, str]:
"""Get plot labels.
:param power_calibration:
:return: Power setting label, power density label
"""
if power_calibration.light_source_type == "laser":
return "Laser Power (mW)", "Pump Power Density (mW/cm^2)"
elif power_calibration.light_source_type == "led":
return "LED Current (A)", "Pump Power Density (mW/cm<sup>2</sup>)"
else:
raise ValueError(f"Invalid light source type: '{power_calibration.light_source_type}'. Valid are 'laser' or 'led'.")
[docs]
@classmethod
def get_power_setting(cls, y: NDArray, power_calibration: PowerCalibration) -> NDArray:
"""Get the power setting values of the calibration data using the configured conversion method.
:param y: The power density values to calculate the power setting for.
:param power_calibration:
:return: Power setting values (either in A for LED or in mW for laser) in same shape as x.
"""
if power_calibration.light_source_type == "laser":
return power_calibration.power_density_mWcm2_to_laser_power_mW(y)
elif power_calibration.light_source_type == "led":
return power_calibration.power_density_mWcm2_to_led_current_A(y)
else:
raise ValueError(f"Invalid light source type: '{power_calibration.light_source_type}'. Valid are 'laser' or 'led'.")
[docs]
@classmethod
def get_power_density_mWcm2(cls, x: NDArray, power_calibration: PowerCalibration) -> NDArray:
"""Get the power density values of the calibration data using the configured conversion method..
:param x: The power setting data to calculate the density for.
:param power_calibration:
:return: Power density in same shape as x
"""
if power_calibration.light_source_type == "laser":
return power_calibration.laser_power_mW_to_power_density_mWcm2(x)
elif power_calibration.light_source_type == "led":
return power_calibration.led_current_A_to_power_density_mWcm2(x)
else:
raise ValueError(f"Invalid light source type: '{power_calibration.light_source_type}'. Valid are 'laser' or 'led'.")
[docs]
@classmethod
def plot_data_points(cls, power_calibration: PowerCalibration, plot_raw: bool=True, plot_conversion: bool=True) -> PlotItem:
"""Plot power setting vs. power setting.
:param power_calibration:
:param plot_raw: Whether to scatter the raw data points
:param plot_conversion: Whether to plot the conversion function
:return: PlotItem
"""
item = PlotItem(name="data")
xlabel, ylabel = cls.get_labels(power_calibration)
item.setLabel("bottom", xlabel)
item.setLabel("left", ylabel)
item.setTitle(cls.tr("Power to Power Density"))
if plot_raw:
scatterplot = ScatterPlotItem()
scatterplot.setData(power_calibration.calibration_data[:,0], power_calibration.calibration_data[:,1])
item.addItem(scatterplot)
if plot_conversion:
curve = PlotCurveItem()
N_POINTS = 200
x = np.linspace(power_calibration.calibration_data[0,0], power_calibration.calibration_data[-1,0], N_POINTS)
y = cls.get_power_density_mWcm2(x, power_calibration)
curve.setData(x, y)
item.addItem(curve)
return item
[docs]
@classmethod
def plot_reverse(cls, power_calibration: PowerCalibration, plot_raw: bool=True, plot_conversion: bool=True) -> PlotItem:
"""Plot the reverse function: power density vs. power setting.
:param power_calibration:
:param plot_raw: Whether to scatter the raw data points
:param plot_conversion: Whether to plot the conversion function
:return: PlotItem
"""
item = PlotItem(name="data")
xlabel, ylabel = cls.get_labels(power_calibration)
item.setLabel("bottom", ylabel)
item.setLabel("left", xlabel)
item.setTitle(cls.tr("Power Density to Power Setting"))
if plot_raw:
scatterplot = ScatterPlotItem()
scatterplot.setData(power_calibration.calibration_data[:,1], power_calibration.calibration_data[:,0])
item.addItem(scatterplot)
if plot_conversion:
curve = PlotCurveItem()
N_POINTS = 200
y = np.linspace(power_calibration.calibration_data[0,1], power_calibration.calibration_data[-1,1], N_POINTS)
x = cls.get_power_setting(y, power_calibration)
curve.setData(y, x)
item.addItem(curve)
return item
[docs]
class PowerCalibrationWidget(QWidget):
"""Class managing pump power calibration.
The widget displays the contents of the power calibration :class:`settings database <.SettingsDb>`.
It allows the user to make a new power calibration.
The widget automatically loads/saves the user input from/to the :class:`app cache <.AppConfig.CACHE_CFG>`.
"""
[docs]
def __init__(self, db: SettingsDb[str, PowerCalibration], parent=None):
super().__init__(parent)
self._db = db
self._l_main = QHBoxLayout()
self.setLayout(self._l_main)
self._l_left = QVBoxLayout()
self._l_main.addLayout(self._l_left, stretch=1)
widget_input = AppConfig.CACHE_CFG.last_power_calibration_settings
if not isinstance(widget_input, NewPowerCalibration):
widget_input = NewPowerCalibration()
# New calibration
self._w_new_calib = SettingsWidget[NewPowerCalibration](widget_input)
self._b_new_calib = QPushButton(self.tr("Start power calibration"))
self._b_new_calib.pressed.connect(self._new_measurement_pressed)
box = QGroupBox()
box.setTitle(self.tr("New power calibration"))
box.setLayout(QVBoxLayout())
box.layout().addWidget(self._w_new_calib)
box.layout().addWidget(self._b_new_calib)
self._l_left.addWidget(box, stretch=0)
# Old calibrations
self._w_list = QListWidget()
self._l_left.addWidget(self._w_list, stretch=1)
self._text_no_selection = self.tr("No calibration selected.")
self._w_calibration = QLabel(self._text_no_selection)
self._l_main.addWidget(self._w_calibration, stretch=2)
self._current_row = -1
self._w_list.currentRowChanged.connect(lambda _: self._on_row_selected())
self._keys: list[str] = []
self._update_power_calibration_table()
[docs]
def save_state(self):
"""Store the input in the app cache"""
input: NewPowerCalibration = self._w_new_calib.get_value()
AppConfig.CACHE_CFG.last_power_calibration_settings = input
[docs]
def _on_row_selected(self):
"""Display the selected configuration in the middle widget and plot."""
row = self._w_list.currentRow()
log.debug(f"Selected row {row}")
if row == self._current_row:
return
self._current_row = row
old_w_calibration = self._w_calibration
if row < 0: # no selection
self._w_calibration = QLabel(self._text_no_selection)
else:
item = self._w_list.item(row)
if item is None: raise RuntimeError(f"Invalid row selected: '{row}'")
key = item.text()
if not key in self._db:
raise KeyError(f"Invalid key selected: '{key}'.")
settings = self._db[key]
w_calib = QWidget()
l = QVBoxLayout()
w_calib.setLayout(l)
w_settings = SettingsWidget(settings)
l.addWidget(w_settings)
w_plot = PowerCalibrationPlot(settings)
l.addWidget(w_plot)
w_settings.valueChanged.connect(w_plot.update_plot)
self._w_calibration = w_calib
# self._w_calibration.deleteLater()
self._l_main.replaceWidget(old_w_calibration, self._w_calibration)
old_w_calibration.deleteLater()
[docs]
def _update_power_calibration_table(self):
"""Redraw the list of calibrations from the database."""
self._w_list.clear()
self._keys = list(sorted(self._db.keys()))
log.debug(f"Power calibration keys are {self._keys}")
for i, k in enumerate(self._keys):
w = QListWidgetItem(k)
# set icon
try:
_type = self._db[k].light_source_type
icon_path = None
if _type == "led":
icon_path = get_resource_path_from("devctrl.resources", "icons/devices/LedController.svg")
elif _type == "laser":
icon_path = get_resource_path_from("devctrl.resources", "icons/devices/Laser.svg")
if icon_path:
pixmap = QPixmap(icon_path)
pixmap = pixmap.scaled(64, 64, aspectRatioMode=Qt.AspectRatioMode.KeepAspectRatio, transformMode=Qt.TransformationMode.SmoothTransformation)
icon = QIcon(pixmap)
w.setIcon(icon)
except Exception as e:
log.warning(f"Failed to set icon for {k}: {type(e)}: {e}")
self._w_list.addItem(w)
[docs]
def add_power_calibration(self, key: str, calib: PowerCalibration):
"""Update the database and list widget. Select new entry."""
log.debug(f"Adding power calibration: '{key}'")
self._db[key] = calib
self._update_power_calibration_table()
try:
index = self._keys.index(key)
except ValueError as e:
log.error(f"Failed to determine index of new calibration: {e}")
index = 0
self._w_list.setCurrentRow(index)
# self._on_row_selected()
[docs]
def _new_measurement_pressed(self):
"""Block the button, call :meth:`_new_measurement` and unblock the button.
:raise: Any exceptions from :meth:`_new_measurement`
"""
self._b_new_calib.setEnabled(False)
dialog = QProgressDialog(self)
try:
self._new_measurement(dialog)
self._b_new_calib.setEnabled(True)
except Exception as e:
self._b_new_calib.setEnabled(True)
dialog.accept()
dialog.deleteLater()
raise e
[docs]
def _new_measurement(self, dialog: QProgressDialog):
"""Perform pump power calibation.
This requires the pump power meter and either the led or laser, depending on user choice.
The calibration is done with :func:`prsctrl.measurement.light_power.measure_light_intensity` in a separate thread.
A dialog blocks the rest of the application during that time.
The calibration result is stored using a default key in the database.
"""
devmg = device_manager.get()
pm = devmg.get_device(dk.POWER_METER_PUMP, PowerMeter)
opts: NewPowerCalibration = self._w_new_calib.get_value()
if opts.pump_source == "led":
pump = devmg.get_device(dk.LED_CONTROLLER, LedController)
elif opts.pump_source == "laser":
pump = devmg.get_device(dk.LASER, Laser)
else:
raise ValueError(f"Invalid value for pump: '{opts.pump_source}'. Must be either 'laser' or 'led'.")
def _measure_light_intensity(data_queue: Queue, final_queue: Queue):
log.debug(f"Running `measure_light_intensity`")
power_calib: PowerCalibration = measure_light_intensity(
pm, pump,
wavelength_nm=opts.wavelength_nm,
max_laser_power_mW=opts.max_laser_power_mW,
max_led_current_A=opts.max_led_current_A,
n_steps=opts.n_steps,
beam_area_cm2=opts.beam_area_cm2,
data_queue=data_queue,
)
final_queue.put(power_calib)
err_queue = Queue()
data_queue = Queue()
final_queue = Queue()
thread = ThreadWithExceptionSupport(queue=err_queue, target=_measure_light_intensity, name="PowerCalibration", args=(data_queue, final_queue, ))
dialog.setMaximum(opts.n_steps)
dialog.setLabelText(self.tr("Pump calibration is running in the background - please wait."))
dialog.setMinimumDuration(1000) # show after 1 sec
b = QPushButton(self.tr("Cancel"))
b.pressed.connect(dialog.cancel)
b.setEnabled(False)
dialog.setCancelButton(b)
dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
dialog.show()
log.debug(f"Starting power calibration thread.")
thread.start()
while thread.is_alive():
# if an error occurd in the thread, show message and return
if not data_queue.empty():
i, x, unit, power_mW, scaled_power_mWcm2 = data_queue.get()
dialog.setValue(i+1)
dialog.setLabelText(f"{i+1}/{opts.n_steps}: {x:.3f} {unit} -> {scaled_power_mWcm2:.3f} mW/cm^2")
if not err_queue.empty():
ex, tb = err_queue.get()
log.error(f"Error during power calibration: {ex}\n{tb}")
QMessageBox.critical(self, "Error", f"Error during power calibration: {ex}\n{tb}")
thread.join()
return
QtCore.QCoreApplication.processEvents()
time.sleep(0.01)
thread.join()
if not err_queue.empty():
ex, tb = err_queue.get()
log.error(f"Error during power calibration: {ex}\n{tb}")
dialog.accept()
dialog.deleteLater()
QMessageBox.critical(self, "Error", f"Error during power calibration: {ex}\n{tb}")
# show success message
dialog.setValue(opts.n_steps)
dialog.setLabelText(self.tr("Calibration completed."))
# b.setEnabled(True)
if final_queue.empty():
raise RuntimeError(f"Failed to retrive new power calibration.")
calib = final_queue.get()
key = get_date_for_filename() + f"_{opts.pump_source}_wl={opts.wavelength_nm}nm"
self.add_power_calibration(key, calib)