diff --git a/.gitignore b/.gitignore index f468fdb03..378a49364 100644 --- a/.gitignore +++ b/.gitignore @@ -139,7 +139,7 @@ kicad/PiFinder/PiFinder-backups *.ps? config.json astro_data/hip_main.dat -comets.txt +astro_data/comets.txt # 3d printing business *.3mf diff --git a/astro_data/bright_stars.csv b/astro_data/bright_stars.csv index e1633743f..632b537f2 100644 --- a/astro_data/bright_stars.csv +++ b/astro_data/bright_stars.csv @@ -1,79 +1,79 @@ N,NamedStar,LatinDesignation,RA Hr,RA Min,Dec Deg,Dec Min,Magnitude,Const -0,Acamar,Theta Eridanus,2,58.2,-40,18,3.2,Eri -1,Achernar,Alpha Eridanus,1,37.6,-57,14,0.4,Eri -2,Acrux,Alpha Crucis,12,26.5,-63,5,1.3,Cru -3,Adara,Epsilon Canis Majoris,6,58.6,-28,58,1.5,CMa -4,Albireo,Beta Cygni,19,30.6,27,57,3,Cyg -5,Alcor,80 Ursae Majoris,13,25.2,54,59,4,UMa -6,Alcyone,Eta Tauri,3,47.4,24,6,2.8,Tau -7,Aldebaran,Alpha Tauri,4,35.8,16,30,0.8,Tau -8,Alderamin,Alpha Cephei,21,18.5,62,35,2.4,Cep -9,Algenib,Gamma Pegasi,0,13.2,15,11,2.8,Peg -10,Algieba (Algeiba),Gamma Leonis,10,19.9,19,50,2.6,Leo -11,Algol,Beta Persei,3,8.1,40,57,2.1,Per -12,Alhena,Gamma Geminorum,6,37.6,16,23,1.9,Gem -13,Alioth,Epsilon Ursae Majoris,12,54,55,57,1.7,UMa -14,Alkaid,Eta Ursae Majoris,13,47.5,49,18,1.8,UMa -15,Almaak (Almach),Gamma Andromedae,2,3.8,42,19,2.2,And -16,Alnair,Alpha Gruis,22,8.2,-46,57,1.7,Gru -17,Alnath (Elnath),Beta Tauri,5,26.2,28,36,1.6,Tau -18,Alnilam,Epsilon Orionis,5,36.2,-1,12,1.7,Ori -19,Alnitak,Zeta Orionis,5,40.7,-1,56,2,Ori -20,Alphard,Alpha Hydrae,9,27.5,-8,39,1.9,Hyd -21,Alphekka,Alpha Coronae Borealis,15,34.6,26,42,2.2,CrB -22,Alpheratz,Alpha Andromedae,0,8.3,29,5,2,And -23,Aishain,Beta Aquilae,19,55.2,6,24,3.7,Aql -24,Altair,Alpha Aquilae,19,50.7,8,52,0.7,Aql -25,Ankaa,Alpha Phoenicis,0,26.2,-42,18,2.3,Phe -26,Antares,Alpha Scorpii,16,29.4,-26,25,1,Sco -27,Arcturus,Alpha Bootis,14,15.6,19,10,0.1,Boo -28,Arneb,Alpha Leporis,5,32.7,-17,49,2.5,Leo -29,Bellatrix,Gamma Orionis,5,25.1,6,20,1.6,Ori -30,Betelgeuse,Alpha Orionis,5,55.1,7,24,0.5,Ori -31,Canopus,Alpha Carinae,6,23.9,-52,41,-0.6,Car -32,Capella,Alpha Aurigae,5,16.6,45,59,0,Aur -33,Castor,Alpha Geminorum,7,34.6,31,53,1.5,Gem -34,CorCaroli,Alpha Canum Venaticorum,12,56,38,19,2.8,CVn -35,Deneb,Alpha Cygni,20,41.4,45,16,1.3,Cyg -36,Denebola,Beta Leonis,11,49,14,34,2.1,Leo -37,Diphda,Beta Ceti,0,43.5,-17,59,2,Cet -38,Dubhe,Alpha Ursae Majoris,11,3.7,61,45,1.8,UMa -39,Enif,Epsilon Pegasi,21,44.1,9,52,2.3,Peg -40,Etamin,Gamma Draconis,17,56.6,51,29,2.2,Dra -41,Fomalhaut,Alpha Piscis Austrini,22,57.6,-29,37,1.2,Psc -42,Hadar,Beta Centauri,14,3.8,-60,22,0.6,Cen -43,Hamal,Alpha Arietis,2,7.1,23,27,2,Ari -44,Izar,Epsilon Bootis,14,44.9,27,4,2.5,Boo -45,Kaus Australis,Epsilon Sagittarii,18,24.1,-34,23,1.8,Sag -46,Kocab (Kochab),Beta Ursae Minoris,14,50.7,74,9,2,UMi -47,Markab,Alpha Pegasi,23,4.7,15,12,2.4,Peg -48,Megrez,Delta Ursae Majoris,12,15.4,57,1,3.3,UMa -49,Menkar,Alpha Ceti,3,2.2,4,5,2.5,Cet -50,Merck,Beta Ursae Majoris,11,1.8,56,22,2.3,UMa -51,Mintaka,Delta Orionis,5,32,0,17,2.2,Ori -52,Mira,Omicron Ceti,2,19.3,-2,58,6.5,Cet -53,Mirach,Beta Andromedae,1,9.7,35,37,2,And -54,Mirphak,Alpha Persei,3,24.3,49,51,1.8,Per -55,Mizar,Zeta Ursae Majoris,13,23.9,54,55,2.2,UMa -56,Nihal,Beta Leporis,5,28.2,-20,45,2.8,Lep -57,Nunki,Sigma Sagittarii,18,55.2,-26,17,2,Sag -58,Phad,Gamma Ursae Majoris,11,53.8,53,41,2.4,UMa -59,Polaris,Alpha Ursae Minoris,2,31.8,89,15,2,UMi -60,Pollux,Beta Geminorum,7,45.3,28,1,1.2,Gem -61,Procyon,Alpha Canis Minoris,7,39.3,5,13,0.4,CMi -62,Rasalgethi,Alpha Herculis,17,14.6,14,23,3.4,Her -63,Rasalhague,Alpha Ophiuchi,17,34.9,12,33,2,Oph -64,Regulus,Alpha Leonis,10,8.3,11,58,1.4,Leo -65,Rigel,Beta Orionis,5,14.5,-8,12,0.2,Ori -66,Sadalmelik,Alpha Aquarii,22,5.7,0,19,2.9,Aql -67,Saiph,Kappa Orionis,5,47.7,-9,40,2,Ori -68,Scheet,Beta Pegasi,23,3.7,28,4,2.4,Peg -69,Shaula,Lambda Scorpii,17,33.6,-37,6,1.6,Sco -70,Shedir (Shedor),Alpha Cassiopeiae,0,40.5,56,32,2.2,Cas -71,Sirius,Alpha Canis Majoris,6,45.1,-16,42,-1.4,CMa -72,Spica,Alpha Viriginis,13,25.1,-11,9,1,Vir -73,Tarazed,Gamma Aquilae,19,46.2,10,36,2.7,Aql -74,Thuban,Alpha Draconis,14,4.3,64,22,3.6,Dra -75,Unukalhai,Alpha Serpentis,15,44.2,6,25,2.6,Ser -76,Vega,Alpha Lyrce,18,36.9,38,47,0,Lyr -77,Vindemiatrix,Epsilon Virginis,13,2.1,10,57,2.8,Vir \ No newline at end of file +1,Acamar,Theta Eridanus,2,58.2,-40,18,3.2,Eri +2,Achernar,Alpha Eridanus,1,37.6,-57,14,0.4,Eri +3,Acrux,Alpha Crucis,12,26.5,-63,5,1.3,Cru +4,Adara,Epsilon Canis Majoris,6,58.6,-28,58,1.5,CMa +5,Albireo,Beta Cygni,19,30.6,27,57,3,Cyg +6,Alcor,80 Ursae Majoris,13,25.2,54,59,4,UMa +7,Alcyone,Eta Tauri,3,47.4,24,6,2.8,Tau +8,Aldebaran,Alpha Tauri,4,35.8,16,30,0.8,Tau +9,Alderamin,Alpha Cephei,21,18.5,62,35,2.4,Cep +10,Algenib,Gamma Pegasi,0,13.2,15,11,2.8,Peg +11,Algieba (Algeiba),Gamma Leonis,10,19.9,19,50,2.6,Leo +12,Algol,Beta Persei,3,8.1,40,57,2.1,Per +13,Alhena,Gamma Geminorum,6,37.6,16,23,1.9,Gem +14,Alioth,Epsilon Ursae Majoris,12,54,55,57,1.7,UMa +15,Alkaid,Eta Ursae Majoris,13,47.5,49,18,1.8,UMa +16,Almaak (Almach),Gamma Andromedae,2,3.8,42,19,2.2,And +17,Alnair,Alpha Gruis,22,8.2,-46,57,1.7,Gru +18,Alnath (Elnath),Beta Tauri,5,26.2,28,36,1.6,Tau +19,Alnilam,Epsilon Orionis,5,36.2,-1,12,1.7,Ori +20,Alnitak,Zeta Orionis,5,40.7,-1,56,2,Ori +21,Alphard,Alpha Hydrae,9,27.5,-8,39,1.9,Hyd +22,Alphekka,Alpha Coronae Borealis,15,34.6,26,42,2.2,CrB +23,Alpheratz,Alpha Andromedae,0,8.3,29,5,2,And +24,Aishain,Beta Aquilae,19,55.2,6,24,3.7,Aql +25,Altair,Alpha Aquilae,19,50.7,8,52,0.7,Aql +26,Ankaa,Alpha Phoenicis,0,26.2,-42,18,2.3,Phe +27,Antares,Alpha Scorpii,16,29.4,-26,25,1,Sco +28,Arcturus,Alpha Bootis,14,15.6,19,10,0.1,Boo +29,Arneb,Alpha Leporis,5,32.7,-17,49,2.5,Leo +30,Bellatrix,Gamma Orionis,5,25.1,6,20,1.6,Ori +31,Betelgeuse,Alpha Orionis,5,55.1,7,24,0.5,Ori +32,Canopus,Alpha Carinae,6,23.9,-52,41,-0.6,Car +33,Capella,Alpha Aurigae,5,16.6,45,59,0,Aur +34,Castor,Alpha Geminorum,7,34.6,31,53,1.5,Gem +35,CorCaroli,Alpha Canum Venaticorum,12,56,38,19,2.8,CVn +36,Deneb,Alpha Cygni,20,41.4,45,16,1.3,Cyg +37,Denebola,Beta Leonis,11,49,14,34,2.1,Leo +38,Diphda,Beta Ceti,0,43.5,-17,59,2,Cet +39,Dubhe,Alpha Ursae Majoris,11,3.7,61,45,1.8,UMa +40,Enif,Epsilon Pegasi,21,44.1,9,52,2.3,Peg +41,Etamin,Gamma Draconis,17,56.6,51,29,2.2,Dra +42,Fomalhaut,Alpha Piscis Austrini,22,57.6,-29,37,1.2,Psc +43,Hadar,Beta Centauri,14,3.8,-60,22,0.6,Cen +44,Hamal,Alpha Arietis,2,7.1,23,27,2,Ari +45,Izar,Epsilon Bootis,14,44.9,27,4,2.5,Boo +46,Kaus Australis,Epsilon Sagittarii,18,24.1,-34,23,1.8,Sag +47,Kocab (Kochab),Beta Ursae Minoris,14,50.7,74,9,2,UMi +48,Markab,Alpha Pegasi,23,4.7,15,12,2.4,Peg +49,Megrez,Delta Ursae Majoris,12,15.4,57,1,3.3,UMa +50,Menkar,Alpha Ceti,3,2.2,4,5,2.5,Cet +51,Merck,Beta Ursae Majoris,11,1.8,56,22,2.3,UMa +52,Mintaka,Delta Orionis,5,32,0,17,2.2,Ori +53,Mira,Omicron Ceti,2,19.3,-2,58,6.5,Cet +54,Mirach,Beta Andromedae,1,9.7,35,37,2,And +55,Mirphak,Alpha Persei,3,24.3,49,51,1.8,Per +56,Mizar,Zeta Ursae Majoris,13,23.9,54,55,2.2,UMa +57,Nihal,Beta Leporis,5,28.2,-20,45,2.8,Lep +58,Nunki,Sigma Sagittarii,18,55.2,-26,17,2,Sag +59,Phad,Gamma Ursae Majoris,11,53.8,53,41,2.4,UMa +60,Polaris,Alpha Ursae Minoris,2,31.8,89,15,2,UMi +61,Pollux,Beta Geminorum,7,45.3,28,1,1.2,Gem +62,Procyon,Alpha Canis Minoris,7,39.3,5,13,0.4,CMi +63,Rasalgethi,Alpha Herculis,17,14.6,14,23,3.4,Her +64,Rasalhague,Alpha Ophiuchi,17,34.9,12,33,2,Oph +65,Regulus,Alpha Leonis,10,8.3,11,58,1.4,Leo +66,Rigel,Beta Orionis,5,14.5,-8,12,0.2,Ori +67,Sadalmelik,Alpha Aquarii,22,5.7,0,19,2.9,Aql +68,Saiph,Kappa Orionis,5,47.7,-9,40,2,Ori +69,Scheet,Beta Pegasi,23,3.7,28,4,2.4,Peg +70,Shaula,Lambda Scorpii,17,33.6,-37,6,1.6,Sco +71,Shedir (Shedor),Alpha Cassiopeiae,0,40.5,56,32,2.2,Cas +72,Sirius,Alpha Canis Majoris,6,45.1,-16,42,-1.4,CMa +73,Spica,Alpha Viriginis,13,25.1,-11,9,1,Vir +74,Tarazed,Gamma Aquilae,19,46.2,10,36,2.7,Aql +75,Thuban,Alpha Draconis,14,4.3,64,22,3.6,Dra +76,Unukalhai,Alpha Serpentis,15,44.2,6,25,2.6,Ser +77,Vega,Alpha Lyrce,18,36.9,38,47,0,Lyr +78,Vindemiatrix,Epsilon Virginis,13,2.1,10,57,2.8,Vir diff --git a/astro_data/pifinder_objects.db b/astro_data/pifinder_objects.db index c1f84bc53..ac3c348a1 100644 Binary files a/astro_data/pifinder_objects.db and b/astro_data/pifinder_objects.db differ diff --git a/case/v2.5/pi_mount.stl b/case/v2.5/pi_mount.stl index 0ba10a20d..dc88fad44 100644 Binary files a/case/v2.5/pi_mount.stl and b/case/v2.5/pi_mount.stl differ diff --git a/case/v3/common/shroud_back.stl b/case/v3/common/shroud_back.stl index da59c73bb..8becaa637 100644 Binary files a/case/v3/common/shroud_back.stl and b/case/v3/common/shroud_back.stl differ diff --git a/case/v3/common/shroud_front.stl b/case/v3/common/shroud_front.stl index a270fa729..7d9696a33 100644 Binary files a/case/v3/common/shroud_front.stl and b/case/v3/common/shroud_front.stl differ diff --git a/default_config.json b/default_config.json index f79df7c1f..cc294056c 100644 --- a/default_config.json +++ b/default_config.json @@ -16,6 +16,7 @@ "chart_constellations": 64, "solve_pixel": [256, 256], "gps_type": "ublox", + "gps_baud_rate": 9600, "filter.selected_catalogs": [ "NGC", "M", diff --git a/docs/source/dev_arch.rst b/docs/source/dev_arch.rst index 1f512ddc7..969ea6cef 100644 --- a/docs/source/dev_arch.rst +++ b/docs/source/dev_arch.rst @@ -219,12 +219,12 @@ There are three types of shared state in PiFinder SharedStateObj( power_state=1, solve_state=True, - solution={'RA': 22.86683471463411, 'Dec': 15.347716050003328, 'imu_pos': [171.39798541261814, 202.7646132036331, 358.2794741322842], + solution={'RA': 22.86683471463411, 'Dec': 15.347716050003328, 'solve_time': 1695297930.5532792, 'cam_solve_time': 1695297930.5532837, 'Roll': 306.2951794424281, 'FOV': 10.200729425086111, RMSE': 21.995567413046142, 'Matches': 12, 'Prob': 6.987725483613384e-13, 'T_solve': 15.00384000246413, 'RA_target': 22.86683471463411, 'Dec_target': 15.347716050003328, 'T_extract': 75.79255499877036, 'Alt': None, 'Az': None, 'solve_source': 'CAM', 'constellation': 'Psc'}, - imu={'moving': False, 'move_start': 1695297928.69749, 'move_end': 1695297928.764207, 'pos': [171.39798541261814, 202.7646132036331, 358.2794741322842], - 'start_pos': [171.4009455613444, 202.76321535004726, 358.2587208386012], 'status': 3}, + imu={'moving': False, 'move_start': 1695297928.69749, 'move_end': 1695297928.764207, + 'status': 3}, location={'lat': 59.05139745, 'lon': 7.987654, 'altitude': 151.4, 'gps_lock': False, 'timezone': 'Europe/Stockholm', 'last_gps_lock': None}, datetime=None, screen=, diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index cd5994f69..690d7bac6 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -80,8 +80,7 @@ Using the PiFinder The PiFinder features a scolling menu with the active option highlighted in the middle of the screen. -.. image:: images/quick_start/main_menu_01_docs.png -.. image:: images/quick_start/main_menu_02_docs.png +.. image:: images/quick_start/pifinder_main_menu.png All the features of the PiFinder are available through this menu by scrolling, selecting options or moving to new menu screens. diff --git a/python/PiFinder/calc_utils.py b/python/PiFinder/calc_utils.py index 633c3c8dd..311718181 100644 --- a/python/PiFinder/calc_utils.py +++ b/python/PiFinder/calc_utils.py @@ -168,8 +168,11 @@ def aim_degrees(shared_state, mount_type, screen_direction, target): else: # EQ Mount type ra_diff = target.ra - solution["RA"] + ra_diff = (ra_diff + 180) % 360 - 180 # Convert to -180 to +180 + dec_diff = target.dec - solution["Dec"] dec_diff = (dec_diff + 180) % 360 - 180 + return ra_diff, dec_diff return None, None diff --git a/python/PiFinder/camera_interface.py b/python/PiFinder/camera_interface.py index a4a903477..073b94fc2 100644 --- a/python/PiFinder/camera_interface.py +++ b/python/PiFinder/camera_interface.py @@ -13,10 +13,13 @@ import queue import time from PIL import Image -from PiFinder import state_utils, utils +import numpy as np from typing import Tuple import logging +from PiFinder import state_utils, utils +import PiFinder.pointing_model.quaternion_transforms as qt + logger = logging.getLogger("Camera.Interface") @@ -111,13 +114,15 @@ def get_image_loop( imu_end = shared_state.imu() # see if we moved during exposure - reading_diff = 0 if imu_start and imu_end: - reading_diff = ( - abs(imu_start["pos"][0] - imu_end["pos"][0]) - + abs(imu_start["pos"][1] - imu_end["pos"][1]) - + abs(imu_start["pos"][2] - imu_end["pos"][2]) + # Returns the pointing difference between successive IMU quaternions as + # an angle (radians). Note that this also accounts for rotation around the + # scope axis. Returns an angle in radians. + pointing_diff = qt.get_quat_angular_diff( + imu_start["quat"], imu_end["quat"] ) + else: + pointing_diff = 0.0 camera_image.paste(base_image) shared_state.set_last_image_metadata( @@ -125,7 +130,9 @@ def get_image_loop( "exposure_start": image_start_time, "exposure_end": image_end_time, "imu": imu_end, - "imu_delta": reading_diff, + "imu_delta": np.rad2deg( + pointing_diff + ), # Pointing change during exposure in degrees } ) diff --git a/python/PiFinder/catalog_imports/bright_stars_loader.py b/python/PiFinder/catalog_imports/bright_stars_loader.py index 9ab4bcd63..fa4dfc34e 100644 --- a/python/PiFinder/catalog_imports/bright_stars_loader.py +++ b/python/PiFinder/catalog_imports/bright_stars_loader.py @@ -42,7 +42,7 @@ def load_bright_stars(): dfs = line.split(",") dfs = [d.strip() for d in dfs] other_names = dfs[1:3] - sequence = int(dfs[0]) + 1 + sequence = int(dfs[0]) logging.debug(f"---------------> Bright Stars {sequence=} <---------------") size = "" diff --git a/python/PiFinder/catalog_imports/wds_loader.py b/python/PiFinder/catalog_imports/wds_loader.py index 6bc6e069d..f6f62a04d 100644 --- a/python/PiFinder/catalog_imports/wds_loader.py +++ b/python/PiFinder/catalog_imports/wds_loader.py @@ -122,24 +122,55 @@ def load_wds(): data = read_wds_catalog(data_path) def parse_coordinates_2000(coord): - ra_h = float(coord[:2]) - ra_m = float(coord[2:4]) - ra_s = float(coord[4:5]) * 6 # Convert tenths of minutes to seconds - dec_deg = float(coord[5:8]) - dec_m = float(coord[8:10]) - return ra_to_deg(ra_h, ra_m, ra_s), dec_to_deg(dec_deg, dec_m, 0) - - def parse_coordinates_arcsec(coord): try: + # Check for correct length (WDS identifier is always 10 chars) + if len(coord) != 10: + return None, None + + # Format: HHMM.t±DDMM (10 characters) - example: 00001-0122 ra_h = float(coord[:2]) ra_m = float(coord[2:4]) - ra_s = float(coord[4:9]) - dec_sign = 1 if coord[9] == "+" else -1 - dec_deg = float(coord[10:12]) * dec_sign - dec_m = float(coord[12:14]) - dec_s = float(coord[14:]) - # 00000+7530A 000006.64+752859.8 - except ValueError: + ra_s = float(coord[4:5]) * 6 # Convert tenths of minutes to seconds + dec_sign = 1 if coord[5] == "+" else -1 + dec_deg = float(coord[6:8]) * dec_sign + dec_m = float(coord[8:10]) + return ra_to_deg(ra_h, ra_m, ra_s), dec_to_deg(dec_deg, dec_m, 0) + except (ValueError, IndexError): + return None, None + + def parse_coordinates_arcsec(coord): + try: + # Handle empty, missing, or '.' coordinates + coord_clean = coord.strip() + if not coord_clean or coord_clean == '.': + return None, None + + # Find the sign position (+ or -) + sign_pos = -1 + for i, char in enumerate(coord_clean): + if char in ['+', '-']: + sign_pos = i + break + + if sign_pos == -1: + return None, None + + # Parse RA part (before sign) + ra_part = coord_clean[:sign_pos].strip() + ra_h = float(ra_part[:2]) + ra_m = float(ra_part[2:4]) + ra_s = float(ra_part[4:]) # Variable length seconds + + # Parse DEC part (after sign) + dec_part = coord_clean[sign_pos:] + dec_sign = 1 if dec_part[0] == '+' else -1 + dec_coords = dec_part[1:].strip() # Remove sign + + dec_deg = float(dec_coords[:2]) * dec_sign + dec_m = float(dec_coords[2:4]) + dec_s = float(dec_coords[4:]) if len(dec_coords) > 4 else 0.0 + + except (ValueError, IndexError): return None, None return ra_to_deg(ra_h, ra_m, ra_s), dec_to_deg(dec_deg, dec_m, dec_s) @@ -152,6 +183,13 @@ def handle_multiples(key, values) -> dict: mag1 = round(value["Mag_First"].item(), 2) mag2 = round(value["Mag_Second"].item(), 2) if i == 0: + # Validate RA/DEC in the first (primary) object + if value['ra'] is None or value['dec'] is None or np.isnan(value['ra']) or np.isnan(value['dec']): + logging.error(f"Empty or invalid RA/DEC in handle_multiples for WDS object '{key}'") + logging.error(f" Primary object RA: {value['ra']}, DEC: {value['dec']}") + logging.error(f" Coordinates_2000: '{value['Coordinates_2000']}'") + logging.error(f" Coordinates_Arcsec: '{value['Coordinates_Arcsec']}'") + raise ValueError(f"Invalid RA/DEC coordinates for primary WDS object '{key}': RA={value['ra']}, DEC={value['dec']}") result["ra"] = value["ra"] result["dec"] = value["dec"] result["mag"] = MagnitudeObject([mag1, mag2]) @@ -177,46 +215,42 @@ def handle_multiples(key, values) -> dict: result["description"] = "\n".join(descriptions) return result - # Convert coordinates - ra_2000, dec_2000 = np.vectorize(parse_coordinates_2000)(data["Coordinates_2000"]) - ra_arcsec, dec_arcsec = np.vectorize(parse_coordinates_arcsec)( - data["Coordinates_Arcsec"] - ) - - # Add these new coordinates to the numpy array - new_dtype = data.dtype.descr + [ - ("ra_2000", "f8"), - ("dec_2000", "f8"), - ("ra_arcsec", "f8"), - ("dec_arcsec", "f8"), - ("ra", "f8"), - ("dec", "f8"), - ] + # Add coordinate columns to the numpy array + new_dtype = data.dtype.descr + [("ra", "f8"), ("dec", "f8")] new_data = np.empty(data.shape, dtype=new_dtype) # Copy existing data for name in data.dtype.names: new_data[name] = data[name] - # Add new data - new_data["ra_2000"] = ra_2000 - new_data["dec_2000"] = dec_2000 - new_data["ra_arcsec"] = ra_arcsec - new_data["dec_arcsec"] = dec_arcsec - new_data["ra"] = 0 - new_data["dec"] = 0 - # Replace the old data with the new data data = new_data - # Append new columns to data + # Parse coordinates on demand and assign final values for i, entry in enumerate(data): - if ra_arcsec[i] is None or dec_arcsec[i] is None: - entry["ra"] = ra_2000[i] - entry["dec"] = dec_2000[i] + # Try arcsecond coordinates first + ra_arcsec, dec_arcsec = parse_coordinates_arcsec(entry["Coordinates_Arcsec"]) + + if ra_arcsec is not None and dec_arcsec is not None: + entry["ra"] = ra_arcsec + entry["dec"] = dec_arcsec else: - entry["ra"] = ra_arcsec[i] - entry["dec"] = dec_arcsec[i] + # Fall back to 2000 coordinates + ra_2000, dec_2000 = parse_coordinates_2000(entry["Coordinates_2000"]) + entry["ra"] = ra_2000 + entry["dec"] = dec_2000 + + # Validate RA/DEC values are not empty/invalid + if entry['ra'] is None or entry['dec'] is None or np.isnan(entry['ra']) or np.isnan(entry['dec']): + coord_2000 = entry['Coordinates_2000'] + coord_arcsec = entry['Coordinates_Arcsec'] + logging.error(f"Empty or invalid RA/DEC detected for WDS object at line {i+1}") + logging.error(f" Coordinates_2000: '{coord_2000}'") + logging.error(f" Coordinates_Arcsec: '{coord_arcsec}'") + logging.error(f" Parsed RA_2000: {ra_2000[i]}, DEC_2000: {dec_2000[i]}") + logging.error(f" Parsed RA_arcsec: {ra_arcsec[i]}, DEC_arcsec: {dec_arcsec[i]}") + logging.error(f" Final RA: {entry['ra']}, DEC: {entry['dec']}") + raise ValueError(f"Invalid RA/DEC coordinates for WDS object at line {i+1}: RA={entry['ra']}, DEC={entry['dec']}") # make a dictionary of WDS objects to group duplicates wds_dict = defaultdict(list) diff --git a/python/PiFinder/catalogs.py b/python/PiFinder/catalogs.py index 87a696c59..43377f099 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -66,9 +66,8 @@ class Names: def __init__(self): self.db = ObjectsDatabase() self.id_to_names = self.db.get_object_id_to_names() - self.name_to_id = self.db.get_name_to_object_id() + self.name_to_id = self.db.get_name_to_object_id(self.id_to_names) self._sort_names() - logger.debug("Loaded %i names from database", len(self.names)) def _sort_names(self): """ @@ -398,6 +397,17 @@ def filter_objects(self) -> List[CompositeObject]: if self.catalog_filter is None: return self.get_objects() + # Skip filtering if catalog is empty (deferred catalogs not loaded yet) + if self.get_count() == 0: + logger.debug( + "Skipping filter for empty catalog %s (deferred loading)", + self.catalog_code, + ) + self.filtered_objects = [] + self.filtered_objects_seq = [] + self.last_filtered = time.time() + return self.filtered_objects + self.filtered_objects = self.catalog_filter.apply(self.get_objects()) logger.info( "FILTERED %s %d/%d", @@ -551,6 +561,21 @@ def select_all_catalogs(self): for catalog in self.__catalogs: self.catalog_filter.selected_catalogs.add(catalog.catalog_code) + def is_loading(self) -> bool: + """ + Check if background catalog loading is still in progress. + + Returns: + True if background loader thread is active, False otherwise + """ + return ( + hasattr(self, "_background_loader") + and self._background_loader is not None + and hasattr(self._background_loader, "_thread") + and self._background_loader._thread is not None + and self._background_loader._thread.is_alive() + ) + def __repr__(self): return f"Catalogs(\n{pformat(self.get_catalogs(only_selected=False))})" @@ -833,34 +858,193 @@ def do_timed_task(self): logger.debug("Updated comet catalog") +class CatalogBackgroundLoader: + """ + Handles background loading of deferred catalog objects. + Isolated, testable, and thread-safe. + """ + + def __init__( + self, + deferred_catalog_objects: List[Dict], + objects: Dict[int, Dict], + common_names: Names, + obs_db: ObservationsDatabase, + on_progress: Optional[callable] = None, + on_complete: Optional[callable] = None, + ): + """ + Args: + deferred_catalog_objects: List of catalog_object dicts to load + objects: Object data dict by ID + common_names: Names lookup instance + obs_db: Observations database instance + on_progress: Callback(loaded_count, total_count, catalog_code) + on_complete: Callback(loaded_objects: List[CompositeObject]) + """ + self._deferred_data = deferred_catalog_objects + self._objects = objects + self._names = common_names + self._obs_db = obs_db + self._on_progress = on_progress + self._on_complete = on_complete + + self._loaded_objects: List[CompositeObject] = [] + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._stop_flag = threading.Event() + + # Performance tuning - load in batches with CPU yielding + self.batch_size = 500 # Objects per batch before yielding CPU + self.yield_time = 0.005 # Seconds to sleep between batches + + def start(self) -> None: + """Start background loading in daemon thread""" + if self._thread and self._thread.is_alive(): + return + + self._stop_flag.clear() + self._thread = threading.Thread( + target=self._load_deferred_objects, daemon=True, name="CatalogLoader" + ) + self._thread.start() + + def stop(self) -> None: + """Stop background loading gracefully""" + self._stop_flag.set() + if self._thread: + self._thread.join(timeout=1.0) + + def get_loaded_objects(self) -> List[CompositeObject]: + """Thread-safe access to loaded objects""" + with self._lock: + return self._loaded_objects.copy() + + def _load_deferred_objects(self) -> None: + """Background worker - loads objects in batches with CPU yielding""" + try: + total = len(self._deferred_data) + batch = [] + current_catalog = None + + for i, catalog_obj in enumerate(self._deferred_data): + if self._stop_flag.is_set(): + logger.info("Background loading stopped by request") + return + + # Create composite object with full details + obj = self._create_full_composite_object(catalog_obj) + batch.append(obj) + current_catalog = catalog_obj["catalog_code"] + + # Process batch + if len(batch) >= self.batch_size: + self._commit_batch(batch) + batch = [] + + # Yield CPU to UI/solver processes + time.sleep(self.yield_time) + + # Progress callback + if self._on_progress: + self._on_progress(i + 1, total, current_catalog) + + # Final batch + if batch: + self._commit_batch(batch) + + # Completion callback + if self._on_complete: + with self._lock: + self._on_complete(self._loaded_objects) + + except Exception as e: + logger.error(f"Background loading failed: {e}", exc_info=True) + + def _commit_batch(self, batch: List[CompositeObject]) -> None: + """Thread-safe append of loaded batch""" + with self._lock: + self._loaded_objects.extend(batch) + + def _create_full_composite_object(self, catalog_obj: Dict) -> CompositeObject: + """Create composite object with all details populated""" + object_id = catalog_obj["object_id"] + obj_data = self._objects[object_id] + + # Full object creation with all details + composite_data = { + "id": catalog_obj["id"], + "object_id": object_id, + "ra": obj_data["ra"], + "dec": obj_data["dec"], + "obj_type": obj_data["obj_type"], + "catalog_code": catalog_obj["catalog_code"], + "sequence": catalog_obj["sequence"], + "description": catalog_obj.get("description", ""), + "const": obj_data.get("const", ""), + "size": obj_data.get("size", ""), + "surface_brightness": obj_data.get("surface_brightness", None), + } + + composite_instance = CompositeObject.from_dict(composite_data) + composite_instance.names = self._names.id_to_names.get(object_id, []) + composite_instance.logged = self._obs_db.check_logged(composite_instance) + + # Parse magnitude + try: + mag = MagnitudeObject.from_json(obj_data.get("mag", "")) + composite_instance.mag = mag + composite_instance.mag_str = mag.calc_two_mag_representation() + except Exception: + composite_instance.mag = MagnitudeObject([]) + composite_instance.mag_str = "-" + + composite_instance._details_loaded = True + return composite_instance + + class CatalogBuilder: """ Builds catalogs from the database Merges object table data and catalog_object table data """ - def build(self, shared_state) -> Catalogs: + def build(self, shared_state, ui_queue=None) -> Catalogs: + """ + Build catalogs with priority loading for popular catalogs. + + Args: + shared_state: Shared state object + ui_queue: Optional queue to signal completion (for main loop integration) + """ db: Database = ObjectsDatabase() obs_db: Database = ObservationsDatabase() + # list of dicts, one dict for each entry in the catalog_objects table catalog_objects: List[Dict] = [dict(row) for row in db.get_catalog_objects()] objects = db.get_objects() common_names = Names() catalogs_info = db.get_catalogs_dict() - - # Disable WDS for now to work on - # performance and sort/nearest bug - catalogs_info.pop("WDS") - objects = {row["id"]: dict(row) for row in objects} + composite_objects: List[CompositeObject] = self._build_composite( - catalog_objects, objects, common_names, obs_db + catalog_objects, objects, common_names, obs_db, ui_queue ) + # This is used for caching catalog dicts # to speed up repeated searches self.catalog_dicts = {} logger.debug("Loaded %i objects from database", len(composite_objects)) + all_catalogs: Catalogs = self._get_catalogs(composite_objects, catalogs_info) + + # Store catalogs reference for background loader completion + self._pending_catalogs_ref = all_catalogs + + # Pass background loader reference to Catalogs instance so it can check loading status + # This is set in _build_composite() if there are deferred objects + if hasattr(self, "_background_loader") and self._background_loader is not None: + all_catalogs._background_loader = self._background_loader # Initialize planet catalog with whatever date we have for now # This will be re-initialized on activation of Catalog ui module # if we have GPS lock @@ -886,32 +1070,143 @@ def check_catalogs_sequences(self, catalogs: Catalogs): return False return True + def _create_full_composite_object( + self, + catalog_obj: Dict, + objects: Dict[int, Dict], + common_names: Names, + obs_db: ObservationsDatabase, + ) -> CompositeObject: + """Create a composite object with all details populated""" + object_id = catalog_obj["object_id"] + obj_data = objects[object_id] + + # Create composite object with all details + composite_data = { + "id": catalog_obj["id"], + "object_id": object_id, + "ra": obj_data["ra"], + "dec": obj_data["dec"], + "obj_type": obj_data["obj_type"], + "catalog_code": catalog_obj["catalog_code"], + "sequence": catalog_obj["sequence"], + "description": catalog_obj.get("description", ""), + "const": obj_data.get("const", ""), + "size": obj_data.get("size", ""), + "surface_brightness": obj_data.get("surface_brightness", None), + } + + composite_instance = CompositeObject.from_dict(composite_data) + composite_instance.names = common_names.id_to_names.get(object_id, []) + composite_instance.logged = obs_db.check_logged(composite_instance) + + # Parse magnitude + try: + mag = MagnitudeObject.from_json(obj_data.get("mag", "")) + composite_instance.mag = mag + composite_instance.mag_str = mag.calc_two_mag_representation() + except Exception: + composite_instance.mag = MagnitudeObject([]) + composite_instance.mag_str = "-" + + composite_instance._details_loaded = True + return composite_instance + def _build_composite( self, catalog_objects: List[Dict], objects: Dict[int, Dict], common_names: Names, obs_db: ObservationsDatabase, + ui_queue=None, ) -> List[CompositeObject]: - composite_objects: List[CompositeObject] = [] + """ + Build composite objects with priority loading. + Popular catalogs (M, NGC, IC) are loaded immediately. + Other catalogs (WDS, etc.) are loaded in background. + """ + # Separate high-priority catalogs from low-priority ones + priority_catalogs = {"NGC", "IC", "M"} # Most popular catalogs + + priority_objects = [] + deferred_objects = [] for catalog_obj in catalog_objects: - object_id = catalog_obj["object_id"] + if catalog_obj["catalog_code"] in priority_catalogs: + priority_objects.append(catalog_obj) + else: + deferred_objects.append(catalog_obj) - # Merge the two dictionaries - composite_data = objects[object_id] | catalog_obj + # Load priority catalogs synchronously (fast - ~13K objects) + composite_objects = [] + for catalog_obj in priority_objects: + obj = self._create_full_composite_object( + catalog_obj, objects, common_names, obs_db + ) + composite_objects.append(obj) + + # Store reference for background loader completion callback + self._pending_catalogs_ref = None + + # Start background loader for deferred objects + if deferred_objects: + loader = CatalogBackgroundLoader( + deferred_catalog_objects=deferred_objects, + objects=objects, + common_names=common_names, + obs_db=obs_db, + on_progress=self._on_loader_progress, + on_complete=lambda objs: self._on_loader_complete(objs, ui_queue), + ) + loader.start() + + # Store loader reference for potential stop/test access + self._background_loader = loader - # Create an instance from the merged dictionaries - composite_instance = CompositeObject.from_dict(composite_data) - composite_instance.logged = obs_db.check_logged(composite_instance) - composite_instance.names = common_names.get_name(object_id) - mag = MagnitudeObject.from_json(composite_instance.mag) - composite_instance.mag = mag - composite_instance.mag_str = mag.calc_two_mag_representation() - # Append to the result dictionary - composite_objects.append(composite_instance) return composite_objects + def _on_loader_progress(self, loaded: int, total: int, catalog: str) -> None: + """Progress callback - log every 10K objects""" + if loaded % 10000 == 0 or loaded == total: + logger.info(f"Background loading: {loaded}/{total} ({catalog})") + + def _on_loader_complete( + self, loaded_objects: List[CompositeObject], ui_queue + ) -> None: + """Completion callback - integrate deferred objects""" + logger.info( + f"Background loading complete: {len(loaded_objects)} objects loaded" + ) + + # Store loaded objects for catalog integration + if self._pending_catalogs_ref: + catalogs = self._pending_catalogs_ref + + # Group objects by catalog code for batch insertion + objects_by_catalog = {} + for obj in loaded_objects: + if obj.catalog_code not in objects_by_catalog: + objects_by_catalog[obj.catalog_code] = [] + objects_by_catalog[obj.catalog_code].append(obj) + + # Add objects in batches (much faster than one-by-one) + for catalog_code, objects in objects_by_catalog.items(): + catalog = catalogs.get_catalog_by_code(catalog_code) + if catalog: + catalog.add_objects(objects) # Batch add - rebuilds indexes once + logger.info(f"Added {len(objects)} objects to {catalog_code}") + + # Re-filter this catalog now that it has objects + if catalog.catalog_filter: + catalog.filter_objects() + + # Signal main loop that catalogs are fully loaded + if ui_queue: + try: + ui_queue.put("catalogs_fully_loaded") + except Exception as e: + logger.error(f"Failed to signal catalog completion: {e}") + def _get_catalogs( self, composite_objects: List[CompositeObject], catalogs_info: Dict[str, Dict] ) -> Catalogs: diff --git a/python/PiFinder/composite_object.py b/python/PiFinder/composite_object.py index 8ce3b4bf1..021749370 100644 --- a/python/PiFinder/composite_object.py +++ b/python/PiFinder/composite_object.py @@ -77,6 +77,8 @@ class CompositeObject: sequence: int = field(default=0) description: str = field(default="") names: list = field(default_factory=list) + # Background loading support + _details_loaded: bool = field(default=False) image_name: str = field(default="") surface_brightness: float = field(default=0.0) logged: bool = field(default=False) diff --git a/python/PiFinder/db/objects_db.py b/python/PiFinder/db/objects_db.py index f22fb5ed1..66d9a9a15 100644 --- a/python/PiFinder/db/objects_db.py +++ b/python/PiFinder/db/objects_db.py @@ -4,13 +4,24 @@ from PiFinder.db.db import Database from collections import defaultdict import logging +import time class ObjectsDatabase(Database): def __init__(self, db_path=utils.pifinder_db): conn, cursor = self.get_database(db_path) super().__init__(conn, cursor, db_path) + + # Performance optimizations for Pi/SD card environments + logging.info("Applying database performance optimizations...") self.cursor.execute("PRAGMA foreign_keys = ON;") + self.cursor.execute("PRAGMA mmap_size = 268435456;") # 256MB memory mapping + self.cursor.execute("PRAGMA cache_size = -64000;") # 64MB cache (negative = KB) + self.cursor.execute("PRAGMA temp_store = MEMORY;") # Keep temporary data in RAM + self.cursor.execute("PRAGMA journal_mode = WAL;") # Write-ahead logging for better concurrency + self.cursor.execute("PRAGMA synchronous = NORMAL;") # Balanced safety/performance + logging.info("Database optimizations applied") + self.conn.commit() self.bulk_mode = False # Flag to disable commits during bulk operations @@ -44,6 +55,20 @@ def create_tables(self): """ ) + # Create indexes on names table for faster lookups + self.cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_names_object_id + ON names(object_id); + """ + ) + self.cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_names_common_name + ON names(common_name); + """ + ) + # Create catalogs table self.cursor.execute( """ @@ -161,13 +186,26 @@ def get_object_id_to_names(self) -> DefaultDict[int, List[str]]: Returns a dictionary of object_id: [common_name, common_name, ...] duplicates are removed. """ + start_time = time.time() + logging.info("Starting get_object_id_to_names query...") + + query_start = time.time() self.cursor.execute("SELECT object_id, common_name FROM names;") results = self.cursor.fetchall() + query_time = time.time() - query_start + logging.info(f"Database query took {query_time:.2f}s, returned {len(results)} rows") + + process_start = time.time() name_dict = defaultdict(list) for object_id, common_name in results: name_dict[object_id].append(common_name.strip()) for object_id in name_dict: name_dict[object_id] = list(set(name_dict[object_id])) + process_time = time.time() - process_start + logging.info(f"Processing took {process_time:.2f}s, created {len(name_dict)} object entries") + + total_time = time.time() - start_time + logging.info(f"get_object_id_to_names total time: {total_time:.2f}s") return name_dict def search_common_names(self, search_term): @@ -176,11 +214,14 @@ def search_common_names(self, search_term): ) return self.cursor.fetchall() - def get_name_to_object_id(self) -> Dict[str, int]: + def get_name_to_object_id(self, id_to_names_dict=None) -> Dict[str, int]: """ Returns a dictionary of common_name: object_id """ - other_dict = self.get_object_id_to_names() + if id_to_names_dict is None: + other_dict = self.get_object_id_to_names() + else: + other_dict = id_to_names_dict result_dict = defaultdict(int) for k, v in other_dict.items(): for name in v: @@ -257,11 +298,15 @@ def get_catalog_objects_by_catalog_code(self, catalog_code): return self.cursor.fetchall() def get_catalog_objects(self): - # disable WDS until we can sort out performance - self.cursor.execute( - "SELECT * FROM catalog_objects where catalog_code != 'WDS';" - ) - return self.cursor.fetchall() + start_time = time.time() + logging.info("Starting get_catalog_objects query...") + + self.cursor.execute("SELECT * FROM catalog_objects;") + results = self.cursor.fetchall() + + total_time = time.time() - start_time + logging.info(f"get_catalog_objects took {total_time:.2f}s, returned {len(results)} rows") + return results # ---- IMAGES_OBJECTS methods ---- def insert_image_object(self, object_id, image_name): diff --git a/python/PiFinder/imu_pi.py b/python/PiFinder/imu_pi.py index e1d7744ad..4d5b52a48 100644 --- a/python/PiFinder/imu_pi.py +++ b/python/PiFinder/imu_pi.py @@ -6,63 +6,37 @@ """ import time +from PiFinder import config from PiFinder.multiproclogging import MultiprocLogging import board import adafruit_bno055 import logging - -from scipy.spatial.transform import Rotation - -from PiFinder import config +import numpy as np +import quaternion # Numpy quaternion logger = logging.getLogger("IMU.pi") QUEUE_LEN = 10 -MOVE_CHECK_LEN = 2 class Imu: + """ + Previous version modified the IMU axes but the IMU now outputs the + measurements using its native axes and the transformation from the IMU + axes to the camera frame is done by the IMU dead-reckonig functionality. + """ + def __init__(self): i2c = board.I2C() self.sensor = adafruit_bno055.BNO055_I2C(i2c) + # IMPLUS mode: Accelerometer + Gyro + Fusion data self.sensor.mode = adafruit_bno055.IMUPLUS_MODE # self.sensor.mode = adafruit_bno055.NDOF_MODE - cfg = config.Config() - if ( - cfg.get_option("screen_direction") == "flat" - or cfg.get_option("screen_direction") == "straight" - or cfg.get_option("screen_direction") == "flat3" - ): - self.sensor.axis_remap = ( - adafruit_bno055.AXIS_REMAP_Y, - adafruit_bno055.AXIS_REMAP_X, - adafruit_bno055.AXIS_REMAP_Z, - adafruit_bno055.AXIS_REMAP_POSITIVE, - adafruit_bno055.AXIS_REMAP_POSITIVE, - adafruit_bno055.AXIS_REMAP_NEGATIVE, - ) - elif cfg.get_option("screen_direction") == "as_bloom": - self.sensor.axis_remap = ( - adafruit_bno055.AXIS_REMAP_X, - adafruit_bno055.AXIS_REMAP_Z, - adafruit_bno055.AXIS_REMAP_Y, - adafruit_bno055.AXIS_REMAP_POSITIVE, - adafruit_bno055.AXIS_REMAP_POSITIVE, - adafruit_bno055.AXIS_REMAP_POSITIVE, - ) - else: - self.sensor.axis_remap = ( - adafruit_bno055.AXIS_REMAP_Z, - adafruit_bno055.AXIS_REMAP_Y, - adafruit_bno055.AXIS_REMAP_X, - adafruit_bno055.AXIS_REMAP_POSITIVE, - adafruit_bno055.AXIS_REMAP_POSITIVE, - adafruit_bno055.AXIS_REMAP_POSITIVE, - ) + self.quat_history = [(0, 0, 0, 0)] * QUEUE_LEN self._flip_count = 0 self.calibration = 0 - self.avg_quat = (0, 0, 0, 0) + self.avg_quat = (0, 0, 0, 0) # Scalar-first quaternion: (w, x, y, z) self.__moving = False self.last_sample_time = time.time() @@ -74,23 +48,12 @@ def __init__(self): # to start moving, second is threshold to fall below # to stop moving. + cfg = config.Config() imu_threshold_scale = cfg.get_option("imu_threshold_scale", 1) self.__moving_threshold = ( 0.0005 * imu_threshold_scale, 0.0003 * imu_threshold_scale, ) - - def quat_to_euler(self, quat): - if quat[0] + quat[1] + quat[2] + quat[3] == 0: - return 0, 0, 0 - rot = Rotation.from_quat(quat) - rot_euler = rot.as_euler("xyz", degrees=True) - # convert from -180/180 to 0/360 - rot_euler[0] += 180 - rot_euler[1] += 180 - rot_euler[2] += 180 - return rot_euler - def moving(self): """ Compares most recent reading @@ -110,6 +73,7 @@ def update(self): if self.calibration == 0: logger.warning("NOIMU CAL") return True + # adafruit_bno055 uses quaternion convention (w, x, y, z) quat = self.sensor.quaternion if quat[0] is None: logger.warning("IMU: Failed to get sensor values") @@ -132,6 +96,9 @@ def update(self): # Sometimes the quat output will 'flip' and change by 2.0+ # from one reading to another. This is clearly noise or an # artifact, so filter them out + # + # NOTE: This is probably due to the double-cover property of quaternions + # where +q and -q describe the same rotation? if self.__reading_diff > 1.5: self._flip_count += 1 if self._flip_count > 10: @@ -148,7 +115,9 @@ def update(self): # no flip self._flip_count = 0 + # avg_quat is the latest quaternion measurement, not the average self.avg_quat = quat + # Write over the quat_hisotry queue FIFO: if len(self.quat_history) == QUEUE_LEN: self.quat_history = self.quat_history[1:] self.quat_history.append(quat) @@ -160,9 +129,6 @@ def update(self): if self.__reading_diff > self.__moving_threshold[0]: self.__moving = True - def get_euler(self): - return list(self.quat_to_euler(self.avg_quat)) - def __str__(self): return ( f"IMU Information:\n" @@ -195,35 +161,40 @@ def imu_monitor(shared_state, console_queue, log_queue): imu = Imu() imu_calibrated = False + # TODO: Remove move_start, move_end imu_data = { "moving": False, "move_start": None, "move_end": None, - "pos": [0, 0, 0], - "quat": [0, 0, 0, 0], - "start_pos": [0, 0, 0], + "quat": quaternion.quaternion( + 0, 0, 0, 0 + ), # Scalar-first numpy quaternion(w, x, y, z) - Init to invalid quaternion "status": 0, } + while True: imu.update() imu_data["status"] = imu.calibration + + # TODO: move_start and move_end don't seem to be used? if imu.moving(): if not imu_data["moving"]: logger.debug("IMU: move start") imu_data["moving"] = True - imu_data["start_pos"] = imu_data["pos"] imu_data["move_start"] = time.time() - imu_data["pos"] = imu.get_euler() - imu_data["quat"] = imu.avg_quat - + # DISABLE old method + imu_data["quat"] = quaternion.from_float_array( + imu.avg_quat + ) # Scalar-first (w, x, y, z) else: if imu_data["moving"]: # If we were moving and we now stopped logger.debug("IMU: move end") imu_data["moving"] = False - imu_data["pos"] = imu.get_euler() - imu_data["quat"] = imu.avg_quat imu_data["move_end"] = time.time() + imu_data["quat"] = quaternion.from_float_array( + imu.avg_quat + ) # Scalar-first (w, x, y, z) if not imu_calibrated: if imu_data["status"] == 3: diff --git a/python/PiFinder/integrator.py b/python/PiFinder/integrator.py index 0f19d0cd5..b49026279 100644 --- a/python/PiFinder/integrator.py +++ b/python/PiFinder/integrator.py @@ -7,88 +7,54 @@ """ +import datetime import queue import time import copy import logging +import numpy as np +import quaternion # numpy-quaternion from PiFinder import config from PiFinder import state_utils import PiFinder.calc_utils as calc_utils from PiFinder.multiproclogging import MultiprocLogging +from PiFinder.pointing_model.astro_coords import initialized_solved_dict, RaDecRoll +from PiFinder.pointing_model.imu_dead_reckoning import ImuDeadReckoning +import PiFinder.pointing_model.quaternion_transforms as qt -IMU_ALT = 2 -IMU_AZ = 0 logger = logging.getLogger("IMU.Integrator") - -def imu_moved(imu_a, imu_b): - """ - Compares two IMU states to determine if they are the 'same' - if either is none, returns False - """ - if imu_a is None: - return False - if imu_b is None: - return False - - # figure out the abs difference - diff = ( - abs(imu_a[0] - imu_b[0]) + abs(imu_a[1] - imu_b[1]) + abs(imu_a[2] - imu_b[2]) - ) - if diff > 0.001: - return True - return False +# Constants: +# Use IMU tracking if the angle moved is above this +IMU_MOVED_ANG_THRESHOLD = np.deg2rad(0.1) def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=False): MultiprocLogging.configurer(log_queue) + """ """ + if is_debug: + logger.setLevel(logging.DEBUG) + logger.debug("Starting Integrator") + try: - if is_debug: - logger.setLevel(logging.DEBUG) - logger.debug("Starting Integrator") - - solved = { - "RA": None, - "Dec": None, - "Roll": None, - "camera_center": { - "RA": None, - "Dec": None, - "Roll": None, - "Alt": None, - "Az": None, - }, - "camera_solve": { - "RA": None, - "Dec": None, - "Roll": None, - }, - "Roll_offset": 0, # May/may not be needed - for experimentation - "imu_pos": None, - "Alt": None, - "Az": None, - "solve_source": None, - "solve_time": None, - "cam_solve_time": 0, - "constellation": None, - } + # Dict of RA, Dec, etc. initialized to None: + solved = initialized_solved_dict() cfg = config.Config() - if ( - cfg.get_option("screen_direction") == "left" - or cfg.get_option("screen_direction") == "flat" - or cfg.get_option("screen_direction") == "flat3" - or cfg.get_option("screen_direction") == "straight" - ): - flip_alt_offset = True - else: - flip_alt_offset = False + + 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_alignment(q_scope2cam) # TODO: Enable when q_scope2cam is available from alignment # This holds the last image solve position info # so we can delta for IMU updates last_image_solve = None last_solve_time = time.time() + while True: state_utils.sleep_for_framerate(shared_state) @@ -100,163 +66,213 @@ def integrator(shared_state, solver_queue, console_queue, log_queue, is_debug=Fa pass if type(next_image_solve) is dict: + # We have a new image solve: Use plate-solving for RA/Dec solved = next_image_solve - - # see if we can generate alt/az - location = shared_state.location() - dt = shared_state.datetime() - - # see if we can calc alt-az - solved["Alt"] = None - solved["Az"] = None - if location and dt: - # We have position and time/date! - calc_utils.sf_utils.set_location( - location.lat, - location.lon, - location.altitude, - ) - alt, az = calc_utils.sf_utils.radec_to_altaz( - solved["RA"], - solved["Dec"], - dt, - ) - solved["Alt"] = alt - solved["Az"] = az - - alt, az = calc_utils.sf_utils.radec_to_altaz( - solved["camera_center"]["RA"], - solved["camera_center"]["Dec"], - dt, - ) - solved["camera_center"]["Alt"] = alt - solved["camera_center"]["Az"] = az - - # Experimental: For monitoring roll offset - # Estimate the roll offset due misalignment of the - # camera sensor with the Pole-to-Source great circle. - solved["Roll_offset"] = estimate_roll_offset(solved, dt) - # Find the roll at the target RA/Dec. Note that this doesn't include the - # roll offset so it's not the roll that the PiFinder camear sees but the - # roll relative to the celestial pole - roll_target_calculated = calc_utils.sf_utils.radec_to_roll( - solved["RA"], solved["Dec"], dt - ) - # Compensate for the roll offset. This gives the roll at the target - # as seen by the camera. - solved["Roll"] = roll_target_calculated + solved["Roll_offset"] - - # calculate roll for camera center - roll_target_calculated = calc_utils.sf_utils.radec_to_roll( - solved["camera_center"]["RA"], - solved["camera_center"]["Dec"], - dt, - ) - # Compensate for the roll offset. This gives the roll at the target - # as seen by the camera. - solved["camera_center"]["Roll"] = ( - roll_target_calculated + solved["Roll_offset"] - ) + update_plate_solve_and_imu(imu_dead_reckoning, solved) last_image_solve = copy.deepcopy(solved) solved["solve_source"] = "CAM" - # Use IMU dead-reckoning from the last camera solve: - # Check we have an alt/az solve, otherwise we can't use the IMU - elif solved["Alt"]: + elif imu_dead_reckoning.tracking: + # Previous plate-solve exists so use IMU dead-reckoning from + # the last plate solved coordinates. imu = shared_state.imu() if imu: - dt = shared_state.datetime() - if last_image_solve and last_image_solve["Alt"]: - # If we have alt, then we have a position/time - - # calc new alt/az - lis_imu = last_image_solve["imu_pos"] - imu_pos = imu["pos"] - if imu_moved(lis_imu, imu_pos): - alt_offset = imu_pos[IMU_ALT] - lis_imu[IMU_ALT] - if flip_alt_offset: - alt_offset = ((alt_offset + 180) % 360 - 180) * -1 - else: - alt_offset = (alt_offset + 180) % 360 - 180 - solved["Alt"] = (last_image_solve["Alt"] - alt_offset) % 360 - solved["camera_center"]["Alt"] = ( - last_image_solve["camera_center"]["Alt"] - alt_offset - ) % 360 - - az_offset = imu_pos[IMU_AZ] - lis_imu[IMU_AZ] - az_offset = (az_offset + 180) % 360 - 180 - solved["Az"] = (last_image_solve["Az"] + az_offset) % 360 - solved["camera_center"]["Az"] = ( - last_image_solve["camera_center"]["Az"] + az_offset - ) % 360 - - # N.B. Assumes that location hasn't changed since last solve - # Turn this into RA/DEC - ( - solved["RA"], - solved["Dec"], - ) = calc_utils.sf_utils.altaz_to_radec( - solved["Alt"], solved["Az"], dt - ) - # Calculate the roll at the target RA/Dec and compensate for the offset. - solved["Roll"] = ( - calc_utils.sf_utils.radec_to_roll( - solved["RA"], solved["Dec"], dt - ) - + solved["Roll_offset"] - ) - - # Now for camera centered solve - ( - solved["camera_center"]["RA"], - solved["camera_center"]["Dec"], - ) = calc_utils.sf_utils.altaz_to_radec( - solved["camera_center"]["Alt"], - solved["camera_center"]["Az"], - dt, - ) - # Calculate the roll at the target RA/Dec and compensate for the offset. - solved["camera_center"]["Roll"] = ( - calc_utils.sf_utils.radec_to_roll( - solved["camera_center"]["RA"], - solved["camera_center"]["Dec"], - dt, - ) - + solved["Roll_offset"] - ) - - solved["solve_time"] = time.time() - solved["solve_source"] = "IMU" + update_imu(imu_dead_reckoning, solved, last_image_solve, imu) # Is the solution new? if solved["RA"] and solved["solve_time"] > last_solve_time: last_solve_time = time.time() + + # Try to set date and time + location = shared_state.location() + dt = shared_state.datetime() + # Set location for roll and altaz calculations. + # TODO: Is itnecessary 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? + 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 solved["constellation"] = calc_utils.sf_utils.radec_to_constellation( solved["RA"], solved["Dec"] ) + # 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 + ) + # add solution shared_state.set_solution(solved) shared_state.set_solve_state(True) + except EOFError: logger.error("Main no longer running for integrator") -def estimate_roll_offset(solved, dt): +# ======== 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 + interface angles in degrees to radians. + + This updates the pointing model with the plate-solved coordinates and the + IMU measurements which are assumed to have been taken at the same time. """ - Estimate the roll offset due to misalignment of the camera sensor with - the mount/scope's coordinate system. The offset is calculated at the - center of the camera's FoV. + if (solved["RA"] is None) or (solved["Dec"] is None): + return # No update + else: + # Successfully plate solved & camera pointing exists + if solved["imu_quat"] is None: + q_x2imu = quaternion.quaternion(np.nan) + else: + q_x2imu = solved["imu_quat"] # IMU measurement at the time of plate solving + + # Update: + solved_cam = RaDecRoll() + solved_cam.set_from_deg( + solved["camera_center"]["RA"], + solved["camera_center"]["Dec"], + solved["camera_center"]["Roll"], + ) + imu_dead_reckoning.update_plate_solve_and_imu(solved_cam, q_x2imu) + + # Set alignment. TODO: Do this once at alignment. Move out of here. + set_alignment(imu_dead_reckoning, solved) - To calculate the roll with offset: roll = calculated_roll + roll_offset + +def set_alignment(imu_dead_reckoning: ImuDeadReckoning, solved: dict): + """ + Set alignment. + TODO: Do this once at alignment """ - # Calculate the expected roll at the camera center given the RA/Dec of - # of the camera center. - roll_camera_calculated = calc_utils.sf_utils.radec_to_roll( - solved["camera_center"]["RA"], solved["camera_center"]["Dec"], dt + # RA, Dec of camera center:: + solved_cam = RaDecRoll() + solved_cam.set_from_deg( + solved["camera_center"]["RA"], + solved["camera_center"]["Dec"], + solved["camera_center"]["Roll"], ) - roll_offset = solved["camera_center"]["Roll"] - roll_camera_calculated - return roll_offset + # RA, Dec of target (where scope is pointing): + solved["Roll"] = 0 # Target roll isn't calculated by Tetra3. Set to zero here + solved_scope = RaDecRoll() + solved_scope.set_from_deg(solved["RA"], solved["Dec"], solved["Roll"]) + + # Set alignment in imu_dead_reckoning + imu_dead_reckoning.set_alignment(solved_cam, solved_scope) + + +def update_imu( + imu_dead_reckoning: ImuDeadReckoning, + solved: dict, + last_image_solve: dict, + imu: dict, +): + """ + Updates the solved dictionary using IMU dead-reckoning from the last + solved pointing. + """ + if not (last_image_solve and imu_dead_reckoning.tracking): + return # Need all of these to do IMU dead-reckoning + + assert isinstance( + imu["quat"], quaternion.quaternion + ), "Expecting quaternion.quaternion type" # TODO: Can be removed later + q_x2imu = imu["quat"] # Current IMU measurement (quaternion) + + # When moving, switch to tracking using the IMU + angle_moved = qt.get_quat_angular_diff(last_image_solve["imu_quat"], q_x2imu) + if angle_moved > IMU_MOVED_ANG_THRESHOLD: + # Estimate camera pointing using IMU dead-reckoning + logger.debug( + "Track using IMU: Angle moved since last_image_solve = " + "{:}(> threshold = {:}) | IMU quat = ({:}, {:}, {:}, {:})".format( + np.rad2deg(angle_moved), + np.rad2deg(IMU_MOVED_ANG_THRESHOLD), + q_x2imu.w, + q_x2imu.x, + q_x2imu.y, + q_x2imu.z, + ) + ) + + # Dead-reckoning using IMU + imu_dead_reckoning.update_imu(q_x2imu) # Latest IMU measurement + + # Store current camera pointing estimate: + cam_eq = imu_dead_reckoning.get_cam_radec() + ( + solved["camera_center"]["RA"], + solved["camera_center"]["Dec"], + solved["camera_center"]["Roll"], + ) = cam_eq.get_deg(use_none=True) + + # 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_source"] = "IMU" + + # Logging for states updated in solved: + logger.debug( + "IMU update: scope: RA: {:}, Dec: {:}, Roll: {:}".format( + solved["RA"], solved["Dec"], solved["Roll"] + ) + ) + logger.debug( + "IMU update: camera_center: RA: {:}, Dec: {:}, Roll: {:}".format( + solved["camera_center"]["RA"], + solved["camera_center"]["Dec"], + solved["camera_center"]["Roll"], + ) + ) + + +def get_roll_by_mount_type( + ra_deg: float, dec_deg: float, location, dt: datetime.datetime, mount_type: str +) -> 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) + 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 + + return roll_deg diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index 175f218c1..641bb4ba1 100644 --- a/python/PiFinder/main.py +++ b/python/PiFinder/main.py @@ -244,6 +244,7 @@ def main( script_name=None, show_fps=False, verbose=False, + profile_startup=False, ) -> None: """ Get this show on the road! @@ -467,8 +468,15 @@ def main( logger.info(" Catalogs") console.update() - # Initialize Catalogs - catalogs: Catalogs = CatalogBuilder().build(shared_state) + # Start profiling (automatic for performance analysis) + import cProfile + import pstats + profiler = cProfile.Profile() + profiler.enable() + startup_profile_start = time.time() + + # Initialize Catalogs (pass ui_queue for background loading completion signal) + catalogs: Catalogs = CatalogBuilder().build(shared_state, ui_queue) # Establish the common catalog filter object _new_filter = CatalogFilter(shared_state=shared_state) @@ -495,6 +503,34 @@ def main( logger.info(" Event Loop") console.update() + # Stop profiling and save results + profiler.disable() + startup_profile_time = time.time() - startup_profile_start + + # Save to file + profile_path = utils.data_dir / "startup_profile.prof" + profiler.dump_stats(str(profile_path)) + + # Print summary + logger.info(f"=== Startup Profiling Complete ({startup_profile_time:.2f}s) ===") + logger.info(f"Profile saved to: {profile_path}") + logger.info("To analyze, run:") + logger.info(f" python -c \"import pstats; p = pstats.Stats('{profile_path}'); p.sort_stats('cumulative').print_stats(30)\"") + + # Also save a text summary + summary_path = utils.data_dir / "startup_profile.txt" + with open(summary_path, 'w') as f: + ps = pstats.Stats(profiler, stream=f) + f.write(f"=== STARTUP PROFILING ({startup_profile_time:.2f}s) ===\n\n") + f.write("Top 30 functions by cumulative time:\n") + f.write("=" * 80 + "\n") + ps.sort_stats('cumulative').print_stats(30) + f.write("\n" + "=" * 80 + "\n") + f.write("Top 30 functions by internal time:\n") + f.write("=" * 80 + "\n") + ps.sort_stats('time').print_stats(30) + logger.info(f"Text summary saved to: {summary_path}") + log_time = True # Start of main except handler / loop try: @@ -589,6 +625,11 @@ def main( menu_manager.jump_to_label("recent") elif ui_command == "reload_config": cfg.load_config() + elif ui_command == "catalogs_fully_loaded": + logger.info( + "All catalogs loaded - WDS and extended catalogs available" + ) + menu_manager.message(_("Catalogs\nFully Loaded"), 2) elif ui_command == "test_mode": dt = datetime.datetime(2025, 6, 28, 11, 0, 0) shared_state.set_datetime(dt) @@ -897,6 +938,13 @@ def main( help="Force user interface language (iso2 code). Changes configuration", type=str, ) + parser.add_argument( + "--profile-startup", + help="Profile startup performance (catalog/menu loading)", + default=False, + action="store_true", + required=False, + ) args = parser.parse_args() # add the handlers to the logger if args.verbose: @@ -916,6 +964,16 @@ def main( imu = importlib.import_module("PiFinder.imu_pi") cfg = config.Config() + + # verify and sync GPSD baud rate + try: + from PiFinder import sys_utils + baud_rate = cfg.get_option("gps_baud_rate", 9600) # Default to 9600 if not set + if sys_utils.check_and_sync_gpsd_config(baud_rate): + logger.info(f"GPSD configuration updated to {baud_rate} baud") + except Exception as e: + logger.warning(f"Could not check/sync GPSD configuration: {e}") + gps_type = cfg.get_option("gps_type") if gps_type == "ublox": gps_monitor = importlib.import_module("PiFinder.gps_ubx") @@ -957,7 +1015,7 @@ def main( config.Config().set_option("language", args.lang) try: - main(log_helper, args.script, args.fps, args.verbose) + main(log_helper, args.script, args.fps, args.verbose, args.profile_startup) except Exception: rlogger.exception("Exception in main(). Aborting program.") os._exit(1) diff --git a/python/PiFinder/multiproclogging.py b/python/PiFinder/multiproclogging.py index db3652a5e..92d36dccd 100644 --- a/python/PiFinder/multiproclogging.py +++ b/python/PiFinder/multiproclogging.py @@ -174,6 +174,11 @@ def configurer(queue: Queue): queue, multiprocessing.queues.Queue ), "That's not a Queue! You have to pass a queue" + log_conf_file = Path("pifinder_logconf.json") + with open(log_conf_file, "r") as logconf: + config = json5.load(logconf) + logging.config.dictConfig(config) + h = logging.handlers.QueueHandler(queue) root = logging.getLogger() root.addHandler(h) diff --git a/python/PiFinder/pointing_model/README.md b/python/PiFinder/pointing_model/README.md new file mode 100644 index 000000000..67a473636 --- /dev/null +++ b/python/PiFinder/pointing_model/README.md @@ -0,0 +1,343 @@ +README: IMU support prototyping +=============================== + +> The first part of this README is temporary for the prototyping phase of the IMU support. + +# TODO + +>Remove this before release! + +See Discord thread: https://discord.com/channels/1087556380724052059/1406599296002035895 + + +For future: +* Calibration to align IMU frame to camera frame to remove the residual error. +* Update imu_pi.py +* Set alignment once at alignment rather than calculating it every loop? + +Done: +* Fails nox +* Support other PiFinder types +* Adjust Roll depending on mount_type for charts +* Lint +* Type hints for integrator.py +* Use RaDecRoll class --> Done. Need to test. +* Go through TODOs in code +* Doesn't pass Nox +* Doesn't run wih v3 Flat. Issue in main branch? (reported by grimaldi: 20 Aug 2025 https://discord.com/channels/1087556380724052059/1406599296002035895/1407813498163167345) +* In EQ mode flickers between 0° and 359°. This is also in the main branch. +* Issue in the default requirements? Error (not related to the IMU changes) + when I try to create a new env using requirements.txt. Maybe I can't just + create a new environment using pip? +* Clean up README + * Remove instruction on venv + * Remove descriptions of frames + +# Sky test log + +>Remove this before release! + +## 20251030: b8e09ff (tested 31 Oct) + +* v2 Flat. +* Issue with chart as before. Switches over in the E at around RA=+6hrs and +also in the West around 22hrs? + +## 20251021: 7519b (tested 24 Oct) + +* v2 Flat. +* Pointings in RA, Dec were OK but in Chart mode, the chart rotated 180 degrees +as I crossed to the East of around Mirfak in Perseus with the result that +slewing to the East showed up as moving to the right. +* Brickbots tested Right version got strange estimates like the axes were + swapped around. + +## 20251001: c6422 (tested 5 Oct) + +* v2 Flat. Exposure 0.4s. +* Tested in altaz mode. +* 97% full moon. Zenity NELM 3. +* Worked fine. When moved quickly to a target, the IMU mode got it to within +1-2° and then it snapped to the pointing from the plate solve and stayed there. +I didn't see any jiggling of the SkySafari cursor when zoomed in at a scale of +5° FOV. +* Changes since last test: Cleaning up & refactoring. EQ mode angle changed to + +/-. Numpy version updated. + +## 20250831: af358e (tested 5/6 Aug) + +* Tested on altaz mount in altaz mode. +* Seemed ok. Didn't check chart display. +* Changes have mainly been refactoring & linting. + +## 20250819: 700f77c (tested 19/20 Aug) + +* Tested on altaz mount in altaz & eq mode +* OK: + * Changed chart display so that altaz is in horizontal frame and EQ mode displays in equatorial + coordinates. This appears to work. + * Tracking on chart and SkySafari OK. +* Issues: + * Catalog crashes in altaz mode (ok in EQ mode). Probably because we don't calculate altaz in integrator.py? Same behaviour under test mode so we could do desktop tests. + + +## 20250817: 5cf8aae + +* Tested on altaz and eq mounts +* **altaz:** Tracked fine. When the PiFinder was non-upright, I got the + feeling it tended to jump after an IMU track and got a plate-solve. This + wasn't seen when the PiFinder was upright. When non-upright, the crosshair + moved diagonally when the scope was moved in az or alt. The rotated + constellations in the chart were hard to make out. +* **EQ:** Seemed to work fine but I'm not experienced with EQ. The display on + SkySafari showed RA movement along the horizontal direction and Dec along + the vertical. This seemed to make sense. + +# Installation & set up + +## Install additional packages + +This branch needs the `numpy.quaternion` package. To do this, run +`pifinder_post_update.sh`, which will install the new package and updage +`numpy`. + +PiFinder can be run from the command line as usual: + +```bash +cd ~/PiFinder/python +python3 -m PiFinder.main +``` + +# Theory + +## Quaternion rotation + +A quaternion is defined by + +$$\mathbf{q} = \cos(\theta/2) + (u_x \mathbf{i} + u_y \mathbf{j} + u_z +\mathbf{k}) \sin(\theta / 2)$$ + +This can be interpreted as a rotation around the axis $\mathbf{u}$ by an angle +$\theta$ in the clockwise direction when looking along $\mathbf{u}$ from the +origin. Alternatively, using the right-hand corkscrew rule, the right thumb +points along the axis and fingers in the direction of positive rotation. + +We can express a quaternion as + +$$\mathbf{q} = (w, x, y, z)$$ + +where $w$ is the scalar part and $(x, y, z)$ is the vector part. We will use +the *scalar-first* convention used by Numpy Quaternion. + +A vector can be rotated by the quaternion $\mathbf{q}$ by defining the vector +as a pure quaternion $\mathbf{p}$ (where the scalar part is zero) as follows: + +$\mathbf{p^\prime} = \mathbf{qpq}^{-1}$ + + +### Numpy quaternion + +In Numpy Quaternion, we can create a quaternion using + +```python +q = quaternion.quaternion(w, x, y, z) +``` + +Quaternion multiplications are simply `q1 * q2`. + +The inverse (or conjugate) is given by `q.conj()`. + + +### Intrinsic and extrinsic rotation + +Intrinsic rotation of $q_0$ followed by $q_1$ + +$$q_{new} = q_0 q_1$$ + +For an extrinsic rotation of $q_0$ followed by $q_1$, left multiply + +$$q_{new} = q_1 q_0$$ + + +## Coordinate frames + +### Coordinate frame definitions + +We define the following reference frames: + +#### Equatorial coordinate system +* Centered around the center of the Earth with the $xy$ plane running through + the Earths' equator. $+z$ points to the north pole and $+x$ to the Vernal + equinox. + +#### Horizontal coordinate system +* Centred around the observer. We will use the convention: +* $x$ points South, $y$ to East and $z$ to the zenith. + +#### Scope frame +* +z is boresight. +* On an altaz mount, we define +y as the vertical direction of the scope and +x + as the horizontal direction to the left when looking along the boresight. +* In the ideal case, the Scope frame is assumed to be the same as the Gimbal + frame. In reality, there may be errors due to mounting or gravity. + +#### Camera frame +* The camera frame describes the pointing of the PiFinder's camera. There will + be an offset between the camera and the scope. +* $+z$ is the boresight of the camera, $+y$ and $+x$ are respectively the + vertical and horizontal (to the left) directions of the camera. + +#### IMU frame +* The IMU frame is the local coordinates that the IMU outputs the data in. +* The diagram below illustrates the IMU coordinate frame for the v2 PiFinder + with the Adafruit BNO055 IMU. + +### IMU and camera coordinate frames + +To use the IMU for dead-reckoning, we need to know the transformation between +the IMU's own coordinate frame and the PiFinder's camera coordinate frame +(which we use as the PiFinder's reference coordinate frame). + +The picture below illustrate the IMU and camera coordinates for the v2 flat +version of the PiFinder. For each type, we need to work out the quaternion +rotation `q_imu2cam` that rotates the IMU frame to the camera frame. + +![Image](docs/PiFinder_Flat_bare_PCB_camera_coords.jpg) + +The transformations will be approximate and there will be small errors in +`q_imu2cam` due to mechanical tolerances. These errors will contribute to the +tracking error between the plate solved coordinates and the IMU dead-reckoning. + +### Roll + +The roll (as given by Tetra3) is defined as the rotation of the north pole +relative to the camera image's "up" direction ($+y$). A positive roll angle +means that the pole is counter-clockwise from image "up" (i.e. towards West). + +### Telescope coordinate transformations + +We will use quaternions to describe rotations. In our convention, the +orientation of the camera frame in equatorial frame is written as `q_eq2cam`. +This is the rotation from the EQ frame's origin to the camera frame. We can +also write the rotation from the camera frame to the scope frame as +`q_cam2scope`. The two quaternions can be multiplied to describe the equitorial +coordinates of the scope pointing by + + +```python +q_eq2cam * q_cam2scope = q_eq2scope +``` + +Note that this convention makes it clear when applying intrinsic rotations (right-multiply). + +The Mount and Gimbal frames are not used in the current implementation but this +framework could be used to extend the implementation to control the mount. For +example, `q_mnt2gimb` depends on the gimbal angles, which is what we can +control to move the scope. + +## Coordinate frame transformation + +We will use the equatorial frame as the reference frame. The goal is determine +the scope pointing in RA and Dec. The pointing of the scope relative to the +equatorial frame can be described by quaternion $q_{eq2scope}$. + +The PiFinder uses the coordinates from plate-solving but this is at a low rate +and plate-solving may not succeed when the scope is moving so the IMU +measurements can be used to infer the pointing between plate-solving by +dead-reckoning. + +### Plate solving + +Plate-solving returns the pointing of the PiFinder camera in (RA, Dec, Roll) +coordinates. The quaternion rotation of the camera pointing relative to the +equatorial frame for time step $k$ is given by $q_{eq2cam}(k)$ and the scope +pointing is give by, + +$$q_{eq2scope}(k) = q_{eq2cam}(k) \; q_{cam2scope}$$ + +We use the PiFinder's camera frame as the reference because plate solving is +done relative to the camera frame. $q_{cam2scope}$ is the quaternion that +represents the alignment offset between the PiFinder camera frame and the scope +frame + +### Alignment + +The alignment offset between the PiFinder camera frame and the scope frame is +determined during alignment of the PiFinder with the scope and is assumed to be +fixed. The goal of alignment is to determine the quaternion $q_{cam2scope}$. + +During alignment, the user user selects the target seen in the center of the +eyepiece, which gives the (RA, Dec) of the scope pointing but not the roll. We +can assume some arbitrary roll value (say roll = 0) and get $q_{eq2scope}$. At +the same time, plate solving measures the (RA, Dec, Roll) at the camera center +or $q_{eq2cam}$. We can express the relation by, + +$$q_{eq2scope} = q_{eq2cam} \; q_{cam2scope}$$ + +Rearranging this gives, + +$$q_{cam2scope} = q_{eq2cam}^{-1} \; q_{eq2scope}$$ + +Note that for unit quaternions, we can also use the conjugate $q^*$ instead of +$q^{-1}$, because the conjugate is slightly faster to compute. + +Roll returned by plate-solving is not relevant for pointing and it can be +arbitrary but it is needed for full three degrees-of-freedom dead-reckoning by +the IMU. + +### Dead-reckoning + +Between plate solving, the IMU extrapolates the scope orientation by dead +reckoning. Suppose that we want to use the IMU measurement at time step $k$ to +estimate the scope pointing; + +```python +q_eq2scope(k) = q_eq2cam(k-m) * q_cam2imu * q_x2imu(k-m).conj() * q_x2imu(k) * q_imu2cam * q_cam2scope +``` + +Where +1. `k` represents the current time step and `k-m` represents the time step + where we had a last solve. +2. `q_x2imu(k)` is the current IMU measurement quaternion w.r.t its own + drifting reference frame `x`. +3. Note that the quaternion `q_x2imu(k-m).conj() * q_x2imu(k)` rotates the IMU + body from the orientation in the last solve (at time step `k-m`) to to the + current orientation (at time step `k`). +4. `q_cam2imu = q_imu2cam.conj()` is the alignment of the IMU to the camera and +depends on the PiFinder configuration. There will be some error due to +mechanical tolerances which will propagate to the pointing error when using the +IMU. + +We can pre-compute the first three terms after plate solving at time step +`k-m`, which corresponds to the quaternion rotation from the `eq` frame to the +IMU's reference frame `x`. + +```python +q_eq2x(k-m) = q_eq2cam(k-m) * q_cam2imu * q_x2imu(k-m).conj() +``` + +## Potential future improvements + +A potential next step could be to use a Kalman filter framework to estimate the +pointing. Some of the benefits are: + +* Smoother, filtered pointing estimate. +* Improves the accuracy of the pointing estimate. Accuracy may be more + beneficial when using the PiFinder estimate to control driven mounts. +* Potentially enable any generic IMU (with gyro and accelerometer) to be used + without internal fusion FW, which tends to add to the BOM cost. +* If required, could take fewer plate-solving frames by only triggering a plate +solve when the uncertainty of the Kalman filter estimate based on IMU +dead-reckoning exceeds some limit. This can reduce power consumption and allow +for a cheaper, less powerful computing platform to be used. + +The accuracy improvement will likely come from the following sources: + +* Filtering benefits from the averaging effects of using multiple measurements. +* The Kalman filter will estimate the accelerometer and gyro bias online. The +calibration will be done in conjunction with the plate-solved measurements so +it will be better than an IMU-only calibration. +* The orientation of the IMU to the camera frame, $q_{imu2cam}$, has errors +because of mechanical tolerances. The Kalman filter will calibrate for this +online. This will improve the accuracy and enable other non-standard +form-factors. diff --git a/python/PiFinder/pointing_model/__init__.py b/python/PiFinder/pointing_model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/PiFinder/pointing_model/astro_coords.py b/python/PiFinder/pointing_model/astro_coords.py new file mode 100644 index 000000000..48428bdd9 --- /dev/null +++ b/python/PiFinder/pointing_model/astro_coords.py @@ -0,0 +1,157 @@ +""" +Various astronomical coordinates functions +""" + +from dataclasses import dataclass +import numpy as np +import quaternion +from typing import Union # When updated to Python 3.10+, remove and use new type hints + + +@dataclass +class RaDecRoll: + """ + Data class for equatorial coordinates defined by (RA, Dec, Roll). This + makes it easier for interfacing and convert between radians and degrees. + + The set methods allow values to be float or None but internally, None will + be stored as np.nan so that the type is consistent. the get methods will + return None if the value is np.nan. + + NOTE: All angles are in radians. + """ + + ra: float = np.nan + dec: float = np.nan + roll: float = np.nan + is_set = False + + def reset(self): + """Reset to unset state""" + self.ra = np.nan + self.dec = np.nan + self.roll = np.nan + self.is_set = False + + def set( + self, ra: Union[float, None], dec: Union[float, None], roll: Union[float, None] + ): + """Set using radians""" + self.ra = ra if ra is not None else np.nan + self.dec = dec if dec is not None else np.nan + self.roll = roll if roll is not None else np.nan + self.is_set = True + + def set_from_deg( + self, + ra_deg: Union[float, None], + dec_deg: Union[float, None], + roll_deg: Union[float, None], + ): + """Set using degrees""" + ra = np.deg2rad(ra_deg) if ra_deg is not None else np.nan + dec = np.deg2rad(dec_deg) if dec_deg is not None else np.nan + roll = np.deg2rad(roll_deg) if roll_deg is not None else np.nan + + self.set(ra, dec, roll) + + def set_from_quaternion(self, q_eq: quaternion.quaternion): + """ + Set from a quaternion rotation relative to the Equatorial frame. + Re-using code from quaternion_transforms.get_radec_of_q_eq. + """ + # Pure quaternion along camera boresight + pz_frame = q_eq * quaternion.quaternion(0, 0, 0, 1) * q_eq.conj() + + # Calculate RA, Dec from the camera boresight: + dec = np.arcsin(pz_frame.z) + ra = np.arctan2(pz_frame.y, pz_frame.x) + + # Calcualte Roll: + # Pure quaternion along y_cam which points to NCP when roll = 0 + py_cam = q_eq * quaternion.quaternion(0, 0, 1, 0) * q_eq.conj() + # Local East and North vectors (roll is the angle between py_cam and the north vector) + vec_east = np.array([-np.sin(ra), np.cos(ra), 0]) + vec_north = np.array( + [-np.sin(dec) * np.cos(ra), -np.sin(dec) * np.sin(ra), np.cos(dec)] + ) + roll = -np.arctan2(np.dot(py_cam.vec, vec_east), np.dot(py_cam.vec, vec_north)) + + self.set(ra, dec, roll) + + def get( + self, use_none=False + ) -> tuple[Union[float, None], Union[float, None], Union[float, None]]: + """ + Returns (ra, dec, roll) in radians. If use_none is True, returns None + for any unset (nan) values. + """ + if use_none: + ra = self.ra if not np.isnan(self.ra) else None + dec = self.dec if not np.isnan(self.dec) else None + roll = self.roll if not np.isnan(self.roll) else None + else: + ra, dec, roll = self.ra, self.dec, self.roll + + return ra, dec, roll + + def get_deg( + self, use_none=False + ) -> tuple[Union[float, None], Union[float, None], Union[float, None]]: + """ + Returns (ra, dec, roll) in degrees. If use_none is True, returns None + for any unset (nan) values. + """ + if use_none: + ra = np.rad2deg(self.ra) if not np.isnan(self.ra) else None + dec = np.rad2deg(self.dec) if not np.isnan(self.dec) else None + roll = np.rad2deg(self.roll) if not np.isnan(self.roll) else None + else: + ra, dec, roll = ( + np.rad2deg(self.ra), + np.rad2deg(self.dec), + np.rad2deg(self.roll), + ) + + return ra, dec, roll + + +def initialized_solved_dict() -> dict: + """ + Returns an initialized 'solved' dictionary with cooridnate and other + information. + + TODO: use RaDecRoll class for the RA, Dec, Roll coordinates here? + TODO: "Alt" and "Az" could be removed but seems to be required by catalogs? + """ + solved = { + # RA, Dec, Roll [deg] of the scope at the target pixel + "RA": None, + "Dec": None, + "Roll": None, + # RA, Dec, Roll [deg] solved at the center of the camera FoV + # update by the IMU in the integrator + "camera_center": { + "RA": None, + "Dec": None, + "Roll": None, + "Alt": None, # NOTE: Altaz needed by catalogs for altaz mounts + "Az": None, + }, + # RA, Dec, Roll [deg] from the camera, not updated by IMU in integrator + "camera_solve": { + "RA": None, + "Dec": None, + "Roll": None, + }, + "imu_quat": None, # IMU quaternion as numpy quaternion (scalar-first) + # Alt, Az [deg] of scope + "Alt": None, + "Az": None, + "solve_source": None, + "solve_time": None, + "cam_solve_time": 0, + "constellation": None, + } + + return solved diff --git a/python/PiFinder/pointing_model/docs/PiFinder_Flat_bare_PCB_camera_coords.jpg b/python/PiFinder/pointing_model/docs/PiFinder_Flat_bare_PCB_camera_coords.jpg new file mode 100644 index 000000000..7aac61044 Binary files /dev/null and b/python/PiFinder/pointing_model/docs/PiFinder_Flat_bare_PCB_camera_coords.jpg differ diff --git a/python/PiFinder/pointing_model/imu_dead_reckoning.py b/python/PiFinder/pointing_model/imu_dead_reckoning.py new file mode 100644 index 000000000..cd6de755f --- /dev/null +++ b/python/PiFinder/pointing_model/imu_dead_reckoning.py @@ -0,0 +1,237 @@ +""" +IMU dead-reckoning extrapolates the scope pointing from the last plate-solved +coordinate using the IMU measurements. + +See quaternion_transforms.py for conventions used for quaternions. + +NOTE: All angles are in radians. +""" + +import numpy as np +import quaternion + +from PiFinder.pointing_model.astro_coords import RaDecRoll +import PiFinder.pointing_model.quaternion_transforms as qt + + +class ImuDeadReckoning: + """ + Use the plate-solved coordinates and IMU measurements to estimate the + pointing using plate solving when available or dead-reckoning using the IMU + when plate solving isn't available (e.g. when the scope is moving or + between frames). + + For an explanation of the theory and conventions used, see + PiFinder/pointing_model/README.md. + + This class uses the Equatorial frame as the reference and expects + plate-solved coordinates in (ra, dec). + + All angles are in radians. None is not allowed as inputs (use np.nan). + + EXAMPLE: + # Set up: + imu_dead_reckoning = ImuDeadReckoning('flat') + imu_dead_reckoning.set_alignment(solved_cam, solved_scope) + + # Update with plate solved and IMU data: + imu_dead_reckoning.update_plate_solve_and_imu(solved_cam, q_x2imu) + + # Dead-reckoning using IMU + imu_dead_reckoning.update_imu(q_x2imu) + """ + + # Alignment: + q_cam2scope: quaternion.quaternion + # IMU orientation: + q_imu2cam: quaternion.quaternion + q_cam2imu: quaternion.quaternion + # Putting them together: + q_imu2scope: quaternion.quaternion + + # The poinging of the camera and scope frames wrt the Equatorial frame. + # These get updated by plate solving and IMU dead-reckoning. + q_eq2cam: quaternion.quaternion + + # True when q_eq2cam is estimated by IMU dead-reckoning. + # False when set by plate solving + dead_reckoning: bool = False + tracking: bool = False # True when previous plate solve exists and is tracking + + # The IMU's unkonwn drifting reference frame X. This is solved for + # every time we have a simultaneous plate solve and IMU measurement. + q_eq2x: quaternion.quaternion = quaternion.quaternion(np.nan) # nan means not set + + def __init__(self, screen_direction: str): + """ """ + # IMU-to-camera orientation. Fixed by PiFinder type + self._set_screen_direction(screen_direction) + + def set_alignment(self, solved_cam: RaDecRoll, solved_scope: RaDecRoll): + """ + Set the alignment between the PiFinder camera center and the scope + pointing. + + INPUTS: + solved_cam: Equatorial coordinate of the camera center at alignment. + solved_scope: Equatorial coordinate of the scope center at alignement. + """ + # Calculate q_scope2cam (alignment) + q_eq2cam = qt.get_q_eq(solved_cam.ra, solved_cam.dec, solved_cam.roll) + q_eq2scope = qt.get_q_eq(solved_scope.ra, solved_scope.dec, solved_scope.roll) + q_scope2cam = q_eq2scope.conjugate() * q_eq2cam + + # Set the alignmen attributes: + self.q_cam2scope = q_scope2cam.normalized().conj() + self.q_imu2scope = self.q_imu2cam * self.q_cam2scope + + def update_plate_solve_and_imu( + self, + solved_cam: RaDecRoll, + q_x2imu: quaternion.quaternion, + ): + """ + Update the state with the az/alt measurements from plate solving in the + camera frame. If the IMU measurement (which should be taken at the same + time) is available, q_x2imu (the unknown drifting reference frame) will + be solved for. + + INPUTS: + solved_cam: RA/Dec/Roll of the camera pointing from plate solving. + q_x2imu: [quaternion] Raw IMU measurement quaternions. This is the IMU + frame orientation wrt unknown drifting reference frame X. + """ + if not solved_cam.is_set: + return # No update + + # Update plate-solved coord: Camera frame relative to the Equatorial + # frame where the +y camera frame (i.e. "up") points to the North + # Celestial Pole (NCP) -- i.e. zero roll offset: + self.q_eq2cam = qt.get_q_eq(solved_cam.ra, solved_cam.dec, solved_cam.roll) + self.dead_reckoning = False # Using plate solve, no dead_reckoning + + # Update IMU: Calculate the IMU's unknown reference frame X using the + # plate solved coordinates and IMU measurements taken from the same + # time. If the IMU measurement isn't provided (i.e. None), the existing + # q_hor2x will continue to be used. + if not np.isnan(q_x2imu): + self.q_eq2x = self.q_eq2cam * self.q_cam2imu * q_x2imu.conj() + self.q_eq2x = self.q_eq2x.normalized() + self.tracking = True # We have a plate solve and IMU measurement + + def update_imu(self, q_x2imu: quaternion.quaternion): + """ + Update the state with the raw IMU measurement. Does a dead-reckoning + estimate of the camera and scope pointing. + + INPUTS: + q_x2imu: Quaternion of the IMU orientation w.r.t. an unknown and drifting + reference frame X used by the IMU. + """ + if not np.isnan(self.q_eq2x): + # Dead reckoning estimate by IMU if q_hor2x has been estimated by a + # previous plate solve. + self.q_eq2cam = self.q_eq2x * q_x2imu * self.q_imu2cam + self.q_eq2cam = self.q_eq2cam.normalized() + + self.q_eq2scope = self.q_eq2cam * self.q_cam2scope + self.q_eq2scope = self.q_eq2scope.normalized() + + self.dead_reckoning = True + + def get_cam_radec(self) -> RaDecRoll: + """ + Returns the (ra, dec, roll) of the camera centre and a Boolean + dead_reckoning to indicate if the estimate is from dead-reckoning + (True) or from plate solving (False). + """ + ra_dec_roll = RaDecRoll() + ra_dec_roll.set_from_quaternion(self.q_eq2cam) + + return ra_dec_roll + + def get_scope_radec(self) -> RaDecRoll: + """ + Returns the (ra, dec, roll) of the scope and a Boolean dead_reckoning + to indicate if the estimate is from dead-reckoning (True) or from plate + solving (False). + """ + ra_dec_roll = RaDecRoll() + ra_dec_roll.set_from_quaternion(self.q_eq2scope) + + return ra_dec_roll + + def reset(self): + """ + Resets the internal state. + """ + self.q_eq2x = None + self.tracking = False + + def _set_screen_direction(self, screen_direction: str): + """ + Sets the screen direction which determines the fixed orientation between + the IMU and camera (q_imu2cam). + """ + self.q_imu2cam = get_screen_direction_q_imu2cam(screen_direction) + self.q_cam2imu = self.q_imu2cam.conj() + + +def get_screen_direction_q_imu2cam(screen_direction: str) -> quaternion.quaternion: + """ + Returns the quaternion that rotates the IMU frame to the camera frame + based on the screen direction. + + INPUTS: + screen_direction: "flat" or "upright" + + RETURNS: + q_imu2cam: Quaternion that rotates the IMU frame to the camera frame. + """ + if screen_direction == "left": + # Left: + # Rotate 90° around x_imu so that z_imu' points along z_camera + q1 = qt.axis_angle2quat([1, 0, 0], np.pi / 2) + # Rotate 90° around z_imu' to align with the camera cooridnates + q2 = qt.axis_angle2quat([0, 0, 1], np.pi / 2) + q_imu2cam = (q1 * q2).normalized() + elif screen_direction == "right": + # Right: + # Rotate -90° around x_imu so that z_imu' points along z_camera + q1 = qt.axis_angle2quat([1, 0, 0], -np.pi / 2) + # Rotate 90° around z_imu' to align with the camera cooridnates + q2 = qt.axis_angle2quat([0, 0, 1], np.pi / 2) + q_imu2cam = (q1 * q2).normalized() + elif screen_direction == "straight": + # Straight: + # Rotate 180° around y_imu so that z_imu' points along z_camera + q1 = qt.axis_angle2quat([0, 1, 0], np.pi) + # Rotate -90° around z_imu' to align with the camera cooridnates + q2 = qt.axis_angle2quat([0, 0, 1], -np.pi / 2) + q_imu2cam = (q1 * q2).normalized() + elif screen_direction == "flat3": + # Flat v3: + # Camera is tilted 30° further down from the screen compared to Flat v2 + # Rotate -120° around y_imu so that z_imu' points along z_camera + q1 = qt.axis_angle2quat([0, 1, 0], -np.pi * 2 / 3) + # Rotate -90° around z_imu' to align with the camera cooridnates + q2 = qt.axis_angle2quat([0, 0, 1], -np.pi / 2) + q_imu2cam = (q1 * q2).normalized() + elif screen_direction == "flat": + # Flat v2: + # Rotate -90° around y_imu so that z_imu' points along z_camera + q1 = qt.axis_angle2quat([0, 1, 0], -np.pi / 2) + # Rotate -90° around z_imu' to align with the camera cooridnates + q2 = qt.axis_angle2quat([0, 0, 1], -np.pi / 2) + q_imu2cam = (q1 * q2).normalized() + elif screen_direction == "as_dream": # TODO: Propose to rename to "back"? + # As Dream: + # Camera points back up from the screen + # NOTE: Need to check if the orientation of the camera is correct + # Rotate +90° around z_imu to align with the camera cooridnates + # (+y_cam is along -x_imu) + q_imu2cam = qt.axis_angle2quat([0, 0, 1], +np.pi / 2) + else: + raise ValueError(f"Unsupported screen_direction: {screen_direction}") + + return q_imu2cam diff --git a/python/PiFinder/pointing_model/quaternion_transforms.py b/python/PiFinder/pointing_model/quaternion_transforms.py new file mode 100644 index 000000000..898b4d8bb --- /dev/null +++ b/python/PiFinder/pointing_model/quaternion_transforms.py @@ -0,0 +1,107 @@ +""" +Quaternion transformations + +For quaternions, we use the notation `q_a2b`. This represents a quaternion that +rotates frame `a` to frame `b` using intrinsic rotation (by post-multiplying +the quaternions). This notation makes makes chains of intrinsic rotations +simple and clear. For example, this gives a quaternion `q_a2c` that rotates +from frame `a` to frame `c`: + +q_a2c = q_a2b * q_a2c + +NOTE: + +* All angles are in radians. +* The quaternions use numpy quaternions and are scalar-first. +* Some of the constant quaternion terms can be speeded up by not using +trigonometric functions. +* The methods do not normalize the quaternions because this incurs a small +computational overhead. Normalization should be done manually as and when +necessary. +""" + +import numpy as np +import quaternion + + +def axis_angle2quat(axis, theta: float) -> quaternion.quaternion: + """ + Convert from axis-angle representation to a quaternion + + INPUTS: + axis: (3,) Axis of rotation (doesn't need to be a unit vector) + angle: Angle of rotation [rad] + """ + assert len(axis) == 3, "axis should be a list or numpy array of length 3." + # Define the vector part of the quaternion + v = np.array(axis) / np.linalg.norm(axis) * np.sin(theta / 2) + + return quaternion.quaternion(np.cos(theta / 2), v[0], v[1], v[2]) + + +def get_quat_angular_diff( + q1: quaternion.quaternion, q2: quaternion.quaternion +) -> float: + """ + Calculates the relative rotation between quaternions `q1` and `q2`. + Accounts for the double-cover property of quaternions so that if q1 and q2 + are close, you get small angle d_theta rather than something around 2 * np.pi. + """ + dq = q1.conj() * q2 + d_theta = 2 * np.arctan2( + np.linalg.norm(dq.vec), dq.w + ) # atan2 is more robust than using acos + + # Account for double cover where q2 = -q1 gives d_theta = 2 * pi + if d_theta > np.pi: + d_theta = 2 * np.pi - d_theta + + return d_theta # In radians + + +# ========== Equatorial frame functions ============================ + + +def get_q_eq(ra_rad: float, dec_rad: float, roll_rad: float) -> quaternion.quaternion: + """ + Express the equatorial coordinates (RA, Dec, Roll) in radians + in a quaternion rotation the relative to the Equatorial frame. + """ + # Intrinsic rotation of q_ra followed by q_dec gives a quaternion rotation + # that points +z towards the boresight of the camera. +y to the left and + # +x down. + q_ra = axis_angle2quat([0, 0, 1], ra_rad) # Rotate frame around z (NCP) + q_dec = axis_angle2quat([0, 1, 0], np.pi / 2 - dec_rad) # Rotate around y' + + # Need to rotate this +90 degrees around z_cam so that +y_cam points up + # and +x_cam points to the left of the Camera frame. In addition, need to + # account for the roll offset of the camera (zero if +y_cam points up along + # the great circle towards the NCP) + q_roll = axis_angle2quat([0, 0, 1], np.pi / 2 + roll_rad) + + # Intrinsic rotation: + q_eq = (q_ra * q_dec * q_roll).normalized() + return q_eq + + +def get_radec_of_q_eq(q_eq2frame: quaternion.quaternion) -> tuple[float, float, float]: + """ + Returns the (ra, dec, roll) angles of the quaterion rotation relative to + the equatorial frame. + """ + # Pure quaternion along camera boresight + pz_frame = q_eq2frame * quaternion.quaternion(0, 0, 0, 1) * q_eq2frame.conj() + # Calculate RA, Dec from the camera boresight: + dec = np.arcsin(pz_frame.z) + ra = np.arctan2(pz_frame.y, pz_frame.x) + + # Pure quaternion along y_cam which points to NCP when roll = 0 + py_cam = q_eq2frame * quaternion.quaternion(0, 0, 1, 0) * q_eq2frame.conj() + # Local East and North vectors (roll is the angle between py_cam and the north vector) + vec_east = np.array([-np.sin(ra), np.cos(ra), 0]) + vec_north = np.array( + [-np.sin(dec) * np.cos(ra), -np.sin(dec) * np.sin(ra), np.cos(dec)] + ) + roll = -np.arctan2(np.dot(py_cam.vec, vec_east), np.dot(py_cam.vec, vec_north)) + + return ra, dec, roll # In radians diff --git a/python/PiFinder/solver.py b/python/PiFinder/solver.py index 84129ef14..026ccb8f7 100644 --- a/python/PiFinder/solver.py +++ b/python/PiFinder/solver.py @@ -20,6 +20,7 @@ from PiFinder import state_utils from PiFinder import utils +from PiFinder.pointing_model.astro_coords import initialized_solved_dict sys.path.append(str(utils.tetra3_dir)) import tetra3 @@ -37,6 +38,7 @@ def solver( align_command_queue, align_result_queue, is_debug=False, + max_imu_ang_during_exposure=1.0, # Max allowed turn during exp [degrees] ): MultiprocLogging.configurer(log_queue) logger.debug("Starting Solver") @@ -46,31 +48,8 @@ def solver( last_solve_time = 0 align_ra = 0 align_dec = 0 - solved = { - # RA, Dec, Roll solved at the center of the camera FoV - # update by integrator - "camera_center": { - "RA": None, - "Dec": None, - "Roll": None, - "Alt": None, - "Az": None, - }, - # RA, Dec, Roll from the camera, not - # affected by IMU in integrator - "camera_solve": { - "RA": None, - "Dec": None, - "Roll": None, - }, - # RA, Dec, Roll at the target pixel - "RA": None, - "Dec": None, - "Roll": None, - "imu_pos": None, - "solve_time": None, - "cam_solve_time": 0, - } + # Dict of RA, Dec, etc. initialized to None: + solved = initialized_solved_dict() centroids = [] log_no_stars_found = True @@ -128,7 +107,7 @@ def solver( logger.error(f"Lost connection to shared state manager: {e}") if ( last_image_metadata["exposure_end"] > (last_solve_time) - and last_image_metadata["imu_delta"] < 1 + and last_image_metadata["imu_delta"] < max_imu_ang_during_exposure ): img = camera_image.copy() img = img.convert(mode="L") @@ -202,10 +181,8 @@ def solver( solved["RA"] = solved["RA_target"] solved["Dec"] = solved["Dec_target"] if last_image_metadata["imu"]: - solved["imu_pos"] = last_image_metadata["imu"]["pos"] solved["imu_quat"] = last_image_metadata["imu"]["quat"] else: - solved["imu_pos"] = None solved["imu_quat"] = None solved["solve_time"] = time.time() solved["cam_solve_time"] = solved["solve_time"] diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index 535c8f02f..934447b39 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -126,12 +126,12 @@ def __repr__(self): SharedStateObj( power_state=1, solve_state=True, - solution={'RA': 22.86683471463411, 'Dec': 15.347716050003328, 'imu_pos': [171.39798541261814, 202.7646132036331, 358.2794741322842], + solution={'RA': 22.86683471463411, 'Dec': 15.347716050003328, 'solve_time': 1695297930.5532792, 'cam_solve_time': 1695297930.5532837, 'Roll': 306.2951794424281, 'FOV': 10.200729425086111, 'RMSE': 21.995567413046142, 'Matches': 12, 'Prob': 6.987725483613384e-13, 'T_solve': 15.00384000246413, 'RA_target': 22.86683471463411, 'Dec_target': 15.347716050003328, 'T_extract': 75.79255499877036, 'Alt': None, 'Az': None, 'solve_source': 'CAM', 'constellation': 'Psc'}, - imu={'moving': False, 'move_start': 1695297928.69749, 'move_end': 1695297928.764207, 'pos': [171.39798541261814, 202.7646132036331, 358.2794741322842], - 'start_pos': [171.4009455613444, 202.76321535004726, 358.2587208386012], 'status': 3}, + imu={'moving': False, 'move_start': 1695297928.69749, 'move_end': 1695297928.764207, + 'status': 3}, location={'lat': 59.05139745, 'lon': 7.987654, 'altitude': 151.4, 'source': 'GPS', gps_lock': False, 'timezone': 'Europe/Stockholm', 'last_gps_lock': None}, datetime=None, screen=, @@ -209,7 +209,7 @@ def __init__(self): "exposure_start": 0, "exposure_end": 0, "imu": None, - "imu_delta": 0, + "imu_delta": 0.0, # Angle between quaternion at start and end of exposure [deg] } self.__solution = None self.__sats = None diff --git a/python/PiFinder/sys_utils.py b/python/PiFinder/sys_utils.py index 7eddbc496..ed0422c7c 100644 --- a/python/PiFinder/sys_utils.py +++ b/python/PiFinder/sys_utils.py @@ -322,3 +322,91 @@ def switch_cam_imx296() -> None: def switch_cam_imx462() -> None: logger.info("SYS: Switching cam to imx462") sh.sudo("python", "-m", "PiFinder.switch_camera", "imx462") + + +def check_and_sync_gpsd_config(baud_rate: int) -> bool: + """ + Checks if GPSD configuration matches the desired baud rate, + and updates it only if necessary. + + Args: + baud_rate: The desired baud rate (9600 or 115200) + + Returns: + True if configuration was updated, False if already correct + """ + logger.info(f"SYS: Checking GPSD config for baud rate {baud_rate}") + + try: + # Read current config + with open("/etc/default/gpsd", "r") as f: + content = f.read() + + # Determine expected GPSD_OPTIONS + if baud_rate == 115200: + # NOTE: the space before -s in the next line is really needed + expected_options = 'GPSD_OPTIONS=" -s 115200"' + else: + expected_options = 'GPSD_OPTIONS=""' + + # Check if update is needed + current_match = re.search(r'^GPSD_OPTIONS=.*$', content, re.MULTILINE) + if current_match: + current_options = current_match.group(0) + if current_options == expected_options: + logger.info("SYS: GPSD config already correct, no update needed") + return False + + # Update is needed + logger.info(f"SYS: GPSD config mismatch, updating to {expected_options}") + update_gpsd_config(baud_rate) + return True + + except Exception as e: + logger.error(f"SYS: Error checking/syncing GPSD config: {e}") + return False + + +def update_gpsd_config(baud_rate: int) -> None: + """ + Updates the GPSD configuration file with the specified baud rate + and restarts the GPSD service. + + Args: + baud_rate: The baud rate to configure (9600 or 115200) + """ + logger.info(f"SYS: Updating GPSD config with baud rate {baud_rate}") + + try: + # Read the current config + with open("/etc/default/gpsd", "r") as f: + lines = f.readlines() + + # Update GPSD_OPTIONS line + updated_lines = [] + for line in lines: + if line.startswith("GPSD_OPTIONS="): + if baud_rate == 115200: + # NOTE: the space before -s in the next line is really needed + updated_lines.append('GPSD_OPTIONS=" -s 115200"\n') + else: + updated_lines.append('GPSD_OPTIONS=""\n') + else: + updated_lines.append(line) + + # Write the updated config to a temporary file + with open("/tmp/gpsd.conf", "w") as f: + f.writelines(updated_lines) + + # Copy the temp file to the actual location with sudo + sh.sudo("cp", "/tmp/gpsd.conf", "/etc/default/gpsd") + + # Restart GPSD service + sh.sudo("systemctl", "restart", "gpsd") + + logger.info("SYS: GPSD configuration updated and service restarted") + + except Exception as e: + logger.error(f"SYS: Error updating GPSD config: {e}") + raise + diff --git a/python/PiFinder/ui/base.py b/python/PiFinder/ui/base.py index 53a0f0363..9ee2c6d03 100644 --- a/python/PiFinder/ui/base.py +++ b/python/PiFinder/ui/base.py @@ -223,7 +223,7 @@ def screen_update(self, title_bar=True, button_hints=True) -> None: (6, 1), _(self.title), font=self.fonts.bold.font, fill=fg ) imu = self.shared_state.imu() - moving = True if imu and imu["pos"] and imu["moving"] else False + moving = True if imu and imu["quat"] and imu["moving"] else False # GPS status if self.shared_state.altaz_ready(): diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 1777bf789..6583a0c05 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -38,6 +38,14 @@ def go_back(ui_module: UIModule) -> None: return +def show_advanced_message(ui_module: UIModule) -> None: + """ + Show popup message when entering Advanced settings menu + """ + ui_module.message(_("Options for\nDIY PiFinders"), 2) + return + + def reset_filters(ui_module: UIModule) -> None: """ Reset all filters to default @@ -317,3 +325,24 @@ def generate_custom_object_name(ui_module: UIModule) -> str: # Return next available number return f"CUSTOM {max_num + 1}" + + +def update_gpsd_baud_rate(ui_module: UIModule) -> None: + """ + Updates the GPSD configuration with the current baud rate setting. + Always updates GPSD config regardless of current GPS type. + """ + baud_rate = ui_module.config_object.get_option("gps_baud_rate") + + ui_module.message(_("Checking GPS\nconfig..."), 2) + logger.info(f"Checking GPSD baud rate {baud_rate}") + + try: + if sys_utils.check_and_sync_gpsd_config(baud_rate): + ui_module.message(_("GPS config\nupdated"), 2) + else: + ui_module.message(_("GPS config\nOK"), 2) + except Exception as e: + logger.error(f"Failed to update GPSD config: {e}") + ui_module.message(_("GPS config\nfailed"), 3) + diff --git a/python/PiFinder/ui/console.py b/python/PiFinder/ui/console.py index 2c3471ecf..2fc4a9d5c 100644 --- a/python/PiFinder/ui/console.py +++ b/python/PiFinder/ui/console.py @@ -135,7 +135,7 @@ def screen_update(self, title_bar=True, button_hints=True): ) self.draw.text((6, 1), self.title, font=self.fonts.bold.font, fill=fg) imu = self.shared_state.imu() - moving = True if imu and imu["pos"] and imu["moving"] else False + moving = True if imu and imu["quat"] and imu["moving"] else False # GPS status if self.shared_state.altaz_ready(): diff --git a/python/PiFinder/ui/menu_manager.py b/python/PiFinder/ui/menu_manager.py index 8a808145a..7b5e88b08 100644 --- a/python/PiFinder/ui/menu_manager.py +++ b/python/PiFinder/ui/menu_manager.py @@ -142,13 +142,10 @@ def __init__( self.ss_count = 0 dyn_menu_equipment(self.config_object) - self.preload_modules() def screengrab(self): self.ss_count += 1 - filename = ( - f"{self.stack[-1].__uuid__}_{self.ss_count :0>3}_{self.stack[-1].title}" - ) + filename = f"{self.stack[-1].__uuid__}_{self.ss_count :0>3}_{self.stack[-1].title.replace('/','-')}" ss_imagepath = self.ss_path + f"/{filename}.png" ss = self.shared_state.screen().copy() ss.save(ss_imagepath) diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index c1de99c1d..f9af6d790 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -214,12 +214,12 @@ def _(key: str) -> Any: "objects": "catalog", "value": "RDS", }, - # { - # "name": _("WDS Doubles"), - # "class": UIObjectList, - # "objects": "catalog", - # "value": "WDS", - # }, + { + "name": _("WDS Doubles"), + "class": UIObjectList, + "objects": "catalog", + "value": "WDS", + }, { "name": _("TLK 90 Variables"), "class": UIObjectList, @@ -874,39 +874,6 @@ def _(key: str) -> Any: }, ], }, - { - "name": _("PiFinder Type"), - "class": UITextMenu, - "select": "single", - "config_option": "screen_direction", - "post_callback": callbacks.restart_pifinder, - "items": [ - { - "name": _("Left"), - "value": "left", - }, - { - "name": _("Right"), - "value": "right", - }, - { - "name": _("Straight"), - "value": "straight", - }, - { - "name": _("Flat v3"), - "value": "flat3", - }, - { - "name": _("Flat v2"), - "value": "flat", - }, - { - "name": _("AS Bloom"), - "value": "as_bloom", - }, - ], - }, { "name": _("Mount Type"), "class": UITextMenu, @@ -925,43 +892,109 @@ def _(key: str) -> Any: ], }, { - "name": _("Camera Type"), + "name": _("Advanced"), "class": UITextMenu, "select": "single", - "value_callback": callbacks.get_camera_type, + "pre_callback": callbacks.show_advanced_message, "items": [ { - "name": _("v2 - imx477"), - "callback": callbacks.switch_cam_imx477, - "value": "imx477", - }, - { - "name": _("v3 - imx296"), - "callback": callbacks.switch_cam_imx296, - "value": "imx296", - }, - { - "name": _("v3 - imx462"), - "callback": callbacks.switch_cam_imx462, - "value": "imx462", + "name": _("PiFinder Type"), + "class": UITextMenu, + "select": "single", + "config_option": "screen_direction", + "post_callback": callbacks.restart_pifinder, + "items": [ + { + "name": _("Left"), + "value": "left", + }, + { + "name": _("Right"), + "value": "right", + }, + { + "name": _("Straight"), + "value": "straight", + }, + { + "name": _("Flat v3"), + "value": "flat3", + }, + { + "name": _("Flat v2"), + "value": "flat", + }, + { + "name": _("AS Bloom"), + "value": "as_bloom", + }, + ], }, - ], - }, - { - "name": _("GPS Type"), - "class": UITextMenu, - "select": "single", - "config_option": "gps_type", - "label": "gps_type", - "post_callback": callbacks.restart_pifinder, - "items": [ { - "name": _("UBlox"), - "value": "ublox", + "name": _("Camera Type"), + "class": UITextMenu, + "select": "single", + "value_callback": callbacks.get_camera_type, + "items": [ + { + "name": _("v2 - imx477"), + "callback": callbacks.switch_cam_imx477, + "value": "imx477", + }, + { + "name": _("v3 - imx296"), + "callback": callbacks.switch_cam_imx296, + "value": "imx296", + }, + { + "name": _("v3 - imx462"), + "callback": callbacks.switch_cam_imx462, + "value": "imx462", + }, + ], }, { - "name": _("GPSD (generic)"), - "value": "gpsd", + "name": _("GPS Settings"), + "class": UITextMenu, + "select": "single", + "items": [ + { + "name": _("GPS Type"), + "class": UITextMenu, + "select": "single", + "config_option": "gps_type", + "label": "gps_type", + "post_callback": callbacks.restart_pifinder, + "items": [ + { + "name": _("UBlox"), + "value": "ublox", + }, + { + "name": _("GPSD (generic)"), + "value": "gpsd", + }, + ], + }, + { + "name": _("GPS Baud Rate"), + "class": UITextMenu, + "select": "single", + "config_option": "gps_baud_rate", + "label": "gps_baud_rate", + "post_callback": callbacks.update_gpsd_baud_rate, + "items": [ + { + "name": _("9600 (standard)"), + "value": 9600, + }, + { + "name": _("115200 (UBlox-10)"), + "value": 115200, + }, + ], + }, + ], }, ], }, diff --git a/python/PiFinder/ui/object_list.py b/python/PiFinder/ui/object_list.py index 12bb8b2fd..f44e11680 100644 --- a/python/PiFinder/ui/object_list.py +++ b/python/PiFinder/ui/object_list.py @@ -86,6 +86,7 @@ def __init__(self, *args, **kwargs) -> None: self._menu_items: list[CompositeObject] = [] self.catalog_info_1: str = "" self.catalog_info_2: str = "" + self._was_loading: bool = False # Track loading state to detect completion # Init display mode defaults self.mode_cycle = cycle(DisplayModes) @@ -402,20 +403,46 @@ def update(self, force: bool = False) -> None: self.clear_screen() begin_x = 12 + # Check if loading just completed and refresh if so + is_loading = self.catalogs.is_loading() + if self._was_loading and not is_loading: + # Loading just completed - force refresh to show new objects + # Update flag BEFORE calling refresh to avoid infinite loop + self._was_loading = False + self.refresh_object_list(force_update=True) + else: + self._was_loading = is_loading + # no objects to display if self.get_nr_of_menu_items() == 0: - self.draw.text( - (begin_x, self.line_position(2)), - _("No objects"), # TRANSLATORS: no objects in object list (1/2) - font=self.fonts.bold.font, - fill=self.colors.get(255), - ) - self.draw.text( - (begin_x, self.line_position(3)), - _("match filter"), # TRANSLATORS: no objects in object list (2/2) - font=self.fonts.bold.font, - fill=self.colors.get(255), - ) + if self.catalogs.is_loading(): + # Still loading - show loading message + self.draw.text( + (begin_x, self.line_position(2)), + _("Loading..."), # TRANSLATORS: catalogs loading message (1/2) + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (begin_x, self.line_position(3)), + _("Please wait"), # TRANSLATORS: catalogs loading message (2/2) + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + else: + # Actually no objects after loading complete + self.draw.text( + (begin_x, self.line_position(2)), + _("No objects"), # TRANSLATORS: no objects in object list (1/2) + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) + self.draw.text( + (begin_x, self.line_position(3)), + _("match filter"), # TRANSLATORS: no objects in object list (2/2) + font=self.fonts.bold.font, + fill=self.colors.get(255), + ) self.screen_update() return diff --git a/python/PiFinder/ui/status.py b/python/PiFinder/ui/status.py index 2f5324690..836bbeaae 100644 --- a/python/PiFinder/ui/status.py +++ b/python/PiFinder/ui/status.py @@ -257,14 +257,15 @@ def update_status_dict(self): imu = self.shared_state.imu() if imu: - if imu["pos"] is not None: + if imu["quat"] is not None: if imu["moving"]: mtext = "Moving" else: mtext = "Static" self.status_dict["IMU"] = f"{mtext : >11}" + " " + str(imu["status"]) self.status_dict["IMU PS"] = ( - f"{imu['pos'][0] : >6.1f}/{imu['pos'][2] : >6.1f}" + "imu NA" + # f"{imu['quat'][0] : >6.1f}/{imu['quat'][2] : >6.1f}" # TODO: Quick hack for now. This was changed from imu["pos"] and should be changed. ) location = self.shared_state.location() sats = self.shared_state.sats() diff --git a/python/PiFinder/ui/text_menu.py b/python/PiFinder/ui/text_menu.py index e61ab7bc9..426d40577 100644 --- a/python/PiFinder/ui/text_menu.py +++ b/python/PiFinder/ui/text_menu.py @@ -179,6 +179,9 @@ def key_right(self): if selected_item_definition is not None and selected_item_definition.get( "class" ): + # Check for pre_callback before adding to stack + if selected_item_definition.get("pre_callback"): + selected_item_definition["pre_callback"](self) self.add_to_stack(selected_item_definition) return diff --git a/python/pyproject.toml b/python/pyproject.toml index 5617b6ec3..fe871ce19 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -128,6 +128,7 @@ module = [ 'sklearn.*', 'PyHotKey.*', 'PiFinder.tetra3.*', + 'quaternion', 'tetra3.*', 'grpc', 'ceder_detect_pb2', diff --git a/python/requirements.txt b/python/requirements.txt index 5f92091ab..2fa71abcf 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -9,7 +9,8 @@ json5==0.9.25 luma.oled==3.12.0 luma.lcd==2.11.0 pillow==10.4.0 -numpy==1.26.2 +numpy==1.26.4 +numpy-quaternion==2023.0.4 pandas==1.5.3 pydeepskylog==1.3.2 pyjwt==2.8.0 diff --git a/python/tests/test_catalog_data.py b/python/tests/test_catalog_data.py index 4f9799f69..5f00330ce 100644 --- a/python/tests/test_catalog_data.py +++ b/python/tests/test_catalog_data.py @@ -1,5 +1,8 @@ import pytest +import threading from PiFinder.db import objects_db +from PiFinder.db import observations_db +from PiFinder.catalogs import CatalogBackgroundLoader, Names @pytest.mark.unit @@ -318,3 +321,73 @@ def test_catalog_data_validation(): check_messier_objects() check_ngc_objects() check_ic_objects() + + +@pytest.mark.unit +def test_background_loader(): + """ + Test that CatalogBackgroundLoader correctly loads objects in background. + """ + # Load minimal test data + db = objects_db.ObjectsDatabase() + + # Create a mock observations database for testing + class MockObservationsDB: + def check_logged(self, obj): + return False + + obs_db = MockObservationsDB() + + # Get small sample of WDS objects for testing + catalog_objects = list(db.get_catalog_objects_by_catalog_code("WDS"))[:100] + catalog_objects_list = [dict(row) for row in catalog_objects] + + # Get objects dict + objects = {row["id"]: dict(row) for row in db.get_objects()} + + # Get names + common_names = Names() + + # Track completion + loaded_count = 0 + completed = threading.Event() + loaded_objects = [] + + def on_progress(loaded, total, catalog): + nonlocal loaded_count + loaded_count = loaded + + def on_complete(objects): + nonlocal loaded_objects + loaded_objects = objects + completed.set() + + # Create and start loader + loader = CatalogBackgroundLoader( + deferred_catalog_objects=catalog_objects_list, + objects=objects, + common_names=common_names, + obs_db=obs_db, + on_progress=on_progress, + on_complete=on_complete, + ) + + # Configure for faster testing + loader.batch_size = 10 + loader.yield_time = 0.001 + + loader.start() + + # Wait for completion (5 second timeout) + assert completed.wait(timeout=5.0), "Background loading did not complete in time" + + # Verify results + assert loaded_count == 100, f"Expected 100 objects, got {loaded_count}" + assert len(loaded_objects) == 100, f"Expected 100 loaded objects, got {len(loaded_objects)}" + + # Verify objects have details loaded + for obj in loaded_objects[:10]: # Check first 10 + assert obj._details_loaded, "Object should have details loaded" + assert obj.mag_str != "...", "Object should have magnitude loaded" + assert hasattr(obj, "names"), "Object should have names" + assert obj.catalog_code == "WDS", "Object should be WDS catalog"