diff --git a/pyobs/background_task.py b/pyobs/background_task.py new file mode 100644 index 00000000..0ae82f11 --- /dev/null +++ b/pyobs/background_task.py @@ -0,0 +1,39 @@ +import asyncio +import logging +from typing import Optional, Coroutine, Any, Callable + +from pyobs.utils.exceptions import SevereError + +log = logging.getLogger(__name__) + + +class BackgroundTask: + def __init__(self, func: Callable[..., Coroutine[Any, Any, None]], restart: bool) -> None: + self._func: Callable[..., Coroutine[Any, Any, None]] = func + self._restart: bool = restart + self._task: Optional[asyncio.Future] = None + + def start(self) -> None: + self._task = asyncio.create_task(self._func()) + self._task.add_done_callback(self._callback_function) + + def _callback_function(self, args=None) -> None: + try: + exception = self._task.exception() + except asyncio.CancelledError: + return + + if isinstance(exception, SevereError): + raise exception + elif exception is not None: + log.error("Exception %s in task %s.", exception, self._func.__name__) + + if self._restart: + log.error("Background task for %s has died, restarting...", self._func.__name__) + self.start() + else: + log.error("Background task for %s has died, quitting...", self._func.__name__) + + def stop(self) -> None: + if self._task is not None: + self._task.cancel() diff --git a/pyobs/images/processors/misc/__init__.py b/pyobs/images/processors/misc/__init__.py index a4cc8cd3..af6b02dc 100644 --- a/pyobs/images/processors/misc/__init__.py +++ b/pyobs/images/processors/misc/__init__.py @@ -10,4 +10,5 @@ from .smooth import Smooth from .softbin import SoftBin from .broadcast import Broadcast +from .circular_mask import CircularMask from .image_source_filter import ImageSourceFilter diff --git a/pyobs/images/processors/misc/circular_mask.py b/pyobs/images/processors/misc/circular_mask.py new file mode 100644 index 00000000..f4b1dbff --- /dev/null +++ b/pyobs/images/processors/misc/circular_mask.py @@ -0,0 +1,52 @@ +import logging +from typing import Any, Tuple + +import numpy as np + +from pyobs.images.processor import ImageProcessor +from pyobs.images import Image + +log = logging.getLogger(__name__) + + +class CircularMask(ImageProcessor): + """Mask for central circle with given radius.""" + + __module__ = "pyobs.images.processors.misc" + + def __init__(self, radius: float, center: Tuple[str, str] = ("CRPIX1", "CRPIX2"), **kwargs: Any): + """Init an image processor that masks out everything except for a central circle. + + Args: + radius: radius of the central circle in pixels + center: fits-header keywords defining the pixel coordinates of the center of the circle + """ + ImageProcessor.__init__(self, **kwargs) + + # init + self._center = center + self._radius = radius + + async def __call__(self, image: Image) -> Image: + """Remove everything outside the given radius from the image. + + Args: + image: Image to mask. + + Returns: + Masked Image. + """ + + center_x, center_y = image.header[self._center[0]], image.header[self._center[1]] + + nx, ny = image.data.shape + x, y = np.arange(0, nx), np.arange(0, ny) + x_coordinates, y_coordinates = np.meshgrid(x, y) + + circ_mask = (x_coordinates - center_x) ** 2 + (y_coordinates - center_y) ** 2 <= self._radius**2 + + image.data *= circ_mask.transpose() + return image + + +__all__ = ["CircularMask"] diff --git a/pyobs/images/processors/offsets/__init__.py b/pyobs/images/processors/offsets/__init__.py index 0437e335..edff22f2 100644 --- a/pyobs/images/processors/offsets/__init__.py +++ b/pyobs/images/processors/offsets/__init__.py @@ -2,7 +2,6 @@ Offsets ------- """ - from .offsets import Offsets from .astrometry import AstrometryOffsets from .brighteststar import BrightestStarOffsets diff --git a/pyobs/mixins/fitsheader.py b/pyobs/mixins/fitsheader.py index 14c2a7c8..079dd7d3 100644 --- a/pyobs/mixins/fitsheader.py +++ b/pyobs/mixins/fitsheader.py @@ -359,7 +359,6 @@ def v(k: str) -> Any: posang = self._fitsheadermixin_rotation if "DEROTOFF" in hdr: posang += hdr["DEROTOFF"] - # write position angle hdr["POSANG"] = (posang, "Position angle [deg e of n]") theta_rad = math.radians(posang) diff --git a/pyobs/modules/telescope/basetelescope.py b/pyobs/modules/telescope/basetelescope.py index 98064782..125a91d7 100644 --- a/pyobs/modules/telescope/basetelescope.py +++ b/pyobs/modules/telescope/basetelescope.py @@ -1,7 +1,9 @@ import asyncio from abc import ABCMeta, abstractmethod from typing import Dict, Any, Tuple, Union, List, Optional -from astropy.coordinates import SkyCoord, ICRS, AltAz + +from astroplan import Observer +from astropy.coordinates import SkyCoord, ICRS, AltAz, EarthLocation import astropy.units as u import logging @@ -308,5 +310,10 @@ async def _update_celestial_headers(self) -> None: "SUNDIST": (None if sun_dist is None else float(sun_dist.degree), "Solar Distance from Target"), } + def _calculate_derotator_position(self, ra: float, dec: float, alt: float, obstime: Time) -> float: + target = SkyCoord(ra=ra * u.deg, dec=dec * u.deg, frame="gcrs") + parallactic = self.observer.parallactic_angle(time=obstime, target=target).deg + return float(parallactic - alt) + __all__ = ["BaseTelescope"] diff --git a/pyobs/object.py b/pyobs/object.py index e887c4ff..c076d03d 100644 --- a/pyobs/object.py +++ b/pyobs/object.py @@ -23,6 +23,7 @@ from astroplan import Observer from astropy.coordinates import EarthLocation +from pyobs.background_task import BackgroundTask from pyobs.comm import Comm from pyobs.comm.dummy import DummyComm @@ -270,12 +271,10 @@ def __init__( self._opened = False # background tasks - self._background_tasks: Dict[ - Callable[..., Coroutine[Any, Any, None]], Tuple[Optional[asyncio.Task[bool]], bool] - ] = {} - self._watchdog_task: Optional[asyncio.Task[None]] = None + self._background_tasks: List[Tuple[BackgroundTask, bool]] = [] - def add_background_task(self, func: Callable[..., Coroutine[Any, Any, None]], restart: bool = True) -> None: + def add_background_task(self, func: Callable[..., Coroutine[Any, Any, None]], + restart: bool = True, autostart: bool = True) -> BackgroundTask: """Add a new function that should be run in the background. MUST be called in constructor of derived class or at least before calling open() on the object. @@ -283,20 +282,20 @@ def add_background_task(self, func: Callable[..., Coroutine[Any, Any, None]], re Args: func: Func to add. restart: Whether to restart this function. + autostart: Whether to start this function when the module is opened + Returns: + Background task """ - # create thread - self._background_tasks[func] = (None, restart) + background_task = BackgroundTask(func, restart) + self._background_tasks.append((background_task, autostart)) + + return background_task async def open(self) -> None: """Open module.""" - # start background tasks - for func, (task, restart) in self._background_tasks.items(): - log.info("Starting background task for %s...", func.__name__) - self._background_tasks[func] = (asyncio.create_task(self._background_func(func)), restart) - if len(self._background_tasks) > 0: - self._watchdog_task = asyncio.create_task(self._watchdog()) + self._perform_background_task_autostart() # open child objects for obj in self._child_objects: @@ -309,6 +308,11 @@ async def open(self) -> None: # success self._opened = True + def _perform_background_task_autostart(self) -> None: + todo = filter(lambda b: b[1] is True, self._background_tasks) + for task, _ in todo: + task.start() + @property def opened(self) -> bool: """Whether object has been opened.""" @@ -322,58 +326,16 @@ async def close(self) -> None: if hasattr(obj, "close"): await obj.close() - # join watchdog and then all threads - if self._watchdog_task and not self._watchdog_task.done(): - self._watchdog_task.cancel() - for func, (task, restart) in self._background_tasks.items(): - if task and not task.done(): - task.cancel() - - @staticmethod - async def _background_func(target: Callable[..., Coroutine[Any, Any, None]]) -> None: - """Run given function. - - Args: - target: Function to run. - """ - try: - await target() - - except asyncio.CancelledError: - # task was canceled - return + self._stop_background_tasks() - except: - log.exception("Exception in thread method %s." % target.__name__) + def _stop_background_tasks(self) -> None: + for task, _ in self._background_tasks: + task.stop() def quit(self) -> None: """Can be overloaded to quit program.""" pass - async def _watchdog(self) -> None: - """Watchdog thread that tries to restart threads if they quit.""" - - while True: - # get dead taks that need to be restarted - dead = {} - for func, (task, restart) in self._background_tasks.items(): - if task.done(): - dead[func] = restart - - # restart dead tasks or quit - for func, restart in dead.items(): - if restart: - log.error("Background task for %s has died, restarting...", func.__name__) - del self._background_tasks[func] - self._background_tasks[func] = (asyncio.create_task(self._background_func(func)), restart) - else: - log.error("Background task for %s has died, quitting...", func.__name__) - self.quit() - return - - # sleep a little - await asyncio.sleep(1) - @overload def get_object( self, diff --git a/pyproject.toml b/pyproject.toml index 791e7b7c..b2e770b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "pyobs-core" packages = [{ include = "pyobs" }] -version = "1.12.8" +version = "1.13.0" description = "robotic telescope software" authors = ["Tim-Oliver Husser "] license = "MIT" diff --git a/tests/images/processors/misc/test_circular_mask.py b/tests/images/processors/misc/test_circular_mask.py new file mode 100644 index 00000000..15db667d --- /dev/null +++ b/tests/images/processors/misc/test_circular_mask.py @@ -0,0 +1,19 @@ +import numpy as np +import pytest + +from pyobs.images import Image +from pyobs.images.processors.misc import CircularMask + + +@pytest.mark.asyncio +async def test_call(): + radius = 1 + circular_mask = CircularMask(radius=radius) + data = np.ones((4, 4)) + image = Image(data) + image.header["CRPIX1"] = 1.5 + image.header["CRPIX2"] = 1.5 + masked_image = await circular_mask(image) + + expected_output = np.array([[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]]) + assert np.array_equal(masked_image.data, expected_output) diff --git a/tests/modules/telescope/__init__.py b/tests/modules/telescope/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/modules/telescope/test_basetelescope.py b/tests/modules/telescope/test_basetelescope.py new file mode 100644 index 00000000..0748d640 --- /dev/null +++ b/tests/modules/telescope/test_basetelescope.py @@ -0,0 +1,18 @@ +import numpy as np +from astroplan import Observer +import astropy.units as u + +from pyobs.modules.telescope import DummyTelescope +from pyobs.utils.time import Time + + +def test_calculate_derotator_position(): + telescope = DummyTelescope() + telescope.observer = Observer(latitude=-32.375823 * u.deg, longitude=20.8108079 * u.deg, elevation=1798.0 * u.m) + obstime = Time("2024-03-21T20:11:52.281735") + ra = 138.01290730636728 + dec = -64.86351112618202 + tel_rot = -138.68173828124998 + alt = 57.24036032917521 + derot = telescope._calculate_derotator_position(ra, dec, alt, obstime) + np.testing.assert_almost_equal(derot - tel_rot, 90.22, decimal=2) diff --git a/tests/test_background_task.py b/tests/test_background_task.py new file mode 100644 index 00000000..f218a762 --- /dev/null +++ b/tests/test_background_task.py @@ -0,0 +1,76 @@ +import asyncio +import logging +from unittest.mock import AsyncMock, Mock + +import pytest +import pyobs.utils.exceptions as exc +from pyobs.background_task import BackgroundTask + + +@pytest.mark.asyncio +async def test_callback_canceled(caplog): + test_function = AsyncMock() + task = asyncio.create_task(test_function()) + task.exception = Mock(side_effect=asyncio.CancelledError()) + + bg_task = BackgroundTask(test_function, False) + bg_task._task = task + + with caplog.at_level(logging.ERROR): + bg_task._callback_function() + + assert len(caplog.messages) == 0 + + +@pytest.mark.asyncio +async def test_callback_exception(caplog): + test_function = AsyncMock() + test_function.__name__ = "test_function" + + task = asyncio.create_task(test_function()) + task.exception = Mock(return_value=Exception("TestError")) + + bg_task = BackgroundTask(test_function, False) + bg_task._task = task + + with caplog.at_level(logging.ERROR): + bg_task._callback_function() + + assert caplog.messages[0] == "Exception TestError in task test_function." + assert caplog.messages[1] == "Background task for test_function has died, quitting..." + + +@pytest.mark.asyncio +async def test_callback_pyobs_error(): + test_function = AsyncMock() + test_function.__name__ = "test_function" + + task = asyncio.create_task(test_function()) + task.exception = Mock(return_value=exc.SevereError(exc.ImageError("TestError"))) + + bg_task = BackgroundTask(test_function, False) + bg_task._task = task + + with pytest.raises(exc.SevereError): + bg_task._callback_function() + + +@pytest.mark.asyncio +async def test_callback_restart(caplog): + test_function = AsyncMock() + test_function.__name__ = "test_function" + + task = asyncio.create_task(test_function()) + task.exception = Mock(return_value=None) + + bg_task = BackgroundTask(test_function, True) + bg_task._task = task + + bg_task.start = Mock() + + with caplog.at_level(logging.ERROR): + bg_task._callback_function() + + assert caplog.messages[0] == "Background task for test_function has died, restarting..." + bg_task.start.assert_called_once() + diff --git a/tests/test_object.py b/tests/test_object.py new file mode 100644 index 00000000..ec2916eb --- /dev/null +++ b/tests/test_object.py @@ -0,0 +1,53 @@ +from unittest.mock import AsyncMock + +import pyobs +from pyobs.background_task import BackgroundTask +from pyobs.object import Object + + +def test_add_background_task(): + obj = Object() + test_function = AsyncMock() + + task = obj.add_background_task(test_function, False, False) + + assert task._func == test_function + assert task._restart is False + + assert obj._background_tasks[0] == (task, False) + + +def test_perform_background_task_autostart(mocker): + mocker.patch("pyobs.background_task.BackgroundTask.start") + + obj = Object() + test_function = AsyncMock() + + obj.add_background_task(test_function, False, True) + obj._perform_background_task_autostart() + + pyobs.background_task.BackgroundTask.start.assert_called_once() + + +def test_perform_background_task_no_autostart(mocker): + mocker.patch("pyobs.background_task.BackgroundTask.start") + + obj = Object() + test_function = AsyncMock() + + obj.add_background_task(test_function, False, False) + obj._perform_background_task_autostart() + + pyobs.background_task.BackgroundTask.start.assert_not_called() + + +def test_stop_background_task(mocker): + mocker.patch("pyobs.background_task.BackgroundTask.stop") + + obj = Object() + test_function = AsyncMock() + + obj.add_background_task(test_function, False, False) + obj._stop_background_tasks() + + pyobs.background_task.BackgroundTask.stop.assert_called_once() \ No newline at end of file