Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d76ec1c
Fix: Horizontal coordinate orientation (use -parallactic_angle)
Apr 30, 2026
848ef57
Remove duplicate definition of dt
May 1, 2026
29cd601
Encapsulate functionality to set Alt, Az, Roll in solved dict to a func
May 1, 2026
f53401a
1) Ensure that AltAz is calculated when plate solved. 2) Fall back to…
May 1, 2026
b898825
Fix: No need to add 180 deg to -parallactic_angle for horizontal coor…
May 2, 2026
ba66685
Add "Coordinate Sys." to settings/Chart. (TODO need to update other l…
May 2, 2026
ef04677
Rename settings
May 2, 2026
265a2a3
Add default config
May 2, 2026
b918b4d
Rename --> chart_coord_sys
May 2, 2026
ae5cb30
Use chart_coord_sys configuration to determine roll
May 2, 2026
1a8c4b1
Rationalize solve_time. Record solve_time and push updates when solve…
May 2, 2026
27033ab
Re-order functions
May 2, 2026
507c2bc
Refactor: Explicitly return values rather than changing the solved di…
May 2, 2026
7c53700
import __future__ to support | for type hints
May 3, 2026
6a0c892
Fix incorrect setting value. Also rename settings for clarity
May 7, 2026
2ca22c1
Fix typo bug
May 7, 2026
25d4f6e
Fix: Gave error when location or dt weren't defined
May 8, 2026
e50cea3
Move get_roll_by_chart_coord_sys() to chart.py
May 9, 2026
4ffcb8f
Fix bug
May 9, 2026
a4e71e3
Refactor
May 9, 2026
790fc24
Make get_roll_by_chart_coord_sys() a func
May 9, 2026
7976226
Rename roll --> chart_rotation_angle
May 9, 2026
c49c8f2
Roll is not needed
May 9, 2026
610c9eb
Edit comments
May 9, 2026
68b6d78
align.py: Re-use get_chart_rotation_angle()
May 9, 2026
0206f1c
menu: Move chart_coordinate_sys further up
May 9, 2026
bce9590
Fix logger issue - flagged up by nox lint check
May 9, 2026
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
1 change: 1 addition & 0 deletions default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions python/PiFinder/calc_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
159 changes: 47 additions & 112 deletions python/PiFinder/integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -100,84 +99,53 @@ 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")


# ======== 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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
33 changes: 16 additions & 17 deletions python/PiFinder/ui/align.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading