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)