From d7cd25aa91064fdbbd701507241163fdcb76dec7 Mon Sep 17 00:00:00 2001 From: gmegh Date: Tue, 24 Oct 2023 10:12:30 +0900 Subject: [PATCH 1/2] Adding news for close_loop scripts --- doc/news/DM-40213.feature.rst | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 doc/news/DM-40213.feature.rst diff --git a/doc/news/DM-40213.feature.rst b/doc/news/DM-40213.feature.rst new file mode 100644 index 00000000..06c9a34e --- /dev/null +++ b/doc/news/DM-40213.feature.rst @@ -0,0 +1,6 @@ +Add new base_close_loop.py script, and executable. +This script allows to run the closed loop, that is, taking images, processing them, and apply ts_ofc corrections. + +Add new maintel/close_loop_comcam.py script, unit test, and executable. + +Add new maintel/close_loop_lsstcam.py script, unit test, and executable. From 0a4386d78b1bc4fd26755bee7d9ef62fb2941c4d Mon Sep 17 00:00:00 2001 From: gmegh Date: Tue, 24 Oct 2023 10:12:42 +0900 Subject: [PATCH 2/2] Adding close_loop scripts, executables and tests --- .../data/scripts/maintel/close_loop_comcam.py | 27 + .../scripts/maintel/close_loop_lsstcam.py | 27 + .../ts/standardscripts/maintel/__init__.py | 3 + .../maintel/base_close_loop.py | 479 ++++++++++++++++++ .../maintel/close_loop_comcam.py | 77 +++ .../maintel/close_loop_lsstcam.py | 64 +++ tests/test_maintel_close_loop_lsstcam.py | 162 ++++++ 7 files changed, 839 insertions(+) create mode 100755 python/lsst/ts/standardscripts/data/scripts/maintel/close_loop_comcam.py create mode 100755 python/lsst/ts/standardscripts/data/scripts/maintel/close_loop_lsstcam.py create mode 100644 python/lsst/ts/standardscripts/maintel/base_close_loop.py create mode 100644 python/lsst/ts/standardscripts/maintel/close_loop_comcam.py create mode 100644 python/lsst/ts/standardscripts/maintel/close_loop_lsstcam.py create mode 100644 tests/test_maintel_close_loop_lsstcam.py diff --git a/python/lsst/ts/standardscripts/data/scripts/maintel/close_loop_comcam.py b/python/lsst/ts/standardscripts/data/scripts/maintel/close_loop_comcam.py new file mode 100755 index 00000000..f010952d --- /dev/null +++ b/python/lsst/ts/standardscripts/data/scripts/maintel/close_loop_comcam.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# This file is part of ts_standardscripts +# +# 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 +# along with this program. If not, see . + +import asyncio + +from lsst.ts.standardscripts.maintel import CloseLoopComCam + +asyncio.run(CloseLoopComCam.amain()) diff --git a/python/lsst/ts/standardscripts/data/scripts/maintel/close_loop_lsstcam.py b/python/lsst/ts/standardscripts/data/scripts/maintel/close_loop_lsstcam.py new file mode 100755 index 00000000..153a433a --- /dev/null +++ b/python/lsst/ts/standardscripts/data/scripts/maintel/close_loop_lsstcam.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# This file is part of ts_standardscripts +# +# 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 +# along with this program. If not, see . + +import asyncio + +from lsst.ts.standardscripts.maintel import CloseLoopLSSTCam + +asyncio.run(CloseLoopLSSTCam.amain()) diff --git a/python/lsst/ts/standardscripts/maintel/__init__.py b/python/lsst/ts/standardscripts/maintel/__init__.py index b447eed6..85cd6f2a 100644 --- a/python/lsst/ts/standardscripts/maintel/__init__.py +++ b/python/lsst/ts/standardscripts/maintel/__init__.py @@ -20,6 +20,9 @@ # along with this program. If not, see . from .apply_dof import * +from .base_close_loop import * +from .close_loop_comcam import * +from .close_loop_lsstcam import * from .enable_comcam import * from .enable_mtcs import * from .home_both_axes import * diff --git a/python/lsst/ts/standardscripts/maintel/base_close_loop.py b/python/lsst/ts/standardscripts/maintel/base_close_loop.py new file mode 100644 index 00000000..d0d4a2ae --- /dev/null +++ b/python/lsst/ts/standardscripts/maintel/base_close_loop.py @@ -0,0 +1,479 @@ +# This file is part of ts_externalcripts +# +# 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 +# along with this program. If not, see . + +__all__ = ["BaseCloseLoop"] + +import abc +import types +import typing + +import numpy as np +import yaml +from lsst.ts import salobj +from lsst.ts.observatory.control import BaseCamera +from lsst.ts.observatory.control.maintel.mtcs import MTCS +from lsst.ts.observatory.control.utils.enums import ClosedLoopMode, DOFName + +STD_TIMEOUT = 10 +CMD_TIMEOUT = 60 + + +class BaseCloseLoop(salobj.BaseScript, metaclass=abc.ABCMeta): + """Closed loop script. This script is used to perform measurements of + the wavefront error, then propose dof offsets based on ts_ofc. + + Parameters + ---------- + index : `int`, optional + Index of Script SAL component (default=1). + descr : `str`, optional + Short description of the script. + + Notes + ----- + **Checkpoints** + + - "Taking image...": If taking in-focus detection image. + - "[N/MAX_ITER]: Closed loop starting...": Before each closed loop + iteration, where "N" is the iteration number and "MAX_ITER" + is the maximum number of iterations. + - "[N/MAX_ITER]: Closed converged.": Once Closed Loop reaches convergence. + - "[N/MAX_ITER]: Closed applying correction.": Just before + corrections are applied. + + **Details** + + This script is used to perform measurements of the wavefront error, then + propose dof offsets based on ts_ofc. The offsets are not applied + automatically and must be turned on by the user through + apply_corrections attribute. if apply_corrections is off, the script + will take a series of intra/extra focal data instead, and the number + of pairs is the number of maximum iterations. + """ + + def __init__(self, index=1, descr="") -> None: + super().__init__( + index=index, + descr=descr, + ) + + self.mtcs = None + self._camera = None + + # The following attributes are set via the configuration + self.filter = None + + # exposure time for the intra/extra images (in seconds) + self.exposure_time = None + + # Define operation mode handler function + self.operation_model_handlers = { + ClosedLoopMode.CWFS: self.handle_cwfs_mode, + ClosedLoopMode.FAM: self.handle_fam_mode, + } + + @property + def camera(self) -> BaseCamera: + if self._camera is not None: + return self._camera + else: + raise RuntimeError("Camera not defined.") + + @camera.setter + def camera(self, value: BaseCamera | None) -> None: + self._camera = value + + @abc.abstractmethod + def configure_camera(self) -> None: + raise NotImplementedError() + + async def configure_tcs(self) -> None: + """Handle creating MTCS object and waiting for remote to start.""" + if self.mtcs is None: + self.log.debug("Creating MTCS.") + self.mtcs = MTCS( + domain=self.domain, + log=self.log, + ) + await self.mtcs.start_task + else: + self.log.debug("MTCS already defined, skipping.") + + @classmethod + def get_schema(cls) -> typing.Dict[str, typing.Any]: + schema_yaml = f""" + $schema: http://json-schema.org/draft-07/schema# + $id: https://github.com/lsst-ts/ts_standardscripts/auxtel/BaseClosedLoop.yaml + title: BaseClosedLoop v1 + description: Configuration for BaseClosedLoop Script. + type: object + properties: + mode: + description: >- + Mode to use for the script. If set to "cwfs", the script will + use the corner wavefront sensors, if set to "fam" the script + will use the full array mode. + type: string + enum: ["CWFS", "FAM"] + default: CWFS + filter: + description: Which filter to use when taking intra/extra focal images. + type: string + default: empty_1 + exposure_time: + description: The exposure time to use when taking images (sec). + type: number + default: 30. + dz: + description: De-focus to apply when acquiring the intra/extra focal images (mm). + type: number + default: 0.8 + threshold: + description: >- + DOF threshold for convergence (um). If DOF offsets are + smaller than this value, the script will stop. + type: array + items: + type: number + minimum: 0 + minItems: 50 + maxItems: 50 + default: {[0.004]*50} + max_iter: + description: >- + Maximum number of iterations. + Note, if apply_corrections is False, the script + will take [max_iter] pairs of images. + type: integer + default: 5 + program: + description: >- + Optional name of the program this dataset belongs to. + type: string + default: CWFS + reason: + description: Optional reason for taking the data. + anyOf: + - type: string + - type: "null" + default: null + used_dofs: + oneOf: + - type: array + items: + type: integer + minimum: 0 + maximum: 49 + - type: array + items: + type: string + enum: {[dof_name.name for dof_name in DOFName]} + default: [1, 2, 3, 4, 5] + apply_corrections: + description: >- + Apply OFC corrections after each iteration. + type: boolean + default: true + additionalProperties: false + """ + return yaml.safe_load(schema_yaml) + + async def configure(self, config: types.SimpleNamespace) -> None: + """Configure script. + + Parameters + ---------- + config : `types.SimpleNamespace` + Script configuration, as defined by `schema`. + """ + + # Configure tcs and camera + await self.configure_tcs() + await self.configure_camera() + + # Set mode + self.mode = getattr(ClosedLoopMode, config.mode) + + # Set filter + self.filter = config.filter + + # Set exposure time + self.exposure_time = config.exposure_time + + # Set intra/extra focal offsets + self.dz = config.dz + + # Set threshold + self.threshold = config.threshold + + # Set maximum number of iterations + self.max_iter = config.max_iter + + # Set program and reason + self.reason = config.reason + self.program = config.program + + # Set used dofs + selected_dofs = config.used_dofs + if isinstance(selected_dofs[0], str): + selected_dofs = [getattr(DOFName, dof) for dof in selected_dofs] + self.used_dofs = np.zeros(50) + self.used_dofs[selected_dofs] = 1 + + # Set apply_corrections + self.apply_corrections = config.apply_corrections + + def set_metadata(self, metadata: salobj.type_hints.BaseMsgType) -> None: + """Sets script metadata. + + Parameters + ---------- + metadata : `salobj.type_hints.BaseMsgType` + Script metadata topic. The information is set on the topic + directly. + """ + # Estimated duration is maximum number of iterations multiplied by + # the time it takes to take the data (2 images) plus estimation on + # processing the data (10s), plus time it takes to take final + # acquisition image + + metadata.duration = ( + self.camera.filter_change_timeout + + self.max_iter + * ( + self.exposure_time + + self.camera.read_out_time + + self.camera.shutter_time + ) + * (2 if self.mode == ClosedLoopMode.CWFS else 1) + + self.camera.read_out_time + + self.camera.shutter_time + ) + metadata.filter = f"{self.filter}" + + async def take_intra_extra_focal_images( + self, + ) -> typing.Tuple[typing.Any, typing.Any]: + """Take intra and extra focal images. + + Returns + ------- + intra_image : `typing.Any` + Intra focal image. + extra_image : `typing.Any` + Extra focal image. + """ + + # Take intra focal image + self.log.debug("Moving to intra-focal position") + + await self.mtcs.move_camera_hexapod(z=self.dz) + + self.log.debug("Taking intra-focal image") + + intra_image = await self.camera.take_cwfs( + exptime=self.exposure_time, + n=1, + group_id=self.group_id, + filter=self.filter, + reason="INTRA" + ("" if self.reason is None else f"_{self.reason}"), + program=self.program, + ) + + self.log.debug("Moving to extra-focal position") + + # Hexapod offsets are relative, so need to move 2x the offset + # to get from the intra- to the extra-focal position. + z_offset = -(self.dz * 2.0) + await self.mtcs.move_camera_hexapod(z=z_offset) + + self.log.debug("Taking extra-focal image") + + # Take extra-focal iamge + extra_image = await self.camera.take_cwfs( + exptime=self.exposure_time, + n=1, + group_id=self.group_id, + filter=self.filter, + reason="EXTRA" + ("" if self.reason is None else f"_{self.reason}"), + program=self.program, + ) + + return intra_image, extra_image + + async def handle_fam_mode(self) -> None: + """Handle Full Array Mode.""" + + # Take intra and extra focal images + intra_image, extra_image = await self.take_intra_extra_focal_images() + + # Set intra and extra visit id + intra_visit_id = int(intra_image[0]) + extra_visit_id = int(extra_image[0]) + + # Run WEP + self.mtcs.rem.mtaos.cmd_runWEP.set_start( + visitId=intra_visit_id, extraId=extra_visit_id, timeout=2 * CMD_TIMEOUT + ) + + async def handle_cwfs_mode(self) -> None: + """Handle CWFS mode.""" + + # Take in-focus image + image = await self.camera.take_acq( + self.exposure_time, + group_id=self.group_id, + reason="INFOCUS" + ("" if self.reason is None else f"_{self.reason}"), + program=self.program, + filter=self.filter, + ) + + # Set visit id + visit_id = int(image[0]) + + # Run WEP + self.mtcs.rem.mtaos.cmd_runWEP.set_start( + visitId=visit_id, timeout=2 * CMD_TIMEOUT + ) + + async def compute_ofc_offsets(self) -> None: + """Compute offsets using ts_ofc.""" + + # Create the config to run OFC + config = { + "filter_name": self.filter, + "comp_dof_idx": { + "m2HexPos": self.used_dofs[:5], + "camHexPos": self.used_dofs[5:10], + "M1M3Bend": self.used_dofs[10:30], + "M2Bend": self.used_dofs[30:], + }, + } + config_yaml = yaml.dump(config, default_flow_style=False) + + # Run OFC + await self.mtcs.rem.mtaos.cmd_runOFC.set_start( + config=config_yaml, timeout=CMD_TIMEOUT + ) + + # Return offsets + return await self.mtcs.rem.mtaos.evt_degreeOfFreedom.next( + flush=False, timeout=STD_TIMEOUT + ) + + async def arun(self, checkpoint: bool = False) -> None: + """Perform wavefront error measurements and DOF adjustments until the + thresholds are reached. + + Parameters + ---------- + checkpoint : `bool`, optional + Should issue checkpoints + + Raises + ------ + RuntimeError: + If coordinates are malformed. + """ + + for i in range(self.max_iter): + self.log.debug(f"Closed Loop iteration {i + 1} starting...") + + if checkpoint: + await self.checkpoint( + f"[{i + 1}/{self.max_iter}]: Closed Loop loop starting..." + ) + + await self.checkpoint(f"[{i + 1}/{self.max_iter}]: Taking image...") + + # Flush wavefront error topic + await self.mtcs.rem.mtaos.evt_wavefrontError.flush() + + # Run the operational mode handler function. + await self.operation_model_handlers[self.mode]() + + # Save the wavefront error + wavefront_error = await self.mtcs.rem.mtaos.evt_wavefrontError.next( + flush=False, timeout=STD_TIMEOUT + ) + + self.log.info( + f"Wavefront error zernike coefficients: {wavefront_error} in um." + ) + + # Compute ts_ofc offsets + dof_offset = await self.compute_ofc_offsets() + + # If apply_corrections is true, + # then we apply the corrections + if self.apply_corrections: + self.log.info("Applying corrections...") + + if checkpoint: + await self.checkpoint( + f"[{i + 1}/{self.max_iter}]: Applying correction." + ) + + # Apply ts_ofc corrections + await self.mtcs.rem.mtaos.cmd_issueCorrection.start(timeout=CMD_TIMEOUT) + + # Check if corrections have converged. If they have, then we stop. + if all(abs(dof_offset) < self.threshold): + self.log.info(f"OFC offsets are inside tolerance ({self.threshold}). ") + if checkpoint: + await self.checkpoint( + f"[{i + 1}/{self.max_iter}]: Closed Loop converged." + ) + + self.log.info("Closed Loop completed successfully!") + return + + # If we reach the maximum number of iterations without + # converging, then we stop. + self.log.warning( + f"Reached maximum iteration ({self.max_iter}) without convergence.\n" + ) + + async def assert_feasibility(self) -> None: + """Verify that the telescope and camera are in a feasible state to + execute the script. + """ + + await self.mtcs.assert_all_enabled() + await self.camera.assert_all_enabled() + + async def assert_mode_compatibility(self) -> None: + """Verify that the script mode is compatible with the camera. + Defaults to pass. It is only overriden by ComCam, since it + only allows for FAM.""" + + pass + + async def run(self) -> None: + """Execute script. + + This method simply call `arun` with `checkpoint=True`. + """ + + await self.assert_feasibility() + await self.assert_mode_compatibility() + + await self.arun(True) diff --git a/python/lsst/ts/standardscripts/maintel/close_loop_comcam.py b/python/lsst/ts/standardscripts/maintel/close_loop_comcam.py new file mode 100644 index 00000000..285e9d00 --- /dev/null +++ b/python/lsst/ts/standardscripts/maintel/close_loop_comcam.py @@ -0,0 +1,77 @@ +# This file is part of ts_externalcripts +# +# 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 +# along with this program. If not, see . + +__all__ = ["CloseLoopComCam"] + +from lsst.ts.observatory.control.maintel.comcam import ComCam +from lsst.ts.observatory.control.utils.enums import ClosedLoopMode + +from .base_close_loop import BaseCloseLoop + +STD_TIMEOUT = 10 + + +class CloseLoopComCam(BaseCloseLoop): + """Run Closed Loop with ComCam. + + Parameters + ---------- + index : `int`, optional + Index of Script SAL component (default=1). + remotes : `bool`, optional + Should the remotes be created (default=True)? For unit testing this + can be set to False, which allows one to mock the remotes behaviour. + descr : `str`, optional + Short description of the script. + """ + + def __init__(self, index, descr="") -> None: + super().__init__(index=index, descr=descr) + + self.config = None + + self._camera = None + + async def configure_camera(self) -> None: + """Handle creating Camera object and waiting for remote to start.""" + if self._camera is None: + self.log.debug("Creating Camera.") + + self._camera = ComCam( + self.domain, + log=self.log, + tcs_ready_to_take_data=self.mtcs.ready_to_take_data, + ) + await self._camera.start_task + else: + self.log.debug("Camera already defined, skipping.") + + async def assert_mode_compatibility(self) -> None: + """Assert that the mode is compatible with ComCam. + + Raises + ------ + RuntimeError + If the mode is not compatible with ComCam. + """ + + if self.mode == ClosedLoopMode.CWFS: + raise RuntimeError("ComCam does not support CWFS mode.") diff --git a/python/lsst/ts/standardscripts/maintel/close_loop_lsstcam.py b/python/lsst/ts/standardscripts/maintel/close_loop_lsstcam.py new file mode 100644 index 00000000..0c973db7 --- /dev/null +++ b/python/lsst/ts/standardscripts/maintel/close_loop_lsstcam.py @@ -0,0 +1,64 @@ +# This file is part of ts_externalcripts +# +# 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 +# along with this program. If not, see . + +__all__ = ["CloseLoopLSSTCam"] + +from lsst.ts.observatory.control.maintel.lsstcam import LSSTCam + +from .base_close_loop import BaseCloseLoop + +STD_TIMEOUT = 10 + + +class CloseLoopLSSTCam(BaseCloseLoop): + """Run closed loop with LSSTCam. + + Parameters + ---------- + index : `int`, optional + Index of Script SAL component (default=1). + remotes : `bool`, optional + Should the remotes be created (default=True)? For unit testing this + can be set to False, which allows one to mock the remotes behaviour. + descr : `str`, optional + Short description of the script. + """ + + def __init__(self, index, descr="") -> None: + super().__init__(index=index, descr=descr) + + self.config = None + + self._camera = None + + async def configure_camera(self) -> None: + """Handle creating Camera object and waiting for remote to start.""" + if self._camera is None: + self.log.debug("Creating Camera.") + + self._camera = LSSTCam( + self.domain, + log=self.log, + tcs_ready_to_take_data=self.mtcs.ready_to_take_data, + ) + await self._camera.start_task + else: + self.log.debug("Camera already defined, skipping.") diff --git a/tests/test_maintel_close_loop_lsstcam.py b/tests/test_maintel_close_loop_lsstcam.py new file mode 100644 index 00000000..517a1b0b --- /dev/null +++ b/tests/test_maintel_close_loop_lsstcam.py @@ -0,0 +1,162 @@ +# This file is part of ts_standardscripts +# +# 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 +# along with this program. If not, see . + +import asyncio +import random +import unittest + +import numpy as np +from lsst.ts import standardscripts +from lsst.ts.observatory.control.maintel.lsstcam import LSSTCam, LSSTCamUsages +from lsst.ts.observatory.control.maintel.mtcs import MTCS, MTCSUsages +from lsst.ts.observatory.control.utils.enums import ClosedLoopMode +from lsst.ts.standardscripts.maintel import CloseLoopLSSTCam + +random.seed(47) # for set_random_lsst_dds_partition_prefix + + +class TestCloseLoopLSSTCam( + standardscripts.BaseScriptTestCase, unittest.IsolatedAsyncioTestCase +): + async def basic_make_script(self, index): + self.script = CloseLoopLSSTCam(index=index) + + self.script.mtcs = MTCS( + domain=self.script.domain, + intended_usage=MTCSUsages.DryTest, + log=self.script.log, + ) + + self.script._camera = LSSTCam( + domain=self.script.domain, + intended_usage=LSSTCamUsages.DryTest, + log=self.script.log, + ) + + # MTCS mocks + self.script.mtcs.assert_all_enabled = unittest.mock.AsyncMock() + self.script.mtcs.move_camera_hexapod = unittest.mock.AsyncMock() + + # MTAOS mocks + self.script.mtcs.rem.mtaos = unittest.mock.AsyncMock() + self.script.mtcs.rem.mtaos.configure_mock( + **{ + "cmd_runWEP.set_start": unittest.mock.AsyncMock(), + "cmd_runOFC.set_start": self.get_offsets, + "evt_wavefrontError.next": self.return_zernikes, + "evt_degreeOfFreedom.next": self.return_offsets, + "cmd_issueCorrection.start": self.apply_offsets, + "evt_wavefrontError.flush": unittest.mock.AsyncMock(), + } + ) + + # Camera mocks + self.script.camera.assert_all_enabled = unittest.mock.AsyncMock() + self.script.camera.take_acq = unittest.mock.AsyncMock() + self.script.camera.take_cwfs = unittest.mock.AsyncMock() + + self.script.assert_mode_compatibility = unittest.mock.AsyncMock() + + self.state_0 = np.zeros(50) + self.state_0[:5] += 1 + + self.corrections = np.zeros(50) + return (self.script,) + + async def return_zernikes(self, *args, **kwargs): + return np.random.rand(19) + + async def return_offsets(self, *args, **kwargs): + return self.corrections + + async def apply_offsets(self, *args, **kwags): + await asyncio.sleep(0.5) + self.state_0 += self.corrections + + async def get_offsets(self, *args, **kwags): + # return corrections to be non zero the first time this is called + await asyncio.sleep(0.5) + self.corrections = np.zeros(50) + + if any(self.state_0): + self.corrections[:5] -= 0.5 + + async def test_configure(self): + # Try configure with minimum set of parameters declared + async with self.make_script(): + mode = "CWFS" + max_iter = 10 + exposure_time = 30 + filter = "r" + used_dofs = ["M2_dz", "M2_dx", "M2_dy", "M2_rx", "M2_ry"] + threshold = [0.005] * 50 + apply_corrections = True + + await self.configure_script( + mode=mode, + max_iter=max_iter, + exposure_time=exposure_time, + filter=filter, + used_dofs=used_dofs, + threshold=threshold, + apply_corrections=apply_corrections, + ) + + assert self.script.mode == ClosedLoopMode.CWFS + assert self.script.max_iter == max_iter + assert self.script.exposure_time == exposure_time + assert self.script.filter == filter + + configured_dofs = np.zeros(50) + configured_dofs[:5] += 1 + assert all(self.script.used_dofs == configured_dofs) + assert self.script.threshold == threshold + assert self.script.apply_corrections == apply_corrections + + async def test_run(self): + # Start the test itself + async with self.make_script(): + await self.configure_script( + max_iter=10, + filter="r", + used_dofs=[0, 1, 2, 3, 4], + ) + + # Run the script + await self.run_script() + + assert all(self.state_0 == np.zeros(50)) + + async def test_executable_close_loop_lsstcam(self) -> None: + """Test that the script is executable for LSSTCam.""" + scripts_dir = standardscripts.get_scripts_dir() + script_path = scripts_dir / "maintel" / "close_loop_lsstcam.py" + await self.check_executable(script_path) + + async def test_executable_close_loop_comcam(self) -> None: + """Test that the script is executable for ComCam.""" + scripts_dir = standardscripts.get_scripts_dir() + script_path = scripts_dir / "maintel" / "close_loop_comcam.py" + await self.check_executable(script_path) + + +if __name__ == "__main__": + unittest.main()