from __future__ import annotations
import asyncio
from typing import Any, Literal
import astropy.units as u
import numpy as np
from astropy import constants
from astropy.coordinates import SkyCoord, get_sun
from pyobs.interfaces import (
HeliocentricPolarState,
HeliographicStonyhurstState,
HelioprojectiveState,
IPointingHeliocentricPolar,
IPointingHeliographicStonyhurst,
IPointingHelioprojective,
IPointingRaDec,
RaDecState,
TrackingMode,
)
from pyobs.modules.telescope._dummytelescopebase import _DummyTelescopeBase
from pyobs.modules.telescope.basetelescope import BaseTelescope
from pyobs.utils.threads import LockWithAbort
from pyobs.utils.time import Time
class DummySolarTelescope(
_DummyTelescopeBase, IPointingHeliocentricPolar, IPointingHeliographicStonyhurst, IPointingHelioprojective
):
"""A dummy telescope dedicated to solar pointing (Heliocentric Polar/Heliographic Stonyhurst/
Helioprojective), for testing -- it always tracks the Sun and does not support arbitrary
body/orbital-element tracking (see dummy-telescope-split-design.md).
"""
__module__ = "pyobs.modules.telescope"
_SOLAR_FOLLOW_INTERVAL_SECONDS = 10.0
def __init__(self, **kwargs: Any):
_DummyTelescopeBase.__init__(self, **kwargs)
# (kind, a, b) of the last move_heliocentric_polar/move_heliographic_stonyhurst/
# move_helioprojective call, or None if not currently following a solar-relative target.
# Re-resolved to RA/Dec every tick by _solar_follow_task since the Sun moves across the sky
# (and, for heliocentric-polar/heliographic-stonyhurst targets fixed on the rotating solar
# surface, also rotates).
self._solar_target: (
tuple[Literal["heliocentric_polar", "heliographic_stonyhurst", "helioprojective"], float, float] | None
) = None
self.add_background_task(self._solar_follow_task)
[docs]
async def open(self) -> None:
"""Open module."""
await _DummyTelescopeBase.open(self)
# no solar target is being followed yet -- publish the disk centre as a placeholder so
# the pubsub nodes exist (real values get set once a move_heliocentric_polar/
# move_heliographic_stonyhurst/move_helioprojective call comes in)
await self.comm.set_state(IPointingHeliocentricPolar, HeliocentricPolarState(mu=1.0, psi=0.0))
await self.comm.set_state(IPointingHeliographicStonyhurst, HeliographicStonyhurstState(lon=0.0, lat=0.0))
await self.comm.set_state(IPointingHelioprojective, HelioprojectiveState(theta_x=0.0, theta_y=0.0))
@staticmethod
def _heliocentric_polar_to_radec(mu: float, psi: float, time: Time) -> tuple[float, float]:
"""Converts Heliocentric Polar (mu, psi) to (ra, dec) in degrees, ICRS. Mirrors
HeliocentricPolarTarget.coordinates() (pyobs/robotic/scheduler/targets), except psi is
taken in degrees here (matching IPointingHeliocentricPolar's declared unit) rather than
implicitly in radians."""
from sunpy.coordinates import Helioprojective
alpha = np.arccos(mu)
d_sun = get_sun(time).distance
r_sun = constants.R_sun # type: ignore[missing-attribute]
theta = np.arctan(r_sun * np.sin(alpha) / (d_sun - (r_sun * mu)))
psi_rad = np.radians(psi)
tx = -theta * np.sin(psi_rad)
ty = theta * np.cos(psi_rad)
coord = SkyCoord(tx, ty, obstime=time, frame=Helioprojective, observer="earth")
icrs = coord.icrs
return float(icrs.ra.degree), float(icrs.dec.degree)
@staticmethod
def _heliographic_stonyhurst_to_radec(lon: float, lat: float, time: Time) -> tuple[float, float]:
"""Converts Heliographic Stonyhurst (lon, lat), in degrees, to (ra, dec) in degrees, ICRS."""
from sunpy.coordinates import HeliographicStonyhurst
coord = SkyCoord(lon * u.deg, lat * u.deg, frame=HeliographicStonyhurst, obstime=time, observer="earth")
icrs = coord.transform_to("icrs")
return float(icrs.ra.degree), float(icrs.dec.degree)
@staticmethod
def _helioprojective_to_radec(theta_x: float, theta_y: float, time: Time) -> tuple[float, float]:
"""Converts Helioprojective (theta_x, theta_y), in degrees, to (ra, dec) in degrees, ICRS."""
from sunpy.coordinates import Helioprojective
coord = SkyCoord(theta_x * u.deg, theta_y * u.deg, obstime=time, frame=Helioprojective, observer="earth")
icrs = coord.icrs
return float(icrs.ra.degree), float(icrs.dec.degree)
[docs]
async def move_radec(self, ra: float, dec: float, **kwargs: Any) -> None:
# a plain move_radec/move_altaz is "go to this fixed coordinate" -- not "follow the Sun
# at this Sun-relative offset" anymore, so stop following, same reasoning as
# BaseTelescope.move_radec clearing _tracked_body/_tracked_elements.
self._solar_target = None
await BaseTelescope.move_radec(self, ra, dec, **kwargs)
[docs]
async def move_altaz(self, alt: float, az: float, **kwargs: Any) -> None:
self._solar_target = None
await BaseTelescope.move_altaz(self, alt, az, **kwargs)
[docs]
async def move_heliocentric_polar(self, mu: float, psi: float, **kwargs: Any) -> None:
"""Moves to and continuously tracks a Heliocentric Polar (mu, psi) coordinate."""
ra, dec = self._heliocentric_polar_to_radec(mu, psi, Time.now())
await self.move_radec(ra, dec)
self._solar_target = ("heliocentric_polar", mu, psi)
await self.set_tracking_mode(TrackingMode.SOLAR)
await self.comm.set_state(IPointingHeliocentricPolar, HeliocentricPolarState(mu=mu, psi=psi))
[docs]
async def move_heliographic_stonyhurst(self, lon: float, lat: float, **kwargs: Any) -> None:
"""Moves to and continuously tracks a Heliographic Stonyhurst (lon, lat) coordinate."""
ra, dec = self._heliographic_stonyhurst_to_radec(lon, lat, Time.now())
await self.move_radec(ra, dec)
self._solar_target = ("heliographic_stonyhurst", lon, lat)
await self.set_tracking_mode(TrackingMode.SOLAR)
await self.comm.set_state(IPointingHeliographicStonyhurst, HeliographicStonyhurstState(lon=lon, lat=lat))
[docs]
async def move_helioprojective(self, theta_x: float, theta_y: float, **kwargs: Any) -> None:
"""Moves to and continuously tracks a Helioprojective (theta_x, theta_y) coordinate."""
ra, dec = self._helioprojective_to_radec(theta_x, theta_y, Time.now())
await self.move_radec(ra, dec)
self._solar_target = ("helioprojective", theta_x, theta_y)
await self.set_tracking_mode(TrackingMode.SOLAR)
await self.comm.set_state(IPointingHelioprojective, HelioprojectiveState(theta_x=theta_x, theta_y=theta_y))
async def _solar_follow_task(self) -> None:
"""Background task: while a solar-relative target is active, keeps the simulated
position on it as the Sun moves across the sky (and, for heliocentric-polar/heliographic-
stonyhurst targets, rotates) -- see dummy-telescope-split-design.md. Dummy-only: real solar
hardware tracks the Sun natively, so there's no equivalent generic machinery in
BaseTelescope."""
while True:
if self._solar_target is not None and not self._lock_moving.locked():
async with LockWithAbort(self._lock_moving, self._abort_move):
kind, a, b = self._solar_target
now = Time.now()
if kind == "heliocentric_polar":
ra, dec = self._heliocentric_polar_to_radec(a, b, now)
elif kind == "heliographic_stonyhurst":
ra, dec = self._heliographic_stonyhurst_to_radec(a, b, now)
else:
ra, dec = self._helioprojective_to_radec(a, b, now)
self._position = SkyCoord(ra=ra * u.deg, dec=dec * u.deg, frame="icrs")
await self.comm.set_state(IPointingRaDec, RaDecState(ra=ra, dec=dec))
await self._publish_altaz()
await asyncio.sleep(self._SOLAR_FOLLOW_INTERVAL_SECONDS)
__all__ = ["DummySolarTelescope"]