diff --git a/pypogs/__init__.py b/pypogs/__init__.py index d7ff2a9..357e5a2 100644 --- a/pypogs/__init__.py +++ b/pypogs/__init__.py @@ -1,6 +1,8 @@ from .system import System, Alignment, Target from .tracking import ControlLoopThread, TrackingThread, SpotTracker -from .hardware import Camera, Mount, Receiver +from .hardware_cameras import Camera +from .hardware_mounts import Mount +from .hardware_receivers import Receiver from .gui import GUI __all__ = ['System', 'Alignment', 'Target' diff --git a/pypogs/hardware.py b/pypogs/hardware_cameras.py similarity index 50% rename from pypogs/hardware.py rename to pypogs/hardware_cameras.py index 0f47cc3..d773bf7 100644 --- a/pypogs/hardware.py +++ b/pypogs/hardware_cameras.py @@ -1,16 +1,14 @@ -"""Hardware interfaces +"""Camera interfaces ====================== -Current hardware support: +Current harware 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: @@ -41,6 +39,7 @@ import numpy as np import serial + class Camera: """Control acquisition and receive images from a camera. @@ -49,7 +48,7 @@ class Camera: auto_init=False is passed). Manually initialise with a call to Camera.initialize(); release hardware with a call to Camera.deinitialize(). - After the Camera is initialised, acquisition properties (e.g. exposure_time and frame_rate) may be set and images + After the Camera is intialised, acquisition properties (e.g. exposure_time and frame_rate) may be set and images received. The Camera also supports event-driven acquisition, see Camera.add_event_callback(), where new images are automatically passed on to the desired functions. @@ -83,7 +82,7 @@ class Camera: # Release the hardware cam.deinitialize() """ - _supported_models = ('ptgrey',) + _supported_models = ('ptgrey','ascom') def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_folder=None): """Create Camera instance. See class documentation.""" @@ -125,6 +124,9 @@ def __init__(self, model=None, identity=None, name=None, auto_init=True, debug_f self._ptgrey_camera = None self._ptgrey_camlist = None self._ptgrey_system = None + #Only used for ascom + self._ascom_camera = None + self._exposure_sec = 0.1 #Callbacks on image event self._call_on_image = set() self._got_image_event = Event() @@ -139,9 +141,11 @@ 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 model is not None: self._logger.debug('Trying to auto-initialise') self.initialize() + else: + self._logger.debug('Skipping auto-initialise') self._logger.debug('Registering destructor') # TODO: Should we register deinitialisor instead? (probably yes...) import atexit, weakref @@ -156,7 +160,7 @@ def __del__(self): pass if self.is_init: try: - self._log_debug('Is initialised, de-initing') + self._log_debug('Is initialized, de-initalizing') except: pass self.deinitialize() @@ -195,6 +199,18 @@ def _ptgrey_release(self): del(self._ptgrey_system) self._ptgrey_system = None self._log_debug('Hardware released') + + 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') + del(self._ascom_camera) + self._log_debug('ASCOM camera hardware released') @property def debug_folder(self): @@ -226,15 +242,17 @@ def model(self): Supported: - 'ptgrey' for FLIR/Point Grey cameras (using Spinnaker/PySpin SDKs). + - '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 intialised device model' model = str(model) assert model.lower() in self._supported_models,\ 'Model type not recognised, allowed: '+str(self._supported_models) @@ -248,13 +266,18 @@ def identity(self): - 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 *ascom*, a driver name may be specified if known, (i.e. DSLR, ASICamera1, ASICamera2, + 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 self.model is not None, 'Must define model first' + assert not self.is_init, 'Cannot change already intialised device' + assert not None in (self.model, identity), 'Must define camera model and identity first' identity = str(identity) if self.model.lower() == 'ptgrey': self._log_debug('Using PtGrey, checking vailidity') @@ -286,6 +309,27 @@ def identity(self, identity): self._ptgrey_camera = None self._identity = identity self._ptgrey_camlist.Clear() + + 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 + #ascom_camera = None + else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -298,6 +342,10 @@ 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() == '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 +354,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: @@ -331,7 +380,7 @@ def initialize(self): self._log_debug('Setting stream mode to newest only') self._ptgrey_camera.TLStream.StreamBufferHandlingMode.SetIntValue( PySpin.StreamBufferHandlingMode_NewestOnly) - class PtGreyEventHandler(PySpin.ImageEventHandler): + class PtGreyEventHandler(PySpin.ImageEvent): """Barebones event handler for ptgrey, just pass along the event to the Camera class.""" def __init__(self, parent): assert parent.model.lower() == 'ptgrey', 'Trying to attach ptgrey event handler to non ptgrey model' @@ -371,9 +420,115 @@ def OnImageEvent(self, img_ptr): self._ptgrey_event_handler = PtGreyEventHandler(self) self._log_debug('Created ptgrey image event handler') - self._ptgrey_camera.RegisterEventHandler( self._ptgrey_event_handler ) + self._ptgrey_camera.RegisterEvent( self._ptgrey_event_handler ) self._log_debug('Registered ptgrey image event handler') self._log_info('Camera successfully initialised') + + 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 not hasattr(self, '_ascom_driver_handler'): + 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._log_debug("Selected camera driver: "+camDriverName) + if not camDriverName: + self._log_debug('User canceled camera selection') + assert camDriverName, 'Unable to identify ASCOM camera.' + self._log_debug('Loading ASCOM camera driver: '+camDriverName) + self._ascom_camera = self._ascom_driver_handler.Dispatch(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" + #self.identity = camDriverName + assert self._ascom_camera is not None, 'ASCOM camera not initialized' + + 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 ASCOM camera imaging loop') + polling_period_sec = 0.05 + while self._is_running: + sleep(polling_period_sec) + 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') + while self.continue_imaging and self.parent._ascom_camera.Connected: + #self.parent._log_debug('Starting ASCOM camera exposure') + self.parent._ascom_camera.StartExposure(self.parent._exposure_sec,True) + waited_time = 0 + timeout = self.parent._exposure_sec + 0.5 + polling_period_sec = 0.05 + while not self.parent._ascom_camera.ImageReady and not waited_time >= timeout: + waited_time += polling_period_sec + sleep(polling_period_sec) + 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)) + else: + self.parent._image_timestamp = datetime.utcnow() + try: + img = np.asarray(self.parent._ascom_camera.ImageArray, dtype=np.uint8).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_data = img + except: + self.parent._log_debug('Failed to access image.') + self.parent._image_data = None + + 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.01) + 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 +536,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() @@ -389,7 +544,7 @@ def deinitialize(self): if self._ptgrey_camera: self._log_debug('Found PtGrey camera, deinitialising') try: - self._ptgrey_camera.UnregisterEventHandler(self._ptgrey_event_handler) + self._ptgrey_camera.UnregisterEvent(self._ptgrey_event_handler) self._log_debug('Unregistered event handler') except: self._log_exception('Failed to unregister event handler') @@ -402,6 +557,11 @@ def deinitialize(self): self._log_exception('Failed to close task') self._log_debug('Trying to release PtGrey hardware resources') self._ptgrey_release() + 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)) @@ -413,6 +573,9 @@ def available_properties(self): 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') + elif self.model.lower() == 'ascom': + return ('flip_x', 'flip_y', 'rotate_90', 'plate_scale', 'rotation', 'binning', 'size_readout',\ + 'gain', 'exposure_time') # FIXME else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -425,6 +588,9 @@ 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() == '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 +603,10 @@ 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() == '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 +619,9 @@ 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() == '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 +634,10 @@ 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() == '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)) @@ -472,6 +649,8 @@ def rotate_90(self): assert self.is_init, 'Camera must be initialised' if self.model.lower() == 'ptgrey': return self._rot90 + elif self.model.lower() == 'ascom': + return self._rot90 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -484,6 +663,10 @@ def rotate_90(self, k): self._log_debug('Using PtGrey camera. Will rotate the received image array ourselves.') self._rot90 = k self._log_debug('rot90 set to: '+str(self._rot90)) + elif self.model.lower() == 'ascom': + self._log_debug('Using ASCOM camera. Will rotate the received image array ourselves.') + self._rot90 = k + self._log_debug('rot90 set to: '+str(self._rot90)) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -534,6 +717,9 @@ def frame_rate_auto(self): val = node.GetValue() self._log_debug('Returning not '+str(val)) return not 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)) @@ -555,6 +741,9 @@ def frame_rate_auto(self, auto): else: self._log_debug('Setting frame rate') node.SetValue(not auto) + 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 +768,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 +793,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') + return 0 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -662,6 +857,9 @@ def gain_auto(self): else: self._log_debug('Unexpected return value') raise RuntimeError('Unknow response from camera') + 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,6 +886,9 @@ def gain_auto(self, auto): else: self._log_debug('Setting gain') node.SetIntValue(node.GetEntryByName(set_to).GetValue()) + 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)) @@ -712,6 +913,9 @@ def gain_limit(self): val = (node1.GetValue(), node2.GetValue()) self._log_debug('Returning '+str(val)) return val + elif self.model.lower() == 'ascom': + self._log_debug('gain setting not yet implemented in ASCOM') + return 0 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -734,6 +938,9 @@ def gain(self): val = node.GetValue() self._log_debug('Returning '+str(val)) return val + elif self.model.lower() == 'ascom': + self._log_debug('gain setting not yet implemented in ASCOM') + return 0 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -764,6 +971,9 @@ def gain(self, gain_db): raise AssertionError('The commanded value is outside the allowed range. See gain_limit') else: raise #Rethrows error + elif self.model.lower() == 'ascom': + self._log_debug('gain setting not yet implemented in ASCOM') + return 0 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -795,6 +1005,9 @@ def exposure_time_auto(self): else: self._log_debug('Unexpected return value') raise RuntimeError('Unknow response from camera') + 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)) @@ -821,6 +1034,9 @@ 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() == '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)) @@ -845,6 +1061,8 @@ def exposure_time_limit(self): val = (node1.GetValue()/1000, node2.GetValue()/1000) self._log_debug('Returning '+str(val)) return val + 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 +1085,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() == 'ascom': + try: + return self._ascom_camera.LastExposureDuration + except: + return 0 else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -898,6 +1121,13 @@ def exposure_time(self, exposure_ms): +' See exposure_time_limit') else: raise #Rethrows error + 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)) @@ -925,6 +1155,12 @@ def binning(self): return val_horiz except PySpin.SpinnakerException: self._log_warning('Failed to read', exc_info=True) + elif self.model.lower() == 'ascom': + binMax = self._ascom_camera.MaxBinX + if binMax: + return binMax + else: + self._log_debug('Error reading camera bin value') else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -958,6 +1194,20 @@ def binning(self, binning): raise AssertionError('Not allowed to change binning now.') else: raise #Rethrows error + elif self.model.lower() == 'ascom': + binMax = self._ascom_camera.MaxBinX + if binMax and binning <= binMax: + try: + print("setting binning to ",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 + print(self._ascom_camera.BinX, binning) + except: + raise AssertionError('Unable to set camera binning') + else: + raise ValueError('exceeds camera max bin val ',binMax) else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -996,6 +1246,13 @@ def size_max(self): self._log_debug('Failure reading', exc_info=True) raise return (val_w, val_h) + 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)) @@ -1024,6 +1281,13 @@ def size_readout(self): self._log_debug('Failure reading', exc_info=True) raise return (val_w, val_h) + 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 +1345,22 @@ 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() == 'ascom': + try: + max_w = self._ascom_camera.CameraXSize + max_h = self._ascom_camera.CameraYSize + binning = self._ascom_camera.binning + if not max_h or not max_w: + raise AssertionError('Unable to read ASCOM camera image size limits.') + if not binning: + raise AssertionError('Unable to read ASCOM camera binning value.') + try: + self._ascom_camera.NumX = max_w/binning + self._ascom_camera.NumY = max_h/binning + 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)) @@ -1097,9 +1377,9 @@ def add_event_callback(self, method): The method should have the signature (image, timestamp, \*args, \*\*kwargs) where: - image (numpy.ndarray): The image data as a 2D numpy array. - - timestamp (datetime.datetime): UTC timestamp when the image event occurred (i.e. when the capture + - timestamp (datetime.datetime): UTC timestamp when the image event occured (i.e. when the capture finished). - - \*args, \*\*kwargs should be allowed for forward compatibility. + - \*args, \*\*kwargs should be allowed for forward compatability. The callback should *not* be used for computations, make sure the method returns as fast as possible. @@ -1119,10 +1399,12 @@ 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() == 'ascom': + return self._ascom_camera.Connected else: self._log_warning('Forbidden model string defined.') raise RuntimeError('An unknown (forbidden) model is defined: '+str(self.model)) @@ -1146,6 +1428,8 @@ 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() == '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)) @@ -1164,6 +1448,8 @@ def stop(self): except: self._log_debug('Could not stop:', exc_info=True) raise RuntimeError('Failed to stop camera acquisition') + 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)) @@ -1237,1204 +1523,4 @@ def get_latest_image(self): """ 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. - """ - - _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') - 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) + return self._image_data \ No newline at end of file diff --git a/pypogs/hardware_mounts.py b/pypogs/hardware_mounts.py new file mode 100644 index 0000000..a96f553 --- /dev/null +++ b/pypogs/hardware_mounts.py @@ -0,0 +1,815 @@ +"""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 +from struct import pack as pack_data + +# 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 continously 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',) + + 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 + # Only used for model ascom + self._ascom_scope_alt_axis = 1 + self._ascom_scope_azi_axis = 0 + # 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. + - '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): + self._logger.debug('Setting model to: '+str(model)) + assert not self.is_init, 'Can not change already intialised 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.) + - 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('Setting identity to: '+str(identity)) + assert not self.is_init, 'Can not change already intialised 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 + elif self.model.lower() == 'ascom': + if not hasattr(self, '_ascom_driver_handler'): + import win32com.client + self._ascom_driver_handler = win32com.client + ascomDriverName = str() + 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.debug("Selected telescope driver: "+ascomDriverName) + if not ascomDriverName: + self._logger.debug('User canceled telescope selection') + assert ascomDriverName, 'Unable to identify ASCOM telescope.' + self._identity = ascomDriverName + 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 + elif self.model.lower() == 'ascom': + 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') + elif self.model.lower() == 'ascom': + return ('zero_altitude', 'home_alt_az', 'max_rate', 'alt_limit', 'azi_limit') + 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 + elif self.model.lower() == "ascom": + ascomDriverName = self.identity; + self._logger.debug('Attempting to connect to ASCOM device "'+ascomDriverName+'"') + self._logger.debug('Loading ASCOM telescope driver: '+ascomDriverName) + self._ascom_telescope = self._ascom_driver_handler.Dispatch(ascomDriverName) + assert hasattr(self._ascom_telescope, 'Connected'), "Unable to access telescope driver" + self._logger.debug('Connecting to telescope') + assert self._ascom_telescope is not None, 'Faile to intialize ASCOM 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 + 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') + elif self.model.lower() == "ascom": + self._logger.debug('Disconnecting ASCOM telescope mount') + self._ascom_telescope.AbortSlew() + self._ascom_telescope.Connected = False + self._ascom_telescope = 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 + 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): + """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 excecution. + """ + 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' or self.model.lower() == "ascom": + 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): + success[0] = command_to_alt_az(alt, azi) + 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() + + def command_to_alt_az(self, alt, azi): + """Command the mount to slew to alt/az coordinates. Must be initialised. + + Args: + alt (float): Altitude (degrees). + azi (float): Azimuth (degrees). + """ + assert self.is_init, 'Must be initialised' + if self.model == 'celestron': + #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) + if self._cel_check_ack(): + self._logger.debug('Mount acknowledged') + return True + else: + self._logger.debug('Mount acknowledged') + return False + 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' + 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) + elif self.model.lower() == "ascom": + self._logger.debug('Using ASCOM, requesting mount position') + alt = self._ascom_telescope.Altitude + azi = self._ascom_telescope.Azimuth + self._logger.debug('Mount returned: 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 excecution. + """ + 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 + elif self.model.lower() == "ascom": + success = [False] + try: + self._ascom_telescope.MoveAxis(self._ascom_scope_alt_axis, alt) + self._ascom_telescope.MoveAxis(self._ascom_scope_azi_axis, azi) + success[0] = True + except: + self._logger.warning('Alt/az rate commanding failed.') + success[0] = False + 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 \ No newline at end of file diff --git a/pypogs/hardware_receivers.py b/pypogs/hardware_receivers.py new file mode 100644 index 0000000..d3de177 --- /dev/null +++ b/pypogs/hardware_receivers.py @@ -0,0 +1,558 @@ +"""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',) + + 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/system.py b/pypogs/system.py index 5b969eb..5b65b97 100644 --- a/pypogs/system.py +++ b/pypogs/system.py @@ -43,7 +43,9 @@ # Internal imports: from tetra3 import Tetra3 -from .hardware import Camera, Mount, Receiver +from .hardware_cameras import Camera +from .hardware_mounts import Mount +from .hardware_receivers import Receiver from .tracking import TrackingThread, ControlLoopThread # Useful definitions: @@ -66,7 +68,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: @@ -133,7 +135,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. @@ -181,8 +183,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.""" @@ -686,7 +688,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: @@ -762,7 +764,7 @@ def clear_mount(self): self.mount = None def do_auto_star_alignment(self, max_trials=1, rate_control=True): - """Do the auto star alignment procedure by taking eight star images across the sky. + """Do the auto star alginment procedure by taking eight star images across the sky. Will call System.Alignment.set_alignment_from_observations() with the captured images. @@ -920,7 +922,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. @@ -1095,7 +1097,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 @@ -1187,7 +1189,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 @@ -1750,7 +1752,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: diff --git a/pypogs/tracking.py b/pypogs/tracking.py index e0ad3cc..63551a1 100644 --- a/pypogs/tracking.py +++ b/pypogs/tracking.py @@ -46,7 +46,7 @@ # Internal imports: sys.path.append('..') # Add one directory up to path from tetra3 import get_centroids_from_image -from .hardware import Camera +from .hardware_cameras import Camera EPS = 10**-6 # Epsilon for use in non-zero check DEG = chr(176) # Degree (unicode) character