diff --git a/doc/news/DM-47627.feature.rst b/doc/news/DM-47627.feature.rst new file mode 100644 index 000000000..707c471aa --- /dev/null +++ b/doc/news/DM-47627.feature.rst @@ -0,0 +1,2 @@ +Add a `move_p2p_diamond.py` script. +The script moves the Simonyi Telescope in a diamond pattern around each grid position provided by the user. \ No newline at end of file diff --git a/python/lsst/ts/externalscripts/data/scripts/maintel/tma/short_long_slews.py b/python/lsst/ts/externalscripts/data/scripts/maintel/tma/short_long_slews.py new file mode 100755 index 000000000..9792330c9 --- /dev/null +++ b/python/lsst/ts/externalscripts/data/scripts/maintel/tma/short_long_slews.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.tma import ShortLongSlews + +asyncio.run(ShortLongSlews.amain()) diff --git a/python/lsst/ts/externalscripts/maintel/tma/__init__.py b/python/lsst/ts/externalscripts/maintel/tma/__init__.py index 85e418f6a..3883e4906 100644 --- a/python/lsst/ts/externalscripts/maintel/tma/__init__.py +++ b/python/lsst/ts/externalscripts/maintel/tma/__init__.py @@ -22,3 +22,4 @@ from .random_walk import * from .random_walk_and_take_image_gencam import * from .serpent_walk import * +from .short_long_slews import * diff --git a/python/lsst/ts/externalscripts/maintel/tma/short_long_slews.py b/python/lsst/ts/externalscripts/maintel/tma/short_long_slews.py new file mode 100755 index 000000000..cf41d0e4b --- /dev/null +++ b/python/lsst/ts/externalscripts/maintel/tma/short_long_slews.py @@ -0,0 +1,326 @@ +# 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__ = ["ShortLongSlews"] + +import asyncio + +import yaml +from lsst.ts.idl.enums.Script import ScriptState +from lsst.ts.observatory.control.maintel.mtcs import MTCS, MTCSUsages +from lsst.ts.salobj import type_hints +from lsst.ts.standardscripts.base_block_script import BaseBlockScript + + +class ShortLongSlews(BaseBlockScript): + """Execute short and long slews for a grid of azimuths and elevations. + + Overview: + + This script performs a series of point-to-point (P2P) telescope + movements forming a diamond pattern around each user-defined grid + position with short and long slews. It is designed for dynamic tests + and performance evaluations, reproducing patterns used in specific + engineering tasks (e.g., BLOCK-T293 and T294). + + Execution Order: + + The script processes the grid positions by iterating over `grid_az` + first, then `grid_el`. For each azimuth value in `grid_az`, it executes + short and long slews at all elevation values in the `grid_el` list. + + User Guidance: + + - Grid Selection: Choose `grid_az` and `grid_el` values within the + telescope's operational limits. Ensure that cumulative movements + from the slews do not exceed these limits. + - Direction Control: Use the `direction` parameter to specify the initial + movement direction of the slew. This is particularly useful when starting + near operational limits (e.g., high elevations), as it allows you to + avoid exceeding those limits by moving in the opposite direction. + - Operational Limits: Azimuth: -180° to +180°, Elevation: 15° to 86.5°. + """ + + def __init__(self, index: int) -> None: + super().__init__(index, descr="Execute a sequence of short and long slews.") + self.mtcs = None + self.grid = dict() + self.pause_for = 0.0 + self.move_timeout = 120.0 + self.direction = "forward" # Default direction + + # Slews definitions + self.AZ_LONG_SLEW = 24 # deg + self.AZ_SHORT_SLEW = 3.5 # deg + self.EL_LONG_SLEW = 12 # deg + self.EL_SHORT_SLEW = 3.5 # deg + + self.EL_DIAG = 0.5 + self.AZ_DIAG = (1 - self.EL_DIAG**2) ** 0.5 + + # Telescope limits + self.max_el = 86.0 + self.min_el = 15 + self.max_az = 180 + self.min_az = -180 + + # This will hold all the precomputed diamond sequences + self.diamond_sequences = [] + + async def configure_tcs(self) -> None: + """Initialize the MTCS component if not already done.""" + if self.mtcs is None: + self.log.debug("Creating MTCS.") + self.mtcs = MTCS( + domain=self.domain, intended_usage=MTCSUsages.Slew, log=self.log + ) + await self.mtcs.start_task + else: + self.log.debug("MTCS already defined, skipping.") + + @classmethod + def get_schema(cls): + # You can retain or update the schema as before + schema_yaml = """ + $schema: http://json-schema.org/draft-07/schema# + title: ShortLongSlews Configuration + type: object + properties: + grid_az: + description: >- + Azimuth coordinate(s) in degrees where the short and long slews will be executed. + Can be a single number or a list of numbers. Ensure that the azimuth values, + along with the cumulative offsets from the slews, remain within the telescope's + operational limits. + anyOf: + - type: number + - type: array + items: + type: number + minItems: 1 + grid_el: + description: >- + Elevation coordinate(s) in degrees where the short and long slews will be executed. + Can be a single number or a list of numbers. Ensure that the elevation values, + along with the cumulative offsets from the slews, remain within the telescope's + operational limits. + anyOf: + - type: number + - type: array + items: + type: number + minItems: 1 + direction: + description: >- + Direction in which to start the slews. Options are 'forward' or 'backward'. + In 'forward' mode, the pattern starts with positive offsets; in 'backward' + mode, it starts with negative offsets. Default is 'forward'. + type: string + enum: + - forward + - backward + default: forward + pause_for: + description: Pause duration between movements in seconds. + type: number + default: 0.0 + move_timeout: + description: Timeout for each move command. + type: number + default: 120.0 + ignore: + description: CSCs to ignore in status check. + type: array + items: + type: string + required: + - grid_az + - grid_el + """ + return yaml.safe_load(schema_yaml) + + async def configure(self, config): + """Configures the script based on user-defined grid and settings.""" + + # Ensure grid_az and grid_el are arrays + grid_az = ( + config.grid_az if isinstance(config.grid_az, list) else [config.grid_az] + ) + grid_el = ( + config.grid_el if isinstance(config.grid_el, list) else [config.grid_el] + ) + + self.grid["azel"] = dict(az=grid_az, el=grid_el) + self.pause_for = config.pause_for + self.move_timeout = config.move_timeout + self.direction = config.direction # Read the direction property + + # Generate and validate all positions + self.generate_and_validate_positions() + + await self.configure_tcs() + for comp in getattr(config, "ignore", []): + if comp not in self.mtcs.components_attr: + self.log.warning(f"Ignoring unknown component {comp}.") + else: + self.log.debug(f"Ignoring component {comp}.") + setattr(self.mtcs.check, comp, False) + + await super().configure(config=config) + + def set_metadata(self, metadata: type_hints.BaseMsgType) -> None: + """Set the estimated duration based on the number of positions.""" + num_positions = sum(len(seq["positions"]) for seq in self.diamond_sequences) + estimated_duration = num_positions * (self.move_timeout + self.pause_for) + metadata.duration = estimated_duration + + def generate_diamond_pattern(self, az0, el0): + """ + Generate a diamond pattern of azimuth and elevation coordinates + with short and long slews. + + Parameters: + az0 (float): Initial azimuth coordinate. + el0 (float): Initial elevation coordinate. + + Returns: + - `positions` (list of tuple): List of positions forming + a kind of a diamond pattern with short and long slews. + + Pattern Details: + - The pattern consists of cumulative movements starting from the + initial position `(az0, el0)`. + - Movements include long and short slews in azimuth and elevation, + as well as diagonal movements. + - The sequence is designed to test the telescope's dynamic performance. + - The `direction` parameter controls whether the pattern starts + with positive or negative offsets. + + Notes: + - When `direction` is set to `'backward'`, all movement offsets are + reversed, allowing the pattern to start in the opposite direction. + - This is useful for avoiding telescope limits when starting near the + operational boundaries. + - The diamond pattern created here aims to reproduce the pattern used + for dynamic tests done under T293 abd T294 + """ + + # Define the slew offsets for the diamond pattern to match dynamic + # tests done under T293, T294 + azel_slew_offsets = [ + (0, 0), + (0, +self.EL_LONG_SLEW), + (0, -self.EL_LONG_SLEW), + (+self.AZ_LONG_SLEW, 0), + (-self.AZ_LONG_SLEW, 0), + (0, +self.EL_SHORT_SLEW), + (0, -self.EL_SHORT_SLEW), + (+self.AZ_SHORT_SLEW, 0), + (-self.AZ_SHORT_SLEW, 0), + (+self.AZ_LONG_SLEW / 2 * self.AZ_DIAG, +self.EL_LONG_SLEW * self.EL_DIAG), + (-self.AZ_LONG_SLEW / 2 * self.AZ_DIAG, +self.EL_LONG_SLEW * self.EL_DIAG), + (-self.AZ_LONG_SLEW / 2 * self.AZ_DIAG, -self.EL_LONG_SLEW * self.EL_DIAG), + (+self.AZ_LONG_SLEW / 2 * self.AZ_DIAG, -self.EL_LONG_SLEW * self.EL_DIAG), + (+self.AZ_SHORT_SLEW * self.AZ_DIAG, +self.EL_SHORT_SLEW * self.EL_DIAG), + (-self.AZ_SHORT_SLEW * self.AZ_DIAG, +self.EL_SHORT_SLEW * self.EL_DIAG), + (-self.AZ_SHORT_SLEW * self.AZ_DIAG, -self.EL_SHORT_SLEW * self.EL_DIAG), + (+self.AZ_SHORT_SLEW * self.AZ_DIAG, -self.EL_SHORT_SLEW * self.EL_DIAG), + ] + + # Adjust offsets based on the specified direction + if self.direction == "backward": + azel_slew_offsets = [ + (-az_offset, -el_offset) for az_offset, el_offset in azel_slew_offsets + ] + + az = az0 + el = el0 + positions = [] + for az_offset, el_offset in azel_slew_offsets: + az += az_offset + el += el_offset + positions.append((round(az, 2), round(el, 2))) + return positions + + def generate_and_validate_positions(self): + """ + Generate all positions for the grid points and their diamond patterns, + validate them, and store them for later use. + """ + + for az0 in self.grid["azel"]["az"]: + for el0 in self.grid["azel"]["el"]: + positions = self.generate_diamond_pattern(az0, el0) + for az, el in positions: + if not (self.min_az <= az <= self.max_az): + raise ValueError( + f"Azimuth {az} out of limits ({self.min_az}, {self.max_az}). " + f"Ensure that the entire movement range stays within the " + f"allowed azimuth limits. Adjust initial azimuth (Az = {az0}) " + f"in your grid accordingly." + ) + if not (self.min_el <= el <= self.max_el): + raise ValueError( + f"Elevation {el} out of limits ({self.min_el}, {self.max_el}). " + f"Ensure that the entire movement range stays within the allowed " + f"elevation limits. Adjust initial elevation (El = {el0}) in your " + f"grid accordingly." + ) + # Add the sequence to the list + self.diamond_sequences.append( + {"az0": az0, "el0": el0, "positions": positions} + ) + + async def move_to_position(self, az, el): + """Move the telescope to the specified azimuth and elevation.""" + await self.mtcs.move_p2p_azel(az=az, el=el, timeout=self.move_timeout) + + async def run_block(self): + """Execute the precomputed positions.""" + total_diamonds = len(self.diamond_sequences) + for i, sequence in enumerate(self.diamond_sequences): + az0 = sequence["az0"] + el0 = sequence["el0"] + positions = sequence["positions"] + # Output checkpoint message + await self.checkpoint( + f"Starting sequence {i+1}/{total_diamonds} at grid point (Az={az0}, El={el0})" + ) + total_positions = len(positions) + for j, (az, el) in enumerate(positions, start=1): + self.log.info( + f"Moving to position {j}/{total_positions} of grid sequence {i+1}: Az={az}, El={el}" + ) + await self.move_to_position(az, el) + self.log.info(f"Pausing for {self.pause_for}s.") + await asyncio.sleep(self.pause_for) + + async def cleanup(self): + """Handle cleanup in case of abnormal termination.""" + if self.state.state != ScriptState.ENDING: + self.log.warning("Terminating abnormally, stopping telescope.") + try: + await self.mtcs.rem.mtmount.cmd_stop.start( + timeout=self.mtcs.long_timeout + ) + except asyncio.TimeoutError: + self.log.exception("Stop command timed out during cleanup.") + except Exception: + self.log.exception("Unexpected error during telescope stop.") diff --git a/tests/maintel/tma/test_short_long_slews.py b/tests/maintel/tma/test_short_long_slews.py new file mode 100644 index 000000000..90fa1b29d --- /dev/null +++ b/tests/maintel/tma/test_short_long_slews.py @@ -0,0 +1,230 @@ +# 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 + +import contextlib +import unittest +from unittest.mock import AsyncMock, MagicMock + +import pytest +from lsst.ts import externalscripts, salobj, standardscripts +from lsst.ts.externalscripts.maintel.tma import ShortLongSlews + + +class TestShortLongSlews( + standardscripts.BaseScriptTestCase, unittest.IsolatedAsyncioTestCase +): + async def basic_make_script(self, index): + self.script = ShortLongSlews(index=index) + return (self.script,) + + @contextlib.asynccontextmanager + async def make_dry_script(self): + async with self.make_script(): + # Mock the mtcs component + self.script.mtcs = AsyncMock() + self.script.mtcs.components_attr = ["mtm1m3"] + # Mock the mtcs.check object + self.script.mtcs.check = MagicMock() + + yield + + async def test_config_ignore(self) -> None: + async with self.make_dry_script(): + grid_az = 0.0 + grid_el = 60.0 + + await self.configure_script( + grid_az=grid_az, grid_el=grid_el, ignore=["mtm1m3", "no_comp"] + ) + assert self.script.mtcs.check.mtm1m3 is False + self.script.mtcs.check.no_comp.assert_not_called() + + async def test_config_directions(self) -> None: + """Test that the direction parameter is correctly set + and positions differ.""" + async with self.make_dry_script(): + grid_az = 30.0 + grid_el = 60.0 + + # Test with direction='forward' + await self.configure_script( + grid_az=grid_az, + grid_el=grid_el, + direction="forward", + ) + assert self.script.direction == "forward" + positions_forward = self.script.generate_diamond_pattern( + az0=grid_az, el0=grid_el + ) + + # Test with direction='backward' + await self.configure_script( + grid_az=grid_az, + grid_el=grid_el, + direction="backward", + ) + assert self.script.direction == "backward" + positions_backward = self.script.generate_diamond_pattern( + az0=grid_az, el0=grid_el + ) + + # The positions should not be the same + assert positions_forward != positions_backward + + # Compare the movement deltas to ensure they are opposite + for i in range(1, len(positions_forward)): + az_fwd_prev, el_fwd_prev = positions_forward[i - 1] + az_fwd_curr, el_fwd_curr = positions_forward[i] + delta_az_fwd = az_fwd_curr - az_fwd_prev + delta_el_fwd = el_fwd_curr - el_fwd_prev + + az_bwd_prev, el_bwd_prev = positions_backward[i - 1] + az_bwd_curr, el_bwd_curr = positions_backward[i] + delta_az_bwd = az_bwd_curr - az_bwd_prev + delta_el_bwd = el_bwd_curr - el_bwd_prev + + # Check that the movement deltas are opposite + assert delta_az_fwd == pytest.approx(-delta_az_bwd) + assert delta_el_fwd == pytest.approx(-delta_el_bwd) + + async def test_config_grid_az_el_scalars(self) -> None: + async with self.make_dry_script(): + grid_az = 30.0 + grid_el = 60.0 + await self.configure_script( + grid_az=grid_az, + grid_el=grid_el, + ) + assert self.script.grid["azel"]["az"] == [grid_az] + assert self.script.grid["azel"]["el"] == [grid_el] + assert self.script.pause_for == 0.0 + assert self.script.move_timeout == 120.0 + assert self.script.direction == "forward" # Default value + + async def test_config_az_el_arrays(self) -> None: + async with self.make_dry_script(): + grid_az = [30.0] + grid_el = [45.0, 45.0, 35.0] + await self.configure_script( + grid_az=grid_az, + grid_el=grid_el, + ) + # assert config_tcs was awaited once + assert self.script.grid["azel"]["az"] == grid_az + assert self.script.grid["azel"]["el"] == grid_el + assert self.script.pause_for == 0.0 + assert self.script.move_timeout == 120.0 + + async def test_config_pause_for_move_timeout(self) -> None: + async with self.make_dry_script(): + grid_az = 30.0 + grid_el = [60.0] + pause_for = 10.0 + move_timeout = 200.0 + await self.configure_script( + grid_az=grid_az, + grid_el=grid_el, + pause_for=pause_for, + move_timeout=move_timeout, + ) + assert self.script.pause_for == pause_for + assert self.script.move_timeout == move_timeout + + async def test_az_outside_limits(self): + async with self.make_dry_script(): + # Use an azimuth value beyond the maximum limit + grid_az = self.script.max_az + 10 # Exceeds max azimuth limit + grid_el = 60.0 # Valid elevation within limits + with pytest.raises( + salobj.ExpectedError, match=f"Azimuth {grid_az} out of limits" + ): + await self.configure_script(grid_az=grid_az, grid_el=grid_el) + + async def test_el_outside_limits(self): + async with self.make_dry_script(): + grid_az = 30.0 # Valid azimuth within limits + # Use an elevation value below the minimum limit + grid_el = self.script.min_el - 5 # Below min elevation limit + with pytest.raises( + salobj.ExpectedError, match=f"Elevation {grid_el} out of limits" + ): + await self.configure_script(grid_az=grid_az, grid_el=grid_el) + + async def test_run_block(self): + async with self.make_dry_script(): + grid_az = [-75] + grid_el = [35.0, 55.0] + pause_for = 1.0 + move_timeout = 120.0 + await self.configure_script( + grid_az=grid_az, + grid_el=grid_el, + pause_for=pause_for, + move_timeout=move_timeout, + ) + + for direction in ["forward", "backward"]: + await self.configure_script( + grid_az=grid_az, + grid_el=grid_el, + pause_for=pause_for, + move_timeout=move_timeout, + direction=direction, + ) + + # Mock move_to_position to prevent actual calls + self.script.move_to_position = AsyncMock() + # Mock checkpoint to prevent actual calls + self.script.checkpoint = AsyncMock() + + await self.script.run_block() + + # Calculate expected number of diamond sequences and positions + total_sequences = len(self.script.diamond_sequences) + expected_total_positions = sum( + len(seq["positions"]) for seq in self.script.diamond_sequences + ) + + # Verify checkpoint messages + assert self.script.checkpoint.call_count == total_sequences + + # Verify move_to_position calls + assert ( + self.script.move_to_position.await_count == expected_total_positions + ) + + # Optionally, check the specific calls + expected_calls = [] + for sequence in self.script.diamond_sequences: + for position in sequence["positions"]: + expected_calls.append( + unittest.mock.call(position[0], position[1]) + ) + self.script.move_to_position.assert_has_awaits(expected_calls) + + # Reset mocks for the next iteration + self.script.move_to_position.reset_mock() + self.script.checkpoint.reset_mock() + + async def test_executable(self): + scripts_dir = externalscripts.get_scripts_dir() + script_path = scripts_dir / "maintel" / "tma" / "short_long_slews.py" + await self.check_executable(script_path)