From bf62ecb567ea75c6b35d2244b495437e70b76d38 Mon Sep 17 00:00:00 2001 From: elanaku Date: Wed, 20 Nov 2024 12:34:04 -0300 Subject: [PATCH 1/6] CBP Image sequence skeleton --- .../base_take_cbp_image_sequence.py | 0 .../maintel/take_cbp_image_sequence_comcam.py | 26 ++++++++++++++++ .../ts/externalscripts/maintel/__init__.py | 1 + .../maintel/take_cbp_image_sequence_comcam.py | 0 .../test_take_cbp_image_sequence_comcam.py | 31 +++++++++++++++++++ 5 files changed, 58 insertions(+) create mode 100644 python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py create mode 100644 python/lsst/ts/externalscripts/data/scripts/maintel/take_cbp_image_sequence_comcam.py create mode 100644 python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py create mode 100644 tests/maintel/test_take_cbp_image_sequence_comcam.py diff --git a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/lsst/ts/externalscripts/data/scripts/maintel/take_cbp_image_sequence_comcam.py b/python/lsst/ts/externalscripts/data/scripts/maintel/take_cbp_image_sequence_comcam.py new file mode 100644 index 000000000..45b35cb1a --- /dev/null +++ b/python/lsst/ts/externalscripts/data/scripts/maintel/take_cbp_image_sequence_comcam.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +# This file is part of ts_externalscripts +# +# Developed for the LSST 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 + +import asyncio + +from lsst.ts.externalscripts.maintel import TakeCBPImageSequenceComCam + +asyncio.run(TakeCBPImageSequenceComCam.amain()) diff --git a/python/lsst/ts/externalscripts/maintel/__init__.py b/python/lsst/ts/externalscripts/maintel/__init__.py index 548abdaa3..9ce891319 100644 --- a/python/lsst/ts/externalscripts/maintel/__init__.py +++ b/python/lsst/ts/externalscripts/maintel/__init__.py @@ -22,6 +22,7 @@ from .make_comcam_calibrations import * from .parameter_march_comcam import * from .parameter_march_lsstcam import * +from .take_cbp_image_sequence_comcam import * from .take_comcam_guider_image import * from .take_ptc_flats_comcam import * from .take_rotated_comcam import * diff --git a/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py b/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/maintel/test_take_cbp_image_sequence_comcam.py b/tests/maintel/test_take_cbp_image_sequence_comcam.py new file mode 100644 index 000000000..c366a1f61 --- /dev/null +++ b/tests/maintel/test_take_cbp_image_sequence_comcam.py @@ -0,0 +1,31 @@ +import unittest + +from lsst.ts import externalscripts, standardscripts +from lsst.ts.externalscripts.maintel.take_cbp_image_sequence_comcam import ( + TakeCBPImageSequenceComCam, +) + + +class TestTakeCBPImageSequenceComCam( + standardscripts.BaseScriptTestCase, + unittest.IsolatedAsyncioTestCase, +): + async def basic_make_script(self, index): + self.script = TakeCBPImageSequenceComCam(index=index) + + return (self.script,) + + async def test_configure(self): + + config = {"nburst": 3, "cbp_elevation": 10} + + async with self.make_script(): + await self.configure_script(**config) + + assert self.script.config.nburst == 3 + assert self.script.config.cbp_elevation == 10 + + async def test_executable(self): + scripts_dir = externalscripts.get_scripts_dir() + script_path = scripts_dir / "maintel" / "take_cbp_image_sequence_comcam.py" + await self.check_executable(script_path) From 0d15d7b92652f13d4cc0fc78016f6487f116323c Mon Sep 17 00:00:00 2001 From: elanaku Date: Wed, 20 Nov 2024 20:49:21 -0300 Subject: [PATCH 2/6] CBP Image Sequence: more edits --- .../base_take_cbp_image_sequence.py | 762 ++++++++++++++++++ 1 file changed, 762 insertions(+) diff --git a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py index e69de29bb..bb97db4d4 100644 --- a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py +++ b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py @@ -0,0 +1,762 @@ +# This file is part of ts_externalscripts +# +# Developed for the LSST 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__ = ["TakeCBPImageSequence"] + +import abc +import asyncio +import types + +import numpy as np +import pandas as pd +import yaml +from lsst.ts import salobj +from lsst.ts.observatory.control.utils.enums import LaserOpticalConfiguration +from lsst.ts.standardscripts.base_block_script import BaseBlockScript +from lsst.ts.xml.enums.Electrometer import UnitToRead +from lsst.ts.xml.enums.TunableLaser import LaserDetailedState + + +class TakeCBPImageSequence(BaseBlockScript, metaclass=abc.ABCMeta): + """Class for making CBP throughput scan with CBP calibration system.""" + + def __init__(self, index, descr="Script for making CBP throughput scan.") -> None: + super().__init__(index, descr) + + self.config = None + + self.cbp = None + self.electrometer_cbp = None + self.tunablelaser = None + + self.long_timeout = 30 + self.long_long_timeout = 60 + self.electrometer_cbp_index = 101 + + self.laser_warmup = 30 + + self.cbp_index = 0 + + ''' + @property + def calsys(self): + return self.mtcalsys + + async def configure_calsys(self): + """Handle creating the MTCalsys object and waiting remote to start""" + if self.mtcalsys is None: + self.log.debug("Creating MTCalsys.") + self.mtcalsys = MTCalsys( + domain=self.domain, + intended_usage=MTCalsysUsages.StateTransition, + log=self.log, + ) + await self.mtcalsys.start_task + else: + self.log.debug("MTCalsys already defined, skipping.") + ''' + + @classmethod + def get_schema(cls): + schema_yaml = """ + $schema: http://json-schema.org/draft-07/schema# + $id: https://github.com/lsst-ts/ts_externalscripts/make_cbp_throughput_scan.yaml + title: MakeCBPThroughputScan + description: Configuration schema for MakeCBPThroughputScan. + type: object + properties: + wavelength: + description: >- + Center wavelength value in nm, used for configuring the Tunable Laser + type: number + default: 700 + set_wavelength_range: + description: >- + If true, set the wavelength range, otherwise, provide wavelength list. + type: boolean + default: true + wavelength_width: + description: >- + Optional. Defines the width of the wavelength scan range to configure the + Tunable Laser for the flat-field calibration sequence when using + monochromatic light. + type: number + default: 400 + wavelength_resolution: + description: >- + Optional. When using a monochromatic light source, it defines the + resolution used to sample values within the scan range, which has a width + defined by `wavelength_width` and is centered around the `wavelength` + attribute. + type: number + default: 100 + wavelength_list: + description: >- + Optional. Lists wavelengths to scan in nm. + type: array + default: [450] + electrometer_integration_time: + description: >- + Integration time in seconds (166.67e-6 to 200e-3) for each sample. + The integration time (measurement speed) of the analog to digital (A/D) + converter, the period of time the input signal is measured (also known as + aperture). Due to the time it takes to read the buffer and process the + data, this is not the rate at which samples are taken. This is generally + specified by the Power Line Cycle (PLC), where 1 PLC for 60Hz is + 16.67msec. Fast integration=0.01PLC; Medium integration=0.1PLC; Normal + (default) integration = 1PLC; High Accuracy integration=10PLC. Here the + integration is set in seconds. + type: number + default: 0.001 + electrometer_mode: + description: >- + Set electrometer to use different modes. The units recorded will be Amps + for `CURRENT`, Volts for `VOLTAGE`, Coulombs for `CHARGE`, and Ohms for + `RESISTANCE`. + type: string + enum: + - CURRENT + - CHARGE + - VOLTAGE + - RESISTANCE + default: CHARGE + electrometer_range: + description: >- + Set measurement range, which effects the accuracy of measurements and the + max signal that can be measured. The maximum input signal is 105% of the + measurement range. It will set the value for the current mode selected. + Auto-range will automatically go to the most sensitive (optimized) range + to make measurements. It is recommended to use autorange. When the + measurement range is changed, a zero correction will be performed. + -1 for automatic range. Volts range from 0 to 210 Volts, Current range + from 0 to 21e-3 Amps, Resistance from 0 to 100e18 Ohms, Charge from 0 to + +2.1e-6 Coulombs. + type: number + use_electrometer: + description: >- + If true, use the electrometer for scans. + type: boolean + default: true + laser_mode: + type: integer + default: 2 + optical_configuration: + type: string + enum: + - SCU + - NO SCU + - F1 SCU + - F2 SCU + - F2 NO SCU + - F1 NO SCU + default: F1 SCU + nburst: + type: integer + default: 5 + cbp_elevation: + description: CBP elevation in degrees. + type: number + default: 0 + cbp_azimuth: + description: CBP azimuth in degrees. + type: number + default: 0 + cbp_mask: + description: CBP azimuth in degrees. + type: integer + default: 4 + minimum: 1 + maximum: 5 + cbp_rotation: + description: CBP mask rotator angle in degrees. + type: number + default: 0 + cbp_focus: + description: CBP focus position in um. + type: number + default: 6000 + minimum: 0 + maximum: 13000 + additionalProperties: false + """ + schema_dict = yaml.safe_load(schema_yaml) + + base_schema_dict = super().get_schema() + + for properties in base_schema_dict["properties"]: + schema_dict["properties"][properties] = base_schema_dict["properties"][ + properties + ] + + return schema_dict + + async def configure(self, config: types.SimpleNamespace): + """Configure script components including camera. + + Parameters + ---------- + config : `types.SimpleNamespace` + Script configuration, as defined by `schema`. + """ + + self.config = config + + await super().configure(config) + + async def start_system(self): + + self.electrometer_cbp = salobj.Remote( + name="Electrometer", + domain=self.domain, + index=self.electrometer_cbp_index, + ) + + self.electrometer_cbp_cal = salobj.Remote( + name="Electrometer", + domain=self.domain, + index=self.electrometer_cbp_cal_index, + ) + + self.cbp = salobj.Remote( + name="CBP", + domain=self.domain, + ) + + self.tunablelaser = salobj.Remote( + name="TunableLaser", + domain=self.domain, + ) + + await self.electrometer_cbp.start_task + await salobj.set_summary_state(self.electrometer_cbp, salobj.State.ENABLED) + + await self.electrometer_cbp_cal.start_task + await salobj.set_summary_state(self.electrometer_cbp_cal, salobj.State.ENABLED) + + await self.cbp.start_task + await salobj.set_summary_state(self.cbp, salobj.State.ENABLED) + + await self.tunablelaser.start_task + await salobj.set_summary_state(self.tunablelaser, salobj.State.ENABLED) + + async def setup_cbp( + self, + azimuth: float, + elevation: float, + mask: int, + focus: float, + rotation: float, + ) -> None: + """Perform all steps for preparing the CBP for measurements. + + Parameters + ---------- + az : `float` + Azimuth of CBP in degrees + el : `float` + Elevation of CBP in degrees + mask : `int` + Mask number to use + focus: `float` + Focus position in um + rot: `int` + Rotator position of mask in degrees + Default 0 + """ + timeout = 60 + await self.cbp.cmd_move.set_start( + azimuth=azimuth, elevation=elevation, timeout=timeout + ) + if focus is not None: + await self.cbp.cmd_setFocus.set_start(focus=focus, timeout=timeout) + if mask is not None: + await self.cbp.cmd_changeMask.set_start(mask=mask, timeout=timeout) + if rotation is not None: + await self.cbp.cmd_changeMaskRotation.set_start( + mask_rotation=rotation, timeout=timeout + ) + + async def setup_electrometer( + self, electrometer, mode: str, range: float, integration_time: float + ) -> None: + """Setup all electrometers. + + Parameters + ---------- + mode : `str` + Electrometer measurement mode. + range : `float` + Electrometer measurement range. -1 for autorange. + integration_time : `float` + Electrometer measurement range. + """ + electrometer_mode = getattr(UnitToRead, mode).value + + await electrometer.cmd_setMode.set_start( + mode=electrometer_mode, + timeout=self.long_timeout, + ) + await electrometer.cmd_setRange.set_start( + setRange=range, + timeout=self.long_timeout, + ) + await electrometer.cmd_setIntegrationTime.set_start( + intTime=integration_time, + timeout=self.long_timeout, + ) + await electrometer.cmd_performZeroCalib.start(timeout=self.long_timeout) + """ + await electrometer.cmd_setDigitalFilter.set_start( + activateFilter=False, + activateAvgFilter=False, + activateMedFilter=False, + timeout=self.long_timeout, + ) + """ + + async def setup_laser( + self, + mode: LaserDetailedState, + wavelength: float, + optical_configuration: LaserOpticalConfiguration = "F1_SCU", + use_projector: bool = True, + ) -> 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 + wavelength : `float` + Wavelength fo the laser in nm + optical_configuration : LaserOpticalConfiguration + Output of laser + Default LaserOpticalConfiguration.SCU + use_projector : `bool` + identifies if you are using the projector while + changing the wavelength + Default True + """ + # TO-DO: DM-45693 implement thermal system checks + + if mode in { + LaserDetailedState.NONPROPAGATING_CONTINUOUS_MODE, + LaserDetailedState.PROPAGATING_CONTINUOUS_MODE, + }: + await self.tunablelaser.cmd_setContinuousMode.start( + timeout=self.long_timeout + ) + elif mode in { + LaserDetailedState.NONPROPAGATING_BURST_MODE, + LaserDetailedState.PROPAGATING_BURST_MODE, + }: + await self.tunablelaser.cmd_setBurstMode.start(timeout=self.long_timeout) + else: + raise RuntimeError( + f"{mode} not an acceptable LaserDetailedState [CONTINOUS, BURST, TRIGGER]" + ) + + await self.change_laser_optical_configuration(optical_configuration) + await self.change_laser_wavelength(wavelength) + + async def change_laser_optical_configuration( + self, optical_configuration: LaserOpticalConfiguration + ) -> None: + """Change the output of the laser. + + Parameters + ---------- + optical_configuration : LaserOpticalConfiguration + """ + assert optical_configuration in list(LaserOpticalConfiguration) + + current_configuration = await self.tunablelaser.evt_opticalConfiguration.aget() + if current_configuration.configuration != optical_configuration: + self.log.debug( + f"Changing optical configuration from {current_configuration} to {optical_configuration}" + ) + await self.tunablelaser.cmd_setOpticalConfiguration.set_start( + configuration=optical_configuration, timeout=self.long_timeout + ) + + else: + self.log.debug("Laser Optical Configuration already in place.") + + async def change_laser_wavelength( + self, + wavelength: int, + ) -> None: + """Change the TunableLaser wavelength setting + + Parameters + ---------- + wavelength : `float` + wavelength of the laser in nm + use_projector : `bool` + identifies if you are using the projector while + changing the wavelength. + Default True + """ + task_wavelength = self.tunablelaser.cmd_changeWavelength.set_start( + wavelength=int(wavelength), timeout=self.long_long_timeout + ) + await task_wavelength + + async def setup_calibration(self): + """Setup calibration system""" + """ + await self.setup_cbp( + self.config.cbp_azimuth, + self.config.cbp_elevation, + self.config.cbp_mask, + self.config.cbp_focus, + self.config.cbp_rotation, + ) + """ + await self.setup_laser( + self.config.laser_mode, + self.config.wavelength, + self.config.optical_configuration, + ) + + await self.laser_start_propagate() + + await self.setup_electrometer( + self.electrometer_cbp, + self.config.electrometer_mode, + self.config.electrometer_range, + self.config.electrometer_integration_time, + ) + + await self.setup_electrometer( + self.electrometer_cbp_cal, + self.config.electrometer_mode, + self.config.electrometer_range, + self.config.electrometer_integration_time, + ) + + def set_metadata(self, metadata: salobj.BaseMsgType) -> None: + """Set script metadata, including estimated duration.""" + + scan_time = 30 + + # Total duration calculation + total_duration = ( + scan_time + * self.config.wavelength_width + * 2 + / self.config.wavelength_resolution + ) + + metadata.duration = total_duration + + async def laser_start_propagate(self) -> None: + """Start the propagation of the Tunable Laser""" + + laser_state = await self.tunablelaser.evt_detailedState.next( + flush=False, timeout=self.long_timeout + ) + self.log.debug(f"laser state is: {laser_state}") + + if laser_state.detailedState not in { + LaserDetailedState.PROPAGATING_CONTINUOUS_MODE, + LaserDetailedState.PROPAGATING_BURST_MODE, + }: + try: + await self.tunablelaser.cmd_startPropagateLaser.start( + timeout=self.laser_warmup + ) + laser_state = await self.tunablelaser.evt_detailedState.next( + flush=False, timeout=self.long_timeout + ) + self.log.info(f"Laser state: {laser_state.detailedState}") + except asyncio.TimeoutError: + raise RuntimeError( + "Tunable Laser did not start propagating when commanded" + ) + + def get_durations(self, npulse, t_dark_min=0.0001, max_pulses=6000): + + if npulse > max_pulses: + nburst = int(self.config.nburst * npulse / max_pulses) + npulse = max_pulses + else: + nburst = self.config.nburst + + burst_duration = npulse * 1e-3 + if burst_duration < 1: + burst_duration = 1 + + if burst_duration / 2 > t_dark_min: + delay_before = burst_duration / 2 + delay_after = burst_duration * 3 / 2 + else: + delay_before = t_dark_min + delay_after = burst_duration + t_dark_min + + duration = ( + nburst * (burst_duration + delay_before + delay_after) + + delay_before + + delay_after + ) + return duration, delay_before, delay_after + + async def _calculate_electrometer_exposure_times( + self, + wavelengths, + ) -> 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 = [] + pulses = [] + delay_before = [] + delay_after = [] + + for wavelength in wavelengths: + npulse = 2000 + pulses.append(npulse) + + exptime, delay_before_temp, delay_after_temp = self.get_durations(npulse) + + delay_before.append(delay_before_temp) + delay_after.append(delay_after_temp) + + time_sep = ( + self.config.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) + + data = { + "wavelength": wavelengths, + "exposure_time": electrometer_exptimes, + "npulses": pulses, + "delay_before": delay_before, + "delay_after": delay_after, + } + return pd.DataFrame(data) + + async def take_bursts( + self, + duration=30, + delay_before=5, + delay_after=5, + t_dark_min=0.0001, + max_pulses=1000, + wait_time=10, + ): + await asyncio.sleep(wait_time) + await asyncio.sleep(5) + await asyncio.sleep(delay_before) + for n in range(self.config.nburst): + await asyncio.sleep(delay_before) + await self.tunablelaser.cmd_triggerBurst.start() + await asyncio.sleep(delay_after) + await asyncio.sleep(delay_after) + + async def _take_data( + self, + electrometer_exposure_time: float | None, + delay_before: float = 5, + delay_after: float = 5, + ) -> dict: + + exposures_done: asyncio.Future = asyncio.Future() + + electrometer_exposure_coroutine_cbp = self.take_electrometer_scan( + self.electrometer_cbp, + exposure_time=electrometer_exposure_time, + exposures_done=exposures_done, + ) + + electrometer_exposure_coroutine_cbp_cal = self.take_electrometer_scan( + self.electrometer_cbp_cal, + exposure_time=electrometer_exposure_time, + exposures_done=exposures_done, + ) + + laser_burst_coroutine = self.take_bursts( + delay_before=delay_before, + delay_after=delay_after, + wait_time=0, + ) + + try: + + electrometer_exposure_task_cbp = asyncio.create_task( + electrometer_exposure_coroutine_cbp + ) + + electrometer_exposure_task_cbp_cal = asyncio.create_task( + electrometer_exposure_coroutine_cbp_cal + ) + + laser_burst_task = asyncio.create_task(laser_burst_coroutine) + + finally: + await asyncio.gather( + laser_burst_task, + electrometer_exposure_task_cbp, + electrometer_exposure_task_cbp_cal, + ) + + async def take_electrometer_scan( + self, + electrometer, + 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. + """ + + electrometer.evt_largeFileObjectAvailable.flush() + + electrometer_exposures = list() + + if exposure_time is not None: + + try: + await 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 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(electrometer, salobj.State.ENABLED) + await self.setup_electrometer( + electrometer, + self.config.electrometer_mode, + self.config.electrometer_range, + self.config.electrometer_integration_time, + ) + return electrometer_exposures + + async def take_calibration_sequence(self): + """Take the CBP calibration sequence.""" + + calibration_summary = {"steps": []} + + if self.config.set_wavelength_range: + wavelength = float(self.config.wavelength) + wavelength_width = float(self.config.wavelength_width) + wavelength_resolution = float(self.config.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 + ) + else: + calibration_wavelengths = self.config.wavelength_list + + exposure_table = await self._calculate_electrometer_exposure_times( + wavelengths=calibration_wavelengths, + ) + + self.log.info(f"Raw exposure table: {exposure_table}") + + shuffled_exposure_table = exposure_table.sample(frac=1).reset_index(drop=True) + + self.log.info(f"Order to take data in: {shuffled_exposure_table}") + + for i, exposure in shuffled_exposure_table.iterrows(): + self.log.debug(f"exposure is {exposure}") + self.log.debug(f"Changing wavelength to {exposure.wavelength=}.") + await self.change_laser_wavelength(wavelength=exposure.wavelength) + await self.tunablelaser.cmd_setBurstMode.set_start( + count=int(exposure.npulses) + ) + + self.log.info(f"Taking sequence {i} out of {len(calibration_wavelengths)}") + + self.log.info( + f"Taking data sequence with wavelength {exposure.wavelength=} nm." + ) + await self._take_data( + electrometer_exposure_time=exposure.exposure_time, + delay_before=exposure.delay_before, + delay_after=exposure.delay_after, + ) + step = dict( + wavelength=exposure.wavelength, + ) + + calibration_summary["steps"].append(step) + self.log.info(f"Calibration summary is {calibration_summary}") + + async def run_block(self): + """Run the block of tasks to take CBP calibration sequence.""" + + await self.start_system() + await self.setup_calibration() + await self.take_calibration_sequence() From e376500de9ac9ddd4cbd4c899ecd73cb802acfb3 Mon Sep 17 00:00:00 2001 From: elanaku Date: Tue, 3 Dec 2024 09:52:24 -0300 Subject: [PATCH 3/6] Update CBP image sequence script --- .../base_take_cbp_image_sequence.py | 223 +++++++++++------- 1 file changed, 141 insertions(+), 82 deletions(-) diff --git a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py index bb97db4d4..84746b50b 100644 --- a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py +++ b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py @@ -73,6 +73,25 @@ async def configure_calsys(self): self.log.debug("MTCalsys already defined, skipping.") ''' + @property + @abc.abstractmethod + def camera(self): + raise NotImplementedError() + + @abc.abstractmethod + async def configure_camera(self): + """Abstract method to configure the camera, to be implemented + in subclasses. + """ + raise NotImplementedError() + + @abc.abstractmethod + async def slew_azel_and_setup_instrument(self, azimuth, elevation): + """Abstract method to configure the TMA, to be implemented + in subclasses. + """ + raise NotImplementedError() + @classmethod def get_schema(cls): schema_yaml = """ @@ -112,6 +131,18 @@ def get_schema(cls): Optional. Lists wavelengths to scan in nm. type: array default: [450] + tma_az: + description: Azimuth of TMA. + type: number + default: 45 + tma_el: + description: Elevation of TMA. + type: number + default: 45 + exp_time: + description: Exposure times for camera. + type: number + default: 30 electrometer_integration_time: description: >- Integration time in seconds (166.67e-6 to 200e-3) for each sample. @@ -170,6 +201,10 @@ def get_schema(cls): nburst: type: integer default: 5 + do_setup_cbp: + description: If true, setup CBP. + type: boolean + default: false cbp_elevation: description: CBP elevation in degrees. type: number @@ -194,6 +229,17 @@ def get_schema(cls): default: 6000 minimum: 0 maximum: 13000 + do_setup_instrument: + description: If true, slew and set up TMA + camera. + type: boolean + default: false + do_start_system: + description: If true, enable componenets in system. + type: boolean + default: false + exposure_times: + description: camera exposure times + type: array additionalProperties: false """ schema_dict = yaml.safe_load(schema_yaml) @@ -216,43 +262,37 @@ async def configure(self, config: types.SimpleNamespace): Script configuration, as defined by `schema`. """ + await self.configure_camera() self.config = config await super().configure(config) async def start_system(self): + """Start up relevant components.""" - self.electrometer_cbp = salobj.Remote( - name="Electrometer", - domain=self.domain, - index=self.electrometer_cbp_index, - ) + if self.config.use_electrometer: + self.electrometer_cbp = salobj.Remote( + name="Electrometer", + domain=self.domain, + index=self.electrometer_cbp_index, + ) + await self.electrometer_cbp.start_task + await salobj.set_summary_state(self.electrometer_cbp, salobj.State.ENABLED) - self.electrometer_cbp_cal = salobj.Remote( - name="Electrometer", - domain=self.domain, - index=self.electrometer_cbp_cal_index, - ) + if self.config.do_setup_cbp: + self.cbp = salobj.Remote( + name="CBP", + domain=self.domain, + ) - self.cbp = salobj.Remote( - name="CBP", - domain=self.domain, - ) + await self.cbp.start_task + await salobj.set_summary_state(self.cbp, salobj.State.ENABLED) self.tunablelaser = salobj.Remote( name="TunableLaser", domain=self.domain, ) - await self.electrometer_cbp.start_task - await salobj.set_summary_state(self.electrometer_cbp, salobj.State.ENABLED) - - await self.electrometer_cbp_cal.start_task - await salobj.set_summary_state(self.electrometer_cbp_cal, salobj.State.ENABLED) - - await self.cbp.start_task - await salobj.set_summary_state(self.cbp, salobj.State.ENABLED) - await self.tunablelaser.start_task await salobj.set_summary_state(self.tunablelaser, salobj.State.ENABLED) @@ -423,17 +463,18 @@ async def change_laser_wavelength( ) await task_wavelength - async def setup_calibration(self): - """Setup calibration system""" - """ - await self.setup_cbp( - self.config.cbp_azimuth, - self.config.cbp_elevation, - self.config.cbp_mask, - self.config.cbp_focus, - self.config.cbp_rotation, - ) - """ + async def setup_system(self): + """Setup calibration system and camera""" + + if self.config.do_setup_cbp: + await self.setup_cbp( + self.config.cbp_azimuth, + self.config.cbp_elevation, + self.config.cbp_mask, + self.config.cbp_focus, + self.config.cbp_rotation, + ) + await self.setup_laser( self.config.laser_mode, self.config.wavelength, @@ -442,19 +483,16 @@ async def setup_calibration(self): await self.laser_start_propagate() - await self.setup_electrometer( - self.electrometer_cbp, - self.config.electrometer_mode, - self.config.electrometer_range, - self.config.electrometer_integration_time, - ) + if self.use_electrometer: + await self.setup_electrometer( + self.electrometer_cbp, + self.config.electrometer_mode, + self.config.electrometer_range, + self.config.electrometer_integration_time, + ) - await self.setup_electrometer( - self.electrometer_cbp_cal, - self.config.electrometer_mode, - self.config.electrometer_range, - self.config.electrometer_integration_time, - ) + if self.config.do_setup_instrument: + self.slew_azel_and_setup_instrument(self.config.tma_az, self.config.tma_el) def set_metadata(self, metadata: salobj.BaseMsgType) -> None: """Set script metadata, including estimated duration.""" @@ -569,7 +607,7 @@ async def _calculate_electrometer_exposure_times( electrometer_exptimes.append(max_exp_time) self.log.info(f"Electrometer exposure time reduced to {max_exp_time}") else: - electrometer_exptimes.append(exptime) + electrometer_exptimes.append(self.config.exp_time) data = { "wavelength": wavelengths, @@ -607,42 +645,61 @@ async def _take_data( exposures_done: asyncio.Future = asyncio.Future() - electrometer_exposure_coroutine_cbp = self.take_electrometer_scan( - self.electrometer_cbp, - exposure_time=electrometer_exposure_time, - exposures_done=exposures_done, + camera_exposure_coroutine = await self.camera.take_acq( + exptime=self.config.exp_time, + n=1, + group_id=self.group_id, + program=self.program, + reason=self.reason, ) - electrometer_exposure_coroutine_cbp_cal = self.take_electrometer_scan( - self.electrometer_cbp_cal, - exposure_time=electrometer_exposure_time, - exposures_done=exposures_done, - ) + if self.config.use_electrometer: - laser_burst_coroutine = self.take_bursts( - delay_before=delay_before, - delay_after=delay_after, - wait_time=0, - ) + electrometer_exposure_coroutine_cbp = self.take_electrometer_scan( + self.electrometer_cbp, + exposure_time=electrometer_exposure_time, + exposures_done=exposures_done, + ) + + if self.config.laser_mode == 4: + laser_burst_coroutine = self.take_bursts( + delay_before=delay_before, + delay_after=delay_after, + wait_time=0, + ) try: - electrometer_exposure_task_cbp = asyncio.create_task( - electrometer_exposure_coroutine_cbp - ) + camera_exposure_task = asyncio.create_task(camera_exposure_coroutine) - electrometer_exposure_task_cbp_cal = asyncio.create_task( - electrometer_exposure_coroutine_cbp_cal - ) + if self.config.use_electrometer: + + electrometer_exposure_task_cbp = asyncio.create_task( + electrometer_exposure_coroutine_cbp + ) - laser_burst_task = asyncio.create_task(laser_burst_coroutine) + if self.config.laser_mode == 4: + laser_burst_task = asyncio.create_task(laser_burst_coroutine) finally: - await asyncio.gather( - laser_burst_task, - electrometer_exposure_task_cbp, - electrometer_exposure_task_cbp_cal, - ) + + if self.config.use_electrometer: + if self.config.laser_mode == 4: + await asyncio.gather( + laser_burst_task, + electrometer_exposure_task_cbp, + camera_exposure_task, + ) + else: + await asyncio.gather( + electrometer_exposure_task_cbp, + camera_exposure_task, + ) + else: + await asyncio.gather( + laser_burst_task, + camera_exposure_task, + ) async def take_electrometer_scan( self, @@ -719,9 +776,10 @@ async def take_calibration_sequence(self): else: calibration_wavelengths = self.config.wavelength_list - exposure_table = await self._calculate_electrometer_exposure_times( - wavelengths=calibration_wavelengths, - ) + if self.config.use_electrometer: + exposure_table = await self._calculate_electrometer_exposure_times( + wavelengths=calibration_wavelengths, + ) self.log.info(f"Raw exposure table: {exposure_table}") @@ -733,9 +791,10 @@ async def take_calibration_sequence(self): self.log.debug(f"exposure is {exposure}") self.log.debug(f"Changing wavelength to {exposure.wavelength=}.") await self.change_laser_wavelength(wavelength=exposure.wavelength) - await self.tunablelaser.cmd_setBurstMode.set_start( - count=int(exposure.npulses) - ) + if self.config.laser_mode == 4: + await self.tunablelaser.cmd_setBurstMode.set_start( + count=int(exposure.npulses) + ) self.log.info(f"Taking sequence {i} out of {len(calibration_wavelengths)}") @@ -756,7 +815,7 @@ async def take_calibration_sequence(self): async def run_block(self): """Run the block of tasks to take CBP calibration sequence.""" - - await self.start_system() - await self.setup_calibration() + if self.config.do_start_system: + await self.start_system() + await self.setup_system() await self.take_calibration_sequence() From a6ca8016280ff4847ecf0c3f7cc4ceef8d28affc Mon Sep 17 00:00:00 2001 From: elanaku Date: Tue, 3 Dec 2024 12:15:49 -0300 Subject: [PATCH 4/6] Add CBP image sequence ComCam --- .../base_take_cbp_image_sequence.py | 46 +++++ .../maintel/take_cbp_image_sequence_comcam.py | 157 ++++++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py index 84746b50b..dd7424100 100644 --- a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py +++ b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py @@ -78,6 +78,11 @@ async def configure_calsys(self): def camera(self): raise NotImplementedError() + @property + @abc.abstractmethod + def tcs(self): + raise NotImplementedError() + @abc.abstractmethod async def configure_camera(self): """Abstract method to configure the camera, to be implemented @@ -85,6 +90,42 @@ async def configure_camera(self): """ raise NotImplementedError() + @abc.abstractmethod + async def configure_tcs(self): + """Abstract method to configure the tcs, to be implemented + in subclasses. + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_instrument_name(self): + """Abstract method to be defined in subclasses to provide the + instrument name. + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_instrument_configuration(self) -> dict: + """Abstract method to get the instrument configuration. + + Returns + ------- + dict + Dictionary with instrument configuration. + """ + raise NotImplementedError() + + @abc.abstractmethod + def get_instrument_filter(self) -> str: + """Abstract method to get the instrument filter configuration. + + Returns + ------- + str + Instrument filter configuration. + """ + raise NotImplementedError() + @abc.abstractmethod async def slew_azel_and_setup_instrument(self, azimuth, elevation): """Abstract method to configure the TMA, to be implemented @@ -139,6 +180,10 @@ def get_schema(cls): description: Elevation of TMA. type: number default: 45 + tma_rotator_angle: + description: Rotator angle of TMA. + type: number + default: 0 exp_time: description: Exposure times for camera. type: number @@ -263,6 +308,7 @@ async def configure(self, config: types.SimpleNamespace): """ await self.configure_camera() + await self.configure_tcs() self.config = config await super().configure(config) diff --git a/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py b/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py index e69de29bb..545c57759 100644 --- a/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py +++ b/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py @@ -0,0 +1,157 @@ +# This file is part of ts_externalscripts +# +# Developed for the LSST 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__ = ["TakeCBPImageSequenceComCam"] + +import yaml +from lsst.ts.observatory.control.maintel.comcam import ComCam, ComCamUsages +from lsst.ts.observatory.control.maintel.mtcs import MTCS, MTCSUsages + +from ..base_take_cbp_image_sequence import BaseTakeCBPImageSequence + + +class TakeCBPImageSequenceComCam(BaseTakeCBPImageSequence): + """Specialized script for taking CBP images with ComCam.""" + + def __init__(self, index): + super().__init__(index=index, descr="Take CBP images with ComCam.") + + self.mtcs = None + self.comcam = None + + @property + def tcs(self): + return self.mtcs + + @property + def camera(self): + return self.comcam + + async def configure_tcs(self) -> None: + """Handle creating the MTCS object and waiting remote to start.""" + if self.mtcs is None: + self.log.debug("Creating MTCS.") + self.mtcs = MTCS( + domain=self.domain, + intended_usage=MTCSUsages.StateTransition | MTCSUsages.Slew, + log=self.log, + ) + await self.mtcs.start_task + else: + self.log.debug("MTCS already defined, skipping.") + + async def configure_camera(self) -> None: + """Handle creating the camera object and waiting remote to start.""" + if self.comcam is None: + self.log.debug("Creating Camera.") + self.comcam = ComCam( + self.domain, + intended_usage=ComCamUsages.TakeImageFull + | ComCamUsages.StateTransition, + log=self.log, + tcs_ready_to_take_data=self.mtcs.ready_to_take_data, + ) + await self.comcam.start_task + else: + self.log.debug("Camera already defined, skipping.") + + @classmethod + def get_schema(cls): + schema_yaml = """ + $schema: http://json-schema.org/draft-07/schema# + $id: https://github.com/lsst-ts/ts_externalscripts/take_cbp_image_sequence_comcam.yaml + title: TakeCBPImageSequenceComCam v1 + description: Configuration for TakeCBPImageSequenceComCam. + type: object + properties: + filter: + description: Filter name or ID. + anyOf: + - type: string + - type: integer + minimum: 1 + - type: "null" + additionalProperties: false + """ + schema_dict = yaml.safe_load(schema_yaml) + + base_schema_dict = super(TakeCBPImageSequenceComCam, cls).get_schema() + + for prop in base_schema_dict["properties"]: + schema_dict["properties"][prop] = base_schema_dict["properties"][prop] + + return schema_dict + + def get_instrument_name(self) -> str: + """Get instrument name. + + Returns + ------- + instrument_name: `string` + """ + return "LSSTComCam" + + def get_instrument_configuration(self) -> dict: + return dict( + filter=self.config.filter, + ) + + def get_instrument_filter(self) -> str: + """Get instrument filter configuration. + + Returns + ------- + instrument_filter: `string` + """ + return f"{self.config.filter}" + + async def slew_azel_and_setup_instrument(self, az, el): + """Abstract method to set the instrument. Change the filter + and slew and track target. + + Parameters + ---------- + az : float + Azimuth of target field. + el : float + Elevation of target field. + """ + current_filter = await self.comcam.get_current_filter() + + if current_filter != self.config.filter: + self.log.debug( + f"Filter change required: {current_filter} -> {self.config.filter}" + ) + await self.comcam.setup_filter(filter=self.config.filter) + else: + self.log.debug( + f"Already in the desired filter ({current_filter}), slewing." + ) + + await self.mtcs.point_azel( + az=az, + el=el, + rot_tel=self.tma_rotator_angle, + ) + + async def configure(self, config): + """Take the sequence of twilight flats twilight flats.""" + self.configure_client() + await super().configure(config) From 3472539c2ac91f33d8c9ce9496d13bb4a66ea2d9 Mon Sep 17 00:00:00 2001 From: elanaku Date: Tue, 3 Dec 2024 12:51:54 -0300 Subject: [PATCH 5/6] CBP image sequence: update tests --- .../base_take_cbp_image_sequence.py | 4 ++-- .../maintel/take_cbp_image_sequence_comcam.py | 0 .../maintel/take_cbp_image_sequence_comcam.py | 5 ----- .../test_take_cbp_image_sequence_comcam.py | 20 +++++++++++++++++++ 4 files changed, 22 insertions(+), 7 deletions(-) mode change 100644 => 100755 python/lsst/ts/externalscripts/data/scripts/maintel/take_cbp_image_sequence_comcam.py diff --git a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py index dd7424100..28d36cfb0 100644 --- a/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py +++ b/python/lsst/ts/externalscripts/base_take_cbp_image_sequence.py @@ -18,7 +18,7 @@ # # You should have received a copy of the GNU General Public License -__all__ = ["TakeCBPImageSequence"] +__all__ = ["BaseTakeCBPImageSequence"] import abc import asyncio @@ -34,7 +34,7 @@ from lsst.ts.xml.enums.TunableLaser import LaserDetailedState -class TakeCBPImageSequence(BaseBlockScript, metaclass=abc.ABCMeta): +class BaseTakeCBPImageSequence(BaseBlockScript, metaclass=abc.ABCMeta): """Class for making CBP throughput scan with CBP calibration system.""" def __init__(self, index, descr="Script for making CBP throughput scan.") -> None: diff --git a/python/lsst/ts/externalscripts/data/scripts/maintel/take_cbp_image_sequence_comcam.py b/python/lsst/ts/externalscripts/data/scripts/maintel/take_cbp_image_sequence_comcam.py old mode 100644 new mode 100755 diff --git a/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py b/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py index 545c57759..59e0fe01d 100644 --- a/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py +++ b/python/lsst/ts/externalscripts/maintel/take_cbp_image_sequence_comcam.py @@ -150,8 +150,3 @@ async def slew_azel_and_setup_instrument(self, az, el): el=el, rot_tel=self.tma_rotator_angle, ) - - async def configure(self, config): - """Take the sequence of twilight flats twilight flats.""" - self.configure_client() - await super().configure(config) diff --git a/tests/maintel/test_take_cbp_image_sequence_comcam.py b/tests/maintel/test_take_cbp_image_sequence_comcam.py index c366a1f61..b62a5b0cf 100644 --- a/tests/maintel/test_take_cbp_image_sequence_comcam.py +++ b/tests/maintel/test_take_cbp_image_sequence_comcam.py @@ -1,4 +1,5 @@ import unittest +import unittest.mock as mock from lsst.ts import externalscripts, standardscripts from lsst.ts.externalscripts.maintel.take_cbp_image_sequence_comcam import ( @@ -13,8 +14,27 @@ class TestTakeCBPImageSequenceComCam( async def basic_make_script(self, index): self.script = TakeCBPImageSequenceComCam(index=index) + self.mock_mtcs() + self.mock_camera() + return (self.script,) + def mock_mtcs(self): + """Mock MTCS instances and its methods.""" + self.script.mtcs = mock.AsyncMock() + self.script.mtcs.assert_liveliness = mock.AsyncMock() + self.script.mtcs.assert_all_enabled = mock.AsyncMock() + self.script.mtcs.offset_aos_lut = mock.AsyncMock() + self.script.mtcs.get_sun_azel = mock.Mock(return_value=(90.0, -3.0)) + + def mock_camera(self): + """Mock camera instance and its methods.""" + self.script.comcam = mock.AsyncMock() + self.script.comcam.assert_liveliness = mock.AsyncMock() + self.script.comcam.assert_all_enabled = mock.AsyncMock() + self.script.comcam.take_imgtype = mock.AsyncMock(return_value=[1234]) + self.script.comcam.take_acq = mock.AsyncMock(return_value=([32, 0])) + async def test_configure(self): config = {"nburst": 3, "cbp_elevation": 10} From dfa8a68130aa6076f1b994d8de1f67679e79a29d Mon Sep 17 00:00:00 2001 From: elanaku Date: Tue, 3 Dec 2024 12:55:40 -0300 Subject: [PATCH 6/6] Add news fragment --- doc/news/DM-47708.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/news/DM-47708.feature.rst diff --git a/doc/news/DM-47708.feature.rst b/doc/news/DM-47708.feature.rst new file mode 100644 index 000000000..4785f5068 --- /dev/null +++ b/doc/news/DM-47708.feature.rst @@ -0,0 +1 @@ +Add script to take a sequence of CBP images with ComCam. \ No newline at end of file