Source code for prsctrl.measurement.settings

"""
Measurement settings for photoreflectance implemented as classes.
This new structure is "more strongly typed" than the old dictionary approach, and makes for less magic values (settings keys in the code).
It is also easier to apply the settings using the `apply(device)` method.
Every setting will also always be present and have a default value when not set explcitly.

Loading and saving from a file is also easier.

You can use the `dataclasses.asdict(<settings class>)` to convert a settings object to a dictionary.
"""
from devctrl import devices as d
from devctrl.data.spectrum.functions import nm_to_eV, eV_to_nm
from devctrl.utility.settings import SettingsClass

from dataclasses import dataclass, field
from typing import Any, Optional, Callable


import numpy as np

import logging
log = logging.getLogger(__name__)


[docs] def convert_range_to_list(wl_range: tuple | list | np.ndarray) -> list: """ :param wl_range: if tuple, return list(range(*wl_range)) if list or np.ndarray, return copy of wl_range """ if isinstance(wl_range, tuple): wavelengths = np.arange(*wl_range).tolist() elif isinstance(wl_range, list): wavelengths = wl_range.copy() elif isinstance(wl_range, np.ndarray): wavelengths = wl_range.tolist() else: raise ValueError(f"Invalid type for : {type(wl_range)}") return wavelengths
[docs] @dataclass class MeasurementSettings(SettingsClass): measurement_time_s: float = field(default=30, metadata={"doc": "Time in seconds where data is recorded at each wavelength.", "valid": (0, 9999999)}) wait_time_s: Optional[float] = field(default=None, metadata={"doc": "Time in seconds to wait for the signal to stabilize after changing the wavelength.\nSet to `0` for no wait time, and to `None` to automatically choose a value based on the lock-in time constant.", "valid": (0, 999999)}) additional_wait_time_s: float = field(default=0, metadata={"doc": "Time in seconds to wait for the signal to stabilize after changing the wavelength.\nSet to `0` for no wait time, and to `None` to automatically choose a value based on the lock-in time constant.", "valid": (0, 999999)}) pump_source: str = field(default="none", metadata={"doc": "String describing which pump source is used.\nIf 'led', then the values from `PrsSettings.led` are applied. If 'laser', then the values from `PrsSettings.laser` are applied.", "valid": ["laser", "led", "laser-manual", "led-manual", "none", "manual"]}) auto_gain: bool = field(default=False, metadata={"doc": "Whether to automatically adjust the gain during the measurement to stay in the optimum DC region. Not current implemented yet."}) auto_sensitivity: bool = field(default=True, metadata={"doc": "Whether to automatically increase the lock-in sensitivity on overloads."}) wavelengths_nm: Optional[tuple|list] = field(default=None, metadata={"doc": "Wavelength range in nm. Must be either a list of values or a tuple (start, stop, step). Only one of `wavelengths_nm` or `energies_eV` may be set."}) energies_eV: Optional[tuple|list] = field(default=None, metadata={"doc": "Energy range in eV. Must be either a list of values or a tuple (start, stop, step). Only one of `wavelengths_nm` or `energies_eV` may be set."}) pump_power_mWcm2: Optional[float] = field(default=None, metadata={"doc": "Power in mW/cm^2. If `pump_source` is set and a conversion table exists for the source then the `power_mW` setting for lasers or the `current_A` setting for LEDs can be overwritten with the value corresponding to the `power_mWcm2` setting."})
[docs] def get_wavelengths_nm(self, round_to_digits: Optional[int]=2, update_field: bool=True) -> list[float]: """ Generate a list of wavelengths according to either the 'wavelengths_nm' or 'energies_eV' field. Either field may already be a list of values, or a tuple (start, stop, step size). :param round_to_digits: Optional number of digits to round the values to. :param update_field: If True, overwrite the value of 'wavelengths_nm' with the returned array and 'energies_eV' with None. :return: list of wavelengths in nm """ if self.wavelengths_nm is not None and self.energies_eV is not None: raise KeyError(f"Only one of 'wavelengths_nm' or 'energies_eV' must be set, but both are.") if self.wavelengths_nm is not None: wavelengths = convert_range_to_list(self.wavelengths_nm) elif self.energies_eV is not None: wavelengths = [(v if type(v) == str else eV_to_nm(v)) for v in convert_range_to_list(self.energies_eV)] else: raise KeyError(f"One of 'wavelengths_nm' or 'energies_eV' must be set, but both are None") if round_to_digits is not None: wavelengths = [(v if type(v) == str else round(v, round_to_digits)) for v in wavelengths] if update_field: self.wavelengths_nm = wavelengths.copy() self.energies_eV = None return wavelengths
[docs] @dataclass class AmplifierSettings(SettingsClass): coupling: str = "DC" gain: float = 5 mode: Optional[str] = None def apply(self, device: d.Amplifier): device.set_coupling(self.coupling) if self.mode is not None: if not hasattr(device, "set_mode"): raise RuntimeError(f"Can not apply amplifier mode, since amplifier of type {device.get_device_name()} has no attribute 'set_mode'") device.set_mode(self.mode) device.set_gain(self.gain)
[docs] @dataclass class LaserSettings(SettingsClass): power_mW: float = 1.0 mode: str = "TTL" def apply(self, device: d.Laser): device.set_mode(self.mode) device.set_power_mW(self.power_mW)
[docs] @dataclass class LedSettings(SettingsClass): mode: str = "TTL" current_A: float = 0.1 def apply(self, device: d.LedController): device.set_mode(self.mode) device.set_current_A(self.current_A)
[docs] @dataclass class LockInSettings(SettingsClass): time_constant_s: float = 1 sensitivity_volt: float = 50e-6 filter_slope: float = 12 sync_filter: float = 1 reserve: str = "Normal" reference: str = "External" """ The source of the reference frequency """ reference_trigger: str = "Rising Edge" frequency_Hz: Optional[float] = None sample_rate_Hz: float = 128 aux_DC: str = "Aux In 1" """ The name of the auxiliary port that is used to measure the DC intensity """
[docs] def apply(self, device: d.LockInAmp): """ Set - time constant - sensitivity - filter slope - sync filter - reserve - reference source - reference trigger setting - frequency """ device.set_time_constant_s(self.time_constant_s) device.set_sensitivity_volt(self.sensitivity_volt) device.set_filter_slope(self.filter_slope) device.set_sync_filter(self.sync_filter) if hasattr(device, "set_reserve"): device.set_reserve(self.reserve) device.set_reference(self.reference) device.set_reference_trigger(self.reference_trigger) if self.frequency_Hz is not None: device.set_frequency_Hz(self.frequency_Hz)
[docs] @dataclass class MonochromatorSettings(SettingsClass): """ Monochromator settings, to apply a bandwidth/resolution """ resolution_nm: float = 1.0 """ Resolution in nm. Will be applied if `resolution_eV` < 0. """ resolution_eV: float = -1 """ Resolution in eV. Will be applied if > 0. """ def apply(self, device: d.Monochromator): if self.resolution_eV > 0: device.set_resolution_eV(self.resolution_eV) else: device.set_resolution_nm(self.resolution_nm)
[docs] @dataclass class ModeSettings(SettingsClass): pre_amplifier: Optional[AmplifierSettings] = None lock_in: LockInSettings = field(default_factory=LockInSettings)
[docs] @dataclass class PrsSettings(SettingsClass): """ All settings for a photoreflectance or phototransmittance measurement. """ modes: dict[str, ModeSettings] = field(default_factory=dict) """Mode key (ref/tra): mode settings""" general: MeasurementSettings = field(default_factory=MeasurementSettings) laser: Optional[LaserSettings] = None led: Optional[LedSettings] = None monochromators: list[MonochromatorSettings] = field(default_factory=list) """ A list of monochromator setting settings is supported to allow for dual monochromator setups. """
[docs] def prepare(self, power_density_to_laser_power_mW: Optional[Callable[[float], float]]=None, power_density_to_led_current_A: Optional[Callable[[float], float]]=None, ): """ Apply pump power density setting. :param power_density_to_laser_power_mW: Function to convert pump power density to laser power density. :param power_density_to_led_current_A: Function to convert pump power density to led current density. """ if "laser" in self.general.pump_source: if self.laser is None: if power_density_to_laser_power_mW is None: log.warning(f"Pump source contains 'laser', but no laser settings and no pump power conversion function is given") elif self.general.pump_power_mWcm2 is not None: power_mW = power_density_to_laser_power_mW(self.general.pump_power_mWcm2) self.laser = LaserSettings(power_mW=power_mW) else: log.warning(f"Pump source contains 'laser', but no laser settings and no pump power density is given") if "led" in self.general.pump_source: if self.led is None: if power_density_to_led_current_A is None: log.warning(f"Pump source contains 'led', but no led settings and no pump power conversion function is given") elif self.general.pump_power_mWcm2 is not None: current_A = power_density_to_led_current_A(self.general.pump_power_mWcm2) self.led = LedSettings(current_A=current_A) else: log.warning(f"Pump source contains 'led', but no led settings and no pump power density is given")
if __name__ == "__main__": # Demonstrate how settings can be merged settings = PrsSettings(modes={"ref": ModeSettings(pre_amplfier=AmplifierSettings())}) print(settings) loaded_from_file = {"general": {"measurement_time_s": 42}, "modes": {"tra": {"lock_in": {"time_constant_s": 5}}}} settings |= loaded_from_file print(settings)