From 505fbf964e24af533e45037e42b6da7b7c07fabf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20S=C3=A1nchez-Gallego?= Date: Wed, 29 May 2024 16:40:30 -0700 Subject: [PATCH] Allow aborting exposures (#20) * Initial work * Revert default exposure times * Merge branch 'main' into albireox/abort-exposures * Full implementation * Merge branch 'main' into albireox/abort-exposures * Make abort-exposures unique * Various fixes and wait for abort to complete * Add tests * Update changelog * More tests --- CHANGELOG.md | 7 ++ src/hal/actor/commands/__init__.py | 1 + src/hal/actor/commands/abort_exposures.py | 76 +++++++++++++++ src/hal/helpers/apogee.py | 10 ++ src/hal/helpers/boss.py | 19 +++- tests/actor/__init__.py | 0 tests/{ => actor}/test_actor.py | 0 tests/actor/test_command_abort_exposures.py | 100 ++++++++++++++++++++ 8 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 src/hal/actor/commands/abort_exposures.py create mode 100644 tests/actor/__init__.py rename tests/{ => actor}/test_actor.py (100%) create mode 100644 tests/actor/test_command_abort_exposures.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d7d9c5..595091a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Next version + +### 🚀 New + +* [#20](https://github.com/sdss/HAL/pull/20) New `abort-exposures` command will stop the `expose` macro, if running, and smoothly (hopfully) stop any ongoing APOGEE and BOSS exposures. + + ## 1.2.2 - May 28th, 2024 ### 🔧 Fixed diff --git a/src/hal/actor/commands/__init__.py b/src/hal/actor/commands/__init__.py index b65a15e..a28960e 100644 --- a/src/hal/actor/commands/__init__.py +++ b/src/hal/actor/commands/__init__.py @@ -106,6 +106,7 @@ def fail_if_running_macro(command: HALCommandType): return True +from .abort_exposures import * from .auto_pilot import * from .bypass import * from .calibrations import * diff --git a/src/hal/actor/commands/abort_exposures.py b/src/hal/actor/commands/abort_exposures.py new file mode 100644 index 0000000..3233895 --- /dev/null +++ b/src/hal/actor/commands/abort_exposures.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2024-05-26 +# @Filename: abort_exposures.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +import asyncio + +from typing import TYPE_CHECKING + +from clu.parsers.click import unique + +from hal.macros.expose import ExposeMacro + +from . import hal_command_parser + + +if TYPE_CHECKING: + from hal.actor import HALCommandType + + +__all__ = ["abort_exposures"] + + +async def wait_until_idle(command: HALCommandType): + """Waits until all cameras are idle.""" + + while True: + await asyncio.sleep(0.5) + + if command.actor.helpers.apogee.is_exposing(): + continue + + if command.actor.helpers.boss.is_exposing(reading_ok=False): + continue + + break + + +@hal_command_parser.command(name="abort-exposures") +@unique() +async def abort_exposures(command: HALCommandType): + """Aborts ongoing exposures..""" + + expose_macro = command.actor.helpers.macros["expose"] + assert isinstance(expose_macro, ExposeMacro) + + if expose_macro.running: + command.warning("Cancelling the expose macro.") + expose_macro.cancel(now=True) + + command.warning("Aborting ongoing exposures.") + + tasks = [ + command.actor.helpers.apogee.abort(command), + command.actor.helpers.boss.abort(command), + ] + + results = await asyncio.gather(*tasks, return_exceptions=True) + for iresult, result in enumerate(results): + instrument = ["APOGEE", "BOSS"][iresult] + if isinstance(result, Exception): + return command.fail(f"Failed to abort {instrument} exposure: {result!s}") + elif result is not True: + return command.fail(f"Unknown error while aborting {instrument} exposure.") + else: + continue + + command.info("Waiting until cameras are idle.") + await wait_until_idle(command) + + return command.finish(text="Exposures have been aborted.") diff --git a/src/hal/helpers/apogee.py b/src/hal/helpers/apogee.py index 942dd57..be66589 100644 --- a/src/hal/helpers/apogee.py +++ b/src/hal/helpers/apogee.py @@ -303,6 +303,16 @@ async def expose_dither_pair( await cancel_task(self._exposure_time_remaining_timer) + async def abort(self, command: HALCommandType): + """Aborts the ongoing exposure.""" + + if not self.is_exposing(): + return True + + await self._send_command(command, "apogee", "expose stop", time_limit=60) + + return True + class APOGEEGangHelper: """Helper for the APOGEE gang connector.""" diff --git a/src/hal/helpers/boss.py b/src/hal/helpers/boss.py index 5dba0ec..03f70f0 100644 --- a/src/hal/helpers/boss.py +++ b/src/hal/helpers/boss.py @@ -72,7 +72,7 @@ def get_exposure_state(self): return exposure_state - def is_exposing(self): + def is_exposing(self, reading_ok: bool = True): """Returns `True` if the BOSS spectrograph is currently exposing.""" state = self.get_exposure_state() @@ -80,6 +80,8 @@ def is_exposing(self): if self.actor.observatory == "APO": if state in ["idle", "aborted"]: return False + if reading_ok and state in ["reading", "prereading"]: + return False else: if "IDLE" in state.value and "READOUT_PENDING" not in state.value: return False @@ -248,3 +250,18 @@ async def readout(self, command: HALCommandType): ) self.clear_readout() + + async def abort(self, command: HALCommandType): + """Aborts the ongoing exposure.""" + + if not self.is_exposing(): + command.warning("No BOSS exposure to abort.") + return True + + if self.actor.observatory == "LCO": + await self._send_command(command, "yao", "abort --reset", time_limit=60) + else: + await self._send_command(command, "boss", "exposure abort", time_limit=60) + await self._send_command(command, "boss", "clearExposure", time_limit=30) + + return True diff --git a/tests/actor/__init__.py b/tests/actor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_actor.py b/tests/actor/test_actor.py similarity index 100% rename from tests/test_actor.py rename to tests/actor/test_actor.py diff --git a/tests/actor/test_command_abort_exposures.py b/tests/actor/test_command_abort_exposures.py new file mode 100644 index 0000000..f6699d8 --- /dev/null +++ b/tests/actor/test_command_abort_exposures.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# @Author: José Sánchez-Gallego (gallegoj@uw.edu) +# @Date: 2024-05-29 +# @Filename: test_command_abort_exposures.py +# @License: BSD 3-clause (http://www.opensource.org/licenses/BSD-3-Clause) + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from hal.exceptions import HALError + + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + from hal.actor import HALActor + + +@pytest.mark.parametrize("observatory", ["LCO", "APO"]) +async def test_abort_exposures( + actor: HALActor, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + observatory: str, +): + apogee = actor.helpers.apogee + boss = actor.helpers.boss + + expose_macro = actor.helpers.macros["expose"] + monkeypatch.setattr(expose_macro, "_running", True) + cancel_mock = mocker.patch.object(expose_macro, "cancel") + + monkeypatch.setattr(actor, "observatory", observatory) + + mocker.patch.object(apogee, "is_exposing", side_effect=[True, True, False, False]) + mocker.patch.object(boss, "is_exposing", side_effect=[True, True, False]) + + cmd = await actor.invoke_mock_command("abort-exposures") + await cmd + + assert cmd.status.did_succeed + cancel_mock.assert_called_once() + + +async def test_abort_exposures_no_exposure_to_abort( + actor: HALActor, + mocker: MockerFixture, +): + apogee = actor.helpers.apogee + boss = actor.helpers.boss + + mocker.patch.object(apogee, "is_exposing", side_effect=[False, False]) + mocker.patch.object(boss, "is_exposing", side_effect=[False, False]) + + cmd = await actor.invoke_mock_command("abort-exposures") + await cmd + + assert cmd.status.did_succeed + + +async def test_abort_exposures_abort_fails(actor: HALActor, mocker: MockerFixture): + apogee = actor.helpers.apogee + boss = actor.helpers.boss + + mocker.patch.object(apogee, "is_exposing", side_effect=[True, False]) + mocker.patch.object(boss, "is_exposing", side_effect=[True, False]) + + mocker.patch.object(apogee, "abort", side_effect=HALError("abort failed")) + + cmd = await actor.invoke_mock_command("abort-exposures") + await cmd + + assert cmd.status.did_fail + error = cmd.replies[-1].message["error"] + assert "Failed to abort" in error + + +async def test_abort_exposures_abort_fails_unknown( + actor: HALActor, + mocker: MockerFixture, +): + apogee = actor.helpers.apogee + boss = actor.helpers.boss + + mocker.patch.object(apogee, "is_exposing", return_value=True) + mocker.patch.object(boss, "is_exposing", return_value=True) + + mocker.patch.object(boss, "abort", return_value=False) + + cmd = await actor.invoke_mock_command("abort-exposures") + await cmd + + assert cmd.status.did_fail + error = cmd.replies[-1].message["error"] + assert "Unknown error" in error