diff --git a/doc/news/DM-43628.feature.rst b/doc/news/DM-43628.feature.rst new file mode 100644 index 00000000..65a1469e --- /dev/null +++ b/doc/news/DM-43628.feature.rst @@ -0,0 +1 @@ +Adds initial implementation of MTCalsys. diff --git a/python/lsst/ts/observatory/control/auxtel/atcalsys.py b/python/lsst/ts/observatory/control/auxtel/atcalsys.py index a1faa11e..ec1df77d 100644 --- a/python/lsst/ts/observatory/control/auxtel/atcalsys.py +++ b/python/lsst/ts/observatory/control/auxtel/atcalsys.py @@ -156,7 +156,9 @@ async def change_wavelength(self, wavelength: float) -> None: ) async def is_ready_for_flats(self) -> bool: - """Add doctring""" + """Designates if the calibraiton hardware is in a state + to take flats. + """ # TODO (DM-44310): Implement method to check that the # system is ready for flats. return True @@ -170,12 +172,12 @@ async def setup_calsys(self, sequence_name: str) -> None: await self.switch_lamp_on() await self.wait_for_lamp_to_warm_up() - async def prepare_for_flat(self, config_name: str) -> None: + async def prepare_for_flat(self, sequence_name: str) -> None: """Configure the ATMonochromator according to the flat parameters Parameters ---------- - config_name : `str` + sequence_name : `str` name of the type of configuration you will run, which is saved in the configuration.yaml files @@ -184,7 +186,7 @@ async def prepare_for_flat(self, config_name: str) -> None: RuntimeError: """ - config_data = self.get_calibration_configuration(config_name) + config_data = self.get_calibration_configuration(sequence_name) wavelength = ( float(config_data["wavelength"]) @@ -204,7 +206,7 @@ async def prepare_for_flat(self, config_name: str) -> None: if self.latiss is None and config_data["use_camera"]: raise RuntimeError( - f"LATISS is not defined but {config_name} requires it. " + f"LATISS is not defined but {sequence_name} requires it. " "Make sure you are instantiating LATISS and passing it to ATCalsys." ) task_setup_latiss = ( diff --git a/python/lsst/ts/observatory/control/base_calsys.py b/python/lsst/ts/observatory/control/base_calsys.py index f22a5127..aba267d8 100644 --- a/python/lsst/ts/observatory/control/base_calsys.py +++ b/python/lsst/ts/observatory/control/base_calsys.py @@ -128,7 +128,7 @@ async def setup_calsys(self, sequence_name: str) -> None: raise NotImplementedError() @abc.abstractmethod - async def prepare_for_flat(self, config_name: str) -> None: + async def prepare_for_flat(self, sequence_name: str) -> None: """Configure calibration system to be ready to take a flat Parameters @@ -159,6 +159,30 @@ async def run_calibration_sequence( """ raise NotImplementedError() + @abc.abstractmethod + async def calculate_optimized_exposure_times( + self, wavelengths: list, config_data: dict + ) -> list: + """Calculates the exposure times for the electrometer and + fiber spectrograph given the type and wavelength of the exposure + and the length of the camera exposure time + + Parameters + ---------- + wavelengths : `list` + List of all wavelengths for this exposure list + config_data : `dict` + All information from configuration file + + Returns + ------- + exposure_list : `list`[ATCalsysExposure|MTCalsysExposure] + List of exposure information, includes wavelength + and camera, fiberspectrograph and electrometer exposure times. + """ + # TO-DO: DM-44777 + raise NotImplementedError() + def load_calibration_config_file(self, filename: str | None = None) -> None: """Load the calibration configuration file. diff --git a/python/lsst/ts/observatory/control/data/mtcalsys.yaml b/python/lsst/ts/observatory/control/data/mtcalsys.yaml new file mode 100644 index 00000000..d7de56c2 --- /dev/null +++ b/python/lsst/ts/observatory/control/data/mtcalsys.yaml @@ -0,0 +1,132 @@ +# Configuration for calibration hardware setup +# Created 2024-07-15 + +whitelight_u: + calib_type: WhiteLight + use_camera: true + mtcamera_filter: u + led_name: + - M385L3 + wavelength: 385.0 + led_location: 210.21 + led_focus: 50.68 + use_electrometer: true + use_fiberspectrograph_red: true + use_fiberspectrograph_blue: true + electrometer_integration_time: 0.1 + electrometer_mode: CURRENT + electrometer_range: -1 + exposure_times: + - 15.0 + +whitelight_g: + calib_type: WhiteLight + use_camera: true + mtcamera_filter: g + led_name: + - M455L4 + - M505L4 + wavelength: 480.0 + led_location: 9.15 + led_focus: 17.029 + use_electrometer: true + use_fiberspectrograph_red: true + use_fiberspectrograph_blue: true + electrometer_integration_time: 0.1 + electrometer_mode: CURRENT + electrometer_range: -1 + exposure_times: + - 15.0 + +whitelight_r: + calib_type: WhiteLight + use_camera: true + mtcamera_filter: r + led_name: + - M565L3 + - M660L4 + wavelength: 612.5 + led_location: 70.40 + led_focus: 16.279 + use_electrometer: true + use_fiberspectrograph_red: true + use_fiberspectrograph_blue: true + electrometer_integration_time: 0.1 + electrometer_mode: CURRENT + electrometer_range: -1 + exposure_times: + - 15.0 + +whitelight_i: + calib_type: WhiteLight + use_camera: true + mtcamera_filter: i + led_name: + - M730L5 + - M780LP1 + wavelength: 755.0 + led_location: 237.36 + led_focus: 15.829 + use_electrometer: true + use_fiberspectrograph_red: true + use_fiberspectrograph_blue: true + electrometer_integration_time: 0.1 + electrometer_mode: CURRENT + electrometer_range: -1 + exposure_times: + - 15.0 + +whitelight_z: + calib_type: WhiteLight + use_camera: true + mtcamera_filter: z + led_name: + - M850L3 + - M940L3 + wavelength: 895.0 + led_location: 299.034 + led_focus: 15.505 + use_electrometer: true + use_fiberspectrograph_red: true + use_fiberspectrograph_blue: true + electrometer_integration_time: 0.1 + electrometer_mode: CURRENT + electrometer_range: -1 + exposure_times: + - 15.0 + +whitelight_y: + calib_type: WhiteLight + use_camera: true + mtcamera_filter: y + led_name: + - M970L4 + wavelength: 970.0 + led_location: 174.91 + led_focus: 15.380 + use_electrometer: true + use_fiberspectrograph_red: true + use_fiberspectrograph_blue: true + electrometer_integration_time: 0.1 + electrometer_mode: CURRENT + electrometer_range: -1 + exposure_times: + - 15.0 + +scan_r: + calib_type: Mono + use_camera: true + mtcamera_filter: r + led_location: 174.91 + led_focus: 15.380 + wavelength: 625.0 + wavelength_width: 250 + wavelength_resolution: 5.0 + use_electrometer: true + use_fiberspectrograph_red: true + use_fiberspectrograph_blue: true + electrometer_integration_time: 0.1 + electrometer_mode: CURRENT + electrometer_range: -1 + exposure_times: + - 15.0 diff --git a/python/lsst/ts/observatory/control/maintel/comcam.py b/python/lsst/ts/observatory/control/maintel/comcam.py index dc260b85..eb95455c 100644 --- a/python/lsst/ts/observatory/control/maintel/comcam.py +++ b/python/lsst/ts/observatory/control/maintel/comcam.py @@ -46,6 +46,7 @@ class ComCamUsages(Usages): TakeImage = 1 << 3 TakeImageFull = 1 << 4 + DryTest = 1 << 6 def __iter__(self) -> typing.Iterator[int]: return iter( @@ -56,6 +57,7 @@ def __iter__(self) -> typing.Iterator[int]: self.MonitorHeartBeat, self.TakeImage, self.TakeImageFull, + self.DryTest, ] ) diff --git a/python/lsst/ts/observatory/control/maintel/mtcalsys.py b/python/lsst/ts/observatory/control/maintel/mtcalsys.py new file mode 100644 index 00000000..5fda92c6 --- /dev/null +++ b/python/lsst/ts/observatory/control/maintel/mtcalsys.py @@ -0,0 +1,763 @@ +# This file is part of ts_observatory_control. +# +# Developed for the Vera Rubin Observatory Telescope and Site Systems. +# This product includes software developed by the LSST Project +# (https://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License + +__all__ = ["MTCalsys", "MTCalsysUsages"] + +import asyncio +import logging +import typing +from dataclasses import dataclass + +import numpy as np +from lsst.ts import salobj, utils +from lsst.ts.xml.enums.TunableLaser import LaserDetailedState + +from ..base_calsys import BaseCalsys +from ..remote_group import Usages +from ..utils import CalibrationType +from . import ComCam + + +class MTCalsysUsages(Usages): + """MTCalsys usages definition. + + Notes + ----- + + Additional usages definition: + + * Setup: Turn on TunableLaser and adjust the projector output with + linear stage select. Enable other CSCs: LEDProjector, Linear + Stages, Electrometers and FiberSpectrographs. + * Configure: Adjust LEDProjector or TunableLaser depending on filter. + * TakeFlat: Take flatfield image with MTCamera (ComCam or LSSTCam), + Electrometer and FiberSpec + * DryTest: Disable CSCs and unit tests + """ + + Setup = 1 << 3 + Configure = 1 << 4 + TakeFlat = 1 << 5 + + def __iter__(self) -> typing.Iterator[int]: + return iter( + [ + self.All, + self.StateTransition, + self.MonitorState, + self.MonitorHeartBeat, + self.Setup, + self.Configure, + self.TakeFlat, + self.DryTest, + ] + ) + + +@dataclass +class MTCalsysExposure: + """Store Exposure information for MTCalsys. + + Attributes + ---------- + wavelength : float + Wavelength of the calibration exposure, in nm. + camera : float + Camera exposure time, in sec. + fiberspectrograph : float | None + Fiber spectrograph exposure time, in sec. + If None, skip fiber spectrograph acquisition. + electrometer : float | None + Electrometer exposure time, in sec. + If None, skip electrometer acquisition. + """ + + wavelength: float + camera: float + fiberspectrograph_red: float | None + fiberspectrograph_blue: float | None + electrometer: float | None + + +class MTCalsys(BaseCalsys): + """LSST Simonyi Telescope Calibration System. + + MTCalsys encapsulates core functionality from the following CSCs: + TunableLaser, LEDProjector, LinearStage, Fiberspectrograph, Electrometer. + + Parameters + ---------- + domain : `lsst.ts.salobj.Domain` + Domain for remotes. If `None` create a domain. + log : `logging.Logger` + Optional logging class to be used for logging operations. If `None`, + creates a new logger. Useful to use in salobj.BaseScript and allow + logging in the class use the script logging. + intended_usage: `int` + Optional integer that maps to a list of intended operations. This is + used to limit the resources allocated by the class by gathering some + knowledge about the usage intention. By default allocates all + resources. + mtcamera: ComCam or LSSTCam + """ + + def __init__( + self, + domain: typing.Optional[salobj.Domain] = None, + log: typing.Optional[logging.Logger] = None, + intended_usage: typing.Optional[int] = None, + mtcamera: typing.Optional[ComCam] = None, + ) -> None: + + self.electrometer_projector_index = 103 + self.fiberspectrograph_blue_index = 1 + self.fiberspectrograph_red_index = 2 + self.linearstage_led_select_index = 1 + self.linearstage_led_focus_index = 2 + self.linearstage_laser_focus_index = 3 + self.linearstage_select_index = 4 + + super().__init__( + components=[ + "TunableLaser", + "LEDProjector", + f"FiberSpectrograph:{self.fiberspectrograph_blue_index}", + f"FiberSpectrograph:{self.fiberspectrograph_red_index}", + f"Electrometer:{self.electrometer_projector_index}", + f"LinearStage:{self.linearstage_led_select_index}", + f"LinearStage:{self.linearstage_led_focus_index}", + f"LinearStage:{self.linearstage_laser_focus_index}", + f"LinearStage:{self.linearstage_select_index}", + ], + domain=domain, + log=log, + intended_usage=intended_usage, + ) + + self.mtcamera = mtcamera + self.ls_select_led_location = 9.96 # mm + self.ls_select_laser_location = 79.96 # mm + self.led_rest_position = 120.0 # mm + + self.laser_enclosure_temp = 20.0 # C + + self.exptime_dict: dict[str, float] = dict( + camera=0.0, + electrometer=0.0, + fiberspectrograph=0.0, + ) + + def calculate_laser_focus_location(self, wavelength: float) -> float: + """Calculates the location of the linear stage that provides the focus + for the laser projector. This location is dependent on the + wavelength of the laser. + + Parameters + ---------- + wavelength : `float` + wavelength of the laser projector in nm + + Returns + ------ + location of the linear stage for the laser projector focus in mm + + """ + # TODO (DM-44772): implement the actual function + return 10.0 + + async def change_laser_wavelength(self, wavelength: float) -> None: + """Change the TunableLaser wavelength setting + + Parameters + ---------- + wavelength : `float` + wavelength of the laser in nm + """ + + task_wavelength = self.rem.tunablelaser.cmd_changeWavelength.set_start( + wavelength=wavelength, timeout=self.long_long_timeout + ) + task_focus = self.linearstage_laser_focus.cmd_moveAbsolute.set_start( + distance=self.calculate_laser_focus_location(wavelength), + timeout=self.long_long_timeout, + ) + + await asyncio.gather(task_wavelength, task_focus) + + async def is_ready_for_flats(self) -> bool: + """Designates if the calibraiton hardware is in a state + to take flats. + """ + # TODO (DM-44310): Implement method to check that the + # system is ready for flats. + return True + + async def setup_calsys(self, sequence_name: str) -> None: + """Setup the calibration system. + + If monochromatic flats, check that laser can be enabled, + check temperature, and turn on laser to warm up. + Move linearstage_select to correct location for + Mono or Whitelight flats. If Monochromatic flats + make sure that LED stage is in the correct place. + + Parameters + ---------- + sequence_name : `str` + name of the type of configuration you will run, which is saved + in the configuration.yaml files + + """ + config_data = self.get_calibration_configuration(sequence_name) + + calibration_type = getattr(CalibrationType, str(config_data["calib_type"])) + + if calibration_type == CalibrationType.WhiteLight: + await self.linearstage_projector_select.cmd_moveAbsolute.set_start( + distance=self.ls_select_led_location, timeout=self.long_timeout + ) + else: + await self.linearstage_led_select.cmd_moveAbsolute.set_start( + distance=self.led_rest_position, timeout=self.long_timeout + ) + await self.linearstage_projector_select.cmd_moveAbsolute.set_start( + distance=self.ls_select_laser_location, timeout=self.long_timeout + ) + await self.setup_laser(config_data["laser_mode"]) + await self.rem.tunablelaser.cmd_startPropagating.start( + timeout=self.long_long_timeout + ) + + async def setup_laser(self, mode: LaserDetailedState) -> None: + """Perform all steps for preparing the laser for monochromatic flats. + This includes confirming that the thermal system is + turned on and set at the right temperature. It also checks + the interlockState to confirm it's ready to propagate. + + Parameters + ---------- + mode : LaserDetailedState + Mode of the TunableLaser + Options: CONTINUOUS, BURST, TRIGGER + + """ + # TO-DO: DM-45693 implement thermal system checks + + if mode in { + LaserDetailedState.NONPROPAGATING_CONTINUOUS_MODE, + LaserDetailedState.PROPAGATING_CONTINUOUS_MODE, + }: + await self.rem.tunablelaser.cmd_setContinuousMode.start( + timeout=self.long_timeout + ) + elif mode in { + LaserDetailedState.NONPROPAGATING_BURST_MODE, + LaserDetailedState.PROPAGATING_BURST_MODE, + }: + await self.rem.tunablelaser.cmd_setBurstMode.start( + timeout=self.long_timeout + ) + else: + raise RuntimeError( + f"{mode} not an acceptable LaserDetailedState [CONTINOUS, BURST, TRIGGER]" + ) + + async def prepare_for_flat(self, sequence_name: str) -> None: + """Configure the ATMonochromator according to the flat parameters + + Parameters + ---------- + sequence_name : `str` + name of the type of configuration you will run, which is saved + in the configuration.yaml files + + Raises + ------ + RuntimeError: + + """ + config_data = self.get_calibration_configuration(sequence_name) + + calibration_type = getattr(CalibrationType, str(config_data["calib_type"])) + + task_setup_camera = ( + self.mtcamera.setup_instrument( + filter=config_data["mtcamera_filter"], + ) + if self.mtcamera is not None and config_data["use_camera"] + else utils.make_done_future() + ) + if self.mtcamera is None and config_data["use_camera"]: + raise RuntimeError( + f"MTCamera is not defined but {sequence_name} requires it. " + "Make sure you are instantiating ComCam and passing it to MTCalsys." + ) + + if calibration_type == CalibrationType.WhiteLight: + task_select_led = self.linearstage_led_select.cmd_moveAbsolute.set_start( + distance=config_data.get("led_location"), timeout=self.long_timeout + ) + task_adjust_led_focus = ( + self.linearstage_led_focus.cmd_moveAbsolute.set_start( + distance=config_data.get("led_focus"), timeout=self.long_timeout + ) + ) + task_turn_led_on = self.rem.ledprojector.cmd_switchOn.set_start( + serialNumbers=config_data.get("led_name"), + timeout=self.long_timeout, + ) + + await asyncio.gather( + task_select_led, + task_adjust_led_focus, + task_turn_led_on, + task_setup_camera, + ) + + elif calibration_type == CalibrationType.Mono: + wavelengths = [400.0] # function of filter_name + task_select_wavelength = self.change_laser_wavelength( + wavelength=wavelengths[0] + ) + + await asyncio.gather(task_select_wavelength, task_setup_camera) + + async def run_calibration_sequence( + self, sequence_name: str, exposure_metadata: dict + ) -> dict: + """Perform full calibration sequence, taking flats with the + camera and all ancillary instruments. + + Parameters + ---------- + sequence_name : `str` + Name of the calibration sequence to execute. + + Returns + ------- + calibration_summary : `dict` + Dictionary with summary information about the sequence. + """ + calibration_summary: dict = dict( + steps=[], + sequence_name=sequence_name, + ) + + config_data = self.get_calibration_configuration(sequence_name) + + calibration_type = getattr(CalibrationType, str(config_data["calib_type"])) + if calibration_type == CalibrationType.WhiteLight: + calibration_wavelengths = np.array([float(config_data["wavelength"])]) + else: + wavelength = float(config_data["wavelength"]) + wavelength_width = float(config_data["wavelength_width"]) + wavelength_resolution = float(config_data["wavelength_resolution"]) + wavelength_start = wavelength - wavelength_width / 2.0 + wavelength_end = wavelength + wavelength_width / 2.0 + + calibration_wavelengths = np.arange( + wavelength_start, wavelength_end, wavelength_resolution + ) + + exposure_table = await self.calculate_optimized_exposure_times( + wavelengths=calibration_wavelengths, config_data=config_data + ) + + for exposure in exposure_table: + self.log.debug( + f"Performing {calibration_type.name} calibration with {exposure.wavelength=}." + ) + await self.change_laser_wavelength(wavelength=exposure.wavelength) + mtcamera_exposure_info: dict = dict() + + for exptime in config_data["exposure_times"]: + self.log.debug("Taking data sequence.") + exposure_info = await self._take_data( + mtcamera_exptime=exposure.camera, + mtcamera_filter=str(config_data["mtcamera_filter"]), + exposure_metadata=exposure_metadata, + fiber_spectrum_red_exposure_time=exposure.fiberspectrograph_red, + fiber_spectrum_blue_exposure_time=exposure.fiberspectrograph_blue, + electrometer_exposure_time=exposure.electrometer, + ) + mtcamera_exposure_info.update(exposure_info) + + if calibration_type == CalibrationType.Mono: + self.log.debug( + "Taking data sequence without filter for monochromatic set." + ) + exposure_info = await self._take_data( + mtcamera_exptime=exposure.camera, + mtcamera_filter="empty_1", + exposure_metadata=exposure_metadata, + fiber_spectrum_red_exposure_time=exposure.fiberspectrograph_red, + fiber_spectrum_blue_exposure_time=exposure.fiberspectrograph_blue, + electrometer_exposure_time=exposure.electrometer, + ) + mtcamera_exposure_info.update(exposure_info) + + step = dict( + wavelength=exposure.wavelength, + mtcamera_exposure_info=mtcamera_exposure_info, + ) + + calibration_summary["steps"].append(step) + return calibration_summary + + async def calculate_optimized_exposure_times( + self, wavelengths: list, config_data: dict + ) -> list[MTCalsysExposure]: + """Calculates the exposure times for the electrometer and + fiber spectrograph given the type and wavelength of the exposure + and the length of the camera exposure time + + Parameters + ---------- + wavelengths : `list` + List of all wavelengths for this exposure list + config_data : `dict` + All information from configuration file + + Returns + ------- + exposure_list : `list`[ATCalsysExposure|MTCalsysExposure] + List of exposure information, includes wavelength + and camera, fiberspectrograph and electrometer exposure times. + """ + exposures: list[MTCalsysExposure] = [] + for wavelength in wavelengths: + electrometer_exptimes = await self._calculate_electrometer_exposure_times( + exptimes=config_data["exposure_times"], + electrometer_integration_time=config_data[ + "electrometer_integration_time" + ], + use_electrometer=config_data["use_electrometer"], + ) + fiberspectrograph_exptimes_red = ( + await self._calculate_fiberspectrograph_exposure_times( + exptimes=config_data["exposure_times"], + use_fiberspectrograph=config_data["use_fiberspectrograph_red"], + ) + ) + fiberspectrograph_exptimes_blue = ( + await self._calculate_fiberspectrograph_exposure_times( + exptimes=config_data["exposure_times"], + use_fiberspectrograph=config_data["use_fiberspectrograph_blue"], + ) + ) + + for i, exptime in enumerate(config_data["exposure_times"]): + exposures.append( + MTCalsysExposure( + wavelength=wavelength, + camera=exptime, + electrometer=electrometer_exptimes[i], + fiberspectrograph_red=fiberspectrograph_exptimes_red[i], + fiberspectrograph_blue=fiberspectrograph_exptimes_blue[i], + ) + ) + + return exposures + + async def _calculate_electrometer_exposure_times( + self, + exptimes: list, + electrometer_integration_time: float, + use_electrometer: bool, + ) -> list[float | None]: + """Calculates the optimal exposure time for the electrometer + + Parameters + ---------- + exptime : `list` + List of Camera exposure times + use_electrometer : `bool` + Identifies if the electrometer will be used in the exposure + + Returns + ------- + `list`[`float` | `None`] + Exposure times for the electrometer + """ + # TODO (DM-44777): Update optimized exposure times + electrometer_buffer_size = 16667 + electrometer_integration_overhead = 0.00254 + electrometer_time_separation_vs_integration = 3.07 + + electrometer_exptimes: list[float | None] = [] + for exptime in exptimes: + if use_electrometer: + time_sep = ( + electrometer_integration_time + * electrometer_time_separation_vs_integration + ) + electrometer_integration_overhead + max_exp_time = electrometer_buffer_size * time_sep + if exptime > max_exp_time: + electrometer_exptimes.append(max_exp_time) + self.log.info( + f"Electrometer exposure time reduced to {max_exp_time}" + ) + else: + electrometer_exptimes.append(exptime) + else: + electrometer_exptimes.append(None) + return electrometer_exptimes + + async def _calculate_fiberspectrograph_exposure_times( + self, + exptimes: list, + use_fiberspectrograph: bool, + ) -> list[float | None]: + """Calculates the optimal exposure time for the electrometer + + Parameters + ---------- + exptime : `list` + List of Camera exposure times + entrance_slit : `float` + exit_slit : `float` + use_fiberspectrograph : `bool` + Identifies if the fiberspectrograph will be used in the exposure + + Returns + ------- + `list`[`float` | `None`] + Exposure times for the fiberspectrograph + """ + # TODO (DM-44777): Update optimized exposure times + fiberspectrograph_exptimes: list[float | None] = [] + for exptime in exptimes: + if use_fiberspectrograph: + base_exptime = 1 # sec + fiberspectrograph_exptimes.append(base_exptime) + else: + fiberspectrograph_exptimes.append(None) + return fiberspectrograph_exptimes + + async def _take_data( + self, + mtcamera_exptime: float, + mtcamera_filter: str, + exposure_metadata: dict, + fiber_spectrum_red_exposure_time: float | None, + fiber_spectrum_blue_exposure_time: float | None, + electrometer_exposure_time: float | None, + ) -> dict: + + assert self.mtcamera is not None + + mtcamera_exposure_task = self.mtcamera.take_flats( + mtcamera_exptime, + nflats=1, + filter=mtcamera_filter, + **exposure_metadata, + ) + exposures_done: asyncio.Future = asyncio.Future() + + fiber_spectrum_red_exposure_coroutine = self.take_fiber_spectrum( + fiberspectrograph_color="red", + exposure_time=fiber_spectrum_red_exposure_time, + exposures_done=exposures_done, + ) + fiber_spectrum_blue_exposure_coroutine = self.take_fiber_spectrum( + fiberspectrograph_color="blue", + exposure_time=fiber_spectrum_blue_exposure_time, + exposures_done=exposures_done, + ) + electrometer_exposure_coroutine = self.take_electrometer_scan( + exposure_time=electrometer_exposure_time, + exposures_done=exposures_done, + ) + try: + fiber_spectrum_red_exposure_task = asyncio.create_task( + fiber_spectrum_red_exposure_coroutine + ) + fiber_spectrum_blue_exposure_task = asyncio.create_task( + fiber_spectrum_blue_exposure_coroutine + ) + electrometer_exposure_task = asyncio.create_task( + electrometer_exposure_coroutine + ) + + mtcamera_exposure_id = await mtcamera_exposure_task + finally: + exposures_done.set_result(True) + ( + fiber_spectrum_red_exposure_result, + fiber_spectrum_blue_exposure_result, + electrometer_exposure_result, + ) = await asyncio.gather( + fiber_spectrum_red_exposure_task, + fiber_spectrum_blue_exposure_task, + electrometer_exposure_task, + ) + + return { + mtcamera_exposure_id[0]: dict( + fiber_spectrum_red_exposure_result=fiber_spectrum_red_exposure_result, + fiber_spectrum_blue_exposure_result=fiber_spectrum_blue_exposure_result, + electrometer_exposure_result=electrometer_exposure_result, + ) + } + + async def take_electrometer_scan( + self, + exposure_time: float | None, + exposures_done: asyncio.Future, + ) -> list[str]: + """Perform an electrometer scan for the specified duration. + + Parameters + ---------- + exposure_time : `float` + Exposure time for the fiber spectrum (seconds). + exposures_done : `asyncio.Future` + A future indicating when the camera exposures where complete. + + Returns + ------- + electrometer_exposures : `list`[`str`] + List of large file urls. + """ + + self.electrometer.evt_largeFileObjectAvailable.flush() + + electrometer_exposures = list() + + if exposure_time is not None: + + try: + await self.electrometer.cmd_startScanDt.set_start( + scanDuration=exposure_time, + timeout=exposure_time + self.long_timeout, + ) + except salobj.AckTimeoutError: + self.log.exception("Timed out waiting for the command ack. Continuing.") + + # Make sure that a new lfo was created + try: + lfo = await self.electrometer.evt_largeFileObjectAvailable.next( + timeout=self.long_timeout, flush=False + ) + electrometer_exposures.append(lfo.url) + except asyncio.TimeoutError: + # TODO (DM-44634): Remove this work around to electrometer + # going to FAULT when issue is resolved. + self.log.warning( + "Time out waiting for electrometer data. Making sure electrometer " + "is in enabled state and continuing." + ) + await salobj.set_summary_state(self.electrometer, salobj.State.ENABLED) + return electrometer_exposures + + async def take_fiber_spectrum( + self, + fiberspectrograph_color: str, + exposure_time: float | None, + exposures_done: asyncio.Future, + ) -> list[str]: + """Take exposures with the fiber spectrograph until + the exposures with the camera are complete. + + This method will continue to take data with the fiber + spectrograph until the exposures_done future is done. + + Parameters + ---------- + exposure_time : `float` + Exposure time for the fiber spectrum (seconds). + exposures_done : `asyncio.Future` + A future indicating when the camera exposures where complete. + + Returns + ------- + fiber_spectrum_exposures : `list`[`str`] + List of large file urls. + """ + if fiberspectrograph_color == "blue": + fiberspec = self.fiberspectrograph_blue + elif fiberspectrograph_color == "red": + fiberspec = self.fiberspectrograph_red + + fiberspec.evt_largeFileObjectAvailable.flush() + + fiber_spectrum_exposures = [] + + if exposure_time is not None: + + try: + await fiberspec.cmd_expose.set_start( + duration=exposure_time, + numExposures=1, + timeout=exposure_time + self.long_timeout, + ) + + except salobj.AckTimeoutError: + self.log.exception("Timed out waiting for the command ack. Continuing.") + + lfo = await fiberspec.evt_largeFileObjectAvailable.next( + timeout=self.long_timeout, flush=False + ) + + fiber_spectrum_exposures.append(lfo.url) + + return fiber_spectrum_exposures + + @property + def electrometer(self) -> salobj.Remote: + return getattr(self.rem, f"electrometer_{self.electrometer_projector_index}") + + @property + def fiberspectrograph_red(self) -> salobj.Remote: + return getattr( + self.rem, f"fiberspectrograph_{self.fiberspectrograph_red_index}" + ) + + @property + def fiberspectrograph_blue(self) -> salobj.Remote: + return getattr( + self.rem, f"fiberspectrograph_{self.fiberspectrograph_blue_index}" + ) + + @property + def linearstage_led_focus(self) -> salobj.Remote: + """Horizontal linear stage that moves the mirror, extending + the distance between the 3rd and 4th lenses. + """ + return getattr(self.rem, f"linearstage_{self.linearstage_led_focus_index}") + + @property + def linearstage_laser_focus(self) -> salobj.Remote: + """Horizontal linear stage that moves a lens to focus the + laser light from the fiber + """ + return getattr(self.rem, f"linearstage_{self.linearstage_laser_focus_index}") + + @property + def linearstage_led_select(self) -> salobj.Remote: + """Horizontal linear stage that moves between LED modules""" + return getattr(self.rem, f"linearstage_{self.linearstage_led_select_index}") + + @property + def linearstage_projector_select(self) -> salobj.Remote: + """Vertical linear stage that selects between the LED + or laser projectors + """ + return getattr(self.rem, f"linearstage_{self.linearstage_select_index}") diff --git a/tests/auxtel/test_atcalsys.py b/tests/auxtel/test_atcalsys.py index f86fc255..a2dc4781 100644 --- a/tests/auxtel/test_atcalsys.py +++ b/tests/auxtel/test_atcalsys.py @@ -206,7 +206,7 @@ async def test_run_calibration_sequence_white_light(self) -> None: assert "sequence_name" in calibration_summary assert calibration_summary["sequence_name"] == "at_whitelight_r" assert "steps" in calibration_summary - self.log.debug("number of steps:", len(calibration_summary["steps"])) + self.log.debug(f"number of steps: {len(calibration_summary['steps'])}") assert len(calibration_summary["steps"]) == len(config_data["exposure_times"]) for latiss_exposure_info in calibration_summary["steps"][0][ "latiss_exposure_info" diff --git a/tests/maintel/test_mtcalsys.py b/tests/maintel/test_mtcalsys.py new file mode 100644 index 00000000..de73e0f3 --- /dev/null +++ b/tests/maintel/test_mtcalsys.py @@ -0,0 +1,250 @@ +# This file is part of ts_observatory_control +# +# Developed for the Vera Rubin Observatory Data Management System. +# This product includes software developed by the LSST Project +# (https://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License + +import asyncio +import logging +import types +import unittest.mock + +import numpy as np +from lsst.ts.observatory.control.maintel.comcam import ComCam, ComCamUsages +from lsst.ts.observatory.control.maintel.mtcalsys import MTCalsys, MTCalsysUsages +from lsst.ts.observatory.control.mock import RemoteGroupAsyncMock +from lsst.ts.utils import index_generator + + +class TestMTCalsys(RemoteGroupAsyncMock): + + @property + def remote_group(self) -> MTCalsys: + """The remote_group property.""" + return self.mtcalsys + + async def setup_mocks(self) -> None: + self.mtcalsys.rem.electrometer_103.configure_mock( + **{ + "evt_largeFileObjectAvailable.next.side_effect": self.mock_electrometer_lfoa + } + ) + self.mtcalsys.rem.fiberspectrograph_1.configure_mock( + **{ + "evt_largeFileObjectAvailable.next.side_effect": self.mock_fiberspectrograph_lfoa + } + ) + + async def setup_types(self) -> None: + pass + + @classmethod + def setUpClass(cls) -> None: + """This classmethod is only called once, when preparing the unit + test. + """ + + cls.log = logging.getLogger(cls.__name__) + + # Pass in a string as domain to prevent MTCalsys from trying to create + # a domain by itself. When using DryTest usage, the class won't create + # any remote. When this method is called there is no event loop + # running so all asyncio facilities will fail to create. This is later + # rectified in the asyncSetUp. + cls.mtcalsys = MTCalsys( + domain="FakeDomain", log=cls.log, intended_usage=MTCalsysUsages.DryTest + ) + + [ + setattr(cls.mtcalsys.check, component, True) # type: ignore + for component in cls.mtcalsys.components_attr + ] + + cls.image_index = index_generator() + cls.electrometer_projector_index = index_generator() + cls.fiberspectrograph_blue_index = index_generator() + cls.fiberspectrograph_red_index = index_generator() + cls.linearstage_led_select_index = index_generator() + cls.linearstage_led_focus_index = index_generator() + cls.linearstage_laser_focus_index = index_generator() + cls.linearstage_select_index = index_generator() + + def test_load_calibration_config_file(self) -> None: + self.mtcalsys.load_calibration_config_file() + + assert "whitelight_r" in self.mtcalsys.get_configuration_options() + + async def test_setup_electrometers(self) -> None: + + config_data = self.mtcalsys.get_calibration_configuration("whitelight_r") + + await self.mtcalsys.setup_electrometers( + mode=str(config_data["electrometer_mode"]), + range=float(config_data["electrometer_range"]), + integration_time=float(config_data["electrometer_integration_time"]), + ) + + self.mtcalsys.electrometer.cmd_performZeroCalib.start.assert_awaited_with( + timeout=self.mtcalsys.long_timeout + ) + self.mtcalsys.electrometer.cmd_setDigitalFilter.set_start.assert_awaited_with( + activateFilter=False, + activateAvgFilter=False, + activateMedFilter=False, + timeout=self.mtcalsys.long_timeout, + ) + + async def test_change_laser_wavelength(self) -> None: + + await self.mtcalsys.change_laser_wavelength(wavelength=500.0) + + async def test_prepare_for_whitelight_flat(self) -> None: + mock_comcam = ComCam( + "FakeDomain", log=self.log, intended_usage=ComCamUsages.DryTest + ) + mock_comcam.rem.cccamera = unittest.mock.AsyncMock() + mock_comcam.rem.cccamera.evt_endReadout.next.configure_mock( + side_effect=self.mock_end_readout + ) + + self.mtcalsys.mtcamera = mock_comcam + + try: + await self.mtcalsys.prepare_for_flat("whitelight_r") + finally: + self.mtcalsys.mtcamera = None + + async def mock_end_readout( + self, flush: bool, timeout: float + ) -> types.SimpleNamespace: + image_index = next(self.image_index) + self.log.debug(f"Calling mock end readout: {image_index=}.") + await asyncio.sleep(0.5) + return types.SimpleNamespace(imageName=f"A_B_20240523_{image_index:04d}") + + async def mock_electrometer_lfoa( + self, flush: bool, timeout: float + ) -> types.SimpleNamespace: + image_index = next(self.electrometer_projector_index) + self.log.debug(f"Calling mock electrometer lfoa: {image_index=}.") + await asyncio.sleep(0.25) + return types.SimpleNamespace( + url=f"https://electrometer_20240523_{image_index:04d}" + ) + + async def mock_fiberspectrograph_lfoa( + self, flush: bool, timeout: float + ) -> types.SimpleNamespace: + image_index = next(self.fiberspectrograph_blue_index) + self.log.debug(f"Calling mock fiberspectrograph lfoa: {image_index=}.") + await asyncio.sleep(0.3) + return types.SimpleNamespace( + url=f"https://fiberspectrograph_20240523_{image_index:04d}" + ) + + async def test_run_calibration_sequence_white_light(self) -> None: + + mock_comcam = ComCam( + "FakeDomain", log=self.log, intended_usage=ComCamUsages.DryTest + ) + mock_comcam.rem.cccamera = unittest.mock.AsyncMock() + mock_comcam.rem.cccamera.evt_endReadout.next.configure_mock( + side_effect=self.mock_end_readout + ) + self.mtcalsys.mtcamera = mock_comcam + + try: + calibration_summary = await self.mtcalsys.run_calibration_sequence( + "whitelight_r", exposure_metadata=dict() + ) + finally: + self.mtcalsys.mtcamera = None + + config_data = self.mtcalsys.get_calibration_configuration("whitelight_r") + + assert "sequence_name" in calibration_summary + assert calibration_summary["sequence_name"] == "whitelight_r" + assert "steps" in calibration_summary + self.log.debug(f"number of steps: {len(calibration_summary['steps'])}") + assert len(calibration_summary["steps"]) == len(config_data["exposure_times"]) + for mtcamera_exposure_info in calibration_summary["steps"][0][ + "mtcamera_exposure_info" + ].values(): + assert len(mtcamera_exposure_info["electrometer_exposure_result"]) >= 1 + assert ( + len(mtcamera_exposure_info["fiber_spectrum_red_exposure_result"]) >= 1 + ) + assert ( + len(mtcamera_exposure_info["fiber_spectrum_blue_exposure_result"]) >= 1 + ) + + async def test_run_calibration_sequence_mono(self) -> None: + + mock_comcam = ComCam( + "FakeDomain", log=self.log, intended_usage=ComCamUsages.DryTest + ) + mock_comcam.rem.cccamera = unittest.mock.AsyncMock() + mock_comcam.rem.cccamera.evt_endReadout.next.configure_mock( + side_effect=self.mock_end_readout + ) + self.mtcalsys.mtcamera = mock_comcam + + try: + calibration_summary = await self.mtcalsys.run_calibration_sequence( + "scan_r", exposure_metadata=dict() + ) + finally: + self.mtcalsys.mtcamera = None + + config_data = self.mtcalsys.get_calibration_configuration("scan_r") + wavelength = float(config_data["wavelength"]) + wavelength_width = float(config_data["wavelength_width"]) + wavelength_resolution = float(config_data["wavelength_resolution"]) + wavelength_start = wavelength - wavelength_width / 2.0 + wavelength_end = wavelength + wavelength_width / 2.0 + + calibration_wavelengths = np.arange( + wavelength_start, wavelength_end, wavelength_resolution + ) + expected_change_wavelegths_calls = [ + unittest.mock.call( + wavelength=wavelength, timeout=self.mtcalsys.long_long_timeout + ) + for wavelength in calibration_wavelengths + ] + + assert "sequence_name" in calibration_summary + assert calibration_summary["sequence_name"] == "scan_r" + assert "steps" in calibration_summary + assert len(calibration_summary["steps"]) == 50 + assert ( + len(calibration_summary["steps"][0]["mtcamera_exposure_info"]) + == len(config_data["exposure_times"]) * 2 + ) + for mtcamera_exposure_info in calibration_summary["steps"][0][ + "mtcamera_exposure_info" + ].values(): + assert len(mtcamera_exposure_info["electrometer_exposure_result"]) >= 1 + assert ( + len(mtcamera_exposure_info["fiber_spectrum_red_exposure_result"]) >= 1 + ) + assert ( + len(mtcamera_exposure_info["fiber_spectrum_blue_exposure_result"]) >= 1 + ) + self.mtcalsys.change_laser_wavelength( + wavelength=expected_change_wavelegths_calls + )