From 7b3d5bb729e93f89233b5904502cfd9181b2791a Mon Sep 17 00:00:00 2001 From: William Schoenell Date: Mon, 27 Jul 2026 17:55:58 -0700 Subject: [PATCH 1/5] fix(site): ra_to_ha returns a signed hour angle lst - ra is not an hour angle until it is wrapped: with LST at 0.5 h and an object at RA 23.5 h, ra_to_ha() answered -23 h for an object one hour past the meridian. Anything comparing the result against a limit -- the pier flip below, dome geometry -- reads that as "still 23 hours to go" and never fires. Wrap it into [-12, +12), the range the rest of the code already assumes: east of the meridian negative, west positive. --- src/chimera/core/site.py | 5 +++-- tests/chimera/core/test_site.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/chimera/core/site.py b/src/chimera/core/site.py index 1e5afec0..3ea76da9 100644 --- a/src/chimera/core/site.py +++ b/src/chimera/core/site.py @@ -265,12 +265,13 @@ def moonphase(self, date=None): def ra_to_ha(self, ra: float): # ra in hours - # returns ha in hours - return float( + # returns ha in hours, in [-12, +12): east of the meridian is negative + ha = float( CoordUtil.ra_to_ha( Coord.from_h(ra), Coord.from_r(self.lst_in_rads()) ).to_h() ) + return (ha + 12) % 24 - 12 def ha_to_ra(self, ha: float): return float( 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.""" From 2b6965e649d3b67693e5b4d2ccd279e461363b45 Mon Sep 17 00:00:00 2001 From: William Schoenell Date: Mon, 27 Jul 2026 17:56:10 -0700 Subject: [PATCH 2/5] feat(telescope): automatic pier flip in TelescopeBase Folds the chimera-autopierchange plugin into the telescope itself, the way per-filter focus offsets went into FilterWheelBase (#259): behaviour that only the instrument can get right belongs on the instrument, not in a controller watching it from outside. TelescopeBase.control() becomes concrete: it runs the pier flip check on every cycle and then calls the driver's new _control() primitive, so drivers keep their periodic work and no longer decide whether the loop runs at all. FakeTelescope is migrated here. The loop rate drops to one cycle every 5 s, since what the base now runs there asks the mount where it is; a driver wanting a faster loop calls set_hz() in __start__ as before. A German equatorial mount is flipped by re-slewing it to the position it is already pointing at, which is what makes the mount pick the other side of the pier: telescope: name: fake type: FakeTelescope pier_flip_ha: 0 Leaving pier_flip_ha unset -- the default, and the whole story on a fork or alt-az mount -- disables the check before it queries the mount at all. Only a mount that reached the limit *by tracking* is flipped; one that slewed straight to an object already past it is left on the side its own driver chose. The plugin tracked that with a TelescopePierSide it inferred from the hour angle at the end of every slew, using the opposite sense of the ASCOM convention the enum otherwise carries, and only for slews it happened to see. The state here is one flag, armed by observing the mount track east of the limit, so a chimera restarted mid-target simply waits for the next crossing instead of guessing. The plugin also computed the hour angle from a bare lst - ra, so a target either side of the 0/24 h wrap was 23 hours from a flip that then never came; see the previous commit. A flip that raises leaves the check armed and is retried on the next cycle -- the mount is tracking into the pier, so quietly giving up is the one thing this must not do. The re-slew fires slew_begin/slew_complete like any other slew, so a dome in track mode follows along. --- docs/configuring.rst | 24 +++ src/chimera/cli/tel.py | 8 + src/chimera/instruments/faketelescope.py | 2 +- src/chimera/instruments/telescope.py | 61 +++++++ src/chimera/interfaces/telescope.py | 4 + tests/chimera/instruments/test_telescope.py | 167 ++++++++++++++++++++ 6 files changed, 265 insertions(+), 1 deletion(-) 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/instruments/faketelescope.py b/src/chimera/instruments/faketelescope.py index ccd58bce..46d81352 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() diff --git a/src/chimera/instruments/telescope.py b/src/chimera/instruments/telescope.py index 5aee14b2..b0e820a9 100644 --- a/src/chimera/instruments/telescope.py +++ b/src/chimera/instruments/telescope.py @@ -25,6 +25,67 @@ def __init__(self): super().__init__() self._park_position = None + # True once the mount has been seen tracking east of pier_flip_ha, i.e. + # heading for the flip. See _check_pier_flip(). + self._pier_flip_armed = False + + # 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. Only a mount that reached + the limit by tracking is flipped: one that slewed straight to an object + past the limit is already on the side the mount driver picked for it. + """ + 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._pier_flip_armed = False + return + + ha = self.get_site().ra_to_ha(self.get_ra()) + + if ha < flip_ha: + self._pier_flip_armed = True + return + + if not self._pier_flip_armed: + return + + self.log.info(f"Hour angle {ha:.3f} h is past {flip_ha} h, flipping the pier.") + self.slew_to_ra_dec(*self.get_position_ra_dec()) + # only on success: a flip that raised stays armed and is retried on the + # next cycle, rather than leaving the mount tracking into the pier + self._pier_flip_armed = False @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/tests/chimera/instruments/test_telescope.py b/tests/chimera/instruments/test_telescope.py index b4172c68..3851778c 100644 --- a/tests/chimera/instruments/test_telescope.py +++ b/tests/chimera/instruments/test_telescope.py @@ -10,7 +10,9 @@ 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 @@ -211,3 +213,168 @@ def test_jog(self, telescope): (start_dec - dec) * 3600, ) assert (start_ra, start_dec) != (ra, dec) + + +# --------------------------------------------------------------------------- +# 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 + + 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)) + + +@pytest.fixture +def pier_telescope(monkeypatch): + def factory(**config): + telescope = PierTelescope() + for key, value in config.items(): + telescope[key] = value + + monkeypatch.setattr( + telescope, + "get_site", + lambda: type("site", (), {"ra_to_ha": lambda _, ra: telescope.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() + assert telescope.slews == [(12.0, -30.0)] + + 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)] + + 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)] + + 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)] From 60403ece71188effea7b7baf9edc12dcdd856bbf Mon Sep 17 00:00:00 2001 From: William Schoenell Date: Thu, 30 Jul 2026 22:47:06 -0700 Subject: [PATCH 3/5] refactor(telescope): address review -- HA wrap in CoordUtil, no flip flag CoordUtil.ra_to_ha() now wraps its own result to +/-12 h instead of Site doing it on the way out, so every caller gets a usable hour angle and not just the one that noticed. ha_to_ra() gets the same treatment on the other side ([0, 24) h), since it answered a negative right ascension for the same inputs. Tested there, on values either side of the 0/24 h wrap. The pier flip no longer keeps a flag saying it is due one. What triggers it is the hour angle crossing the limit between two consecutive cycles -- the crossing is read fresh each cycle from the previous hour angle and the current one, and a mount that slewed straight to a target past the limit never produces one. Recording the new hour angle last is what makes a failed flip retry: the crossing stays unconsumed until the slew returns. The re-slew now passes its epoch explicitly. get_position_ra_dec() and slew_to_ra_dec() are both in the interface's default J2000 -- FakeTelescope and TheSkyXTelescope reject anything else, and the latter reads the mount through PrecessNowTo2000 -- so the flip re-slews to exactly where the mount already is. Left to the default it would have read as "assume J2000", which is only true because it is J2000, and the test now pins it. --- src/chimera/core/site.py | 5 ++- src/chimera/instruments/telescope.py | 40 +++++++++++---------- src/chimera/util/coord.py | 17 +++++++-- tests/chimera/instruments/test_telescope.py | 12 ++++--- tests/chimera/util/test_coord.py | 33 ++++++++++++++++- 5 files changed, 77 insertions(+), 30 deletions(-) diff --git a/src/chimera/core/site.py b/src/chimera/core/site.py index 3ea76da9..c00769de 100644 --- a/src/chimera/core/site.py +++ b/src/chimera/core/site.py @@ -265,13 +265,12 @@ def moonphase(self, date=None): def ra_to_ha(self, ra: float): # ra in hours - # returns ha in hours, in [-12, +12): east of the meridian is negative - ha = float( + # 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()) ).to_h() ) - return (ha + 12) % 24 - 12 def ha_to_ra(self, ha: float): return float( diff --git a/src/chimera/instruments/telescope.py b/src/chimera/instruments/telescope.py index b0e820a9..599d7e82 100644 --- a/src/chimera/instruments/telescope.py +++ b/src/chimera/instruments/telescope.py @@ -25,9 +25,9 @@ def __init__(self): super().__init__() self._park_position = None - # True once the mount has been seen tracking east of pier_flip_ha, i.e. - # heading for the flip. See _check_pier_flip(). - self._pier_flip_armed = False + # 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 @@ -59,9 +59,10 @@ 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. Only a mount that reached - the limit by tracking is flipped: one that slewed straight to an object - past the limit is already on the side the mount driver picked for it. + 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: @@ -69,23 +70,24 @@ def _check_pier_flip(self): if self.is_slewing() or not self.is_tracking(): # wherever the next slew lands is the new starting point - self._pier_flip_armed = False + self._last_ha = None return ha = self.get_site().ra_to_ha(self.get_ra()) - if ha < flip_ha: - self._pier_flip_armed = True - return - - if not self._pier_flip_armed: - return - - self.log.info(f"Hour angle {ha:.3f} h is past {flip_ha} h, flipping the pier.") - self.slew_to_ra_dec(*self.get_position_ra_dec()) - # only on success: a flip that raised stays armed and is retried on the - # next cycle, rather than leaving the mount tracking into the pier - self._pier_flip_armed = False + 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." + ) + # get_position_ra_dec() and slew_to_ra_dec() are both in the + # interface's default epoch, so this re-slews to exactly where the + # mount already is, with no precession in or out + self.slew_to_ra_dec(*self.get_position_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 @lock def slew_to_object(self, name): 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/instruments/test_telescope.py b/tests/chimera/instruments/test_telescope.py index 3851778c..0b8158fe 100644 --- a/tests/chimera/instruments/test_telescope.py +++ b/tests/chimera/instruments/test_telescope.py @@ -256,7 +256,7 @@ def get_position_ra_dec(self): 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)) + self.slews.append((ra, dec, epoch)) @pytest.fixture @@ -298,7 +298,9 @@ def test_tracking_past_the_limit_flips(self, pier_telescope): telescope.ha = +0.1 telescope.control() - assert telescope.slews == [(12.0, -30.0)] + # 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_flips_only_once(self, pier_telescope): telescope = pier_telescope(pier_flip_ha=0) @@ -309,7 +311,7 @@ def test_flips_only_once(self, pier_telescope): for telescope.ha in (0.1, 0.2, 0.3): telescope.control() - assert telescope.slews == [(12.0, -30.0)] + 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 @@ -359,7 +361,7 @@ def test_the_limit_is_configurable(self, pier_telescope): telescope.ha = 0.6 telescope.control() - assert telescope.slews == [(12.0, -30.0)] + 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 @@ -377,4 +379,4 @@ def test_a_failed_flip_is_retried(self, pier_telescope): telescope.slew_error = None telescope.control() - assert telescope.slews == [(12.0, -30.0)] + 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, + ) From c5cca17187d70017f713fc9d36cd49c369e6ae5e Mon Sep 17 00:00:00 2001 From: William Schoenell Date: Thu, 30 Jul 2026 22:53:38 -0700 Subject: [PATCH 4/5] fix(faketelescope): a jog across 0 h wraps instead of falling off the clock move_east()/move_west() added the offset to the current right ascension and handed the result to Position.from_ra_dec(), which rejects anything outside 0-24 h. Jogging west from RA 0 therefore raised ValueError: Invalid RA range -00:00:33.719 and test_jog, which jogs from wherever alt 60/az 30 happens to be, failed whenever the sidereal clock put that near 0 h -- on master as much as here. Both directions now go through one _jog_ra() that wraps the result, and the regression test drives the wrap directly instead of waiting for the LST to come round to it. --- src/chimera/instruments/faketelescope.py | 23 ++++++++------------- tests/chimera/instruments/test_telescope.py | 23 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/chimera/instruments/faketelescope.py b/src/chimera/instruments/faketelescope.py index 46d81352..d6e4fcc4 100644 --- a/src/chimera/instruments/faketelescope.py +++ b/src/chimera/instruments/faketelescope.py @@ -149,27 +149,22 @@ 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) + ra = (self.get_ra() + offset) % 24 + pos = Position.from_ra_dec(ra, self.get_dec(), epoch=Epoch.NOW) self.slew_begin(float(pos.ra), float(pos.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/tests/chimera/instruments/test_telescope.py b/tests/chimera/instruments/test_telescope.py index 0b8158fe..23b575e1 100644 --- a/tests/chimera/instruments/test_telescope.py +++ b/tests/chimera/instruments/test_telescope.py @@ -6,6 +6,7 @@ import sys import time from concurrent.futures import ThreadPoolExecutor, wait +from types import SimpleNamespace import pytest @@ -215,6 +216,28 @@ def test_jog(self, telescope): 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. From 77a0cd84c1888ff83c293bb36a02a0443ab96516 Mon Sep 17 00:00:00 2001 From: William Schoenell Date: Thu, 30 Jul 2026 23:59:20 -0700 Subject: [PATCH 5/5] fix(telescope): measure the flip hour angle in the epoch of date Deployed to the LNA 0.4 m (Paramount ME + TheSkyX) with pier_flip_ha: 0 and the flip fired ~2 minutes early. The re-slew was right -- J2000 out of get_position_ra_dec(), J2000 into slew_to_ra_dec() -- but the hour angle it triggers on was not: it subtracted a J2000 right ascension from the local sidereal time, which is epoch of date, and pocketed 26 years of precession as hour angle. Measured on the mount, 2026-07-31, RA 21.48354 h dec -58.836: RA J2000 : 21.48354 h RA epoch-of-date : 21.51615 h difference : +117.4 s of time The error is 3.075 + 1.336 sin(a) tan(d) seconds a year, so it grows, and the declination term reaches a minute of its own at dec -60. The sign is the safe one -- early rather than into the pier -- but pier_flip_ha means an hour angle, not roughly one. _ra_of_date() precesses the position forward before the subtraction. The position is now read once per cycle and used for both the hour angle and the re-slew, so the check also costs one mount query instead of two. While here: FakeTelescope's jog built a Position only to take float(pos.ra) back out of it, which is degrees, not the hours slew_begin() documents. It publishes the plain hours it already has. --- src/chimera/instruments/faketelescope.py | 3 +-- src/chimera/instruments/telescope.py | 25 ++++++++++++++----- tests/chimera/instruments/test_telescope.py | 27 ++++++++++++++++++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/chimera/instruments/faketelescope.py b/src/chimera/instruments/faketelescope.py index d6e4fcc4..a1c6fd7f 100644 --- a/src/chimera/instruments/faketelescope.py +++ b/src/chimera/instruments/faketelescope.py @@ -161,8 +161,7 @@ def _jog_ra(self, offset): self._slewing = True ra = (self.get_ra() + offset) % 24 - pos = Position.from_ra_dec(ra, self.get_dec(), epoch=Epoch.NOW) - self.slew_begin(float(pos.ra), float(pos.dec)) + self.slew_begin(ra, self.get_dec()) self._ra = ra self._set_alt_az_from_ra_dec() diff --git a/src/chimera/instruments/telescope.py b/src/chimera/instruments/telescope.py index 599d7e82..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"] @@ -73,22 +73,35 @@ def _check_pier_flip(self): self._last_ha = None return - ha = self.get_site().ra_to_ha(self.get_ra()) + 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." ) - # get_position_ra_dec() and slew_to_ra_dec() are both in the - # interface's default epoch, so this re-slews to exactly where the - # mount already is, with no precession in or out - self.slew_to_ra_dec(*self.get_position_ra_dec(), epoch=2000) + # 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): _, ra, dec, epoch = simbad_lookup(name) or (None, None, None, None) diff --git a/tests/chimera/instruments/test_telescope.py b/tests/chimera/instruments/test_telescope.py index 23b575e1..057c0e6d 100644 --- a/tests/chimera/instruments/test_telescope.py +++ b/tests/chimera/instruments/test_telescope.py @@ -16,6 +16,7 @@ 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") @@ -256,6 +257,7 @@ def __init__(self): self.slews = [] self.controls = 0 self.slew_error = None + self.hour_angle_args = [] def _control(self): self.controls += 1 @@ -289,10 +291,12 @@ def factory(**config): 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: type("site", (), {"ra_to_ha": lambda _, ra: telescope.ha})(), + telescope, "get_site", lambda: SimpleNamespace(ra_to_ha=ra_to_ha) ) return telescope @@ -325,6 +329,23 @@ def test_tracking_past_the_limit_flips(self, pier_telescope): # 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)