diff --git a/.gitignore b/.gitignore index b76187d..b72907f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,15 @@ # Cache *__pycache__* +# Personal +*my_* +/my_& +*My* + # System data and debug /pypogs/data/* /pypogs/debug/* +*.txt # Builds /build/* @@ -16,3 +22,12 @@ # Settings /setup.cfg + +# Exclude custom tetra3 databases (except default_database.npz) +*.npz +!default_database.npz + +#Exclude tetra3 source database files (tyc_main.dat, hip_main.dat) +*.dat +*.pyc +tetra3 diff --git a/README.rst b/README.rst index b93f40e..e9dda37 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,187 @@ -Welcome to pypogs! +Welcome to (unofficial) pypogs! ================== +*Experimental fork for satellite imaging* +----------------------------------------- + +==== + +Relation to main ESA project +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This fork is unaffiliated with ESA and is managed outside of the main ESA project, but +coordinates informally with the main project. This fork pulls updates form the main project +and in some cases may feed forward new features, developed and tested here, if deemed robust +and useful to the optical comm goals of the main ESA project. + ++ pypogs **main project**: https://github.com/esa/pypogs ++ pypogs **documentation**: https://pypogs.readthedocs.io/ + +==== + +Geared toward satellite imaging +^^^^ + +With several features added for user convenience: + ++ ASCOM mount control (partially tested) ++ ASCOM camera control (for non-ZWO astro cameras; limited to ~5 fps) ++ Expanded target selection methods: + + "saved" target selection pull-down menu (users can append this short list via startup scripts) + + TLE lookup by NORAD ID + + JPL horizons epehemeris lookup (for JWST, Psyche, etc!) ++ Configurable TCP application links to: + + Stellarium for graphical display of telescope pointing and receipt of go-to requests + + SkyTrack for receiving target TLEs ++ Expanded configurability of auto-alignment routine + + Self-alignment vectors configurable by command (to avoid my neighbor's trees) + + Additional user controls for settling time, retry counts, etc. ++ Sidereal tracking button (sometimes useful for finding faint satellites) ++ Camera settings (gain, exposure, etc) exposed to initialization commands (useful in startup scripts) ++ Improved start of tracking by projecting intercept point ++ Additional control offset in coarse camera view (to help recover from severe misalignment) + + +==== + +Hardware Compatibility +^^^^^^^^^^^^^^^^^^^^^^ + +| **!!!! Presently restricted to alt-az mounts only !!!!** +| *Equatorial mount support is under consideration but would require refactoring the entire pypogs architecture.* + +The telescope mount must be capable of smoothly executing continuous floating-point rate commands. + +**Telescope Mounts:** + ++ Celestron CPC (tested, *recommended*) & NexStar (tested) ++ iOptron AZMP with `latest firmware `_ (tested) + + Probably: HAZ31, HAZ46 (not tested) ++ Possibly others via ASCOM (*not* tested) ++ Planned: Meade LX200 (*not* tested) ++ Not testd: Sky-Watcher + +**Cameras:** + ++ Point Gray (tested) ++ ZWO (tested, *recommended*) ++ QHY via ASCOM (tested, limited by driver to 5 fps) ++ Possibly others via ASCOM (not tested, limited by driver to 5 fps) ++ Under consideration: QHY direct driver (*not* tested) ++ Under consideration: directshow (for webcams and non-ZWO cameras, *not* tested) + +==== + +Optical Configuration Considerations +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Image outside of pypogs** *(for now)* + +Presently, pypogs cannot record frames from a camera at full frame rate while simultaneously +controlling from the same camera. Therefore, it is recommended that users operate main imaging +cameras through separate software (e.g. SharpCap, FIreCapture, ASICap, etc) while tracking a +satellite with one or two dedicated control cameras in pypogs. + +**Wide field of view for auto-alignment** + +Pypogs provides a brilliant auto-alignment routine which measures and compensates for a mount's +inherent alignment error. The auto-alignment routine dramatically improves pypogs target +acquisition and tracking performance. It is highly recommended that operators run auto-alignment +prior to tracking, or load a previous alignment solution if a mount and telescope system has not +changed. The plate solver used by pypogs' auto-alignment routine works best with wide fields of +view, roughly 10 degrees or more. + +:: + + field of view = arctan( camera sensor width / focal length) + +Focal lengths of 35mm and 50mm have been found to work well with with small-chip (asi120, asi290, +asi178, asi224, etc) and large-chip (asi174) guide cameras, respectively. C-mount/CS-mount CCTV +lenses work well, but must be of decent optical quality. The plate solver is sensitive to +optical distortion from low-quality lenses. Several ultra-cheap CCTV lenses were found not to +work due to field distortion and field flatness (corners out of focus). + +Recommended star camera lenses: + ++ For small-chip guide cameras (asi290, etc): `Fujinon hf35ha-1s 35mm Lens `_ ($110 USD) +* For large-chip guide cameras (asi174): `Fujinon hf50ha-1s 50mm Lens `_ ($155 USD) ++ Budget option for large-chip guide cameras (asi174): `Arducam C-Mount 50mm Lens `_ ($46 USD, one test article shows noticeable tilt but works reliably) + + + + +**Competing constraints: auto-alignment and bright target acquisition vs tracking precision** + +In addition to being better suited for plate solving, a wide field of view coarse camera +configuration can reduce susceptibility to alignment error during initial target acquisition by +presenting a larger patch of sky for pypogs to search. This wide field advantage can only be +realized with targets that are bright enough (visual magnitude ~3 or less) to be detected in the +wide view. Small or distant, dim targets generally require longer focal length to detect and +track. Moreover, longer focal length (narrower field of view) yields better tracking performance. +As a rule of thumb, it is recommended that the finest view used by pypogs have focal length not +less than about 1/10th that of the primary imaging telescope. For example, with a C8 at f/10 +(2032 mm focal length), the guide scope focal length should be at least 200 mm. + +*In a nutshell, although it may be possible to operate pypogs with a single guide scope and +camera, competing objectives of auto-alignment, initial target acquisition, and tracking +generally warrant operating pypogs with at least 2 optical systems - one wide field optical +system for auto-alignment and bright object initial acquisition, and a separate, longer focal +length system for dim object initial acquisition and fine guiding.* + +**Star Camera, Coarse Camera, or Fine Camera?** + +Which camera "role" in pypogs should be associated with which optical system? It depends. + +If you are planning to track only bright objects like ISS and CSS, use a wide field system as +your Coarse Camera, and enable "Link Star/Coarse Cameras" to use this camera in both roles. +Select a narrow field of view system as the Fine Camera. This way, the wide field system +will be used for both auto-alignment and initial target acquisition and tracking, and once +the pypogs locks onto the target in the coarse view, it should then automatically search for +and lock onto the target in the fine camera, providing best stabilization for a primary imaging +system (operated outside of pypogs). + +If you are planning to track dim objects (visual magnitude >2.5 or so) which cannot be +detected in the wide field camera view, configure the wide field system as your Star Camera +only, and load the narrow field of view camera as the Coarse Camera. + + +==== + +Getting Started +^^^^ + +Install `ASI Camera SDK` if using ZWO cameras. + +Check hardware compatiblity before proceeding. + +| Follow `installation instructions `_ + provided from the main project, **but** +| clone "https://github.com/rkinnett/pypogs.git" +| instead of "https://github.com/esa/pypogs.git". + +Once installed, run graphical pypogs by: + +:: + + cd examples + python run_pypogsGUI.py + +This is a starting point configuration without any hardware initialized, and with default +settings for everything. + +The file run_pypogsGUI.py contains many commented-out (via # and ''') configuration commands +as examples of how to customize a startup configuration. + +The user may copy run_pypogsGUI.py to a new file titled "my_pypogs.py" or similar, specifically +prefixed by "my\_" so that git will not try to configuration manage unique configuration files +when the user updates pypogs via git. + + +==== + +pypogs general overview (from main project) +------------------------------------------- + *pypogs is an automated closed-loop satellite tracker for portable telescopes written in Python.* Use it to control your optical ground station, auto-align it to the stars, and automatically acquire diff --git a/examples/clear_logs.bat b/examples/clear_logs.bat new file mode 100644 index 0000000..0a7e103 --- /dev/null +++ b/examples/clear_logs.bat @@ -0,0 +1,2 @@ +echo. 2> ../pypogs/debug/pypogs.txt +echo. 2> ../pypogs/debug/gui.txt \ No newline at end of file diff --git a/examples/mount_only.py b/examples/mount_only.py new file mode 100644 index 0000000..ccb1efd --- /dev/null +++ b/examples/mount_only.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +""" +Run the pypogs GUI +================== + +Run this script (i.e. type python run_pypogsGUI.py in a termnial window) to start the pypogs Graphical User Interface. +""" +import sys +sys.path.append('..') +import pypogs + +# ClEAR LOGS: +open('../pypogs/debug/pypogs.txt', 'w').close() +open('../pypogs/debug/gui.txt', 'w').close() + +# INITIALIZE PYPOGS SYSTEM: +sys = pypogs.System() + +# CONFIGURE GROUND STATION SITE: +class MySite: + lat = 34.2 # degrees N + lon = -118.2 # degrees E + elev = 600 # meters above MSL +sys.alignment.set_location_lat_lon(lat=MySite.lat, lon=MySite.lon, height=MySite.elev) +sys.alignment.set_alignment_enu() + + +# ADD MOUNT: +#sys.add_mount(model="ASCOM", identity="Simulator") +#sys.add_mount(model="ASCOM", identity="DeviceHub", axis_directions=(1, -1)) # ascom inverts alt axis? +sys.add_mount(model="iOptron AZMP", identity="COM2", max_rate=(16, 16)) +#sys.add_mount(model="Celestron", identity="COM5") + +# APPLICATION LINKS +# Use address 127.0.0.1 if the external application runs on this computer. +# Use address 0.0.0.0 if the external application runs on another computer on your local network. +sys.stellarium_telescope_server.start(address='127.0.0.1', port=10001, poll_period=1) # Stellarium connection +sys.target_server.start(address='127.0.0.1', port=12345, poll_period=1) # SkyTrack connection + + +# START GUI: +try: + pypogs.GUI(sys, 500) + #sys.do_auto_star_alignment(max_trials=2, rate_control=True, pos_list=[(40, -135), (60, -135)]) + +except Exception: + raise +finally: + sys.deinitialize() \ No newline at end of file diff --git a/examples/pypogs_sim.py b/examples/pypogs_sim.py new file mode 100644 index 0000000..caf5749 --- /dev/null +++ b/examples/pypogs_sim.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +""" +Run the pypogs GUI +================== + +Run this script (i.e. type python run_pypogsGUI.py in a termnial window) to start the pypogs Graphical User Interface. +""" +import sys, pathlib +sys.path.append('..') +import pypogs + +# ClEAR LOGS: +open('../pypogs/debug/pypogs.txt', 'w').close() +open('../pypogs/debug/gui.txt', 'w').close() + +# INITIALIZE PYPOGS SYSTEM: +sys = pypogs.System() + +# APPLICATION LINKS +# Use address 127.0.0.1 if the external application runs on this computer. +# Use address 0.0.0.0 if the external application runs on another computer on your local network. +#sys.stellarium_telescope_server.start(address='127.0.0.1', port=10001, poll_period=1) # Stellarium connection +sys.stellarium_telescope_server.start(address='0.0.0.0', port=10001, poll_period=1) # Stellarium connection +sys.target_server.start(address='127.0.0.1', port=12345, poll_period=1) # SkyTrack connection + + +# Set custom tetra3 database: +#sys.tetra3.load_database('my_custom_tetra3_database') # If my_custom_tetra3_database.npz is in your tetra3 installation directory +#sys.tetra3.load_database(pathlib.Path('my_custom_tetra3_database.npz')) # If my_custom_tetra3_database.npz is in current working directory + + +# CONFIGURE GROUND STATION SITE: +sys.alignment.set_location_lat_lon( + lat = 34, # degrees N + lon = -118, # degrees E + height = 500 # meters MSL +) +sys.alignment.set_alignment_enu() +#sys.alignment.get_alignment_data_form_file('../pypogs/data/2022-03-09T050113_Alignment_from_obs.csv') + + +''' +auto_align_azi = (-30, 60, 150, -120) +auto_align_alt = (50, 60) +auto_align_vectors = [] +for azi in auto_align_azi: + for alt in auto_align_alt: + auto_align_vectors.append((alt, azi)) +sys.auto_align_vectors = auto_align_vectors +''' + +# ADD MOUNT: +# Note: it appears ASCOM inverts the alt axis, hence -1 axis direction. +sys.add_mount(model="ASCOM", identity="Simulator", max_rate=(10, 10), alt_limit=(-8, 95), axis_directions=(1, -1)) +#sys.add_mount(model="ASCOM", identity="DeviceHub", axis_directions=(1, -1)) +#sys.add_mount(model="iOptron AZMP", identity="COM2") +#sys.add_mount(model="Celestron", identity="COM2") + + +# ADD COARSE CAMERA: +sys.add_coarse_camera( + model="ASCOM", + #identity="ASICamera2", + #identity="ASICamera2_2", + identity="Simulator", + exposure_time = 150, + gain = 400, + plate_scale = 5, + binning = 1 +) + + +# ADD STAR CAMERA: +#sys.add_star_camera_from_coarse() +sys.add_star_camera( + model="ASCOM", + #identity="ASICamera2", + #identity="ASICamera2_2", + identity="Simulator", + exposure_time = 150, + gain = 400, + plate_scale = 20, + binning = 1 +) + + +# ADD FINE CAMERA: +#finePlateScale = 206 * 5.86 / 2350 # arcsec/pixel, 206 * pixel_pitch_um / focal_length_mm +#sys.add_fine_camera(model="ASCOM", identity="ASICamera2", exposure_time=500, gain=260, plate_scale=finePlateScale) + +if sys.mount is not None: + + # GENERAL FEEDBACK SETTINGS: + sys.control_loop_thread.integral_max_add = 36 #30 + sys.control_loop_thread.integral_max_subtract = 360 #30 + sys.control_loop_thread.integral_min_rate = 0 #5 + + # OPEN LOOP TRACKING SETTINGS: + sys.control_loop_thread.OL_P = 0.5 #1 + sys.control_loop_thread.OL_I = 10 + sys.control_loop_thread.OL_speed_limit = 10*3600 # increased from 7200 arcsec/sec to improve zenith recovery + + # COARSE TRACKING SETTINGS: + if sys.coarse_camera is not None: + sys.coarse_track_thread.spot_tracker.smoothing_parameter = 4 + sys.coarse_track_thread.spot_tracker.sigma_mode = 'global_root_square' + sys.coarse_track_thread.spot_tracker.bg_subtract_mode = 'local_mean' + sys.coarse_track_thread.spot_tracker.filtsize = 25 + + sys.coarse_track_thread.spot_tracker.max_search_radius = 1000 #500 + sys.coarse_track_thread.spot_tracker.min_search_radius = 200 + sys.coarse_track_thread.spot_tracker.spot_min_sum = 50 #500 + sys.coarse_track_thread.spot_tracker.spot_min_area = 6 #3 + sys.coarse_track_thread.spot_tracker.fails_to_drop = 10 + sys.coarse_track_thread.spot_tracker.smoothing_parameter = 4 #8 + sys.coarse_track_thread.spot_tracker.rmse_smoothing_parameter = 8 + sys.coarse_track_thread.feedforward_threshold = 10 + + sys.control_loop_thread.CCL_P = 0.5 #1 + sys.control_loop_thread.CCL_I = 8 #5 + sys.control_loop_thread.CCL_speed_limit = 3600 + sys.control_loop_thread.CCL_transition_th = 200 # increased from 100 to tolerate more drift + + # FINE TRACKING SETTINGS: + if sys.fine_camera is not None: + sys.fine_track_thread.spot_tracker.smoothing_parameter = 4 + sys.fine_track_thread.spot_tracker.sigma_mode = 'global_root_square' + sys.fine_track_thread.spot_tracker.bg_subtract_mode = 'local_mean' + sys.fine_track_thread.spot_tracker.filtsize = 25 + + sys.control_loop_thread.FCL_P = 1 #2 + sys.control_loop_thread.FCL_I = 5 #5 + sys.control_loop_thread.FCL_speed_limit = 60 # keep this small to reduce smear in primary imaging camera + sys.control_loop_thread.FCL_transition_th = 200 # increased from 100 to tolerate more drift + + + +# SET TARGET: +#sys.target.get_and_set_tle_from_sat_id(23712) # ISS = 25544 +sys.target.get_and_set_tle_from_sat_id(25544) # ISS = 25544 +#sys.target.get_ephem(obj_id='-48', lat=MySite.lat, lon=MySite.lon, height=MySite.elev) +#sys.target.get_ephem(obj_id='7', lat=MySite.lat, lon=MySite.lon, height=MySite.elev) +#sys.target.get_ephem(obj_id='-170', lat=MySite.lat, lon=MySite.lon, height=MySite.elev) + + +# START GUI: +try: + pypogs.GUI(sys, 500) + #sys.do_auto_star_alignment(max_trials=2, rate_control=True, pos_list=[(40, -135), (60, -135)]) + +except Exception: + raise +finally: + sys.deinitialize() \ No newline at end of file diff --git a/examples/run_pypogsGUI.py b/examples/run_pypogsGUI.py index fbb40b6..23cab58 100644 --- a/examples/run_pypogsGUI.py +++ b/examples/run_pypogsGUI.py @@ -6,16 +6,114 @@ Run this script (i.e. type python run_pypogsGUI.py in a termnial window) to start the pypogs Graphical User Interface. """ import sys +from pathlib import Path sys.path.append('..') - import pypogs +# ClEAR LOGS: +open('../pypogs/debug/pypogs.txt', 'w').close() +open('../pypogs/debug/gui.txt', 'w').close() + +# INITIALIZE PYPOGS SYSTEM: sys = pypogs.System() +# ADD TARGETS TO SAVED TARGETS LIST: +#sys.saved_targets['YAOGAN 1'] = 29092 + +# CONFIGURE GROUND STATION SITE: +# class MySite: +# lat = 0 # degrees N +# lon = 0 # degrees E +# elev = 500 # meters above MSL +#sys.alignment.set_location_lat_lon(lat=MySite.lat, lon=MySite.lon, height=MySite.elev) +#sys.alignment.set_alignment_enu() # uses mount-internal alignment model with no corrections +#sys.alignment.get_alignment_data_form_file('../pypogs/data/2022-03-09T050113_Alignment_from_obs.csv') # load a previous alignment solution + + +# ADD MOUNT: +''' +sys.add_mount( + model="ASCOM", identity="Simulator" +# model="ASCOM", identity="DeviceHub" +# model="iOptron AZMP", identity="COM2" +# model="Celestron", identity="COM3" +) +if sys.mount is not None: + sys.mount.max_rate = (6, 6) + sys.mount.alt_limit = (-8, 80) + sys.stellarium_telescope_server.start(address='0.0.0.0', port=10001, poll_period=1) +''' + + + +# ADD COARSE CAMERA: +''' +coarsePlateScale = 206 * 5.86 / (400*0.65) # arcsec/pixel, 206 * pixel_pitch_um / focal_length_mm +sys.add_coarse_camera( + model="ASCOM", + #identity="ASICamera2", + #identity="ASICamera2_2", + identity="Simulator", + #identity="QHYCCD_GUIDER", + exposure_time = 100, + #gain = 400, + plate_scale = round(coarsePlateScale, 3), + binning = 2 +) +''' + +# ADD STAR CAMERA: +#if sys.coarse_camera is not None: +# sys.add_star_camera_from_coarse() + + +# ADD FINE CAMERA: +#finePlateScale = 206 * 5.86 / 2350 # arcsec/pixel, 206 * pixel_pitch_um / focal_length_mm +#sys.add_fine_camera(model="ASCOM", identity="ASICamera2", exposure_time=500, gain=260, plate_scale=finePlateScale) + + +# CONFIGURE TRACKING SETTINGS (FEEDBACK PROPERTIES): +#sys.control_loop_thread.OL_speed_limit = 7200 +#sys.control_loop_thread.CCL_speed_limit = 360 +#sys.control_loop_thread.CCL_transition_th = 180 +#sys.control_loop_thread.FCL_transition_th = 100 + +# CHANGE COARSE/FINE TRACKING SETTINGS: +# (MOUNT AND RESPECTIVE COARSE/FINE CAMERA MUST BE DEFINED PREVIOUSLY) +''' +if sys.mount is not None and sys.coarse_camera is not None: + sys.coarse_track_thread.spot_tracker.smoothing_parameter = 4 + sys.coarse_track_thread.spot_tracker.sigma_mode = 'global_root_square' + sys.coarse_track_thread.spot_tracker.bg_subtract_mode = 'local_mean' + sys.coarse_track_thread.spot_tracker.filtsize = 25 +''' + +# ENABLE SAVING IMAGES DURING TRACKING: +# (MOUNT AND RESPECTIVE COARSE/FINE CAMERA MUST BE DEFINED PREVIOUSLY) +#sys.coarse_track_thread.img_save_frequency = 1 +#sys.coarse_track_thread.image_folder = Path('D:\pypogs') +#sys.fine_track_thread.img_save_frequency = 1 +#sys.fine_track_thread.image_folder = Path('D:\pypogs') + +# SET TARGET: +#sys.target.get_and_set_tle_from_sat_id(23712) # ISS = 25544 +sys.target.get_and_set_tle_from_sat_id(25544) # ISS = 25544 +#sys.target.get_ephem(obj_id='-48', lat=MySite.lat, lon=MySite.lon, height=MySite.elev) +#sys.target.get_ephem(obj_id='7', lat=MySite.lat, lon=MySite.lon, height=MySite.elev) +#sys.target.get_ephem(obj_id='-170', lat=MySite.lat, lon=MySite.lon, height=MySite.elev) + +# APPLICATION LINKS +# Use address 127.0.0.1 if the external application runs on this computer. +# Use address 0.0.0.0 if the external application runs on another computer on your local network. +sys.stellarium_telescope_server.start(address='127.0.0.1', port=10001, poll_period=1) # Stellarium connection +sys.target_server.start(address='127.0.0.1', port=12345, poll_period=1) # SkyTrack connection + + + +# START GUI: try: - pypogs.GUI(sys, 500) + pypogs.GUI(sys, 50) except Exception: raise finally: - sys.deinitialize() - + sys.deinitialize() \ No newline at end of file diff --git a/pypogs/_system_data/ASICamera2.dll b/pypogs/_system_data/ASICamera2.dll new file mode 100644 index 0000000..c3b402e Binary files /dev/null and b/pypogs/_system_data/ASICamera2.dll differ diff --git a/pypogs/_system_data/ASICamera2.lib b/pypogs/_system_data/ASICamera2.lib new file mode 100644 index 0000000..45d5c6c Binary files /dev/null and b/pypogs/_system_data/ASICamera2.lib differ diff --git a/pypogs/_system_data/ASICamera2_license.txt b/pypogs/_system_data/ASICamera2_license.txt new file mode 100644 index 0000000..5e86d68 --- /dev/null +++ b/pypogs/_system_data/ASICamera2_license.txt @@ -0,0 +1,20 @@ +Copyright (c) 2015, ZWO Company +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/pypogs/gui.py b/pypogs/gui.py index f1f233c..4389a12 100644 --- a/pypogs/gui.py +++ b/pypogs/gui.py @@ -24,6 +24,7 @@ # Standard imports: import tkinter as tk import tkinter.ttk as ttk +from tkinter import filedialog from astropy.time import Time as apy_time from astropy.coordinates import SkyCoord from skyfield.sgp4lib import EarthSatellite @@ -32,6 +33,7 @@ import numpy as np from pathlib import Path import logging +import sys, os, traceback COMMAND = 0 MOUNT = 1 @@ -107,6 +109,7 @@ def __init__(self, pypogs_system, gui_update_ms=500, debug_folder=None): self.logger.debug('Loading MountControlFrame') self.manual_control_frame = MountControlFrame(self.col1_frame, self.sys, self.logger) self.manual_control_frame.grid(column=0, pady=(0,10), sticky=tk.E+tk.W+tk.N) + self.manual_control_frame.start(gui_update_ms) self.logger.debug('Loading AlignmentFrame') self.alignment_frame = AlignmentFrame(self.col1_frame, self.sys, self.logger) self.alignment_frame.grid(column=0, pady=(0,10), sticky=tk.E+tk.W+tk.N) @@ -204,7 +207,7 @@ def controller_callback(self): def coarse_callback(self): try: - assert self.sys.coarse_track_thread is not None, 'No coarse tracker' + assert self.sys.coarse_track_thread is not None, 'No coarse tracker (requires mount and coarse camera).' if self.coarse_popup is None: self.coarse_popup = self.ControlPopup(self, device=self.sys.coarse_track_thread.spot_tracker,\ title='Coarse Tracker') @@ -217,7 +220,7 @@ def coarse_callback(self): def fine_callback(self): try: - assert self.sys.fine_track_thread is not None, 'No fine tracker' + assert self.sys.fine_track_thread is not None, 'No fine tracker (requires mount and fine camera).' if self.fine_popup is None: self.fine_popup = self.ControlPopup(self, device=self.sys.fine_track_thread.spot_tracker,\ title='Fine Tracker') @@ -276,7 +279,7 @@ def update(self): label.grid(row=row, column=column, sticky=tk.E) try: name = prop - value = str(getattr(self.device, name)) + value = str(getattr(self.device, name)).replace(' ',',') entry.insert(0, value) label['text'] = name except AttributeError as err: @@ -299,7 +302,7 @@ def update(self): def set_properties(self): self.logger.debug('Got set properties. Here are the entries:') for name, entry, old_value, label in self.property_entries: - new_value = entry.get() + new_value = entry.get().replace('[','').replace(']','') if not new_value == old_value: if name in ('sigma_mode', 'bg_subtract_mode'): parsed = new_value @@ -329,7 +332,7 @@ def __init__(self, master, pypogs_system, logger): self.sys = pypogs_system self._update_stop = True self._update_after = 1000 - + self.logger.debug('Creating label and buttons') ttk.Label(self, text='Tracking').pack(fill=tk.BOTH, expand=True) @@ -361,9 +364,9 @@ def __init__(self, master, pypogs_system, logger): .grid(sticky=tk.W) ttk.Checkbutton(auto_frame, text='Fine', variable=self.auto_fine, command=self.fine_callback) \ .grid(sticky=tk.W) - ttk.Button(self, text='Start Tracking', command=self.start_callback, width=12) \ + ttk.Button(self, text='Start Tracking', command=self.start_tracking_callback, width=12) \ .pack(fill=tk.BOTH, expand=True) - ttk.Button(self, text='Stop Tracking', command=self.stop_callback, width=12) \ + ttk.Button(self, text='Stop Tracking', command=self.stop_tracking_callback, width=12) \ .pack(fill=tk.BOTH, expand=True) self.update() @@ -386,7 +389,7 @@ def update(self): def coarse_callback(self): try: - assert self.sys.coarse_track_thread is not None, 'No coarse tracker' + assert self.sys.coarse_track_thread is not None, 'No coarse tracker (requires mount and coarse camera).' self.sys.coarse_track_thread.auto_acquire_track = self.auto_coarse.get() except Exception as err: self.logger.debug('Could not set coarse to auto', exc_info=True) @@ -394,7 +397,7 @@ def coarse_callback(self): def fine_callback(self): try: - assert self.sys.fine_track_thread is not None, 'No fine tracker' + assert self.sys.fine_track_thread is not None, 'No fine tracker (requires mount and fine camera).' self.sys.fine_track_thread.auto_acquire_track = self.auto_fine.get() except Exception as err: self.logger.debug('Could not set fine to auto', exc_info=True) @@ -418,13 +421,17 @@ def enable_callback(self): ErrorPopup(self, err, self.logger) # self.update() - def start_callback(self): + def start_tracking_callback(self): + if self.sys.mount is not None and self.sys.mount.is_init and self.sys.mount._is_sidereal_tracking: + self.logger.debug('Sidereal tracking is on. Will turn off.') + self.sys.mount.stop_sidereal_tracking() try: self.sys.start_tracking() except Exception as err: self.logger.debug('Did not start tracking', exc_info=True) ErrorPopup(self, err, self.logger) - def stop_callback(self): + def stop_tracking_callback(self): + self.logger.debug('TrackingControlFrame got stop request') try: self.sys.stop() except Exception as err: @@ -489,6 +496,7 @@ def __init__(self, master, pypogs_system, logger): self.canvas_image = None self.logger.debug('Creating radiobuttons for camera selection') + # Top row under live view self.logger.debug('Filling bottom frame with interactive controls') ttk.Label(self.bottom_frame1, text='Camera (Tracker):').grid(row=0, column=0) self.camera_variable = tk.IntVar() @@ -502,7 +510,7 @@ def __init__(self, master, pypogs_system, logger): ttk.Radiobutton(self.bottom_frame1, text='Fine (FCL)', variable=self.camera_variable, value=FINE_FCL) \ .grid(row=0, column=5, padx=(5,0)) self.logger.debug('Creating entry and checkbox for image max value control') - ttk.Label(self.bottom_frame1, text='Max Value:').grid(row=0, column=6, padx=(30,0)) + ttk.Label(self.bottom_frame1, text='Max Value:').grid(row=0, column=6, padx=(20,0)) self.max_entry = ttk.Entry(self.bottom_frame1, width=10) self.max_entry.grid(row=0, column=7, padx=(5,0)) self.auto_max_variable = tk.BooleanVar() @@ -510,16 +518,14 @@ def __init__(self, master, pypogs_system, logger): ttk.Checkbutton(self.bottom_frame1, variable=self.auto_max_variable, text='Auto')\ .grid(row=0, column=8, padx=(5,0)) - self.annotate_variable = tk.BooleanVar() - self.annotate_variable.set(True) - self.goal_handles = [None]*4 - self.offset_handles = [None]*4 - self.track_circle_handle = None - self.search_circle_handle = None - self.track_cross_handles = [None]*2 - ttk.Checkbutton(self.bottom_frame1, text='Annotate', variable=self.annotate_variable) \ - .grid(row=0, column=9, padx=(30,0)) + self.logger.debug('Creating entry for quick exposure time control') + ttk.Label(self.bottom_frame1, text='Exposure time:').grid(row=0, column=9, padx=(20,0)) + self.exposure_entry = ttk.Spinbox(self.bottom_frame1, width=10, from_=0, to=10000, increment=.01, + command=lambda: self.exposure_entry_callback(None)) + self.exposure_entry.grid(row=0, column=10, padx=(5,0)) + self.exposure_entry.bind(('',), self.exposure_entry_callback) + # Bottom row under live view self.set_search_variable = tk.BooleanVar() self.set_search_variable.set(False) ttk.Checkbutton(self.bottom_frame2, text='Manual Acquire', variable=self.set_search_variable) \ @@ -539,6 +545,16 @@ def __init__(self, master, pypogs_system, logger): ttk.Checkbutton(self.bottom_frame2, text='Intercam Alignment', variable=self.set_goal_variable) \ .grid(row=0, column=5, padx=(50,0)) + self.annotate_variable = tk.BooleanVar() + self.annotate_variable.set(True) + self.goal_handles = [None]*4 + self.offset_handles = [None]*4 + self.track_circle_handle = None + self.search_circle_handle = None + self.track_cross_handles = [None]*2 + ttk.Checkbutton(self.bottom_frame2, text='Annotate', variable=self.annotate_variable) \ + .grid(row=0, column=6, padx=(50,0)) + self.logger.debug('Finished creating. Calling update on self') self.update() @@ -574,7 +590,7 @@ def clear_offset_callback(self): """Clear the offset *of the preceding tracker*.""" self.logger.debug('Clicked on clear offset') cam = self.camera_variable.get() - if cam == COARSE_CCL: + if cam == COARSE_CCL or cam == STAR_OL: self.logger.info('Clearing OL offset.') try: self.sys.control_loop_thread.OL_goal_offset_x_y = (0,0) @@ -589,6 +605,59 @@ def clear_offset_callback(self): self.logger.debug('Could not set CCL offset', exc_info=True) ErrorPopup(self, err, self.logger) + def exposure_entry_callback(self, event): + """User requested a new exposure time""" + self.logger.info('exposure_entry_callback, event: ' + str(event)) + old_exposure = None + updated_value = None + new_value = None + if event is None: # Clicked increment or decrement button + entry_value = float(self.exposure_entry.get()) + # Figure out the camera + cam = self.camera_variable.get() + self.logger.debug('Incrementing exposure for camera: ' + str(cam)) + if cam == STAR_OL: + old_exposure = self.sys.star_camera.exposure_time + new_value = round(old_exposure*(1/1.26 if entry_value < old_exposure else 1.26), 2) + self.sys.star_camera.exposure_time = new_value + updated_value = self.sys.star_camera.exposure_time + elif cam == COARSE_CCL: + old_exposure = self.sys.coarse_camera.exposure_time + new_value = round(old_exposure*(1/1.26 if entry_value < old_exposure else 1.26), 2) + self.sys.coarse_camera.exposure_time = new_value + updated_value = self.sys.coarse_camera.exposure_time + elif cam == FINE_FCL: + old_exposure = self.sys.fine_camera.exposure_time + new_value = round(old_exposure*(1/1.26 if entry_value < old_exposure else 1.26), 2) + self.sys.fine_camera.exposure_time = new_value + updated_value = self.sys.fine_camera.exposure_time + + elif event: # Hit enter + cam = self.camera_variable.get() + new_value = self.exposure_entry.get() + self.logger.debug('Hit enter on exposure entry, set to camera ' + str(cam) + ' value ' + str(new_value)) + if cam == STAR_OL: + self.sys.star_camera.exposure_time = new_value + updated_value = self.sys.star_camera.exposure_time + elif cam == COARSE_CCL: + self.sys.coarse_camera.exposure_time = new_value + updated_value = self.sys.coarse_camera.exposure_time + elif cam == FINE_FCL: + self.sys.fine_camera.exposure_time = new_value + updated_value = self.sys.fine_camera.exposure_time + # Update box afterwords + if updated_value: + self.logger.debug('Setting exposure time box to ' + str(updated_value)) + self.exposure_entry.delete(0, 'end') + self.exposure_entry.insert(0, updated_value) + self.logger.debug('exposure_entry_callback succeeded (%s)' % str([event, old_exposure, new_value, updated_value])) + elif old_exposure: + self.logger.debug('exposure_entry_callback failed (%s)' % str([event, old_exposure, new_value, updated_value])) + self.exposure_entry.delete(0, 'end') + self.exposure_entry.insert(0, old_exposure) + else: + self.logger.warning('exposure invalid') + def click_canvas_callback(self, event): self.logger.debug('Canvas click callback') if event.x > self.image_size[0] or event.y > self.image_size[1]: @@ -636,7 +705,23 @@ def click_canvas_callback(self, event): self.set_goal_variable.set(False) elif self.add_offset_variable.get() and not None in (x_image, y_image): cam = self.camera_variable.get() - if cam == COARSE_CCL: + if cam == STAR_OL: + self.logger.debug('Setting offset for OL tracker from Coarse camera') + try: + plate_scale = self.sys.star_camera.plate_scale + rotation = self.sys.star_camera.rotation + click = [x_image / self.image_scale * plate_scale, y_image / self.image_scale * plate_scale] + offset = np.array(click) - np.array(self.sys.coarse_track_thread.goal_x_y) + rotmx = np.array([[np.cos(np.deg2rad(rotation)), np.sin(np.deg2rad(rotation))], + [-np.sin(np.deg2rad(rotation)), np.cos(np.deg2rad(rotation))]]) + offset = rotmx @ offset + self.logger.info('Subtracting from OL offset: ' + str(offset)) + old_offset = self.sys.control_loop_thread.OL_goal_offset_x_y + self.sys.control_loop_thread.OL_goal_offset_x_y = np.array(old_offset) - offset + except Exception as err: + self.logger.debug('Could not set OL offset', exc_info=True) + ErrorPopup(self, err, self.logger) + elif cam == COARSE_CCL: self.logger.debug('Setting offset for OL tracker from Coarse camera') try: plate_scale = self.sys.coarse_camera.plate_scale @@ -714,6 +799,7 @@ def update(self): track_pos = (None, None) search_pos = (None, None) search_rad = None + exposure = None if cam == STAR_OL: self.logger.debug('Trying to get star OL data') try: @@ -722,6 +808,7 @@ def update(self): plate_scale = self.sys.star_camera.plate_scale rotation = self.sys.star_camera.rotation goal_pos = self.sys.control_loop_thread.OL_goal_x_y + exposure = self.sys.star_camera.exposure_time try: offset_pos = np.array(goal_pos) + np.array(self.sys.control_loop_thread.OL_goal_offset_x_y) except: @@ -745,6 +832,7 @@ def update(self): track_pos = self.sys.coarse_track_thread.track_x_y_absolute search_pos = self.sys.coarse_track_thread.pos_search_x_y search_rad = self.sys.coarse_track_thread.pos_search_rad + exposure = self.sys.coarse_camera.exposure_time except: self.logger.debug('Failed', exc_info=True) elif cam == FINE_FCL: @@ -764,12 +852,13 @@ def update(self): track_pos = self.sys.fine_track_thread.track_x_y_absolute search_pos = self.sys.fine_track_thread.pos_search_x_y search_rad = self.sys.fine_track_thread.pos_search_rad + exposure = self.sys.fine_camera.exposure_time except: self.logger.debug('Failed', exc_info=True) if img is not None: zoom = self.zoom_variable.get() - (height, width) = img.shape + (height, width) = img.shape[0:2] offs_x = round(width / 2 * (1 - 1/zoom)) offs_y = round(height / 2 * (1 - 1/zoom)) width = width//zoom @@ -779,7 +868,7 @@ def update(self): except: self.logger.warning('Failed to zoom image') - self.logger.debug('Setting image to: ' + str(img)) + #self.logger.debug('Setting image to: ' + str(img)) if img is not None: if self.auto_max_variable.get(): #Auto set max scaling maxval = int(np.max(img)) @@ -955,6 +1044,12 @@ def update(self): self.canvas.tag_lower('search') self.canvas.tag_lower('track') + # Update exposure box, unless it is in focus + if exposure and not self.exposure_entry.instate(('focus',)): + self.logger.debug('Setting exposure time box to ' + str(exposure)) + self.exposure_entry.delete(0, 'end') + self.exposure_entry.insert(0, exposure) + if not self._update_stop: self.logger.debug('Calling update on self after {} ms'.format(self._update_after)) self.after(self._update_after, self.update) @@ -1088,8 +1183,8 @@ def mount_callback(self): self.logger.debug('HardwareFrame mount button clicked') try: if self.mount_popup is None: - self.mount_popup = self.HardwarePopup(self, self.sys.mount, self.sys.add_mount, self.sys.clear_mount, \ - title='Mount', default_name='UnnamedMount') + self.mount_popup = self.HardwarePopup(self, 'mount', self.sys.mount, self.sys.add_mount, self.sys.clear_mount, \ + title='Mount', default_name='Mount') else: self.mount_popup.update() self.mount_popup.deiconify() @@ -1101,7 +1196,7 @@ def star_callback(self): self.logger.debug('HardwareFrame star button clicked') try: if self.star_popup is None: - self.star_popup = self.HardwarePopup(self, self.sys.star_camera, self.sys.add_star_camera, \ + self.star_popup = self.HardwarePopup(self, 'camera', self.sys.star_camera, self.sys.add_star_camera, \ self.sys.clear_star_camera, title='Star camera', \ default_name='StarCamera', link_device=self.sys.coarse_camera, \ link_func=self.sys.add_coarse_camera_from_star) @@ -1116,7 +1211,7 @@ def coarse_callback(self): self.logger.debug('HardwareFrame coarse button clicked') try: if self.coarse_popup is None: - self.coarse_popup = self.HardwarePopup(self, self.sys.coarse_camera, self.sys.add_coarse_camera, \ + self.coarse_popup = self.HardwarePopup(self, 'camera', self.sys.coarse_camera, self.sys.add_coarse_camera, \ self.sys.clear_coarse_camera, title='Coarse camera', \ default_name='CoarseCamera', link_device=self.sys.star_camera, \ link_func=self.sys.add_star_camera_from_coarse) @@ -1131,7 +1226,7 @@ def fine_callback(self): self.logger.debug('HardwareFrame fine button clicked') try: if self.fine_popup is None: - self.fine_popup = self.HardwarePopup(self, self.sys.fine_camera, self.sys.add_fine_camera, \ + self.fine_popup = self.HardwarePopup(self, 'camera', self.sys.fine_camera, self.sys.add_fine_camera, \ self.sys.clear_fine_camera, title='Fine camera', \ default_name='FineCamera') else: @@ -1145,7 +1240,7 @@ def receiver_callback(self): self.logger.debug('HardwareFrame receiver button clicked') try: if self.receiver_popup is None: - self.receiver_popup = self.HardwarePopup(self, self.sys.receiver, self.sys.add_receiver, \ + self.receiver_popup = self.HardwarePopup(self, 'receiver', self.sys.receiver, self.sys.add_receiver, \ self.sys.clear_receiver, title='Receiver', \ default_name='UnnamedReceiver') else: @@ -1178,7 +1273,7 @@ class HardwarePopup(tk.Toplevel): For star/coarse camera, pass the other one in link_device to get the option to join them. """ - def __init__(self, master, device, add_func, clear_func, link_device=0, link_func=0, properties_frame=None, \ + def __init__(self, master, device_type, device, add_func, clear_func, link_device=0, link_func=0, properties_frame=None, \ title='Hardware', default_name=''): super().__init__(master, padx=10, pady=10, bg=ttk.Style().lookup('TFrame', 'background')) self.logger = master.logger @@ -1190,7 +1285,7 @@ def __init__(self, master, device, add_func, clear_func, link_device=0, link_fun self.link_device = link_device self.link_func = link_func self.default_name = default_name - + setup_frame = ttk.Frame(self) setup_frame.grid(row=0, column=0, sticky=tk.S) self.linked_bool = tk.BooleanVar() @@ -1201,11 +1296,13 @@ def __init__(self, master, device, add_func, clear_func, link_device=0, link_fun r+=1 ttk.Label(setup_frame, text='Model:').grid(row=r, column=0); r+=1 - self.model_entry = ttk.Entry(setup_frame, width=20) - self.model_entry.grid(row=r, column=0); r+=1 + self.model_combo = ttk.Combobox(setup_frame, values=master.sys._supported_models[device_type]) + self.model_combo.grid(row=r, column=0); r+=1 + self.model_combo.set(device.model if device and device.model else master.sys._default_model[device_type] or '') ttk.Label(setup_frame, text='Identity:').grid(row=r, column=0); r+=1 self.identity_entry = ttk.Entry(setup_frame, width=20) self.identity_entry.grid(row=r, column=0); r+=1 + self.identity_entry.insert(0, device.identity if device and device.identity else '') ttk.Label(setup_frame, text='Name:').grid(row=r, column=0); r+=1 self.name_entry = ttk.Entry(setup_frame, width=20) self.name_entry.grid(row=r, column=0); r+=1 @@ -1223,12 +1320,12 @@ def __init__(self, master, device, add_func, clear_func, link_device=0, link_fun def update(self): self.logger.debug('HardwarePopup got update request') - self.model_entry.delete(0, 'end') - self.identity_entry.delete(0, 'end') - self.name_entry.delete(0, 'end') + #self.model_entry.delete(0, 'end') + #self.identity_entry.delete(0, 'end') + #self.name_entry.delete(0, 'end') if self.device is None: - model = '' - identity = '' + #model = '' + #identity = '' name = self.default_name else: model = self.device.model @@ -1236,11 +1333,13 @@ def update(self): identity = self.device.identity if identity is None: identity = '' name = self.device.name - self.model_entry.insert(0, model) - self.identity_entry.insert(0, identity) + #self.update_properties() + self.identity_entry.delete(0, tk.END) + self.identity_entry.insert(0, identity) + self.name_entry.delete(0, tk.END) self.name_entry.insert(0, name) self.linked_bool.set(self.device is not None and self.device is self.link_device) - self.master.update() + self.master.update() self.update_properties() def clear_callback(self): @@ -1252,7 +1351,7 @@ def clear_callback(self): def connect_callback(self): self.logger.debug('HardwarePopup connect button clicked') # Read the entries - model = self.model_entry.get() + model = self.model_combo.get() if not model.strip(): model = None identity = self.identity_entry.get() if not identity.strip(): identity = None @@ -1344,18 +1443,24 @@ def __init__(self, master, pypogs_system, logger): self.sys = pypogs_system self._update_after = 1000 self._update_stop = True - # Create widgets and layout - ttk.Label(self, text='Target').grid(row=0, column=0, columnspan=2) - tk.Frame(self, height=1, bg='gray50').grid(row=1, column=0, columnspan=2, sticky=tk.W+tk.E) + # Create widgets and layout + #ttk.Label(self, text='Controller Properties').pack(fill=tk.BOTH, expand=True) + #tk.Frame(self, height=1, bg='gray50').pack(fill=tk.BOTH, expand=True) + #ttk.Button(self, text='Feedback', command=self.controller_callback, width=12) \ + # .pack(fill=tk.BOTH, expand=True) + + + ttk.Label(self, text='Target').pack(fill=tk.BOTH, expand=True) + tk.Frame(self, height=1, bg='gray50').pack(fill=tk.BOTH, expand=True) self.status_label = ttk.Label(self, font='TkFixedFont') - self.update() - self.status_label.grid(row=2, column=0, columnspan=2) - ttk.Button(self, text='Set Manual', command=self.manual_button_callback, width=15).grid(row=3, column=0) - ttk.Button(self, text='Set from File', command=self.file_button_callback, width=15).grid(row=3, column=1) + self.status_label.pack(fill=tk.BOTH, expand=True) + ttk.Button(self, text='Set Target', command=self.manual_button_callback, width=15).pack(fill=tk.BOTH, expand=True) + ttk.Button(self, text='Go To Target', command=self.go_to_target_callback).pack(fill=tk.BOTH, expand=True) self.manual_popup = None + self.update() def update(self): - self.logger.debug('TargetFrame got update request') + #self.logger.debug('TargetFrame got update request') """Update the target status""" target_string = self.sys.target.get_short_string() t_start = self.sys.target.start_time @@ -1387,7 +1492,7 @@ def manual_button_callback(self): self.logger.debug('TargetFrame manual button clicked') try: if self.manual_popup is None: - self.manual_popup = self.ManualPopup(self) + self.manual_popup = self.TargetPopup(self) else: self.manual_popup.update() self.manual_popup.deiconify() @@ -1395,43 +1500,96 @@ def manual_button_callback(self): self.logger.debug('Could not open manual popup', exc_info=True) ErrorPopup(self, err, self.logger) - def file_button_callback(self): + + def go_to_target_callback(self): + self.logger.debug('MountControlFrame Go to target clicked') + assert self.sys.mount is not None and self.sys.mount.is_init, 'No mount or not initialised' try: - raise NotImplementedError('Feature coming soon!') + itrf_xyz = self.sys.get_itrf_direction_of_target() + enu_altaz = self.sys.alignment.get_enu_altaz_from_itrf_xyz(itrf_xyz) + altaz_string = 'Alt:' + str(round(enu_altaz[0],3)) + DEG + ' Az:' + str(round(enu_altaz[1],3)) + DEG + self.logger.debug('Target coordinates: '+altaz_string) + self.logger.debug('Send in EastNorthUp') + self.sys.mount.move_to_alt_az(*enu_altaz, block=False) except Exception as err: - ErrorPopup(self, err, self.logger) + ErrorPopup(self.master, err, self.logger) - class ManualPopup(tk.Toplevel): + class TargetPopup(tk.Toplevel): """Extends tk.Toplevel for setting target manually.""" def __init__(self, master): super().__init__(master, padx=10, pady=10, bg=ttk.Style().lookup('TFrame', 'background')) self.logger = master.logger - self.title('Target') + self.title('Target Selection Methods') self.resizable(False, False) + # self.grab_set() #Grab control + + # list common targets + target_selection_frame = ttk.Frame(self) + target_selection_frame.grid(row=0, column=0, columnspan=4, padx=(0,10), pady=10, sticky=tk.E+tk.W) + target_selection_frame.columnconfigure(index=(0, 1, 2, 3), weight=1, uniform="equal") + ttk.Label(target_selection_frame, text='Select satellite:').grid(row=0, column=0, sticky=tk.W) + self.target_selection_combo = ttk.Combobox(target_selection_frame, values=list(self.master.sys.saved_targets.keys())) + self.target_selection_combo.grid(row=0, column=2, sticky="EW") + self.target_selection_combo.set('ISS') + ttk.Button(target_selection_frame, text='Get', command=self.get_tle_for_selected_satellite).grid(row=0, column=3, sticky="EW") + + # Fetch TLE Input: + get_tle_frame = ttk.Frame(self) + get_tle_frame.grid(row=1, column=0, columnspan=4, padx=(0,10), pady=10, sticky=tk.E+tk.W) + get_tle_frame.columnconfigure(index=(0, 1, 2, 3), weight=1, uniform="equal") + ttk.Label(get_tle_frame, text='Set from NORAD ID:').grid(row=0, column=0, sticky=tk.W) + self.sat_norad_id_entry = ttk.Entry(get_tle_frame, width=15, font='TkFixedFont') + self.sat_norad_id_entry.grid(row=0, column=2, sticky=tk.E+tk.W) + ttk.Button(get_tle_frame, text='Set', command=self.get_and_set_tle_callback).grid(row=0, column=3, sticky=tk.E+tk.W) + + # Ephemeris Input: + get_ephem_frame = ttk.Frame(self) + get_ephem_frame.grid(row=2, column=0, columnspan=4, padx=(0,10), pady=10, sticky=tk.E+tk.W) + get_ephem_frame.columnconfigure(index=(0, 1, 2, 3), weight=1, uniform="equal") + ttk.Label(get_ephem_frame, text='Set from NAIF ID:').grid(row=0, column=0, sticky=tk.W) + self.naif_obj_id_entry = ttk.Entry(get_ephem_frame, width=15, font='TkFixedFont') + self.naif_obj_id_entry.grid(row=0, column=2, sticky=tk.E+tk.W) + ttk.Button(get_ephem_frame, text='Set', command=self.get_ephem_callback).grid(row=0, column=3, sticky=tk.E+tk.W) + + # Name label: + name_label_frame = ttk.Frame(self) + name_label_frame.grid(row=3, column=0, columnspan=4, padx=(0,10), pady=10, sticky=tk.E+tk.W) + name_label_frame.columnconfigure(index=(0, 1, 2, 3), weight=1, uniform="equal") + ttk.Label(name_label_frame, text='Selected:').grid(row=0, column=0, sticky=tk.W) + self.sat_name_label = ttk.Label(name_label_frame, font='TkFixedFont', text='None') + self.sat_name_label.grid(row=0, column=1, columnspan=3, padx=10, sticky=tk.E+tk.W) + + # TLE Manual Input: tle_frame = ttk.Frame(self) - tle_frame.grid(row=0, column=0, columnspan=2, padx=(0,10), pady=10) - ttk.Label(tle_frame, text='Set target from TLE:').grid(row=0, column=0) + tle_frame.grid(row=4, column=0, columnspan=4, padx=(0,10), pady=10, sticky=tk.E+tk.W) + tle_frame.columnconfigure(index=(0, 1, 2, 3), weight=1, uniform="equal") + ttk.Label(tle_frame, text='Set from TLE:').grid(row=0, column=0, sticky=tk.W) + ttk.Button(tle_frame, text='Load from file', command=self.target_from_file_button_callback, width=15, state='DISABLE') \ + .grid(row=0, column=1, sticky=tk.E+tk.W) + ttk.Button(tle_frame, text='Clear', command=self.clear_tle_callback).grid(row=0, column=2, sticky=tk.E+tk.W) + ttk.Button(tle_frame, text='Set', command=self.set_tle_callback).grid(row=0, column=3, sticky=tk.E+tk.W) self.tle_line1_entry = ttk.Entry(tle_frame, width=69, font='TkFixedFont') - self.tle_line1_entry.grid(row=1, column=0) + self.tle_line1_entry.grid(row=1, column=0, columnspan=4, sticky=tk.W+tk.E) self.tle_line2_entry = ttk.Entry(tle_frame, width=69, font='TkFixedFont') - self.tle_line2_entry.grid(row=2, column=0) - ttk.Button(tle_frame, text='Set', command=self.tle_callback).grid(row=3, column=0, sticky=tk.W+tk.E) + self.tle_line2_entry.grid(row=2, column=0, columnspan=4, sticky=tk.W+tk.E) + # RA/Dec Input: radec_frame = ttk.Frame(self) - radec_frame.grid(row=1, column=0, padx=(10,0), pady=10) - ttk.Label(radec_frame, text='Set target from RA/Dec:').grid(row=0, column=0, columnspan=2) + radec_frame.grid(row=5, column=0, columnspan=3, padx=(10,0), pady=10) + ttk.Label(radec_frame, text='Set from RA/Dec:').grid(row=0, column=0, columnspan=2) ttk.Label(radec_frame, text='RA: (deg)').grid(row=1, column=0, sticky=tk.E) self.ra_entry = ttk.Entry(radec_frame, width=25, font='TkFixedFont') self.ra_entry.grid(row=1, column=1) ttk.Label(radec_frame, text='Dec: (deg)').grid(row=2, column=0, sticky=tk.E) self.dec_entry = ttk.Entry(radec_frame, width=25, font='TkFixedFont') self.dec_entry.grid(row=2, column=1) - ttk.Button(radec_frame, text='Set', command=self.radec_callback) \ + ttk.Button(radec_frame, text='Set', command=self.set_radec_callback) \ .grid(row=3, column=1, sticky=tk.W+tk.E) + # Tracking Time Input: time_frame = ttk.Frame(self) - time_frame.grid(row=1, column=1, padx=(10,0), pady=10) + time_frame.grid(row=5, column=3, columnspan=3, padx=(10,0), pady=10) ttk.Label(time_frame, text='Set tracking time (optional):').grid(row=0, column=0, columnspan=3) ttk.Label(time_frame, text='Start: (UTC)').grid(row=1, column=0, sticky=tk.E) self.start_entry = ttk.Entry(time_frame, width=25, font='TkFixedFont') @@ -1439,16 +1597,32 @@ def __init__(self, master): ttk.Label(time_frame, text='End: (UTC)').grid(row=2, column=0, sticky=tk.E) self.end_entry = ttk.Entry(time_frame, width=25, font='TkFixedFont') self.end_entry.grid(row=2, column=1, columnspan=2) - ttk.Button(time_frame, text='Set', width=10, command=self.set_time_callback) \ - .grid(row=3, column=1, sticky=tk.W+tk.E) ttk.Button(time_frame, text='Clear', width=10, command=self.clear_time_callback) \ + .grid(row=3, column=1, sticky=tk.W+tk.E) + ttk.Button(time_frame, text='Set', width=10, command=self.set_time_callback) \ .grid(row=3, column=2, sticky=tk.W+tk.E) + # Application Link + ''' + tcp_link_frame = ttk.Frame(self) + tcp_link_frame.grid(row=6, column=0, columnspan=4, padx=(0,10), pady=10, sticky=tk.E+tk.W) + tcp_link_frame.columnconfigure(index=(0, 1, 2, 3), weight=1, uniform="equal") + self.tcp_link_enabled = tk.IntVar() + ttk.Checkbutton(tcp_link_frame, text="Enable TCP host", variable=self.tcp_link_enabled, command = lambda: self.toggle_tcp_link(),) \ + .grid(row=0, column=0, sticky=tk.W) + ttk.Label(tcp_link_frame, text='Port:').grid(row=0, column=1, sticky=tk.E) + self.tcp_port_entry = ttk.Entry(tcp_link_frame, width=15, font='TkFixedFont') + self.tcp_port_entry.grid(row=0, column=2, sticky=tk.E+tk.W) + self.tcp_port_entry.insert(0, '12345') + ttk.Button(tcp_link_frame, text='Set', command=self.get_ephem_callback).grid(row=0, column=3, sticky=tk.E+tk.W) + ''' + + self.protocol('WM_DELETE_WINDOW', self.withdraw) self.update() def update(self): - self.logger.debug('ManualPopup got update request') + self.logger.debug('TargetPopup got update request') """Read the target status and fill in fields.""" target = self.master.sys.target.target_object # Clear everything @@ -1478,24 +1652,81 @@ def update(self): self.end_entry.insert(0, str(t_end) if t_end is not None else '') self.master.update() - def tle_callback(self): + def toggle_tcp_link(self): + print(self.tcp_link_enabled.get()) + + def get_tle_callback(self): + self.logger.debug("TLE requested for sat ID: " + self.sat_norad_id_entry.get()) + try: + self.sat_id = int(self.sat_norad_id_entry.get()) + except: + self.logger.debug("sat ID invalid") + self.sat_name_label['text'] = '' + self.sat_id = None + if self.sat_id: + tle = self.master.sys.target.get_tle_from_sat_id(self.sat_id) + if tle is not None and len(tle)==3: + self.tle_line1_entry.delete(0, tk.END) + self.tle_line1_entry.insert(0,tle[0]) + self.tle_line2_entry.delete(0, tk.END) + self.tle_line2_entry.insert(0,tle[1]) + sat_name = tle[2] + self.sat_name_label['text'] = sat_name or '' + self.logger.debug('successfully fetched TLE for sat ID '+str(self.sat_id)+', "'+sat_name+'"') + self.logger.debug(tle[0]) + self.logger.debug(tle[1]) + self.logger.debug(tle[2]) + else: + self.logger.info('Failed to retrieve TLE for sat ID ' +str(self.sat_id)) + self.logger.info(tle) + self.sat_name_label['text'] = '(failed)' + + def get_and_set_tle_callback(self): + self.get_tle_callback() + if self.sat_id is not None: + self.set_tle_callback() + + def clear_tle_callback(self): + self.tle_line1_entry.delete(0, tk.END) + self.tle_line2_entry.delete(0, tk.END) + + def get_tle_for_selected_satellite(self): + selected_sat_name = self.target_selection_combo.get() + self.logger.info('Selected target name: '+selected_sat_name) + if selected_sat_name in self.master.sys.saved_targets: + self.sat_id = self.master.sys.saved_targets[selected_sat_name] + self.sat_norad_id_entry.delete(0, tk.END) + self.sat_norad_id_entry.insert(0,str(self.sat_id)) + self.get_and_set_tle_callback() + + def set_tle_callback(self): try: line1 = self.tle_line1_entry.get() line2 = self.tle_line2_entry.get() self.master.sys.target.set_target_from_tle((line1, line2)) self.clear_time_callback() #self.update() called in clear_time_callback() + self.master.sys.target.set_source('TLE') except Exception as err: ErrorPopup(self, err, self.logger) - def radec_callback(self): + + def target_from_file_button_callback(self): + try: + raise NotImplementedError('Feature coming soon!') + except Exception as err: + ErrorPopup(self, err, self.logger) + + def set_radec_callback(self): try: ra = self.ra_entry.get() dec = self.dec_entry.get() self.master.sys.target.set_target_from_ra_dec(ra, dec) self.clear_time_callback() #self.update() called in clear_time_callback() + self.master.sys.target.set_source('RADEC') except Exception as err: ErrorPopup(self, err, self.logger) + def set_time_callback(self): try: start = self.start_entry.get() @@ -1510,6 +1741,7 @@ def set_time_callback(self): self.update() except Exception as err: ErrorPopup(self, err, self.logger) + def clear_time_callback(self): try: self.start_entry.delete(0, 'end') @@ -1521,6 +1753,21 @@ def clear_time_callback(self): except Exception as err: ErrorPopup(self, err, self.logger) + def get_ephem_callback(self): + try: + self.logger.debug("Ephemeris requested for NAIF object ID: " + self.naif_obj_id_entry.get()) + obj_id = self.naif_obj_id_entry.get() + (lat, lon, elevation_m) = self.master.sys.alignment.get_location_lat_lon_height() + assert lat is not None and lon is not None and elevation_m is not None, 'Location not initialized' + self.logger.info("Ephemeris requested for sat ID: " + obj_id) + self.master.sys.target.get_ephem(obj_id, lat, lon, elevation_m) + if self.master.sys.target._ephem.target_name is not None: + self.logger.info('Ephemeris target object: "%s"' % self.master.sys.target._ephem.target_name) + self.sat_name_label['text'] = self.master.sys.target._ephem.target_name or '' + self.master.sys.target.set_source('EPHEM') + except Exception as err: + ErrorPopup(self, err, self.logger) + self.sat_name_label['text'] = 'None' class AlignmentFrame(ttk.Frame): """Extends tkinter.Frame for controlling System.alignment""" @@ -1666,7 +1913,12 @@ def enu_callback(self): ErrorPopup(self, err, self.logger) def load_callback(self): try: - raise NotImplementedError('Feature coming soon!') + filename = filedialog.askopenfilename( + initialdir = self.master.sys.data_folder, + title = 'Select alignment file (*_Alignment_from_obs.csv)', + filetypes = (("CSV Files","csv",),("all files","*.*")) + ) + self.master.sys.alignment.get_alignment_data_form_file(filename) except Exception as err: ErrorPopup(self, err, self.logger) @@ -1691,21 +1943,35 @@ def update(self): self.logger.debug('StatusFrame got update request') """Update status once. Auto update with start() and stop() instead.""" keys = ('alt', 'azi', 'alt_rate', 'azi_rate') - state = self.sys.mount.state_cache if self.sys.mount is not None else None + mount_state = self.sys.mount.state_cache if self.sys.mount is not None else None status_string = '' for key in keys: try: - status_string += ('{:>13s}:'.format(key) + '{: 7.2f}'.format(state[key]) + '\n') + status_string += ('{:>13s}:'.format(key) + '{: 7.2f}'.format(mount_state[key]) + '\n') except: status_string += ('{:>13s}:'.format(key) + ' --- ' + '\n') - state = self.sys.control_loop_thread.state_cache - for key in state.keys(): + + control_state = self.sys.control_loop_thread.state_cache + + # Modify mode indicator + ''' + if self.sys.mount is not None and self.sys.mount.is_init: + if control_state['mode'] in (None, 'SDRL'): + if self.sys.mount.is_sidereal_tracking: + if control_state['mode'] is None: # recheck since could have changed while checking if sidereal + control_state['mode'] = 'SDRL' + else: + control_state['mode'] = None + ''' + + + for key in control_state.keys(): try: if key in ('mode', 'ct_has_track', 'ft_has_track'): - assert state[key] is not None - status_string += ('{:>13s}:'.format(key) + ' {: <5}'.format(str(state[key])) + '\n') + assert control_state[key] is not None + status_string += ('{:>13s}:'.format(key) + ' {: <5}'.format(str(control_state[key])) + '\n') else: - status_string += ('{:>13s}:'.format(key) + '{: 7.2f}'.format(state[key]) + '\n') + status_string += ('{:>13s}:'.format(key) + '{: 7.2f}'.format(control_state[key]) + '\n') except: status_string += ('{:>13s}:'.format(key) + ' --- ' + '\n') @@ -1731,6 +1997,9 @@ def __init__(self, master, pypogs_system, logger): self.logger.debug('Creating ManualControlFrame') super().__init__(master) self.sys = pypogs_system + self._update_stop = True + self._update_after = 1000 + # Create widgets and layout ttk.Label(self, text='Manual Control').grid(row=0, column=0, columnspan=2) tk.Frame(self, height=1, bg='gray50').grid(row=1, column=0, columnspan=2, sticky=tk.W+tk.E) @@ -1753,13 +2022,45 @@ def __init__(self, master, pypogs_system, logger): ttk.Radiobutton(rb_frame, text='MNT', variable=self.coord_variable, value=MOUNT).grid(sticky=tk.W) ttk.Radiobutton(rb_frame, text='ENU', variable=self.coord_variable, value=ENU).grid(sticky=tk.W) - ttk.Button(self, text='Stop', command=self.stop_button_callback, width=15).grid(row=6, column=0) - ttk.Button(self, text='Send', command=self.send_button_callback, width=15).grid(row=6, column=1) + ttk.Button(self, text='Send', command=self.send_button_callback, width=15).grid(row=6, column=0) + ttk.Button(self, text='Stop', command=self.stop_button_callback, width=15).grid(row=6, column=1) + ttk.Style().configure('sidereal.TButton') + self.sidereal_button = ttk.Button(self, text='Sidereal Tracking', style='sidereal.TButton', command=self.toggle_sidereal_tracking) + self.sidereal_button.grid(row=7, column=0, columnspan=2, sticky=tk.W+tk.E) + + self.update() + + def update(self): + self.logger.debug('MountControlFrame got update request') + + if self.sys.mount is not None and self.sys.mount.is_init: + if self.sys.mount._is_sidereal_tracking: + ttk.Style().configure('sidereal.TButton', background='green', foreground='green') + self.sidereal_button['text'] = 'Stop sidereal tracking' + #self.sys.mount.get_alt_az() + else: + ttk.Style().configure('sidereal.TButton',\ + background=ttk.Style().lookup('TButton', 'background'), \ + foreground=ttk.Style().lookup('TButton', 'foreground')) + self.sidereal_button['text'] = 'Start sidereal tracking' + if not self._update_stop: + self.after(self._update_after, self.update) + + def start(self, after=None): + """Give number of milliseconds to wait between updates.""" + if after is not None: self._update_after = after + self._update_stop = False + self.update() + + def stop(self): + """Stop updating.""" + self._update_stop = True + def stop_button_callback(self): self.logger.debug('MountControlFrame stop clicked') assert self.sys.mount is not None and self.sys.mount.is_init, 'No mount or not initialised' - self.sys.mount.stop() + self.sys.stop() def send_button_callback(self): self.logger.debug('MountControlFrame send clicked') @@ -1810,17 +2111,33 @@ def send_button_callback(self): except Exception as err: ErrorPopup(self.master, err, self.logger) - + def toggle_sidereal_tracking(self): + self.logger.debug('Sidereal tracking button clicked') + if self.sys.mount._is_sidereal_tracking: + self.logger.debug('Sidereal tracking is on. Will turn off.') + self.sys.mount.stop_sidereal_tracking() + else: + self.logger.debug('Sidereal tracking is off. Will turn on.') + # Require mount initialized + assert self.sys.mount is not None and self.sys.mount.is_init, 'No mount or not initialised' + # Stop satellite tracking + try: + self.logger.debug('Stopping control loops') + self.sys.stop() + except Exception as err: + self.logger.debug('Did not stop', exc_info=True) + ErrorPopup(self, err, self.logger) + # Start sidereal tracking + self.logger.debug('Starting sidereal tracking') + self.sys.mount.start_sidereal_tracking() + class ErrorPopup(tk.Toplevel): """Extends tkinter.Toplevel for error popups""" def __init__(self, master, error, logger): logger.debug('ErrorPopup: Got error: ' + str(error)) + print(traceback.format_exc()) super().__init__(master, padx=10, pady=10, bg=ttk.Style().lookup('TFrame', 'background')) self.title('Error') self.grab_set() #Grab control ttk.Label(self, text=str(error), width=50).pack() ttk.Button(self, text='Close', command=self.destroy).pack(fill=tk.BOTH) - - - - diff --git a/pypogs/hardware/__init__.py b/pypogs/hardware/__init__.py new file mode 100644 index 0000000..243eb83 --- /dev/null +++ b/pypogs/hardware/__init__.py @@ -0,0 +1,5 @@ +from .hardware_camera import Camera +from .hardware_mount import Mount +from .hardware_receiver import Receiver + +__all__ = ['Camera', 'Mount', 'Receiver'] \ No newline at end of file diff --git a/pypogs/hardware.py b/pypogs/hardware/hardware_camera.py similarity index 50% rename from pypogs/hardware.py rename to pypogs/hardware/hardware_camera.py index 0f47cc3..6ee0d52 100644 --- a/pypogs/hardware.py +++ b/pypogs/hardware/hardware_camera.py @@ -1,16 +1,14 @@ -"""Hardware interfaces -====================== +"""Camera hardware interface +============================ Current hardware support: - :class:`pypogs.Camera`: 'ptgrey' for FLIR (formerly Point Grey) machine vision cameras. Requires Spinnaker API and PySpin, see the installation instructions. Tested with Blackfly S USB3 model BFS-U3-31S4M. - - :class:`pypogs.Mount`: 'celestron' for Celestron, Orion and SkyWatcher telescopes (using NexStar serial protocol). No additional - packages required. Tested with Celestron model CPC800. - - - :class:`pypogs.Receiver`: 'ni_daq' for National Instruments DAQ data acquisition cards. Requires NI-DAQmx API and nidaqmx, see the - installation instructions. Tested with NI DAQ model USB-6211. - + - :class:`pypogs.Camera`: 'ascom' for ASCOM-enabled cameras. Requires ASCOM platform, ASCOM drivers, and native drivers. + Tested with FIXME. + + This is Free and Open-Source Software originally written by Gustav Pettersson at ESA. License: @@ -28,19 +26,24 @@ See the License for the specific language governing permissions and limitations under the License. """ - # Standard imports: from pathlib import Path import logging -from time import sleep, time as timestamp +from time import sleep, time as timestamp, perf_counter as precision_timestamp from datetime import datetime from threading import Thread, Event from struct import pack as pack_data + # External imports: import numpy as np import serial +# Hardware support imports: +import zwoasi + +_zwoasi_bayer = {0:'RGGB', 1:'BGGR', 2:'GRBG', 3:'GBRG'} + class Camera: """Control acquisition and receive images from a camera. @@ -83,14 +86,15 @@ class Camera: # Release the hardware cam.deinitialize() """ - _supported_models = ('ptgrey',) + _supported_models = ('ptgrey','zwoasi','ascom') + _default_model = 'zwoasi' - def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_folder=None): + def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_folder=None, properties=[]): """Create Camera instance. See class documentation.""" # Logger setup self._debug_folder = None if debug_folder is None: - self.debug_folder = Path(__file__).parent / 'debug' + self.debug_folder = Path(__file__).parent.parent / 'debug' else: self.debug_folder = debug_folder self._logger = logging.getLogger('pypogs.hardware.Camera') @@ -99,7 +103,7 @@ def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_f self._logger.setLevel(logging.DEBUG) # Console handler at INFO level ch = logging.StreamHandler() - ch.setLevel(logging.INFO) + ch.setLevel(logging.INFO) #CHANGE MEEEEEEE # File handler at DEBUG level fh = logging.FileHandler(self.debug_folder / 'pypogs.txt') fh.setLevel(logging.DEBUG) @@ -118,19 +122,33 @@ def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_f self._name = 'UnnamedCamera' self._plate_scale = 1.0 self._rotation = 0.0 + self._binning = 1 self._flipX = False self._flipY = False self._rot90 = 0 #Number of times to rotate by 90 deg, done after flips + self._color_bin = True # Downscale when debayering instead of interpolating for speed #Only used for ptgrey self._ptgrey_camera = None self._ptgrey_camlist = None self._ptgrey_system = None + #Only used for zwoasi + self._zwoasi_camera_index = None + self._zwoasi_camera = None + self._zwoasi_is_init = False + self._zwoasi_image_handler = None + self._zwoasi_property = None + #Only used for ascom + self._ascom_driver_handler = None + self._ascom_camera = None + self._exposure_sec = 0.1 #Callbacks on image event self._call_on_image = set() self._got_image_event = Event() self._image_data = None self._image_timestamp = None self._imgs_since_start = 0 + self._average_frame_time = None # Running average of time between frames in ms + self._image_precision_timestamp = None # Precision timestamp of last frame self._logger.debug('Calling self on constructor input') if model is not None: @@ -139,9 +157,21 @@ def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_f self.identity = identity if name is not None: self.name = name - if auto_init and not None in (model, identity): + if auto_init and not None in (model, ): self._logger.debug('Trying to auto-initialise') self.initialize() + else: + self._logger.debug('Skipping auto-initialise') + + available_properties = self.available_properties + for property_name in properties: + if property_name in available_properties: + self._logger.debug('Setting property "%s" to value "%s"' % (property_name, properties[property_name])) + try: + setattr(self, property_name, properties[property_name]) + except: + self._logger.warning('Failed to set camera property "%s" to value "%s"' % (property_name, properties[property_name])) + self._logger.debug('Registering destructor') # TODO: Should we register deinitialisor instead? (probably yes...) import atexit, weakref @@ -175,6 +205,23 @@ def _log_warning(self, msg, **kwargs): def _log_exception(self, msg, **kwargs): self._logger.exception(self.name + ': ' + msg, **kwargs) + @property + def debug_folder(self): + """pathlib.Path: Get or set the path for debug logging. Will create folder if not existing.""" + return self._debug_folder + @debug_folder.setter + def debug_folder(self, path): + # Do not do logging in here! This will be called before the logger is set up + path = Path(path) #Make sure pathlib.Path + if path.is_file(): + path = path.parent + if not path.is_dir(): + path.mkdir(parents=True) + self._debug_folder = path + + + #FIXME: do we need release method for zwo? + def _ptgrey_release(self): """PRIVATE: Release Point Grey hardware resources.""" self._log_debug('PointGrey hardware release called') @@ -195,20 +242,19 @@ def _ptgrey_release(self): del(self._ptgrey_system) self._ptgrey_system = None self._log_debug('Hardware released') - - @property - def debug_folder(self): - """pathlib.Path: Get or set the path for debug logging. Will create folder if not existing.""" - return self._debug_folder - @debug_folder.setter - def debug_folder(self, path): - # Do not do logging in here! This will be called before the logger is set up - path = Path(path) #Make sure pathlib.Path - if path.is_file(): - path = path.parent - if not path.is_dir(): - path.mkdir(parents=True) - self._debug_folder = path + + def _ascom_release(self): + """PRIVATE: Release ASCOM hardware resources.""" + self._log_debug('ASCOM camera release called') + if self._ascom_camera is not None: + if self._ascom_camera.Connected: + if self._ascom_camera.CanAbortExposure: + self._ascom_camera.AbortExposure() + self._ascom_camera.Connected = False + self._log_debug('ASCOM camera disconnected') + self._ascom_pythoncom.CoUninitialize() + self._ascom_camera = None + self._log_debug('ASCOM camera hardware released') @property def name(self): @@ -226,15 +272,18 @@ def model(self): Supported: - 'ptgrey' for FLIR/Point Grey cameras (using Spinnaker/PySpin SDKs). + - 'zwoasi' for ZWO ASI cameras. + - 'ascom' for ASCOM-enabled cameras. - This will determine which hardware API that is used. - Must set before initialising the device and may not be changed for an initialised device. + """ return self._model @model.setter def model(self, model): self._log_debug('Setting model to: '+str(model)) - assert not self.is_init, 'Can not change already initialised device model' + assert not self.is_init, 'Cannot change already initialised device model' model = str(model) assert model.lower() in self._supported_models,\ 'Model type not recognised, allowed: '+str(self._supported_models) @@ -245,15 +294,18 @@ def model(self, model): @property def identity(self): """str: Get or set the device and/or input. Model must be defined first. - - - For model *ptgrey* this is the serial number *as a string* - Must set before initialising the device and may not be changed for an initialised device. + - For model *ptgrey* this is the serial number *as a string* + - For model *zwoasi* this is the index (starting at zero) + - For model *ascom*, a driver name may be specified if known, (i.e. DSLR, ASICamera1, ASICamera2, Simulator, + QHYCCD, QHYCCD_GUIDER, QHYCCD_CAM2, AtikCameras, AtikCameras2, etc), otherwise the ASCOM driver + selector will open. """ return self._identity @identity.setter def identity(self, identity): self._log_debug('Setting identity to: '+str(identity)) - assert not self.is_init, 'Can not change already initialised device' + assert not self.is_init, 'Cannot change already initialised device' assert self.model is not None, 'Must define model first' identity = str(identity) if self.model.lower() == 'ptgrey': @@ -286,6 +338,79 @@ def identity(self, identity): self._ptgrey_camera = None self._identity = identity self._ptgrey_camlist.Clear() + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi, first load and initialise the package') + library_path = Path(__file__).parent.parent / '_system_data' / 'ASICamera2' + self._log_debug('Initialising with files at ' + str(library_path.resolve())) + try: + zwoasi.init(str(library_path.resolve())) + except zwoasi.ZWO_Error as e: + if not str(e) == 'Library already initialized': + raise # Throw error if any other problem than already initialised + + self._log_debug('Library intialised, checking if identity is available') + + # Get count and list of detected ZWO cameras: + zwo_num_cameras = zwoasi.get_num_cameras() + assert zwo_num_cameras > 0, 'No ZWO cameras detected.' + zwo_camera_names = zwoasi.list_cameras() + self._log_info('Detected '+str(zwo_num_cameras)+' ZWO cameras: '+str(zwo_camera_names)) + + # Derive ZWO camera index from identity: + self._zwoasi_camera_index = None + if identity is None: + # Disallow for now. Later, consider populating a selection dialog. + raise AssertionError('Identity is none') + elif identity.isdigit(): + self._log_info('specified camera identity as index ('+identity+')') + self._zwoasi_camera_index = int(identity) + elif identity.lower().startswith('zwo') or identity.lower().startswith('asi'): + self._log_info('specified camera identity by string ('+identity+')') + for detected_camera_idx, detected_camera in enumerate(zwo_camera_names): + if detected_camera.lower().replace('zwo ','') == identity.lower().replace('zwo ',''): + self._zwoasi_camera_index = detected_camera_idx + break + else: + raise AssertionError('Unrecognized identity') + + self._log_info('Selected ZWO camera: index '+str(self._zwoasi_camera_index)+', name "'+zwo_camera_names[self._zwoasi_camera_index]+'"') + assert self._zwoasi_camera_index is not None, 'Unrecognized ZWO camera identity: "'+identity+'"' + assert self._zwoasi_camera_index < zwo_num_cameras, ('Selected identity is greater than the available cameras,' + 'largest possible is one less than ' + str(num_cams)) + # TODO: test if in use. Turns out API allows you to initialise several objects + # connected to the same hardware without complaining... Must keep own list? + + #self._log_debug('Identity available, testing if in use') + #try... + #self._zwoasi_camera = zwoasi.Camera(identity) + + #except... + + #finally... close + + self._identity = identity + + elif self.model.lower() == 'ascom': + self._log_debug('Checking ASCOM camera identity and availability') + assert identity is not None, 'ASCOM camera identity not resolved' + if not identity.startswith('ASCOM'): + identity = 'ASCOM.'+identity+'.Camera' + #self._log_debug('Loading ASCOM camera driver: '+str(identity)) + #try: + # ascom_camera = self._ascom_driver_handler.Dispatch(identity) + #except: + # raise RuntimeError('Failed to load camera driver') + #if not ascom_camera or not hasattr(ascom_camera, 'Connected'): + # raise RuntimeError('Failed to load camera driver (2)') + #if self._ascom_camera.Connected: + # self._log_debug("Camera was already connected") + # raise RuntimeError('The camera is already in use') + #else: + # self._log_debug("Camera available. Setting identity.") + self._identity = identity + self._log_debug('Specified identity: "'+str(self.identity)+'" ['+str(len(self.identity))+']') + #ascom_camera = None + else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -298,6 +423,12 @@ def is_init(self): if self.model.lower() == 'ptgrey': init = self._ptgrey_camera is not None and self._ptgrey_camera.IsInitialized() return init + elif self.model.lower() == 'zwoasi': + return self._zwoasi_is_init + elif self.model.lower() == 'ascom': + init = hasattr(self,'_ascom_camera') and self._ascom_camera is not None and self._ascom_camera.Connected + return init + #FIXME: bypassed this because camera preserves .Connected state when GUI restarts without disconnecting else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -306,8 +437,9 @@ def initialize(self): """Initialise (make ready to start) the device. The model and identity must be defined.""" self._log_debug('Initialising') assert not self.is_init, 'Already initialised' - assert not None in (self.model, self.identity), 'Must define model and identity before initialising' + assert not None in (self.model, ), 'Must define model before initialising' if self.model.lower() == 'ptgrey': + assert self.identity is not None, 'Must define identity of Pt Grey camera before initialising' self._log_debug('Using PySpin, try to initialise') import PySpin if self._ptgrey_camera is not None: @@ -342,6 +474,8 @@ def OnImageEvent(self, img_ptr): """Read out the image and a timestamp, reshape to array, pass to parent""" self.parent._log_debug('Image event! Unpack and release pointer') self.parent._image_timestamp = datetime.utcnow() + last_timestamp = self.parent._image_precision_timestamp + self.parent._image_precision_timestamp = precision_timestamp() try: img = img_ptr.GetData().reshape((img_ptr.GetHeight(), img_ptr.GetWidth())) if self.parent._flipX: @@ -362,11 +496,17 @@ def OnImageEvent(self, img_ptr): + ' Type:' + str(self.parent._image_data.dtype)) for func in self.parent._call_on_image: try: - self.parent._log_debug('Calling back to: ' + str(func)) + #self.parent._log_debug('Calling back to: ' + str(func)) func(self.parent._image_data, self.parent._image_timestamp) except: self.parent._log_warning('Failed image callback', exc_info=True) self.parent._imgs_since_start += 1 + if last_timestamp is not None: + new_frame_time = self.parent._image_precision_timestamp - last_timestamp + if self.parent._average_frame_time is None: + self.parent._average_frame_time = new_frame_time + else: + self.parent._average_frame_time = .8*self.parent._average_frame_time + .2*new_frame_time self.parent._log_debug('Event handler finished.') self._ptgrey_event_handler = PtGreyEventHandler(self) @@ -374,6 +514,234 @@ def OnImageEvent(self, img_ptr): self._ptgrey_camera.RegisterEventHandler( self._ptgrey_event_handler ) self._log_debug('Registered ptgrey image event handler') self._log_info('Camera successfully initialised') + + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi, try to initialise') + assert self._zwoasi_camera_index is not None, 'ZWO camera index not determined from identity' + self._zwoasi_camera = zwoasi.Camera(self._zwoasi_camera_index) + + # Set to normal mode and 16 bit mode by default + self._zwoasi_camera.set_camera_mode(zwoasi.ASI_MODE_NORMAL) + self._zwoasi_camera.set_image_type(zwoasi.ASI_IMG_RAW16) + + self._zwoasi_property = self._zwoasi_camera.get_camera_property() + + # Set everything to default to be safe + set_to_default = {'Exposure':zwoasi.ASI_EXPOSURE, 'Gain':zwoasi.ASI_GAIN, + 'Flip':zwoasi.ASI_FLIP, 'BandWidth':zwoasi.ASI_BANDWIDTHOVERLOAD, + 'HardwareBin':zwoasi.ASI_HARDWARE_BIN, 'WB_B':zwoasi.ASI_WB_B, + 'WB_R':zwoasi.ASI_WB_R, 'Offset':zwoasi.ASI_OFFSET, + 'HighSpeedMode':zwoasi.ASI_HIGH_SPEED_MODE, 'MonoBin':zwoasi.ASI_MONO_BIN} + controls = self._zwoasi_camera.get_controls() + for k in set_to_default.keys(): + if k in controls: # Check that our model has this property + self._zwoasi_camera.set_control_value(set_to_default[k], controls[k]['DefaultValue']) + + # Handler class to deal with the image stream + class ZwoAsiImageHandler(): + """Barebones class to start/stop camera and read images""" + def __init__(self, parent): + self.parent = parent + self._thread = None + self._stop_running = False + def start(self): + self.parent._log_info('Starting zwoasi imaging thread') + self._thread = Thread(target = self._run) + self._stop_running = False + self._thread.start() + def stop(self): + self.parent._log_info('Stopping zwoasi imaging thread') + self._stop_running = True + self._thread.join() + self.parent._zwoasi_camera.stop_video_capture() + self.parent._log_info('zwoasi imaging thread has been stopped') + @property + def is_running(self): + return self._thread is not None and self._thread.is_alive() + def _run(self): + """Start camera and continiously read out data""" + cam = self.parent._zwoasi_camera + cam.start_video_capture() + timeout_ms = self.parent.exposure_time + 500 + while not self._stop_running: + try: + img = cam.capture_video_frame(timeout = timeout_ms) + self.parent._image_timestamp = datetime.utcnow() + last_timestamp = self.parent._image_precision_timestamp + self.parent._image_precision_timestamp = precision_timestamp() + except zwoasi.ZWO_IOError as e: + if str(zwoasi.ZWO_IOError) == 'Camera closed': + self.parent._log_debug('zwoasi Camera closed, probably deinitialising') + else: + raise + if self._stop_running: + break + self.parent._log_debug('New image captured! Unpack and set image event') + if self.parent._rot90: + img = np.rot90(img, self.parent._rot90) + if len(img.shape) == 3: + # Camera gives GRB, reverse to RGB + img = img[:, :, ::-1] + + # If color camera we may need to debayer + if self.parent._zwoasi_property['IsColorCam'] and len(img.shape) < 3: + pattern = _zwoasi_bayer[self.parent._zwoasi_property['BayerPattern']] + t0_debayer = precision_timestamp() + self.parent._image_data = _debayer_image(img, order=pattern, + downsize=self.parent.color_bin) + t_debayer = precision_timestamp() - t0_debayer + self.parent._log_debug('Debayered image in ' + str(t_debayer)) + else: + self.parent._image_data = img + + self.parent._got_image_event.set() + self.parent._log_debug('Time: ' + str(self.parent._image_timestamp) \ + + ' Size:' + str(self.parent._image_data.shape) \ + + ' Type:' + str(self.parent._image_data.dtype)) + for func in self.parent._call_on_image: + try: + #self.parent._log_debug('Calling back to: ' + str(func)) + func(self.parent._image_data, self.parent._image_timestamp) + except: + self.parent._log_warning('Failed image callback', exc_info=True) + + self.parent._imgs_since_start += 1 + if last_timestamp is not None: + new_frame_time = self.parent._image_precision_timestamp - last_timestamp + if self.parent._average_frame_time is None: + self.parent._average_frame_time = new_frame_time + else: + self.parent._average_frame_time = .8*self.parent._average_frame_time + .2*new_frame_time + + self.parent._log_debug('Event handler finished.') + + self._zwoasi_image_handler = ZwoAsiImageHandler(self) + self._zwoasi_is_init = True + + elif self.model.lower() == "ascom": + if self._ascom_camera is not None: + raise RuntimeError('There is already an ASCOM camera object here') + self._log_debug('Attempting to connect to ASCOM device "'+str(self.identity)+'"') + if self._ascom_driver_handler is None: + import pythoncom + self._ascom_pythoncom = pythoncom + import win32com.client + self._ascom_driver_handler = win32com.client + camDriverName = str() + if self.identity is not None: + self._log_debug('Specified identity: "'+str(self.identity)+'" ['+str(len(self.identity))+']') + if self.identity.startswith('ASCOM'): + camDriverName = self.identity + else: + camDriverName = 'ASCOM.'+str(self.identity)+'.Camera' + else: + ascomSelector = self._ascom_driver_handler.Dispatch("ASCOM.Utilities.Chooser") + ascomSelector.DeviceType = 'Camera' + camDriverName = ascomSelector.Choose('None') + self._logger.info("Selected camera driver: "+camDriverName) + if not camDriverName: + self._log_debug('User canceled camera selection') + assert camDriverName, 'Unable to identify ASCOM camera.' + self._identity = camDriverName.replace('ASCOM.','').replace('.Camera','') + self._logger.info('Loading ASCOM camera driver: '+camDriverName) + self._ascom_pythoncom.CoInitialize() + try: + self._ascom_camera = self._ascom_driver_handler.Dispatch(camDriverName) + except self._ascom_pythoncom.com_error: + raise AssertionError('Error attaching to device "%s", check name.' % camDriverName) + assert hasattr(self._ascom_camera, 'Connected'), "Unable to access camera driver" + self._log_debug('Connecting to camera') + self._ascom_camera.Connected = True + assert self._ascom_camera.Connected, "Failed to connect to camera" + assert self._ascom_camera is not None, 'ASCOM camera not initialized' + self._log_debug('ReadoutMode: '+str(self._ascom_camera.ReadoutModes[self._ascom_camera.ReadoutMode])) + self._log_debug('SensorType: '+str(self._ascom_camera.SensorType)) + self._log_debug('CameraState: '+str(self._ascom_camera.CameraState)) + + class AscomCameraImagingLoopHandler(): + def __init__(self, parent): + super().__init__() + self.parent = parent + self._is_running = False + + def start_imaging_loop(self): + assert self.parent._ascom_camera, 'Cannot start imaging - camera not initialized' + self.parent._log_debug('Starting ASCOM camera imaging loop') + self.continue_imaging = True + self.imagethread = Thread(target=self.imaging_loop) + self.imagethread.start() + + def stop_imaging_loop(self): + self.parent._log_debug('Stopping ASCOM camera imaging loop') + self.continue_imaging = False + if self._is_running: + self.parent._log_debug('Waiting for ASCOM camera imaging loop to stop') + polling_period = 0.05 #sec + while self._is_running: + sleep(polling_period) + self.parent._log_debug('Stopped ASCOM camera imaging loop') + + def imaging_loop(self): + assert self.parent._ascom_camera, 'Cannot start imaging - ASCOM camera driver not loaded' + assert self.parent._ascom_camera.Connected, 'Cannot start imaging - ASCOM camera not connected' + self._is_running = True + self.parent._log_debug('Starting ASCOM camera imaging loop') + timeout = 0.5 # sec + polling_period = 0.001 # sec + while self.continue_imaging and self.parent._ascom_camera.Connected: + #self.parent._log_debug('Starting ASCOM camera exposure') + # Start exposure: + self.parent._ascom_camera.StartExposure(self.parent._exposure_sec,True) + + # Wait for image to be ready: + sleep(self.parent._exposure_sec * 0.95) + waited_time = 0 + while not self.parent._ascom_camera.ImageReady and waited_time < timeout: + sleep(polling_period) + waited_time += polling_period + if not self.parent._ascom_camera.ImageReady: + self.parent._log_debug('Timed out waiting for image') + self.parent._log_debug('Camera connected: '+str(self.parent._ascom_camera.Connected)) + continue + # Get and pre-process image: + got_image = False + try: + img = np.array(self.parent._ascom_camera.ImageArray, dtype=np.float).copy().T + if self.parent._flipX: + img = np.fliplr(img) + if self.parent._flipY: + img = np.flipud(img) + if self.parent._rot90: + img = np.rot90(img, self.parent._rot90) + self.parent._image_timestamp = datetime.utcnow() + self.parent._image_data = img + got_image = True + except: + self.parent._log_debug('Failed to access image.') + self.parent._image_data = None + + # Signal image ready and run callbacks: + if got_image: + self.parent._got_image_event.set() + self.parent._log_debug('Time: ' + str(self.parent._image_timestamp) \ + + ' Size:' + str(self.parent._image_data.shape) \ + + ' Type:' + str(self.parent._image_data.dtype)) + for func in self.parent._call_on_image: + try: + #self.parent._log_debug('Calling back to: ' + str(func)) + func(self.parent._image_data, self.parent._image_timestamp) + except: + self.parent._log_warning('Failed image callback', exc_info=True) + self.parent._imgs_since_start += 1 + + #self.parent._log_debug('ASCOM image loop finished.') + if self.continue_imaging: + sleep(0.001) + self._is_running = False + + self._ascom_camera_imaging_handler = AscomCameraImagingLoopHandler(self) + self._ascom_camera_imaging_handler.start_imaging_loop() + else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -381,7 +749,7 @@ def OnImageEvent(self, img_ptr): def deinitialize(self): """De-initialise the device and release hardware resources. Will stop the acquisition if it is running.""" self._log_debug('De-initialising') - assert self.is_init, 'Not initialised' + #assert self.is_init, 'Not initialised' if self.is_running: self._log_debug('Is running, stopping') self.stop() @@ -402,6 +770,18 @@ def deinitialize(self): self._log_exception('Failed to close task') self._log_debug('Trying to release PtGrey hardware resources') self._ptgrey_release() + elif self._zwoasi_camera: + self._log_debug('Found zwoasi camera, deinitialising') + self._zwoasi_camera.close() + self._zwoasi_is_init = False + self._zwoasi_camera = None + self._zwoasi_property = None + self._log_debug('Closed, set deinit flag, deleted object') + elif self._ascom_camera: + self._log_debug('Deinitialising ASCOM camera') + self.stop() + self._log_debug('Deinitialised ASCOM camera') + self._ascom_release() else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -411,8 +791,14 @@ def available_properties(self): """tuple of str: Get all the available properties (settings) supported by this device.""" assert self.is_init, 'Camera must be initialised' if self.model.lower() == 'ptgrey': - return ('flip_x', 'flip_y', 'rotate_90', 'plate_scale', 'rotation', 'binning', 'size_readout', 'frame_rate_auto',\ - 'frame_rate', 'gain_auto', 'gain', 'exposure_time_auto', 'exposure_time') + return ('flip_x', 'flip_y', 'rotate_90', 'plate_scale', 'rotation', 'binning', 'size_readout', + 'frame_rate_auto', 'frame_rate', 'gain_auto', 'gain', 'exposure_time_auto', 'exposure_time') + elif self.model.lower() == 'zwoasi': + return ('flip_x', 'flip_y', 'rotate_90', 'plate_scale', 'rotation', 'binning', 'size_readout', + 'frame_rate_auto', 'gain', 'gain_auto', 'exposure_time_auto', 'exposure_time', 'color_bin') + elif self.model.lower() == 'ascom': + return ('flip_x', 'flip_y', 'rotate_90', 'plate_scale', 'rotation', 'binning', 'size_readout',\ + 'gain', 'exposure_time') else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -425,6 +811,12 @@ def flip_x(self): if self.model.lower() == 'ptgrey': self._log_debug('Using PtGrey camera. Will flip the received image array ourselves: ' +str(self._flipX)) return self._flipX + elif self.model.lower() == 'zwoasi': + flipmode = self._zwoasi_camera.get_control_value(zwoasi.ASI_FLIP)[0] + return (flipmode == 1) or (flipmode == 3) # mode 1 is flip horizontal, mode 3 is flip both + elif self.model.lower() == 'ascom': + self._log_debug('Using ASCOM camera. Will flip the received image array ourselves: ' +str(self._flipX)) + return self._flipX else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -437,6 +829,25 @@ def flip_x(self, flip): self._log_debug('Using PtGrey camera. Will flip the received image array ourselves.') self._flipX = flip self._log_debug('_flipX set to: '+str(self._flipX)) + elif self.model.lower() == 'zwoasi': + if not flip: # Disable horizontal flipping + if not self.flip_y: + # No flipping + self._zwoasi_camera.set_control_value(zwoasi.ASI_FLIP, 0) + else: + # Set to only vertical flipping + self._zwoasi_camera.set_control_value(zwoasi.ASI_FLIP, 2) + else: # Enable horizontal flipping + if not self.flip_y: + # Flip only horizontal + self._zwoasi_camera.set_control_value(zwoasi.ASI_FLIP, 1) + else: + # Flip both + self._zwoasi_camera.set_control_value(zwoasi.ASI_FLIP, 3) + elif self.model.lower() == 'ascom': + self._log_debug('Using ASCOM camera. Will flip the received image array ourselves.') + self._flipX = flip + self._log_debug('_flipX set to: '+str(self._flipX)) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -449,6 +860,12 @@ def flip_y(self): if self.model.lower() == 'ptgrey': self._log_debug('Using PtGrey camera. Will flip the received image array ourselves: ' +str(self._flipX)) return self._flipY + elif self.model.lower() == 'zwoasi': + flipmode = self._zwoasi_camera.get_control_value(zwoasi.ASI_FLIP)[0] + return (flipmode == 2) or (flipmode == 3) # mode 2 is flip vertical, mode 3 is flip both + elif self.model.lower() == 'ascom': + self._log_debug('Using ASCOM camera. Will flip the received image array ourselves: ' +str(self._flipX)) + return self._flipY else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -461,6 +878,25 @@ def flip_y(self, flip): self._log_debug('Using PtGrey camera. Will flip the received image array ourselves.') self._flipY = flip self._log_debug('_flipY set to: '+str(self._flipY)) + elif self.model.lower() == 'zwoasi': + if not flip: # Disable vertical flipping + if not self.flip_x: + # No flipping + self._zwoasi_camera.set_control_value(zwoasi.ASI_FLIP, 0) + else: + # Set to only horizontal flipping + self._zwoasi_camera.set_control_value(zwoasi.ASI_FLIP, 1) + else: # Enable vertical flipping + if not self.flip_x: + # Flip only vertical + self._zwoasi_camera.set_control_value(zwoasi.ASI_FLIP, 2) + else: + # Flip both + self._zwoasi_camera.set_control_value(zwoasi.ASI_FLIP, 3) + elif self.model.lower() == 'ascom': + self._log_debug('Using ASCOM camera. Will flip the received image array ourselves.') + self._flipY = flip + self._log_debug('_flipY set to: '+str(self._flipY)) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -470,7 +906,7 @@ def rotate_90(self): """int: Get or set how many times the image should be rotated by 90 degrees. Applied *after* flip_x and flip_y. """ assert self.is_init, 'Camera must be initialised' - if self.model.lower() == 'ptgrey': + if self.model.lower() in ('ptgrey','zwoasi','ascom'): return self._rot90 else: self._log_warning('Forbidden model string defined.') @@ -480,8 +916,8 @@ def rotate_90(self, k): self._log_debug('Set rot90 called with: '+str(k)) assert self.is_init, 'Camera must be initialised' k = int(k) - if self.model.lower() == 'ptgrey': - self._log_debug('Using PtGrey camera. Will rotate the received image array ourselves.') + if self.model.lower() in ('ptgrey','zwoasi','ascom'): + self._log_debug('Will rotate the received image array ourselves.') self._rot90 = k self._log_debug('rot90 set to: '+str(self._rot90)) else: @@ -495,7 +931,7 @@ def plate_scale(self): This will not affect anything in this class but is used elsewhere. Set this to the physical pixel plate scale *before* any binning. When getting the plate scale it will be scaled by the binning factor. """ - return self._plate_scale * self.binning + return self._plate_scale * self.binning * (1 if not self.color_bin else 2) @plate_scale.setter def plate_scale(self, arcsec): self._log_debug('Set plate scale called with: '+str(arcsec)) @@ -534,6 +970,11 @@ def frame_rate_auto(self): val = node.GetValue() self._log_debug('Returning not '+str(val)) return not val + elif self.model.lower() == 'zwoasi': + return True # Only auto frame rate available in normal mode + elif self.model.lower() == 'ascom': + self._log_debug('frame_rate_auto not supported in ASCOM') + return False else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -555,6 +996,13 @@ def frame_rate_auto(self, auto): else: self._log_debug('Setting frame rate') node.SetValue(not auto) + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi') + if not auto: + self._log_warning('zwoasi does not support fixed frame rate, staying in auto mode.') + elif self.model.lower() == 'ascom': + self._log_debug('frame_rate_auto not supported in ASCOM') + return False else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -579,6 +1027,9 @@ def frame_rate_limit(self): val = (node1.GetValue(), node2.GetValue()) self._log_debug('Returning '+str(val)) return val + elif self.model.lower() == 'ascom': + self._log_debug('frame_rate_auto not supported in ASCOM') + return False else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -601,6 +1052,9 @@ def frame_rate(self): val = node.GetValue() self._log_debug('Returning '+str(val)) return val + elif self.model.lower() == 'ascom': + self._log_debug('frame rate not supported in ASCOM camera class') + return 0 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -631,11 +1085,18 @@ def frame_rate(self, frame_rate_hz): raise AssertionError('The commanded value is outside the allowed range. See frame_rate_limit') else: raise #Rethrows error + elif self.model.lower() == 'ascom': + self._log_debug('frame rate not supported in ASCOM camera class') else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @property + def frame_rate_actual(self): + """float: Get the actual image frame rate in Hz. Returns None if not running.""" + return 1/self._average_frame_time if self._average_frame_time is not None else None + + @property def gain_auto(self): """bool: Get or set automatic gain. If True the gain will be continuously updated.""" self._log_debug('Get gain auto called') @@ -662,6 +1123,11 @@ def gain_auto(self): else: self._log_debug('Unexpected return value') raise RuntimeError('Unknow response from camera') + elif self.model.lower() == 'zwoasi': + return self._zwoasi_camera.get_control_value(zwoasi.ASI_GAIN)[1] + elif self.model.lower() == 'ascom': + self._log_debug('auto gain not implemented in ASCOM') + return False else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -688,13 +1154,27 @@ def gain_auto(self, auto): else: self._log_debug('Setting gain') node.SetIntValue(node.GetEntryByName(set_to).GetValue()) + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi') + if not self.gain_auto == auto: + self._log_debug('Changing gain auto mode to: ' + str(auto)) + controls = self._zwoasi_camera.get_controls() + default = controls['Gain']['DefaultValue'] + self._log_debug('Setting gain to auto ' + str(auto) + ' and default: ' + str(default)) + self._zwoasi_camera.set_control_value(zwoasi.ASI_GAIN, default, auto) + self._log_debug('Set gain auto to: ' + str(self.gain_auto)) + else: + self._log_warning('Gain auto mode already set to: ' + str(auto) + ', doing nothing') + elif self.model.lower() == 'ascom': + self._log_debug('auto gain not implemented in ASCOM') + return False else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @property def gain_limit(self): - """tuple of float: Get the minimum and maximum gain in dB supported.""" + """tuple of float: Get the minimum and maximum gain supported in the camera's native unit.""" self._log_debug('Get gain limit called') assert self.is_init, 'Camera must be initialised' if self.model.lower() == 'ptgrey': @@ -712,13 +1192,22 @@ def gain_limit(self): val = (node1.GetValue(), node2.GetValue()) self._log_debug('Returning '+str(val)) return val + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi') + controls = self._zwoasi_camera.get_controls() + min = controls['Gain']['MinValue'] + max = controls['Gain']['MaxValue'] + self._log_debug('Camera gave min ' + str(min) + ' and max ' + str(max)) + return (min, max) + elif self.model.lower() == 'ascom': + return (self._ascom_camera.GainMin, self._ascom_camera.GainMax) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @property def gain(self): - """float: Get or set the camera gain in dB. Will set auto frame rate to False.""" + """float: Get or set the camera gain in the camera's native unit.""" self._log_debug('Get gain called') assert self.is_init, 'Camera must be initialised' if self.model.lower() == 'ptgrey': @@ -734,14 +1223,17 @@ def gain(self): val = node.GetValue() self._log_debug('Returning '+str(val)) return val + elif self.model.lower() == 'zwoasi': + return self._zwoasi_camera.get_control_value(zwoasi.ASI_GAIN)[0] + elif self.model.lower() == 'ascom': + return self._ascom_camera.Gain else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @gain.setter - def gain(self, gain_db): - self._log_debug('Set gain called with: '+str(gain_db)) + def gain(self, gain): + self._log_debug('Set gain called with: '+str(gain)) assert self.is_init, 'Camera must be initialised' - gain_db = float(gain_db) if self.gain_auto: self._log_debug('Gain is set to auto. Command auto off') self.gain_auto = False @@ -758,12 +1250,21 @@ def gain(self, gain_db): else: self._log_debug('Setting gain') try: - node.SetValue(gain_db) + node.SetValue(float(gain)) except PySpin.SpinnakerException as e: if 'OutOfRangeException' in e.message: raise AssertionError('The commanded value is outside the allowed range. See gain_limit') else: raise #Rethrows error + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi') + self._zwoasi_camera.set_control_value(zwoasi.ASI_GAIN, int(gain)) + self._log_debug('Set gain to ' + str(self.gain)) + elif self.model.lower() == 'ascom': + if gain < self._ascom_camera.GainMin or gain > self._ascom_camera.GainMax: + self._log_debug('Requested gain out of allowable range ('+str(self._ascom_camera.GainMin)+':'+str(self._ascom_camera.GainMax)+').') + raise AssertionError('Requested gain ['+str(gain)+'] out of allowable range ('+str(self._ascom_camera.GainMin)+' - '+str(self._ascom_camera.GainMax)+').') + self._ascom_camera.Gain = gain else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -795,12 +1296,17 @@ def exposure_time_auto(self): else: self._log_debug('Unexpected return value') raise RuntimeError('Unknow response from camera') + elif self.model.lower() == 'zwoasi': + return self._zwoasi_camera.get_control_value(zwoasi.ASI_EXPOSURE)[1] + elif self.model.lower() == 'ascom': + self._log_debug('auto exposure not implemented in ASCOM') + return False else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @exposure_time_auto.setter def exposure_time_auto(self, auto): - self._log_debug('Set expsure time called with: '+str(auto)) + self._log_debug('Set exposure time auto called with: '+str(auto)) assert self.is_init, 'Camera must be initialised' auto = bool(auto) if self.model.lower() == 'ptgrey': @@ -821,6 +1327,20 @@ def exposure_time_auto(self, auto): else: self._log_debug('Setting exposure auto to: '+set_to) node.SetIntValue(node.GetEntryByName(set_to).GetValue()) + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi') + if not self.exposure_time_auto == auto: + self._log_debug('Changing exposure auto mode to: ' + str(auto)) + controls = self._zwoasi_camera.get_controls() + default = controls['Exposure']['DefaultValue'] + self._log_debug('Setting exposure to auto ' + str(auto) + ' and default: ' + str(default)) + self._zwoasi_camera.set_control_value(zwoasi.ASI_EXPOSURE, default, auto) + self._log_debug('Set exposure auto to: ' + str(self.exposure_time_auto)) + else: + self._log_warning('Exposure auto mode already set to: ' + str(auto) + ', doing nothing') + elif self.model.lower() == 'ascom': + self._log_debug('auto exposure not implemented in ASCOM') + return 0 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -828,7 +1348,7 @@ def exposure_time_auto(self, auto): @property def exposure_time_limit(self): """tuple of float: Get the minimum and maximum expsure time in ms supported.""" - self._log_debug('Get gain limit called') + self._log_debug('Get exposure time limit called') assert self.is_init, 'Camera must be initialised' if self.model.lower() == 'ptgrey': self._log_debug('Using PySpin') @@ -845,6 +1365,15 @@ def exposure_time_limit(self): val = (node1.GetValue()/1000, node2.GetValue()/1000) self._log_debug('Returning '+str(val)) return val + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi') + controls = self._zwoasi_camera.get_controls() + min = controls['Exposure']['MinValue'] + max = controls['Exposure']['MaxValue'] + self._log_debug('Camera gave min ' + str(min) + ' and max ' + str(max)) + return (min/1000, max/1000) + elif self.model.lower() == 'ascom': + return self._ascom_camera.ExposureMin, self._ascom_camera.ExposureMax else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -867,6 +1396,11 @@ def exposure_time(self): val = node.GetValue() / 1000 #microseconds used in PtGrey self._log_debug('Returning '+str(val)) return val + elif self.model.lower() == 'zwoasi': + return self._zwoasi_camera.get_control_value(zwoasi.ASI_EXPOSURE)[0] / 1000 #microseconds used in zwoasi + elif self.model.lower() == 'ascom': + self._log_debug('Returning '+str(self._exposure_sec*1000)) + return self._exposure_sec*1000 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -898,6 +1432,16 @@ def exposure_time(self, exposure_ms): +' See exposure_time_limit') else: raise #Rethrows error + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi, setting to ' + str(int(exposure_ms*1000))) + self._zwoasi_camera.set_control_value(zwoasi.ASI_EXPOSURE, int(exposure_ms*1000)) + elif self.model.lower() == 'ascom': + exposure_sec = exposure_ms/1000 + if exposure_sec < self._ascom_camera.ExposureMin or exposure_sec > self._ascom_camera.ExposureMax: + self._log_debug('Exposure time out of allowable range ('+str(self._ascom_camera.ExposureMin)+':'+str(self._ascom_camera.ExposureMax)) + raise AssertionError('Requested exposure time ['+str(exposure_sec)+'] out of allowable range.') + self._exposure_sec = exposure_ms/1000 + else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -905,10 +1449,12 @@ def exposure_time(self, exposure_ms): @property def binning(self): """int: Number of pixels to bin in each dimension (e.g. 2 gives 2x2 binning). Bins by summing. + *ptgrey* cameras bin by summing, *zwoasi* cameras bin by averaging. Setting will stop and restart camera if running. Will scale size_readout to show the same sensor area. """ assert self.is_init, 'Camera must be initialised' + #self._log_debug('Get binning called') if self.model.lower() == 'ptgrey': self._log_debug('Using PySpin') import PySpin @@ -925,6 +1471,19 @@ def binning(self): return val_horiz except PySpin.SpinnakerException: self._log_warning('Failed to read', exc_info=True) + elif self.model.lower() == 'zwoasi': + return self._zwoasi_camera.get_bin() + elif self.model.lower() == 'ascom': + try: + val_horiz = self._ascom_camera.BinX + val_vert = self._ascom_camera.BinY + #self._log_debug('Got '+str(val_horiz)+' '+str(val_vert)) + if val_horiz != val_vert: + self._log_warning('Horizontal and vertical binning is not equal.') + self._binning = val_horiz + return val_horiz + except PySpin.SpinnakerException: + self._log_warning('Failed to read binning property', exc_info=True) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -938,8 +1497,17 @@ def binning(self, binning): self._log_debug('Camera is running, stop it and restart immediately after.') self.stop() initial_size = self.size_readout + if self.color_bin: + initial_size = [2*x for x in initial_size] initial_bin = self.binning self._log_debug('Initial sensor readout area and binning: '+str(initial_size)+' ,'+str(initial_bin)) + + # Calculate what the new ROI needs to be set to + bin_scaling = binning/initial_bin + new_size = [round(sz/bin_scaling) for sz in initial_size] + + self._log_debug('New binning and new size to set: '+str(binning)+' ,'+str(new_size)) + if self.model.lower() == 'ptgrey': self._log_debug('Using PySpin') import PySpin @@ -958,24 +1526,92 @@ def binning(self, binning): raise AssertionError('Not allowed to change binning now.') else: raise #Rethrows error + # Correctly set the ROI to adjust for new binning size + try: + self.size_readout = new_size + self._log_debug('Set new size to: ' + str(self.size_readout)) + except: + self._log_warning('Failed to scale readout after binning change', exc_info=True) + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi, width must be multiple of 8 and height multiple of 2') + new_size = [new_size[0] - (new_size[0] % 8), new_size[1] - (new_size[1] % 2)] + self._log_debug('Adjusted to allowable size: ' + str(new_size)) + self._zwoasi_camera.set_roi(width = new_size[0], height = new_size[1], bins = binning) + self._log_debug('Set binning to ' + str(self.binning) + ' and readout to ' + str(self.size_readout)) + elif self.model.lower() == 'ascom': + binMax = self._ascom_camera.MaxBinX + if binMax and binning <= binMax: + try: + self._logger.info("setting binning to %i" % binning) + self._ascom_camera.BinX = binning + self._ascom_camera.BinY = binning + self._ascom_camera.NumX = self._ascom_camera.CameraXSize/binning + self._ascom_camera.NumY = self._ascom_camera.CameraYSize/binning + self._binning = binning + except: + raise AssertionError('Unable to set camera binning') + else: + raise ValueError('exceeds camera max bin val ',binMax) + #new_bin = self._binning + #bin_scaling = new_bin/initial_bin + #new_size = [round(sz/bin_scaling) for sz in initial_size] + #self._log_debug('New binning and new size to set: '+str(new_bin)+' ,'+str(new_size)) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - new_bin = self.binning - bin_scaling = new_bin/initial_bin - new_size = [round(sz/bin_scaling) for sz in initial_size] - self._log_debug('New binning and new size to set: '+str(new_bin)+' ,'+str(new_size)) - try: - self.size_readout = new_size - self._log_debug('Set new size to: ' + str(self.size_readout)) - except: - self._log_warning('Failed to scale readout after binning change', exc_info=True) + + #try: + # self.size_readout = new_size + # self._log_debug('Set new size to: ' + str(self.size_readout)) + #except: + # self._log_warning('Failed to scale readout after binning change', exc_info=True) + if was_running: + self._log_debug('Restarting camera imaging loop.') try: self.start() self._log_debug('Restarted') except Exception: self._log_debug('Failed to restart: ', exc_info=True) + else: + self._log_debug('Camera imaging loop was not previously running.') + + @property + def color_bin(self): + """bool: Get or set if colour binning is active. Defaults to True for colour cameras. Is always False for mono + cameras. + + When colour binning is True, each 2x2 Bayer group on the image sensor will form one RGB pixel in the output. + If set to False, interpolation will be used to create an RGB image at full resolution. Interpolation may slow + down the image processing significantly. + """ + assert self.is_init, 'Camera must be initialised' + if self.model.lower() == 'ptgrey': + return False + elif self.model.lower() == 'zwoasi': + return self._zwoasi_property['IsColorCam'] and self._color_bin + elif self.model.lower() == 'ascom': + return False + else: + self._log_warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + @color_bin.setter + def color_bin(self, bin): + self._log_debug('Set color bin called with: '+str(bin)) + assert self.is_init, 'Camera must be initialised' + if self.model.lower() == 'ptgrey': + raise RuntimeError('ptgrey cameras do not support color binning') + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi, check if we use a colour camera') + assert self._zwoasi_property['IsColorCam'], 'Must have colour camera to do colour binning' + self._color_bin = bool(bin) + self._log_debug('Set color bin to: ' + str(self._color_bin)) + elif self.model.lower() == 'ascom': + raise RuntimeError('ascom cameras do not support color binning') + else: + self._log_warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @property def size_max(self): @@ -996,6 +1632,21 @@ def size_max(self): self._log_debug('Failure reading', exc_info=True) raise return (val_w, val_h) + elif self.model.lower() == 'zwoasi': + properties = self._zwoasi_camera.get_camera_property() + bin = self.binning + width = int(properties['MaxWidth'] / bin) + width -= width % 8 # Must be multiple of 8 + height = int(properties['MaxHeight'] / bin) + height -= height % 2 # Must be multiple of 2 + return (width, height) if not self.color_bin else (width//2, height//2) + elif self.model.lower() == 'ascom': + try: + val_w = self._ascom_camera.CameraXSize + val_h = self._ascom_camera.CameraYSize + return (val_w, val_h) + except: + self._log_debug('Unable to read ASCOM camera max image dimensions', exc_info=True) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -1006,6 +1657,8 @@ def size_readout(self): This applies after binning, i.e. this is the size the output image will be. + For model *zwoasi* the set size will be rounded down to the nearest multiple of 8 in width and 2 in height. + Setting will stop and restart camera if running. """ assert self.is_init, 'Camera must be initialised' @@ -1024,6 +1677,16 @@ def size_readout(self): self._log_debug('Failure reading', exc_info=True) raise return (val_w, val_h) + elif self.model.lower() == 'zwoasi': + (width, height) = tuple(self._zwoasi_camera.get_roi_format()[:2]) + return (width, height) if not self.color_bin else (width//2, height//2) + elif self.model.lower() == 'ascom': + try: + val_w = self._ascom_camera.NumX + val_h = self._ascom_camera.NumY + return (val_w, val_h) + except: + self._log_debug('Unable to read ASCOM camera image dimensions', exc_info=True) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -1081,6 +1744,35 @@ def size_readout(self, size): node_offs_y.SetValue(new_offset[1]) except PySpin.SpinnakerException: self._log_warning('Failure centering readout', exc_info=True) + elif self.model.lower() == 'zwoasi': + self._log_debug('Using zwoasi, check if colour binning and adjust') + if self.color_bin: + size = tuple([x*2 for x in size]) + self._log_debug('Size adjusted to ' + str(size)) + self._log_debug('Adjust desired size to allowable size:') + size = [size[0] - (size[0] % 8), size[1] - (size[1] % 2)] + self._zwoasi_camera.set_roi(width = size[0], height = size[1]) + self._log_debug('Set readout to ' + str(self.size_readout)) + elif self.model.lower() == 'ascom': + try: + max_w = self._ascom_camera.CameraXSize + max_h = self._ascom_camera.CameraYSize + if not max_h or not max_w: + raise AssertionError('Unable to read ASCOM camera image size limits.') + try: + bin_w = self._ascom_camera.BinX + bin_h = self._ascom_camera.BinY + except: + raise AssertionError('Unable to read ASCOM camera binning value.') + if not bin_h or not bin_w: + raise AssertionError('Unable to read ASCOM camera binning value.') + try: + self._ascom_camera.NumX = max_w/bin_w + self._ascom_camera.NumY = max_h/bin_h + except: + raise AssertionError('Unable to set ASCOM camera image size.') + except: + raise AssertionError('Unable to read ASCOM camera image size limits.') else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -1119,10 +1811,14 @@ def remove_event_callback(self, method): @property def is_running(self): """bool: True if device is currently acquiring data.""" - self._log_debug('Checking if running') + #self._log_debug('Checking if running') if not self.is_init: return False if self.model.lower() == 'ptgrey': return self._ptgrey_camera is not None and self._ptgrey_camera.IsStreaming() + elif self.model.lower() == 'zwoasi': + return self._zwoasi_camera is not None and self._zwoasi_image_handler.is_running + elif self.model.lower() == 'ascom': + return self._ascom_camera.Connected and self._ascom_camera_imaging_handler._is_running else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -1146,6 +1842,13 @@ def start(self): self._log_warning('The camera was already streaming...') else: raise RuntimeError('Failed to start camera acquisition') from e + + elif self.model.lower() == 'zwoasi': + self._log_debug('Calling start on zwoasi image handler') + self._zwoasi_image_handler.start() + + elif self.model.lower() == 'ascom': + self._ascom_camera_imaging_handler.start_imaging_loop() else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -1153,10 +1856,10 @@ def start(self): def stop(self): """Stop the acquisition.""" + self._log_debug('Got stop command') if not self.is_running: self._log_info('Camera was not running, name: '+self.name) return - self._log_debug('Got stop command') if self.model.lower() == 'ptgrey': self._log_debug('Using PtGrey') try: @@ -1164,11 +1867,17 @@ def stop(self): except: self._log_debug('Could not stop:', exc_info=True) raise RuntimeError('Failed to stop camera acquisition') + elif self.model.lower() == 'zwoasi': + self._log_debug('Calling stop on zwoasi image handler') + self._zwoasi_image_handler.stop() + elif self.model.lower() == 'ascom': + self._ascom_camera_imaging_handler.stop_imaging_loop() else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) self._image_data = None self._image_timestamp = None + self._average_frame_time = None self._got_image_event.clear() self._log_info('Acquisition stopped, name: '+self.name) @@ -1235,1206 +1944,88 @@ def get_latest_image(self): Returns: numpy.ndarray: 2d array with image data. """ - self._log_debug('Got latest image request') + #self._log_debug('Got latest image request') assert self.is_running, 'Camera must be running' return self._image_data - - -class Mount: - """Control a telescope gimbal mount. - - To initialise a Mount a *model* (determines hardware interface) and *identity* (identifying the specific device) - must be given. If both are given to the constructor the Mount will be initialised immediately (unless - auto_init=False is passed). Manually initialise with a call to Mount.initialize(); release hardware with a call to - Mount.deinitialize(). - - After the Mount is initialised, the gimbal angles and rates may be read and commanded. Several properties (e.g - maximum angles and rates) may be set. - - Args: - model (str, optional): The model used to determine the the hardware control interface. Supported: 'celestron' - for Celestron NexStar and Orion/SkyWatcher SynScan (all the same) hand controller communication over serial. - identity (str or int, optional): String or int identifying the device. For model *celestron* this can either be - a string with the serial port (e.g. 'COM3' on Windows or '/dev/ttyUSB0' on Linux) or an int with the index - in the list of available ports to use (e.g. identity=0 i if only one serial device is connected.) - name (str, optional): Name for the device. - auto_init (bool, optional): If both model and identity are given when creating the Mount and auto_init - is True (the default), Mount.initialize() will be called after creation. - debug_folder (pathlib.Path, optional): The folder for debug logging. If None (the default) - the folder *pypogs*/logs will be used/created. - - Example: - :: - - # Create instance (will auto initialise) - mount = pypogs.Mount(model='celestron', identity='COM3', name='CPC800') - # Move to position - mount.move_to_alt_az(30, 10) #degrees; by default blocks until finished - # Set gimbal rates - mount.set_rate_alt_az(0, -1.5) #degrees per second - # Wait for a while - time.sleep(2) - # Stop moving - mount.stop() - # Disconnect from the mount - mount.deinitialize() - - Note: - The Mount class allows two modes of control for moving to positions. The default is rate_control=True, where - this class will continuously send rate commands until the desired position is reached. It is possible to use the - internal motion controller in the mount by passing rate_control=False. However, it is slow and implements - backlash compensation. In our testing the accuracy difference is negligible so the default is recommended. +def _debayer_image(img, order='RGGB', downsize=False): + """PRIVATE: Debayer image (2d np array) with given pixel order. + Order can be RGGB (default), BGGR, GBRG, or GRBG. + If downsize is set to True, the output image will be half the size of the original. """ - - _supported_models = ('celestron',) - - def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_folder=None): - """Create Mount instance. See class documentation.""" - # Logger setup - self._debug_folder = None - if debug_folder is None: - self.debug_folder = Path(__file__).parent / 'debug' - else: - self.debug_folder = debug_folder - self._logger = logging.getLogger('pypogs.hardware.Mount') - if not self._logger.hasHandlers(): - # Add new handlers to the logger if there are none - self._logger.setLevel(logging.DEBUG) - # Console handler at INFO level - ch = logging.StreamHandler() - ch.setLevel(logging.INFO) - # File handler at DEBUG level - fh = logging.FileHandler(self.debug_folder / 'pypogs.txt') - fh.setLevel(logging.DEBUG) - # Format and add - formatter = logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s') - fh.setFormatter(formatter) - ch.setFormatter(formatter) - self._logger.addHandler(fh) - self._logger.addHandler(ch) - - # Start of constructor - self._logger.debug('Mount called with model=' + str(model) + ' identity=' + str(identity) + ' name=' \ - + str(name) + ' auto_init=' + str(auto_init)) - self._model = None - self._identity = None - self._name = 'UnnamedMount' - self._is_init = False - self._max_speed = (4.0, 4.0) #(alt,azi) degrees/sec - self._alt_limit = (-5, 95) #limit degrees - self._azi_limit = (None, None) #limit degees - self._home_pos = (0, 0) #Home position - self._alt_zero = 0 #Amount to subtract from alt. - # Only used for model celestron - self._cel_serial_port = None - # Thread for rate control - self._control_thread = None - self._control_thread_stop = True - # Cache of the state of the mount - self._state_cache = {'alt': 0.0, 'azi': 0.0, 'alt_rate': 0.0, 'azi_rate': 0.0} - if name is not None: - self.name = name - if model is not None: - self.model = model - if identity is not None: - self.identity = identity - if model is not None and identity is not None: - self.initialize() - # Try to get Python to clean up the object properly - import atexit, weakref - atexit.register(weakref.ref(self.__del__)) - self._logger.info('Mount instance created with name: ' + self.name + '.') - - def __del__(self): - """Destructor, try to stop the mount and disconnect.""" - try: - self._logger.debug('Destructor called, try stop moving and disconnecting') - except: - pass - try: - self.stop() - self._logger.debug('Stopped') - except: - pass - try: - self.deinitialize() - self._logger.debug('Deinitialised') - except: - pass - try: - self._logger.debug('Destructor finished') - except: - pass - - @property - def debug_folder(self): - """pathlib.Path: Get or set the path for debug logging. Will create folder if not - existing.""" - return self._debug_folder - @debug_folder.setter - def debug_folder(self, path): - # Do not do logging in here! This will be called before the logger is set up - path = Path(path) - if path.is_file(): - path = path.parent - if not path.is_dir(): - path.mkdir(parents=True) - self._debug_folder = path - - @property - def state_cache(self): - """dict: Get cache with the current state of the Mount. Updates on calls to get_alt_az() and set_rate_alt_az(). - - Keys: - azi: float, alt: float, azi_rate: float, alt_rate: float - """ - if self.is_init: - return self._state_cache - else: - return None - - @property - def name(self): - """str: Get or set the name.""" - return self._name - @name.setter - def name(self, name): - self._logger.debug('Setting name to: '+str(name)) - self._name = str(name) - self._logger.debug('Name set to '+str(self.name)) - - @property - def model(self): - """str: Get or set the device model. - - Supported: - - 'celestron' for Celestron NexStar and Orion/SkyWatcher SynScan hand controllers over serial. - - - This will determine which hardware interface is used. - - Must set before initialising the device and may not be changed for an initialised device. - """ - return self._model - @model.setter - def model(self, model): - self._logger.debug('Setting model to: '+str(model)) - assert not self.is_init, 'Can not change already initialised device model' - model = str(model) - assert model.lower() in self._supported_models,\ - 'Model type not recognised, allowed: '+str(self._supported_models) - self._model = model.lower() - self._logger.debug('Model set to '+str(self.model)) - - @property - def identity(self): - """str: Get or set the device and/or input. Model must be defined first. - - - For model *celestron* this can either be a string with the serial port (e.g. 'COM3' on Windows or - '/dev/ttyUSB0' on Linux) or an int with the index in the list of available ports to use (e.g. identity=0 i if - only one serial device is connected.) - - Must set before initialising the device and may not be changed for an initialised device. - - Raises: - AssertionError: if unable to connect to and verify identity of the mount. - """ - return self._identity - @identity.setter - def identity(self, identity): - self._logger.debug('Setting identity to: '+str(identity)) - assert not self.is_init, 'Can not change already initialised device' - assert isinstance(identity, (str, int)), 'Identity must be a string or an int' - assert self.model is not None, 'Must define model first' - if self.model == 'celestron': - if isinstance(identity, int): - self._logger.debug('Got int instance, finding open ports') - ports = self.list_available_ports() - self._logger.debug('Found ports: '+str(ports)) - try: - identity = ports[identity][0] - except IndexError: - self._logger.debug('Index error', exc_info=True) - raise AssertionError('No serial port for index: '+str(identity)) - self._logger.debug('Using celestron with string identity, try to open and check model') - try: - with serial.Serial(identity, 9600, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE,\ - timeout=3.5, write_timeout=3.5) as ser: - ser.write(b'm') - ser.flush() - r = ser.read(2) - self._logger.debug('Got response: '+str(r)) - assert len(r)==2 and r[1] == ord('#'), 'Did not get the expected response from the device' - except serial.SerialException: - self._logger.debug('Failed to open port', exc_info=True) - raise AssertionError('Failed to open the serial port named: '+str(identity)) - self._identity = identity - else: - self._logger.warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - self._logger.debug('Identity set to: '+str(self.identity)) - - @property - def is_init(self): - """bool: True if the device is initialised (and therefore ready to control).""" - if not self.model: return False - if self.model == 'celestron': - return self._is_init - else: - self._logger.warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - @property - def available_properties(self): - """tuple of str: Get all the available properties (settings) supported by this device.""" - assert self.is_init, 'Mount must be initialised' - if self.model.lower() == 'celestron': - return ('zero_altitude', 'home_alt_az', 'max_rate', 'alt_limit', 'azi_limit') + + (height, width) = img.shape + datatype = img.dtype + # Unpack pixels. Determine the pixel offset (position in each quad group) + offset = {} + green2 = False + for c, offs in zip(order.upper(), [(0, 0), (0, 1), (1, 0), (1, 1)]): + if c == 'G': + if not green2: + offset['G1'] = offs + green2 = True + else: + offset['G2'] = offs else: - self._log_warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - @property - def zero_altitude(self): - """float: Get or set the zero altitude angle (degrees). Default 0. - - Normally the mount is initialised with the telescope level. In this case zero_altitude is 0. However, if the - mount is e.g. initialised with the telescope pointing straight up, zero_altitude must be set to +90. - """ - return self._alt_zero - @zero_altitude.setter - def zero_altitude(self, angle): - self._logger.debug('Got set zero altitude with: '+str(angle)) - self._alt_zero = float(angle) - self._logger.debug('Alt zero set to: '+str(self._alt_zero)) - - @property - def home_alt_az(self): - """tuple of float: Get or set the home position (altitude, azimuth) in degrees. Default (0, 0)""" - return self._home_pos - @home_alt_az.setter - def home_alt_az(self, pos): - self._logger.debug('Got set home pos with: '+str(pos)) - try: - pos = tuple([float(x) for x in pos]) - assert len(pos)==2 - except TypeError: - pos = (float(pos), float(pos)) - self._home_pos = pos - self._logger.debug('Home pos set to: '+str(self.home_alt_az)) - - @property - def max_rate(self): - """tuple of float: Get or set the max slew rate (degrees per second) for the axes (altitude, azimith). - Default (4.0, 4.0). - - If a scalar is set, both axes' rates will be set to this value. - """ - return self._max_speed - @max_rate.setter - def max_rate(self, maxrate): - self._logger.debug('Got set max rate with: '+str(maxrate)) - try: - maxrate = tuple([float(x) for x in maxrate]) - assert len(maxrate)==2 - except TypeError: - maxrate = (float(maxrate), float(maxrate)) - self._max_speed = maxrate - self._logger.debug('Set max to: '+str(self.max_rate)) - - @property - def alt_limit(self): - """tuple of float: Get or set the altitude limits (degrees) where the mount can safely move. - May be set to None. Default (-5, 95). Not enforced when slewing (set_rate) the mount. - """ - return self._alt_limit - @alt_limit.setter - def alt_limit(self, altlim): - if altlim is None: - self._logger.debug('Setting alt limit to None') - self._alt_limit = (None, None) - else: - assert isinstance(altlim, (tuple, list)) and len(altlim)==2, 'Must be 2-tuple' - self._logger.debug('Got set alt limits with: '+str(altlim)) - self._alt_limit = (float(altlim[0]) if altlim[0] is not None else None \ - , float(altlim[1]) if altlim[1] is not None else None) - self._logger.debug('Set alt limit to: '+str(self._alt_limit)) - - @property - def azi_limit(self): - """tuple of float: Get or set the azimuth limits (degrees) where the mount can safely move. - May be set to None. Default (None, None). Not enforced when slewing (set_rate) the mount. - """ - return self._azi_limit - @azi_limit.setter - def azi_limit(self, azilim): - if azilim is None: - self._logger.debug('Setting azi limit to None') - self._azi_limit = (None, None) - assert isinstance(azilim, (tuple, list)) and len(azilim)==2, 'Must be 2-tuple' - self._logger.debug('Got set azi limits with: '+str(azilim)) - self._azi_limit = (float(azilim[0]) if azilim[0] is not None else None \ - , float(azilim[1]) if azilim[1] is not None else None) - self._logger.debug('Set azi limit to: '+str(self._azi_limit)) - - def initialize(self): - """Initialise (make ready to start) the device. The model and identity must be defined.""" - self._logger.debug('Initialising') - assert not self.is_init, 'Already initialised' - assert not None in (self.model, self.identity), 'Must define model and identity before initialising' - if self.model == 'celestron': - self._logger.debug('Using Celestron, try to initialise') - try: - self._cel_serial_port = serial.Serial(self.identity, 9600, parity=serial.PARITY_NONE,\ - stopbits=serial.STOPBITS_ONE, timeout=3.5, write_timeout=3.5) - except serial.SerialException: - self._logger.debug('Failed to open', exc_info=True) - raise RuntimeError('Failed to connect to the mount during initialisation') - self._logger.debug('Sending model check') - self._cel_serial_port.write(b'm') - self._cel_serial_port.flush() - r = self._cel_serial_port.read(2) - self._logger.debug('Got response: '+str(r)) - assert len(r)==2 and r[1] == ord('#'), 'Did not get the expected response during initialisation' - self._logger.debug('Ensure sidreal tracking is off.') - self._cel_tracking_off() - self._is_init = True - else: - self._logger.warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - self._logger.info('Mount initialised.') - try: - self.get_alt_az() #Get cache to update - except AssertionError: - self._logger.debug('Failed to set state cache', exc_info=True) - - def deinitialize(self): - """De-initialise the device and release hardware (serial port). Will stop the mount if it is moving.""" - self._logger.debug('De-initialising') - assert self.is_init, 'Not initialised' - try: - self._logger.debug('Stopping mount') - self.stop() - except: - self._logger.debug('Did not stop', exc_info=True) - if self.model == 'celestron': - self._logger.debug('Using celestron, closing and deleting serial port') - self._cel_serial_port.close() - self._cel_serial_port = None - self._is_init = False - self._logger.info('Mount deinitialised') - else: - self._logger.warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - @property - def is_moving(self): - """Returns True if the mount is currently moving.""" - assert self.is_init, 'Must be initialised' - self._logger.debug('Got is moving request, checking thread') - if self._control_thread is not None and self._control_thread.is_alive(): - self._logger.debug('Has active celestron control thread') - return True - if self.model == 'celestron': - self._logger.debug('Using celestron, asking if moving') - ret = [None] - def _is_moving_to(ret): - self._cel_send_text_command('L') - ret[0] = self._cel_read_to_eol() - t = Thread(target=_is_moving_to, args=(ret,)) - t.start() - t.join() - moving = not ret[0] == b'0' - self._logger.debug('Mount returned: ' + str(ret[0]) + ', is moving: ' + str(moving)) - return moving - else: - self._logger.warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - def move_to_alt_az(self, alt, azi, block=True, rate_control=True): - """Move the mount to the given position. Must be initialised. - - Args: - alt (float): Altitude angle (degrees). - azi (float): Azimuth angle (degrees). - block (bool, optional): If True (the default) the call to this method will block until the move is finished. - rate_control (bool, optional): If True (the default) the rate of the mount will be controlled until position - is reached, if False the position command will be sent to the mount for execution. - """ - assert self.is_init, 'Must be initialised' - assert self._alt_limit[0] is None or alt >= self._alt_limit[0], 'Altitude outside range!' - assert self._alt_limit[1] is None or alt <= self._alt_limit[1], 'Altitude outside range!' - assert self._azi_limit[0] is None or azi >= self._azi_limit[0], 'Azimuth outside range!' - assert self._azi_limit[1] is None or azi <= self._azi_limit[1], 'Azimuth outside range!' - self._logger.debug('Got move command with: alt=' + str(alt) + ' azi=' + str(azi) + ' block='+str(block) \ - + ' rate_control=' + str(rate_control)) - self._logger.debug('Stopping mount first') - self.stop() - if self.model == 'celestron': - self._logger.debug('Using celestron, ensure range -180 to 180') -# alt = self.degrees_to_n180_180(alt - self._alt_zero) - alt = self.degrees_to_n180_180(alt) - azi = self.degrees_to_n180_180(azi) - self._logger.debug('Will command: alt=' + str(alt) + ' azi=' + str(azi)) - if rate_control: #Use own control thread - self._logger.debug('Starting rate controller') - Kp = 1.5 - self._control_thread_stop = False - success = [False] - def _loop_slew_to(alt, azi, success): - while not self._control_thread_stop: - curr_pos = self.get_alt_az() - eAlt = Kp * self.degrees_to_n180_180(alt - curr_pos[0]) - eAzi = Kp * self.degrees_to_n180_180(azi - curr_pos[1]) - if eAlt < -self._max_speed[0]: eAlt = -self._max_speed[0] - if eAlt > self._max_speed[0]: eAlt = self._max_speed[0] - if eAzi < -self._max_speed[1]: eAzi = -self._max_speed[1] - if eAzi > self._max_speed[1]: eAzi = self._max_speed[1] - - if abs(eAlt)<.001 and abs(eAzi)<.001: - self.set_rate_alt_az(0, 0) - success[0] = True - break - else: - self.set_rate_alt_az(eAlt, eAzi) - self._control_thread_stop = True - self._control_thread = Thread(target=_loop_slew_to, args=(alt, azi, success)) - self._control_thread.start() - if block: - self._logger.debug('Waiting for thread to finish') - self._control_thread.join() - assert success[0], 'Failed moving with rate controller' - else: - self._logger.debug('Sending move command to mount') - success = [False] - def _move_to_alt_az(alt, azi, success): - #azi = azi %360 #Mount uses 0-360 - # TODO check alt zero correct - altRaw = int(self.degrees_to_0_360(alt - self._alt_zero) / 360 * 2**32) & 0xFFFFFF00 - aziRaw = int(self.degrees_to_0_360(azi) / 360 * 2**32) & 0xFFFFFF00 - altFormatted = '{0:0{1}X}'.format(altRaw,8) - aziFormatted = '{0:0{1}X}'.format(aziRaw,8) - command = 'b' + aziFormatted + ',' + altFormatted - self._cel_send_text_command(command) - assert self._cel_check_ack(), 'Mount did not acknowledge' - success[0] = True - t = Thread(target=_move_to_alt_az, args=(alt, azi, success)) - t.start() - t.join() - assert success[0], 'Failed communicating with mount' - self._logger.debug('Send successful') - if block: - self._logger.debug('Waiting for mount to finish') - self.wait_for_move_to() - else: - self._logger.warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - def get_alt_az(self): - """Get the current alt and azi angles of the mount. - - Returns: - tuple of float: the (altitude, azimuth) angles of the mount in degrees (-180, 180]. - """ - assert self.is_init, 'Must be initialised' - if self.model == 'celestron': - self._logger.debug('Using celestron, requesting mount position') - def _get_alt_az(ret): - command = bytes([ord('z')]) #Get precise AZM-ALT - self._cel_serial_port.write(command) - # The command returns ASCII encoded text of HEX values! - res = self._cel_read_to_eol().decode('ASCII') - r2 = res.split(',') - ret[0] = int(r2[1], 16) - ret[1] = int(r2[0], 16) - ret = [None, None] - t = Thread(target=_get_alt_az, args=(ret,)) - t.start() - t.join() - alt = self.degrees_to_n180_180( float(ret[0]) / 2**32 * 360 + self._alt_zero) - azi = self.degrees_to_n180_180( float(ret[1]) / 2**32 * 360 ) - self._logger.debug('Mount returned: alt=' + str(ret[0]) + ' azi=' + str(ret[1]) \ - + ' => alt=' + str(alt) + ' azi=' + str(azi)) - self._state_cache['alt'] = alt - self._state_cache['azi'] = azi - return (alt, azi) - else: - self._logger.warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - def move_home(self, block=True, rate_control=True): - """Move to the position defined by Mount.home_alt_az. - - Args: - block (bool, optional): If True (the default) the call to this method will block until the move is finished. - rate_control (bool, optional): If True (the default) the rate of the mount will be controlled until position - is reached, if False the position command will be sent to the mount for execution. - """ - self.move_to_alt_az(*self.home_alt_az, block=block, rate_control=rate_control) - - def set_rate_alt_az(self, alt, azi): - """Set the mount slew rate. Must be initialised. - - Args: - alt (float): Altitude rate (degrees per second). - azi (float): Azimuth rate (degrees per second). - """ - assert self.is_init, 'Must be initialised' - self._logger.debug('Got rate command. alt=' + str(alt) + ' azi=' + str(azi)) - if (abs(alt) > self._max_speed[0]) or (abs(azi) > self._max_speed[0]): - raise ValueError('Above maximum speed!') - if self.model == 'celestron': - self._logger.debug('Using celestron, sending rate command to mount') - success = [False] - def _set_rate_alt_az(alt, azi, success): - #Altitude - rate = int(round(alt*3600*4)) - if rate >= 0: - rateLo = rate & 0xFF - rateHi = rate>>8 & 0xFF - self._cel_send_bytes_command([ord('P'),3,17,6,rateHi,rateLo,0,0]) - else: - rateLo = -rate & 0xFF - rateHi = -rate>>8 & 0xFF - self._cel_send_bytes_command([ord('P'),3,17,7,rateHi,rateLo,0,0]) - assert self._cel_check_ack(), 'Mount did not acknowledge!' - #Azimuth - rate = int(round(azi*3600*4)) - if rate >= 0: - rateLo = rate & 0xFF - rateHi = rate>>8 & 0xFF - self._cel_send_bytes_command([ord('P'),3,16,6,rateHi,rateLo,0,0]) - else: - rateLo = -rate & 0xFF - rateHi = -rate>>8 & 0xFF - self._cel_send_bytes_command([ord('P'),3,16,7,rateHi,rateLo,0,0]) - assert self._cel_check_ack(), 'Mount did not acknowledge!' - success[0] = True - t = Thread(target=_set_rate_alt_az, args=(alt, azi, success)) - t.start() - t.join() - assert success[0], 'Failed communicating with mount' - self._logger.debug('Send successful') - self._state_cache['alt_rate'] = alt - self._state_cache['azi_rate'] = azi - else: - self._logger.warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - def stop(self): - """Stop moving.""" - assert self.is_init, 'Must be initialised' - self._logger.debug('Got stop command, check thread') - if self._control_thread is not None and self._control_thread.is_alive(): - self._logger.debug('Stopping celestron control thread') - self._control_thread_stop = True - self._control_thread.join() - self._logger.debug('Stopped') - self._logger.debug('Sending zero rate command') - self.set_rate_alt_az(0, 0) - self._logger.debug('Stopped mount') - - def wait_for_move_to(self, timeout=120): - """Wait for mount to finish move. - - Args: - timeout (int, optional): Maximum time (seconds) to wait before raising TimeoutError. Default 120. - """ - assert self.is_init, 'Must be initialised' - t_start = timestamp() - self._logger.debug('Waiting for move to, start time: '+str(t_start)) - try: - while timestamp() - t_start < timeout: - if self.is_moving: - sleep(.5) - else: - return - except KeyboardInterrupt: - self._logger.debug('Waiting interrupted', exc_info=True) - - raise TimeoutError('Waiting for mount move took more than ' + str(timeout) + 'seconds.') - - @staticmethod - def list_available_ports(): - """List the available serial port names and descriptions. - - Returns: - list of tuple: (device, description) for each available serial port (see serial.tools.list_ports). - """ - import serial.tools.list_ports as ports - port_list = ports.comports() - return [(x.device, x.description) for x in port_list] if len(port_list) > 0 else [] - - @staticmethod - def degrees_to_0_360(number): - """float: Convert angle (degrees) to range [0, 360).""" - return float(number)%360 - - @staticmethod - def degrees_to_n180_180(number): - """float: Convert angle (degrees) to range (-180, 180]""" - return 180 - (180-float(number))%360 - - def _cel_tracking_off(self): - """PRIVATE: Disable sidreal tracking on celestron mount.""" - success = [False] - def _set_tracking_off(success): - self._cel_send_bytes_command([ord('T'),0]) - assert self._cel_check_ack(), 'Mount did not acknowledge!' - success[0] = True - t = Thread(target=_set_tracking_off, args=(success,)) - t.start() - t.join() - assert success[0], 'Failed communicating with mount' - - def _cel_send_text_command(self,command): - """PRIVATE: Encode and send str to mount.""" - # Given command as type 'str', send to mount as ASCII text - self._cel_serial_port.write(command.encode('ASCII')) - self._cel_serial_port.flush() #Push out data - - def _cel_send_bytes_command(self,command): - """PRIVATE: Send bytes to mount.""" - # Given command as list of integers, send to mount as bytes - self._cel_serial_port.write(bytes(command)) - self._cel_serial_port.flush() #Push out data - - def _cel_check_ack(self): - """PRIVATE: Read one byte and check that it is the ack #.""" - # Checks if '#' is returned as expected - b = self._cel_serial_port.read() - return int.from_bytes(b, 'big') == ord('#') - - def _cel_read_to_eol(self): - """PRIVATE: Read response to the EOL # character. Return bytes.""" - # Read from mount until EOL # character. Return as type 'bytes' - response = b'' #Empty type 'bytes' - while True: - r = self._cel_serial_port.read() - if r == b'': #If we didn't get anything/timeout - raise RuntimeError('No response from mount!') - else: - if int.from_bytes(r, 'big') == ord('#'): - self._logger.debug('Read from mount: '+str(response)) - return response - else: - response += r - - -class Receiver: - """Control acquisition and read received power from a photodetector. - - To initialise a Receiver a *model* (determines hardware interface) and *identity* (identifying the specific device) - must be given. If both are given to the constructor the Receiver will be initialised immediately (unless - auto_init=False is passed). Manually initialise with a call to Receiver.initialize(); release hardware with a call - to Receiver.deinitialize(). - - The raw data can be saved to a file by specifying data_folder (filenames are auto-generated). While the acquisition - is running the instantaneous (last measurement) and (exponentially) smoothed power can be read. - - Args: - model (str, optional): The model used to determine the correct hardware API. Supported: 'ni_daq' for - National Instruments DAQ cards (tested on USB-6211). - identity (str, optional): String identifying the device and input. For *ni_daq* this is 'device/input' eg. - 'Dev1/ai1' for device 'Dev1' and analog input 1; only differential input is supported for *ni_daq*. - name (str, optional): Name for the device. - auto_init (bool, optional): If both model and identity are given when creating the Receiver and auto_init - is True (the default), Receiver.initialize() will be called after creation. - data_folder (pathlib.Path, optional): The folder for data saving. If None (the default) no data will be saved. - debug_folder (pathlib.Path, optional): The folder for debug logging. If None (the default) the folder - *pypogs*/debug will be used/created. - - Example: - :: - - # Create instance and set parameters (will auto initialise) - rec = pypogs.Receiver(model='ni_daq', identity='Dev1/ai1', name='PhotoDiode') - rec.sample_rate = 1000 #Samples per second - rec.smoothing_parameter = 100 #number of samples to smooth over - rec.measurement_range = (-10, 10) #Volts for ni_daq - # Add a save path (filenames are auto-generated) - rec.data_folder = pathlib.Path('./datafolder') - # Start acquisition - rec.start() - # Wait for a while - time.sleep(2) - # Read the smooth and instantaneous powers - print('Smoothed power is: ' + str(rec.smooth_power)) - print('Instant power is: ' + str(rec.instant_power)) - # Stop the acquisition - rec.stop() - - """ - _supported_models = ('ni_daq',) - - def __init__(self, model=None, identity=None, name=None, auto_init=True, data_folder=None,\ - debug_folder=None): - """Create Receiver instance. See class documentation.""" - # Logger setup - self._debug_folder = None - if debug_folder is None: - self.debug_folder = Path(__file__).parent / 'debug' - else: - self.debug_folder = debug_folder - self._logger = logging.getLogger('pypogs.hardware.Receiver') - if not self._logger.hasHandlers(): - # Add new handlers to the logger if there are none - self._logger.setLevel(logging.DEBUG) - # Console handler at INFO level - ch = logging.StreamHandler() - ch.setLevel(logging.INFO) - # File handler at DEBUG level - fh = logging.FileHandler(self.debug_folder / 'pypogs.txt') - fh.setLevel(logging.DEBUG) - # Format and add - formatter = logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s') - fh.setFormatter(formatter) - ch.setFormatter(formatter) - self._logger.addHandler(fh) - self._logger.addHandler(ch) - - # Start of constructor - self._logger.debug('Receiver called with: model=' + str(model) + ' identity=' + str(identity) + ' name=' \ - +str(name) + ' auto_init=' + str(auto_init) + ' data_folder=' + str(data_folder)) - self._is_init = False - self._is_running = False - self._model = None - self._identity = None - self._name = 'UnnamedReceiver' - self._data_folder = None - self._data_file = None - # Power values stored from the receiver - self._instant_power = None - self._smooth_power = None - self._smoothing_parameter = 100 - #Only used for NI DAQ devices - self._ni_task = None - if model is not None: - self.model = model - if identity is not None: - self.identity = identity - if name is not None: - self.name = name - if data_folder is not None: - self.data_folder = data_folder - self._logger.info('Instance created'+(': '+self.name) if self.name is not None else '') - if auto_init and not None in (model, identity): - self.initialize() - import atexit, weakref - atexit.register(weakref.ref(self.__del__)) - self._logger.info('Receiver instance created with name: ' + self.name + '.') - - def __del__(self): - """Receiver destructor. Will close connections to device before destruction.""" - try: - self._logger.debug('Deleter called') - except: - pass - try: - self.deinitialize() - try: - self._logger.debug('Deinitialised') - except: - pass - except: - self._logger.debug('Did not deinitialise', exc_info=True) - try: - self._logger.debug('Instance deleted') - except: - pass - - @property - def data_folder(self): - """pathlib.Path: Get or set the path for data saving. Will create folder if not existing.""" - return self._data_folder - @data_folder.setter - def data_folder(self, path): - self._logger.debug('Got set data folder with: '+str(path)) - path = Path(path) - if path.is_file(): - path = path.parent - if not path.is_dir(): - path.mkdir(parents=True) - self._data_folder = path - self._logger.debug('Set data folder to: '+str(self.data_folder)) - - @property - def name(self): - '''str: Get or set the name.''' - return self._name - @name.setter - def name(self, name): - self._logger.debug('Setting name to: '+str(name)) - assert isinstance(name, str), 'Name must be a string' - self._name = name - self._logger.debug('Name set') - - @property - def model(self): - """str: Get or set the device model. - - Supported: - - 'ni_daq' for National Instruments DAQ devices (e.g. USB-6211). - - - This will determine which hardware API that is used. - - Must set before initialising the device and may not be changed for an initialised device. - """ - return self._model - @model.setter - def model(self, model): - self._logger.debug('Setting model to: '+str(model)) - assert not self.is_init, 'Can not change already initialised device model' - assert isinstance(model, str), 'Model type must be a string' - assert model.lower() in self._supported_models,\ - 'Model type not recognised, allowed: '+str(self._supported_models) - self._model = model - self._logger.debug('Model set') - - @property - def identity(self): - """str: Get or set the device and/or input. Model must be defined first. - - - - For model *ni_daq* this is 'device/input' eg. 'Dev1/ai1' for device 'Dev1' and analog input 1. Only - differential input is supported for NI DAQ. - - Must set before initialising the device and may not be changed for an initialised device. - """ - return self._identity - @identity.setter - def identity(self, identity): - self._logger.debug('Setting identity to: '+str(identity)) - assert not self.is_init, 'Can not change already initialised device' - assert isinstance(identity, str), 'Identity must be a string' - assert self.model is not None, 'Must define model first' - if self.model.lower() == 'ni_daq': - self._logger.debug('Using NI DAQ, checking validity by opening a task') - import nidaqmx as ni - t = ni.Task() - try: - t.ai_channels.add_ai_voltage_chan(identity) - self._identity = identity - except ni.DaqError: - self._logger.debug('Verification unsucessful', exc_info=True) - raise AssertionError('The identity was not found') - finally: - try: - self._logger.debug('Deleting task') - t.close() - del(t) - except: - self._logger.debug('Failed to delete task used to test identity', exc_info=True) - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - self._logger.debug('Identity set') - - @property - def is_init(self): - """bool: True if the device is initialised (and therefore ready to start).""" - return self._is_init - - def initialize(self): - """Initialise (make ready to start) the device. The model and identity must be defined.""" - self._logger.debug('Initialising') - assert not self.is_init, 'Already initialised' - assert not None in (self.model, self.identity), 'Must define model and identity before initialising' - if self.model.lower() == 'ni_daq': - import nidaqmx as ni - self._logger.debug('Using NI DAQ, create a task') - try: - self._ni_task = ni.Task(self.name) if self.name is not None else ni.Task() - except ni.DaqError: - self._logger.debug('Failed to create task', exc_info=True) - raise RuntimeError('Failed to initialise, may conflict with existing instance') - try: - self._ni_task.ai_channels.add_ai_voltage_chan(self.identity) - self._ni_task.timing.cfg_samp_clk_timing(rate=1000, sample_mode=ni.constants.AcquisitionType.CONTINUOUS) - self._logger.info('Successfully initialised'+(': '+self.name) if self.name is not None else '') - self._is_init = True - except ni.DaqError: - self._logger.debug('Failed to initialise', exc_info=True) - try: - self._ni_task.close() - self._ni_task = None - self._logger.debug('Closed the task') - except: - self._logger.debug('Failed to close task', exc_info=True) - raise RuntimeError('Failed to initialise, may conflict with existing instance') - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - def deinitialize(self): - """De-initialise the device. Will stop the acquisition if it is running.""" - self._logger.debug('De-initialising') - assert self.is_init, 'Not initialised' - if self.is_running: - self._logger.debug('Is running, stopping') - self.stop() - self._logger.debug('Stopped') - if self._ni_task is not None: - self._logger.debug('Found NI DAQ task, closing and removing') - try: - self._ni_task.close() - self._ni_task = None - self._logger.debug('Closed task') - except: - self._logger.exception('Failed to close task') - self._is_init = False - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - @property - def available_properties(self): - """tuple of str: Get all the available properties (settings) supported by this device.""" - assert self.is_init, 'Must be initialised' - if self.model.lower() == 'ni_daq': - return ('sample_rate', 'measurement_range', 'smoothing_parameter') - else: - self._log_warning('Forbidden model string defined.') - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - @property - def sample_rate(self): - """int or float: Get or set the sample rate (in Hz) of the device. Must initialise the device first.""" - assert self.is_init, 'Must initialise first' - self._logger.debug('Getting sample rate') - if self.model.lower() == 'ni_daq': - self._logger.debug('Using NI DAQ, trying to get') - return self._ni_task.timing.samp_clk_rate - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - @sample_rate.setter - def sample_rate(self, rate_hz): - self._logger.debug('Setting sample rate to (Hz): '+str(rate_hz)) - assert isinstance(rate_hz, (float,int)), 'Sample rate must be a scalar (float or int)' - assert self.is_init, 'Must initialise first' - assert not self.is_running, 'Cant change rate while running' - if self.model.lower() == 'ni_daq': - import nidaqmx as ni - self._logger.debug('Using NI DAQ, trying to set') - self._logger.debug('Checking valid rate') - assert 0 < rate_hz <= self._ni_task.timing.samp_clk_max_rate, 'Requested rate is not allowed, '\ - +'maximum rate is: '+str(self._ni_task.timing.samp_clk_max_rate) - try: - self._ni_task.timing.cfg_samp_clk_timing(rate=rate_hz,\ - sample_mode=ni.constants.AcquisitionType.CONTINUOUS) - self._logger.debug('Sampling rate set to: '+str(self._ni_task.timing.samp_clk_rate)) - except: - self._logger.exception('Failed to set sample rate: ') - raise - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - @property - def measurement_range(self): - """tuple, int or float: Get or set the measurement range (lower_limit, upper_limit). - - - If given as a scalar the range will be set to +- the supplied value. - """ - assert self.is_init, 'Must initialise first' - self._logger.debug('Getting measurement range') - if self.model.lower() == 'ni_daq': - self._logger.debug('Using NI DAQ, trying to get') - try: - maxval = self._ni_task.ai_channels[0].ai_max - minval = self._ni_task.ai_channels[0].ai_min - return (minval, maxval) - except: - self._logger.exception('Failed to get range') - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - @measurement_range.setter - def measurement_range(self, meas_range): - assert self.is_init, 'Must initialise first' - assert not self.is_running, 'Cant change range while running' - self._logger.debug('Setting measurement range to: '+str(meas_range)) - assert isinstance(meas_range,(int,float,tuple)), 'Input must be scalar (int/float) or a 2-tuple of scalars' - if isinstance(meas_range,tuple): - assert len(meas_range) == 2, 'Input must be scalar (int or float) or a 2-tuple of scalars' - assert all( isinstance(x,(int,float)) for x in meas_range ),\ - 'Input must be scalar (int or float) or a 2-tuple of scalars' - else: - meas_range = (-meas_range, meas_range) - self._logger.debug('Decoded input: '+str(meas_range)) - if self.model.lower() == 'ni_daq': - import nidaqmx as ni - self._logger.debug('Using NI DAQ, trying to set') - self._logger.debug('NOTE: nidaqmx is broken, must manually check if range is allowed (-10, 10)...') - assert min(meas_range)>=-10 and max(meas_range)<=10, 'Values must be <=10 and >=-10' - self._logger.debug('NOTE: Passed manual value check') - try: - self._logger.debug('Setting new values') - self._ni_task.ai_channels[0].ai_max = meas_range[1] - self._ni_task.ai_channels[0].ai_min = meas_range[0] - self._logger.debug('Range set to: '+str(self.measurement_range)) - except ni.DaqError: - self._logger.exception('Failed to set new values, this may cause strange behaviour. De-init and re-init.') - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - @property - def smoothing_parameter(self): - """int or float: Get or set the smoothing parameter. It roughly corresponds to the number of samples to average. - - - Exponential smoothing is used. smoothing_parameter is the *inverse* of 'alpha'. Each smoothed value s is - defined from the measurements x by: - - ``s[n] = alpha*x[n] + (1-alpha)*s[n-1]; s[0] = x[0]`` - """ - return self._smoothing_parameter - @smoothing_parameter.setter - def smoothing_parameter(self, param): - assert isinstance(param, (int, float)), 'Parameter must be scalar (float or int)' - assert param > 0, 'Parameter must be >0' - self._smoothing_parameter = param - self._logger.debug('Smoothing parameter set to '+str(param)) - - @property - def instant_power(self): - """float: Get the latest raw measurement.""" - if not self.is_running: return None - self._get_update_from_hardware() - return self._instant_power - - @property - def smooth_power(self): - """float: Get the current smoothed measurement (see smoothing_parameter).""" - if not self.is_running: return None - self._get_update_from_hardware() - return self._smooth_power - - @property - def is_running(self): - """bool: True if device is currently acquiring data.""" - return self._is_running - - def start(self): - """Start the acquisition. Device must be initialised. Data will only be saved if data_folder is set.""" - assert self.is_init, 'Must initialise first' - assert not self.is_running, 'Acquisition already running' - self._logger.debug('Got start command') - if self.data_folder is not None: - self._logger.debug('Data folder exists, creating file and header') - self._create_data_file() - else: - self._logger.debug('No save path set') - if self.model.lower() == 'ni_daq': - import nidaqmx as ni - self._logger.debug('Using NI DAQ, setting up callback') - cb_count = int(min(1000, max(1, self.sample_rate/10))) #Typically 10Hz, min 1 and max 1000 per callback - - def _ni_buffering_callback(task_handle, event_type, number_of_samples, callback_data): - self._logger.debug('Got a callback') - try: - self._get_update_from_hardware() - except: - logging.error('Could not update from hardware') - return 0 - - try: - self._ni_task.register_every_n_samples_acquired_into_buffer_event(cb_count, _ni_buffering_callback) - self._logger.debug('Registered event every n='+str(cb_count)+' samples') - except ni.DaqError: - self._logger.debug('Unable to register event, trying to unregister and try again') - self._ni_task.register_every_n_samples_acquired_into_buffer_event(cb_count, None) - self._ni_task.register_every_n_samples_acquired_into_buffer_event(cb_count, _ni_buffering_callback) - self._logger.debug('Registered event every n='+str(cb_count)+' samples') - self._ni_task.start() - self._logger.info('Started acquisition from receiver') - self._is_running = True - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - def stop(self): - """Stop the acquisition. Will ensure all data in the buffer is read before stopping.""" - assert self.is_running, 'Acquisition is not running' - self._logger.debug('Got stop command') - if self.model.lower() == 'ni_daq': - self._logger.debug('Using NI DAQ, stopping task') - self._is_running = False - self._ni_task.stop() - self._logger.debug('Stopped task') - self._logger.info('Stopped acquisition from receiver') - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - def _get_update_from_hardware(self): - """PRIVATE: Read all available data from the device and call _update_stored_values. Save if data_folder is set.""" - self._logger.debug('Got hardware update command') - if self.model.lower() == 'ni_daq': - import nidaqmx as ni - self._logger.debug('Using NI DAQ, reading all available') - try: - data = self._ni_task.read(ni.constants.READ_ALL_AVAILABLE) - self._logger.debug('Data of length '+str(len(data))+' and class '+str(type(data))) - except: - data = None - if self.is_running: - self._logger.exception('Failed to read data') - else: - self._loger.debug('Got a callback after stop command') - if data: - try: - self._update_stored_values(data) - except: - self._logger.exception('Failed to update stored values') - if self.data_folder is not None: - try: - self._write_data_to_data_file(data) - except: - self._logger.exception('Failed to save update') - else: - raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) - - def _update_stored_values(self, data): - """PRIVATE: Update the stored instantaneous and smoothed measurement.""" - self._logger.debug('Got update request with length '+str(len(data))) - if None in (self._smooth_power, self._instant_power): #No old data, need to initialise - self._logger.debug('No previous values') - self._instant_power = data[-1] #Last read data saved here - if len(data) == 1: #If only one point - self._smooth_power = data[0] - else: - k = len(data) - a = 1/self._smoothing_parameter - data = np.array(data) - facs = (1-a)**np.arange(k) #Smoothing factors - self._smooth_power = a*np.sum( facs[:-1]*data[:-1] ) + facs[-1]*data[-1] - else: #Doing a normal update - self._logger.debug('Previous smooth and instant powers are: '\ - +str(self._smooth_power)+' '+str(self._instant_power)) - self._instant_power = data[-1] #Last read data saved here - k = len(data) - a = 1/self._smoothing_parameter - if k == 1: #If only one point - self._smooth_power = a*data[0] + (1-a)*self._smooth_power - else: - data = np.array(data) - facs = (1-a)**np.arange(k+1) #Smoothing factors - self._smooth_power = a*np.sum( facs[:-1]*data ) + facs[-1]*self._smooth_power - - self._logger.debug('Smooth and instant power are now: '+str(self._smooth_power)+' '+str(self._instant_power)) - - def _write_data_to_data_file(self, data): - """PRIVATE: Write data to the data file.""" - self._logger.debug('Writing to data file, got '+str(len(data))+' measurements') - assert self._data_file is not None, 'No logfile is defined...' - with open(self._data_file, 'ba') as file: - dpack = pack_data('%df' % len(data), *data) #Create binary (dobule) representation of data - file.write(dpack) - - def _create_data_file(self): - """"PRIVATE: Create data file and write the header.""" - assert self.data_folder is not None, 'No save path here...' - self._logger.debug('Creating data file') - timestamp = datetime.utcnow() - filename = timestamp.strftime('%Y-%m-%dT%H%M%S') + '_Receiver.dat' - self._data_file = self.data_folder / Path(filename) - self._logger.debug('File: ' + str(filename)) - header = 'TIME: ' + timestamp.isoformat() + '; ' \ - +'NAME: ' + str(self.name) + '; ' \ - +'MODEL: ' + str(self.model) + '; ' \ - +'IDENTITY: ' + str(self.identity) + '; ' \ - +'SAMPLE_RATE: ' + str(self.sample_rate) + '; ' \ - +'MEASUREMENT_RANGE: ' + str(self.measurement_range) +'; ' \ - +'FORMAT: ' + 'STRUCT_PACK_FLOAT32' + ';\n' - self._logger.debug('Header: ' + header) - with open(self._data_file, 'a') as file: - file.write(header) + offset[c] = offs + + # Unpack channels to individual arrays + img_red = img[offset['R'][0]::2, offset['R'][1]::2] + img_green_1 = img[offset['G1'][0]::2, offset['G1'][1]::2] + img_green_2 = img[offset['G2'][0]::2, offset['G2'][1]::2] + img_blue = img[offset['B'][0]::2, offset['B'][1]::2] + + if downsize: + # Use red and blue as is, average green. Gives half outsize. + out = np.zeros((height//2, width//2, 3), dtype=datatype) + # Red green and blue channels. Green has a thrid dim for the two pixels in each group. + out[:, :, 0] = img_red + out[:, :, 1] = ((img_green_1.astype(np.float32) + img_green_2.astype(np.float32))/2).astype(datatype) + out[:, :, 2] = img_blue + + else: + # Bilinear interpolation to give approximate full outsize. + out = np.zeros((height, width, 3), dtype=datatype) + + def insert_red_blue(oy, ox, image): + out = np.zeros(np.array(image.shape)*2, dtype=np.float32) + # Pad array to deal with edges + image = np.pad(image, 1, mode='edge').astype(np.float32) + # Set output array according to bilinear interpolation + out[oy::2, ox::2] = image[1:-1, 1:-1] + out[(oy+1)%2::2, ox::2] = (image[(oy+1)%2:(oy+1)%2-2, 1:-1] + + image[(oy+1)%2+1:((oy+1)%2-1 or None), 1:-1])/2 + out[oy::2, (ox+1)%2::2] = (image[1:-1, (ox+1)%2:(ox+1)%2-2] + + image[1:-1, (ox+1)%2+1:((ox+1)%2-1 or None)])/2 + out[(oy+1)%2::2, (ox+1)%2::2] = (image[(oy+1)%2:(oy+1)%2-2, (ox+1)%2+1:((ox+1)%2-1 or None)] + + image[(oy+1)%2+1:((oy+1)%2-1 or None):, (ox+1)%2+1:((ox+1)%2-1 or None)] + + image[(oy+1)%2:(oy+1)%2-2, (ox+1)%2:(ox+1)%2-2] + + image[(oy+1)%2+1:((oy+1)%2-1 or None), (ox+1)%2:(ox+1)%2-2])/4 + return out + + def insert_green(oy1, ox1, image1, oy2, ox2, image2): + out = np.zeros(np.array(image1.shape)*2, dtype=np.float32) + # Pad arrays to deal with edges + image1 = np.pad(image1, 1, mode='edge').astype(np.float32) + image2 = np.pad(image2, 1, mode='edge').astype(np.float32) + out[oy1::2, ox1::2] = image1[1:-1, 1:-1] + out[(oy1+1)%2::2, (ox1+1)%2::2] = image2[1:-1, 1:-1] + out[(oy1+1)%2::2, ox1::2] = (image1[(oy1+1)%2:(oy1+1)%2-2, 1:-1] + + image1[(oy1+1)%2+1:((oy1+1)%2-1 or None), 1:-1] + + image2[1:-1, (ox2+1)%2+1:((ox2+1)%2-1 or None)] + + image2[1:-1, (ox2+1)%2:(ox2+1)%2-2])/4 + + out[oy1::2, (ox1+1)%2::2] = (image1[1:-1, (ox1+1)%2:(ox1+1)%2-2] + + image1[1:-1, (ox1+1)%2+1:((ox1+1)%2-1 or None)] + + image2[(oy2+1)%2+1:((oy2+1)%2-1 or None), 1:-1] + + image2[(oy2+1)%2:(oy2+1)%2-2, 1:-1])/4 + return out + + # Debayer/interpolate red and blue channels + out[:, :, 0] = insert_red_blue(offset['R'][0], offset['R'][1], img_red).astype(datatype) + out[:, :, 2] = insert_red_blue(offset['B'][0], offset['B'][1], img_blue).astype(datatype) + + # Debayer/interpolate green channel + out[:, :, 1] = insert_green(offset['G1'][0], offset['G1'][1], img_green_1, + offset['G2'][0], offset['G2'][1], img_green_2,).astype(datatype) + + return out diff --git a/pypogs/hardware/hardware_mount.py b/pypogs/hardware/hardware_mount.py new file mode 100644 index 0000000..4597e3e --- /dev/null +++ b/pypogs/hardware/hardware_mount.py @@ -0,0 +1,1348 @@ +"""Mount interfaces +====================== + +Current harware support: + - :class:`pypogs.Mount`: 'Celestron' for Celestron, Orion and SkyWatcher telescopes (using NexStar serial protocol). No additional + packages required. Tested with Celestron model CPC800. + - :class:`pypogs.Mount`: 'ASCOM' for ASCOM-enabled mounts. Requires ASCOM platform and mount driver. + +This is Free and Open-Source Software originally written by Gustav Pettersson at ESA. + +License: + Copyright 2019 the European Space Agency + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +# Standard imports: +from pathlib import Path +import logging +from time import sleep, time as timestamp +from datetime import datetime +from threading import Thread, Event +import pythoncom +from struct import pack as pack_data +from enum import Enum + +# External imports: +import numpy as np +import serial + +class Mount: + """Control a telescope gimbal mount. + + To initialise a Mount a *model* (determines hardware interface) and *identity* (identifying the specific device) + must be given. If both are given to the constructor the Mount will be initialised immediately (unless + auto_init=False is passed). Manually initialise with a call to Mount.initialize(); release hardware with a call to + Mount.deinitialize(). + + After the Mount is initialised, the gimbal angles and rates may be read and commanded. Several properties (e.g + maximum angles and rates) may be set. + + Args: + model (str, optional): The model used to determine the the hardware control interface. + Supported: + 'Celestron' for Celestron NexStar and Orion/SkyWatcher SynScan (all the same) hand controller communication over serial. + 'ASCOM' for ASCOM-enabled telescope mounts. + identity (str or int, optional): String or int identifying the device. For model *Celestron* this can either be + a string with the serial port (e.g. 'COM3' on Windows or '/dev/ttyUSB0' on Linux) or an int with the index + in the list of available ports to use (e.g. identity=0 i if only one serial device is connected.) + For model *ASCOM* this can either be left blank to invoke the ASCOM telescope selection menu, or may specify + a specific installed ASCOM driver by (case sensitive) name (e.g. DeviceHub, Celestron, Simulator, SkyWatcher, etc). + name (str, optional): Name for the device. + auto_init (bool, optional): If both model and identity are given when creating the Mount and auto_init + is True (the default), Mount.initialize() will be called after creation. + debug_folder (pathlib.Path, optional): The folder for debug logging. If None (the default) + the folder *pypogs*/logs will be used/created. + + Example: + :: + + # Create instance (will auto initialise) + mount = pypogs.Mount(model='Celestron', identity='COM3', name='CPC800') + # Move to position + mount.move_to_alt_az(30, 10) #degrees; by default blocks until finished + # Set gimbal rates + mount.set_rate_alt_az(0, -1.5) #degrees per second + # Wait for a while + time.sleep(2) + # Stop moving + mount.stop() + # Disconnect from the mount + mount.deinitialize() + + Note: + The Mount class allows two modes of control for moving to positions. The default is rate_control=True, where + this class will continuously send rate commands until the desired position is reached. It is possible to use the + internal motion controller in the mount by passing rate_control=False. However, it is slow and implements + backlash compensation. In our testing the accuracy difference is negligible so the default is recommended. + """ + + _supported_models = ('Celestron','ASCOM','iOptron AZMP') + _default_model = 'ASCOM' + + + def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_folder=None, **properties): + """Create Mount instance. See class documentation.""" + # Logger setup + self._debug_folder = None + if debug_folder is None: + self.debug_folder = Path(__file__).parent.parent / 'debug' + else: + self.debug_folder = debug_folder + self._logger = logging.getLogger('pypogs.hardware.Mount') + if not self._logger.hasHandlers(): + # Add new handlers to the logger if there are none + self._logger.setLevel(logging.DEBUG) + # Console handler at INFO level + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + # File handler at DEBUG level + fh = logging.FileHandler(self.debug_folder / 'pypogs.txt') + fh.setLevel(logging.DEBUG) + # Format and add + formatter = logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s') + fh.setFormatter(formatter) + ch.setFormatter(formatter) + self._logger.addHandler(fh) + self._logger.addHandler(ch) + + # Start of constructor + self._logger.debug('Mount called with model=' + str(model) + ' identity=' + str(identity) + ' name=' \ + + str(name) + ' auto_init=' + str(auto_init)) + + self._serial_baud_rate = { + 'Celestron': 9600, + 'iOptron AZMP': 115200, + } + self._serial_is_init = False + self._model = None + self._identity = None + self._name = 'UnnamedMount' + self._is_init = False + self._max_speed = (4.0, 4.0) #(alt,azi) degrees/sec + self._alt_limit = (-5, 95) #limit degrees + self._azi_limit = (None, None) #limit degees + self._home_pos = (0, 0) #Home position + self._alt_zero = 0 #Amount to subtract from alt. + self._is_sidereal_tracking = False + self._axis_directions = (1, 1) #set to 1 to use mount default axis direction, -1 to invert direction + self._supported_models_lowercase = [x.lower() for x in self._supported_models] + # Only used for model Celestron + self._serial_port = None + # Only used for model iOptron AZMP + self._azmp_command_modes = {b'5035': 'normal', b'9035': 'special'} + self._azmp_states = { + '0': 'stopped at non-zero pos', + '1': 'tracking with PEC disabled', + '2': 'slewing', + '3': 'autoguiding', + '4': 'meridian flipping', + '5': 'tracking with PEC enabled', + '6': 'parked', + '7': 'stopped at zero pos' + } + self._azmp_command_mode_names = self._azmp_command_modes.values() + self._azmp_command_mode_code = b'' + # Only used for model ASCOM + self._ascom_telescope = None + self._ascom_scope_alt_axis = 1 + self._ascom_scope_azi_axis = 0 + self._ascom_availableRatesAlt = [0] + self._ascom_availableRatesAzi = [0] + self._ascom_driver_handler = None + # Thread for rate control + self._control_thread = None + self._control_thread_stop = True + # Cache of the state of the mount + self._state_cache = {'alt': 0.0, 'azi': 0.0, 'alt_rate': 0.0, 'azi_rate': 0.0} + if name is not None: + self.name = name + if model is not None: + self.model = model + if identity is not None: + self.identity = identity + if self.model is not None: + self.initialize() + + available_properties = self.available_properties + for property_name in properties: + if property_name in available_properties: + self._logger.debug('Setting mount property "%s" to value "%s"' % (property_name, properties[property_name])) + try: + setattr(self, property_name, properties[property_name]) + except: + self._logger.warning('Failed to set mount property "%s" to value "%s"' % (property_name, properties[property_name])) + + # Try to get Python to clean up the object properly + import atexit, weakref + atexit.register(weakref.ref(self.__del__)) + self._logger.info('Mount instance created with name: ' + self.name + '.') + + def __del__(self): + """Destructor, try to stop the mount and disconnect.""" + try: + self._logger.debug('Destructor called, try stop moving and disconnecting') + except: + pass + try: + self.stop() + self._logger.debug('Stopped') + except: + pass + try: + self.deinitialize() + self._logger.debug('Deinitialised') + except: + pass + try: + self._logger.debug('Destructor finished') + except: + pass + + @property + def debug_folder(self): + """pathlib.Path: Get or set the path for debug logging. Will create folder if not + existing.""" + return self._debug_folder + @debug_folder.setter + def debug_folder(self, path): + # Do not do logging in here! This will be called before the logger is set up + path = Path(path) + if path.is_file(): + path = path.parent + if not path.is_dir(): + path.mkdir(parents=True) + self._debug_folder = path + + @property + def state_cache(self): + """dict: Get cache with the current state of the Mount. Updates on calls to get_alt_az() and set_rate_alt_az(). + + Keys: + azi: float, alt: float, azi_rate: float, alt_rate: float + """ + if self.is_init: + return self._state_cache + else: + return None + + @property + def name(self): + """str: Get or set the name.""" + return self._name + @name.setter + def name(self, name): + self._logger.debug('Setting name to: '+str(name)) + self._name = str(name) + self._logger.debug('Name set to '+str(self.name)) + + @property + def model(self): + """str: Get or set the device model. + + Supported: + - 'Celestron' for Celestron NexStar and Orion/SkyWatcher SynScan hand controllers over serial. + - 'ASCOM' for ASCOM-enabled telescope mounts. + - This will determine which hardware interface is used. + - Must set before initialising the device and may not be changed for an initialised device. + """ + return self._model + @model.setter + def model(self, model): + assert not self.is_init, 'Can not change already initialised device model' + model = str(model).lower() + self._logger.debug('Setting model to: '+model) + assert model.lower() in self._supported_models_lowercase,\ + 'Model type not recognised, allowed: '+str(self._supported_models) + self._model = model + self._logger.debug('Model set to '+str(self.model)) + + @property + def identity(self): + """str: Get or set the device and/or input. Model must be defined first. + + - For model *celestron* or *iptron azmp* this can either be a string with the serial port (e.g. 'COM3' on + Windows or '/dev/ttyUSB0' on Linux) or an int with the index in the list of available ports to use (e.g. + identity=0 i if only one serial device is connected.) + - For model *ASCOM* this can either be left blank to invoke the ASCOM telescope selection menu, or may specify + a specific installed ASCOM driver by name (case sensitive) (e.g. DeviceHub, Celestron, Simulator, SkyWatcher, etc). + - Must set before initialising the device and may not be changed for an initialised device. + + Raises: + AssertionError: if unable to connect to and verify identity of the mount. + """ + return self._identity + @identity.setter + def identity(self, identity): + self._logger.debug('Mount identity setter called with "'+str(identity)+'"') + assert not self.is_init, 'Can not change already initialised device' + assert self.model is not None, 'Must define model first' + + if self.model.lower() == 'celestron': + self._logger.debug('Using %s, try to open serial port and confirm model' % self.model) + serial_port_name = self._serial_find_port(identity) if identity.isnumeric() else identity + self._logger.debug('Opening serial port: ' + str(serial_port_name)) + r = self._serial_test(serial_port_name, test_command='m', baud=9600, nbytes=2) + assert r is not None and len(r)>0, 'Serial port "%s" test failed' % (serial_port_name) + assert len(r)==2 and r[1] == ord('#'), 'Did not get the expected response from the device' + self._logger.debug('Setting identity to: '+serial_port_name) + self._identity = serial_port_name + + elif self.model.lower() == 'ioptron azmp': + self._logger.debug('Using %s with string identity, try to open and check model' % self.model) + serial_port_name = self._serial_find_port(identity) if identity.isnumeric() else identity + self._logger.debug('Opening serial port: ' + str(serial_port_name)) + r = self._serial_test(serial_port_name, test_command=':MountInfo#', baud=115200, nbytes=4) + assert r is not None and len(r)>0, 'Serial port "%s" test failed' % (serial_port_name) + assert len(r)==4 and r == b'5035' or r == b'9035', 'Did not get the expected response from the device' + self._logger.debug('Setting identity to: '+serial_port_name) + self._identity = serial_port_name + + elif self.model.lower() == 'ascom': + self._logger.debug('Attempting to connect to ASCOM device "'+str(identity)+'"') + if self._ascom_driver_handler is None: + self._logger.debug('Loading ASCOM win32com device handler') + import win32com.client + self._ascom_driver_handler = win32com.client + ascomDriverName = str() + if identity is not None: + self._logger.debug('Specified identity: "'+str(identity)+'" ['+str(len(identity))+']') + if identity.startswith('ASCOM'): + ascomDriverName = identity + else: + ascomDriverName = 'ASCOM.'+str(identity)+'.Telescope' + else: + ascomSelector = self._ascom_driver_handler.Dispatch("ASCOM.Utilities.Chooser") + ascomSelector.DeviceType = 'Telescope' + ascomDriverName = ascomSelector.Choose('None') + self._logger.info('Selected telescope driver: ' + ascomDriverName) + if not ascomDriverName: + self._logger.debug('User canceled telescope selection') + return False + try: + identity = ascomDriverName.replace('ASCOM.','').replace('.Telescope','') + except: + identity = None + if not ascomDriverName: + raise AssertionError('Failed to identify ASCOM telescope') + #try: + self._ascom_telescope = self._ascom_driver_handler.Dispatch(ascomDriverName) + self._ascom_telescope = None + #except: + # raise AssertionError('Failed to connect to ASCOM telescope: '+str(ascomDriverName)) + self._identity = identity + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + self._logger.debug('Identity set to: '+str(self.identity)) + + @property + def is_init(self): + """bool: True if the device is initialised (and therefore ready to control).""" + if not self.model: return False + if self.model.lower() in self._supported_models_lowercase: + return self._is_init + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + @property + def available_properties(self): + """tuple of str: Get all the available properties (settings) supported by this device.""" + if not self.is_init: + return None + elif self.model.lower() == 'celestron': + return ('zero_altitude', 'home_alt_az', 'max_rate', 'alt_limit', 'azi_limit') + elif self.model.lower() == 'ioptron azmp': + return ('zero_altitude', 'home_alt_az', 'max_rate', 'alt_limit', 'azi_limit') + elif self.model.lower() == 'ascom': + return ('zero_altitude', 'home_alt_az', 'max_rate', 'alt_limit', 'azi_limit', 'axis_directions') + else: + self._log_warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + @property + def zero_altitude(self): + """float: Get or set the zero altitude angle (degrees). Default 0. + + Normally the mount is initialised with the telescope level. In this case zero_altitude is 0. However, if the + mount is e.g. initialised with the telescope pointing straight up, zero_altitude must be set to +90. + """ + return self._alt_zero + @zero_altitude.setter + def zero_altitude(self, angle): + self._logger.debug('Got set zero altitude with: '+str(angle)) + self._alt_zero = float(angle) + self._logger.debug('Alt zero set to: '+str(self._alt_zero)) + + @property + def home_alt_az(self): + """tuple of float: Get or set the home position (altitude, azimuth) in degrees. Default (0, 0)""" + return self._home_pos + @home_alt_az.setter + def home_alt_az(self, pos): + self._logger.debug('Got set home pos with: '+str(pos)) + assert pos in (int, float) or len(pos) == 2, 'Must be scalar or array of length 2' + if pos in (int, float): + pos = tuple([float(x) for x in pos]) + else: + pos = (float(pos), float(pos)) + self._home_pos = pos + self._logger.debug('Home pos set to: '+str(self.home_alt_az)) + + @property + def max_rate(self): + """tuple of float: Get or set the max slew rate (degrees per second) for the axes (altitude, azimith). + Default (4.0, 4.0). + + If a scalar is set, both axes' rates will be set to this value. + """ + return self._max_speed + @max_rate.setter + def max_rate(self, maxrate): + self._logger.debug('Got set max rate with: '+str(maxrate)) + assert maxrate in (int, float) or len(maxrate) == 2, 'Must be scalar or array of length 2' + if maxrate in (int, float): + maxrate = tuple([float(x) for x in maxrate]) + else: + maxrate = (float(maxrate[0]), float(maxrate[1])) + self._max_speed = maxrate + self._logger.debug('Set max to: '+str(self.max_rate)) + + @property + def alt_limit(self): + """tuple of float: Get or set the altitude limits (degrees) where the mount can safely move. + May be set to None. Default (-5, 95). Not enforced when slewing (set_rate) the mount. + """ + return self._alt_limit + @alt_limit.setter + def alt_limit(self, altlim): + if altlim is None: + self._logger.debug('Setting alt limit to None') + self._alt_limit = (None, None) + else: + assert isinstance(altlim, (tuple, list)) and len(altlim)==2, 'Must be 2-tuple' + self._logger.debug('Got set alt limits with: '+str(altlim)) + self._alt_limit = (float(altlim[0]) if altlim[0] is not None else None \ + , float(altlim[1]) if altlim[1] is not None else None) + self._logger.debug('Set alt limit to: '+str(self._alt_limit)) + + @property + def azi_limit(self): + """tuple of float: Get or set the azimuth limits (degrees) where the mount can safely move. + May be set to None. Default (None, None). Not enforced when slewing (set_rate) the mount. + """ + return self._azi_limit + @azi_limit.setter + def azi_limit(self, azilim): + if azilim is None: + self._logger.debug('Setting azi limit to None') + self._azi_limit = (None, None) + assert isinstance(azilim, (tuple, list)) and len(azilim)==2, 'Must be 2-tuple' + self._logger.debug('Got set azi limits with: '+str(azilim)) + self._azi_limit = (float(azilim[0]) if azilim[0] is not None else None \ + , float(azilim[1]) if azilim[1] is not None else None) + self._logger.debug('Set azi limit to: '+str(self._azi_limit)) + + @property + def axis_directions(self): + """tuple of float: Get or set the azimuth limits (degrees) where the mount can safely move. + May be set to None. Default (None, None). Not enforced when slewing (set_rate) the mount. + """ + return self._axis_directions + @axis_directions.setter + def axis_directions(self, axis_dirs): + if axis_dirs is None: + self._logger.debug('Setting axis directions to 1') + self._axis_directions = (1, 1) + assert isinstance(axis_dirs, (tuple, list)) and len(axis_dirs)==2, 'Must be 2-tuple' + assert axis_dirs[0] in [-1, 1] and axis_dirs[1] in [-1, 1], 'Axis directions must be 1 or -1' + self._logger.debug('Got set axis directions with: '+str(axis_dirs)) + self._axis_directions = (int(axis_dirs[0]) if axis_dirs[0] is not None else None \ + , int(axis_dirs[1]) if axis_dirs[1] is not None else None) + self._logger.debug('Set axis directions to: '+str(self._axis_directions)) + + @property + def is_sidereal_tracking(self): + """bool: True if the device is in sidereal tracking mode.""" + if self._is_init: + if self.model.lower() == 'celestron': + tracking_mode = self._serial_query('t', ord('#')) + self._is_sidereal_tracking = (tracking_mode is not None and tracking_mode[0] == '1') + elif self.model.lower() == 'ioptron azmp': + # Sidereal tracking (and state query) is only available in normal commanding mode. + if self._azmp_command_mode == 'normal': + mount_state = self._serial_query(':GLS#', ord('#')).decode('ASCII') + # The 18th digit indicates system status: 1 = tracking with PEC disabled, 5 means tracking with PEC enabled + status = mount_state[14] + self._logger.debug('Mount tracking state: "%s"' % status) + self._is_sidereal_tracking = (status == '1' or status == '5') + else: + self._is_sidereal_tracking = False + elif self.model.lower() == 'ascom': + self._is_sidereal_tracking = (self._ascom_telescope is not None and self._ascom_telescope.Tracking) + return self._is_sidereal_tracking + @is_sidereal_tracking.setter + def is_sidereal_tracking(self, enable_sidereal): + """bool: Set to True to enable sidereal tracking mode.""" + elf._logger.debug('Setting is_sidereal_tracking to '+str(enable_sidereal)) + if enable_sidereal: + self._logger.debug('Enabling sidereal tracking') + self.start_sidereal_tracking() + else: + self._logger.debug('Disabling sidereal tracking') + self.stop_sidereal_tracking() + + def initialize(self): + """Initialise (make ready to start) the device. The model and identity must be defined.""" + self._logger.debug('Initialising') + assert not self.is_init, 'Already initialised' + #assert not None in (self.model, self.identity), 'Must define model and identity before initialising' + assert not None in (self.model, ), 'Must define model before initialising' + if self.model.lower() == 'celestron': + assert self.identity is not None, 'Must define identity before initialising' + self._logger.debug('Using Celestron, try to initialise') + self._logger.debug('Opening serial port '+self.identity) + self._baud = 9600 + self._eol_byte = b'#' + self._serial_port_open(self.identity) + self._logger.debug('Opened serial port, sending test command') + res = self._serial_query('m') + self._logger.debug('Mount responded with: ' + str(res)) + self._logger.debug('Set tracking to off') + self._cel_tracking_off() + self._is_init = True + + elif self.model.lower() == 'ioptron azmp': + assert self.identity is not None, 'Must define identity before initialising' + self._logger.debug('Using %s, try to initialise' % self.model) + self._logger.debug('Opening serial port '+self.identity) + self._baud = 115200 + self._eol_byte = b'#' + self._serial_port_open(self.identity) + self._logger.debug('Opened serial port, checking command mode') + # Command mode persists across resets. Expect either mode initially, and try to get to special mode. + self._azmp_get_command_mode() + assert self._azmp_command_mode in self._azmp_command_mode_names, 'Failed to get initial mount commanding mode.' + self._logger.debug('Initial command mode: %s' % self._azmp_command_mode) + if self._azmp_command_mode == 'normal': + self._logger.debug('Ensure sidereal tracking is off and transition to special') + self.stop_sidereal_tracking() + self._azmp_set_command_mode('special') + assert self._azmp_command_mode == 'special', 'Unable to switch mount to special command mode.' + self._is_init = True + + elif self.model.lower() == 'ascom': + if self._ascom_telescope is not None: + raise RuntimeError('There is already an ASCOM telescope object here') + self._logger.debug('Attempting to connect to ASCOM device "'+str(self.identity)+'"') + if self._ascom_driver_handler is None: + import win32com.client + self._ascom_driver_handler = win32com.client + if self.identity is not None: + self._logger.debug('Specified identity: "'+str(self.identity)+'" ['+str(len(self.identity))+']') + if self.identity.startswith('ASCOM'): + ascomDriverName = self.identity + else: + ascomDriverName = 'ASCOM.'+str(self.identity)+'.Telescope' + else: + ascomSelector = self._ascom_driver_handler.Dispatch("ASCOM.Utilities.Chooser") + ascomSelector.DeviceType = 'Telescope' + ascomDriverName = ascomSelector.Choose('None') + self._logger.info("Selected telescope driver: "+ascomDriverName) + if not ascomDriverName: + self._logger.debug('User canceled telescope selection') + return False + self._identity = ascomDriverName.replace('ASCOM.','').replace('.Telescope','') + assert ascomDriverName, 'Unable to identify ASCOM telescope.' + assert self._ascom_driver_handler is not None, 'Unable to access win32com driver handler' + self._logger.info('Loading ASCOM telescope driver: '+ascomDriverName) + self._ascom_telescope = self._ascom_driver_handler.Dispatch(ascomDriverName) + assert self._ascom_telescope is not None, 'Failed to intialize ASCOM telescope' + assert hasattr(self._ascom_telescope, 'Connected'), "Unable to access telescope driver" + self._logger.debug('Connecting to telescope') + self._ascom_telescope.Connected = True + assert self._ascom_telescope.Connected, "Failed to connect to telescope" + self._logger.debug('Connected to ASCOM telescope') + if hasattr(self._ascom_telescope, 'CanSetTracking') and self._ascom_telescope.CanSetTracking: + self._ascom_telescope.Tracking = False #turn off tracking + try: + self._ascom_canSlewAltAz = self._ascom_telescope.CanSlewAltAz + except: + self._ascom_canSlewAltAz = False + max_speed = [0, 0] + for axis in [0, 1]: + self._logger.debug('axis ' + str(axis) + ' rate count: ' + str(self._ascom_telescope.AxisRates(axis).Count)) + for i in range(1, self._ascom_telescope.AxisRates(axis).Count+1): + self._logger.debug('axis rate ' + str(i) + ' min: ' + str(self._ascom_telescope.AxisRates(axis).Item(i).Minimum) + ', max: ' + str(self._ascom_telescope.AxisRates(axis).Item(i).Maximum)) + max_speed[axis] = self._ascom_telescope.AxisRates(axis).Item(i).Maximum + for i in range(1, self._ascom_telescope.AxisRates(self._ascom_scope_alt_axis).Count+1): + self._ascom_availableRatesAlt.append(float(self._ascom_telescope.AxisRates(self._ascom_scope_alt_axis).Item(i).Maximum)) + for i in range(1, self._ascom_telescope.AxisRates(self._ascom_scope_azi_axis).Count+1): + self._ascom_availableRatesAzi.append(float(self._ascom_telescope.AxisRates(self._ascom_scope_azi_axis).Item(i).Maximum)) + self.max_rate = (max_speed[self._ascom_scope_alt_axis], max_speed[self._ascom_scope_azi_axis]) + self._is_init = True + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + if self._is_init: + self._logger.info('Mount initialised.') + try: + self.get_alt_az() #Get cache to update + except AssertionError: + self._logger.debug('Failed to set state cache', exc_info=True) + else: + self._logger.info('Mount not initialised.') + + + def deinitialize(self): + """De-initialise the device and release hardware (serial port). Will stop the mount if it is moving.""" + self._logger.debug('De-initialising') + assert self.is_init, 'Not initialised' + try: + self._logger.debug('Stopping mount') + self.stop() + except: + self._logger.debug('Did not stop', exc_info=True) + if self.model.lower() == 'celestron': + self._logger.debug('Using celestron, closing and deleting serial port') + self._serial_port_close() + self._is_init = False + self._logger.info('Mount deinitialised') + elif self.model.lower() == 'ioptron azmp': + self._logger.debug('Using iOptron AZMP, reverting mount commanding mode to normal') + self._azmp_set_command_mode('normal') + self._logger.debug('Closing and deleting serial port') + self._serial_port_close() + self._is_init = False + self._logger.info('Mount deinitialised') + elif self.model.lower() == 'ascom': + self._logger.debug('Disconnecting ASCOM telescope mount') + if self._ascom_telescope is not None: + try: + if self._ascom_telescope.Connected: + self._ascom_telescope.AbortSlew() + self._ascom_telescope.Connected = False + except: + pass + self._is_init = False + pythoncom.CoUninitialize() + self._ascom_telescope = None + self._logger.info('Mount deinitialised') + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + @property + def is_moving(self): + """Returns True if the mount is currently moving.""" + assert self.is_init, 'Must be initialised' + self._logger.debug('Got is moving request, checking thread') + if self._control_thread is not None and self._control_thread.is_alive(): + self._logger.debug('Has active control thread') + return True + if self.model.lower() == 'celestron': + self._logger.debug('Using celestron, asking if moving') + ret = [None] + def _is_moving_to(ret): + self._serial_send_text_command('L') + ret[0] = self._serial_read_to_eol() + t = Thread(target=_is_moving_to, args=(ret,)) + t.start() + t.join() + moving = not ret[0] == b'0' + self._logger.debug('Mount returned: ' + str(ret[0]) + ', is moving: ' + str(moving)) + return moving + + elif self.model.lower() == 'ioptron azmp': + is_moving = False + self._logger.debug('Using %s in %s command mode, asking if moving' % (self.model, self._azmp_command_mode)) + if self._azmp_command_mode == 'special': + azi_axis_rate = self._serial_query(':Q0#').decode('ASCII') + alt_axis_rate = self._serial_query(':Q1#').decode('ASCII') + self._logger.debug('azi_axis_rate: "%s", alt_axis_rate: "%s"' % (azi_axis_rate, alt_axis_rate)) + try: + is_moving = (int(azi_axis_rate or 0) != 0 or int(alt_axis_rate or 0) != 0) + except: + raise AssertionError('invalid rate query response (azi_axis_rate: "%s", alt_axis_rate: "%s")' % \ + (azi_axis_rate, alt_axis_rate)) + else: + mount_system_status_char = '' + mount_state = self._serial_query(':GLS#') + print(mount_state) + assert mount_state is not None and len(mount_state)>14, 'Unexpected AZMP state query response: "%s"' % mount_state + try: + mount_system_status_char = mount_state.decode('ASCII')[14] + print(mount_system_status_char) + mount_system_state = self._azmp_states[mount_system_status_char] + self._logger.debug('AZMP system state: "%s" (%s)' % (mount_system_state, mount_system_status_char)) + except KeyError: + self._logger.warning('Unexpected AZMP state query response: "%s", status byte: %s' % (mount_state, mount_state.decode[14])) + assert mount_system_status_char in self._azmp_states, 'Invalid AZMP state index: %s' % mount_system_status_char + is_moving = mount_system_status_char in '12345' # azmp motion states are between 1 and 5 inclusive + return is_moving + + elif self.model.lower() == 'ascom': + return self._ascom_telescope.Slewing or self._ascom_telescope.Tracking + + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def move_to_alt_az(self, alt, azi, block=True, rate_control=True, tolerance_deg=0.001, min_speed=0.001): + """Move the mount to the given position. Must be initialised. + + Args: + alt (float): Altitude angle (degrees). + azi (float): Azimuth angle (degrees). + block (bool, optional): If True (the default) the call to this method will block until the move is finished. + rate_control (bool, optional): If True (the default) the rate of the mount will be controlled until position + is reached, if False the position command will be sent to the mount for execution. + """ + assert self.is_init, 'Must be initialised' + assert self._alt_limit[0] is None or alt >= self._alt_limit[0], 'Altitude outside range!' + assert self._alt_limit[1] is None or alt <= self._alt_limit[1], 'Altitude outside range!' + assert self._azi_limit[0] is None or azi >= self._azi_limit[0], 'Azimuth outside range!' + assert self._azi_limit[1] is None or azi <= self._azi_limit[1], 'Azimuth outside range!' + self._logger.debug('Got move command with: alt=' + str(alt) + ' azi=' + str(azi) + ' block='+str(block) \ + + ' rate_control=' + str(rate_control)) + self._logger.debug('Stopping mount first') + self.stop() + if self.model.lower() in ('celestron', 'ioptron azmp', 'ascom'): + self._logger.debug('Adjusting range to -180 to 180') + alt = self.degrees_to_n180_180(alt - self._alt_zero) + alt = self.degrees_to_n180_180(alt) + azi = self.degrees_to_n180_180(azi) + self._logger.debug('Will command to alt=' + str(alt) + ' azi=' + str(azi)) + + if self.model.lower() == 'ioptron azmp': + self._azmp_set_command_mode('special') + + if not rate_control: # Command mount natively + self._logger.debug('Sending move command to mount') + success = [False] + def _move_to_alt_az(alt, azi, success): + success[0] = _command_to_alt_az(alt, azi) + t = Thread(target=_move_to_alt_az, args=(alt, azi, success)) + t.start() + t.join() + self._logger.debug('Send successful') + if block: + self._logger.debug('Waiting for mount to finish') + self.wait_for_move_to() + + else: # Use own control thread + self._logger.debug('Starting rate controller') + Kp = 0.8 + self._control_thread_stop = False + success = [False] + def _loop_slew_to(alt, azi, success): + if self.model.lower() == 'ascom': pythoncom.CoInitialize() + while not self._control_thread_stop: + curr_pos = self.get_alt_az() + # Get current position error + error_alt = self.degrees_to_n180_180(alt - curr_pos[0]) + error_azi = self.degrees_to_n180_180(azi - curr_pos[1]) + + if abs(error_alt) < tolerance_deg: + rate_alt = 0 + else: + rate_alt = Kp * error_alt + # Clip to maximum and minimum speeds + if abs(rate_alt) > self.max_rate[0]: rate_alt = self.max_rate[0] * np.sign(rate_alt) + if abs(rate_alt) < min_speed: rate_alt = min_speed * np.sign(rate_alt) + + if abs(error_azi) < tolerance_deg: + rate_azi = 0 + else: + rate_azi = Kp * error_azi + # Clip to maximum and minimum speeds + if abs(rate_azi) > self.max_rate[1]: rate_azi = self.max_rate[1] * np.sign(rate_azi) + if abs(rate_azi) < min_speed: rate_azi = min_speed * np.sign(rate_azi) + + self.set_rate_alt_az(rate_alt, rate_azi) + if rate_alt == 0 and rate_azi == 0: + success[0] = True + break + + self._control_thread_stop = True + self._control_thread = Thread(target=_loop_slew_to, args=(alt, azi, success)) + self._control_thread.start() + if block: + self._logger.debug('Waiting for thread to finish') + self._control_thread.join() + #assert success[0], 'Failed moving with rate controller' + + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def _command_to_alt_az(self, alt, azi): + """PRIVATE: Command mount to slew to alt/az coordinates. Must be initialised. + + Args: + alt (float): Altitude (degrees). + azi (float): Azimuth (degrees). + """ + self._logger.debug('Got request to command to alt: %0.3f, azi: %0.3f' % (alt, azi)) + assert self.is_init, 'Must be initialised' + if self.model.lower() == 'celestron': + self._logger.debug('Sending move command to mount') + success = [False] + def _move_to_alt_az(alt, azi, success): + #azi = azi %360 #Mount uses 0-360 + # TODO check alt zero correct + altRaw = int(self.degrees_to_0_360(alt - self._alt_zero) / 360 * 2**32) & 0xFFFFFF00 + aziRaw = int(self.degrees_to_0_360(azi) / 360 * 2**32) & 0xFFFFFF00 + altFormatted = '{0:0{1}X}'.format(altRaw,8) + aziFormatted = '{0:0{1}X}'.format(aziRaw,8) + command = 'b' + aziFormatted + ',' + altFormatted + self._serial_send_text_command(command) + assert self._serial_check_ack(), 'Mount did not acknowledge' + success[0] = True + t = Thread(target=_move_to_alt_az, args=(alt, azi, success)) + t.start() + t.join() + assert success[0], 'Failed communicating with mount' + self._logger.debug('Send successful') + + elif self.model.lower() == 'ioptron azmp': + # TODO check alt zero correct + self._azmp_set_command_mode('special') + # Azimuth: + command = 'T0%+i#' % int(self.degrees_to_0_360(azi) * 3600 / 0.01 ) + self._serial_send_text_command(command) + # Altitude: + command = 'T1%+i#' % int(self.degrees_to_0_360(alt - self._alt_zero) * 3600 / 0.01 ) + self._serial_send_text_command(command) + + elif self.model.lower() == 'ascom': + if not self._ascom_telescope.CanSlewAltAz: + raise RuntimeError('ASCOM mount does not support alt/az go-to commanding') + return False + if self._ascom_telescope.AtPark: + raise RuntimeError('ASCOM mount is parked; cannot command alt/az slew') + return False + if self._ascom_telescope.Tracking: + raise RuntimeError('ASCOM mount is tracking; cannot command alt/az slew') + return False + self._ascom_telescope.SlewToAltAz(alt, azi) + return True + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def get_alt_az(self): + """Get the current alt and azi angles of the mount. + + Returns: + tuple of float: the (altitude, azimuth) angles of the mount in degrees (-180, 180]. + """ + assert self.is_init, 'Must be initialised' + #self._logger.debug('Requesting mount position') + if self.model.lower() == 'celestron': + + ret = [None, None] + + def _get_alt_az(ret): + command = bytes([ord('z')]) #Get precise AZM-ALT + self._serial_port.write(command) + # The command returns ASCII encoded text of HEX values! + res = self._serial_read_to_eol().decode('ASCII') + r2 = res.split(',') + ret[0] = int(r2[1], 16) + ret[1] = int(r2[0], 16) + ret = [None, None] + t = Thread(target=_get_alt_az, args=(ret,)) + t.start() + t.join() + + alt = self.degrees_to_n180_180( float(ret[0]) / 2**32 * 360 + self._alt_zero) + azi = self.degrees_to_n180_180( float(ret[1]) / 2**32 * 360 ) + self._logger.debug('Mount position: alt=' + str(ret[0]) + ' azi=' + str(ret[1]) \ + + ' => alt=' + str(alt) + ' azi=' + str(azi)) + self._state_cache['alt'] = alt + self._state_cache['azi'] = azi + return (alt, azi) + + elif self.model.lower() == 'ioptron azmp': + (alt, azi) = (None, None) + if self._azmp_command_mode == 'special': + # returns integer units of 0.01 arcsec + for attempt in (0, 1): + azi_raw = self._serial_query(':P0#').decode('ASCII') + alt_raw = self._serial_query(':P1#').decode('ASCII') + try: + azi = self.degrees_to_n180_180( int(azi_raw) * 0.01 / 3600 + 180 ) + alt = self.degrees_to_n180_180( 90 - int(alt_raw) * 0.01 / 3600 + self._alt_zero) + self._logger.debug('Mount position: alt=' + str(alt_raw) + ' azi=' + str(azi_raw) \ + + ' => alt=' + str(alt) + ' azi=' + str(azi)) + break + except: + self._logger.info('WARNING: invalid responses from mount (alt: "%s", azi: "%s")' % (alt_raw, azi_raw)) + elif self._azmp_command_mode == 'normal': + # returns char array: : “sTTTTTTTTTTTTTTTTT#” + mount_altaz_info = self._serial_query(':GAC#').decode('ASCII') + assert mount_altaz_info is not None, 'Failed to get mount position.' + alt_raw = int(mount_altaz_info[0:9]) + azi_raw = int(mount_altaz_info[9:18]) + azi = self.degrees_to_n180_180( int(azi_raw) * 0.01 / 3600 ) + alt = self.degrees_to_n180_180( int(alt_raw) * 0.01 / 3600 + self._alt_zero) + self._logger.debug('Mount position: alt=' + str(alt_raw) + ' azi=' + str(azi_raw) \ + + ' => alt=' + str(alt) + ' azi=' + str(azi)) + self._state_cache['alt'] = alt + self._state_cache['azi'] = azi + return (alt, azi) + + elif self.model.lower() == 'ascom': + alt = self._ascom_telescope.Altitude + azi = self._ascom_telescope.Azimuth + self._logger.debug('Mount position: alt=' + str(alt) + ' azi=' + str(azi)) + self._state_cache['alt'] = alt + self._state_cache['azi'] = azi + return (alt, azi) + + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def move_home(self, block=True, rate_control=True): + """Move to the position defined by Mount.home_alt_az. + + Args: + block (bool, optional): If True (the default) the call to this method will block until the move is finished. + rate_control (bool, optional): If True (the default) the rate of the mount will be controlled until position + is reached, if False the position command will be sent to the mount for execution. + """ + self.move_to_alt_az(*self.home_alt_az, block=block, rate_control=rate_control) + + def set_rate_alt_az(self, alt, azi): + """Set the mount slew rate. Must be initialised. + + Args: + alt (float): Altitude rate (degrees per second). + azi (float): Azimuth rate (degrees per second). + """ + assert self.is_init, 'Must be initialised' + self._logger.debug('Got rate command. alt=' + str(alt) + ' azi=' + str(azi)) + if (abs(alt) > self._max_speed[0]) or (abs(azi) > self._max_speed[1]): + raise ValueError('Above maximum speed! ('+str(abs(alt))+', '+str(abs(azi))+')') + if self.model.lower() == 'celestron': + self._logger.debug('Using celestron, sending rate command to mount') + success = [False] + def _set_rate_alt_az(alt, azi, success): + #Altitude + rate = int(round(alt*3600*4)) + if rate >= 0: + rateLo = rate & 0xFF + rateHi = rate>>8 & 0xFF + command_bytes = [ord('P'),3,17,6,rateHi,rateLo,0,0] + else: + rateLo = -rate & 0xFF + rateHi = -rate>>8 & 0xFF + command_bytes = [ord('P'),3,17,7,rateHi,rateLo,0,0] + self._serial_send_bytes_command(command_bytes) + self._logger.debug('Sending: '+str(command_bytes)) + assert self._serial_check_ack(), 'Mount did not acknowledge!' + #Azimuth + rate = int(round(azi*3600*4)) + if rate >= 0: + rateLo = rate & 0xFF + rateHi = rate>>8 & 0xFF + self._serial_send_bytes_command([ord('P'),3,16,6,rateHi,rateLo,0,0]) + else: + rateLo = -rate & 0xFF + rateHi = -rate>>8 & 0xFF + self._serial_send_bytes_command([ord('P'),3,16,7,rateHi,rateLo,0,0]) + assert self._serial_check_ack(), 'Mount did not acknowledge!' + success[0] = True + t = Thread(target=_set_rate_alt_az, args=(alt, azi, success)) + t.start() + t.join() + assert success[0], 'Failed communicating with mount' + self._logger.debug('Send successful') + self._state_cache['alt_rate'] = alt + self._state_cache['azi_rate'] = azi + + elif self.model.lower() == 'ioptron azmp': + #self._logger.debug('Using %s, sending rate command to mount' % self.model) + self._serial_port.reset_input_buffer() + if self._azmp_command_mode == 'special': + # convert rates to integer units of 0.01 arcsec/second + alt_rate_command = ':M1%+i#' % int(round(-1*alt*3600/0.01)) + azi_rate_command = ':M0%+i#' % int(round(azi*3600/0.01)) + self._serial_query(alt_rate_command, eol_byte = b'1') + self._serial_query(azi_rate_command, eol_byte = b'1') + elif self._azmp_command_mode == 'normal' and alt==0 and azi==0: + # support zero-rate commanding in normal mode specifically to support the stop method. + self._serial_query(':Q#', eol_byte = b'1') # "quit slew" command stops + else: + raise AssertionError('Non-zero rate requested while mount is not in compatible mode.') + + self._logger.debug('Send successful') + self._state_cache['alt_rate'] = alt + self._state_cache['azi_rate'] = azi + + elif self.model.lower() == 'ascom': + if alt==0 and azi==0: + try: + self._ascom_telescope.AbortSlew() + except: + pass + self._ascom_telescope.MoveAxis(self._ascom_scope_alt_axis, 0) + self._ascom_telescope.MoveAxis(self._ascom_scope_azi_axis, 0) + rates = [0, 0] + else: + requested_rates = [0, 0] + requested_rates[self._ascom_scope_alt_axis] = alt + requested_rates[self._ascom_scope_azi_axis] = azi + rates = [0, 0] + # Verify requested rates are within allowable ranges, or round down to nearest allowed range: + for axis in [0, 1]: + requested_rate_mag = abs(requested_rates[axis]) + requested_rate_sign = 1 if requested_rates[axis]>=0 else -1 + self._logger.debug('axis ' + str(axis) + ' requested rate mag: ' + str(requested_rate_mag) + ', sign: ' + str(requested_rate_sign)) + for i in range(1, self._ascom_telescope.AxisRates(axis).Count+1): + if requested_rate_mag >= self._ascom_telescope.AxisRates(axis).Item(i).Minimum: + if requested_rate_mag <= self._ascom_telescope.AxisRates(axis).Item(i).Maximum: + rates[axis] = requested_rate_mag * requested_rate_sign + break + else: + rates[axis] = self._ascom_telescope.AxisRates(axis).Item(i).Maximum * requested_rate_sign + else: + break + rates[axis] *= self._axis_directions[axis] + self._logger.debug('Commanding alt rate: '+str(rates[self._ascom_scope_alt_axis])) + self._ascom_telescope.MoveAxis(self._ascom_scope_alt_axis, rates[self._ascom_scope_alt_axis]) + self._logger.debug('Commanding azi rate: '+str(rates[self._ascom_scope_azi_axis])) + self._ascom_telescope.MoveAxis(self._ascom_scope_azi_axis, rates[self._ascom_scope_azi_axis]) + + self._state_cache['alt_rate'] = rates[self._ascom_scope_alt_axis] + self._state_cache['azi_rate'] = rates[self._ascom_scope_azi_axis] + + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def start_sidereal_tracking(self): + """Enable sidereal tracking.""" + self._logger.debug('Got request to start sidereal tracking') + assert self.is_init, 'Must be initialised' + if self.model.lower() == 'celestron': + success = [False] + def _set_tracking_on(success): + #self._serial_send_bytes_command([ord('T'),1]) + #assert self._serial_check_ack(ord('#')), 'Mount did not acknowledge!' + assert self._serial_query('T1') is not None, 'Mount did not acknowledge!' + success[0] = True + self._is_sidereal_tracking = True + t = Thread(target=_set_tracking_on, args=(success,)) + t.start() + t.join() + assert success[0], 'Failed communicating with mount' + self._is_sidereal_tracking = True + elif self.model.lower() == 'ioptron azmp': + # sidereal tracking (and state check) is only supported in normal commanding mode. + self._azmp_set_command_mode('normal') + assert self._azmp_command_mode == 'normal', 'Cannot enable sidereal tracking in "%s" mode' % self._azmp_command_mode + assert self._serial_query(':ST1#', b'1') is not None, 'Mount did not acknowledge!' + #self._serial_send_text_command(':ST1#') + #assert self._serial_check_ack('1'), 'Mount did not acknowledge!' + self._is_sidereal_tracking = True + elif self.model.lower() == 'ascom': + if hasattr(self._ascom_telescope, 'CanSetTracking') and self._ascom_telescope.CanSetTracking: + try: + self._ascom_telescope.Tracking = True #turn on tracking + self._is_sidereal_tracking = True + except: + self._logger.warning('Failed to start sidereal tracking.') + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def stop_sidereal_tracking(self): + """Disable sidereal tracking.""" + self._logger.debug('Got request to stop sidereal tracking') + if self.is_init: + if self.model.lower() == 'celestron': + success = [False] + def _set_tracking_off(success): + self._serial_send_bytes_command([ord('T'),0]) + assert self._serial_check_ack('#'), 'Mount did not acknowledge!' + success[0] = True + self._is_sidereal_tracking = False + t = Thread(target=_set_tracking_off, args=(success,)) + t.start() + t.join() + assert success[0], 'Failed communicating with mount' + elif self.model.lower() == 'ioptron azmp': + # sidereal tracking (and state check) is only supported in normal commanding mode. + #self._azmp_get_command_mode() + if self._azmp_command_mode == 'normal': + #self._serial_send_text_command(':ST0#') + #assert self._serial_check_ack('1'), 'Mount did not acknowledge!' + assert self._serial_query(':ST0#', b'1') is not None, 'Mount did not acknowledge!' + self._is_sidereal_tracking = False + #self._azmp_set_command_mode('special') + #assert self._azmp_command_mode == 'special', 'Unable to switch mount to special command mode.' + #self.get_alt_az() + else: + self._is_sidereal_tracking = False + elif self.model.lower() == 'ascom': + if hasattr(self._ascom_telescope, 'CanSetTracking') and self._ascom_telescope.CanSetTracking: + try: + self._ascom_telescope.Tracking = False #turn off tracking + self._is_sidereal_tracking = False + except: + self._logger.warning('Failed to stop sidereal tracking.') + else: + self._logger.warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def stop(self): + """Stop moving.""" + + self._logger.debug('Got stop command, check thread') + if self.is_init: + #if self.is_moving: + self._logger.info('stopping mount') + if self._control_thread is not None and self._control_thread.is_alive(): + self._logger.debug('Stopping control thread') + self._control_thread_stop = True + self._control_thread.join() + self._logger.debug('Stopped') + if self._serial_is_init: + #sleep(0.2) + self._serial_port.reset_input_buffer() + self._serial_port.reset_output_buffer() + #sleep(0.2) + self._logger.debug('Sending zero rate command') + self.set_rate_alt_az(0, 0) + #sleep(0.2) + if self._is_sidereal_tracking: + self.stop_sidereal_tracking() + self._logger.debug('Stopped mount') + + def wait_for_move_to(self, timeout=120): + """Wait for mount to finish move. + + Args: + timeout (int, optional): Maximum time (seconds) to wait before raising TimeoutError. Default 120. + """ + assert self.is_init, 'Must be initialised' + t_start = timestamp() + self._logger.debug('Waiting for move to, start time: '+str(t_start)) + try: + while timestamp() - t_start < timeout: + if self.is_moving: + sleep(.5) + else: + return + except KeyboardInterrupt: + self._logger.debug('Waiting interrupted', exc_info=True) + + raise TimeoutError('Waiting for mount move took more than ' + str(timeout) + 'seconds.') + + @staticmethod + def list_available_ports(): + """List the available serial port names and descriptions. + + Returns: + list of tuple: (device, description) for each available serial port (see serial.tools.list_ports). + """ + import serial.tools.list_ports as ports + port_list = ports.comports() + return [(x.device, x.description) for x in port_list] if len(port_list) > 0 else [] + + @staticmethod + def degrees_to_0_360(number): + """float: Convert angle (degrees) to range [0, 360).""" + return float(number)%360 + + @staticmethod + def degrees_to_n180_180(number): + """float: Convert angle (degrees) to range (-180, 180]""" + return 180 - (180-float(number))%360 + + def _serial_find_port(self, port_index): + """PRIVATE: Get serial port name at a given index, starting at zero""" + port_index = int(port_index) + self._logger.debug('Searching for serial port at index: ' +str(port_index)) + ports = self.list_available_ports() + num_ports = len(ports) + self._logger.debug('Found '+str(num_ports)+' ports: ' + str(ports)) + assert port_index < num_ports, 'Port index %i out of range (%i ports found)' % (port_index, num_ports) + return ports[port_index][0] + + def _serial_test(self, port_name, baud, test_command, nbytes): + """PRIVATE: Test if serial communication is established by sending """ + self._logger.debug('Testing serial port "%s" with test command "%s", reading %i bytes' % ( + port_name, test_command, nbytes)) + self._logger.debug('Testing serial port "%s", baud %i' % (port_name, baud)) + try: + with serial.Serial(port_name, baud, timeout=3.5, write_timeout=3.5) as ser: + ser.write(test_command.encode('ASCII')) + ser.flush() + response = ser.read(nbytes) + self._logger.debug('Got response: '+str(response)) + return response + except serial.SerialException: + self._logger.warning('Failed to communicate on serial port ' + str(port_name), exc_info=True) + return [] + #raise + + + + def _serial_port_open(self, port_name, baud=None, timeout=3.5): + """PRIVATE: Opens serial port specified by port_name string.""" + self._logger.debug('Got open serial port with name: ' + str(port_name)) + baud = baud or self._baud # take from self if not given + try: + self._serial_port = serial.Serial(port_name, baud, timeout=timeout, write_timeout=timeout) + self._serial_is_init = True + self._logger.debug('Successfully opened port') + except serial.SerialException: + self._logger.waring('Failed to open serial port at ' + str(port_name), exc_info=True) + raise + + def _serial_port_close(self): + """PRIVATE: Closes serial port.""" + self._logger.debug('Got close serial port') + if self._serial_port is not None: + self._serial_port.close() + self._serial_port = None + self._logger.debug('Port closed') + self._serial_is_init = False + + def _serial_query(self, command, eol_byte=None): + """PRIVATE: Encodes as ASCII and sends command string to mount, then reads and returns + response string from mount ending in indicated end-of-line character. + inputs: + command (str): command message to be sent to mount. + eol_byte (byte, optional): expected terminating character at end of mount response. + returns: ASCII string response from mount up to and including EOL byte character. + """ + assert self._serial_port is not None and self._serial_is_init, 'Serial port is not initialized' + response = b'' + self._logger.debug('Sending serial command "%s" to mount' % str(command)) + eol_byte = eol_byte or self._eol_byte # Take from self if not given + try: + self._serial_port.reset_input_buffer() + self._serial_port.reset_output_buffer() + self._serial_send_text_command(command) + #self._serial_port.flush() + response = self._serial_read_to_eol(eol_byte) + if response is None: + self._logger.warning('No response from mount (query: "%s")' % command) + response = b'' + except: + self._logger.debug('Failed to communicate', exc_info=True) + raise + return response + + + def _serial_send_text_command(self, command): + """PRIVATE: Encode as ASCII and send to mount.""" + assert self._serial_is_init, 'Serial port is not initialized' + self._serial_port.write(command.encode('ASCII')) + self._serial_port.flush() #Push out data + + def _serial_send_bytes_command(self, command): + """PRIVATE: Send bytes to mount.""" + assert self._serial_is_init, 'Serial port is not initialized' + self._serial_port.write(bytes(command)) + self._serial_port.flush() #Push out data + + def _serial_check_ack(self, ack_byte = None): + """PRIVATE: Read one byte and compare to the acknowledge byte character.""" + assert self._serial_is_init, 'Serial port is not initialized' + ack_byte = ack_byte or self._eol_byte + b = self._serial_port.read() + return b == ack_byte + + ''' + def _serial_read_to_eol(self, eol_byte=None): + """PRIVATE: Read response to the EOL byte character. Return bytes.""" + assert self._serial_is_init, 'Serial port is not initialized' + eol_byte = eol_byte or self._eol_byte # Take from self if not given + response = b'' #Empty type 'bytes' + while True: + r = self._serial_port.read() + if r == b'': #If we didn't get anything/timeout + #raise RuntimeError('Mount serial timed out!') + self._logger.warning('Mount serial timed out!') + else: + if r == eol_byte: + self._logger.debug('Read from mount: '+str(response)) + return response + else: + response += r + ''' + + def _serial_read_to_eol(self, eol_byte=None, timeout=3): + """PRIVATE: Read response to the EOL byte character. Return bytes.""" + assert self._serial_is_init, 'Serial port is not initialized' + eol_byte = eol_byte or self._eol_byte # Take from self if not given + timeout_time = timestamp() + timeout + response = b'' #Empty type 'bytes' + while timestamp() < timeout_time: + r = self._serial_port.read(1) + if r == eol_byte: + #self._logger.debug('Read from mount: '+str(response)) + return response + else: + response += r + + if timestamp() > timeout_time: + self._logger.info('timed out waiting for serial response (read: "%s", looking for eol byte: %s)' % (response, eol_byte)) + return None + sleep(0.0001) + + def _cel_tracking_off(self): + """PRIVATE: Disable sidreal tracking on celestron mount.""" + success = [False] + def _set_tracking_off(success): + self._serial_send_bytes_command([ord('T'),0]) + assert self._serial_check_ack(), 'Mount did not acknowledge!' + success[0] = True + t = Thread(target=_set_tracking_off, args=(success,)) + t.start() + t.join() + assert success[0], 'Failed communicating with mount' + + def _azmp_get_command_mode(self): + """PRIVATE: Get the iOptron AZMP command mode. Returns either 'normal' or 'special'""" + self._logger.debug('Checking AZMP command mode') + assert self._serial_is_init, 'Serial port is not initialized' + # Empty both buffers + self._serial_port.reset_input_buffer() + self._serial_port.reset_output_buffer() + self._serial_send_text_command(':MountInfo#') + command_mode_code = self._serial_port.read(4) + assert command_mode_code in (b'5035', b'9035'), 'Failed to get AZMP command mode. Mount returned: ' + str(command_mode_code) + self._azmp_command_mode = 'normal' if command_mode_code == b'5035' else 'special' + self._logger.info('AZMP command mode is "%s" (%s)' % (self._azmp_command_mode, str(command_mode_code))) + + def _azmp_set_command_mode(self, to_mode): + self._logger.debug('Got request to transition mount to %s commanding mode' % to_mode) + self._azmp_get_command_mode() + if self._azmp_command_mode == to_mode: + self._logger.debug('Mout is already in command mode "%s"' % self._azmp_command_mode) + else: + self._logger.debug('Commanding AZMP mode transition') + self._serial_send_text_command(':ZZZ#') + sleep(2.5) + self._azmp_get_command_mode() + assert self._azmp_command_mode == to_mode, 'Failed to transition mount to %s commanding mode"' % to_mode \ No newline at end of file diff --git a/pypogs/hardware/hardware_receiver.py b/pypogs/hardware/hardware_receiver.py new file mode 100644 index 0000000..792ac8f --- /dev/null +++ b/pypogs/hardware/hardware_receiver.py @@ -0,0 +1,559 @@ +"""Receiver interfaces +====================== + +Current harware support: + - :class:`pypogs.Receiver`: 'ni_daq' for National Instruments DAQ data acquisition cards. Requires NI-DAQmx API and nidaqmx, see the + installation instructions. Tested with NI DAQ model USB-6211. + +This is Free and Open-Source Software originally written by Gustav Pettersson at ESA. + +License: + Copyright 2019 the European Space Agency + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +# Standard imports: +from pathlib import Path +import logging +from time import sleep, time as timestamp +from datetime import datetime +from threading import Thread, Event +from struct import pack as pack_data + +# External imports: +import numpy as np +import serial + + +class Receiver: + """Control acquisition and read received power from a photodetector. + + To initialise a Receiver a *model* (determines hardware interface) and *identity* (identifying the specific device) + must be given. If both are given to the constructor the Receiver will be initialised immediately (unless + auto_init=False is passed). Manually initialise with a call to Receiver.initialize(); release hardware with a call + to Receiver.deinitialize(). + + The raw data can be saved to a file by specifying data_folder (filenames are auto-generated). While the acquisition + is running the instantaneous (last measurement) and (exponentially) smoothed power can be read. + + Args: + model (str, optional): The model used to determine the correct hardware API. Supported: 'ni_daq' for + National Instruments DAQ cards (tested on USB-6211). + identity (str, optional): String identifying the device and input. For *ni_daq* this is 'device/input' eg. + 'Dev1/ai1' for device 'Dev1' and analog input 1; only differential input is supported for *ni_daq*. + name (str, optional): Name for the device. + auto_init (bool, optional): If both model and identity are given when creating the Receiver and auto_init + is True (the default), Receiver.initialize() will be called after creation. + data_folder (pathlib.Path, optional): The folder for data saving. If None (the default) no data will be saved. + debug_folder (pathlib.Path, optional): The folder for debug logging. If None (the default) the folder + *pypogs*/debug will be used/created. + + Example: + :: + + # Create instance and set parameters (will auto initialise) + rec = pypogs.Receiver(model='ni_daq', identity='Dev1/ai1', name='PhotoDiode') + rec.sample_rate = 1000 #Samples per second + rec.smoothing_parameter = 100 #number of samples to smooth over + rec.measurement_range = (-10, 10) #Volts for ni_daq + # Add a save path (filenames are auto-generated) + rec.data_folder = pathlib.Path('./datafolder') + # Start acquisition + rec.start() + # Wait for a while + time.sleep(2) + # Read the smooth and instantaneous powers + print('Smoothed power is: ' + str(rec.smooth_power)) + print('Instant power is: ' + str(rec.instant_power)) + # Stop the acquisition + rec.stop() + + """ + _supported_models = ('ni_daq',) + _default_model = 'ni_daq' + + def __init__(self, model=None, identity=None, name=None, auto_init=True, data_folder=None,\ + debug_folder=None): + """Create Receiver instance. See class documentation.""" + # Logger setup + self._debug_folder = None + if debug_folder is None: + self.debug_folder = Path(__file__).parent / 'debug' + else: + self.debug_folder = debug_folder + self._logger = logging.getLogger('pypogs.hardware.Receiver') + if not self._logger.hasHandlers(): + # Add new handlers to the logger if there are none + self._logger.setLevel(logging.DEBUG) + # Console handler at INFO level + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + # File handler at DEBUG level + fh = logging.FileHandler(self.debug_folder / 'pypogs.txt') + fh.setLevel(logging.DEBUG) + # Format and add + formatter = logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s') + fh.setFormatter(formatter) + ch.setFormatter(formatter) + self._logger.addHandler(fh) + self._logger.addHandler(ch) + + # Start of constructor + self._logger.debug('Receiver called with: model=' + str(model) + ' identity=' + str(identity) + ' name=' \ + +str(name) + ' auto_init=' + str(auto_init) + ' data_folder=' + str(data_folder)) + self._is_init = False + self._is_running = False + self._model = None + self._identity = None + self._name = 'UnnamedReceiver' + self._data_folder = None + self._data_file = None + # Power values stored from the receiver + self._instant_power = None + self._smooth_power = None + self._smoothing_parameter = 100 + #Only used for NI DAQ devices + self._ni_task = None + if model is not None: + self.model = model + if identity is not None: + self.identity = identity + if name is not None: + self.name = name + if data_folder is not None: + self.data_folder = data_folder + self._logger.info('Instance created'+(': '+self.name) if self.name is not None else '') + if auto_init and not None in (model, identity): + self.initialize() + import atexit, weakref + atexit.register(weakref.ref(self.__del__)) + self._logger.info('Receiver instance created with name: ' + self.name + '.') + + def __del__(self): + """Receiver destructor. Will close connections to device before destruction.""" + try: + self._logger.debug('Deleter called') + except: + pass + try: + self.deinitialize() + try: + self._logger.debug('Deinitialised') + except: + pass + except: + self._logger.debug('Did not deinitialise', exc_info=True) + try: + self._logger.debug('Instance deleted') + except: + pass + + @property + def data_folder(self): + """pathlib.Path: Get or set the path for data saving. Will create folder if not existing.""" + return self._data_folder + @data_folder.setter + def data_folder(self, path): + self._logger.debug('Got set data folder with: '+str(path)) + path = Path(path) + if path.is_file(): + path = path.parent + if not path.is_dir(): + path.mkdir(parents=True) + self._data_folder = path + self._logger.debug('Set data folder to: '+str(self.data_folder)) + + @property + def name(self): + '''str: Get or set the name.''' + return self._name + @name.setter + def name(self, name): + self._logger.debug('Setting name to: '+str(name)) + assert isinstance(name, str), 'Name must be a string' + self._name = name + self._logger.debug('Name set') + + @property + def model(self): + """str: Get or set the device model. + + Supported: + - 'ni_daq' for National Instruments DAQ devices (e.g. USB-6211). + + - This will determine which hardware API that is used. + - Must set before initialising the device and may not be changed for an initialised device. + """ + return self._model + @model.setter + def model(self, model): + self._logger.debug('Setting model to: '+str(model)) + assert not self.is_init, 'Can not change already intialised device model' + assert isinstance(model, str), 'Model type must be a string' + assert model.lower() in self._supported_models,\ + 'Model type not recognised, allowed: '+str(self._supported_models) + self._model = model + self._logger.debug('Model set') + + @property + def identity(self): + """str: Get or set the device and/or input. Model must be defined first. + + + - For model *ni_daq* this is 'device/input' eg. 'Dev1/ai1' for device 'Dev1' and analog input 1. Only + differential input is supported for NI DAQ. + - Must set before initialising the device and may not be changed for an initialised device. + """ + return self._identity + @identity.setter + def identity(self, identity): + self._logger.debug('Setting identity to: '+str(identity)) + assert not self.is_init, 'Can not change already intialised device' + assert isinstance(identity, str), 'Identity must be a string' + assert self.model is not None, 'Must define model first' + if self.model.lower() == 'ni_daq': + self._logger.debug('Using NI DAQ, checking vailidity by opening a task') + import nidaqmx as ni + t = ni.Task() + try: + t.ai_channels.add_ai_voltage_chan(identity) + self._identity = identity + except ni.DaqError: + self._logger.debug('Verification unsucessful', exc_info=True) + raise AssertionError('The identity was not found') + finally: + try: + self._logger.debug('Deleting task') + t.close() + del(t) + except: + self._logger.debug('Failed to delete task used to test identity', exc_info=True) + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + self._logger.debug('Identity set') + + @property + def is_init(self): + """bool: True if the device is initialised (and therefore ready to start).""" + return self._is_init + + def initialize(self): + """Initialise (make ready to start) the device. The model and identity must be defined.""" + self._logger.debug('Initialising') + assert not self.is_init, 'Already initialised' + assert not None in (self.model, self.identity), 'Must define model and identity before initialising' + if self.model.lower() == 'ni_daq': + import nidaqmx as ni + self._logger.debug('Using NI DAQ, create a task') + try: + self._ni_task = ni.Task(self.name) if self.name is not None else ni.Task() + except ni.DaqError: + self._logger.debug('Failed to create task', exc_info=True) + raise RuntimeError('Failed to initialise, may conflict with existing instance') + try: + self._ni_task.ai_channels.add_ai_voltage_chan(self.identity) + self._ni_task.timing.cfg_samp_clk_timing(rate=1000, sample_mode=ni.constants.AcquisitionType.CONTINUOUS) + self._logger.info('Successfully initialised'+(': '+self.name) if self.name is not None else '') + self._is_init = True + except ni.DaqError: + self._logger.debug('Failed to initialise', exc_info=True) + try: + self._ni_task.close() + self._ni_task = None + self._logger.debug('Closed the task') + except: + self._logger.debug('Failed to close task', exc_info=True) + raise RuntimeError('Failed to initialise, may conflict with existing instance') + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def deinitialize(self): + """De-initialise the device. Will stop the acquisition if it is running.""" + self._logger.debug('De-initialising') + assert self.is_init, 'Not initialised' + if self.is_running: + self._logger.debug('Is running, stopping') + self.stop() + self._logger.debug('Stopped') + if self._ni_task is not None: + self._logger.debug('Found NI DAQ task, closing and removing') + try: + self._ni_task.close() + self._ni_task = None + self._logger.debug('Closed task') + except: + self._logger.exception('Failed to close task') + self._is_init = False + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + @property + def available_properties(self): + """tuple of str: Get all the available properties (settings) supported by this device.""" + assert self.is_init, 'Must be initialised' + if self.model.lower() == 'ni_daq': + return ('sample_rate', 'measurement_range', 'smoothing_parameter') + else: + self._log_warning('Forbidden model string defined.') + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + @property + def sample_rate(self): + """int or float: Get or set the sample rate (in Hz) of the device. Must initialise the device first.""" + assert self.is_init, 'Must initialise first' + self._logger.debug('Getting sample rate') + if self.model.lower() == 'ni_daq': + self._logger.debug('Using NI DAQ, trying to get') + return self._ni_task.timing.samp_clk_rate + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + @sample_rate.setter + def sample_rate(self, rate_hz): + self._logger.debug('Setting sample rate to (Hz): '+str(rate_hz)) + assert isinstance(rate_hz, (float,int)), 'Sample rate must be a scalar (float or int)' + assert self.is_init, 'Must initialise first' + assert not self.is_running, 'Cant change rate while running' + if self.model.lower() == 'ni_daq': + import nidaqmx as ni + self._logger.debug('Using NI DAQ, trying to set') + self._logger.debug('Checking valid rate') + assert 0 < rate_hz <= self._ni_task.timing.samp_clk_max_rate, 'Requested rate is not allowed, '\ + +'maximum rate is: '+str(self._ni_task.timing.samp_clk_max_rate) + try: + self._ni_task.timing.cfg_samp_clk_timing(rate=rate_hz,\ + sample_mode=ni.constants.AcquisitionType.CONTINUOUS) + self._logger.debug('Sampling rate set to: '+str(self._ni_task.timing.samp_clk_rate)) + except: + self._logger.exception('Failed to set sample rate: ') + raise + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + @property + def measurement_range(self): + """tuple, int or float: Get or set the measurement range (lower_limit, upper_limit). + + - If given as a scalar the range will be set to +- the supplied value. + """ + assert self.is_init, 'Must initialise first' + self._logger.debug('Getting measurement range') + if self.model.lower() == 'ni_daq': + self._logger.debug('Using NI DAQ, trying to get') + try: + maxval = self._ni_task.ai_channels[0].ai_max + minval = self._ni_task.ai_channels[0].ai_min + return (minval, maxval) + except: + self._logger.exception('Failed to get range') + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + @measurement_range.setter + def measurement_range(self, meas_range): + assert self.is_init, 'Must initialise first' + assert not self.is_running, 'Cant change range while running' + self._logger.debug('Setting measurement range to: '+str(meas_range)) + assert isinstance(meas_range,(int,float,tuple)), 'Input must be scalar (int/float) or a 2-tuple of scalars' + if isinstance(meas_range,tuple): + assert len(meas_range) == 2, 'Input must be scalar (int or float) or a 2-tuple of scalars' + assert all( isinstance(x,(int,float)) for x in meas_range ),\ + 'Input must be scalar (int or float) or a 2-tuple of scalars' + else: + meas_range = (-meas_range, meas_range) + self._logger.debug('Decoded input: '+str(meas_range)) + if self.model.lower() == 'ni_daq': + import nidaqmx as ni + self._logger.debug('Using NI DAQ, trying to set') + self._logger.debug('NOTE: nidaqmx is broken, must manually check if range is allowed (-10, 10)...') + assert min(meas_range)>=-10 and max(meas_range)<=10, 'Values must be <=10 and >=-10' + self._logger.debug('NOTE: Passed manual value check') + try: + self._logger.debug('Setting new values') + self._ni_task.ai_channels[0].ai_max = meas_range[1] + self._ni_task.ai_channels[0].ai_min = meas_range[0] + self._logger.debug('Range set to: '+str(self.measurement_range)) + except ni.DaqError: + self._logger.exception('Failed to set new values, this may cause strange behaviour. De-init and re-init.') + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + @property + def smoothing_parameter(self): + """int or float: Get or set the smoothing parameter. It roughly corresponds to the number of samples to average. + + - Exponential smoothing is used. smoothing_parameter is the *inverse* of 'alpha'. Each smoothed value s is + defined from the measurements x by: + + ``s[n] = alpha*x[n] + (1-alpha)*s[n-1]; s[0] = x[0]`` + """ + return self._smoothing_parameter + @smoothing_parameter.setter + def smoothing_parameter(self, param): + assert isinstance(param, (int, float)), 'Parameter must be scalar (float or int)' + assert param > 0, 'Parameter must be >0' + self._smoothing_parameter = param + self._logger.debug('Smoothing parameter set to '+str(param)) + + @property + def instant_power(self): + """float: Get the latest raw measurement.""" + if not self.is_running: return None + self._get_update_from_hardware() + return self._instant_power + + @property + def smooth_power(self): + """float: Get the current smoothed measurement (see smoothing_parameter).""" + if not self.is_running: return None + self._get_update_from_hardware() + return self._smooth_power + + @property + def is_running(self): + """bool: True if device is currently acquiring data.""" + return self._is_running + + def start(self): + """Start the acquisition. Device must be initialised. Data will only be saved if data_folder is set.""" + assert self.is_init, 'Must initialise first' + assert not self.is_running, 'Acquisition already running' + self._logger.debug('Got start command') + if self.data_folder is not None: + self._logger.debug('Data folder exists, creating file and header') + self._create_data_file() + else: + self._logger.debug('No save path set') + if self.model.lower() == 'ni_daq': + import nidaqmx as ni + self._logger.debug('Using NI DAQ, setting up callback') + cb_count = int(min(1000, max(1, self.sample_rate/10))) #Typically 10Hz, min 1 and max 1000 per callback + + def _ni_buffering_callback(task_handle, event_type, number_of_samples, callback_data): + self._logger.debug('Got a callback') + try: + self._get_update_from_hardware() + except: + logging.error('Could not update from hardware') + return 0 + + try: + self._ni_task.register_every_n_samples_acquired_into_buffer_event(cb_count, _ni_buffering_callback) + self._logger.debug('Registered event every n='+str(cb_count)+' samples') + except ni.DaqError: + self._logger.debug('Unable to register event, trying to unregister and try again') + self._ni_task.register_every_n_samples_acquired_into_buffer_event(cb_count, None) + self._ni_task.register_every_n_samples_acquired_into_buffer_event(cb_count, _ni_buffering_callback) + self._logger.debug('Registered event every n='+str(cb_count)+' samples') + self._ni_task.start() + self._logger.info('Started acquisition from receiver') + self._is_running = True + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def stop(self): + """Stop the acquisition. Will ensure all data in the buffer is read before stopping.""" + assert self.is_running, 'Acquisition is not running' + self._logger.debug('Got stop command') + if self.model.lower() == 'ni_daq': + self._logger.debug('Using NI DAQ, stopping task') + self._is_running = False + self._ni_task.stop() + self._logger.debug('Stopped task') + self._logger.info('Stopped acquisition from receiver') + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def _get_update_from_hardware(self): + """PRIVATE: Read all available data from the device and call _update_stored_values. Save if data_folder is set.""" + self._logger.debug('Got hardware update command') + if self.model.lower() == 'ni_daq': + import nidaqmx as ni + self._logger.debug('Using NI DAQ, reading all available') + try: + data = self._ni_task.read(ni.constants.READ_ALL_AVAILABLE) + self._logger.debug('Data of length '+str(len(data))+' and class '+str(type(data))) + except: + data = None + if self.is_running: + self._logger.exception('Failed to read data') + else: + self._loger.debug('Got a callback after stop command') + if data: + try: + self._update_stored_values(data) + except: + self._logger.exception('Failed to update stored values') + if self.data_folder is not None: + try: + self._write_data_to_data_file(data) + except: + self._logger.exception('Failed to save update') + else: + raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) + + def _update_stored_values(self, data): + """PRIVATE: Update the stored instantaneous and smoothed measurement.""" + self._logger.debug('Got update request with length '+str(len(data))) + if None in (self._smooth_power, self._instant_power): #No old data, need to initialise + self._logger.debug('No previous values') + self._instant_power = data[-1] #Last read data saved here + if len(data) == 1: #If only one point + self._smooth_power = data[0] + else: + k = len(data) + a = 1/self._smoothing_parameter + data = np.array(data) + facs = (1-a)**np.arange(k) #Smoothing factors + self._smooth_power = a*np.sum( facs[:-1]*data[:-1] ) + facs[-1]*data[-1] + else: #Doing a normal update + self._logger.debug('Previous smooth and instant powers are: '\ + +str(self._smooth_power)+' '+str(self._instant_power)) + self._instant_power = data[-1] #Last read data saved here + k = len(data) + a = 1/self._smoothing_parameter + if k == 1: #If only one point + self._smooth_power = a*data[0] + (1-a)*self._smooth_power + else: + data = np.array(data) + facs = (1-a)**np.arange(k+1) #Smoothing factors + self._smooth_power = a*np.sum( facs[:-1]*data ) + facs[-1]*self._smooth_power + + self._logger.debug('Smooth and instant power are now: '+str(self._smooth_power)+' '+str(self._instant_power)) + + def _write_data_to_data_file(self, data): + """PRIVATE: Write data to the data file.""" + self._logger.debug('Writing to data file, got '+str(len(data))+' measurements') + assert self._data_file is not None, 'No logfile is defined...' + with open(self._data_file, 'ba') as file: + dpack = pack_data('%df' % len(data), *data) #Create binary (dobule) representation of data + file.write(dpack) + + def _create_data_file(self): + """"PRIVATE: Create data file and write the header.""" + assert self.data_folder is not None, 'No save path here...' + self._logger.debug('Creating data file') + timestamp = datetime.utcnow() + filename = timestamp.strftime('%Y-%m-%dT%H%M%S') + '_Receiver.dat' + self._data_file = self.data_folder / Path(filename) + self._logger.debug('File: ' + str(filename)) + header = 'TIME: ' + timestamp.isoformat() + '; ' \ + +'NAME: ' + str(self.name) + '; ' \ + +'MODEL: ' + str(self.model) + '; ' \ + +'IDENTITY: ' + str(self.identity) + '; ' \ + +'SAMPLE_RATE: ' + str(self.sample_rate) + '; ' \ + +'MEASUREMENT_RANGE: ' + str(self.measurement_range) +'; ' \ + +'FORMAT: ' + 'STRUCT_PACK_FLOAT32' + ';\n' + self._logger.debug('Header: ' + header) + with open(self._data_file, 'a') as file: + file.write(header) \ No newline at end of file diff --git a/pypogs/horizons_ephem.py b/pypogs/horizons_ephem.py new file mode 100644 index 0000000..efef25f --- /dev/null +++ b/pypogs/horizons_ephem.py @@ -0,0 +1,137 @@ +''' +horizons_ephem.py, by R. Kinnett, 2021 + +Utility for fetching ephemerides from JPL Horizons REST API (primarily for interplanetary spacecraft). + +:class: Ephem + Methods: + + fetch( object_id, start_date_time_utc, end_date_time_utc, time_step, lat, lon, elevation_m) + Queries JPL Horizons ephemeris REST API and tabulates results across specified time span + for interpolation later by provided methods. This should be performed upon target + selection to pre-cache reference data. + + Parameters: + object_id: THE SPICE convention uses negative integers as spacecraft ID codes. + The code assigned to interplanetary spacecraft is normally the negative of + the code assigned to the same spacecraft by JPL's Deep Space Network (DSN) + as determined the NASA control authority at Goddard Space Flight Center. + + Examples: HST: -48, JWST: -170, Europa Clipper: -159 + + start_date_time_utc: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' + end_date_time_utc: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS' + time_step: Time step in minutes between ephemerides + lat: Ground station North latitude in decimal degrees + lon: Ground station East latitude in decimal degrees + elevation_m: Ground station elevation in meters above mean sea level. + + interp( julian_date ) + Interpolates alt, azi coordinates at specified time. + + now() + Interpolates current alt, azi coordinates for present time. + + Example: + ephem = Ephem() + ephem.fetch(-170, '2021-12-25 12:50:00', '2021-12-31', 34.2, -118.2, 500) + print(ephem.now()) + +''' + +import requests +import numpy as np +from astropy.time import Time + +class Ephem: + jd = [] + azi = [] + alt = [] + len = 0 + target_name = '' + obj_id = None + is_init = False + + def __init__(self, object_id, start_date_time_utc, end_date_time_utc, time_step_minutes, lat, lon, elevation_m): + request_str = 'https://ssd.jpl.nasa.gov/api/horizons.api?format=text' \ + + '&COMMAND="'+str(object_id)+'"' \ + + '&MAKE_EPHEM="YES"' + '&EPHEM_TYPE="OBSERVER"' + '&CENTER="coord@399"' + '&COORD_TYPE="GEODETIC"' \ + + '&SITE_COORD="'+str(lon)+','+str(lat)+','+str(elevation_m/1000)+'"' \ + + '&START_TIME="'+start_date_time_utc+'"' \ + + '&STOP_TIME="'+end_date_time_utc+'"' \ + + '&STEP_SIZE="'+str(time_step_minutes)+' MINUTES"' \ + + '&CAL_FORMAT="JD"' \ + + '&QUANTITIES="4"' \ + + '&CSV_FORMAT="YES"' + http_request = requests.get(request_str) + self.len = 0 + self.jd = [] + self.alt = [] + self.azi = [] + self.obj_id = object_id + if http_request.status_code == 200: + found_ephem_start = False + found_ephem_end = False + for line in http_request.text.splitlines(): + if not found_ephem_start: + if line == '$$SOE': + found_ephem_start = True + elif line.startswith('Target body name'): + try: + self.target_name = line[18: line.find(' (') or len(line)] + except: + pass + elif not found_ephem_end: + if line == '$$EOE': + found_ephem_end = True + break + #print(line) + ephem_line = line.split(',') + self.jd.append(float(ephem_line[0])) + self.azi.append(float(ephem_line[3])) + self.alt.append(float(ephem_line[4])) + self.len += 1 + #print(ephem.jd[-1],ephem.azi[-1],ephem.alt[-1]) + if self.len > 0: + self.is_init = True + #else: + #print('Received response but no ephemeris') + #print(http_request.text) + + #else: + #print('status code: %i' % http_request.status_code) + #print(request_str) + + + def interp(self,jdate): + assert self.is_init, 'Ephemeris not loaded.' + def circular_interpolation(x, xp, fp, range_min=0, range_max=360): + period = range_max - range_min + midpoint = period/2 + y = np.mod(np.interp(x, xp, np.unwrap(fp, period=period)), period) + while y>range_max: y -= period + while y1: + for i in range(1, times.size): + az_el_pair = self.interp(times.jd[i]) + az_el_pairs = np.concatenate([az_el_pairs, az_el_pair]) + #print(az_el_pairs) + return az_el_pairs.T + + def now(self): + ut = Time.now() + return self.interp(ut.jd) + diff --git a/pypogs/system.py b/pypogs/system.py index 9bf84bc..28391c0 100644 --- a/pypogs/system.py +++ b/pypogs/system.py @@ -31,12 +31,15 @@ from pathlib import Path import logging from threading import Thread -from csv import writer as csv_write +from csv import writer as csv_write, reader as csv_reader +from time import sleep, time as timestamp +import socket, struct, math, re # External imports: import numpy as np -from astropy.time import Time as apy_time +from astropy.time import Time as apy_time, TimeDelta as apy_time_delta from astropy import units as apy_unit, coordinates as apy_coord, utils as apy_util +from satellite_tle import fetch_tle_from_celestrak from skyfield import sgp4lib as sgp4 from skyfield import api as sf_api from tifffile import imwrite as tiff_write @@ -45,6 +48,7 @@ from tetra3 import Tetra3 from .hardware import Camera, Mount, Receiver from .tracking import TrackingThread, ControlLoopThread +from .horizons_ephem import Ephem # Useful definitions: EPS = 10**-6 # Epsilon for use in non-zero check @@ -66,7 +70,7 @@ class System: Tables of Earth's rotation must be downloaded to do coordinate transforms. Calling :meth:`update_databases()` will attempt to download these from the internet (if expired). To facilitate offline use of pypogs, these will otherwise not be automatically downloaded - unless strictly necessary. If you use pypogs offline do this update every few months to + unless strictly neccessary. If you use pypogs offline do this update every few months to keep the coordinate transforms accurate. Example: @@ -135,7 +139,7 @@ class System: If your mount has built in alignment (and/or is physically aligned to the earth) you may call :meth:`alignment.set_alignment_enu` to set the telescope alignment to East, North, Up (ENU) coordinates, which will also disable the corrections done in pypogs. ENU is the - traditional astronomical coordinate system for altitude (elevation) and azimuth telescopes, + traditional astronomical coordinate system for altitide (elevation) and azimuth telescopes, measured as degrees above the horizon and degrees away from north (towards east) respectively. @@ -160,6 +164,12 @@ class System: '2 28647 56.9987 238.9694 0122260 223.0550 136.0876 15.05723818 6663'] sys.target.set_target_from_tle(tle) + Example satellite by ID: + :: + + tle = sys.target.get_tle_from_sat_id(25544) + sys.target.set_target_from_tle(tle) + Example star: :: @@ -183,8 +193,8 @@ class System: @staticmethod def update_databases(): """Download and update Skyfield and Astropy databases (of earth rotation).""" - sf_api.Loader(_system_data_dir).download('finals2000A.all') - apy_util.data.download_file(apy_util.iers.IERS_A_URL, cache='update') + sf_api.Loader(_system_data_dir).timescale() + apy_util.iers.IERS_Auto.open() def __init__(self, data_folder=None, debug_folder=None): """Create System instance. See class documentation.""" @@ -223,10 +233,14 @@ def __init__(self, data_folder=None, debug_folder=None): self._coarse_track_thread = None self._fine_track_thread = None self._control_loop_thread = ControlLoopThread(self) # Create the control loop thread + self._stellarium_telescope_server = StellariumTelescopeServer(self) + self._target_server = TargetServer(self) # Hardware not managed by threads self._star_cam = None self._receiver = None self._mount = None + self._supported_models = {'mount': Mount._supported_models, 'camera': Camera._supported_models, 'receiver': Receiver._supported_models} + self._default_model = {'mount': Mount._default_model, 'camera': Camera._default_model, 'receiver': Receiver._default_model} self._alignment = Alignment() self._target = Target() # tetra3 instance used for plate solving @@ -234,10 +248,24 @@ def __init__(self, data_folder=None, debug_folder=None): # Variable to stop system thread self._stop_loop = True self._thread = None + # Auto alignment settings + self._auto_align_tolerate_failures = 3 + self._auto_align_vectors = [(40, -135), (60, -135), (60, -45), (40, -45), (40, 45), (60, 45), (60, 135), (40, 135)] + self._auto_align_settle_time_sec = 1 + self._auto_align_max_trials = 3 import atexit import weakref atexit.register(weakref.ref(self.__del__)) self._logger.info('System instance created.') + # Common targets list: + self.saved_targets = { + 'ISS': 25544, + 'CSS': 48274, + 'HST': 20580, + 'Terra': 25994, + } + + def __del__(self): """Destructor. Calls deinitialize().""" @@ -309,6 +337,26 @@ def initialize(self): def deinitialize(self): """Deinitialise camera, mount, and receiver if they are initialised.""" self._logger.debug('Deinitialise called') + if self.stellarium_telescope_server is not None: + self._logger.debug('Has telescope server') + if self.stellarium_telescope_server.is_init: + try: + self.stellarium_telescope_server.stop() + self._logger.debug('Deinitialized') + except BaseException: + self._logger.warning('Failed to deinit', exc_info=True) + else: + self._logger.debug('Not initialised') + if self.target_server is not None: + self._logger.debug('Has target server') + if self.target_server.is_init: + try: + self.target_server.stop() + self._logger.debug('Deinitialized') + except BaseException: + self._logger.warning('Failed to deinit', exc_info=True) + else: + self._logger.debug('Not initialised') if self.star_camera is not None: self._logger.debug('Has star cam') if self.star_camera.is_init: @@ -427,6 +475,16 @@ def control_loop_thread(self): """System.ControlLoopThread: Get the system control loop thread.""" return self._control_loop_thread + @property + def stellarium_telescope_server(self): + """System.StellariumTelescopeServer: Get the Stellarium telescope server object.""" + return self._stellarium_telescope_server + + @property + def target_server(self): + """System.TargetServer: Get the target server object.""" + return self._target_server + @property def alignment(self): """pypogs.Alignment: Get the system alignment object.""" @@ -459,7 +517,7 @@ def star_camera(self, cam): self._star_cam = cam self._logger.debug('Star camera set to: ' + str(self.star_camera)) - def add_star_camera(self, model=None, identity=None, name='StarCamera', auto_init=True): + def add_star_camera(self, model=None, identity=None, name='StarCamera', auto_init=True, **properties): """Create and set the star camera. Calls pypogs.Camera constructor with name='StarCamera' and the given arguments. @@ -487,11 +545,11 @@ def add_star_camera(self, model=None, identity=None, name='StarCamera', auto_ini self.star_camera = None self._logger.debug('Create new camera') self.star_camera = Camera(model=model, identity=identity, name=name, - auto_init=auto_init) + auto_init=auto_init, properties=properties) else: self._logger.debug('Dont have anything old to clean up, create new camera') self.star_camera = Camera(model=model, identity=identity, name=name, - auto_init=auto_init) + auto_init=auto_init, properties=properties) return self.star_camera def add_star_camera_from_coarse(self): @@ -537,7 +595,7 @@ def coarse_camera(self, cam): self._coarse_track_thread.camera = cam self._logger.debug('Set coarse camera to: ' + str(self.coarse_camera)) - def add_coarse_camera(self, model=None, identity=None, name='CoarseCamera', auto_init=True): + def add_coarse_camera(self, model=None, identity=None, name='CoarseCamera', auto_init=True, **properties): """Create and set the coarse camera. Calls pypogs.Camera constructor with name='CoarseCamera' and the given arguments. @@ -571,7 +629,7 @@ def add_coarse_camera(self, model=None, identity=None, name='CoarseCamera', auto else: self._logger.debug('Dont have anything old to clean up, create new camera') self.coarse_camera = Camera(model=model, identity=identity, name=name, - auto_init=auto_init) + auto_init=auto_init, properties=properties) return self.coarse_camera def add_coarse_camera_from_star(self): @@ -617,7 +675,7 @@ def fine_camera(self, cam): self._fine_track_thread.camera = cam self._logger.debug('Set fine camera to: ' + str(self.fine_camera)) - def add_fine_camera(self, model=None, identity=None, name='FineCamera', auto_init=True): + def add_fine_camera(self, model=None, identity=None, name='FineCamera', auto_init=True, **properties): """Create and set the fine camera. Calls pypogs.Camera constructor with name='FineCamera' and the given arguments. @@ -650,13 +708,40 @@ def add_fine_camera(self, model=None, identity=None, name='FineCamera', auto_ini else: self._logger.debug('Dont have anything old to clean up, create new camera') self.fine_camera = Camera(model=model, identity=identity, name=name, - auto_init=auto_init) + auto_init=auto_init, properties=properties) return self.fine_camera def clear_fine_camera(self): """Set the fine camera to None.""" self.fine_camera = None + @property + def auto_align_vectors(self): + """Get or set reference vectors for auto alignment.""" + return self._auto_align_vectors + + @auto_align_vectors.setter + def auto_align_vectors(self, vectors): + self._auto_align_vectors = vectors + + @property + def auto_align_settle_time_sec(self): + """Get or set time in milliseconds to settle after motion and before acquiring an image""" + return self._auto_align_settle_time_sec + + @auto_align_settle_time_sec.setter + def auto_align_settle_time_sec(self, seconds): + self._auto_align_settle_time_sec = seconds + + @property + def auto_align_max_trials(self): + """Get or set number of trials allowed at each alignment position""" + return self._auto_align_max_trials + + @auto_align_max_trials.setter + def auto_align_max_trials(self, n_trials): + self._auto_align_max_trials = n_trials + @property def coarse_track_thread(self): """pypogs.TrackingThread: Get the coarse tracking thread.""" @@ -690,7 +775,7 @@ def receiver(self, rec): self._logger.debug('Receiver set to: ' + str(self._receiver)) def add_receiver(self, *args, **kwargs): - """Create and set a pypogs.Receiver for the system. Arguments passed to constructor. + """Create and set a pypogs.Receiver for the system. Arguments passed to contructor. Args: model (str, optional): The model used to determine the correct hardware API. Supported: @@ -726,11 +811,12 @@ def mount(self, mount): self._logger.debug('Got set mount with: ' + str(mount)) if self.mount is not None: self._logger.debug('Already have a mount, try to clear it') - try: - self.mount.deinitialize() - self._logger.debug('Deinit') - except BaseException: - self._logger.debug('Failed to deinit', exc_info=True) + if self.mount._is_init: + try: + self.mount.deinitialize() + self._logger.debug('Deinit') + except BaseException: + self._logger.debug('Failed to deinit', exc_info=True) self._mount = None self._logger.debug('Cleared') if mount is not None: @@ -765,6 +851,16 @@ def clear_mount(self): """Set the mount to None.""" self.mount = None + @property + def auto_align_tolerate_failures(self): + """Get or set number of failures to tolerate during auto alignment. + """ + return self._auto_align_tolerate_failures + + @auto_align_tolerate_failures.setter + def auto_align_tolerate_failures(self, failure_count): + self._auto_align_tolerate_failures = failure_count + @property def tetra3(self): """tetra3.Tetra3: Get or set the tetra3 instance used for plate solving star images. Will @@ -782,7 +878,7 @@ def tetra3(self, tetra3): self._tetra3 = tetra3 delf._logger.debug('Set tetra3 instace') - def do_auto_star_alignment(self, max_trials=1, rate_control=True): + def do_auto_star_alignment(self, max_trials=None, rate_control=True, pos_list=None, settle_time_sec=None): """Do the auto star alignment procedure by taking eight star images across the sky. Will call System.Alignment.set_alignment_from_observations() with the captured images. @@ -797,12 +893,18 @@ def do_auto_star_alignment(self, max_trials=1, rate_control=True): assert self.star_camera is not None, 'No star camera' assert self.is_init, 'System not initialized' assert not self.is_busy, 'System is busy' + + if pos_list is None: + pos_list = self.auto_align_vectors + if settle_time_sec is None: + settle_time_sec = self.auto_align_settle_time_sec + if max_trials is None: + max_trials = self.auto_align_max_trials def run(): - self._logger.info('Starting auto-alignment.') + self._logger.info('Starting auto-alignment with reference vectors: ' + str(pos_list)) + print('Starting auto-alignment with reference vectors: ' + str(pos_list) + ' and settling time ' + str(settle_time_sec)) try: - pos_list = [(40, -135), (60, -135), (60, -45), (40, -45), (40, 45), (60, 45), - (60, 135), (40, 135)] alignment_list = [] start_time = apy_time.now() # Create logfile @@ -822,19 +924,43 @@ def run(): writer.writerow(['RA', 'DEC', 'ROLL', 'FOV', 'PROB', 'TIME', 'ALT', 'AZI', 'TRIAL']) + failure_count = 0 + for idx, (alt, azi) in enumerate(pos_list): + assert not self._stop_loop, 'Thread stop flag is set' self._logger.info('Getting measurement at Alt: ' + str(alt) + ' Az: ' + str(azi) + '.') - self.mount.move_to_alt_az(alt, azi, rate_control=rate_control, block=True) - for trial in range(max_trials): + + if not self._stop_loop: + self.mount.move_to_alt_az(alt, azi, rate_control=rate_control, tolerance_deg=0.01, block=False) + + # Wait for mount to settle: + self._logger.info('Waiting '+str(settle_time_sec)+' seconds for mount to settle.') + waited_time = 0 # sec + check_period = 0.001 # sec + while waited_time < settle_time_sec and not self._stop_loop: + sleep(check_period) + waited_time += check_period + + for trial in range(0,max_trials+1): assert not self._stop_loop, 'Thread stop flag is set' - img = self.star_camera.get_next_image() + self._logger.info('trial ' + str(trial) + ' at Alt: ' + str(alt) + ' Az: ' + str(azi)) + img = self.star_camera.get_new_image() timestamp = apy_time.now() # TODO: Test fov_estimate = self.star_camera.plate_scale * img.shape[1] / 3600 - solve = self.tetra3.solve_from_image(img, fov_estimate=fov_estimate, - fov_max_error=.1) + self._logger.debug('FOV estimate: ' + str(fov_estimate)) + solution = self.tetra3.solve_from_image(img, + fov_estimate=fov_estimate, + sigma=4, + filtsize=21, + sigma_mode='global_root_square', + pattern_checking_stars=10, + fov_max_error=1, # deg + ) self._logger.debug('TIME: ' + timestamp.iso) + self._logger.info('Solution: ' + str(solution)) + #self._logger.debug('Solution: ' + str(solution)) # Save image tiff_write(self.data_folder / (start_time.strftime('%Y-%m-%dT%H%M%S') + '_Alt' + str(alt) + '_Azi' + str(azi) @@ -842,19 +968,23 @@ def run(): # Save result to logfile with open(data_file, 'a') as file: writer = csv_write(file) - data = np.hstack((solve['RA'], solve['Dec'], solve['Roll'], - solve['FOV'], solve['Prob'], timestamp.iso, alt, azi, + data = np.hstack((solution['RA'], solution['Dec'], solution['Roll'], + solution['FOV'], solution['Prob'], timestamp.iso, alt, azi, trial + 1)) writer.writerow(data) - if solve['RA'] is not None: + if solution['RA'] is not None: + alignment_list.append((solution['RA'], solution['Dec'], timestamp, alt, azi)) break - elif trial + 1 < max_trials: + elif trial < max_trials: self._logger.debug('Failed attempt '+str(trial+1)) else: - self._logger.debug('Failed attempt '+str(trial+1)+', skipping...') - alignment_list.append((solve['RA'], solve['Dec'], timestamp, alt, azi)) + self._logger.info('Failed attempt '+str(trial+1)+', skipping...') + failure_count += 1 + if failure_count > self._auto_align_tolerate_failures: + self._logger.warning('Failed to solve at '+str(failure_count)+' positions. Stopping auto-alignment.', exc_info=True) + self._stop_loop = True - self.mount.move_home(block=False) + #self.mount.move_home(block=False) # Set the alignment! assert len(alignment_list) > 0, 'Did not identify any star patterns' self.alignment.set_alignment_from_observations(alignment_list) @@ -867,6 +997,7 @@ def run(): self._thread = Thread(target=run) self._stop_loop = False self._thread.start() + def get_alt_az_of_target(self, times=None, time_step=.1): """Get the corrected altitude and azimuth angles and rates of the target from the current @@ -884,7 +1015,7 @@ def get_alt_az_of_target(self, times=None, time_step=.1): numpy.ndarray: Nx2 array with altitude and azimuth rates in degrees per second. """ if times is None: - times = apy_time.now() + times = apy_time.now() + apy_time_delta(self._control_loop_thread.projection_time_adjustment, format='sec') assert isinstance(times, apy_time), 'Times must be astropy time' # Extend the time vector by 0.1 second if only one time single_time = (times.size == 1) @@ -899,8 +1030,11 @@ def get_alt_az_of_target(self, times=None, time_step=.1): elif isinstance(self.target.target_object, apy_coord.SkyCoord): vec = self.target.get_target_itrf_xyz(times) alt_az = self.alignment.get_com_altaz_from_itrf_xyz(vec) + elif isinstance(self.target.target_object, Ephem): + enu_altaz = self.target.target_object.project_ephem(times) + alt_az = self.alignment.get_com_altaz_from_enu_altaz(enu_altaz) else: - raise RuntimeError('The target is of unknown type!') + raise RuntimeError('The target is of unknown type! (%s)' % type(self.target.target_object)) angvel_alt_az = (((alt_az[:, 1:] - alt_az[:, :-1] + 180) % 360) - 180) / dt if single_time: @@ -921,15 +1055,18 @@ def get_itrf_direction_of_target(self, times=None): """ assert self.target.has_target, 'No target set' if times is None: - times = apy_time.now() + times = apy_time.now() + apy_time_delta(self._control_loop_thread.projection_time_adjustment, format='sec') assert isinstance(times, apy_time), 'Times must be astropy time' if isinstance(self.target.target_object, sgp4.EarthSatellite): pos = self.target.get_target_itrf_xyz(times) itrf_xyz = self.alignment.get_itrf_relative_from_position(pos) elif isinstance(self.target.target_object, apy_coord.SkyCoord): itrf_xyz = self.target.get_target_itrf_xyz(times) + elif isinstance(self.target.target_object, Ephem): + enu_altaz = self.target.target_object.project_ephem(times) + itrf_xyz = self.alignment.get_itrf_xyz_from_enu_altaz(enu_altaz) else: - raise RuntimeError('The target is of unknown type!') + raise RuntimeError('The target is of unknown type! (%s)' % type(self.target.target_object)) itrf_xyz /= np.linalg.norm(itrf_xyz, axis=0, keepdims=True) return itrf_xyz @@ -939,7 +1076,7 @@ def slew_to_target(self, time=None, block=True, rate_control=True): Args: time (astropy.time.Time, optional): The time to calculate target position. If None (the default) the current time is used. - block (bool, optional): If True (the default), execution is blocked until move + block (bool, optional): If True (the default), excecution is blocked until move finishes. rate_control (bool, optional): If True (the default) rate control (see pypogs.Mount) is used. @@ -947,14 +1084,14 @@ def slew_to_target(self, time=None, block=True, rate_control=True): assert self.mount is not None, 'No Mount' assert self.is_init, 'System not initialized' if time is None: - time = apy_time.now() + time = apy_time.now() + apy_time_delta(self._control_loop_thread.projection_time_adjustment, format='sec') assert isinstance(time, apy_time), 'Times must be astropy time' if time.size == 1: alt_azi = self.get_alt_az_of_target(time)[0] else: alt_azi = self.get_alt_az_of_target(time[0])[0] - self.mount.move_to_alt_az(alt_azi[0], alt_azi[1], block=block) + self.mount.move_to_alt_az(alt_azi[0], alt_azi[1], tolerance_deg=5, block=block) def start_tracking(self): """Track the target, using closed loop feedback if defined. @@ -985,6 +1122,7 @@ def stop(self): except BaseException: self._logger.warning('Failed to join system worker thread.', exc_info=True) if self.mount is not None and self.mount.is_init: + self._logger.info('Stopping mount') try: self.mount.stop() except BaseException: @@ -1055,18 +1193,18 @@ def do_alignment_test(self, max_trials=2, rate_control=True): self.mount.move_to_alt_az(*altaz, rate_control=rate_control, block=True) for trial in range(max_trials): img = self.star_camera.get_next_image() - timestamp = apy_time.now() + timestamp = apy_time.now() + apy_time_delta(self._control_loop_thread.projection_time_adjustment, format='sec') # TODO: Test fov_estimate = self.star_camera.plate_scale * img.shape[1] / 3600 - solve = self.tetra3.solve_from_image(img, fov_estimate=fov_estimate, fov_max_error=.1) + solution = self.tetra3.solve_from_image(img, fov_estimate=fov_estimate, fov_max_error=.1) self._logger.debug('TIME: ' + timestamp.iso) # Save image tiff_write(self.data_folder / (test_time.strftime('%Y-%m-%dT%H%M%S') + '_Alt' + str(alt) + '_Azi' + str(azi) + '_Try' + str(trial + 1) + '.tiff'), img) - if solve['RA'] is not None: + if solution['RA'] is not None: # ra,dec,time to ITRF - c = apy_coord.SkyCoord(solve['RA'], solve['Dec'], obstime=timestamp, + c = apy_coord.SkyCoord(solution['RA'], solution['Dec'], obstime=timestamp, unit='deg') c = c.transform_to(apy_coord.ITRS) xyz_observed = [c.x.value, c.y.value, c.z.value] @@ -1085,14 +1223,14 @@ def do_alignment_test(self, max_trials=2, rate_control=True): # Save result to logfile with open(data_file, 'a') as file: writer = csv_write(file) - data = np.hstack((solve['RA'], solve['Dec'], solve['Roll'], solve['FOV'], - solve['Prob'], timestamp.iso, alt, azi, alt_obs, azi_obs, + data = np.hstack((solution['RA'], solution['Dec'], solution['Roll'], solution['FOV'], + solution['Prob'], timestamp.iso, alt, azi, alt_obs, azi_obs, trial+1)) print(data) writer.writerow(data) - if solve['RA'] is not None: + if solution['RA'] is not None: break - elif trial + 1 < max_trials: + elif trial < max_trials: print('Failed attempt '+str(trial+1)) else: print('Failed attempt '+str(trial+1)+', skipping...') @@ -1111,7 +1249,7 @@ class Alignment: Alignment refers to the different coordinate frames in use to get the telescope to point in the correct direction. A direction in some coordinate frame can either be represented as a cartesian unit vector or as two angles: altitude and azimuth. In the former case they are - appended by _xyz and in the latter by _altaz. There are four coordinate frames to consider, + appended by _xyz and in the latter by _altaz. There are four coodinate frames to consider, ITRF, ENU, MNT, and COM, see below for descriptions. The fundamental coordinates used are ITRF_xyz unit vectors. They give direction in an earth @@ -1193,6 +1331,11 @@ def __init__(self, data_folder=None, debug_folder=None): self._logger.addHandler(ch) self._logger.debug('Alignment constructor called') + # Alignment points + + self._alignment_file_header = ['Ma', 'Mb', 'Mc', 'Alt0', 'Cvd', 'Cnp', 'Mz_std', 'My_std', + 'Alt0_std', 'Cvd_std', 'Cnp_std'] + # Data folder setup self._data_folder = None if data_folder is None: @@ -1203,7 +1346,7 @@ def __init__(self, data_folder=None, debug_folder=None): self._telescope_ITRF = None # Tel. location ITRF (xyz) in metres self._location = None # Telescope location astropy EarthLocation # Transformation matrices - self._MX_itrf2enu = None # Matrix transforming vectors in ITRF-xyz to ENU-xyz + self._MX_itrf2enu = None # Matrix tranforming vectors in ITRF-xyz to ENU-xyz self._MX_enu2itrf = None # Inverse of above self._MX_itrf2mnt = None # Matrix transforming vectors in ITRF-xyz to MNT-xyz self._MX_mnt2itrf = None # Inverse of above @@ -1716,8 +1859,7 @@ def set_alignment_from_observations(self, obs_data, alt0=None, Cvd=None, Cnp=Non + '_Alignment_from_obs.csv') with open(log_path, 'w') as logfile: logwriter = csv_write(logfile) - logwriter.writerow(['Ma', 'Mb', 'Mc', 'Alt0', 'Cvd', 'Cnp', 'Mz_std', 'My_std', - 'Alt0_std', 'Cvd_std', 'Cnp_std']) + logwriter.writerow(self._alignment_file_header) logwriter.writerow([Mx, My, Mz, alt0, Cvd, Cnp, Mz_sstd, My_sstd, alt0_sstd, Cvd_sstd, Cnp_sstd]) @@ -1751,6 +1893,35 @@ def set_alignment_enu(self): self._Cvd = 0 self._Cnp = 0 + def set_alignment_from_alignment_data(self, Ma, Mb, Mc, alt0=None, Cvd=None, Cnp=None): + self._MX_itrf2mnt = np.vstack((Ma, Mb, Mc)) + self._MX_mnt2itrf = self._MX_itrf2mnt.transpose() + self._Alt0 = alt0 + self._Cvd = Cvd + self._Cnp = Cnp + + def get_alignment_data_form_file(self, alignment_from_obs_filename): + self._logger.info('Loading alignment file: '+alignment_from_obs_filename) + alignment_file = open(alignment_from_obs_filename) + alignment_file_reader = csv_reader(alignment_file) + header = next(alignment_file_reader) + self._logger.debug('Alignment file header: ' + str(header)) + assert header == self._alignment_file_header, 'Unrecognized CSV header: '+str(header) + entry = None + for idx, row in enumerate(alignment_file_reader): + if len(row)>0 and len(row[0])>0: + entry = row + break + assert entry is not None, 'Failed to read alignment file'+str(alignment_from_obs_filename) + Ma = [float(item) for item in entry[0].replace('[','').replace(']','').split()] + Mb = [float(item) for item in entry[1].replace('[','').replace(']','').split()] + Mc = [float(item) for item in entry[2].replace('[','').replace(']','').split()] + alt0 = float(entry[3]) + Cvd = float(entry[4]) + Cnp = float(entry[5]) + self._logger.info('Loaded alignment data:'+str((Ma,Mb,Mc,alt0,Cvd,Cnp))) + self.set_alignment_from_alignment_data(Ma, Mb, Mc, alt0=alt0, Cvd=Cvd, Cnp=Cnp) + class Target: """Target to track and start and end times. @@ -1766,7 +1937,7 @@ class Target: You may also give a start and end time (e.g. useful for satellite rise and set times) when creating the target or by the method Target.set_start_end_time(). - With a target set, get the ITRF_xyz coordinates at your preferred times with + With a target set, get the ITRF_xyz coordinates at your prefered times with Target.get_target_itrf_xyz(). Note: @@ -1776,17 +1947,25 @@ class Target: metres) from the centre of Earth to the satellite. """ - _allowed_types = (apy_coord.SkyCoord, sgp4.EarthSatellite) + _allowed_types = (apy_coord.SkyCoord, sgp4.EarthSatellite, Ephem) def __init__(self): - """Create Alignment instance. See class documentation.""" + """Create Target instance. See class documentation.""" self._target = None + self._source = None self._rise_time = None self._set_time = None self._tle_line1 = None self._tle_line2 = None self._skyfield_ts = sf_api.Loader(_system_data_dir, expire=False).timescale() + + self._ephem = None + + class Sources: + TLE = 0 + RADEC = 1 + EPHEM = 2 @property def has_target(self): @@ -1807,6 +1986,20 @@ def target_object(self, target): assert isinstance(target, self._allowed_types), \ 'Must be None or of type ' + str(self._allowed_types) self._target = target + + @property + def source(self): + """ Index of selected source of target coordinates """ + return self._source + + def set_source(self, target_source_name): + """ Sets source of target coordinates + + Args: + target_source_name (string): source of target coordinates ('TLE', 'RADEC', 'EPHEM') + """ + assert hasattr(self.Sources, target_source_name), 'Invalid target source: "%s"' % target_source_name + self._source = getattr(self.Sources, target_source_name) def set_target_from_ra_dec(self, ra, dec, start_time=None, end_time=None): """Create an Astropy *SkyCoord* and set as the target. @@ -1819,6 +2012,44 @@ def set_target_from_ra_dec(self, ra, dec, start_time=None, end_time=None): """ self.target_object = apy_coord.SkyCoord(ra, dec, unit='deg') self.set_start_end_time(start_time, end_time) + + def get_tle_from_sat_id(self, sat_id): + """Fetches TLE for a satellite specified by Satellite Catalog ID. + + Args: + sat_id (unsigned int): Satellite Catalog ID number. + """ + try: + tle_from_celestrak = fetch_tle_from_celestrak(sat_id) + tle = (tle_from_celestrak[1], tle_from_celestrak[2], tle_from_celestrak[0]) #reorder + except IndexError: + tle = None + return tle + + def get_and_set_tle_from_sat_id(self, sat_id): + """Fetches TLE for a satellite specified by Satellite Catalog ID and sets TLE and target. + + Args: + sat_id (unsigned int): Satellite Catalog ID number. + """ + tle = self.get_tle_from_sat_id(sat_id) + if tle is not None: + self.set_target_from_tle(tle) + + def get_ephem(self, obj_id, lat, lon, height): + """Fetches and pre-caches ephemeris data from JPL Horizons API + + Args: + obj_id (signed int): NAIF object ID number. + lat (float): Site North latitude (degrees) + lon (float): Site East longitude (degrees) + height (float): Site elevation above mean sea level (meters) + """ + utc_now = apy_time.now() + utc_tomorrow = utc_now + apy_time_delta(1, format='jd') + time_step_minutes = 30 + self._ephem = Ephem(obj_id, utc_now.strftime('%Y-%m-%d %H:00:00'), utc_tomorrow.strftime('%Y-%m-%d %H:00:00'), time_step_minutes, lat, lon, height) + self.target_object = self._ephem def set_target_deep_by_name(self, name, start_time=None, end_time=None): """Use Astropy name lookup for setting a SkyCoord deep sky target. @@ -1913,10 +2144,12 @@ def get_short_string(self): if self._target is None: return 'No target' elif isinstance(self._target, sgp4.EarthSatellite): - return 'TLE #' + str(self._target.model.satnum) + return 'Source: TLE #' + str(self._target.model.satnum) elif isinstance(self._target, apy_coord.SkyCoord): - return 'RA:' + str(round(self._target.ra.to_value('deg'), 2)) + DEG \ + return 'Source: RA:' + str(round(self._target.ra.to_value('deg'), 2)) + DEG \ + ' D:' + str(round(self._target.dec.to_value('deg'), 2)) + DEG + elif isinstance(self._target, Ephem): + return 'Source: ephem obj #' + str(self._ephem.obj_id) def get_target_itrf_xyz(self, times=None): """Get the ITRF_xyz position vector from the centre of Earth to the EarthSatellite (in @@ -1930,7 +2163,7 @@ def get_target_itrf_xyz(self, times=None): """ assert self.has_target, 'No target set.' if times is None: - times = apy_time.now() + times = apy_time.now() + apy_time_delta(self._control_loop_thread.projection_time_adjustment, format='sec') if isinstance(self._target, apy_coord.SkyCoord): itrs = self._target.transform_to(apy_coord.ITRS(obstime=times)) return np.array(itrs.data.xyz) @@ -1939,3 +2172,338 @@ def get_target_itrf_xyz(self, times=None): itrf_xyz = (self._target.ITRF_position_velocity_error(ts_time)[0] * apy_unit.au.in_units(apy_unit.m)) return itrf_xyz + + +class StellariumTelescopeServer: + """StellariumTelescopeServer creates a telescope telemetry and control daemon to + serve telescope position data to Stellarium and receives slew commands. + + The default hosted server address is localhost (127.0.0.1) port 10001. + + See Stellarium documentation for instructions for how to connect to this + telescope interface as "External software or remote computer". + + The StellariumTelescopeServer thread manages TCP connections + """ + def __init__(self, parent, address='127.0.0.1', port=10001, poll_period=0.1, debug_folder=None): + """Create Server instance. See class documentation.""" + self._address = address + self._port = port + self._poll_period = poll_period # sec + + self.parent = parent + + # Logger setup + self._debug_folder = None + if debug_folder is None: + self.debug_folder = Path(__file__).parent / 'debug' + else: + self.debug_folder = debug_folder + self._logger = logging.getLogger('pypogs.system.Server') + if not self._logger.hasHandlers(): + # Add new handlers to the logger if there are none + self._logger.setLevel(logging.DEBUG) + # Console handler at INFO level + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + # File handler at DEBUG level + fh = logging.FileHandler(self.debug_folder / 'pypogs.txt') + fh.setLevel(logging.DEBUG) + # Format and add + formatter = logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s') + fh.setFormatter(formatter) + ch.setFormatter(formatter) + self._logger.addHandler(fh) + self._logger.addHandler(ch) + + # Start of constructor + self._logger.debug('System constructor called') + self._thread = None + self._stop_loop = False + self._is_init = False + + @property + def is_init(self): + """Indicate whether this thread is active""" + return self._is_init + + @property + def address(self): + """This server address""" + return self._address + + @address.setter + def address(self, address): + self._address = address + + @property + def port(self): + """Server port""" + return self._port + + @port.setter + def port(self, port): + self._port = port + + def start(self, address=None, port=None, poll_period=None): + self._address = address if address is not None else self._address + self._port = port if port is not None else self._port + self._poll_period = poll_period if poll_period is not None else self._poll_period # sec + self._thread = None + + def run(): + if self.parent.mount is not None and self.parent.mount.model == 'ASCOM': + import pythoncom + pythoncom.CoInitialize() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind((self._address, self._port)) + s.settimeout(0.5) + + # WAITING FOR CONNECTION LOOP + s.listen() + self._logger.info('Listening for connection on %s port %i' % (self._address, self._port)) + while not self._stop_loop and self.parent.is_init: + try: + conn, addr = s.accept() + conn.settimeout(self._poll_period) + + # CONNECTED LOOP + self._logger.info('New connection from %s:%d' % addr) + while not self._stop_loop and self.parent.is_init: + if self.parent.mount and self.parent.mount.is_init and self.parent.alignment.is_aligned and self.parent.alignment.is_located: + # Receive go-to coordinates from Stellarium: + try: + data = conn.recv(1024) + if data: + #print('Received',len(data),'bytes:',data) + ra_raw = int.from_bytes(data[12:16], byteorder='little', signed=False) + dec_raw = int.from_bytes(data[16:20], byteorder='little', signed=True) + ra_goal = float(ra_raw)*180/2147483648 # deg + dec_goal = float(dec_raw)*90/1073741824 # deg + + # ra,dec,time to ITRF + #c = apy_coord.SkyCoord(ra_goal, dec_goal, unit='deg').transform_to(apy_coord.ITRS) + #(alt_goal, azi_goal) = self.parent.alignment.get_enu_altaz_from_itrf_xyz( + # [c.x.value, c.y.value, c.z.value] + #) + self._logger.info('Received go-to instruction to RA: %0.5f deg, Dec: %0.5f deg' % (ra_goal, dec_goal)) + if self.parent.mount.is_init: + self.parent.target.set_target_from_ra_dec(ra_goal, dec_goal) + self.parent.target.set_source('RADEC') + itrf_xyz = self.parent.get_itrf_direction_of_target() + enu_altaz = self.parent.alignment.get_enu_altaz_from_itrf_xyz(itrf_xyz) + if not self.parent.is_busy: + try: + self.parent.mount.move_to_alt_az(*enu_altaz, block=False) + except BaseException: + self._logger.warning('Failed to command mount to requested coordinates.', exc_info=True) + + else: + self._logger.info('Disconnected') + self._logger.info('Listening for connection...') + break + except socket.timeout: # (no data) + pass + except KeyboardInterrupt: + self._stop_loop = True + break + + # Send mount coordinates to Stellarium: + try: + mount_alt = self.parent.mount._state_cache['alt'] + mount_azi = self.parent.mount._state_cache['azi'] + except AttributeError: + continue + mount_ra, mount_dec = (0, 0) + icrs = apy_coord.ICRS() + c = apy_coord.AltAz( + alt = mount_alt*apy_unit.deg, + az = mount_azi*apy_unit.deg, + obstime = apy_time.now(), + location = self.parent.alignment._location + ).transform_to(icrs) + (mount_ra, mount_dec) = (c.ra.value, c.dec.value) + mount_ra = (mount_ra + 360) % 360 + #print(mount_ra, mount_dec) + position_report = struct.pack(' 1: # If more than 1 degree off - self._log_info('Slewing to target start position.') - self._parent.slew_to_target(start_time) + difference = (target_alt_az - mount_alt_az + 180) % 360 - 180 + difference_norm = np.sqrt(np.sum( difference ** 2)) + if difference_norm > 5: # If more than 5 degrees off + seconds_to_slew_each_axis_to_current_target_position = np.array(( + abs(difference[0]) / self._parent.mount.max_rate[0], + abs(difference[1]) / self._parent.mount.max_rate[1] + )) + seconds_to_slew_to_current_target_position = apy_time_delta(np.max(seconds_to_slew_each_axis_to_current_target_position), format='sec') + self._log_info('angular distance to target: '+str(difference)+' deg') + self._log_info('time to slew each axis: '+str(seconds_to_slew_each_axis_to_current_target_position)) + self._log_info('time to slew: '+str(seconds_to_slew_to_current_target_position)) + self._log_info('Slewing to projected target position '+str(seconds_to_slew_to_current_target_position)+' seconds from now') + self._parent.slew_to_target(start_time + seconds_to_slew_to_current_target_position) while start_time > apy_time.now(): # Wait to start self._log_info('Waiting for target to rise.') sleep(min(10, (start_time - apy_time.now()).sec)) @@ -781,7 +859,7 @@ def seconds_since_start(): return precision_timestamp() - start_timestamp # CONTROL LOOP # # Time info: loop_timestamp = seconds_since_start() - loop_utctime = apy_time.now() # Astropy timestamp in UTC + loop_utctime = apy_time.now() + apy_time_delta(self.projection_time_adjustment, format='sec') # Astropy timestamp in UTC dt = loop_timestamp - last_timestamp if last_timestamp is not None else 0.0 last_timestamp = loop_timestamp self._log_debug('Control loop timestamp: '+str(loop_timestamp)) @@ -1075,9 +1153,10 @@ def seconds_since_start(): return precision_timestamp() - start_timestamp self._parent.coarse_track_thread.goal_offset_x_y = list(rot_offset) self._log_debug('FB updated: ' + str(angvel_correction)) self._log_debug('Offset set: ' + str(rot_offset)) - # Calculate total rates + # Calculate total rates angvel_total = self._get_safe_rates(angvel_correction + target_mnt_rate, mount_mnt_altaz) + angvel_total = self._avoid_rates(angvel_total) self._log_debug('Sending rates: ' + str(angvel_total)) # Check post-calc clearing conditions if saturated and self._reset_integral_if_saturated: @@ -1185,6 +1264,36 @@ def seconds_since_start(): return precision_timestamp() - start_timestamp self._parent.receiver.stop() self._log_info('Tracking ended') + def _avoid_rates(self, desired_rates): + """PRIVATE: If desired rate for either axis is within the avoidane half-width of a + rate to be avoided, then clip the rate to the lower bound of the avoidance window. + + Args: + desired_rates (numpy.ndarray, tuple, list): length 2 array with desired altitude and + azimuth rates in degrees per second. + """ + # Reorder avoidance rates in descending order: + try: + if self._avoid_alt_rates is not None and self._avoid_alt_rates.shape != (1,): + self._avoid_alt_rates[::-1].sort()[::-1] + if self._avoid_azi_rates is not None and self._avoid_azi_rates.shape != (1,): + self._avoid_azi_rates[::-1].sort()[::-1] + except TypeError: + pass + + avoidance_rates = [self._avoid_alt_rates, self._avoid_azi_rates] + adjusted_rates = desired_rates + for axis in [0, 1]: + desired_rate_sign = np.sign(adjusted_rates[axis]) + desired_rate_abs = np.abs(adjusted_rates[axis]) + if avoidance_rates[axis] is not None: + for avoid_rate in avoidance_rates[axis]: + if (avoid_rate-self._rate_avoidance_half_width) < desired_rate_abs < (avoid_rate+self._rate_avoidance_half_width): + adjusted_rates[axis] = (avoid_rate-self._rate_avoidance_half_width)*desired_rate_sign + #print('Clipping axis %i rate to %0.3f' % (axis, avoid_rate-self._rate_avoidance_half_width)) + return adjusted_rates + + def _get_safe_rates(self, desired_rates, curr_alt_az=None): """PRIVATE: Limit the desired rates to safe values such that the maximum speed and position of the Mount is not violated. @@ -1519,8 +1628,9 @@ def _run(self): loop_index = 0 try: while not self._stop_running: + image_wait_timeout = self._camera.exposure_time * 1.1 + 0.5 # Synchronisation and time management - if not self._process_image.wait(timeout=3): + if not self._process_image.wait(timeout=image_wait_timeout): self._log_warning('Timeout waiting for image in loop') image = self._image_data.copy() loop_timestamp = precision_timestamp() - start_timestamp @@ -1895,14 +2005,13 @@ def goal_offset_x_y(self, goal): def _on_image_event(self, image, timestamp, *args, **kwargs): """PRIVATE: Method to attach as camera callback.""" - self._log_debug('Got image event, saving') - self._image_data = image - self._image_timestamp = timestamp - if not self.is_running: - self._log_debug('Not running') - else: + if self.is_running: + self._log_debug('Got image event, saving') + self._image_data = image + self._image_timestamp = timestamp if self._process_image.is_set(): - self._log_warning('Already processing, dropping frame.') + #self._log_warning('Already processing, dropping frame.') + pass else: self._process_image.set() self._log_debug('Set processing flag') @@ -2075,7 +2184,7 @@ def _log_exception(self, msg, **kwargs): def available_properties(self): """tuple of str: Get the available tracking parameters (e.g. gains).""" return ('successes_to_track', 'fails_to_drop', 'failure_sd_penalty', 'max_search_radius', - 'min_search_radius', 'position_sigma', 'sum_sigma', 'sum_max_sd', 'sum_min_sd', + 'min_search_radius', 'smoothing_parameter', 'position_sigma', 'sum_sigma', 'sum_max_sd', 'sum_min_sd', 'area_sigma', 'area_max_sd', 'area_min_sd', 'crop', 'downsample', 'filtsize', 'sigma_mode', 'bg_subtract_mode', 'image_sigma_th', 'image_th', 'binary_open', 'centroid_window', 'spot_min_sum', 'spot_max_sum', 'spot_min_area', diff --git a/requirements.txt b/requirements.txt index d5b1034..e0216a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,6 @@ pyserial==3.5 scipy==1.7.1 skyfield==1.39 tifffile==2021.7.30 +satellitetle>=0.10.1 +pywin32==303 +zwoasi==0.1.0.1 \ No newline at end of file diff --git a/tetra3 b/tetra3 index 4dc7f45..4231bf3 160000 --- a/tetra3 +++ b/tetra3 @@ -1 +1 @@ -Subproject commit 4dc7f4595c5e2a02f6a616f2b7b4d54f4a574759 +Subproject commit 4231bf3b9fccd61e3bab70134a9d85998c7f606d