Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/configuring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
8 changes: 8 additions & 0 deletions src/chimera/cli/tel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
5 changes: 3 additions & 2 deletions src/chimera/core/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move the coord convention adjustment to CoordUtil.ra_to_ha itself.


def ha_to_ra(self, ha: float):
return float(
Expand Down
2 changes: 1 addition & 1 deletion src/chimera/instruments/faketelescope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
61 changes: 61 additions & 0 deletions src/chimera/instruments/telescope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This boolean is not needed, you can just take the desired flip/not-flip and return inside _check_pier_flip. A decision doesn't carry for the next cycle, it is always evaluated again.


# 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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double check if the epoch return by get_position_ra_dec would be the expected one for the next slew so we don't move targets by mistake.

# 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):
Expand Down
4 changes: 4 additions & 0 deletions src/chimera/interfaces/telescope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions tests/chimera/core/test_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva <ph.silva@gmail.com>

import datetime as dt
import math
import time

import msgspec
Expand Down Expand Up @@ -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."""
Expand Down
167 changes: 167 additions & 0 deletions tests/chimera/instruments/test_telescope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)]
Loading