diff --git a/docs/configuring.rst b/docs/configuring.rst index c08e320b..4ea54d2b 100644 --- a/docs/configuring.rst +++ b/docs/configuring.rst @@ -83,6 +83,30 @@ cannot reach position surfaces as an error instead of silently unfocused data. This replaces the ``chimera-filterfocus`` plugin, whose ``focus_filters`` and ``focus_difference`` options map to the ``focus_offsets`` mapping above. +Automatic pier flip +^^^^^^^^^^^^^^^^^^^ + +:: + + telescope: + name: fake + type: FakeTelescope + pier_flip_ha: 0 + +A German equatorial mount cannot track indefinitely past the meridian: sooner or later the tube +runs into the pier. Set *pier_flip_ha* to the hour angle, in hours, where that becomes a problem +and the telescope re-slews to the position it is already pointing at as soon as tracking takes it +there, which is what makes the mount pick the other side of the pier. ``0`` flips at the meridian, +``0.5`` gives it another 30 minutes of tracking. + +Only a mount that arrived at the limit *by tracking* is flipped; slewing straight to an object +that is already past it leaves the mount on whichever side its own driver chose. The flip fires +``slew_begin``/``slew_complete`` like any other slew, so a dome in ``track`` mode follows along. + +Leave ``pier_flip_ha`` unset (the default) on a fork or alt-azimuth mount, which has nothing to +flip. This replaces the ``chimera-autopierchange`` plugin, whose ``ha_flip`` option maps to +``pier_flip_ha``. + Controllers Configuration ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/chimera/cli/tel.py b/src/chimera/cli/tel.py index 4e511a82..f44c4999 100644 --- a/src/chimera/cli/tel.py +++ b/src/chimera/cli/tel.py @@ -381,6 +381,14 @@ def info(self, options): "current side of pier: %s " % telescope.get_pier_side().__str__().lower() ) + self.out( + "automatic pier flip: %s" + % ( + f"at hour angle {telescope['pier_flip_ha']} h" + if telescope["pier_flip_ha"] is not None + else "disabled" + ) + ) if self.telescope["fans"] is not None: for fan in self.telescope["fans"]: diff --git a/src/chimera/core/site.py b/src/chimera/core/site.py index 1e5afec0..c00769de 100644 --- a/src/chimera/core/site.py +++ b/src/chimera/core/site.py @@ -265,7 +265,7 @@ def moonphase(self, date=None): def ra_to_ha(self, ra: float): # ra in hours - # returns ha in hours + # returns ha in hours, +/-12: east of the meridian is negative return float( CoordUtil.ra_to_ha( Coord.from_h(ra), Coord.from_r(self.lst_in_rads()) diff --git a/src/chimera/instruments/faketelescope.py b/src/chimera/instruments/faketelescope.py index ccd58bce..a1c6fd7f 100644 --- a/src/chimera/instruments/faketelescope.py +++ b/src/chimera/instruments/faketelescope.py @@ -49,7 +49,7 @@ def _set_ra_dec_from_alt_az(self): def __start__(self): self.set_hz(1) - def control(self): + def _control(self): if not self._slewing: if self._tracking: self._set_alt_az_from_ra_dec() @@ -149,27 +149,21 @@ def is_slewing(self): @lock def move_east(self, offset, rate=None): - self._slewing = True - - ra, dec = self.get_position_ra_dec() - pos = Position.from_ra_dec(ra + Coord.from_as(offset), dec, epoch=Epoch.NOW) - self.slew_begin(float(pos.ra), float(pos.dec)) - - self._ra += float(Coord.from_as(offset).to_h()) - self._set_alt_az_from_ra_dec() - - self._slewing = False - self.slew_complete(self._ra, self._dec, TelescopeStatus.OK) + self._jog_ra(float(Coord.from_as(offset).to_h())) @lock def move_west(self, offset, rate=None): + self._jog_ra(-float(Coord.from_as(offset).to_h())) + + def _jog_ra(self, offset): + # offset in hours. RA is a clock: a jog either side of 0 h wraps, it + # does not run off the end into a Position that refuses to be built self._slewing = True - ra, dec = self.get_position_ra_dec() - pos = Position.from_ra_dec(ra + Coord.from_as(-offset), dec) - self.slew_begin(float(pos.ra), float(pos.dec)) + ra = (self.get_ra() + offset) % 24 + self.slew_begin(ra, self.get_dec()) - self._ra += float(Coord.from_as(-offset).to_h()) + self._ra = ra self._set_alt_az_from_ra_dec() self._slewing = False diff --git a/src/chimera/instruments/telescope.py b/src/chimera/instruments/telescope.py index 5aee14b2..3a2ce524 100644 --- a/src/chimera/instruments/telescope.py +++ b/src/chimera/instruments/telescope.py @@ -12,7 +12,7 @@ TelescopeTracking, ) from chimera.util.coord import Coord -from chimera.util.position import Position, airmass +from chimera.util.position import Epoch, Position, airmass from chimera.util.simbad import simbad_lookup __all__ = ["TelescopeBase"] @@ -25,6 +25,82 @@ def __init__(self): super().__init__() self._park_position = None + # hour angle seen on the previous control cycle, None while slewing or + # not tracking. See _check_pier_flip(). + self._last_ha = None + + # a pier flip is a minutes-scale event and every check asks the mount + # where it is: 2 Hz (the ChimeraObject default) is pointless traffic on + # a serial mount. Drivers needing a faster loop call set_hz() in + # __start__, which runs after this. + self.set_hz(1 / 5.0) + + def control(self) -> bool: + """ + Runs the automatic pier flip check on every cycle of the object's + control loop, then the driver's own periodic work. Drivers implement + L{_control}. + """ + self._check_pier_flip() + return self._control() + + def _control(self) -> bool: + """ + Periodic driver work, called from L{control} on every cycle of the + control loop. Runs unlocked, on the control loop thread. + + @return: False to stop the control loop. + @rtype: bool + """ + return True + + def _check_pier_flip(self): + """ + Flip a German equatorial mount that has tracked past C{pier_flip_ha}. + + The mount is re-slewed to where it is already pointing, which is what + makes it choose the other side of the pier. What triggers it is the + hour angle crossing the limit between two cycles: a mount that slewed + straight to an object already past the limit never crosses it here and + is left on the side its driver picked. + """ + flip_ha = self["pier_flip_ha"] + if flip_ha is None: + return + + if self.is_slewing() or not self.is_tracking(): + # wherever the next slew lands is the new starting point + self._last_ha = None + return + + ra, dec = self.get_position_ra_dec() + ha = self.get_site().ra_to_ha(self._ra_of_date(ra, dec)) + + if self._last_ha is not None and self._last_ha < flip_ha <= ha: + self.log.info( + f"Hour angle {ha:.3f} h is past {flip_ha} h, flipping the pier." + ) + # the position went out in the epoch it came back in, so this + # re-slews to exactly where the mount already is: it changes side + # of pier, not target + self.slew_to_ra_dec(ra, dec, epoch=2000) + + # last: a flip that raised leaves the crossing unrecorded and is + # retried on the next cycle, rather than leaving the mount tracking + # into the pier + self._last_ha = ha + + def _ra_of_date(self, ra: float, dec: float) -> float: + """ + C{ra} precessed from the J2000 the position accessors answer in to the + epoch of date, in hours. + + An hour angle is a difference against the local sidereal time, which is + epoch of date: measuring it from a J2000 right ascension is 26 years of + accumulated precession out, ~2 minutes of time in 2026 and growing. + """ + of_date = Position.from_ra_dec(ra, dec, epoch=Epoch.J2000).to_epoch(Epoch.NOW) + return float(of_date.ra.to_h()) @lock def slew_to_object(self, name): diff --git a/src/chimera/interfaces/telescope.py b/src/chimera/interfaces/telescope.py index a2f0fc9b..7411984f 100644 --- a/src/chimera/interfaces/telescope.py +++ b/src/chimera/interfaces/telescope.py @@ -65,6 +65,10 @@ class TelescopeSlew(Telescope): "position_sigma_delta": 60.0, # arcseconds "skip_init": False, "min_altitude": 20, + # Hour angle, in hours, at which a German equatorial mount that tracked + # into it is flipped to the other side of the pier. None (the default) + # never flips. + "pier_flip_ha": None, } def slew_to_object(self, name: str) -> None: diff --git a/src/chimera/util/coord.py b/src/chimera/util/coord.py index d98ace32..fa71bc64 100644 --- a/src/chimera/util/coord.py +++ b/src/chimera/util/coord.py @@ -284,11 +284,24 @@ def make_valid_180_to_180(coord): @staticmethod def ra_to_ha(ra, lst): - return Coord.from_r(CoordUtil.coord_to_r(lst) - CoordUtil.coord_to_r(ra)) + """ + Hour angle of C{ra} at C{lst}, wrapped to +/-12 h: negative east of + the meridian, positive west of it. Without the wrap an object either + side of the 0/24 h boundary comes back ~24 h away from the meridian + it is actually sitting on. + """ + return CoordUtil.make_valid_180_to_180( + CoordUtil.coord_to_r(lst) - CoordUtil.coord_to_r(ra) + ) @staticmethod def ha_to_ra(ha, lst): - return Coord.from_r(CoordUtil.coord_to_r(lst) - CoordUtil.coord_to_r(ha)) + """ + Right ascension of C{ha} at C{lst}, wrapped to [0, 24) h. + """ + return CoordUtil.make_valid_0_to_360( + CoordUtil.coord_to_r(lst) - CoordUtil.coord_to_r(ha) + ) # coord_rotate adopted from sidereal.py # http://www.nmt.edu/tcc/help/lang/python/examples/sidereal/ims/ diff --git a/tests/chimera/core/test_site.py b/tests/chimera/core/test_site.py index a4122a34..c76ceb92 100644 --- a/tests/chimera/core/test_site.py +++ b/tests/chimera/core/test_site.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva import datetime as dt +import math import time import msgspec @@ -93,6 +94,17 @@ def test_sun_altitude_survives_the_bus(self, manager): with pytest.raises(TypeError): encoder.encode(site.sunpos()) + def test_ra_to_ha_is_signed_around_the_meridian(self, manager): + """HA comes back in [-12, +12), east of the meridian negative, even + for a right ascension on the other side of the 0/24 h wrap.""" + site = manager.get_proxy("/Site/0") + lst = site.lst_in_rads() * 12 / math.pi + + for hour_angle in (-11.5, -1, 0, 1, 11.5): + ra = (lst - hour_angle) % 24 + # the sidereal clock keeps running: 0.01 h is 36 seconds of slack + assert site.ra_to_ha(ra) == pytest.approx(hour_angle, abs=0.01) + def test_is_dusk_tracks_the_sun_not_the_clock(self, manager): """Dusk is the sun on its way down, sampled all the way around a day and checked against the altitude it is about to have.""" diff --git a/tests/chimera/instruments/test_telescope.py b/tests/chimera/instruments/test_telescope.py index b4172c68..057c0e6d 100644 --- a/tests/chimera/instruments/test_telescope.py +++ b/tests/chimera/instruments/test_telescope.py @@ -6,13 +6,17 @@ import sys import time from concurrent.futures import ThreadPoolExecutor, wait +from types import SimpleNamespace import pytest import chimera.core.log +from chimera.core.exceptions import ChimeraException from chimera.instruments.faketelescope import FakeTelescope +from chimera.instruments.telescope import TelescopeBase from chimera.interfaces.telescope import TelescopeStatus from chimera.util.coord import Coord +from chimera.util.position import Epoch, Position chimera.core.log.set_console_level(int(1e10)) log = logging.getLogger("chimera.tests") @@ -211,3 +215,212 @@ def test_jog(self, telescope): (start_dec - dec) * 3600, ) assert (start_ra, start_dec) != (ra, dec) + + +def test_jog_wraps_ra_at_the_clock(monkeypatch): + """A jog either side of 0 h stays on the clock: RA 0.05 h jogged 6 minutes + west is 23.95 h, not the negative RA Position refuses to be built from.""" + telescope = FakeTelescope() + monkeypatch.setattr( + telescope, + "get_site", + lambda: SimpleNamespace(ra_dec_to_alt_az=lambda ra, dec: (60.0, 30.0)), + ) + for event in ("slew_begin", "slew_complete"): + monkeypatch.setattr(FakeTelescope, event, lambda *args: None, raising=False) + + telescope._ra, telescope._dec = 0.05, -30.0 + six_minutes = float(Coord.from_h(0.1).to_as()) + + telescope.move_west(six_minutes) + assert telescope.get_ra() == pytest.approx(23.95) + + telescope.move_east(six_minutes) + assert telescope.get_ra() == pytest.approx(0.05) + + +# --------------------------------------------------------------------------- +# Automatic pier flip (unit level, no bus): TelescopeBase.control() re-slews a +# mount that tracked past pier_flip_ha. +# --------------------------------------------------------------------------- + + +class PierTelescope(TelescopeBase): + """Just enough telescope to drive the pier flip check by hand.""" + + def __init__(self): + TelescopeBase.__init__(self) + + self.ha = -1.0 # hour angle the site will report, in hours + self.tracking = True + self.slewing = False + self.slews = [] + self.controls = 0 + self.slew_error = None + self.hour_angle_args = [] + + def _control(self): + self.controls += 1 + return True + + def is_slewing(self): + return self.slewing + + def is_tracking(self): + return self.tracking + + def get_ra(self): + return 12.0 + + def get_dec(self): + return -30.0 + + def get_position_ra_dec(self): + return self.get_ra(), self.get_dec() + + def slew_to_ra_dec(self, ra, dec, epoch=2000): + if self.slew_error is not None: + raise self.slew_error + self.slews.append((ra, dec, epoch)) + + +@pytest.fixture +def pier_telescope(monkeypatch): + def factory(**config): + telescope = PierTelescope() + for key, value in config.items(): + telescope[key] = value + + def ra_to_ha(ra): + telescope.hour_angle_args.append(ra) + return telescope.ha + + monkeypatch.setattr( + telescope, "get_site", lambda: SimpleNamespace(ra_to_ha=ra_to_ha) + ) + return telescope + + return factory + + +class TestPierFlip: + def test_disabled_by_default(self, pier_telescope): + telescope = pier_telescope() + + telescope.ha = -0.1 + telescope.control() + telescope.ha = +0.1 + telescope.control() + + assert telescope.slews == [] + # the driver's own periodic work still runs on every cycle + assert telescope.controls == 2 + + def test_tracking_past_the_limit_flips(self, pier_telescope): + telescope = pier_telescope(pier_flip_ha=0) + + telescope.ha = -0.1 + telescope.control() + assert telescope.slews == [] + + telescope.ha = +0.1 + telescope.control() + # same position, same epoch the position accessors answer in: the + # mount changes side of pier, not target + assert telescope.slews == [(12.0, -30.0, 2000)] + + def test_the_hour_angle_is_measured_in_the_epoch_of_date(self, pier_telescope): + """The position accessors answer in J2000; the local sidereal time an + hour angle is measured against is epoch of date. Subtracting one from + the other is 26 years of precession out -- ~2 minutes of time in 2026, + and more every year -- so the flip fires early.""" + telescope = pier_telescope(pier_flip_ha=0) + + telescope.control() + + (ra,) = telescope.hour_angle_args + of_date = Position.from_ra_dec(12.0, -30.0, epoch=Epoch.J2000).to_epoch( + Epoch.NOW + ) + assert ra == pytest.approx(float(of_date.ra.to_h())) + # a quarter century of precession, not the J2000 number it started from + assert 0.01 < ra - 12.0 < 0.05 + + def test_flips_only_once(self, pier_telescope): + telescope = pier_telescope(pier_flip_ha=0) + + telescope.ha = -0.1 + telescope.control() + + for telescope.ha in (0.1, 0.2, 0.3): + telescope.control() + + assert telescope.slews == [(12.0, -30.0, 2000)] + + def test_a_slew_past_the_limit_does_not_flip(self, pier_telescope): + """The mount never tracked into the limit: the driver put it on + whichever side it wanted when it slewed there.""" + telescope = pier_telescope(pier_flip_ha=0) + + telescope.ha = +1.0 + telescope.control() + telescope.ha = +1.5 + telescope.control() + + assert telescope.slews == [] + + def test_slewing_disarms_the_flip(self, pier_telescope): + telescope = pier_telescope(pier_flip_ha=0) + + telescope.ha = -0.1 + telescope.control() + + telescope.slewing = True + telescope.ha = +0.1 + telescope.control() + + telescope.slewing = False + telescope.control() + + assert telescope.slews == [] + + def test_a_parked_mount_is_not_flipped(self, pier_telescope): + telescope = pier_telescope(pier_flip_ha=0) + + telescope.ha = -0.1 + telescope.control() + + telescope.tracking = False + telescope.ha = +0.1 + telescope.control() + + assert telescope.slews == [] + + def test_the_limit_is_configurable(self, pier_telescope): + telescope = pier_telescope(pier_flip_ha=0.5) + + for telescope.ha in (-0.1, 0.1, 0.4): + telescope.control() + assert telescope.slews == [] + + telescope.ha = 0.6 + telescope.control() + assert telescope.slews == [(12.0, -30.0, 2000)] + + def test_a_failed_flip_is_retried(self, pier_telescope): + """The mount is tracking into the pier: giving up quietly is the one + thing the check must not do.""" + telescope = pier_telescope(pier_flip_ha=0) + + telescope.ha = -0.1 + telescope.control() + + telescope.slew_error = ChimeraException("mount is not answering") + telescope.ha = +0.1 + with pytest.raises(ChimeraException): + telescope.control() + assert telescope.slews == [] + + telescope.slew_error = None + telescope.control() + assert telescope.slews == [(12.0, -30.0, 2000)] diff --git a/tests/chimera/util/test_coord.py b/tests/chimera/util/test_coord.py index 09bd7eb3..2a4c7ce4 100644 --- a/tests/chimera/util/test_coord.py +++ b/tests/chimera/util/test_coord.py @@ -3,7 +3,7 @@ from astropy.io import ascii -from chimera.util.coord import Coord +from chimera.util.coord import Coord, CoordUtil class TestCoord: @@ -159,3 +159,34 @@ def test_parse_dms(self): print( f"#{len(coords)} coords parsed in {t_parse:.3f}s ({len(coords) / t_parse:.3f}/s) and checked in {t_check:.3f}s ({len(coords) / t_check:.3f}/s) ..." ) + + +class TestHourAngle: + """ + ra_to_ha() and ha_to_ra() answer in the conventional ranges, including + across the 0/24 h boundary, where a bare lst - ra is ~24 h out. + """ + + def test_ra_to_ha_is_signed_around_the_meridian(self): + for lst, ra, ha in [ + (2.0, 1.0, +1.0), # west of the meridian + (1.0, 2.0, -1.0), # east of it + (12.0, 12.0, 0.0), # on it + (0.5, 23.5, +1.0), # one hour past, across the wrap + (23.5, 0.5, -1.0), # one hour short of it, across the wrap + ]: + assert TestCoord.equal( + float(CoordUtil.ra_to_ha(Coord.from_h(ra), Coord.from_h(lst)).to_h()), + ha, + ) + + def test_ha_to_ra_stays_on_the_clock(self): + for lst, ha, ra in [ + (2.0, 1.0, 1.0), + (0.5, 1.0, 23.5), # would be -0.5 h without the wrap + (23.5, -1.0, 0.5), + ]: + assert TestCoord.equal( + float(CoordUtil.ha_to_ra(Coord.from_h(ha), Coord.from_h(lst)).to_h()), + ra, + )