Skip to content

Commit

Permalink
Allow aborting exposures (#20)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
albireox authored May 29, 2024
1 parent 874adfa commit 505fbf9
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 1 deletion.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions src/hal/actor/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
76 changes: 76 additions & 0 deletions src/hal/actor/commands/abort_exposures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego ([email protected])
# @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.")
10 changes: 10 additions & 0 deletions src/hal/helpers/apogee.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
19 changes: 18 additions & 1 deletion src/hal/helpers/boss.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,16 @@ 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()

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
Expand Down Expand Up @@ -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
Empty file added tests/actor/__init__.py
Empty file.
File renamed without changes.
100 changes: 100 additions & 0 deletions tests/actor/test_command_abort_exposures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# @Author: José Sánchez-Gallego ([email protected])
# @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

0 comments on commit 505fbf9

Please sign in to comment.