diff --git a/default_config.json b/default_config.json index 7ce50978..e1694891 100644 --- a/default_config.json +++ b/default_config.json @@ -16,6 +16,7 @@ "chart_dso": 128, "chart_reticle": 128, "chart_constellations": 64, + "chart_coord_sys": "horiz", "solve_pixel": [256, 256], "gps_type": "ublox", "gps_baud_rate": 9600, diff --git a/python/PiFinder/calc_utils.py b/python/PiFinder/calc_utils.py index 112271a3..af474143 100644 --- a/python/PiFinder/calc_utils.py +++ b/python/PiFinder/calc_utils.py @@ -388,6 +388,25 @@ def ra_to_ha(self, ra_deg, dt): return ha_hrs * 180 / 12 # Hour angle [deg] + def radec_to_pa(self, ra_deg, dec_deg, dt): + """ + Returns the parallactic angle of an object at (ra, dec) as seen from an + observer at latitiude, lat and time, dt. See hadec_to_pa() for how + parallactic angle is defined. + + INPUTS: + ra_deg: Right ascension [deg] + dec_deg: Declination [deg] + dt: Python datetime object (must be timezone-aware) + + RETURNS: + pa_deg: Parallactic angle [deg] + """ + ha_deg = self.ra_to_ha(ra_deg, dt) # Note that HA is in deg + lat_deg = self._observer_geoid.latitude.degrees + pa_deg = hadec_to_pa(ha_deg, dec_deg, lat_deg) + return pa_deg # Parallactic angle [deg] + def radec_to_roll(self, ra_deg, dec_deg, dt): """ Returns the roll (field rotation) of an object at (ra, dec) as diff --git a/python/PiFinder/integrator.py b/python/PiFinder/integrator.py index 2457f487..598d8cb9 100644 --- a/python/PiFinder/integrator.py +++ b/python/PiFinder/integrator.py @@ -13,8 +13,8 @@ - Refactor into class PointingTracker """ +from __future__ import annotations # To support | in typehints (remove this for Python 3.10+) -import datetime import queue import time import copy @@ -52,9 +52,6 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa solved = get_initialized_solved_dict() cfg = config.Config() - mount_type = cfg.get_option("mount_type") - logger.debug(f"mount_type = {mount_type}") - # Set up dead-reckoning tracking by the IMU: imu_dead_reckoning = ImuDeadReckoning(cfg.get_option("screen_direction")) # imu_dead_reckoning.set_cam2scope_alignment(q_scope2cam) # TODO: Enable when q_scope2cam is available from alignment @@ -65,6 +62,7 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa last_solve_time = time.time() while True: + pointing_updated = False # Flag to track if pointing was updated in this loop state_utils.sleep_for_framerate(shared_state) # Check for new camera solve in queue @@ -75,6 +73,7 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa pass if type(next_image_solve) is dict: + # TODO: Refactor this bit: # For camera solves, always start from last successful camera solve # NOT from shared_state (which may contain IMU drift) # This prevents IMU noise accumulation during failed solves @@ -100,76 +99,46 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa # For failed solves, preserve ALL position data from previous solve # Don't recalculate from GPS (causes drift from GPS noise) - # Set solve_source and push camera solves immediately if solved["RA"] is not None: + # Successfully plate-solved: last_image_solve = copy.deepcopy(solved) solved["solve_source"] = "CAM" - # Calculate constellation for successful solve - solved["constellation"] = ( - calc_utils.sf_utils.radec_to_constellation( - solved["RA"], solved["Dec"] - ) - ) shared_state.set_solve_state(True) # We have a new image solve: Use plate-solving for RA/Dec update_plate_solve_and_imu(imu_dead_reckoning, solved) + pointing_updated = True else: # Failed solve - clear constellation solved["solve_source"] = "CAM_FAILED" - solved["constellation"] = "" - + solved["constellation"] = "" # NOTE: This gets over-written by IMU dead-reckoning # Push failed solved immediately # This ensures auto-exposure sees Matches=0 for failed solves shared_state.set_solution(solved) shared_state.set_solve_state(False) - elif imu_dead_reckoning.tracking: + if imu_dead_reckoning.tracking and not pointing_updated: # Previous plate-solve exists so use IMU dead-reckoning from # the last plate solved coordinates. imu = shared_state.imu() if imu: update_imu(imu_dead_reckoning, solved, last_image_solve, imu) + pointing_updated = True - # Push IMU updates only if newer than last push - if ( - solved["RA"] and solved["solve_time"] > last_solve_time - # and solved["solve_source"] == "IMU" - ): - last_solve_time = time.time() # TODO: solve_time is ambiguous because it's also used for IMU dead-reckoning - - # Set location for roll and altaz calculations. - # TODO: Is it necessary to set location? - # TODO: Altaz doesn't seem to be required for catalogs when in - # EQ mode? Could be disabled in future when in EQ mode? - location = shared_state.location() - dt = shared_state.datetime() - if location: - calc_utils.sf_utils.set_location( - location.lat, location.lon, location.altitude - ) - - # Set the roll so that the chart is displayed appropriately for the mount type - solved["Roll"] = get_roll_by_mount_type( - solved["RA"], solved["Dec"], location, dt, mount_type - ) - - # Update remaining solved keys - # Calculate constellation for current position - solved["constellation"] = calc_utils.sf_utils.radec_to_constellation( - solved["RA"], solved["Dec"] - ) # TODO: Can the outer brackets be omitted? - - # Set Alt/Az because it's needed for the catalogs for the - # Alt/Az mount type. TODO: Can this be moved to the catalog? - dt = shared_state.datetime() - if location and dt: - solved["Alt"], solved["Az"] = calc_utils.sf_utils.radec_to_altaz( - solved["RA"], solved["Dec"], dt - ) - - # Push IMU update - shared_state.set_solution(solved) - shared_state.set_solve_state(True) + # Update Alt, Az only if newer than last push + if pointing_updated and solved["solve_time"] > last_solve_time: + solved["constellation"] = get_constellation(solved["RA"], solved["Dec"]) + + # TODO: Altaz doesn't seem to be required for catalogs when in + # EQ mode? Could be disabled in future when in EQ mode? + solved["Alt"], solved["Az"] = get_alt_az(solved["RA"], solved["Dec"], + shared_state.location(), + shared_state.datetime()) + + if (solved["RA"] is not None) and (solved["Dec"] is not None): + # Push new solved to shared state + shared_state.set_solution(solved) + shared_state.set_solve_state(True) + last_solve_time = solved["solve_time"] except EOFError: logger.error("Main no longer running for integrator") @@ -177,7 +146,6 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa # ======== Wrapper and helper functions =============================== - def update_plate_solve_and_imu(imu_dead_reckoning: ImuDeadReckoning, solved: dict): """ Wrapper for ImuDeadReckoning.update_plate_solve_and_imu() to @@ -225,6 +193,7 @@ def update_imu( imu["quat"], quaternion.quaternion ), "Expecting quaternion.quaternion type" # TODO: Can be removed later q_x2imu = imu["quat"] # Current IMU measurement (quaternion) + imu_time = time.time() # When moving, switch to tracking using the IMU angle_moved = qt.get_quat_angular_diff(last_image_solve["imu_quat"], q_x2imu) @@ -256,8 +225,7 @@ def update_imu( # Store the current scope pointing estimate scope_eq = imu_dead_reckoning.get_scope_radec() solved["RA"], solved["Dec"], solved["Roll"] = scope_eq.get_deg(use_none=True) - - solved["solve_time"] = time.time() + solved["solve_time"] = imu_time solved["solve_source"] = "IMU" # Logging for states updated in solved: @@ -275,6 +243,28 @@ def update_imu( ) +def get_constellation(RA_deg, Dec_deg) -> str: + """ + Get constellation name from the current RA/Dec position. + """ + if RA_deg is None or Dec_deg is None: + return "" + else: + return calc_utils.sf_utils.radec_to_constellation(RA_deg, Dec_deg) + + +def get_alt_az(RA_deg, Dec_deg, location, dt) -> tuple[float | None, float | None]: + """ + Get Alt/Az from RA/Dec, location and datetime. + RETURNS: alt_deg, az_deg + """ + if RA_deg is None or Dec_deg is None or location is None or dt is None: + return None, None + else: + calc_utils.sf_utils.set_location(location.lat, location.lon, location.altitude) + return calc_utils.sf_utils.radec_to_altaz(RA_deg, Dec_deg, dt) + + def set_cam2scope_alignment(imu_dead_reckoning: ImuDeadReckoning, solved: dict): """ Set alignment. @@ -295,58 +285,3 @@ def set_cam2scope_alignment(imu_dead_reckoning: ImuDeadReckoning, solved: dict): # Set alignment in imu_dead_reckoning imu_dead_reckoning.set_cam2scope_alignment(solved_cam, solved_scope) - - -def get_roll_by_mount_type( - ra_deg: float, # Right Ascension of the target in degrees - dec_deg: float, # Declination of the target in degrees - location, # astropy EarthLocation object or None - dt: datetime.datetime, # datetime.datetime object or None - mount_type: str, # "Alt/Az" or "EQ" -) -> float: - """ - Returns the roll (in degrees) depending on the mount type so that the chart - is displayed appropriately for the mount type. The RA and Dec of the target - should be provided (in degrees). - - * Alt/Az mount: Display the chart in the horizontal coordinate so that up - in the chart points to the Zenith. - * EQ mount: Display the chart in the equatorial coordinate system with the - NCP up so roll = 0. - - Assumes that location has already been set in calc_utils.sf_utils. - """ - if mount_type == "Alt/Az": - # Altaz mounts: Display chart in horizontal coordinates - if location and dt: - # We have location and time/date (and assume that location has been set) - # Roll at the target RA/Dec in the horizontal frame - roll_deg = calc_utils.sf_utils.radec_to_roll(ra_deg, dec_deg, dt) - - # HACK: - # The IMU direction flips at a certaint point. Could due to a - # an issue in the formula in calc_utils.sf_utils.hadec_to_roll() - # This is a temperary hack for testing. - ha_deg = calc_utils.sf_utils.ra_to_ha(ra_deg, dt) - roll_deg = ( - roll_deg - np.sign(ha_deg) * 180 - ) # In essence, gives: roll_deg = -pa_deg - # End of HACK - else: - # No position or time/date available, so set roll to 0.0 - roll_deg = 0.0 - elif mount_type == "EQ": - # EQ-mounts: Display chart with NCP up so roll = 0.0 - roll_deg = 0.0 - else: - logger.error(f"Unknown mount type: {mount_type}. Cannot set roll.") - roll_deg = 0.0 - - # If location is available, adjust roll for hemisphere: - # Altaz: North up in northern hemisphere, South up in southern hemisphere - # EQ mounts: NCP up in northern hemisphere, SCP up in southern hemisphere - if location: - if location.lat < 0.0: - roll_deg += 180.0 # Southern hemisphere - - return roll_deg diff --git a/python/PiFinder/ui/align.py b/python/PiFinder/ui/align.py index 976ea37c..71f9aa8b 100644 --- a/python/PiFinder/ui/align.py +++ b/python/PiFinder/ui/align.py @@ -14,6 +14,7 @@ from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder import plot from PiFinder.ui.base import UIModule +from PiFinder.ui.chart import get_chart_rotation_angle def align_on_radec(ra, dec, command_queues, config_object, shared_state) -> bool: @@ -215,26 +216,25 @@ def update(self, force=False): last_solve_time = self.solution["solve_time"] if self.solution_is_new(last_solve_time) or force: - # This needs to be called first to set RA/DEC/ROLL if self.align_mode: # We want to use the CAMERA solve as # it's not updated by the IMU and we'll be moving # the reticle to the star - image_obj, self.visible_stars = self.starfield.plot_starfield( - self.solution["camera_solve"]["RA"], - self.solution["camera_solve"]["Dec"], - self.solution["camera_solve"]["Roll"], - constellation_brightness, - shade_frustrum=True, - ) + chart_center = self.solution["camera_solve"] else: - image_obj, self.visible_stars = self.starfield.plot_starfield( - self.solution["camera_center"]["RA"], - self.solution["camera_center"]["Dec"], - self.solution["camera_center"]["Roll"], - constellation_brightness, - shade_frustrum=True, - ) + chart_center = self.solution["camera_center"] + + chart_rot_angle = get_chart_rotation_angle( + chart_center["RA"], chart_center["Dec"], + chart_coord_sys=self.config_object.get_option("chart_coord_sys"), + location=self.shared_state.location(), + dt=self.shared_state.datetime() + ) + # This needs to be called first to set RA/DEC/chart_rot_angle + image_obj, self.visible_stars = self.starfield.plot_starfield( + chart_center["RA"], chart_center["Dec"], chart_rot_angle, + constellation_brightness, shade_frustrum=True, + ) image_obj = ImageChops.multiply( image_obj.convert("RGB"), self.colors.red_image @@ -454,8 +454,7 @@ def solution_is_new(self, last_solve_time): if last_solve_time <= self.last_update: return False if ( - self.solution["Roll"] is None - or self.solution["RA"] is None + self.solution["RA"] is None or self.solution["Dec"] is None ): return False diff --git a/python/PiFinder/ui/chart.py b/python/PiFinder/ui/chart.py index 670e9c61..55349f68 100644 --- a/python/PiFinder/ui/chart.py +++ b/python/PiFinder/ui/chart.py @@ -5,7 +5,10 @@ This module contains the chart (starfield + constellation lines) UI Module class """ +from __future__ import annotations # To support | in typehints (remove this for Python 3.10+) +import datetime +import logging import time from PIL import ImageChops, Image @@ -16,6 +19,9 @@ from PiFinder import calc_utils +logger = logging.getLogger("Chart") + + class UIChart(UIModule): __title__ = "CHART" __help_name__ = "chart" @@ -157,11 +163,17 @@ def update(self, force=False): self.plot_no_solve() elif self.solution_is_new(last_solve_time): # Solution is new so plot the updated chart - # This needs to be called first to set RA/DEC/ROLL + chart_rot_angle = get_chart_rotation_angle( + self.solution["RA"], self.solution["Dec"], + chart_coord_sys=self.config_object.get_option("chart_coord_sys"), + location=self.shared_state.location(), + dt=self.shared_state.datetime() + ) + # This needs to be called first to set RA/DEC/chart_rot_angle image_obj, _visible_stars = self.starfield.plot_starfield( self.solution["RA"], self.solution["Dec"], - self.solution["Roll"], + chart_rot_angle, constellation_brightness, ) image_obj = ImageChops.multiply( @@ -235,8 +247,7 @@ def solution_is_new(self, last_solve_time): if last_solve_time <= self.last_update: return False if ( - self.solution["Roll"] is None - or self.solution["RA"] is None + self.solution["RA"] is None or self.solution["Dec"] is None ): return False @@ -263,3 +274,53 @@ def key_square(self): self.fov_index = 1 self.set_fov(self.fov_list[self.fov_index]) self.update() + + + +def get_chart_rotation_angle( + ra_deg: float, # Right Ascension of the target in degrees + dec_deg: float, # Declination of the target in degrees + chart_coord_sys: str, + location=None, + dt: datetime.datetime | None = None, +) -> float: + """ + Returns angle (in degrees) to rotate the chart by depending on the + configured chart coordinate system. This was previously called "roll". + Chart will be plotted rotated around (RA, Dec); +ve means anti-clockwise + rotation. The RA and Dec of the target should be provided (in degrees). + + * horiz: Display the chart in the horizontal coordinate so that up in the + chart points to the Zenith. + * EQ (Auto): Display the chart in the equatorial coordinate system. + Automatically select NCP or SCP-up based on location. + * EQ (North-up), EQ (South-up): Display chart in the equatorial coordinate + system with NCP or SCP up. + """ + if (ra_deg is None) or (dec_deg is None): + return None # Can't calculate without RA/Dec + + if chart_coord_sys == "horiz": + # Horizontal coordinates (alt/az): + if location and dt: + calc_utils.sf_utils.set_location(location.lat, location.lon, location.altitude) + # Use -parallactic_angle + rot_deg = -calc_utils.sf_utils.radec_to_pa(ra_deg, dec_deg, dt) + else: + # No position or time/date available. Default to display in equatorial coordinate + rot_deg = 0.0 # NCP up + elif chart_coord_sys == "eq_auto": + # Equatorial coordinates: (North-up/south-up depending on latitude) + rot_deg = 0.0 # NCP up (default & northern hemisphere) + if location: + if location.lat < 0.0: + rot_deg = 180.0 # SCP up (for southern hemisphere) + elif chart_coord_sys == "eq_north_up": + rot_deg = 0.0 + elif chart_coord_sys == "eq_south_up": + rot_deg = 180.0 + else: + logger.error(f"Unknown chart coordinate system: {chart_coord_sys}. Defaulting to EQ North-up.") + rot_deg = 0.0 # NCP up + + return rot_deg diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index 52c3bad7..6a4f15bd 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -771,6 +771,30 @@ def _(key: str) -> Any: "select": "single", "label": "chart_settings", "items": [ + { + "name": _("Coordinate Sys."), + "class": UITextMenu, + "select": "single", + "config_option": "chart_coord_sys", + "items": [ + { + "name": _("Horizontal"), + "value": "horiz", + }, + { + "name": _("EQ (Auto)"), + "value": "eq_auto", + }, + { + "name": _("EQ (North-up)"), + "value": "eq_north_up", + }, + { + "name": _("EQ (South-up)"), + "value": "eq_south_up", + }, + ], + }, { "name": _("Reticle"), "class": UITextMenu,