class DirectWeather: """ the class DirectWeather inherits all information and handling of the environment device >>> DirectWeather(host=None, >>> name='' >>> ) """ __all__ = [ 'DirectWeather', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, app=None): self.app = app # minimum set for driver package built in self.framework = None self.run = {'built-in': self} self.name = '' @staticmethod def startCommunication(): return True @staticmethod def stopCommunication(): return True
def module_setup_teardown(qtbot): global ui, widget, Test, Test1, app class Test1(QObject): mount = Mount() update1s = pyqtSignal() update10s = pyqtSignal() threadPool = QThreadPool() class Test(QObject): config = {'mainW': {}} threadPool = QThreadPool() update1s = pyqtSignal() message = pyqtSignal(str, int) mount = Mount() mount.obsSite.location = Topos(latitude_degrees=20, longitude_degrees=10, elevation_m=500) sensorWeather = SensorWeather(app=Test1()) onlineWeather = OnlineWeather(app=Test1()) directWeather = DirectWeather(app=Test1()) skymeter = Skymeter(app=Test1()) cover = FlipFlat(app=Test1()) filter = Filter(app=Test1()) camera = Camera(app=Test1()) focuser = Focuser(app=Test1()) dome = Dome(app=Test1()) power = PegasusUPB(app=Test1()) astrometry = Astrometry(app=Test1()) relay = KMRelay() measure = MeasureData(app=Test1()) remote = Remote(app=Test1()) telescope = Telescope(app=Test1()) widget = QWidget() ui = Ui_MainWindow() ui.setupUi(widget) app = SettDevice(app=Test(), ui=ui, clickable=MWidget().clickable, change=MWidget().changeStyleDynamic) app.close = MWidget().close app.deleteLater = MWidget().deleteLater app.deviceStat = dict() app.log = CustomLogger(logging.getLogger(__name__), {}) app.threadPool = QThreadPool() app.config = dict() app.BACK_NORM = '#000000' qtbot.addWidget(app) yield del widget, ui, Test, Test1, app
class Worker(PyQt5.QtCore.QRunnable): """ The Worker class offers a generic interface to allow any function to be executed as a thread in an threadpool """ __all__ = ['Worker', 'run'] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, fn, *args, **kwargs): super(Worker, self).__init__() # Store constructor arguments (re-used for processing) self.fn = fn self.args = args self.kwargs = kwargs # the worker signal must not be a class variable, but instance otherwise # we get trouble when having multiple threads running self.signals = WorkerSignals() @PyQt5.QtCore.pyqtSlot() def run(self): """ runs an arbitrary methods with it's parameters and catches the result :return: nothing, but sends results and status as signals """ try: result = self.fn(*self.args, **self.kwargs) except Exception: # as we want to send a clear message to the log file exc_type, exc_value, exc_traceback = sys.exc_info() tb = exc_traceback # moving toward the end of the trace while tb.tb_next is not None: tb = tb.tb_next # getting data out for processing file = tb.tb_frame.f_code.co_filename line = tb.tb_frame.f_lineno errorString = f'{file}, line {line} {exc_value}' self.log.critical(errorString) self.signals.error.emit(errorString) else: self.signals.result.emit(result) finally: self.signals.finished.emit()
class SkymeterIndi(IndiClass): """ the class SkymeterIndi inherits all information and handling of the Skymeter device >>> s = SkymeterIndi(app=None) """ __all__ = [ 'SkymeterIndi', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # update rate to 1 seconds for setting indi server UPDATE_RATE = 5 def __init__(self, app=None, signals=None, data=None): super().__init__(app=app, data=data) self.signals = signals self.data = data def setUpdateConfig(self, deviceName): """ _setUpdateRate corrects the update rate of weather devices to get an defined setting regardless, what is setup in server side. :param deviceName: :return: success """ if deviceName != self.name: return False if self.device is None: return False update = self.device.getNumber('WEATHER_UPDATE') if 'PERIOD' not in update: return False if update.get('PERIOD', 0) == self.UPDATE_RATE: return True update['PERIOD'] = self.UPDATE_RATE suc = self.client.sendNewNumber(deviceName=deviceName, propertyName='WEATHER_UPDATE', elements=update) return suc
def module_setup_teardown(qtbot): global ui, widget, Test, Test1, app class Test1(QObject): mount = Mount() update10s = pyqtSignal() threadPool = QThreadPool() class Test(QObject): config = {'mainW': {}} threadPool = QThreadPool() update1s = pyqtSignal() update30m = pyqtSignal() message = pyqtSignal(str, int) mount = Mount() mount.obsSite.location = Topos(latitude_degrees=20, longitude_degrees=10, elevation_m=500) sensorWeather = SensorWeather(app=Test1()) onlineWeather = OnlineWeather(app=Test1()) skymeter = Skymeter(app=Test1()) widget = QWidget() ui = Ui_MainWindow() ui.setupUi(widget) app = EnvironGui(app=Test(), ui=ui, clickable=MWidget().clickable, change=MWidget().changeStyleDynamic) app.close = MWidget().close app.deleteLater = MWidget().deleteLater app.deviceStat = dict() app.log = CustomLogger(logging.getLogger(__name__), {}) app.threadPool = QThreadPool() qtbot.addWidget(app) yield del widget, ui, Test, Test1, app
class QAwesomeTooltipEventFilter(PyQt5.QtCore.QObject): """ Tooltip-specific event filter dramatically improving the tooltips of all widgets for which this filter is installed. Motivation ---------- **Rich text tooltips** (i.e., tooltips containing one or more HTML-like tags) are implicitly wrapped by Qt to the width of their parent windows and hence typically behave as expected. **Plaintext tooltips** (i.e., tooltips containing no such tags), however, are not. For unclear reasons, plaintext tooltips are implicitly truncated to the width of their parent windows. The only means of circumventing this obscure constraint is to manually inject newlines at the appropriate 80-character boundaries of such tooltips -- which has the distinct disadvantage of failing to scale to edge-case display and device environments (e.g., high-DPI). Such tooltips *cannot* be guaranteed to be legible in the general case and hence are blatantly broken under *all* Qt versions to date. This is a `well-known long-standing issue <issue_>`__ for which no official resolution exists. This filter globally addresses this issue by implicitly converting *all* intercepted plaintext tooltips into rich text tooltips in a general-purpose manner, thus wrapping the former exactly like the latter. To do so, this filter (in order): #. Auto-detects whether the: * Current event is a :class:`QEvent.ToolTipChange` event. * Current widget has a **non-empty plaintext tooltip**. #. When these conditions are satisfied: #. Escapes all HTML syntax in this tooltip (e.g., converting all ``&`` characters to ``&`` substrings). #. Embeds this tooltip in the Qt-specific ``<qt>...</qt>`` tag, thus implicitly converting this plaintext tooltip into a rich text tooltip. .. _issue: https://bugreports.qt.io/browse/QTBUG-41051 """ logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def eventFilter(self, widget, event): """ Tooltip-specific event filter handling the passed Qt object and event. """ # If this is a tooltip event... if event.type() == PyQt5.QtCore.QEvent.ToolTipChange: # If the target Qt object containing this tooltip is *NOT* a widget, # raise a human-readable exception. While this should *NEVER* be the # case, edge cases are edge cases because they sometimes happen. if not isinstance(widget, PyQt5.QtWidgets.QWidget): self.log.error('QObject "{}" not a widget.'.format(widget)) # Tooltip for this widget if any *OR* the empty string otherwise. tooltip = widget.toolTip() # If this tooltip is both non-empty and not already rich text... if tooltip == '<html><head/><body><p><br/></p></body></html>': widget.setToolTip(None) return True elif tooltip and not PyQt5.QtCore.Qt.mightBeRichText(tooltip): # Convert this plaintext tooltip into a rich text tooltip by: # # * Escaping all HTML syntax in this tooltip. # * Embedding this tooltip in the Qt-specific "<qt>...</qt>" tag. tooltip = '<qt>{}</qt>'.format(html.escape(tooltip)) # Replace this widget's non-working plaintext tooltip with this # working rich text tooltip. widget.setToolTip(tooltip) # Notify the parent event handler this event has been handled. return True elif event.type() == PyQt5.QtCore.QEvent.ToolTip: if not isinstance(widget, PyQt5.QtWidgets.QWidget): self.log.error('QObject "{}" not a widget.'.format(widget)) # Tooltip for this widget if any *OR* the empty string otherwise. tooltip = widget.toolTip() if tooltip == '<html><head/><body><p><br/></p></body></html>': widget.setToolTip(None) return True # Else, defer to the default superclass handling of this event. return super().eventFilter(widget, event)
class DataPoint(object): """ The class Data inherits all information and handling of modeldata data and other attributes. this includes horizon data, model points data and their persistence >>> data = DataPoint( >>> app=None, >>> mwGlob=mwglob, >>> ) """ __all__ = [ 'DataPoint', 'genGreaterCircle', 'genGrid', 'genInitial', 'loadBuildP', 'saveBuildP', 'clearPoints' 'deleteBelowHorizon', 'sort', 'loadHorizonP', 'saveHorizonP', 'clearHorizonP', 'generateCelestialEquator', 'generateDSOPath', 'generateGoldenSpiral', 'genAlign', 'hip', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # data for generating greater circles, dec and step only for east, west is reversed DEC = { 'min': [-15, 0, 15, 30, 45, 60, 75], 'norm': [-15, 0, 15, 30, 45, 60, 75], 'med': [-15, -5, 5, 15, 25, 40, 55, 70, 85], 'max': [-15, -5, 5, 15, 25, 35, 45, 55, 65, 75, 85], } STEP = { 'min': [15, -15, 15, -15, 15, -30, 30], 'norm': [10, -10, 10, -10, 10, -20, 20], 'med': [10, -10, 10, -10, 10, -10, 10, -30, 30], 'max': [10, -10, 10, -10, 10, -10, 10, -10, 10, -30, 30], } START = { 'min': [-120, -5, -120, -5, -120, -5, -120, 5, 120, 5, 120, 5, 120, 5], 'norm': [-120, -5, -120, -5, -120, -5, -120, 5, 120, 5, 120, 5, 120, 5], 'med': [ -120, -5, -120, -5, -120, -5, -120, -5, -120, 5, 120, 5, 120, 5, 120, 5, 120, 5 ], 'max': [ -120, -5, -120, -5, -120, -5, -120, -5, -120, -5, -120, 5, 120, 5, 120, 5, 120, 5, 120, 5, 120, 5 ], } STOP = { 'min': [0, -120, 0, -120, 0, -120, 0, 120, 0, 120, 0, 120, 0, 120], 'norm': [0, -120, 0, -120, 0, -120, 0, 120, 0, 120, 0, 120, 0, 120], 'med': [ 0, -120, 0, -120, 0, -120, 0, -120, 0, 120, 0, 120, 0, 120, 0, 120, 0, 120, 0 ], 'max': [ 0, -120, 0, -120, 0, -120, 0, -120, 0, -120, 0, 120, 0, 120, 0, 120, 0, 120, 0, 120, 0, 120, 0 ], } def __init__( self, app=None, mwGlob=None, ): self.mwGlob = mwGlob self.app = app self._horizonP = [(0, 0), (0, 360)] self._buildP = list() @property def buildP(self): return self._buildP @buildP.setter def buildP(self, value): if not isinstance(value, list): self._buildP = list() return if not all([isinstance(x, tuple) for x in value]): self.log.warning('malformed value: {0}'.format(value)) self._buildP = list() return self._buildP = value def addBuildP(self, value=None, position=None): """ addBuildP extends the list of modeldata points. the new point could be added at the end of the list (default) or in any location in the list. :param value: value to be inserted :param position: position in list :return: """ if value is None: return False if not isinstance(value, tuple): self.log.warning('malformed value: {0}'.format(value)) return False if len(value) != 2: self.log.warning('malformed value: {0}'.format(value)) return False if position is None: position = len(self._buildP) if not isinstance(position, (int, float)): self.log.warning('malformed position: {0}'.format(position)) return False if self.app.mount.setting.horizonLimitHigh is not None: high = self.app.mount.setting.horizonLimitHigh else: high = 90 if self.app.mount.setting.horizonLimitLow is not None: low = self.app.mount.setting.horizonLimitLow else: low = 0 if value[0] > high: return False if value[0] < low: return False position = int(position) position = min(len(self._buildP), position) position = max(0, position) self._buildP.insert(position, value) return True def delBuildP(self, position): """ delBuildP deletes one point from the modeldata points list at the given index. :param position: :return: """ if not isinstance(position, (int, float)): self.log.warning('malformed position: {0}'.format(position)) return False position = int(position) if position < 0 or position > len(self._buildP) - 1: self.log.warning('invalid position: {0}'.format(position)) return False self._buildP.pop(position) return True def clearBuildP(self): self._buildP = list() def checkHorizonBoundaries(self): if self._horizonP[0] != (0, 0): self._horizonP.insert(0, (0, 0)) horMax = len(self._horizonP) if self._horizonP[horMax - 1] != (0, 360): self._horizonP.insert(horMax, (0, 360)) @property def horizonP(self): self._horizonP = sorted(self._horizonP, key=lambda x: x[1]) self.checkHorizonBoundaries() return self._horizonP @horizonP.setter def horizonP(self, value): if not isinstance(value, list): self.clearHorizonP() return if not all([isinstance(x, tuple) for x in value]): self.log.warning('malformed value: {0}'.format(value)) self.clearHorizonP() return self._horizonP = value self.checkHorizonBoundaries() @staticmethod def checkFormat(value): if not isinstance(value, list): return False if not all([isinstance(x, list) for x in value]): return False if not all([len(x) == 2 for x in value]): return False return True def addHorizonP(self, value=None, position=None): """ addHorizonP extends the list of modeldata points. the new point could be added at the end of the list (default) or in any location in the list. :param value: :param position: :return: """ if value is None: return False if not isinstance(value, tuple): self.log.warning('malformed value: {0}'.format(value)) return False if len(value) != 2: self.log.warning('malformed value: {0}'.format(value)) return False if position is None: position = len(self._horizonP) if not isinstance(position, (int, float)): self.log.warning('malformed position: {0}'.format(position)) return False position = int(position) position = min(len(self._horizonP), position) position = max(0, position) self._horizonP.insert(position, value) return True def delHorizonP(self, position): """ delHorizonP deletes one point from the modeldata points list at the given index. :param position: :return: """ if not isinstance(position, (int, float)): self.log.warning('malformed position: {0}'.format(position)) return False position = int(position) if position < 0 or position > len(self._horizonP) - 1: self.log.warning('invalid position: {0}'.format(position)) return False if self._horizonP[position] == (0, 0): return False if self._horizonP[position] == (0, 360): return False self._horizonP.pop(position) return True def clearHorizonP(self): self._horizonP = [(0, 0), (0, 360)] def isAboveHorizon(self, point): """ isAboveHorizon calculates for a given point the relationship to the actual horizon and determines if this point is above the horizon line. for that there will be a linear interpolation for the horizon line points. :param point: :return: """ if point[1] > 360: point = (point[0], 360) if point[1] < 0: point = (point[0], 0) x = range(0, 361) y = np.interp( x, [i[1] for i in self._horizonP], [i[0] for i in self._horizonP], ) if point[0] > y[int(point[1])]: return True else: return False def isCloseMeridian(self, point): """ isCloseMeridian check if a point is in inner to meridian slew limit :param point: :return: status """ slew = self.app.mount.setting.meridianLimitSlew track = self.app.mount.setting.meridianLimitTrack if slew is None or track is None: return True value = max(slew, track) lower = 180 - value upper = 180 + value if lower < point[1] < upper: return False else: return True def deleteBelowHorizon(self): """ deleteBelowHorizon deletes all points which are below horizon line :return: true for test purpose """ self._buildP = [x for x in self._buildP if self.isAboveHorizon(x)] return True def deleteCloseMeridian(self): """ deleteCloseMeridian deletes all points which are too close to the meridian and therefore need to flip all the time :return: true for test purpose """ self._buildP = [x for x in self._buildP if self.isCloseMeridian(x)] return True def sort(self, eastwest=False, highlow=False): """ :param eastwest: flag if to be sorted east - west :param highlow: flag if sorted high low altitude :return: true for test purpose """ if eastwest and highlow: return False if not eastwest and not highlow: return False east = [x for x in self._buildP if x[1] <= 180] west = [x for x in self._buildP if x[1] > 180] if eastwest: east = sorted(east, key=lambda x: -x[1]) west = sorted(west, key=lambda x: -x[1]) if highlow: east = sorted(east, key=lambda x: -x[0]) west = sorted(west, key=lambda x: -x[0]) self._buildP = east + west return True def loadBuildP(self, fileName=None): """ loadBuildP loads a modeldata pints file and stores the data in the buildP list. necessary conversion are made. :param fileName: name of file to be handled :return: success """ if fileName is None: return False fileName = self.mwGlob['configDir'] + '/' + fileName + '.bpts' if not os.path.isfile(fileName): return False try: with open(fileName, 'r') as handle: value = json.load(handle) except Exception as e: self.log.warning('Cannot load: {0}, error: {1}'.format( fileName, e)) return False suc = self.checkFormat(value) if not suc: self.clearBuildP() return False # json makes list out of tuple, was to be reversed value = [tuple(x) for x in value] self._buildP = value return True def saveBuildP(self, fileName=None): """ saveBuildP saves the actual modeldata points list in a file in json dump format :param fileName: name of file to be handled :return: success """ if fileName is None: return False fileName = self.mwGlob['configDir'] + '/' + fileName + '.bpts' with open(fileName, 'w') as handle: json.dump(self.buildP, handle, indent=4) return True def loadHorizonP(self, fileName=None): """ loadHorizonP loads a modeldata pints file and stores the data in the buildP list. necessary conversion are made. :param fileName: name of file to be handled :return: success """ if fileName is None: return False fileName = self.mwGlob['configDir'] + '/' + fileName + '.hpts' if not os.path.isfile(fileName): return False try: with open(fileName, 'r') as handle: value = json.load(handle) except Exception as e: self.log.warning('Cannot load: {0}, error: {1}'.format( fileName, e)) return False suc = self.checkFormat(value) if not suc: self.clearHorizonP() return False # json makes list out of tuple, was to be reversed value = [tuple(x) for x in value] self._horizonP = value return True @staticmethod def checkBoundaries(points): """ checkBoundaries removes point 0,0 and 0, 360 if present. :param points: :return: points """ if points[0] == (0, 0): del points[0] if points[len(points) - 1] == (0, 360): del points[-1] return points def saveHorizonP(self, fileName=None): """ saveHorizonP saves the actual modeldata points list in a file in json dump format :param fileName: name of file to be handled :return: success """ if fileName is None: return False fileName = self.mwGlob['configDir'] + '/' + fileName + '.hpts' points = self.checkBoundaries(self.horizonP) with open(fileName, 'w') as handle: json.dump(points, handle, indent=4) return True def genHaDecParams(self, selection): """ genHaDecParams selects the parameters for generating the boundaries for next step processing greater circles. the parameters are sorted for different targets actually for minimum slew distance between the points. defined is only the east side of data, the west side will be mirrored to the east one. :param selection: type of model we would like to use :return: yield tuple of dec value and step, start and stop for range """ if selection not in self.DEC or selection not in self.STEP: return eastDec = self.DEC[selection] westDec = list(reversed(eastDec)) decL = eastDec + westDec eastStepL = self.STEP[selection] westStepL = list(reversed(eastStepL)) stepL = eastStepL + westStepL startL = self.START[selection] stopL = self.STOP[selection] for dec, step, start, stop in zip(decL, stepL, startL, stopL): yield dec, step, start, stop def genGreaterCircle(self, selection='norm'): """ genGreaterCircle takes the generated boundaries for the rang routine and transforms ha, dec to alt az. reasonable values for the alt az values are 5 to 85 degrees. :param selection: :return: yields alt, az tuples which are above horizon """ if not self.app.mount.obsSite.location: return False self.clearBuildP() lat = self.app.mount.obsSite.location.latitude.degrees for dec, step, start, stop in self.genHaDecParams(selection): for ha in range(start, stop, step): alt, az = HaDecToAltAz(ha / 10, dec, lat) # only values with above horizon = 0 if 5 <= alt <= 85 and 2 < az < 358: alt += random.uniform(-2, 2) self.addBuildP((alt, az)) return True @staticmethod def genGridGenerator(eastAlt, westAlt, minAz, stepAz, maxAz): """ genGridGenerator generates the point values out of the given ranges of altitude and azimuth :param eastAlt: :param westAlt: :param minAz: :param stepAz: :param maxAz: :return: """ for i, alt in enumerate(eastAlt): if i % 2: for az in range(minAz, 180, stepAz): yield (alt, az) else: for az in range(180 - minAz, 0, -stepAz): yield (alt, az) for i, alt in enumerate(westAlt): if i % 2: for az in range(180 + minAz, 360, stepAz): yield (alt, az) else: for az in range(maxAz, 180, -stepAz): yield (alt, az) def genGrid(self, minAlt=5, maxAlt=85, numbRows=5, numbCols=6): """ genGrid generates a grid of points and transforms ha, dec to alt az. with given limits in alt, the min and max will be used as a hard condition. on az there is not given limit, therefore a split over the whole space (omitting the meridian) is done. the simplest way to avoid hitting the meridian is to enforce the number of cols to be a factor of 2. reasonable values for the grid are 5 to 85 degrees. defined is only the east side of data, the west side will be mirrored to the east one. the number of rows is 2 < x < 8 the number of columns is 2 < x < 15 :param minAlt: altitude min :param maxAlt: altitude max :param numbRows: numbRows :param numbCols: numbCols :return: yields alt, az tuples which are above horizon """ if not 5 <= minAlt <= 85: return False if not 5 <= maxAlt <= 85: return False if not maxAlt > minAlt: return False if not 1 < numbRows < 9: return False if not 1 < numbCols < 16: return False if numbCols % 2: return False minAlt = int(minAlt) maxAlt = int(maxAlt) numbCols = int(numbCols) numbRows = int(numbRows) stepAlt = int((maxAlt - minAlt) / (numbRows - 1)) eastAlt = list(range(minAlt, maxAlt + 1, stepAlt)) westAlt = list(reversed(eastAlt)) stepAz = int(360 / numbCols) minAz = int(180 / numbCols) maxAz = 360 - minAz self.clearBuildP() for point in self.genGridGenerator(eastAlt, westAlt, minAz, stepAz, maxAz): self.addBuildP(point) return True def genAlign(self, altBase=30, azBase=10, numberBase=3): """ genAlign generates a number of initial points for the first step of modeling. it adjusts the first align point in a matter, that the starting point is the closest to az = 0. :param altBase: :param azBase: :param numberBase: :return: yields alt, az tuples which are above horizon """ if not 5 <= altBase <= 85: return False if not 2 < numberBase < 11: return False if not 0 <= azBase < 360: return False stepAz = int(360 / numberBase) altBase = int(altBase) azBase = int(azBase) % stepAz numberBase = int(numberBase) self.clearBuildP() for i in range(0, numberBase): az = azBase + i * stepAz self.addBuildP((altBase, az % 360)) return True def generateCelestialEquator(self): """ generateCelestialEquator calculates a line for greater circles like a celestial equator for showing the paths in the hemisphere window. :return: celestial equator """ celestialEquator = list() if not self.app.mount.obsSite.location: return celestialEquator lat = self.app.mount.obsSite.location.latitude.degrees for dec in range(-15, 90, 15): for ha in range(-119, 120, 2): az, alt = HaDecToAltAz(ha / 10, dec, lat) if alt > 0: celestialEquator.append((az, alt)) for ha in range(-115, 120, 10): for dec in range(-90, 90, 2): az, alt = HaDecToAltAz(ha / 10, dec, lat) if alt > 0: celestialEquator.append((az, alt)) return celestialEquator def generateDSOPath(self, ra=0, dec=0, timeJD=0, location=None, numberPoints=0, duration=0, timeShift=0): """ generateDSOPath calculates a list of model points along the desired path beginning at ra, dec coordinates, which is in time duration hours long and consists of numberPoints model points. TimeShift moves the pearl of points to an earlier or later point in time. :param ra: :param dec: :param timeJD: :param location: :param numberPoints: :param duration: :param timeShift: :return: True for test purpose """ if numberPoints < 1: return False if duration == 0: return False if location is None: return False numberPoints = int(numberPoints) self.clearBuildP() for i in range(0, numberPoints): startPoint = ra.hours - i * duration / numberPoints - timeShift raCalc = skyfield.api.Angle(hours=startPoint) az, alt = transform.J2000ToAltAz(raCalc, dec, timeJD, location) if alt.degrees > 0: self.addBuildP((alt.degrees, az.degrees % 360)) return True def generateGoldenSpiral(self, numberPoints): """ based on the evaluations and implementation of CR Drost from 17-05-24 found at: https://stackoverflow.com/questions/9600801/evenly-distributing-n-points-on-a-sphere the implementation of an equally distributed points cloud over on half of the hemisphere. :param numberPoints: :return: true for test purpose """ self.clearBuildP() indices = np.arange(0, numberPoints, dtype=float) + 0.5 phi = np.arccos(1 - 2 * indices / numberPoints) theta = np.pi * (1 + 5**0.5) * indices # do not transfer to xyz coordinates # x, y, z = np.cos(theta) * np.sin(phi), np.sin(theta) * np.sin(phi), np.cos(phi) altitude = 90 - np.degrees(phi) azimuth = np.degrees(theta) % 360 for alt, az in zip(altitude, azimuth): # only adding above horizon if alt > 0: self.addBuildP((alt, az)) return True
class AstrometryNET(object): """ the class Astrometry inherits all information and handling of astrometry.net handling Keyword definitions could be found under https://fits.gsfc.nasa.gov/fits_dictionary.html >>> astrometry = AstrometryNET(app=app, >>> ) """ __all__ = ['AstrometryNET', 'solveNET', 'abortNET', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, parent=None): self.parent = parent self.data = parent.data self.tempDir = parent.tempDir self.readFitsData = parent.readFitsData self.getSolutionFromWCS = parent.getSolutionFromWCS self.result = {'success': False} self.process = None def runImage2xy(self, binPath='', tempPath='', fitsPath='', timeout=30): """ runImage2xy extracts a list of stars out of the fits image. there is a timeout of 3 seconds set to get the process finished :param binPath: full path to image2xy executable :param tempPath: full path to star file :param fitsPath: full path to fits file :param timeout: :return: success """ runnable = [binPath, '-O', '-o', tempPath, fitsPath] timeStart = time.time() try: self.process = subprocess.Popen(args=runnable, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout, stderr = self.process.communicate(timeout=timeout) except subprocess.TimeoutExpired as e: self.log.critical(e) return False except Exception as e: self.log.critical(f'error: {e} happened') return False else: delta = time.time() - timeStart self.log.info(f'image2xy took {delta}s return code: ' + str(self.process.returncode) + ' stderr: ' + stderr.decode().replace('\n', ' ') + ' stdout: ' + stdout.decode().replace('\n', ' ') ) success = (self.process.returncode == 0) return success def runSolveField(self, binPath='', configPath='', tempPath='', options='', timeout=30): """ runSolveField solves finally the xy star list and writes the WCS data in a fits file format :param binPath: full path to image2xy executable :param configPath: full path to astrometry.cfg file :param tempPath: full path to star file :param options: additional solver options e.g. ra and dec hint :param timeout: :return: success """ runnable = [binPath, '--overwrite', '--no-remove-lines', '--no-plots', '--no-verify-uniformize', '--uniformize', '0', '--sort-column', 'FLUX', '--scale-units', 'app', '--crpix-center', '--cpulimit', str(timeout), '--config', configPath, tempPath, ] runnable += options timeStart = time.time() try: self.process = subprocess.Popen(args=runnable, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout, stderr = self.process.communicate(timeout=timeout) except subprocess.TimeoutExpired as e: self.log.critical(e) return False except Exception as e: self.log.critical(f'error: {e} happened') return False else: delta = time.time() - timeStart self.log.info(f'solve-field took {delta}s return code: ' + str(self.process.returncode) + ' stderr: ' + stderr.decode().replace('\n', ' ') + ' stdout: ' + stdout.decode().replace('\n', ' ') ) success = (self.process.returncode == 0) return success @staticmethod def getWCSHeader(wcsHDU=None): """ getWCSHeader returns the header part of a fits HDU :param wcsHDU: fits file with wcs data :return: wcsHeader """ if wcsHDU is None: return None wcsHeader = wcsHDU[0].header return wcsHeader def solve(self, solver={}, fitsPath='', raHint=None, decHint=None, scaleHint=None, radius=2, timeout=30, updateFits=False): """ Solve uses the astrometry.net solver capabilities. The intention is to use an offline solving capability, so we need a installed instance. As we go multi platform and we need to focus on MW function, we use the astrometry.net package which is distributed with KStars / EKOS. Many thanks to them providing such a nice package. As we go using astrometry.net we focus on the minimum feature set possible to omit many of the installation and wrapping work to be done. So we only support solving of FITS files, use no python environment for astrometry.net parts (as we could access these via MW directly) The base outside ideas of implementation come from astrometry.net itself and the astrometry implementation from cloudmakers.eu (another nice package for MAC Astro software) :param solver: which astrometry implementation to choose :param fitsPath: full path to fits file :param raHint: ra dest to look for solve in J2000 :param decHint: dec dest to look for solve in J2000 :param scaleHint: scale to look for solve in J2000 :param radius: search radius around target coordinates :param timeout: time after the subprocess will be killed. :param updateFits: if true update Fits image file with wcsHeader data :return: success """ self.process = None self.result = {'success': False} if not solver: return False if not os.path.isfile(fitsPath): self.result['message'] = 'image missing' return False tempPath = self.tempDir + '/temp.xy' configPath = self.tempDir + '/astrometry.cfg' solvedPath = self.tempDir + '/temp.solved' wcsPath = self.tempDir + '/temp.wcs' binPathImage2xy = solver['programPath'] + '/image2xy' binPathSolveField = solver['programPath'] + '/solve-field' if os.path.isfile(wcsPath): os.remove(wcsPath) cfgFile = self.tempDir + '/astrometry.cfg' with open(cfgFile, 'w+') as outFile: outFile.write('cpulimit 300\n') outFile.write(f'add_path {solver["indexPath"]}\n') outFile.write('autoindex\n') # using sextractor in astrometry.net and KStars """ with open('default.param', 'w+') as outFile: outFile.write('MAG_AUTO Kron-like elliptical aperture magnitude [mag]\n') outFile.write('X_IMAGE Object position along x [pixel]\n') outFile.write('Y_IMAGE Object position along y [pixel]\n') with open('default.conv', 'w+') as outFile: outFile.write('CONV NORM\n') outFile.write('1 2 1\n') outFile.write('2 4 2\n') outFile.write('1 2 1\nn') """ suc = self.runImage2xy(binPath=binPathImage2xy, tempPath=tempPath, fitsPath=fitsPath, timeout=timeout, ) if not suc: self.log.error(f'image2xy error in [{fitsPath}]') self.result['message'] = 'image2xy failed' return False raFITS, decFITS, scaleFITS, _, _ = self.readFitsData(fitsPath=fitsPath) # if parameters are passed, they have priority if raHint is None: raHint = raFITS if decHint is None: decHint = decFITS if scaleHint is None: scaleHint = scaleFITS searchRatio = 1.1 ra = transform.convertToHMS(raHint) dec = transform.convertToDMS(decHint) scaleLow = scaleHint / searchRatio scaleHigh = scaleHint * searchRatio options = ['--scale-low', f'{scaleLow}', '--scale-high', f'{scaleHigh}', '--ra', f'{ra}', '--dec', f'{dec}', '--radius', f'{radius:1.1f}', ] # split between ekos and cloudmakers as cloudmakers use an older version of # solve-field, which need the option '--no-fits2fits', whereas the actual # version used in KStars throws an error using this option. if 'Astrometry.app' in solver['programPath']: options.append('--no-fits2fits') suc = self.runSolveField(binPath=binPathSolveField, configPath=configPath, tempPath=tempPath, options=options, timeout=timeout, ) if not suc: self.log.error(f'solve-field error in [{fitsPath}]') self.result['message'] = 'solve-field error' return False if not os.path.isfile(solvedPath): self.log.info(f'solve files for [{fitsPath}] missing') self.result['message'] = 'solve failed' return False if not os.path.isfile(wcsPath): self.log.info(f'solve files for [{wcsPath}] missing') self.result['message'] = 'solve failed' return False with fits.open(wcsPath) as wcsHDU: wcsHeader = self.getWCSHeader(wcsHDU=wcsHDU) with fits.open(fitsPath, mode='update') as fitsHDU: solve, header = self.getSolutionFromWCS(fitsHeader=fitsHDU[0].header, wcsHeader=wcsHeader, updateFits=updateFits) fitsHDU[0].header = header self.result = { 'success': True, 'solvedPath': fitsPath, 'message': 'Solved', } self.result.update(solve) return True def abort(self): """ abortNET stops the solving function hardly just by killing the process :return: success """ if self.process: self.process.kill() return True else: return False
class Remote(PyQt5.QtCore.QObject): """ The class Remote inherits all information and handling of remotely controlling mountwizzard 4. >>> remote = Remote(app=None) """ __all__ = [ 'Remote', 'startCommunication', 'stopCommunication', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__( self, app=None, ): super().__init__() self.app = app # minimum set for driver package built in self.framework = None self.run = {'built-in': self} self.name = '' self.clientConnection = None self.tcpServer = None def startCommunication(self): """ startCommunication prepares the remote listening by starting a tcp server listening on localhost and port 3490. :return: success """ if self.tcpServer is not None: return False self.tcpServer = QtNetwork.QTcpServer(self) hostAddress = QtNetwork.QHostAddress('127.0.0.1') if not self.tcpServer.listen(hostAddress, 3490): self.log.warning('Port already in use') self.tcpServer = None return False else: self.log.info('Remote access enabled') self.tcpServer.newConnection.connect(self.addConnection) return True def stopCommunication(self): """ stopCommunication kills all connections and stops the tcpServer :return: true for test purpose """ if self.clientConnection is not None: self.clientConnection.close() if self.tcpServer is not None: self.tcpServer = None return True def addConnection(self): """ addConnection allows a new connection for remote access to mw4 only one connection is allowed. :return: success """ if self.tcpServer is None: return False self.clientConnection = self.tcpServer.nextPendingConnection() if self.clientConnection == 0: self.log.error('Cannot establish incoming connection') return False self.clientConnection.nextBlockSize = 0 self.clientConnection.readyRead.connect(self.receiveMessage) self.clientConnection.disconnected.connect(self.removeConnection) self.clientConnection.error.connect(self.handleError) connection = self.clientConnection.peerAddress().toString() self.log.info(f'Connection to MountWizzard from {connection}') return True def receiveMessage(self): """ receiveMessage is the command dispatcher for remote access :return: success """ if self.clientConnection.bytesAvailable() == 0: return False validCommands = [ 'shutdown', 'shutdown mount', 'boot mount', ] connection = self.clientConnection.peerAddress().toString() command = str(self.clientConnection.read(100), "ascii") command = command.replace('\n', '') command = command.replace('\r', '') self.log.info(f'Command {command} from {connection} received') if command in validCommands: self.app.remoteCommand.emit(command) else: self.log.error( f'Unknown command {command} from {connection} received') return True def removeConnection(self): """ removeConnection clear the existing connection :return: true for test purpose """ connection = self.clientConnection.peerAddress().toString() self.clientConnection.close() self.log.info(f'Connection from {connection} closed') return True def handleError(self, socketError): """ handleError does error handling -> writing to log :param socketError: :return: true for test purpose """ connection = self.clientConnection.peerAddress().toString() self.log.critical( f'Connection from {connection} failed, error: {socketError}') return True
class MountWizzard4(PyQt5.QtCore.QObject): """ MountWizzard4 class is the main class for the application. it loads all windows and classes needed to fulfil the work of mountwizzard. any gui work should be handled through the window classes. main class is for setup, config, start, persist and shutdown the application. """ __all__ = [ 'MountWizzard4', ] __version__ = version('mountwizzard4') logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # central message and logging dispatching message = PyQt5.QtCore.pyqtSignal(str, int) messageQueue = Queue() redrawHemisphere = PyQt5.QtCore.pyqtSignal() remoteCommand = PyQt5.QtCore.pyqtSignal(str) # all cyclic tasks update0_1s = PyQt5.QtCore.pyqtSignal() update1s = PyQt5.QtCore.pyqtSignal() update3s = PyQt5.QtCore.pyqtSignal() update10s = PyQt5.QtCore.pyqtSignal() update60s = PyQt5.QtCore.pyqtSignal() update3m = PyQt5.QtCore.pyqtSignal() update10m = PyQt5.QtCore.pyqtSignal() update30m = PyQt5.QtCore.pyqtSignal() update1h = PyQt5.QtCore.pyqtSignal() def __init__( self, mwGlob=None, ): super().__init__() self.expireData = False self.mountUp = False self.mwGlob = mwGlob self.timerCounter = 0 self.mainW = None self.threadPool = PyQt5.QtCore.QThreadPool() self.threadPool.setMaxThreadCount(20) self.message.connect(self.writeMessageQueue) # persistence management through dict self.config = {} self.loadConfig() # write basic data to message window profile = self.config.get('profileName', '-') self.messageQueue.put(('MountWizzard4 started', 1)) self.messageQueue.put((f'Workdir is: [{self.mwGlob["workDir"]}]', 1)) self.messageQueue.put((f'Profile [{profile}] loaded', 1)) # initialize commands to mount pathToData = self.mwGlob['dataDir'] self.mount = qtmount.Mount( host='localhost', MAC='00.c0.08.87.35.db', threadPool=self.threadPool, pathToData=pathToData, expire=False, verbose=False, ) # setting location to last know config topo = self.initConfig() self.mount.obsSite.location = topo self.mount.signals.mountUp.connect(self.loadMountData) # get all planets for calculation try: self.planets = self.mount.obsSite.loader('de421_23.bsp') except Exception as e: self.log.critical(f'Failed loading planets: {e}') self.planets = None self.relay = KMRelay(host='localhost') self.sensorWeather = SensorWeather(self) self.onlineWeather = OnlineWeather(self) self.directWeather = DirectWeather(self) self.cover = FlipFlat(self) self.dome = Dome(self) self.camera = Camera(self) self.filter = Filter(self) self.focuser = Focuser(self) self.telescope = Telescope(self) self.skymeter = Skymeter(self) self.power = PegasusUPB(self) self.data = DataPoint(self, configDir=self.mwGlob['configDir']) self.hipparcos = Hipparcos(self) self.measure = MeasureData(self) self.remote = Remote(self) self.astrometry = Astrometry(self, tempDir=mwGlob['tempDir']) # get the window widgets up self.mainW = MainWindow(self) # link cross widget gui signals as all ui widgets have to be present self.uiWindows = { 'showMessageW': { 'button': self.mainW.ui.openMessageW, 'classObj': None, 'name': 'MessageDialog', 'class': MessageWindow, }, 'showHemisphereW': { 'button': self.mainW.ui.openHemisphereW, 'classObj': None, 'name': 'HemisphereDialog', 'class': HemisphereWindow, }, 'showImageW': { 'button': self.mainW.ui.openImageW, 'classObj': None, 'name': 'ImageDialog', 'class': ImageWindow, }, 'showMeasureW': { 'button': self.mainW.ui.openMeasureW, 'classObj': None, 'name': 'MeasureDialog', 'class': MeasureWindow, }, 'showSatelliteW': { 'button': self.mainW.ui.openSatelliteW, 'classObj': None, 'name': 'SatelliteDialog', 'class': SatelliteWindow, }, } # todo: we can only add keypad on arm when we have compiled version if platform.machine() != 'armv7l': self.uiWindows['showKeypadW'] = { 'button': self.mainW.ui.openKeypadW, 'classObj': None, 'name': 'KeypadDialog', 'class': KeypadWindow, } # show all sub windows self.showWindows() # connecting buttons to window open close for win in self.uiWindows: self.uiWindows[win]['button'].clicked.connect(self.toggleWindow) # starting mount communication self.mount.startTimers() self.timer0_1s = PyQt5.QtCore.QTimer() self.timer0_1s.setSingleShot(False) self.timer0_1s.timeout.connect(self.sendUpdate) self.timer0_1s.start(100) # finishing for test: MW4 runs with keyword 'test' for 10 seconds an terminates if not hasattr(sys, 'argv'): return if not len(sys.argv) > 1: return if sys.argv[1] == 'test': self.update10s.connect(self.quitSave) def toggleWindow(self, windowTag=''): """ togglePowerPort toggles the state of the power switch :return: true for test purpose """ for win in self.uiWindows: isSender = (self.uiWindows[win]['button'] == self.sender()) isWindowTag = (win == windowTag) if not isSender and not isWindowTag: continue winObj = self.uiWindows[win] if not winObj['classObj']: newWindow = winObj['class'](self) # make new object instance from window winObj['classObj'] = newWindow winObj['classObj'].destroyed.connect(self.deleteWindow) else: winObj['classObj'].close() return True def deleteWindow(self, widget): """ :return: success """ if not widget: return False for win in self.uiWindows: winObj = self.uiWindows[win] if winObj['name'] != widget.objectName(): continue winObj['classObj'] = None gc.collect() return True def initConfig(self): """ initConfig read the key out of the configuration dict and stores it to the gui elements. if some initialisations have to be proceeded with the loaded persistent data, they will be launched as well in this method. :return: """ # set observer position to last one first, to greenwich if not known lat = self.config.get('topoLat', 51.47) lon = self.config.get('topoLon', 0) elev = self.config.get('topoElev', 46) topo = skyfield.api.Topos(longitude_degrees=lon, latitude_degrees=lat, elevation_m=elev) config = self.config.get('mainW', {}) if config.get('loglevelDeepDebug', True): level = 'DEBUG' elif config.get('loglevelDebug', True): level = 'INFO' else: level = 'WARN' setCustomLoggingLevel(level) return topo def storeConfig(self): """ storeConfig collects all persistent data from mainApp and it's submodules and stores it in the persistence dictionary for later saving :return: success for test purpose """ config = self.config = {} location = self.mount.obsSite.location if location is not None: config['topoLat'] = location.latitude.degrees config['topoLon'] = location.longitude.degrees config['topoElev'] = location.elevation.m self.mainW.storeConfig() for win in self.uiWindows: winObj = self.uiWindows[win] config[win] = bool(winObj['classObj']) if config[win]: winObj['classObj'].storeConfig() return True def showWindows(self): """ :return: true for test purpose """ for win in self.uiWindows: if self.config.get(win, False): self.toggleWindow(windowTag=win) return True def sendUpdate(self): """ sendUpdate send regular signals in 1 and 10 seconds to enable regular tasks. it tries to avoid sending the signals at the same time. :return: true for test purpose """ self.timerCounter += 1 if self.timerCounter % 1 == 0: self.update0_1s.emit() if (self.timerCounter + 5) % 10 == 0: self.update1s.emit() if (self.timerCounter + 10) % 30 == 0: self.update3s.emit() if (self.timerCounter + 20) % 100 == 0: self.update10s.emit() if (self.timerCounter + 25) % 600 == 0: self.update60s.emit() if (self.timerCounter + 12) % 1800 == 0: self.update3m.emit() if (self.timerCounter + 13) % 6000 == 0: self.update10m.emit() if (self.timerCounter + 14) % 18000 == 0: self.update30m.emit() if (self.timerCounter + 15) % 36000 == 0: self.update1h.emit() return True def quit(self): """ quit without saving persistence data :return: True for test purpose """ self.mount.stopTimers() self.measure.timerTask.stop() self.relay.timerTask.stop() self.timer0_1s.stop() self.message.emit('MountWizzard4 manual stopped with quit', 1) PyQt5.QtCore.QCoreApplication.quit() return True def quitSave(self): """ quit with saving persistence data :return: True for test purpose """ self.mount.stopTimers() self.measure.timerTask.stop() self.relay.timerTask.stop() self.storeConfig() self.saveConfig() self.timer0_1s.stop() self.message.emit('MountWizzard4 manual stopped with quit/save', 1) PyQt5.QtCore.QCoreApplication.quit() return True @staticmethod def defaultConfig(config=None): """ :param config: :return: """ if config is None: config = dict() config['profileName'] = 'config' config['version'] = '4.0' return config def loadConfig(self, name=None): """ loadConfig loads a json file from disk and stores it to the config dicts for persistent data. if a file path is given, that's the relevant file, otherwise loadConfig loads from th default file, which is config.cfg :param name: name of the config file :return: success if file could be loaded """ configDir = self.mwGlob['configDir'] # looking for file existence and creating new if necessary if name is None: name = 'config' fileName = configDir + '/' + name + '.cfg' if not os.path.isfile(fileName): self.config = self.defaultConfig() if name == 'config': self.log.warning( 'Config file {0} not existing'.format(fileName)) return True else: return False # parsing the default file try: with open(fileName, 'r') as configFile: configData = json.load(configFile) except Exception as e: self.log.critical('Cannot parse: {0}, error: {1}'.format( fileName, e)) self.config = self.defaultConfig() return False # check if reference ist still to default -> correcting if configData.get('reference', '') == 'config': del configData['reference'] elif not configData.get('reference', ''): configData['profileName'] = 'config' # loading default and finishing up if configData['profileName'] == 'config': self.config = self.convertData(configData) return True # checking if reference to another file is available refName = configData.get('reference', 'config') if refName != name: suc = self.loadConfig(refName) else: self.config = configData return True return suc @staticmethod def convertData(data): """ convertDate tries to convert data from an older or newer version of the config file to the actual needed one. :param data: config data as dict :return: data: config data as dict """ return data def saveConfig(self, name=None): """ saveConfig saves a json file to disk from the config dicts for persistent data. :param name: name of the config file :return: success """ configDir = self.mwGlob['configDir'] if self.config.get('profileName', '') == 'config': if 'reference' in self.config: del self.config['reference'] # default saving for reference if name is None: name = self.config.get('reference', 'config') fileName = configDir + '/' + name + '.cfg' with open(fileName, 'w') as outfile: json.dump(self.config, outfile, sort_keys=True, indent=4) # if we save a reference first, we have to save the config as well if name != 'config': fileName = configDir + '/config.cfg' with open(fileName, 'w') as outfile: json.dump(self.config, outfile, sort_keys=True, indent=4) return True def loadMountData(self, status): """ loadMountData polls data from mount if connected otherwise clears all entries in attributes. :param status: connection status to mount computer :return: status how it was called """ if status and not self.mountUp: self.mount.getFW() self.mount.getLocation() self.mount.cycleSetting() self.mainW.refreshName() self.mainW.refreshModel() self.mount.getTLE() self.mountUp = True return True elif not status and self.mountUp: location = self.mount.obsSite.location self.mount.resetData() self.mount.obsSite.location = location self.mountUp = False return False return status def writeMessageQueue(self, message, mType): """ writeMessageQueue receives all signals handling the message sending and puts them in a queue. the queue enables the print of messages event when the message window is not open. :param message: :param mType: :return: True for test purpose """ self.messageQueue.put((message, mType)) return True
class IndiClass(object): """ the class indiClass inherits all information and handling of indi devices this class will be only referenced from other classes and not directly used >>> indi = IndiClass(app=None, data={}) """ __all__ = ['IndiClass'] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) RETRY_DELAY = 1500 NUMBER_RETRY = 5 def __init__(self, app=None, data={}): super().__init__() self.app = app self.client = qtIndiBase.Client(host=None) self.name = '' self._host = ('localhost', 7624) self.data = data self.retryCounter = 0 self.device = None self.showMessages = False self.timerRetry = PyQt5.QtCore.QTimer() self.timerRetry.setSingleShot(True) self.timerRetry.timeout.connect(self.startRetry) # link signals self.client.signals.newDevice.connect(self.newDevice) self.client.signals.removeDevice.connect(self.removeDevice) self.client.signals.newProperty.connect(self.connectDevice) self.client.signals.newNumber.connect(self.updateNumber) self.client.signals.defNumber.connect(self.updateNumber) self.client.signals.newSwitch.connect(self.updateSwitch) self.client.signals.defSwitch.connect(self.updateSwitch) self.client.signals.newText.connect(self.updateText) self.client.signals.defText.connect(self.updateText) self.client.signals.newLight.connect(self.updateLight) self.client.signals.defLight.connect(self.updateLight) self.client.signals.newBLOB.connect(self.updateBLOB) self.client.signals.defBLOB.connect(self.updateBLOB) self.client.signals.deviceConnected.connect(self.setUpdateConfig) self.client.signals.serverConnected.connect(self.serverConnected) self.client.signals.serverDisconnected.connect(self.serverDisconnected) self.client.signals.newMessage.connect(self.updateMessage) @property def host(self): return self._host @host.setter def host(self, value): self._host = value self.client.host = value def serverConnected(self): """ serverConnected is called when the server signals the connection. if so, we would like to start watching the defined device. this will be triggered directly :return: success """ if self.name: suc = self.client.watchDevice(self.name) self.log.info(f'Indi watch: {self.name}, watch: result:{suc}') return suc return False @staticmethod def serverDisconnected(devices): """ :param devices: :return: true for test purpose """ return True def newDevice(self, deviceName): """ newDevice is called whenever a new device entry is received in indi client. it adds the device if the name fits to the given name in configuration. :param deviceName: :return: true for test purpose """ if deviceName == self.name: self.device = self.client.getDevice(deviceName) self.app.message.emit(f'INDI device found: [{deviceName}]', 0) # else: # self.app.message.emit(f'INDI device snoops: [{deviceName}]', 0) return True def removeDevice(self, deviceName): """ removeDevice is called whenever a device is removed from indi client. it sets the device entry to None :param deviceName: :return: true for test purpose """ if deviceName == self.name: self.app.message.emit(f'INDI removed device: [{deviceName}]', 0) self.device = None self.data.clear() return True else: return False def startRetry(self): """ startRetry tries to connect the server a NUMBER_RETRY times, if necessary with a delay of RETRY_DELAY :return: True for test purpose """ if not self.name: return False self.retryCounter += 1 if not self.data: self.startCommunication() self.log.info( f'Indi server {self.name} connection retry: {self.retryCounter}' ) else: self.retryCounter = 0 if self.retryCounter < self.NUMBER_RETRY: self.timerRetry.start(self.RETRY_DELAY) return True def startCommunication(self): """ startCommunication adds a device on the watch list of the server. :return: success of reconnecting to server """ self.client.startTimers() suc = self.client.connectServer() if not suc: self.log.info(f'Cannot start connection to: {self.name}') else: # adding a single retry if first connect does not happen self.timerRetry.start(self.RETRY_DELAY) return suc def stopCommunication(self): """ stopCommunication adds a device on the watch list of the server. :return: success of reconnecting to server """ self.client.stopTimers() suc = self.client.disconnectServer(self.name) self.name = '' return suc def connectDevice(self, deviceName, propertyName): """ connectDevice is called when a new property is received and checks it against property CONNECTION. if this is there, we could check the connection state of a given device :param deviceName: :param propertyName: :return: success if device could connect """ if propertyName != 'CONNECTION': return False suc = False if deviceName == self.name: suc = self.client.connectDevice(deviceName=deviceName) return suc def setUpdateConfig(self, deviceName): """ _setUpdateRate corrects the update rate of weather devices to get an defined setting regardless, what is setup in server side. :param deviceName: :return: success """ pass def updateNumber(self, deviceName, propertyName): """ updateNumber is called whenever a new number is received in client. it runs through the device list and writes the number data to the according locations. :param deviceName: :param propertyName: :return: success """ if self.device is None: return False if deviceName != self.name: return False for element, value in self.device.getNumber(propertyName).items(): key = propertyName + '.' + element self.data[key] = value # print(self.name, key, value) return True def updateSwitch(self, deviceName, propertyName): """ updateSwitch is called whenever a new switch is received in client. it runs through the device list and writes the switch data to the according locations. :param deviceName: :param propertyName: :return: success """ if self.device is None: return False if deviceName != self.name: return False for element, value in self.device.getSwitch(propertyName).items(): key = propertyName + '.' + element self.data[key] = value # print(self.name, key, value) return True def updateText(self, deviceName, propertyName): """ updateText is called whenever a new text is received in client. it runs through the device list and writes the text data to the according locations. :param deviceName: :param propertyName: :return: success """ if self.device is None: return False if deviceName != self.name: return False for element, value in self.device.getText(propertyName).items(): key = propertyName + '.' + element self.data[key] = value # print(self.name, key, value) return True def updateLight(self, deviceName, propertyName): """ updateLight is called whenever a new light is received in client. it runs through the device list and writes the light data to the according locations. :param deviceName: :param propertyName: :return: success """ if self.device is None: return False if deviceName != self.name: return False for element, value in self.device.getLight(propertyName).items(): key = propertyName + '.' + element self.data[key] = value # print(self.name, key, value) return True def updateBLOB(self, deviceName, propertyName): """ updateBLOB is called whenever a new BLOB is received in client. it runs through the device list and writes the BLOB data to the according locations. :param deviceName: :param propertyName: :return: success """ if self.device is None: return False if deviceName != self.name: return False return True @staticmethod def removePrefix(text, prefix): """ :param text: :param prefix: :return: """ value = text[text.startswith(prefix) and len(prefix):] value = value.strip() return value def updateMessage(self, device, text): """ message take a message send by indi device and emits them in the user message window as well. :param device: device name :param text: message received :return: success """ if self.showMessages: if text.startswith('[WARNING]'): text = self.removePrefix(text, '[WARNING]') self.app.message.emit(device + ' -> ' + text, 0) elif text.startswith('[ERROR]'): text = self.removePrefix(text, '[ERROR]') self.app.message.emit(device + ' -> ' + text, 2) else: self.app.message.emit(device + ' -> ' + text, 0) return True return False
class KMRelay(PyQt5.QtCore.QObject): """ The class KMRelay inherits all information and handling of KMtronic relay board attributes of the connected board and provides the abstracted interface. >>> relay = KMRelay(host=None, user='', password='') """ __all__ = [ 'KMRelay', 'startCommunication', 'stopCommunication', 'cyclePolling', 'pulse', 'switch', 'set', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # polling cycle for relay box CYCLE_POLLING = 1000 # default port for KMTronic Relay DEFAULT_PORT = 80 # timeout for requests TIMEOUT = 0.5 # width for pulse PULSEWIDTH = 0.5 # signal if correct status received and decoded statusReady = PyQt5.QtCore.pyqtSignal() def __init__( self, host=None, user=None, password=None, ): super().__init__() # minimum set for driver package built in self.framework = None self.run = {'built-in': self} self.name = '' self.host = host self.mutexPoll = PyQt5.QtCore.QMutex() self.user = user self.password = password self.status = [0] * 8 self.timerTask = PyQt5.QtCore.QTimer() self.timerTask.setSingleShot(False) self.timerTask.timeout.connect(self.cyclePolling) @property def host(self): return self._host def checkFormat(self, value): # checking format if not value: self.log.info('Host value not configured') return None if not isinstance(value, (tuple, str)): self.log.warning(f'Wrong host value: {value}') return None # now we got the right format if isinstance(value, str): value = (value, self.DEFAULT_PORT) return value @host.setter def host(self, value): value = self.checkFormat(value) self._host = value @property def user(self): return self._user @user.setter def user(self, value): self._user = value @property def password(self): return self._password @password.setter def password(self, value): self._password = value def startCommunication(self): """ startCommunication enables the cyclic timers for polling necessary relay data. :return: success """ if self._host is None: return False if not self._host[0]: return False if not self._host[1]: return False self.timerTask.start(self.CYCLE_POLLING) return True def stopCommunication(self): """ stopCommunication disables the cyclic timers for polling necessary relay data. :return: True for test purpose """ self.timerTask.stop() return True def debugOutput(self, result=None): """ debugOutput writes a nicely formed output for diagnosis in relay environment :param result: value from requests get commend :return: True for test purpose """ if result is None: self.log.warning('No valid result') return False text = result.text.replace('\r\n', ', ') reason = result.reason status = result.status_code url = result.url elapsed = result.elapsed self.log.debug(f'Result: {url}, {reason}, {status}, {elapsed}, {text}') return True def getRelay(self, url='/status.xml', debug=True): """ getRelay sets and reads data from the given host ip using the given user and password :param url: web address of relay box :param debug: write extended debug output :return: result: return values from web interface of box """ if self.host is None: return None if not self.mutexPoll.tryLock(): return None auth = requests.auth.HTTPBasicAuth( self.user, self.password, ) url = f'http://{self._host[0]}:{self._host[1]}{url}' result = None try: result = requests.get(url, auth=auth, timeout=self.TIMEOUT) except requests.exceptions.Timeout: self.log.info(f'Connection timeout: [{url}]') except requests.exceptions.ConnectionError: self.log.info(f'Connection error: [{url}]') except Exception as e: self.log.critical(f'Error in request: {e}') if debug: self.debugOutput(result=result) self.mutexPoll.unlock() return result def cyclePolling(self): """ cyclePolling reads the status of the relay status of each single relay. with success the statusReady single is sent. :return: success """ value = self.getRelay('/status.xml', debug=False) if value is None: return False if value.reason != 'OK': return False lines = value.text.splitlines() for line in lines: value = re.findall(r'\d', line) if not value: continue value = [int(s) for s in value] self.status[value[0] - 1] = value[1] self.statusReady.emit() return True def getByte(self, relayNumber=0, state=False): """ getByte generates the right bit mask for setting or resetting the relay mask :param relayNumber: relay number :param state: state to archive :return: bit mask for switching """ byteStat = 0b0 for i, status in enumerate(self.status): if status: byteStat = byteStat | 1 << i position = 1 << relayNumber byteOn = byteStat | position byteOff = byteOn & ~position if state: return byteOn else: return byteOff def pulse(self, relayNumber): """ pulse switches a relay on for one second and off back. :param relayNumber: number of relay to be pulsed, counting from 0 onwards :return: success """ self.log.info(f'Pulse relay:{relayNumber}') byteOn = self.getByte(relayNumber=relayNumber, state=True) byteOff = self.getByte(relayNumber=relayNumber, state=False) value1 = self.getRelay(f'/FFE0{byteOn:02X}') time.sleep(self.PULSEWIDTH) value2 = self.getRelay(f'/FFE0{byteOff:02X}') if value1 is None or value2 is None: self.log.error(f'Relay:{relayNumber}') return False elif value1.reason != 'OK' or value2.reason != 'OK': self.log.error(f'Relay:{relayNumber}') return False return True def switch(self, relayNumber): """ switch toggles the relay status (on, off) :param relayNumber: number of relay to be pulsed, counting from 0 onwards :return: success """ self.log.info(f'Switch relay:{relayNumber}') value = self.getRelay('/relays.cgi?relay={0:1d}'.format(relayNumber + 1)) if value is None: self.log.error(f'Relay:{relayNumber}') return False elif value.reason != 'OK': self.log.error(f'Relay:{relayNumber}') return False return True def set(self, relayNumber, value): """ set toggles the relay status to the desired value (on, off) :param relayNumber: number of relay to be pulsed, counting from 0 onwards :param value: relay state. :return: success """ self.log.info(f'Set relay:{relayNumber}') byteOn = self.getByte(relayNumber=relayNumber, state=value) value = self.getRelay(f'/FFE0{byteOn:02X}') if value is None: self.log.error(f'Relay:{relayNumber}') return False elif value.reason != 'OK': self.log.error(f'Relay:{relayNumber}') return False return True
class MessageWindow(widget.MWidget): """ the message window class handles """ __all__ = [ 'MessageWindow', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, app): super().__init__() self.app = app self.ui = message_ui.Ui_MessageDialog() self.ui.setupUi(self) self.initUI() self.messColor = [ self.COLOR_ASTRO, self.COLOR_WHITE, self.COLOR_YELLOW, self.COLOR_RED, ] self.messFont = [ PyQt5.QtGui.QFont.Normal, PyQt5.QtGui.QFont.Bold, PyQt5.QtGui.QFont.Normal, PyQt5.QtGui.QFont.Normal, ] self.initConfig() self.showWindow() def initConfig(self): """ initConfig read the key out of the configuration dict and stores it to the gui elements. if some initialisations have to be proceeded with the loaded persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'messageW' not in self.app.config: self.app.config['messageW'] = {} config = self.app.config['messageW'] x = config.get('winPosX', 100) y = config.get('winPosY', 100) if x > self.screenSizeX: x = 0 if y > self.screenSizeY: y = 0 self.move(x, y) height = config.get('height', 600) self.resize(800, height) return True def storeConfig(self): """ storeConfig writes the keys to the configuration dict and stores. if some saving has to be proceeded to persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'messageW' not in self.app.config: self.app.config['messageW'] = {} config = self.app.config['messageW'] config['winPosX'] = self.pos().x() config['winPosY'] = self.pos().y() config['height'] = self.height() return True def closeEvent(self, closeEvent): self.storeConfig() # gui signals self.ui.clear.clicked.disconnect(self.clearWindow) # self.app.message.disconnect(self.writeMessage) self.app.update1s.disconnect(self.writeMessage) super().closeEvent(closeEvent) def showWindow(self): self.show() # gui signals self.ui.clear.clicked.connect(self.clearWindow) # self.app.message.connect(self.writeMessage) self.app.update1s.connect(self.writeMessage) def clearWindow(self): """ clearWindow resets the window and shows empty text. :return: true for test purpose """ self.ui.message.clear() return True def writeMessage(self): """ writeMessage takes singles with message and writes them to the text browser window. types: 0: normal text 1: highlighted text 2: warning text 3: error text :return: true for test purpose """ while not self.app.messageQueue.empty(): message, mType = self.app.messageQueue.get() if mType < 0: continue if mType > len(self.messColor): continue prefix = time.strftime('%H:%M:%S ', time.localtime()) message = prefix + message self.log.info('Message window: [{0}]'.format(message)) self.ui.message.setTextColor(self.messColor[mType]) self.ui.message.setFontWeight(self.messFont[mType]) self.ui.message.insertPlainText(message + '\n') self.ui.message.moveCursor(PyQt5.QtGui.QTextCursor.End) return True
class DevicePopup(PyQt5.QtWidgets.QDialog, widget.MWidget): """ the DevicePopup window class handles """ __all__ = [ 'DevicePopup', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # INDI device types indiTypes = { 'telescope': (1 << 0), 'camera': (1 << 1), 'guider': (1 << 2), 'focuser': (1 << 3), 'filter': (1 << 4), 'dome': (1 << 5), 'weather': (1 << 7), 'skymeter': 0, 'cover': (1 << 9) | (1 << 10), 'power': (1 << 7) | (1 << 3) } indiDefaults = { 'telescope': 'LX200 10micron', 'skymeter': 'SQM', 'power': 'Pegasus UPB', } def __init__(self, geometry=None, driver='', deviceType='', framework={}, data=None): super().__init__() self.ui = Ui_DevicePopup() self.ui.setupUi(self) self.initUI() self.setWindowModality(PyQt5.QtCore.Qt.ApplicationModal) self.data = data self.driver = driver self.deviceType = deviceType self.framework = framework self.indiClass = None self.indiSearchNameList = () self.indiSearchType = None self.returnValues = {'close': 'cancel'} # setting to center of parent image x = geometry[0] + (geometry[2] - self.width()) / 2 y = geometry[1] + (geometry[3] - self.height()) / 2 self.move(x, y) self.ui.cancel.clicked.connect(self.close) self.ui.ok.clicked.connect(self.storeConfig) self.ui.indiSearch.clicked.connect(self.searchDevices) self.ui.copyAlpaca.clicked.connect(self.copyAllAlpacaSettings) self.ui.copyIndi.clicked.connect(self.copyAllIndiSettings) self.initConfig() def initConfig(self): """ :return: True for test purpose """ # populate data deviceData = self.data.get(self.driver, {}) selectedFramework = deviceData.get('framework', 'indi') self.indiSearchType = self.indiTypes.get(self.deviceType, 0xff) self.setWindowTitle(f'Setup for: {self.driver}') # populating indi data self.ui.indiHost.setText(deviceData.get('indiHost', 'localhost')) self.ui.indiPort.setText(deviceData.get('indiPort', '7624')) self.ui.indiNameList.clear() self.ui.indiNameList.setView(PyQt5.QtWidgets.QListView()) indiName = deviceData.get('indiName', '') nameList = deviceData.get('indiNameList', []) if not nameList: self.ui.indiNameList.addItem('-') for i, name in enumerate(nameList): self.ui.indiNameList.addItem(name) if indiName == name: self.ui.indiNameList.setCurrentIndex(i) self.ui.indiMessages.setChecked(deviceData.get('indiMessages', False)) self.ui.indiLoadConfig.setChecked( deviceData.get('indiLoadConfig', False)) # populating alpaca data self.ui.alpacaProtocol.setCurrentIndex( deviceData.get('alpacaProtocol', 0)) self.ui.alpacaHost.setText(deviceData.get('alpacaHost', 'localhost')) self.ui.alpacaPort.setText(deviceData.get('alpacaPort', '11111')) number = int(deviceData.get('alpacaName', '"":0').split(':')[1]) self.ui.alpacaNumber.setValue(number) self.ui.alpacaUser.setText(deviceData.get('alpacaUser', 'user')) self.ui.alpacaPassword.setText( deviceData.get('alpacaPassword', 'password')) # for fw in self.framework: tabWidget = self.ui.tab.findChild(PyQt5.QtWidgets.QWidget, selectedFramework) tabIndex = self.ui.tab.indexOf(tabWidget) self.ui.tab.setCurrentIndex(tabIndex) for index in range(0, self.ui.tab.count()): if self.ui.tab.tabText(index).lower() in self.framework: continue self.ui.tab.setTabEnabled(index, False) self.show() return True def storeConfig(self): """ :return: true for test purpose """ # collecting indi data self.data[self.driver]['indiHost'] = self.ui.indiHost.text() self.data[self.driver]['indiPort'] = self.ui.indiPort.text() self.data[self.driver]['indiName'] = self.ui.indiNameList.currentText() model = self.ui.indiNameList.model() nameList = [] for index in range(model.rowCount()): nameList.append(model.item(index).text()) self.data[self.driver]['indiNameList'] = nameList self.data[ self.driver]['indiMessages'] = self.ui.indiMessages.isChecked() self.data[self.driver][ 'indiLoadConfig'] = self.ui.indiLoadConfig.isChecked() # collecting alpaca data self.data[self.driver][ 'alpacaProtocol'] = self.ui.alpacaProtocol.currentIndex() self.data[self.driver]['alpacaHost'] = self.ui.alpacaHost.text() self.data[self.driver]['alpacaPort'] = self.ui.alpacaPort.text() name = f'{self.deviceType}:{self.ui.alpacaNumber.value()}' self.data[self.driver]['alpacaName'] = name self.data[self.driver]['alpacaUser'] = self.ui.alpacaUser.text() self.data[ self.driver]['alpacaPassword'] = self.ui.alpacaPassword.text() # storing ok as closing self.returnValues['close'] = 'ok' # finally closing window self.close() return True def closeEvent(self, event): """ closeEvent collects all data necessary for the following process :param event: :return: """ # getting last setting: self.returnValues['framework'] = self.ui.tab.tabText( self.ui.tab.currentIndex()).lower() super().closeEvent(event) return def copyAllIndiSettings(self): """ copyAllIndiSettings transfers all data from host, port, messages to all other driver settings :return: true for test purpose """ for driver in self.data: self.data[driver]['indiHost'] = self.ui.indiHost.text() self.data[driver]['indiPort'] = self.ui.indiPort.text() self.data[driver][ 'indiMessages'] = self.ui.indiLoadConfig.isChecked() self.data[driver][ 'indiLoadConfig'] = self.ui.indiLoadConfig.isChecked() # memorizing that copy was done: self.returnValues['copyIndi'] = True return True def copyAllAlpacaSettings(self): """ copyAllAlpacaSettings transfers all data from protocol, host, port, user, password to all other driver settings :return: true for test purpose """ for driver in self.data: self.data[driver][ 'alpacaProtocol'] = self.ui.alpacaProtocol.currentIndex() self.data[driver]['alpacaHost'] = self.ui.alpacaHost.text() self.data[driver]['alpacaPort'] = self.ui.alpacaPort.text() self.data[driver]['alpacaNumber'] = self.ui.alpacaNumber.value() self.data[driver]['alpacaUser'] = self.ui.alpacaUser.text() self.data[driver]['alpacaPassword'] = self.ui.alpacaPassword.text() # memorizing that copy was done: self.returnValues['copyAlpaca'] = True return True def addDevicesWithType(self, deviceName, propertyName): """ addDevicesWithType gety called whenever a new device send out text messages. than it checks, if the device type fits to the search type desired. if they match, the device name is added to the list. :param deviceName: :param propertyName: :return: success """ device = self.indiClass.client.devices.get(deviceName) if not device: return False interface = device.getText(propertyName).get('DRIVER_INTERFACE', None) if interface is None: return False if self.indiSearchType is None: return False interface = int(interface) if interface & self.indiSearchType: self.indiSearchNameList.append(deviceName) return True def searchDevices(self): """ searchDevices implements a search for devices of a certain device type. it is called from a button press and checks which button it was. after that for the right device it collects all necessary data for host value, instantiates an INDI client and watches for all devices connected to this server. Than it connects a subroutine for collecting the right device names and opens a model dialog. the data collection takes place as long as the model dialog is open. when the user closes this dialog, the collected data is written to the drop down list. :return: success finding """ self.indiSearchNameList = list() if self.driver in self.indiDefaults: self.indiSearchNameList.append(self.indiDefaults[self.driver]) else: host = (self.ui.indiHost.text(), int(self.ui.indiPort.text())) self.indiClass = IndiClass() self.indiClass.host = host self.indiClass.client.signals.defText.connect( self.addDevicesWithType) self.indiClass.client.connectServer() self.indiClass.client.watchDevice() msg = PyQt5.QtWidgets.QMessageBox msg.information( self, 'Searching Devices', f'Search for [{self.driver}] could take some seconds!') self.indiClass.client.disconnectServer() self.ui.indiNameList.clear() self.ui.indiNameList.setView(PyQt5.QtWidgets.QListView()) for name in self.indiSearchNameList: self.log.info(f'Indi search found: {name}') for deviceName in self.indiSearchNameList: self.ui.indiNameList.addItem(deviceName) return True
class MeasureWindow(widget.MWidget): """ the measure window class handles """ __all__ = [ 'MeasureWindow', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) NUMBER_POINTS = 250 NUMBER_XTICKS = 5 def __init__(self, app): super().__init__() self.app = app self.ui = measure_ui.Ui_MeasureDialog() self.ui.setupUi(self) self.initUI() self.refreshCounter = 1 self.measureIndex = 0 self.timeIndex = 0 self.mSetUI = [ self.ui.measureSet1, self.ui.measureSet2, self.ui.measureSet3, ] self.plotFunc = { 'None': None, 'RA Stability': self.plotRa, 'DEC Stability': self.plotDec, 'Temperature': self.plotTemperature, 'Pressure': self.plotPressure, 'Humidity': self.plotHumidity, 'Sky Quality': self.plotSQR, 'Voltage': self.plotVoltage, 'Current': self.plotCurrent, } self.timeScale = { ' 1s Tick - 4 min': 1, ' 2s Ticks - 8 min': 2, ' 4s Ticks - 16 min': 4, ' 8s Ticks - 32 min': 8, ' 16s Ticks - 1 hour': 16, ' 32s Ticks - 2 hours': 32, ' 64s Ticks - 4 hours': 64, '128s Ticks - 9 hours': 128, } # doing the matplotlib embedding self.measureMat = self.embedMatplot(self.ui.measure) self.measureMat.parentWidget().setStyleSheet(self.BACK_BG) self.initConfig() def initConfig(self): """ initConfig read the key out of the configuration dict and stores it to the gui elements. if some initialisations have to be proceeded with the loaded persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'measureW' not in self.app.config: self.app.config['measureW'] = {} config = self.app.config['measureW'] x = config.get('winPosX', 100) y = config.get('winPosY', 100) if x > self.screenSizeX: x = 0 if y > self.screenSizeY: y = 0 self.move(x, y) height = config.get('height', 600) width = config.get('width', 800) self.resize(width, height) self.setupButtons() self.ui.measureSet1.setCurrentIndex(config.get('measureSet1', 0)) self.ui.measureSet2.setCurrentIndex(config.get('measureSet2', 0)) self.ui.measureSet3.setCurrentIndex(config.get('measureSet3', 0)) self.ui.timeSet.setCurrentIndex(config.get('timeSet', 0)) self.setCycleRefresh() self.showWindow() return True def storeConfig(self): """ storeConfig writes the keys to the configuration dict and stores. if some saving has to be proceeded to persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'measureW' not in self.app.config: self.app.config['measureW'] = {} config = self.app.config['measureW'] config['winPosX'] = self.pos().x() config['winPosY'] = self.pos().y() config['height'] = self.height() config['width'] = self.width() config['measureSet1'] = self.ui.measureSet1.currentIndex() config['measureSet2'] = self.ui.measureSet2.currentIndex() config['measureSet3'] = self.ui.measureSet3.currentIndex() config['timeSet'] = self.ui.timeSet.currentIndex() return True def showWindow(self): """ :return: """ self.show() # signals for gui self.ui.timeSet.currentIndexChanged.connect(self.setCycleRefresh) self.ui.measureSet1.currentIndexChanged.connect(self.setCycleRefresh) self.ui.measureSet2.currentIndexChanged.connect(self.setCycleRefresh) self.ui.measureSet3.currentIndexChanged.connect(self.setCycleRefresh) self.app.update1s.connect(self.cycleRefresh) return True def closeEvent(self, closeEvent): """ :param closeEvent: :return: """ # stop cyclic tasks self.app.update1s.disconnect(self.cycleRefresh) # save config self.storeConfig() # signals for gui self.ui.timeSet.currentIndexChanged.disconnect(self.setCycleRefresh) self.ui.measureSet1.currentIndexChanged.disconnect( self.setCycleRefresh) self.ui.measureSet2.currentIndexChanged.disconnect( self.setCycleRefresh) self.ui.measureSet3.currentIndexChanged.disconnect( self.setCycleRefresh) # remove big object plt.close(self.measureMat.figure) super().closeEvent(closeEvent) def setupButtons(self): """ setupButtons prepares the dynamic content od the buttons in measurement window. it write the bottom texts and number as well as the coloring for the actual setting :return: success for test purpose """ for mSet in self.mSetUI: mSet.clear() mSet.setView(PyQt5.QtWidgets.QListView()) for text in self.plotFunc.keys(): mSet.addItem(text) tSet = self.ui.timeSet tSet.clear() tSet.setView(PyQt5.QtWidgets.QListView()) for text in self.timeScale.keys(): tSet.addItem(text) return True def setCycleRefresh(self): """ :return: True for test purpose """ self.refreshCounter = self.timeScale[self.ui.timeSet.currentText()] self.cycleRefresh() return True def cycleRefresh(self): """ :return: True for test purpose """ cycle = self.timeScale[self.ui.timeSet.currentText()] if not self.refreshCounter % cycle: self.drawMeasure(cycle=cycle) self.refreshCounter += 1 return True def setupAxes(self, figure=None, numberPlots=3): """ setupAxes cleans up the axes object in figure an setup a new plotting. it draws grid, ticks etc. :param numberPlots: :param figure: axes object of figure :return: """ if figure is None: return None if numberPlots > 3: return None if numberPlots < 0: return None for axe in figure.axes: axe.cla() del axe gc.collect() figure.clf() figure.subplots_adjust(left=0.1, right=0.95, bottom=0.05, top=0.95) for i in range(numberPlots): self.measureMat.figure.add_subplot(numberPlots, 1, i + 1, facecolor=None) for axe in figure.axes: axe.set_facecolor((0, 0, 0, 0)) axe.tick_params(colors=self.M_BLUE, labelsize=12) axe.spines['bottom'].set_color(self.M_BLUE) axe.spines['top'].set_color(self.M_BLUE) axe.spines['left'].set_color(self.M_BLUE) axe.spines['right'].set_color(self.M_BLUE) return figure.axes def plotRa(self, axe=None, title='', data=None, cycle=None): """ drawRaDec show the specific graph for plotting the ra dec deviations. this is done with two color and axes to distinguish the values for ra and dec. ideally the values are around zero, but the scales of ra and dec axis have to be different. :param axe: axe for plotting :param title: title text :param data: data location :param cycle: cycle time for measurement :return: success """ ylabel = 'delta RA [arcsec]' start = -self.NUMBER_POINTS * cycle axe.set_title(title, color=self.M_BLUE, fontweight='bold', fontsize=16) axe.set_ylabel(ylabel, color=self.M_BLUE, fontweight='bold', fontsize=12) axe.plot( data['time'][start:-1:cycle], data['raJNow'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) axe.grid(True, color=self.M_GREY, alpha=1) axe.set_ylim(-4, 4) axe.get_yaxis().set_major_locator( ticker.MaxNLocator( nbins=8, integer=True, min_n_ticks=4, prune='both', )) axe.get_yaxis().set_major_formatter(ticker.FormatStrFormatter( '%.1f', )) return True def plotDec(self, axe=None, title='', data=None, cycle=None): """ drawRaDec show the specific graph for plotting the ra dec deviations. this is done with two color and axes to distinguish the values for ra and dec. ideally the values are around zero, but the scales of ra and dec axis have to be different. :param axe: axe for plotting :param title: title text :param data: data location :param cycle: cycle time for measurement :return: success """ ylabel = 'delta DEC [arcsec]' start = -self.NUMBER_POINTS * cycle axe.set_title(title, color=self.M_BLUE, fontweight='bold', fontsize=16) axe.set_ylabel(ylabel, color=self.M_BLUE, fontweight='bold', fontsize=12) axe.plot( data['time'][start:-1:cycle], data['decJNow'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) axe.grid(True, color=self.M_GREY, alpha=1) axe.set_ylim(-4, 4) axe.get_yaxis().set_major_locator( ticker.MaxNLocator( nbins=8, integer=True, min_n_ticks=4, prune='both', )) axe.get_yaxis().set_major_formatter(ticker.FormatStrFormatter( '%.1f', )) return True def plotTemperature(self, axe=None, title='', data=None, cycle=None): """ drawRaDec show the specific graph for plotting the ra dec deviations. this is done with two color and axes to distinguish the values for ra and dec. ideally the values are around zero, but the scales of ra and dec axis have to be different. :param axe: axe for plotting :param title: title text :param data: data location :param cycle: cycle time for measurement :return: success """ ylabel = 'Temperature [deg C]' start = -self.NUMBER_POINTS * cycle plotList = [] labelList = [] axe.set_title(title, color=self.M_BLUE, fontweight='bold', fontsize=16) axe.set_ylabel(ylabel, color=self.M_BLUE, fontweight='bold', fontsize=12) if 'sensorWeather' in self.app.measure.devices: r1, = axe.plot( data['time'][start:-1:cycle], data['sensorWeatherTemp'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) r2, = axe.plot( data['time'][start:-1:cycle], data['sensorWeatherDew'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) plotList.append(r1) plotList.append(r2) labelList.append('Sensor Temp') labelList.append('Sensor Dew') if 'onlineWeather' in self.app.measure.devices: r3, = axe.plot( data['time'][start:-1:cycle], data['onlineWeatherTemp'][start:-1:cycle], marker=None, markersize=3, color=self.M_GREEN, ) r4, = axe.plot( data['time'][start:-1:cycle], data['onlineWeatherDew'][start:-1:cycle], marker=None, markersize=3, color=self.M_GREEN, ) plotList.append(r3) plotList.append(r4) labelList.append('Online Temp') labelList.append('Online Dew') if 'directWeather' in self.app.measure.devices: r5, = axe.plot( data['time'][start:-1:cycle], data['directWeatherTemp'][start:-1:cycle], marker=None, markersize=3, color=self.M_RED, ) r6, = axe.plot( data['time'][start:-1:cycle], data['directWeatherDew'][start:-1:cycle], marker=None, markersize=3, color=self.M_RED, ) plotList.append(r5) plotList.append(r6) labelList.append('Direct Temp') labelList.append('Direct Dew') if 'power' in self.app.measure.devices: r7, = axe.plot( data['time'][start:-1:cycle], data['powTemp'][start:-1:cycle], marker=None, markersize=3, color=self.M_PINK, ) r8, = axe.plot( data['time'][start:-1:cycle], data['powDew'][start:-1:cycle], marker=None, markersize=3, color=self.M_PINK, ) plotList.append(r7) plotList.append(r8) labelList.append('Power Temp') labelList.append('Power Dew') if 'skymeter' in self.app.measure.devices: r9, = axe.plot( data['time'][start:-1:cycle], data['skyTemp'][start:-1:cycle], marker=None, markersize=3, color=self.M_YELLOW, ) plotList.append(r9) labelList.append('Skymeter Temp') if not labelList: return False legend = axe.legend( plotList, labelList, facecolor=self.M_BLACK, edgecolor=self.M_BLUE, ) for text in legend.get_texts(): text.set_color(self.M_BLUE) axe.grid(True, color=self.M_GREY, alpha=1) axe.margins(y=0.2) axe.get_yaxis().set_major_locator( ticker.MaxNLocator( nbins=8, integer=True, min_n_ticks=4, prune='both', )) axe.get_yaxis().set_major_formatter(ticker.FormatStrFormatter( '%.1f', )) return True def plotPressure(self, axe=None, title='', data=None, cycle=None): """ drawRaDec show the specific graph for plotting the ra dec deviations. this is done with two color and axes to distinguish the values for ra and dec. ideally the values are around zero, but the scales of ra and dec axis have to be different. :param axe: axe for plotting :param title: title text :param data: data location :param cycle: cycle time for measurement :return: success """ ylabel = 'Pressure [hPas]' start = -self.NUMBER_POINTS * cycle plotList = [] labelList = [] axe.set_title(title, color=self.M_BLUE, fontweight='bold', fontsize=16) axe.set_ylabel(ylabel, color=self.M_BLUE, fontweight='bold', fontsize=12) if 'sensorWeather' in self.app.measure.devices: r1, = axe.plot( data['time'][start:-1:cycle], data['sensorWeatherPress'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) plotList.append(r1) labelList.append('Sensor Temp') if 'onlineWeather' in self.app.measure.devices: r2, = axe.plot( data['time'][start:-1:cycle], data['onlineWeatherPress'][start:-1:cycle], marker=None, markersize=3, color=self.M_GREEN, ) plotList.append(r2) labelList.append('Online Press') if 'directWeather' in self.app.measure.devices: r3, = axe.plot( data['time'][start:-1:cycle], data['directWeatherPress'][start:-1:cycle], marker=None, markersize=3, color=self.M_RED, ) plotList.append(r3) labelList.append('Direct Press') if not labelList: return False legend = axe.legend( plotList, labelList, facecolor=self.M_BLACK, edgecolor=self.M_BLUE, ) for text in legend.get_texts(): text.set_color(self.M_BLUE) axe.grid(True, color=self.M_GREY, alpha=1) axe.margins(y=0.2) axe.get_yaxis().set_major_locator( ticker.MaxNLocator( nbins=8, integer=True, min_n_ticks=4, prune='both', )) axe.get_yaxis().set_major_formatter(ticker.FormatStrFormatter( '%.0f', )) return True def plotHumidity(self, axe=None, title='', data=None, cycle=None): """ drawRaDec show the specific graph for plotting the ra dec deviations. this is done with two color and axes to distinguish the values for ra and dec. ideally the values are around zero, but the scales of ra and dec axis have to be different. :param axe: axe for plotting :param title: title text :param data: data location :param cycle: cycle time for measurement :return: success """ ylabel = 'Humidity [%]' start = -self.NUMBER_POINTS * cycle plotList = [] labelList = [] axe.set_title(title, color=self.M_BLUE, fontweight='bold', fontsize=16) axe.set_ylabel(ylabel, color=self.M_BLUE, fontweight='bold', fontsize=12) if 'sensorWeather' in self.app.measure.devices: r1, = axe.plot( data['time'][start:-1:cycle], data['sensorWeatherHum'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) plotList.append(r1) labelList.append('Sensor Hum') if 'onlineWeather' in self.app.measure.devices: r2, = axe.plot( data['time'][start:-1:cycle], data['onlineWeatherHum'][start:-1:cycle], marker=None, markersize=3, color=self.M_GREEN, ) plotList.append(r2) labelList.append('Online Hum') if 'directWeather' in self.app.measure.devices: r3, = axe.plot( data['time'][start:-1:cycle], data['directWeatherHum'][start:-1:cycle], marker=None, markersize=3, color=self.M_RED, ) plotList.append(r3) labelList.append('Direct Hum') if 'power' in self.app.measure.devices: r4, = axe.plot( data['time'][start:-1:cycle], data['powHum'][start:-1:cycle], marker=None, markersize=3, color=self.M_PINK, ) plotList.append(r4) labelList.append('Power Hum') if not labelList: return False legend = axe.legend( plotList, labelList, facecolor=self.M_BLACK, edgecolor=self.M_BLUE, ) for text in legend.get_texts(): text.set_color(self.M_BLUE) axe.grid(True, color=self.M_GREY, alpha=1) axe.set_ylim(-0, 100) axe.get_yaxis().set_major_locator( ticker.MaxNLocator( nbins=8, integer=True, min_n_ticks=4, prune='both', )) axe.get_yaxis().set_major_formatter(ticker.FormatStrFormatter( '%.0f', )) return True def plotSQR(self, axe=None, title='', data=None, cycle=None): """ drawRaDec show the specific graph for plotting the ra dec deviations. this is done with two color and axes to distinguish the values for ra and dec. ideally the values are around zero, but the scales of ra and dec axis have to be different. :param axe: axe for plotting :param title: title text :param data: data location :param cycle: cycle time for measurement :return: success """ ylabel = 'Sky Quality [mpas]' start = -self.NUMBER_POINTS * cycle plotList = [] labelList = [] axe.set_title(title, color=self.M_BLUE, fontweight='bold', fontsize=16) axe.set_ylabel(ylabel, color=self.M_BLUE, fontweight='bold', fontsize=12) if 'skymeter' in self.app.measure.devices: r1, = axe.plot( data['time'][start:-1:cycle], data['skySQR'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) plotList.append(r1) labelList.append('Skymeter SQR') if not labelList: return False legend = axe.legend( plotList, labelList, facecolor=self.M_BLACK, edgecolor=self.M_BLUE, ) for text in legend.get_texts(): text.set_color(self.M_BLUE) axe.grid(True, color=self.M_GREY, alpha=1) axe.margins(y=0.2) axe.get_yaxis().set_major_locator( ticker.MaxNLocator( nbins=8, integer=True, min_n_ticks=4, prune='both', )) axe.get_yaxis().set_major_formatter(ticker.FormatStrFormatter( '%.2f', )) return True def plotVoltage(self, axe=None, title='', data=None, cycle=None): """ drawRaDec show the specific graph for plotting the ra dec deviations. this is done with two color and axes to distinguish the values for ra and dec. ideally the values are around zero, but the scales of ra and dec axis have to be different. :param axe: axe for plotting :param title: title text :param data: data location :param cycle: cycle time for measurement :return: success """ ylabel = 'Power Voltage [V]' start = -self.NUMBER_POINTS * cycle plotList = [] labelList = [] axe.set_title(title, color=self.M_BLUE, fontweight='bold', fontsize=16) axe.set_ylabel(ylabel, color=self.M_BLUE, fontweight='bold', fontsize=12) if 'power' in self.app.measure.devices: r1, = axe.plot( data['time'][start:-1:cycle], data['powVolt'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) plotList.append(r1) labelList.append('Voltage') if not labelList: return False legend = axe.legend( plotList, labelList, facecolor=self.M_BLACK, edgecolor=self.M_BLUE, ) for text in legend.get_texts(): text.set_color(self.M_BLUE) axe.grid(True, color=self.M_GREY, alpha=1) axe.margins(y=0.2) axe.get_yaxis().set_major_locator( ticker.MaxNLocator( nbins=8, integer=True, min_n_ticks=4, prune='both', )) axe.get_yaxis().set_major_formatter(ticker.FormatStrFormatter( '%.1f', )) return True def plotCurrent(self, axe=None, title='', data=None, cycle=None): """ drawRaDec show the specific graph for plotting the ra dec deviations. this is done with two color and axes to distinguish the values for ra and dec. ideally the values are around zero, but the scales of ra and dec axis have to be different. :param axe: axe for plotting :param title: title text :param data: data location :param cycle: cycle time for measurement :return: success """ ylabel = 'Power Current [A]' start = -self.NUMBER_POINTS * cycle plotList = [] labelList = [] axe.set_title(title, color=self.M_BLUE, fontweight='bold', fontsize=16) axe.set_ylabel(ylabel, color=self.M_BLUE, fontweight='bold', fontsize=12) if 'power' in self.app.measure.devices: r1, = axe.plot( data['time'][start:-1:cycle], data['powCurr'][start:-1:cycle], marker=None, markersize=3, color=self.M_WHITE, ) r2, = axe.plot( data['time'][start:-1:cycle], data['powCurr1'][start:-1:cycle], marker=None, markersize=3, color=self.M_PINK, ) r3, = axe.plot( data['time'][start:-1:cycle], data['powCurr2'][start:-1:cycle], marker=None, markersize=3, color=self.M_YELLOW, ) r4, = axe.plot( data['time'][start:-1:cycle], data['powCurr3'][start:-1:cycle], marker=None, markersize=3, color=self.M_GREEN, ) r5, = axe.plot( data['time'][start:-1:cycle], data['powCurr4'][start:-1:cycle], marker=None, markersize=3, color=self.M_RED, ) plotList.append(r1) plotList.append(r2) plotList.append(r3) plotList.append(r4) plotList.append(r5) labelList.append('Curr Sum') labelList.append('Curr 1') labelList.append('Curr 2') labelList.append('Curr 3') labelList.append('Curr 4') if not labelList: return False legend = axe.legend( plotList, labelList, facecolor=self.M_BLACK, edgecolor=self.M_BLUE, ) for text in legend.get_texts(): text.set_color(self.M_BLUE) axe.grid(True, color=self.M_GREY, alpha=1) axe.margins(y=0.2) axe.get_yaxis().set_major_locator( ticker.MaxNLocator( nbins=8, integer=True, min_n_ticks=4, prune='both', )) axe.get_yaxis().set_major_formatter(ticker.FormatStrFormatter( '%.1f', )) return True def drawMeasure(self, cycle=1): """ drawMeasure does the basic preparation for making the plot. it checks for borders and does finally the content dispatcher. currently there is no chance to implement a basic pattern as the graphs differ heavily. :param cycle: :return: success """ data = self.app.measure.data if 'time' not in data: return False if len(data['time']) < 4: return False numberPlots = 0 for mSet in self.mSetUI: if mSet.currentText() == 'None': continue numberPlots += 1 axes = self.setupAxes(figure=self.measureMat.figure, numberPlots=numberPlots) if axes is None: return False if not numberPlots: self.measureMat.figure.canvas.draw() return False grid = int(self.NUMBER_POINTS / self.NUMBER_XTICKS) ratio = cycle * grid time_end = data['time'][-1] time_ticks = np.arange(-self.NUMBER_XTICKS, 1, 1) time_ticks = time_ticks * ratio * 1000000 time_ticks = time_ticks + time_end time_labels = [x.astype(dt).strftime('%H:%M:%S') for x in time_ticks] for i, axe in enumerate(axes): axe.set_xticks(time_ticks) axe.set_xlim(time_ticks[0], time_ticks[-1]) if i == len(axes) - 1: axe.set_xticklabels(time_labels) else: axe.set_xticklabels([]) for axe, mSet in zip(axes, self.mSetUI): key = mSet.currentText() if self.plotFunc[key] is None: continue self.plotFunc[key](axe=axe, title=mSet.currentText(), data=data, cycle=cycle) axe.figure.canvas.draw() return True
class HemisphereWindow(widget.MWidget, HemisphereWindowExt): """ the hemisphere window class handles """ __all__ = [ 'HemisphereWindow', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) RESIZE_FINISHED_TIMEOUT = 0.3 def __init__(self, app): super().__init__() self.app = app self.ui = hemisphere_ui.Ui_HemisphereDialog() self.ui.setupUi(self) self.initUI() self.mutexDraw = PyQt5.QtCore.QMutex() self.operationMode = 'normal' self.MODE = dict(normal=dict(horMarker='None', horColor=self.M_GREEN, buildPColor=self.M_GREEN_H, starSize=6, starColor=self.M_YELLOW_L, starAnnColor=self.M_WHITE_L), build=dict(horMarker='None', horColor=self.M_GREEN, buildPColor=self.M_PINK_H, starSize=6, starColor=self.M_YELLOW_L, starAnnColor=self.M_WHITE_L), horizon=dict(horMarker='o', horColor=self.M_PINK_H, buildPColor=self.M_GREEN_L, starSize=6, starColor=self.M_YELLOW_L, starAnnColor=self.M_WHITE_L), star=dict(horMarker='None', horColor=self.M_GREEN_LL, buildPColor=self.M_GREEN_L, starSize=12, starColor=self.M_YELLOW_H, starAnnColor=self.M_WHITE_H)) self.startup = True self.resizeTimerValue = -1 # attributes to be stored in class self.pointerAltAz = None self.pointerDome = None self.pointsBuild = None self.pointsBuildAnnotate = list() self.starsAlign = None self.starsAlignAnnotate = list() self.horizonFill = None self.horizonMarker = None self.meridianSlew = None self.meridianTrack = None self.horizonLimitHigh = None self.horizonLimitLow = None self.celestialPath = None # doing the matplotlib embedding self.hemisphereMat = self.embedMatplot(self.ui.hemisphere) self.hemisphereMat.parentWidget().setStyleSheet(self.BACK_BG) self.hemisphereBack = None self.hemisphereBackStars = None self.initConfig() def initConfig(self): """ initConfig read the key out of the configuration dict and stores it to the gui elements. if some initialisations have to be proceeded with the loaded persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'hemisphereW' not in self.app.config: self.app.config['hemisphereW'] = {} config = self.app.config['hemisphereW'] x = config.get('winPosX', 100) y = config.get('winPosY', 100) if x > self.screenSizeX: x = 0 if y > self.screenSizeY: y = 0 self.move(x, y) height = config.get('height', 600) width = config.get('width', 800) self.resize(width, height) self.ui.checkShowSlewPath.setChecked( config.get('checkShowSlewPath', False)) self.ui.checkShowMeridian.setChecked( config.get('checkShowMeridian', False)) self.ui.checkShowCelestial.setChecked( config.get('checkShowCelestial', False)) self.ui.checkShowAlignStar.setChecked( config.get('checkShowAlignStar', False)) self.ui.checkUseHorizon.setChecked(config.get('checkUseHorizon', False)) self.ui.showPolar.setChecked(config.get('showPolar', False)) self.configOperationMode() self.showWindow() return True def storeConfig(self): """ storeConfig writes the keys to the configuration dict and stores. if some saving has to be proceeded to persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'hemisphereW' not in self.app.config: self.app.config['hemisphereW'] = {} config = self.app.config['hemisphereW'] config['winPosX'] = self.pos().x() config['winPosY'] = self.pos().y() config['height'] = self.height() config['width'] = self.width() config['checkShowSlewPath'] = self.ui.checkShowSlewPath.isChecked() config['checkShowMeridian'] = self.ui.checkShowMeridian.isChecked() config['checkShowCelestial'] = self.ui.checkShowCelestial.isChecked() config['checkShowAlignStar'] = self.ui.checkShowAlignStar.isChecked() config['checkUseHorizon'] = self.ui.checkUseHorizon.isChecked() config['showPolar'] = self.ui.showPolar.isChecked() def closeEvent(self, closeEvent): """ :param closeEvent: :return: """ self.app.update10s.disconnect(self.updateAlignStar) self.app.update0_1s.disconnect(self.resizeTimer) self.storeConfig() # signals for gui self.ui.checkShowSlewPath.clicked.disconnect(self.drawHemisphere) self.ui.checkShowMeridian.clicked.disconnect(self.updateSettings) self.ui.checkShowCelestial.clicked.disconnect(self.updateSettings) self.ui.checkUseHorizon.clicked.disconnect(self.drawHemisphere) self.ui.checkEditNone.clicked.disconnect(self.setOperationMode) self.ui.checkEditHorizonMask.clicked.disconnect(self.setOperationMode) self.ui.checkEditBuildPoints.clicked.disconnect(self.setOperationMode) self.ui.checkPolarAlignment.clicked.disconnect(self.setOperationMode) self.ui.checkShowAlignStar.clicked.disconnect(self.drawHemisphere) self.ui.checkShowAlignStar.clicked.disconnect(self.configOperationMode) self.app.redrawHemisphere.disconnect(self.drawHemisphere) self.app.mount.signals.pointDone.disconnect(self.updatePointerAltAz) self.app.mount.signals.settingDone.disconnect(self.updateSettings) self.app.dome.signals.azimuth.disconnect(self.updateDome) self.app.dome.signals.deviceDisconnected.disconnect(self.updateDome) self.app.dome.signals.serverDisconnected.disconnect(self.updateDome) plt.close(self.hemisphereMat.figure) super().closeEvent(closeEvent) def resizeEvent(self, event): """ we are using the resize event to reset the timer, which means waiting for RESIZE_FINISHED_TIMEOUT in total before redrawing the complete hemisphere. as we are using a 0.1s cyclic timer. :param event: :return: """ super().resizeEvent(event) if self.startup: self.startup = False else: self.resizeTimerValue = int(self.RESIZE_FINISHED_TIMEOUT / 0.1) def resizeTimer(self): """ the resize timer is a workaround because when resizing the window, the blit function needs a new scaled background picture to retrieve. otherwise you will get odd picture. unfortunately there is no resize finished event from qt framework itself. problem is you never know, when resizing is finished. it might be the best way to implement a event filter with mouse and resize events to build a resize finished event. so the only quick solution is to wait for some time after we got the last resize event and than draw the widgets from scratch on. :return: True for test purpose """ self.resizeTimerValue -= 1 if self.resizeTimerValue == 0: self.drawHemisphere() return True def showWindow(self): """ :return: """ # signals for gui self.ui.checkShowSlewPath.clicked.connect(self.drawHemisphere) self.ui.checkUseHorizon.clicked.connect(self.drawHemisphere) self.ui.checkShowAlignStar.clicked.connect(self.drawHemisphere) self.app.redrawHemisphere.connect(self.drawHemisphere) self.ui.checkShowMeridian.clicked.connect(self.updateSettings) self.ui.checkShowCelestial.clicked.connect(self.updateSettings) self.app.mount.signals.settingDone.connect(self.updateSettings) self.app.mount.signals.pointDone.connect(self.updatePointerAltAz) self.app.dome.signals.azimuth.connect(self.updateDome) self.app.dome.signals.deviceDisconnected.connect(self.updateDome) self.app.dome.signals.serverDisconnected.connect(self.updateDome) self.ui.checkEditNone.clicked.connect(self.setOperationMode) self.ui.checkEditHorizonMask.clicked.connect(self.setOperationMode) self.ui.checkEditBuildPoints.clicked.connect(self.setOperationMode) self.ui.checkPolarAlignment.clicked.connect(self.setOperationMode) self.ui.checkShowAlignStar.clicked.connect(self.configOperationMode) self.app.update10s.connect(self.updateAlignStar) self.app.update0_1s.connect(self.resizeTimer) # finally setting the mouse handler self.hemisphereMat.figure.canvas.mpl_connect('button_press_event', self.onMouseDispatcher) self.hemisphereMat.figure.canvas.mpl_connect('motion_notify_event', self.showMouseCoordinates) self.show() self.drawHemisphere() return True def setupAxes(self, widget=None): """ setupAxes cleans up the axes object in figure an setup a new plotting. it draws grid, ticks etc. :param widget: object of embedded canvas :return: """ if widget is None: return None for axe in widget.figure.axes: axe.cla() del axe gc.collect() widget.figure.clf() # used constrained_layout = True instead # figure.subplots_adjust(left=0.075, right=0.95, bottom=0.1, top=0.975) axe = widget.figure.add_subplot(1, 1, 1, facecolor=None) axe.set_facecolor((0, 0, 0, 0)) axe.set_xlim(0, 360) axe.set_ylim(0, 90) axe.grid(True, color=self.M_GREY) axe.tick_params(axis='x', bottom=True, colors=self.M_BLUE, labelsize=12) axeTop = axe.twiny() axeTop.set_facecolor((0, 0, 0, 0)) axeTop.set_xlim(0, 360) axeTop.tick_params(axis='x', top=True, colors=self.M_BLUE, labelsize=12) axeTop.set_xticks(np.arange(0, 361, 45)) axeTop.grid(axis='both', visible=False) axeTop.set_xticklabels( ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'N']) axeTop.spines['bottom'].set_color(self.M_BLUE) axeTop.spines['top'].set_color(self.M_BLUE) axeTop.spines['left'].set_color(self.M_BLUE) axeTop.spines['right'].set_color(self.M_BLUE) axe.set_xticks(np.arange(0, 361, 45)) axe.set_xticklabels( ['0', '45', '90', '135', '180', '225', '270', '315', '360']) axe.tick_params(axis='y', colors=self.M_BLUE, which='both', labelleft=True, labelright=True, labelsize=12) axe.set_xlabel('Azimuth in degrees', color=self.M_BLUE, fontweight='bold', fontsize=12) axe.set_ylabel('Altitude in degrees', color=self.M_BLUE, fontweight='bold', fontsize=12) return axe def drawBlit(self): """ There were some optimizations in with regard to drawing speed derived from: https://stackoverflow.com/questions/8955869/why-is-plotting-with-matplotlib-so-slow so whenever a draw canvas is made, I store the background and painting the pointers is done via blit. :return: success """ if not self.mutexDraw.tryLock(): return False if self.hemisphereMat.figure.axes and self.hemisphereBackStars: axe = self.hemisphereMat.figure.axes[0] axe.figure.canvas.restore_region(self.hemisphereBackStars) self.pointerAltAz.set_visible(True) axe.draw_artist(self.pointerAltAz) axe.draw_artist(self.pointerDome) axe.figure.canvas.blit(axe.bbox) self.mutexDraw.unlock() return True def drawBlitStars(self): """ The alignment stars were the second layer to be draw. There were some optimizations in with regard to drawing speed derived from: https://stackoverflow.com/questions/8955869/why-is-plotting-with-matplotlib-so-slow so whenever a draw canvas is made, I store the background and painting the pointers is done via blit. :return: success """ if not self.mutexDraw.tryLock(): return False if self.hemisphereMat.figure.axes and self.hemisphereBack: axe = self.hemisphereMat.figure.axes[0] axe.figure.canvas.restore_region(self.hemisphereBack) axe.draw_artist(self.starsAlign) for annotation in self.starsAlignAnnotate: axe.draw_artist(annotation) axe.figure.canvas.blit(axe.bbox) self.hemisphereBackStars = axe.figure.canvas.copy_from_bbox( axe.bbox) self.mutexDraw.unlock() return True def updateCelestialPath(self): """ updateCelestialPath is called whenever an update of settings from mount are given. it takes the actual values and corrects the point in window if window is in show status. If the object is not created, the routing returns false. :return: needs drawing """ if self.celestialPath is None: return False isVisible = self.celestialPath.get_visible() newVisible = self.ui.checkShowCelestial.isChecked() needDraw = isVisible != newVisible self.celestialPath.set_visible(newVisible) return needDraw def updateMeridian(self, sett): """ updateMeridian is called whenever an update of settings from mount are given. it takes the actual values and corrects the point in window if window is in show status. If the object is not created, the routing returns false. :param sett: settings reference from mount :return: needs drawing """ slew = sett.meridianLimitSlew track = sett.meridianLimitTrack if slew is None or track is None: return False if self.meridianTrack is None: return False if self.meridianSlew is None: return False isVisible = self.meridianTrack.get_visible() newVisible = self.ui.checkShowMeridian.isChecked() needDraw = isVisible != newVisible self.meridianTrack.set_visible(newVisible) self.meridianSlew.set_visible(newVisible) self.meridianTrack.set_xy((180 - track, 0)) self.meridianSlew.set_xy((180 - slew, 0)) aktTrack = self.meridianTrack.get_width() / 2 aktSlew = self.meridianSlew.get_width() / 2 needDraw = needDraw or (track != aktTrack) or (slew != aktSlew) self.meridianTrack.set_width(2 * track) self.meridianSlew.set_width(2 * slew) return needDraw def updateHorizonLimits(self, sett): """ updateHorizonLimits is called whenever an update of settings from mount are given. it takes updateHorizonLimits actual values and corrects the point in window if window is in show status. If the object is not created, the routing returns false. :param sett: settings reference from mount :return: success """ high = sett.horizonLimitHigh low = sett.horizonLimitLow if high is None or low is None: return False if self.horizonLimitLow is None: return False if self.horizonLimitHigh is None: return False aktHigh = self.horizonLimitHigh.get_xy()[1] aktLow = self.horizonLimitLow.get_height() needDraw = aktHigh != high or aktLow != low self.horizonLimitHigh.set_xy((0, high)) self.horizonLimitHigh.set_height(90 - high) self.horizonLimitLow.set_xy((0, 0)) self.horizonLimitLow.set_height(low) return needDraw def updateSettings(self): """ updateSettings renders all static settings upon signals received and aggregates the need of a drawing. the called methods have to detect if something changed to determine if a redraw has to be done. this is done to reduce runtime and preserve low CPU processing. :return: needs draw """ sett = self.app.mount.setting suc = self.updateCelestialPath() suc = self.updateHorizonLimits(sett) or suc suc = self.updateMeridian(sett) or suc if suc: self.drawHemisphere() return suc def updatePointerAltAz(self): """ updatePointerAltAz is called whenever an update of coordinates from mount are given. it takes the actual values and corrects the point in window if window is in show status. If the object is not created, the routing returns false. There were some optimizations in with regard to drawing speed derived from: https://stackoverflow.com/questions/8955869/why-is-plotting-with-matplotlib-so-slow so whenever a draw canvas is made, I store the background and painting the pointers is done via blit. :return: success """ obsSite = self.app.mount.obsSite if obsSite.Alt is None: return False if obsSite.Az is None: return False if self.pointerAltAz is None: return False alt = obsSite.Alt.degrees az = obsSite.Az.degrees self.pointerAltAz.set_data((az, alt)) self.drawBlit() return True def updateDome(self, azimuth): """ updateDome is called whenever an update of coordinates from dome are given. it takes the actual values and corrects the point in window if window is in show status. If the object is not created, the routing returns false. :param azimuth: :return: success """ if self.pointerDome is None: return False if not isinstance(azimuth, (int, float)): self.pointerDome.set_visible(False) return False visible = self.app.mainW.deviceStat['dome'] self.pointerDome.set_xy((azimuth - 15, 1)) self.pointerDome.set_visible(visible) self.drawBlit() return True def updateAlignStar(self): """ updateAlignStar is called whenever an update of coordinates from mount are given. it takes the actual values and corrects the point in window if window is in show status. If the object is not created, the routing returns false. :return: success """ if not self.ui.checkShowAlignStar.isChecked(): return False if self.starsAlign is None: return False if self.starsAlignAnnotate is None: return False axes = self.hemisphereMat.figure.axes[0] hip = self.app.hipparcos hip.calculateAlignStarPositionsAltAz() self.starsAlign.set_data(hip.az, hip.alt) for i, starAnnotation in enumerate(self.starsAlignAnnotate): starAnnotation.remove() self.starsAlignAnnotate = list() # due to the fact that all annotation are only shown if in axes when coordinate # are in data, after some time, no annotation will be shown, because just moved. # therefore we add each time the annotation again. visible = self.ui.checkShowAlignStar.isChecked() for alt, az, name in zip(hip.alt, hip.az, hip.name): annotation = axes.annotate( name, xy=(az, alt), xytext=(2, 2), textcoords='offset points', xycoords='data', color='#808080', fontsize=12, clip_on=True, visible=visible, ) self.starsAlignAnnotate.append(annotation) self.drawBlitStars() return True def clearHemisphere(self): """ clearHemisphere is called when after startup the location of the mount is changed to reconstruct correctly the hemisphere window :return: """ self.pointsBuild = None self.app.data.clearBuildP() self.drawHemisphere() def staticHorizon(self, axes=None): """ staticHorizon draw the horizon line. in case of a polar plot it will be reversed, which mean the background will be green and to horizon polygon will be drawn in background color :param axes: matplotlib axes object :return: """ showHorizon = self.ui.checkUseHorizon.isChecked() if not (self.app.data.horizonP and showHorizon): return False alt, az = zip(*self.app.data.horizonP) alt = np.array(alt) az = np.array(az) self.horizonFill, = axes.fill(az, alt, color='#002000', zorder=-20) self.horizonMarker, = axes.plot( az, alt, color=self.MODE[self.operationMode]['horColor'], marker=self.MODE[self.operationMode]['horMarker'], zorder=-20, lw=3) return True def staticModelData(self, axes=None): """ staticModelData draw in the chart the build points and their index as annotations :param axes: matplotlib axes object :return: success """ if not self.app.data.buildP: return False alt, az = zip(*self.app.data.buildP) alt = np.array(alt) az = np.array(az) # show line path pf slewing if self.ui.checkShowSlewPath.isChecked(): ls = ':' lw = 1 else: ls = '' lw = 0 self.pointsBuild, = axes.plot( az, alt, marker=self.markerPoint(), markersize=9, linestyle=ls, lw=lw, fillstyle='none', color=self.MODE[self.operationMode]['buildPColor'], zorder=20, ) self.pointsBuildAnnotate = list() for i, AltAz in enumerate(zip(az, alt)): annotation = axes.annotate( '{0:2d}'.format(i), xy=AltAz, xytext=(2, -10), textcoords='offset points', color='#E0E0E0', zorder=10, ) self.pointsBuildAnnotate.append(annotation) return True def staticCelestialEquator(self, axes=None): """ staticCelestialEquator draw ra / dec lines on the chart :param axes: matplotlib axes object :return: success """ # draw celestial equator visible = self.ui.checkShowCelestial.isChecked() celestial = self.app.data.generateCelestialEquator() alt, az = zip(*celestial) self.celestialPath, = axes.plot(az, alt, '.', markersize=1, fillstyle='none', color='#808080', visible=visible) return True def staticMeridianLimits(self, axes=None): """ :param axes: matplotlib axes object :return: success """ # draw meridian limits if self.app.mount.setting.meridianLimitSlew is not None: slew = self.app.mount.setting.meridianLimitSlew else: slew = 0 visible = self.ui.checkShowMeridian.isChecked() self.meridianSlew = mpatches.Rectangle((180 - slew, 0), 2 * slew, 90, zorder=-5, color='#00008080', visible=visible) axes.add_patch(self.meridianSlew) if self.app.mount.setting.meridianLimitTrack is not None: track = self.app.mount.setting.meridianLimitTrack else: track = 0 self.meridianTrack = mpatches.Rectangle((180 - track, 0), 2 * track, 90, zorder=-10, color='#80800080', visible=visible) axes.add_patch(self.meridianTrack) return True def staticHorizonLimits(self, axes=None): """ :param axes: matplotlib axes object :return: success """ if self.app.mount.setting.horizonLimitHigh is not None: high = self.app.mount.setting.horizonLimitHigh else: high = 90 if self.app.mount.setting.horizonLimitLow is not None: low = self.app.mount.setting.horizonLimitLow else: low = 0 self.horizonLimitHigh = mpatches.Rectangle((0, high), 360, 90 - high, zorder=-30, color='#60383880', visible=True) self.horizonLimitLow = mpatches.Rectangle((0, 0), 360, low, zorder=-30, color='#60383880', visible=True) axes.add_patch(self.horizonLimitHigh) axes.add_patch(self.horizonLimitLow) return True def drawHemisphereStatic(self, axes=None): """ drawHemisphereStatic renders the static part of the hemisphere window and puts all drawing on the static plane. the content consist of: - modeldata points - horizon mask - celestial paths - meridian limits with all their styles an coloring :param axes: matplotlib axes object :return: success """ if not self.mutexDraw.tryLock(): return False self.staticHorizon(axes=axes) self.staticCelestialEquator(axes=axes) self.staticMeridianLimits(axes=axes) self.staticHorizonLimits(axes=axes) self.staticModelData(axes=axes) axes.figure.canvas.draw() axes.figure.canvas.flush_events() self.hemisphereBack = axes.figure.canvas.copy_from_bbox(axes.bbox) self.mutexDraw.unlock() return True def drawHemisphereMoving(self, axes=None): """ drawHemisphereMoving is rendering the moving part which consists of: - pointer: where the mount points to - dome widget: which shows the position of the dome opening the dynamic ones are located on a separate plane to improve rendering speed, because we update this part very often. :param axes: matplotlib axes object :return: """ self.pointerAltAz, = axes.plot( 180, 45, zorder=10, color='#FF00FF', marker=self.markerAltAz(), markersize=25, linestyle='none', fillstyle='none', clip_on=True, visible=False, ) self.pointerDome = mpatches.Rectangle((165, 1), 30, 88, zorder=-30, color='#40404080', lw=3, clip_on=True, fill=True, visible=False) axes.add_patch(self.pointerDome) return True def drawAlignmentStars(self, axes=None): """ drawAlignmentStars is rendering the alignment star map. this moves over time with the speed of earth turning. so we have to update the rendering, but on low speed without having any user interaction. :param axes: matplotlib axes object :return: true for test purpose """ visible = self.ui.checkShowAlignStar.isChecked() self.starsAlignAnnotate = list() hip = self.app.hipparcos hip.calculateAlignStarPositionsAltAz() self.starsAlign, = axes.plot( hip.az, hip.alt, marker=self.markerStar(), markersize=7, linestyle='', color=self.MODE[self.operationMode]['starColor'], zorder=-20, visible=visible, ) for alt, az, name in zip(hip.alt, hip.az, hip.name): annotation = axes.annotate( name, xy=(az, alt), xytext=(2, 2), textcoords='offset points', xycoords='data', color='#808080', fontsize=12, clip_on=True, visible=visible, ) self.starsAlignAnnotate.append(annotation) self.drawBlitStars() return True def drawHemisphere(self): """ drawHemisphere is the basic renderer for all items and widgets in the hemisphere window. it takes care of drawing the grid, enables three layers of transparent widgets for static content, moving content and star maps. this is mainly done to get a reasonable performance when redrawing the canvas. in addition it initializes the objects for points markers, patches, lines etc. for making the window nice and user friendly. the user interaction on the hemisphere windows is done by the event handler of matplotlib itself implementing an on Mouse handler, which takes care of functions. :return: nothing """ # clearing axes before drawing, only static visible, dynamic only when content # is available. visibility is handled with their update method self.hemisphereMat.figure.canvas.draw() axes = self.setupAxes(widget=self.hemisphereMat) # calling renderer self.drawHemisphereStatic(axes=axes) self.drawHemisphereMoving(axes=axes) self.drawAlignmentStars(axes=axes)
class KeypadWindow(widget.MWidget): """ the KeypadWindow window class handles """ __all__ = ['KeypadWindow', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, app): super().__init__() self.app = app self.ui = keypad_ui.Ui_KeypadDialog() self.ui.setupUi(self) self.initUI() # getting a new browser object self.browser = PyQt5.QtWebEngineWidgets.QWebEngineView() # adding it to window widget self.ui.keypad.addWidget(self.browser) # avoid flickering in white self.browser.setVisible(False) self.browser.page().setBackgroundColor(PyQt5.QtCore.Qt.transparent) self.initConfig() self.showWindow() def initConfig(self): """ initConfig read the key out of the configuration dict and stores it to the gui elements. if some initialisations have to be proceeded with the loaded persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'keypadW' not in self.app.config: self.app.config['keypadW'] = {} config = self.app.config['keypadW'] x = config.get('winPosX', 100) y = config.get('winPosY', 100) if x > self.screenSizeX: x = 0 if y > self.screenSizeY: y = 0 self.move(x, y) height = config.get('height', 500) width = config.get('width', 260) self.resize(width, height) return True def storeConfig(self): """ storeConfig writes the keys to the configuration dict and stores. if some saving has to be proceeded to persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'keypadW' not in self.app.config: self.app.config['keypadW'] = {} config = self.app.config['keypadW'] config['winPosX'] = self.pos().x() config['winPosY'] = self.pos().y() config['height'] = self.height() config['width'] = self.width() return True def closeEvent(self, closeEvent): """ :param closeEvent: :return: """ # save config self.storeConfig() # gui signals self.browser.loadFinished.disconnect(self.loadFinished) # remove big object self.browser = None super().closeEvent(closeEvent) def showWindow(self): """ :return: """ self.browser.loadFinished.connect(self.loadFinished) self.showUrl() self.show() def loadFinished(self): """ :return: """ self.browser.setVisible(True) def showUrl(self): """ :return: success """ host = self.app.mainW.ui.mountHost.text() if not host: return False file = f'qrc:/webif/virtkeypad.html?host={host}' self.browser.load(PyQt5.QtCore.QUrl(file)) return True
class FlipFlatIndi(IndiClass): """ the class FlipFlatIndi inherits all information and handling of the FlipFlat device >>> f = FlipFlatIndi(app=None) """ __all__ = [ 'FlipFlatIndi', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # update rate to 1 seconds for setting indi server UPDATE_RATE = 1 def __init__(self, app=None, signals=None, data=None): super().__init__(app=app) self.signals = signals self.data = data def setUpdateConfig(self, deviceName): """ _setUpdateRate corrects the update rate of weather devices to get an defined setting regardless, what is setup in server side. :param deviceName: :return: success """ if deviceName != self.name: return False if self.device is None: return False update = self.device.getNumber('PERIOD_MS') if 'PERIOD' not in update: return False if update.get('PERIOD', 0) == self.UPDATE_RATE: return True update['PERIOD'] = self.UPDATE_RATE suc = self.client.sendNewNumber(deviceName=deviceName, propertyName='PERIOD_MS', elements=update) return suc def sendCoverPark(self, park=True): """ :param park: :return: success """ if self.device is None: return False cover = self.device.getSwitch('CAP_PARK') cover['UNPARK'] = not park cover['PARK'] = park suc = self.client.sendNewSwitch( deviceName=self.name, propertyName='CAP_PARK', elements=cover, ) return suc
class FilterIndi(IndiClass): """ the class FilterIndi inherits all information and handling of the FilterWheel device >>> f = FilterIndi(app=None) """ __all__ = [ 'FilterIndi', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # update rate to 1 seconds for setting indi server UPDATE_RATE = 1 def __init__(self, app=None, signals=None, data=None): super().__init__(app=app, data=data) self.signals = signals self.data = data def setUpdateConfig(self, deviceName): """ _setUpdateRate corrects the update rate of weather devices to get an defined setting regardless, what is setup in server side. :param deviceName: :return: success """ if deviceName != self.name: return False if self.device is None: return False update = self.device.getNumber('PERIOD_MS') if 'PERIOD' not in update: return False if update.get('PERIOD', 0) == self.UPDATE_RATE: return True update['PERIOD'] = self.UPDATE_RATE suc = self.client.sendNewNumber(deviceName=deviceName, propertyName='PERIOD_MS', elements=update) return suc def sendFilterNumber(self, filterNumber=1): """ sendFilterNumber send the desired filter number :param filterNumber: :return: success """ # setting fast mode: filterNo = self.device.getNumber('FILTER_SLOT') filterNo['FILTER_SLOT_VALUE'] = filterNumber suc = self.client.sendNewNumber( deviceName=self.name, propertyName='FILTER_SLOT', elements=filterNo, ) return suc
class DomeIndi(IndiClass): """ the class Dome inherits all information and handling of the Dome device. there will be some parameters who will define the slewing position of the dome relating to the mount. >>> dome = DomeIndi(app=None) """ __all__ = [ 'DomeIndi', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # update rate to 1000 milli seconds for setting indi server UPDATE_RATE = 1000 def __init__(self, app=None, signals=None, data=None): super().__init__(app=app, data=data) self.signals = signals self.data = data self.settlingTime = 0 self.azimuth = -1 self.slewing = False self.app.update1s.connect(self.updateStatus) self.settlingWait = PyQt5.QtCore.QTimer() self.settlingWait.setSingleShot(True) self.settlingWait.timeout.connect(self.waitSettlingAndEmit) @property def settlingTime(self): return self._settlingTime * 1000 @settlingTime.setter def settlingTime(self, value): self._settlingTime = value def setUpdateConfig(self, deviceName): """ _setUpdateRate corrects the update rate of dome devices to get an defined setting regardless, what is setup in server side. :param deviceName: :return: success """ if deviceName != self.name: return False if self.device is None: return False # setting polling updates in driver update = self.device.getNumber('POLLING_PERIOD') if 'PERIOD_MS' not in update: return False if update.get('PERIOD_MS', 0) == self.UPDATE_RATE: return True update['PERIOD_MS'] = self.UPDATE_RATE suc = self.client.sendNewNumber( deviceName=deviceName, propertyName='POLLING_PERIOD', elements=update, ) return suc def updateStatus(self): """ updateStatus emits the actual azimuth status every 3 second in case of opening a window and get the signals late connected as INDI does nt repeat any signal of it's own :return: true for test purpose """ if not self.client.connected: return False self.signals.azimuth.emit(self.azimuth) return True def waitSettlingAndEmit(self): """ waitSettlingAndEmit emit the signal for slew finished :return: true for test purpose """ self.signals.slewFinished.emit() return True def updateNumber(self, deviceName, propertyName): """ updateNumber is called whenever a new number is received in client. it runs through the device list and writes the number data to the according locations. :param deviceName: :param propertyName: :return: """ if self.device is None: return False if deviceName != self.name: return False for element, value in self.device.getNumber(propertyName).items(): key = propertyName + '.' + element self.data[key] = value # print(propertyName, element, value) if element != 'DOME_ABSOLUTE_POSITION': continue # starting condition: don't do anything if self.azimuth == -1: self.azimuth = value continue # send trigger for new data self.signals.azimuth.emit(self.azimuth) # calculate the stop slewing condition isSlewing = (self.device.ABS_DOME_POSITION['state'] == 'Busy') if isSlewing: self.signals.message.emit('slewing') else: self.signals.message.emit('') if self.slewing and not isSlewing: # start timer for settling time and emit signal afterwards self.settlingWait.start(self.settlingTime) # store for the next cycle self.azimuth = value self.slewing = isSlewing return True def slewToAltAz(self, altitude=0, azimuth=0): """ slewToAltAz sends a command to the dome to move to azimuth / altitude. if a dome does support this :param altitude: :param azimuth: :return: success """ if self.device is None: return False if self.name is None or not self.name: return False position = self.device.getNumber('ABS_DOME_POSITION') if 'DOME_ABSOLUTE_POSITION' not in position: return False position['DOME_ABSOLUTE_POSITION'] = azimuth suc = self.client.sendNewNumber( deviceName=self.name, propertyName='ABS_DOME_POSITION', elements=position, ) if suc: self.slewing = True return suc
class Focuser: __all__ = [ 'Focuser', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, app): self.app = app self.threadPool = app.threadPool self.signals = FocuserSignals() self.data = {} self.framework = None self.run = { 'indi': FocuserIndi(self.app, self.signals, self.data), } self.name = '' self.host = ('localhost', 7624) self.isGeometry = False # signalling from subclasses to main self.run['indi'].client.signals.serverConnected.connect( self.signals.serverConnected) self.run['indi'].client.signals.serverDisconnected.connect( self.signals.serverDisconnected) self.run['indi'].client.signals.deviceConnected.connect( self.signals.deviceConnected) self.run['indi'].client.signals.deviceDisconnected.connect( self.signals.deviceDisconnected) @property def host(self): return self._host @host.setter def host(self, value): self._host = value if self.framework in self.run.keys(): self.run[self.framework].host = value @property def name(self): return self._name @name.setter def name(self, value): self._name = value if self.framework in self.run.keys(): self.run[self.framework].name = value def startCommunication(self): """ """ if self.framework not in self.run.keys(): return False suc = self.run[self.framework].startCommunication() return suc def stopCommunication(self): """ """ if self.framework not in self.run.keys(): return False suc = self.run[self.framework].stopCommunication() return suc
class ImageWindow(widget.MWidget): """ the image window class handles fits image loading, stretching, zooming and handles the gui interface for display. both wcs and pixel coordinates will be used. """ __all__ = [ 'ImageWindow', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, app): super().__init__() self.app = app self.ui = image_ui.Ui_ImageDialog() self.ui.setupUi(self) self.initUI() self.signals = ImageWindowSignals() self.imageFileName = '' self.imageFileNameOld = '' self.imageStack = None self.raStack = 0 self.decStack = 0 self.angleStack = 0 self.scaleStack = 0 self.numberStack = 0 self.folder = '' self.deviceStat = { 'expose': False, 'exposeN': False, 'solve': False, } self.colorMaps = { 'Grey': 'gray', 'Cool': 'plasma', 'Rainbow': 'rainbow', 'Spectral': 'nipy_spectral', } self.stretchValues = { 'Low X': (98, 99.999), 'Low': (90, 99.995), 'Mid': (50, 99.99), 'High': (20, 99.98), 'Super': (10, 99.9), 'Super X': (1, 99.8), } self.zoomLevel = { ' 1x Zoom': 1, ' 2x Zoom': 2, ' 4x Zoom': 4, ' 8x Zoom': 8, '16x Zoom': 16, } self.imageMat = self.embedMatplot(self.ui.image) self.imageMat.parentWidget().setStyleSheet(self.BACK_BG) # cyclic updates self.app.update1s.connect(self.updateWindowsStats) self.initConfig() self.showWindow() def initConfig(self): """ initConfig read the key out of the configuration dict and stores it to the gui elements. if some initialisations have to be proceeded with the loaded persistent data, they will be launched as well in this method. if not entry is already in the config dict, it will be created first. default values will be set in case of missing parameters. screen size will be set as well as the window position. if the window position is out of the current screen size (because of copy configs or just because the screen resolution was changed) the window will be repositioned so that it will be visible. :return: True for test purpose """ if 'imageW' not in self.app.config: self.app.config['imageW'] = {} config = self.app.config['imageW'] x = config.get('winPosX', 100) y = config.get('winPosY', 100) if x > self.screenSizeX: x = 0 if y > self.screenSizeY: y = 0 self.move(x, y) height = config.get('height', 600) width = config.get('width', 800) self.resize(width, height) self.setupDropDownGui() self.ui.color.setCurrentIndex(config.get('color', 0)) self.ui.zoom.setCurrentIndex(config.get('zoom', 0)) self.ui.stretch.setCurrentIndex(config.get('stretch', 0)) self.imageFileName = config.get('imageFileName', '') self.folder = self.app.mwGlob.get('imageDir', '') self.ui.checkUseWCS.setChecked(config.get('checkUseWCS', False)) self.ui.checkStackImages.setChecked( config.get('checkStackImages', False)) self.ui.checkShowCrosshair.setChecked( config.get('checkShowCrosshair', False)) self.ui.checkShowGrid.setChecked(config.get('checkShowGrid', True)) self.ui.checkAutoSolve.setChecked(config.get('checkAutoSolve', False)) self.ui.checkEmbedData.setChecked(config.get('checkEmbedData', False)) return True def storeConfig(self): """ storeConfig writes the keys to the configuration dict and stores. if some saving has to be proceeded to persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'imageW' not in self.app.config: self.app.config['imageW'] = {} config = self.app.config['imageW'] config['winPosX'] = self.pos().x() config['winPosY'] = self.pos().y() config['height'] = self.height() config['width'] = self.width() config['color'] = self.ui.color.currentIndex() config['zoom'] = self.ui.zoom.currentIndex() config['stretch'] = self.ui.stretch.currentIndex() config['imageFileName'] = self.imageFileName config['checkUseWCS'] = self.ui.checkUseWCS.isChecked() config['checkStackImages'] = self.ui.checkStackImages.isChecked() config['checkShowCrosshair'] = self.ui.checkShowCrosshair.isChecked() config['checkShowGrid'] = self.ui.checkShowGrid.isChecked() config['checkAutoSolve'] = self.ui.checkAutoSolve.isChecked() config['checkEmbedData'] = self.ui.checkEmbedData.isChecked() return True def showWindow(self): """ showWindow prepares all data for showing the window, plots the image and show it. afterwards all necessary signal / slot connections will be established. :return: true for test purpose """ self.showCurrent() self.show() # gui signals self.ui.load.clicked.connect(self.selectImage) self.ui.color.currentIndexChanged.connect(self.showCurrent) self.ui.stretch.currentIndexChanged.connect(self.showCurrent) self.ui.zoom.currentIndexChanged.connect(self.showCurrent) self.ui.checkUseWCS.clicked.connect(self.showCurrent) self.ui.checkShowGrid.clicked.connect(self.showCurrent) self.ui.checkShowCrosshair.clicked.connect(self.showCurrent) self.ui.solve.clicked.connect(self.solveCurrent) self.ui.expose.clicked.connect(self.exposeImage) self.ui.exposeN.clicked.connect(self.exposeImageN) self.ui.abortImage.clicked.connect(self.abortImage) self.ui.abortSolve.clicked.connect(self.abortSolve) self.signals.showCurrent.connect(self.showCurrent) self.signals.showImage.connect(self.showImage) self.signals.solveImage.connect(self.solveImage) return True def closeEvent(self, closeEvent): """ closeEvent overlays the window close event of qt. first it stores all persistent data for the windows and its functions, than removes al signal / slot connections removes the matplotlib embedding and finally calls the parent calls for handling the framework close event. :param closeEvent: :return: True for test purpose """ self.storeConfig() # gui signals self.ui.load.clicked.disconnect(self.selectImage) self.ui.color.currentIndexChanged.disconnect(self.showCurrent) self.ui.stretch.currentIndexChanged.disconnect(self.showCurrent) self.ui.zoom.currentIndexChanged.disconnect(self.showCurrent) self.ui.checkUseWCS.clicked.disconnect(self.showCurrent) self.ui.checkShowGrid.clicked.disconnect(self.showCurrent) self.ui.checkShowCrosshair.clicked.disconnect(self.showCurrent) self.ui.solve.clicked.disconnect(self.solveCurrent) self.ui.expose.clicked.disconnect(self.exposeImage) self.ui.exposeN.clicked.disconnect(self.exposeImageN) self.ui.abortImage.clicked.disconnect(self.abortImage) self.ui.abortSolve.clicked.disconnect(self.abortSolve) self.signals.showCurrent.disconnect(self.showCurrent) self.signals.showImage.disconnect(self.showImage) self.signals.solveImage.disconnect(self.solveImage) plt.close(self.imageMat.figure) super().closeEvent(closeEvent) def setupDropDownGui(self): """ setupDropDownGui handles the population of list for image handling. :return: success for test """ self.ui.color.clear() self.ui.color.setView(PyQt5.QtWidgets.QListView()) for text in self.colorMaps: self.ui.color.addItem(text) self.ui.zoom.clear() self.ui.zoom.setView(PyQt5.QtWidgets.QListView()) for text in self.zoomLevel: self.ui.zoom.addItem(text) self.ui.stretch.clear() self.ui.stretch.setView(PyQt5.QtWidgets.QListView()) for text in self.stretchValues: self.ui.stretch.addItem(text) return True def updateWindowsStats(self): """ updateWindowsStats changes dynamically the enable and disable of user gui elements depending of the actual state of processing :return: true for test purpose """ if self.deviceStat['expose']: self.ui.exposeN.setEnabled(False) self.ui.load.setEnabled(False) self.ui.abortImage.setEnabled(True) elif self.deviceStat['exposeN']: self.ui.expose.setEnabled(False) self.ui.load.setEnabled(False) self.ui.abortImage.setEnabled(True) else: self.ui.solve.setEnabled(True) self.ui.expose.setEnabled(True) self.ui.exposeN.setEnabled(True) self.ui.load.setEnabled(True) self.ui.abortImage.setEnabled(False) if self.deviceStat['solve']: self.ui.abortSolve.setEnabled(True) else: self.ui.abortSolve.setEnabled(False) if not self.app.mainW.deviceStat['camera']: self.ui.expose.setEnabled(False) self.ui.exposeN.setEnabled(False) if not self.app.mainW.deviceStat['astrometry']: self.ui.solve.setEnabled(False) if self.deviceStat['expose']: self.changeStyleDynamic(self.ui.expose, 'running', 'true') elif self.deviceStat['exposeN']: self.changeStyleDynamic(self.ui.exposeN, 'running', 'true') else: self.changeStyleDynamic(self.ui.expose, 'running', 'false') self.changeStyleDynamic(self.ui.exposeN, 'running', 'false') if self.deviceStat['solve']: self.changeStyleDynamic(self.ui.solve, 'running', 'true') else: self.changeStyleDynamic(self.ui.solve, 'running', 'false') return True def selectImage(self): """ selectImage does a dialog to choose a FITS file for viewing. The file will not be loaded, just the full filepath will be stored. if succeeding, the signal for displaying the image will be emitted. :return: success """ loadFilePath, name, ext = self.openFile( self, 'Select image file', self.folder, 'FITS files (*.fit*)', enableDir=True, ) if not name: return False self.ui.imageFileName.setText(name) self.imageFileName = loadFilePath self.app.message.emit(f'Image [{name}] selected', 0) self.ui.checkUseWCS.setChecked(False) self.folder = os.path.dirname(loadFilePath) self.signals.showImage.emit(self.imageFileName) return True def writeHeaderToGUI(self, header=None): """ writeHeaderToGUI tries to read relevant values from FITS header and possible replace values and write them to the imageW gui :param header: header of fits file :return: hasCelestial, hasDistortion """ name = header.get('OBJECT', '').upper() self.ui.object.setText(f'{name}') ra = Angle(degrees=header.get('RA', 0)) dec = Angle(degrees=header.get('DEC', 0)) # ra will be in hours self.ui.ra.setText(f'{ra.hstr(warn=False)}') self.ui.dec.setText(f'{dec.dstr()}') scale = header.get('SCALE', 0) rotation = header.get('ANGLE', 0) self.ui.scale.setText(f'{scale:5.3f}') self.ui.rotation.setText(f'{rotation:6.3f}') ccdTemp = header.get('CCD-TEMP', 0) self.ui.ccdTemp.setText(f'{ccdTemp:4.1f}') expTime1 = header.get('EXPOSURE', 0) expTime2 = header.get('EXPTIME', 0) expTime = max(expTime1, expTime2) self.ui.expTime.setText(f'{expTime:5.1f}') filterCCD = header.get('FILTER', 0) self.ui.filter.setText(f'{filterCCD}') binX = header.get('XBINNING', 0) binY = header.get('YBINNING', 0) self.ui.binX.setText(f'{binX:1.0f}') self.ui.binY.setText(f'{binY:1.0f}') sqm = max( header.get('SQM', 0), header.get('SKY-QLTY', 0), header.get('MPSAS', 0), ) self.ui.sqm.setText(f'{sqm:5.2f}') flipped = bool(header.get('FLIPPED', False)) self.ui.isFlipped.setEnabled(flipped) # check if distortion is in header if 'CTYPE1' in header: wcsObject = wcs.WCS(header, relax=True) hasCelestial = wcsObject.has_celestial hasDistortion = wcsObject.has_distortion else: wcsObject = None hasCelestial = False hasDistortion = False self.ui.hasDistortion.setEnabled(hasDistortion) self.ui.hasWCS.setEnabled(hasCelestial) return hasDistortion, wcsObject def zoomImage(self, image=None, wcsObject=None): """ zoomImage cutouts a portion of the original image to zoom in the image itself. it returns a copy of the image with an updated wcs content. we have to be careful about the use of Cutout2D, because they are mixing x and y coordinates. so position is in (x, y), but size ind in (y, x) :param image: :param wcsObject: :return: """ if image is None: return None sizeY, sizeX = image.shape factor = self.zoomLevel[self.ui.zoom.currentText()] position = (int(sizeX / 2), int(sizeY / 2)) size = (int(sizeY / factor), int(sizeX / factor)) cutout = Cutout2D( image, position=position, size=size, wcs=wcsObject, copy=True, ) return cutout.data def stretchImage(self, image=None): """ stretchImage take the actual image and calculated norm based on the min, max derived from interval which is calculated with AsymmetricPercentileInterval. :param image: image :return: norm for plot """ if image is None: return None values = self.stretchValues[self.ui.stretch.currentText()] interval = AsymmetricPercentileInterval(*values) vmin, vmax = interval.get_limits(image) # cutout the noise delta = vmax - vmin vmin = min(vmin + delta * 0.01, vmax) norm = ImageNormalize( image, vmin=vmin, vmax=vmax, stretch=SqrtStretch(), ) return norm, vmin, vmax def colorImage(self): """ colorImage take the index from gui and generates the colormap for image show command from matplotlib :return: color map """ colorMap = self.colorMaps[self.ui.color.currentText()] return colorMap def setupDistorted(self, figure=None, wcsObject=None): """ setupDistorted tries to setup all necessary context for displaying the image with wcs distorted coordinates. still plenty of work to be done, because very often the labels are not shown :param figure: :param wcsObject: :return: axes object to plot onto """ if figure is None: return False if wcsObject is None: return False figure.clf() figure.subplots_adjust(left=0.1, right=0.9, bottom=0.1, top=0.9) axe = figure.add_subplot(1, 1, 1, projection=wcsObject, facecolor=None) axe.coords.frame.set_color(self.M_BLUE) axe0 = axe.coords[0] axe1 = axe.coords[1] if self.ui.checkShowGrid.isChecked(): axe0.grid(True, color=self.M_BLUE, ls='solid', alpha=0.5) axe1.grid(True, color=self.M_BLUE, ls='solid', alpha=0.5) axe0.tick_params(colors=self.M_BLUE, labelsize=12) axe1.tick_params(colors=self.M_BLUE, labelsize=12) axe0.set_axislabel('Right Ascension', color=self.M_BLUE, fontsize=12, fontweight='bold') axe1.set_axislabel('Declination', color=self.M_BLUE, fontsize=12, fontweight='bold') axe0.set_ticks(number=10) axe1.set_ticks(number=10) axe0.set_ticks_position('lr') axe0.set_ticklabel_position('lr') axe0.set_axislabel_position('lr') axe1.set_ticks_position('tb') axe1.set_ticklabel_position('tb') axe1.set_axislabel_position('tb') return figure, axe def setupNormal(self, figure=None, header=None): """ setupNormal build the image widget to show it with pixels as axes. the center of the image will have coordinates 0,0. :param figure: :param header: :return: axes object to plot onto """ if figure is None: return False if header is None: return False figure.clf() # figure.subplots_adjust(left=0.1, right=0.95, bottom=0.1, top=0.95) axe = figure.add_subplot(1, 1, 1, facecolor=None) axe.tick_params(axis='x', which='major', colors=self.M_BLUE, labelsize=12) axe.tick_params(axis='y', which='major', colors=self.M_BLUE, labelsize=12) factor = self.zoomLevel[self.ui.zoom.currentText()] sizeX = header.get('NAXIS1', 1) / factor sizeY = header.get('NAXIS2', 1) / factor midX = int(sizeX / 2) midY = int(sizeY / 2) number = 10 valueX, _ = np.linspace(-midX, midX, num=number, retstep=True) textX = list((str(int(x)) for x in valueX)) ticksX = list((x + midX for x in valueX)) axe.set_xticklabels(textX) axe.set_xticks(ticksX) valueY, _ = np.linspace(-midY, midY, num=number, retstep=True) textY = list((str(int(x)) for x in valueY)) ticksY = list((x + midY for x in valueY)) axe.set_yticklabels(textY) axe.set_yticks(ticksY) if self.ui.checkShowCrosshair.isChecked(): axe.axvline(midX, color=self.M_RED) axe.axhline(midY, color=self.M_RED) if self.ui.checkShowGrid.isChecked(): axe.grid(True, color=self.M_BLUE, ls='solid', alpha=0.5) axe.set_xlabel(xlabel='Pixel', color=self.M_BLUE, fontsize=12, fontweight='bold') axe.set_ylabel(ylabel='Pixel', color=self.M_BLUE, fontsize=12, fontweight='bold') return figure, axe def stackImages(self, imageData=None, header=None): """ :param imageData: :param header: is only used, when stacking with alignment :return: """ if np.shape(imageData) != np.shape(self.imageStack): self.imageStack = None # if first image, we just store the data as reference frame if self.imageStack is None: self.imageStack = imageData self.numberStack = 1 return imageData # now we are going to stack the results self.numberStack += 1 self.imageStack = np.add(self.imageStack, imageData) return self.imageStack / self.numberStack def showImage(self, imagePath=''): """ showImage shows the fits image. therefore it calculates color map, stretch, zoom and other topics. :param imagePath: :return: success """ if not imagePath: return False if not os.path.isfile(imagePath): return False self.imageFileName = imagePath full, short, ext = self.extractNames([imagePath]) self.ui.imageFileName.setText(short) with fits.open(imagePath, mode='update') as fitsHandle: imageData = fitsHandle[0].data header = fitsHandle[0].header # check the bayer options, i normally us only RGGB pattern # todo: if it's an exposure directly, I get a bayer mosaic ?? if 'BAYERPAT' in header and len(imageData.shape) > 2: imageData = cv2.cvtColor(imageData, cv2.COLOR_BAYER_BG2GRAY) # correct faulty headers, because some imaging programs did not # interpret the Keywords in the right manner (SGPro) if header.get('CTYPE1', '').endswith('DEF'): header['CTYPE1'] = header['CTYPE1'].replace('DEF', 'TAN') if header.get('CTYPE2', '').endswith('DEF'): header['CTYPE2'] = header['CTYPE2'].replace('DEF', 'TAN') if self.ui.checkStackImages.isChecked(): imageData = self.stackImages(imageData=imageData, header=header) self.ui.numberStacks.setText(f'mean of: {self.numberStack:4.0f}') else: self.imageStack = None self.ui.numberStacks.setText('single') # check the data content and capabilities hasDistortion, wcsObject = self.writeHeaderToGUI(header=header) # process the image for viewing: stretching imageData = self.zoomImage(image=imageData, wcsObject=wcsObject) # normalization norm, iMin, iMax = self.stretchImage(image=imageData) # we process a colormap if we have a greyscale image colorMap = self.colorImage() # check the data content and capabilities useWCS = self.ui.checkUseWCS.isChecked() # check which type of presentation we would like to have if hasDistortion and useWCS: fig, axe = self.setupDistorted(figure=self.imageMat.figure, wcsObject=wcsObject) else: fig, axe = self.setupNormal(figure=self.imageMat.figure, header=header) # finally show it axe.imshow(imageData, norm=norm, cmap=colorMap, origin='lower') axe.figure.canvas.draw() return True def showCurrent(self): """ :return: true for test purpose """ self.showImage(self.imageFileName) return True def exposeRaw(self): """ exposeImage gathers all necessary parameters and starts exposing :return: True for test purpose """ expTime = self.app.mainW.ui.expTime.value() binning = self.app.mainW.ui.binning.value() subFrame = self.app.mainW.ui.subFrame.value() fastReadout = self.app.mainW.ui.checkFastDownload.isChecked() time = self.app.mount.obsSite.timeJD.utc_strftime('%Y-%m-%d-%H-%M-%S') fileName = time + '-exposure.fits' imagePath = self.app.mwGlob['imageDir'] + '/' + fileName self.imageFileNameOld = self.imageFileName self.app.camera.expose( imagePath=imagePath, expTime=expTime, binning=binning, subFrame=subFrame, fastReadout=fastReadout, ) self.app.message.emit(f'Exposing: [{os.path.basename(imagePath)}]', 0) text = f'Duration: {expTime:3.0f}s Bin: {binning:1.0f} Sub: {subFrame:3.0f}%' self.app.message.emit(f' {text}', 0) return True def exposeImageDone(self, imagePath=''): """ exposeImageDone is the partner method to exposeImage. it resets the gui elements to it's default state and disconnects the signal for the callback. finally when all elements are done it emits the showImage signal. :param imagePath: :return: True for test purpose """ self.deviceStat['expose'] = False self.app.camera.signals.saved.disconnect(self.exposeImageDone) self.app.message.emit(f'Exposed: [{os.path.basename(imagePath)}]', 0) if self.ui.checkAutoSolve.isChecked(): self.signals.solveImage.emit(imagePath) else: self.signals.showImage.emit(imagePath) return True def exposeImage(self): """ exposeImage disables all gui elements which could interfere when having a running exposure. it connects the callback for downloaded image and presets all necessary parameters for imaging :return: success """ self.imageStack = None self.deviceStat['expose'] = True self.ui.checkStackImages.setChecked(False) self.app.camera.signals.saved.connect(self.exposeImageDone) self.exposeRaw() return True def exposeImageNDone(self, imagePath=''): """ exposeImageNDone is the partner method to exposeImage. it resets the gui elements to it's default state and disconnects the signal for the callback. finally when all elements are done it emits the showImage signal. :param imagePath: :return: True for test purpose """ self.app.message.emit(f'Exposed: [{os.path.basename(imagePath)}]', 0) if self.ui.checkAutoSolve.isChecked(): self.signals.solveImage.emit(imagePath) else: self.signals.showImage.emit(imagePath) self.exposeRaw() return True def exposeImageN(self): """ exposeImageN disables all gui elements which could interfere when having a running exposure. it connects the callback for downloaded image and presets all necessary parameters for imaging :return: success """ self.imageStack = None self.deviceStat['exposeN'] = True self.app.camera.signals.saved.connect(self.exposeImageNDone) self.exposeRaw() return True def abortImage(self): """ abortImage stops the exposure and resets the gui and the callback signals to default values :return: True for test purpose """ self.app.camera.abort() # for disconnection we have to split which slots were connected to disable the # right ones if self.deviceStat['expose']: self.app.camera.signals.saved.disconnect(self.exposeImageDone) if self.deviceStat['exposeN']: self.app.camera.signals.saved.disconnect(self.exposeImageNDone) # last image file was nor stored, so getting last valid it back self.imageFileName = self.imageFileNameOld self.deviceStat['expose'] = False self.deviceStat['exposeN'] = False self.app.message.emit('Exposing aborted', 2) return True def solveDone(self, result=None): """ solveDone is the partner method for solveImage. it enables the gui elements back removes the signal / slot connection for receiving solving results, checks the solving result itself and emits messages about the result. if solving succeeded, solveDone will redraw the image in the image window. :param result: result (named tuple) :return: success """ self.deviceStat['solve'] = False self.app.astrometry.signals.done.disconnect(self.solveDone) if not result: self.app.message.emit('Solving error, result missing', 2) return False if result['success']: text = f'Solved : ' text += f'Ra: {transform.convertToHMS(result["raJ2000S"])} ' text += f'({result["raJ2000S"].hours:4.3f}), ' text += f'Dec: {transform.convertToDMS(result["decJ2000S"])} ' text += f'({result["decJ2000S"].degrees:4.3f}), ' self.app.message.emit(text, 0) text = f' ' text += f'Angle: {result["angleS"]:3.0f}, ' text += f'Scale: {result["scaleS"]:4.3f}, ' text += f'Error: {result["errorRMS_S"]:4.1f}' self.app.message.emit(text, 0) else: text = f'Solving error: {result.get("message")}' self.app.message.emit(text, 2) return False isStack = self.ui.checkStackImages.isChecked() isAutoSolve = self.ui.checkAutoSolve.isChecked() if not isStack or isAutoSolve: self.signals.showImage.emit(result['solvedPath']) return True def solveImage(self, imagePath=''): """ solveImage calls astrometry for solving th actual image in a threading manner. as result the gui will be active while the solving process takes part a background task. if the check update fits is selected the solution will be stored in the image header as well. solveImage will disable gui elements which might interfere when doing solve in background and sets the signal / slot connection for receiving the signal for finishing. this is linked to a second method solveDone, which is basically the partner method for handling this async behaviour of the gui. finally it emit a message about the start of solving :param imagePath: :return: """ if not imagePath: return False if not os.path.isfile(imagePath): return False updateFits = self.ui.checkEmbedData.isChecked() solveTimeout = self.app.mainW.ui.solveTimeout.value() searchRadius = self.app.mainW.ui.searchRadius.value() self.app.astrometry.solveThreading( fitsPath=imagePath, radius=searchRadius, timeout=solveTimeout, updateFits=updateFits, ) self.deviceStat['solve'] = True self.app.astrometry.signals.done.connect(self.solveDone) self.app.message.emit(f'Solving: [{os.path.basename(imagePath)}]', 0) return True def solveCurrent(self): """ :return: true for test purpose """ self.signals.solveImage.emit(self.imageFileName) return True def abortSolve(self): """ abortSolve stops the exposure and resets the gui and the callback signals to default values :return: success """ suc = self.app.astrometry.abort() return suc
class PegasusUPB: __all__ = [ 'PegasusUPB', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, app): self.app = app self.threadPool = app.threadPool self.signals = PegasusUPBSignals() self.data = {} self.framework = None self.run = { 'indi': PegasusUPBIndi(self.app, self.signals, self.data), } self.name = '' self.host = ('localhost', 7624) self.isGeometry = False # signalling from subclasses to main self.run['indi'].client.signals.serverConnected.connect( self.signals.serverConnected) self.run['indi'].client.signals.serverDisconnected.connect( self.signals.serverDisconnected) self.run['indi'].client.signals.deviceConnected.connect( self.signals.deviceConnected) self.run['indi'].client.signals.deviceDisconnected.connect( self.signals.deviceDisconnected) @property def host(self): return self._host @host.setter def host(self, value): self._host = value if self.framework in self.run.keys(): self.run[self.framework].host = value @property def name(self): return self._name @name.setter def name(self, value): self._name = value if self.framework in self.run.keys(): self.run[self.framework].name = value def startCommunication(self): """ """ if self.framework in self.run.keys(): suc = self.run[self.framework].startCommunication() return suc else: return False def stopCommunication(self): """ """ if self.framework in self.run.keys(): suc = self.run[self.framework].stopCommunication() return suc else: return False def togglePowerPort(self, port=None): """ togglePowerPort :param port: :return: true fot test purpose """ if self.framework in self.run.keys(): suc = self.run[self.framework].togglePowerPort(port=port) return suc else: return False def togglePowerPortBoot(self, port=None): """ togglePowerPortBoot :param port: :return: true fot test purpose """ if self.framework in self.run.keys(): suc = self.run[self.framework].togglePowerPortBoot(port=port) return suc else: return False def toggleHubUSB(self): """ toggleHubUSB :return: true fot test purpose """ if self.framework in self.run.keys(): suc = self.run[self.framework].toggleHubUSB() return suc else: return False def togglePortUSB(self, port=None): """ togglePortUSB :param port: :return: true fot test purpose """ if self.framework in self.run.keys(): suc = self.run[self.framework].togglePortUSB(port=port) return suc else: return False def toggleAutoDew(self): """ toggleAutoDewPort :return: true fot test purpose """ if self.framework in self.run.keys(): suc = self.run[self.framework].toggleAutoDew() return suc else: return False return suc def toggleAutoDewPort(self, port=None): """ toggleAutoDewPort :param port: :return: true fot test purpose """ if self.framework in self.run.keys(): suc = self.run[self.framework].toggleAutoDewPort(port=port) return suc else: return False return suc def sendDew(self, port='', value=None): """ :param port: :param value: :return: success """ if self.framework in self.run.keys(): suc = self.run[self.framework].sendDew(port=port, value=value) return suc else: return False def sendAdjustableOutput(self, value=None): """ :param value: :return: success """ if self.framework in self.run.keys(): suc = self.run[self.framework].sendAdjustableOutput(value=value) return suc else: return False
class SensorWeatherIndi(IndiClass): """ the class SensorWeatherIndi inherits all information and handling of the SensorWeather device >>> SensorWeatherIndi(host=None, >>> name='' >>> ) """ __all__ = [ 'SensorWeatherIndi', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # update rate to 1 seconds for setting indi server UPDATE_RATE = 1 def __init__(self, app=None, signals=None, data=None): super().__init__(app=app, data=data) self.signals = signals self.data = data def setUpdateConfig(self, deviceName): """ setUpdateRate corrects the update rate of weather devices to get an defined setting regardless, what is setup in server side. :param deviceName: :return: success """ if deviceName != self.name: return False if self.device is None: return False update = self.device.getNumber('WEATHER_UPDATE') if 'PERIOD' not in update: return False if update.get('PERIOD', 0) == self.UPDATE_RATE: return True update['PERIOD'] = self.UPDATE_RATE suc = self.client.sendNewNumber(deviceName=deviceName, propertyName='WEATHER_UPDATE', elements=update) return suc def updateNumber(self, deviceName, propertyName): """ updateNumber is called whenever a new number is received in client. it runs through the device list and writes the number data to the according locations. for global weather data as there is no dew point value available, it calculates it and stores it as value as well. if no dew point is available in data, it will calculate this value from temperature and humidity. :param deviceName: :param propertyName: :return: """ if self.device is None: return False if deviceName != self.name: return False for element, value in self.device.getNumber(propertyName).items(): # consolidate to WEATHER_PRESSURE if element == 'WEATHER_BAROMETER': element = 'WEATHER_PRESSURE' key = propertyName + '.' + element self.data[key] = value # print(self.name, key, value) return True
class Dome: __all__ = [ 'Dome', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__(self, app): self.app = app self.threadPool = app.threadPool self.signals = DomeSignals() self.data = {} self.framework = None self.run = { 'indi': DomeIndi(self.app, self.signals, self.data), 'alpaca': DomeAlpaca(self.app, self.signals, self.data), } self.name = '' self.host = ('localhost', 7624) self.isGeometry = False # signalling from subclasses to main alpacaSignals = self.run['alpaca'].client.signals alpacaSignals.serverConnected.connect(self.signals.serverConnected) alpacaSignals.serverDisconnected.connect( self.signals.serverDisconnected) alpacaSignals.deviceConnected.connect(self.signals.deviceConnected) alpacaSignals.deviceDisconnected.connect( self.signals.deviceDisconnected) indiSignals = self.run['indi'].client.signals indiSignals.serverConnected.connect(self.signals.serverConnected) indiSignals.serverDisconnected.connect(self.signals.serverDisconnected) indiSignals.deviceConnected.connect(self.signals.deviceConnected) indiSignals.deviceDisconnected.connect(self.signals.deviceDisconnected) @property def host(self): return self._host @host.setter def host(self, value): self._host = value if self.framework in self.run.keys(): self.run[self.framework].host = value @property def name(self): return self._name @name.setter def name(self, value): self._name = value if self.framework in self.run.keys(): self.run[self.framework].name = value @property def settlingTime(self): if self.framework in self.run.keys(): return self.run[self.framework].settlingTime else: return None @settlingTime.setter def settlingTime(self, value): if self.framework in self.run.keys(): self.run[self.framework].settlingTime = value def startCommunication(self): """ """ if self.framework in self.run.keys(): suc = self.run[self.framework].startCommunication() return suc else: return False def stopCommunication(self): """ """ if self.framework in self.run.keys(): self.signals.message.emit('') suc = self.run[self.framework].stopCommunication() return suc else: return False def slewDome(self, altitude=0, azimuth=0): """ :param altitude: :param azimuth: :return: success """ if not self.data: return False if self.isGeometry: ha = self.app.mount.obsSite.haJNowTarget.radians dec = self.app.mount.obsSite.decJNowTarget.radians lat = self.app.mount.obsSite.location.latitude.radians pierside = self.app.mount.obsSite.piersideTarget alt, az = self.app.mount.geometry.calcTransformationMatrices( ha=ha, dec=dec, lat=lat, pierside=pierside) alt = alt.degrees az = az.degrees # todo: correct calculation that this is not necessary if alt is np.nan or az is np.nan: self.log.warning( f'alt:{altitude}, az:{azimuth}, pier:{pierside}') if alt is np.nan: alt = altitude if az is np.nan: az = azimuth else: alt = altitude az = azimuth geoStat = 'Geometry corrected' if self.isGeometry else 'Equal mount' delta = azimuth - az text = f'Slewing dome: {geoStat}, az: {az:3.1f} delta: {delta:3.1f}°' self.app.message.emit(text, 0) suc = self.run[self.framework].slewToAltAz(azimuth=az, altitude=alt) return suc
class Hipparcos(object): """ The class Data inherits all information and handling of hipparcos data and other attributes. this includes data about the alignment stars defined in generateAlignStars, their ra dec coordinates, proper motion, parallax and radial velocity and the calculation of data for display and slew commands >>> hip = Hipparcos( >>> app=app >>> ) """ __all__ = [ 'Hipparcos', 'calculateAlignStarsPositionsAltAz', 'getAlignStarRaDecFromIndex', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) def __init__( self, app=None, ): self.app = app self.lat = app.mount.obsSite.location.latitude.degrees self.name = list() self.alt = list() self.az = list() self.alignStars = generateAlignStars() self.calculateAlignStarPositionsAltAz() def calculateAlignStarPositionsAltAz(self): """ calculateAlignStarPositionsAltAz does calculate the star coordinates from give data out of generated star list. calculation routines are from astropy erfa. atco13 does the results based on proper motion, parallax and radial velocity and need J2000 coordinates. because of using the hipparcos catalogue, which is based on J1991, 25 epoch the pre calculation from J1991,25 to J2000 is done already when generating the alignstars file. there is no refraction data taken into account, because we need this only for display purpose and for this, the accuracy is more than sufficient. :return: lists for alt, az and name of star """ location = self.app.mount.obsSite.location if location is None: return False t = self.app.mount.obsSite.timeJD star = list(self.alignStars.values()) self.name = list(self.alignStars.keys()) aob, zob, hob, dob, rob, eo = erfa.atco13( [x[0] for x in star], [x[1] for x in star], [x[2] for x in star], [x[3] for x in star], [x[4] for x in star], [x[5] for x in star], t.ut1, 0.0, t.dut1, location.longitude.radians, location.latitude.radians, location.elevation.m, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) self.az = aob * 360 / 2 / np.pi self.alt = 90.0 - zob * 360 / 2 / np.pi return True def getAlignStarRaDecFromName(self, name): """ getAlignStarRaDecFromName does calculate the star coordinates from give data out of generated star list. calculation routines are from astropy erfa. atco13 does the results based on proper motion, parallax and radial velocity and need J2000 coordinates. because of using the hipparcos catalogue, which is based on J1991, 25 epoch the pre calculation from J1991,25 to J2000 is done already when generating the alignstars file. there is no refraction data taken into account, because we need this only for display purpose and for this, the accuracy is more than sufficient. the return values are in JNow epoch as the mount only handles this epoch ! :param name: name of star :return: values for ra, dec in hours / degrees in JNow epoch ! """ if name not in self.alignStars: return None, None t = self.app.mount.obsSite.ts.now() values = self.alignStars[name] ra, dec, eo = erfa.atci13( values[0], values[1], values[2], values[3], values[4], values[5], t.ut1, 0.0, ) ra = erfa.anp(ra - eo) * 24 / 2 / np.pi dec = dec * 360 / 2 / np.pi return ra, dec
class MeasureData(object): """ the class MeasureData inherits all information and handling of data management and storage >>> measure = MeasureData( >>> ) """ __all__ = ['MeasureData', 'startMeasurement', 'stopMeasurement', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # update rate to 1 seconds for setting indi server CYCLE_UPDATE_TASK = 1000 # maximum size of measurement task MAXSIZE = 24 * 60 * 60 def __init__(self, app): self.app = app self.mutexMeasure = PyQt5.QtCore.QMutex() # internal calculations self.shorteningStart = True self.raRef = None self.decRef = None self.data = {} self.devices = {} self.deviceStat = None # minimum set for driver package built in self.name = '' self.framework = None self.run = { 'built-in': self } # time for measurement self.timerTask = PyQt5.QtCore.QTimer() self.timerTask.setSingleShot(False) self.timerTask.timeout.connect(self.measureTask) def startCommunication(self): """ :return: True for test purpose """ dItems = self.deviceStat.items() self.devices = [key for key, value in dItems if self.deviceStat[key] is not None] self.setEmptyData() self.timerTask.start(self.CYCLE_UPDATE_TASK) return True def stopCommunication(self): """ :return: True for test purpose """ self.timerTask.stop() return True def setEmptyData(self): """ :return: True for test purpose """ self.data = { 'time': np.empty(shape=[0, 1], dtype='datetime64'), 'raJNow': np.empty(shape=[0, 1]), 'decJNow': np.empty(shape=[0, 1]), 'status': np.empty(shape=[0, 1]), } if 'sensorWeather' in self.devices: self.data['sensorWeatherTemp'] = np.empty(shape=[0, 1]) self.data['sensorWeatherHum'] = np.empty(shape=[0, 1]) self.data['sensorWeatherPress'] = np.empty(shape=[0, 1]) self.data['sensorWeatherDew'] = np.empty(shape=[0, 1]) if 'onlineWeather' in self.devices: self.data['onlineWeatherTemp'] = np.empty(shape=[0, 1]) self.data['onlineWeatherHum'] = np.empty(shape=[0, 1]) self.data['onlineWeatherPress'] = np.empty(shape=[0, 1]) self.data['onlineWeatherDew'] = np.empty(shape=[0, 1]) if 'directWeather' in self.devices: self.data['directWeatherTemp'] = np.empty(shape=[0, 1]) self.data['directWeatherHum'] = np.empty(shape=[0, 1]) self.data['directWeatherPress'] = np.empty(shape=[0, 1]) self.data['directWeatherDew'] = np.empty(shape=[0, 1]) if 'skymeter' in self.devices: self.data['skyTemp'] = np.empty(shape=[0, 1]) self.data['skySQR'] = np.empty(shape=[0, 1]) if 'filterwheel' in self.devices: self.data['filterNumber'] = np.empty(shape=[0, 1]) if 'focuser' in self.devices: self.data['focusPosition'] = np.empty(shape=[0, 1]) if 'power' in self.devices: self.data['powCurr1'] = np.empty(shape=[0, 1]) self.data['powCurr2'] = np.empty(shape=[0, 1]) self.data['powCurr3'] = np.empty(shape=[0, 1]) self.data['powCurr4'] = np.empty(shape=[0, 1]) self.data['powVolt'] = np.empty(shape=[0, 1]) self.data['powCurr'] = np.empty(shape=[0, 1]) self.data['powHum'] = np.empty(shape=[0, 1]) self.data['powTemp'] = np.empty(shape=[0, 1]) self.data['powDew'] = np.empty(shape=[0, 1]) return True def calculateReference(self): """ calculateReference run the states to get the calculation with references for RaDec deviations better stable. it takes into account, when the mount is tracking and when we calculate the offset (ref) to make the deviations balanced to zero :return: raJNow, decJNow """ dat = self.data obs = self.app.mount.obsSite raJNow = 0 decJNow = 0 if obs.raJNow is None: return raJNow, decJNow length = len(dat['status']) period = min(length, 10) hasMean = length > 0 and period > 0 if not hasMean: return raJNow, decJNow periodData = dat['status'][-period:] hasValidData = all(x is not None for x in periodData) if hasValidData: trackingIsStable = (periodData.mean() == 0) else: trackingIsStable = False if trackingIsStable: if self.raRef is None: self.raRef = obs.raJNow._degrees if self.decRef is None: self.decRef = obs.decJNow.degrees # we would like to have the difference in arcsec raJNow = (obs.raJNow._degrees - self.raRef) * 3600 decJNow = (obs.decJNow.degrees - self.decRef) * 3600 else: self.raRef = None self.decRef = None return raJNow, decJNow def checkStart(self, lenData): """ checkStart throws the first N measurements away, because they or not valid :param lenData: :return: True if splitting happens """ if self.shorteningStart and lenData > 2: self.shorteningStart = False for measure in self.data: self.data[measure] = np.delete(self.data[measure], range(0, 2)) def checkSize(self, lenData): """ checkSize keep tracking of memory usage of the measurement. if the measurement get s too much data, it split the history by half and only keeps the latest only for work. if as well throws the first N measurements away, because they or not valid :param lenData: :return: True if splitting happens """ if lenData < self.MAXSIZE: return False for measure in self.data: self.data[measure] = np.split(self.data[measure], 2)[1] return True def getDirectWeather(self): """ getDirectWeather checks if data is already collected and send 0 in case of missing data. :return: values """ temp = self.app.mount.setting.weatherTemperature if temp is None: temp = 0 press = self.app.mount.setting.weatherPressure if press is None: press = 0 dew = self.app.mount.setting.weatherDewPoint if dew is None: dew = 0 hum = self.app.mount.setting.weatherHumidity if hum is None: hum = 0 return temp, press, dew, hum def measureTask(self): """ measureTask runs all necessary pre processing and collecting task to assemble a large dict of lists, where all measurement data is stored. the intention later on would be to store and export this data. the time object is related to the time held in mount computer and is in utc timezone. data sources are: environment mount pointing position :return: success """ if not self.mutexMeasure.tryLock(): self.log.info('overrun in measure') return False lenData = len(self.data['time']) self.checkStart(lenData) self.checkSize(lenData) dat = self.data # gathering all the necessary data raJNow, decJNow = self.calculateReference() timeStamp = self.app.mount.obsSite.timeJD.utc_datetime().replace(tzinfo=None) dat['time'] = np.append(dat['time'], np.datetime64(timeStamp)) dat['raJNow'] = np.append(dat['raJNow'], raJNow) dat['decJNow'] = np.append(dat['decJNow'], decJNow) dat['status'] = np.append(dat['status'], self.app.mount.obsSite.status) if 'sensorWeather' in self.devices: sens = self.app.sensorWeather sensorWeatherTemp = sens.data.get('WEATHER_PARAMETERS.WEATHER_TEMPERATURE', 0) sensorWeatherPress = sens.data.get('WEATHER_PARAMETERS.WEATHER_PRESSURE', 0) sensorWeatherDew = sens.data.get('WEATHER_PARAMETERS.WEATHER_DEWPOINT', 0) sensorWeatherHum = sens.data.get('WEATHER_PARAMETERS.WEATHER_HUMIDITY', 0) dat['sensorWeatherTemp'] = np.append(dat['sensorWeatherTemp'], sensorWeatherTemp) dat['sensorWeatherHum'] = np.append(dat['sensorWeatherHum'], sensorWeatherHum) dat['sensorWeatherPress'] = np.append(dat['sensorWeatherPress'], sensorWeatherPress) dat['sensorWeatherDew'] = np.append(dat['sensorWeatherDew'], sensorWeatherDew) if 'onlineWeather' in self.devices: onlineWeatherTemp = self.app.onlineWeather.data.get('temperature', 0) onlineWeatherPress = self.app.onlineWeather.data.get('pressure', 0) onlineWeatherDew = self.app.onlineWeather.data.get('dewPoint', 0) onlineWeatherHum = self.app.onlineWeather.data.get('humidity', 0) dat['onlineWeatherTemp'] = np.append(dat['onlineWeatherTemp'], onlineWeatherTemp) dat['onlineWeatherHum'] = np.append(dat['onlineWeatherHum'], onlineWeatherHum) dat['onlineWeatherPress'] = np.append(dat['onlineWeatherPress'], onlineWeatherPress) dat['onlineWeatherDew'] = np.append(dat['onlineWeatherDew'], onlineWeatherDew) if 'directWeather' in self.devices: temp, press, dew, hum = self.getDirectWeather() dat['directWeatherTemp'] = np.append(dat['directWeatherTemp'], temp) dat['directWeatherHum'] = np.append(dat['directWeatherHum'], hum) dat['directWeatherPress'] = np.append(dat['directWeatherPress'], press) dat['directWeatherDew'] = np.append(dat['directWeatherDew'], dew) if 'skymeter' in self.devices: skySQR = self.app.skymeter.data.get('SKY_QUALITY.SKY_BRIGHTNESS', 0) skyTemp = self.app.skymeter.data.get('SKY_QUALITY.SKY_TEMPERATURE', 0) dat['skySQR'] = np.append(dat['skySQR'], skySQR) dat['skyTemp'] = np.append(dat['skyTemp'], skyTemp) if 'filterwheel' in self.devices: filterNo = self.app.filterwheel.data.get('FILTER_SLOT.FILTER_SLOT_VALUE', 0) dat['filterNumber'] = np.append(dat['filterNumber'], filterNo) if 'focuser' in self.devices: focus = self.app.focuser.data.get('ABS_FOCUS_POSITION.FOCUS_ABSOLUTE_POSITION', 0) dat['focusPosition'] = np.append(dat['focusPosition'], focus) if 'power' in self.devices: powCurr1 = self.app.power.data.get('POWER_CURRENT.POWER_CURRENT_1', 0) powCurr2 = self.app.power.data.get('POWER_CURRENT.POWER_CURRENT_2', 0) powCurr3 = self.app.power.data.get('POWER_CURRENT.POWER_CURRENT_3', 0) powCurr4 = self.app.power.data.get('POWER_CURRENT.POWER_CURRENT_4', 0) powVolt = self.app.power.data.get('POWER_SENSORS.SENSOR_VOLTAGE', 0) powCurr = self.app.power.data.get('POWER_SENSORS.SENSOR_CURRENT', 0) powTemp = self.app.power.data.get('WEATHER_PARAMETERS.WEATHER_TEMPERATURE', 0) powDew = self.app.power.data.get('WEATHER_PARAMETERS.WEATHER_DEWPOINT', 0) powHum = self.app.power.data.get('WEATHER_PARAMETERS.WEATHER_HUMIDITY', 0) dat['powCurr1'] = np.append(dat['powCurr1'], powCurr1) dat['powCurr2'] = np.append(dat['powCurr2'], powCurr2) dat['powCurr3'] = np.append(dat['powCurr3'], powCurr3) dat['powCurr4'] = np.append(dat['powCurr4'], powCurr4) dat['powCurr'] = np.append(dat['powCurr'], powCurr) dat['powVolt'] = np.append(dat['powVolt'], powVolt) dat['powTemp'] = np.append(dat['powTemp'], powTemp) dat['powDew'] = np.append(dat['powDew'], powDew) dat['powHum'] = np.append(dat['powHum'], powHum) self.mutexMeasure.unlock() return True
class AlpacaClass(object): """ the class AlpacaClass inherits all information and handling of alpaca devices this class will be only referenced from other classes and not directly used >>> a = AlpacaClass(app=None, data={}) """ logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # relaxed generic timing CYCLE_DEVICE = 3000 CYCLE_DATA = 3000 def __init__(self, app=None, data={}): super().__init__() self.app = app self.threadPool = app.threadPool self.client = AlpacaBase() self.data = data self.deviceConnected = False self.serverConnected = False self.cycleDevice = PyQt5.QtCore.QTimer() self.cycleDevice.setSingleShot(False) self.cycleDevice.timeout.connect(self.startPollStatus) self.cycleData = PyQt5.QtCore.QTimer() self.cycleData.setSingleShot(False) self.cycleData.timeout.connect(self.pollData) @property def host(self): return self.client.host @host.setter def host(self, value): self.client.host = value @property def name(self): return self.client.name @name.setter def name(self, value): self.client.name = value @property def apiVersion(self): return self.client.apiVersion @apiVersion.setter def apiVersion(self, value): self.client.apiVersion = value @property def protocol(self): return self.client.protocol @protocol.setter def protocol(self, value): self.client.protocol = value def getInitialConfig(self): """ :return: success of reconnecting to server """ self.client.connected(Connected=True) suc = self.client.connected() if not suc: return False if not self.serverConnected: self.serverConnected = True self.client.signals.serverConnected.emit() if not self.deviceConnected: self.deviceConnected = True self.client.signals.deviceConnected.emit(f'{self.name}') self.app.message.emit(f'Alpaca device found: [{self.name}]', 0) self.data['DRIVER_INFO.DRIVER_NAME'] = self.client.nameDevice() self.data['DRIVER_INFO.DRIVER_VERSION'] = self.client.driverVersion() self.data['DRIVER_INFO.DRIVER_EXEC'] = self.client.driverInfo() return True def startTimer(self): """ startTimer enables the cyclic timer for polling information :return: true for test purpose """ self.cycleData.start(self.CYCLE_DATA) self.cycleDevice.start(self.CYCLE_DEVICE) return True def stopTimer(self): """ stopTimer disables the cyclic timer for polling information :return: true for test purpose """ self.cycleData.stop() self.cycleDevice.stop() return True def dataEntry(self, value, element, elementInv=None): """ :param value: :param element: :param elementInv: :return: reset entry """ resetValue = value is None and element in self.data if resetValue: del self.data[element] else: self.data[element] = value if elementInv is None: return resetValue resetValue = value is None and elementInv in self.data if resetValue: del self.data[elementInv] else: self.data[elementInv] = value return resetValue def pollStatus(self): """ pollStatus is the thread method to be called for collecting data :return: success """ suc = self.client.connected() if self.deviceConnected and not suc: self.deviceConnected = False self.client.signals.deviceDisconnected.emit(f'{self.name}') self.app.message.emit(f'Alpaca device remove:[{self.name}]', 0) elif not self.deviceConnected and suc: self.deviceConnected = True self.client.signals.deviceConnected.emit(f'{self.name}') self.app.message.emit(f'Alpaca device found: [{self.name}]', 0) else: pass return suc def emitData(self): pass def workerPollData(self): pass def pollData(self): """ :return: success """ if not self.deviceConnected: return False worker = Worker(self.workerPollData) worker.signals.result.connect(self.emitData) self.threadPool.start(worker) return True def startPollStatus(self): """ startPollStatus starts a thread every 1 second for polling. :return: success """ worker = Worker(self.pollStatus) self.threadPool.start(worker) return True def startCommunication(self): """ startCommunication starts cycling of the polling. :return: True for connecting to server """ worker = Worker(self.getInitialConfig) worker.signals.finished.connect(self.startTimer) self.threadPool.start(worker) return True def stopCommunication(self): """ stopCommunication stops cycling of the server. :return: true for test purpose """ self.stopTimer() self.deviceConnected = False self.serverConnected = False self.client.signals.deviceDisconnected.emit(f'{self.name}') self.client.signals.serverDisconnected.emit({f'{self.name}': 0}) self.app.message.emit(f'Alpaca device remove:[{self.name}]', 0) worker = Worker(self.client.connected, Connected=False) self.threadPool.start(worker) return True
class SatelliteWindow(widget.MWidget): """ the satellite window class handles """ __all__ = ['SatelliteWindow', 'receiveSatelliteAndShow', 'updatePositions', ] logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) # length of forecast time in hours FORECAST_TIME = 3 # earth radius EARTH_RADIUS = 6378.0 def __init__(self, app, threadPool): super().__init__() self.app = app self.threadPool = threadPool self.ui = satellite_ui.Ui_SatelliteDialog() self.ui.setupUi(self) self.initUI() self.signals = SatelliteWindowSignals() self.satellite = None self.plotSatPosSphere1 = None self.plotSatPosSphere2 = None self.plotSatPosHorizon = None self.plotSatPosEarth = None self.satSphereMat1 = self.embedMatplot(self.ui.satSphere1) self.satSphereMat1.parentWidget().setStyleSheet(self.BACK_BG) self.satSphereMat2 = self.embedMatplot(self.ui.satSphere2) self.satSphereMat2.parentWidget().setStyleSheet(self.BACK_BG) self.satHorizonMat = self.embedMatplot(self.ui.satHorizon) self.satHorizonMat.parentWidget().setStyleSheet(self.BACK_BG) self.satEarthMat = self.embedMatplot(self.ui.satEarth) self.satEarthMat.parentWidget().setStyleSheet(self.BACK_BG) self.signals.show.connect(self.receiveSatelliteAndShow) self.signals.update.connect(self.updatePositions) # as we cannot access data from Qt resource system, we have to convert it to # ByteIO first stream = PyQt5.QtCore.QFile(':/data/worldmap.dat') stream.open(PyQt5.QtCore.QFile.ReadOnly) pickleData = stream.readAll() stream.close() # loading the world image from nasa as PNG as matplotlib only loads png. self.world = pickle.load(BytesIO(pickleData)) self.initConfig() self.showWindow() def initConfig(self): """ initConfig read the key out of the configuration dict and stores it to the gui elements. if some initialisations have to be proceeded with the loaded persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'satelliteW' not in self.app.config: self.app.config['satelliteW'] = {} config = self.app.config['satelliteW'] x = config.get('winPosX', 100) y = config.get('winPosY', 100) if x > self.screenSizeX: x = 0 if y > self.screenSizeY: y = 0 self.move(x, y) height = config.get('height', 600) width = config.get('width', 800) self.resize(width, height) return True def storeConfig(self): """ storeConfig writes the keys to the configuration dict and stores. if some saving has to be proceeded to persistent data, they will be launched as well in this method. :return: True for test purpose """ if 'satelliteW' not in self.app.config: self.app.config['satelliteW'] = {} config = self.app.config['satelliteW'] config['winPosX'] = self.pos().x() config['winPosY'] = self.pos().y() config['height'] = self.height() config['width'] = self.width() return True def closeEvent(self, closeEvent): """ closeEvent is overloaded to be able to store the data before the windows is close and all it's data is garbage collected :param closeEvent: :return: """ self.storeConfig() # gui signals super().closeEvent(closeEvent) def showWindow(self): """ showWindow starts constructing the main window for satellite view and shows the window content :return: True for test purpose """ self.receiveSatelliteAndShow(self.app.mainW.satellite) self.show() return True @staticmethod def markerSatellite(): """ markerSatellite constructs a custom marker for presentation of satellite view :return: marker """ circle = mpath.Path.unit_circle() rect1p = [[0, 0], [1, 2], [0, 3], [4, 7], [7, 4], [3, 0], [2, 1], [6, 5], [5, 6], [1, 2], [2, 1], [0, 0]] rect1p = np.array(rect1p) rect1c = [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 79] rect1c = np.array(rect1c) # concatenate the circle with an internal cutout of the star verts = np.concatenate([rect1p, rect1p * -1, circle.vertices]) codes = np.concatenate([rect1c, rect1c, circle.codes]) marker = mpath.Path(verts, codes) return marker def receiveSatelliteAndShow(self, satellite=None): """ receiveSatelliteAndShow receives a signal with the content of the selected satellite. it locally sets it an draws the the complete view :param satellite: :return: true for test purpose """ if satellite is None: self.drawSatellite() return False self.satellite = satellite if satellite.model.no < 0.02: self.FORECAST_TIME = 24 else: self.FORECAST_TIME = 3 self.drawSatellite() return True def updatePositions(self, observe=None, subpoint=None, altaz=None): """ updatePositions is triggered once a second and update the satellite position in each view. :return: success """ if observe is None: return False if subpoint is None: return False if altaz is None: return False if self.satellite is None: return False if self.plotSatPosEarth is None: return False if self.plotSatPosHorizon is None: return False if self.plotSatPosSphere1 is None: return False if self.plotSatPosSphere2 is None: return False # sphere1 x, y, z = observe.position.km self.plotSatPosSphere1.set_data_3d((x, y, z)) # sphere2 lat = subpoint.latitude.radians lon = subpoint.longitude.radians elev = subpoint.elevation.m / 1000 + self.EARTH_RADIUS x, y, z = transform.sphericalToCartesian(azimuth=lon, altitude=lat, radius=elev) self.plotSatPosSphere2.set_data_3d((x, y, z)) # earth lat = subpoint.latitude.degrees lon = subpoint.longitude.degrees self.plotSatPosEarth.set_data((lon, lat)) # horizon alt, az, _ = altaz alt = alt.degrees az = az.degrees self.plotSatPosHorizon.set_data((az, alt)) # update the plot and redraw self.satSphereMat1.figure.canvas.draw() self.satEarthMat.figure.canvas.draw() self.satHorizonMat.figure.canvas.draw() return True @staticmethod def makeCubeLimits(axe, centers=None, hw=None): """ :param axe: :param centers: :param hw: :return: """ limits = axe.get_xlim(), axe.get_ylim(), axe.get_zlim() if centers is None: centers = [0.5 * sum(pair) for pair in limits] if hw is None: widths = [pair[1] - pair[0] for pair in limits] hw = 0.5 * max(widths) axe.set_xlim(centers[0] - hw, centers[0] + hw) axe.set_ylim(centers[1] - hw, centers[1] + hw) axe.set_zlim(centers[2] - hw, centers[2] + hw) else: try: hwx, hwy, hwz = hw axe.set_xlim(centers[0] - hwx, centers[0] + hwx) axe.set_ylim(centers[1] - hwy, centers[1] + hwy) axe.set_zlim(centers[2] - hwz, centers[2] + hwz) except Exception: axe.set_xlim(centers[0] - hw, centers[0] + hw) axe.set_ylim(centers[1] - hw, centers[1] + hw) axe.set_zlim(centers[2] - hw, centers[2] + hw) return centers, hw def drawSphere1(self, observe=None): """ draw sphere and put face color als image overlay: https://stackoverflow.com/questions/53074908/ map-an-image-onto-a-sphere-and-plot-3d-trajectories but performance problems see also: https://space.stackexchange.com/questions/25958/ how-can-i-plot-a-satellites-orbit-in-3d-from-a-tle-using-python-and-skyfield :param observe: :return: success """ figure = self.satSphereMat1.figure figure.clf() figure.subplots_adjust(left=-0.1, right=1.1, bottom=-0.3, top=1.2) axe = figure.add_subplot(111, projection='3d') # switching all visual grids and planes off axe.set_facecolor((0, 0, 0, 0)) axe.tick_params(colors=self.M_GREY, labelsize=12) axe.set_axis_off() axe.w_xaxis.set_pane_color((0.0, 0.0, 0.0, 0.0)) axe.w_yaxis.set_pane_color((0.0, 0.0, 0.0, 0.0)) axe.w_zaxis.set_pane_color((0.0, 0.0, 0.0, 0.0)) # calculating sphere theta = np.linspace(0, 2 * np.pi, 51) cth, sth, zth = [f(theta) for f in [np.cos, np.sin, np.zeros_like]] lon0 = self.EARTH_RADIUS * np.vstack((cth, zth, sth)) longitudes = [] lonBase = np.arange(-180, 180, 15) for phi in np.radians(lonBase): cph, sph = [f(phi) for f in [np.cos, np.sin]] lon = np.vstack((lon0[0] * cph - lon0[1] * sph, lon0[1] * cph + lon0[0] * sph, lon0[2])) longitudes.append(lon) lats = [] latBase = np.arange(-75, 90, 15) for phi in np.radians(latBase): cph, sph = [f(phi) for f in [np.cos, np.sin]] lat = self.EARTH_RADIUS * np.vstack((cth * cph, sth * cph, zth + sph)) lats.append(lat) # plotting sphere and labels for i, longitude in enumerate(longitudes): x, y, z = longitude axe.plot(x, y, z, '-k', lw=1, color=self.M_GREY) for i, lat in enumerate(lats): x, y, z = lat axe.plot(x, y, z, '-k', lw=1, color=self.M_GREY) axe.plot([0, 0], [0, 0], [- self.EARTH_RADIUS * 1.1, self.EARTH_RADIUS * 1.1], lw=3, color=self.M_BLUE) axe.text(0, 0, self.EARTH_RADIUS * 1.2, 'N', fontsize=14, color=self.M_BLUE) axe.text(0, 0, - self.EARTH_RADIUS * 1.2 - 200, 'S', fontsize=14, color=self.M_BLUE) # empty chart if no satellite is chosen if observe is None: axe.figure.canvas.draw() return False # drawing satellite x, y, z = observe.position.km axe.plot(x, y, z, color=self.M_YELLOW) self.plotSatPosSphere1, = axe.plot([x[0]], [y[0]], [z[0]], marker=self.markerSatellite(), markersize=16, linewidth=2, fillstyle='none', color=self.M_PINK) self.makeCubeLimits(axe) axe.figure.canvas.draw() return True def drawSphere2(self, observe=None, subpoint=None): """ draw sphere and put face color als image overlay: https://stackoverflow.com/questions/53074908/ map-an-image-onto-a-sphere-and-plot-3d-trajectories but performance problems see also: https://space.stackexchange.com/questions/25958/ how-can-i-plot-a-satellites-orbit-in-3d-from-a-tle-using-python-and-skyfield :param observe: :param subpoint: :return: success """ figure = self.satSphereMat2.figure figure.clf() figure.subplots_adjust(left=-0.1, right=1.1, bottom=-0.3, top=1.2) axe = figure.add_subplot(111, projection='3d') # switching all visual grids and planes off axe.set_facecolor((0, 0, 0, 0)) axe.tick_params(colors=self.M_GREY, labelsize=12) axe.set_axis_off() axe.w_xaxis.set_pane_color((0.0, 0.0, 0.0, 0.0)) axe.w_yaxis.set_pane_color((0.0, 0.0, 0.0, 0.0)) axe.w_zaxis.set_pane_color((0.0, 0.0, 0.0, 0.0)) # calculating sphere theta = np.linspace(0, 2 * np.pi, 51) cth, sth, zth = [f(theta) for f in [np.cos, np.sin, np.zeros_like]] lon0 = self.EARTH_RADIUS * np.vstack((cth, zth, sth)) longitudes = [] lonBase = np.arange(-180, 180, 15) for phi in np.radians(lonBase): cph, sph = [f(phi) for f in [np.cos, np.sin]] lon = np.vstack((lon0[0] * cph - lon0[1] * sph, lon0[1] * cph + lon0[0] * sph, lon0[2])) longitudes.append(lon) lats = [] latBase = np.arange(-75, 90, 15) for phi in np.radians(latBase): cph, sph = [f(phi) for f in [np.cos, np.sin]] lat = self.EARTH_RADIUS * np.vstack((cth * cph, sth * cph, zth + sph)) lats.append(lat) # plotting sphere and labels for i, longitude in enumerate(longitudes): x, y, z = longitude axe.plot(x, y, z, '-k', lw=1, color=self.M_GREY) for i, lat in enumerate(lats): x, y, z = lat axe.plot(x, y, z, '-k', lw=1, color=self.M_GREY) axe.plot([0, 0], [0, 0], [- self.EARTH_RADIUS * 1.1, self.EARTH_RADIUS * 1.1], lw=3, color=self.M_BLUE) axe.text(0, 0, self.EARTH_RADIUS * 1.2, 'N', fontsize=14, color=self.M_BLUE) axe.text(0, 0, - self.EARTH_RADIUS * 1.2 - 200, 'S', fontsize=14, color=self.M_BLUE) # plot world for key in self.world.keys(): shape = self.world[key] x, y, z = transform.sphericalToCartesian(azimuth=shape['xRad'], altitude=shape['yRad'], radius=self.EARTH_RADIUS) verts = [list(zip(x, y, z))] collect = Poly3DCollection(verts, facecolors=self.M_BLUE, alpha=0.5) axe.add_collection3d(collect) # drawing home position location on earth lat = self.app.mount.obsSite.location.latitude.radians lon = self.app.mount.obsSite.location.longitude.radians x, y, z = transform.sphericalToCartesian(altitude=lat, azimuth=lon, radius=self.EARTH_RADIUS) axe.plot([x], [y], [z], marker='.', markersize=12, color=self.M_RED, ) # empty chart if no satellite is chosen if observe is None: axe.figure.canvas.draw() return False # drawing satellite subpoint path lat = subpoint.latitude.radians lon = subpoint.longitude.radians elev = subpoint.elevation.m / 1000 + self.EARTH_RADIUS x, y, z = transform.sphericalToCartesian(azimuth=lon, altitude=lat, radius=elev) axe.plot(x, y, z, color=self.M_YELLOW) # draw satellite position self.plotSatPosSphere2, = axe.plot([x[0]], [y[0]], [z[0]], marker=self.markerSatellite(), markersize=16, linewidth=2, fillstyle='none', color=self.M_PINK) # finalizing self.makeCubeLimits(axe) axe.figure.canvas.draw() return True def drawEarth(self, subpoint=None): """ drawEarth show a full earth view with the path of the subpoint of the satellite drawn on it. :param subpoint: :return: success """ figure = self.satEarthMat.figure figure.clf() figure.subplots_adjust(left=0.2, right=0.85, bottom=0.2, top=0.9) axe = self.satEarthMat.figure.add_subplot(1, 1, 1, facecolor=None) axe.set_facecolor((0, 0, 0, 0)) axe.set_xlim(-180, 180) axe.set_ylim(-90, 90) axe.spines['bottom'].set_color(self.M_BLUE) axe.spines['top'].set_color(self.M_BLUE) axe.spines['left'].set_color(self.M_BLUE) axe.spines['right'].set_color(self.M_BLUE) axe.grid(True, color=self.M_GREY) axe.tick_params(axis='x', colors=self.M_BLUE, labelsize=12) axe.set_xticks(np.arange(-180, 181, 45)) axe.tick_params(axis='y', colors=self.M_BLUE, which='both', labelleft=True, labelright=True, labelsize=12) axe.set_xlabel('Longitude in degrees', color=self.M_BLUE, fontweight='bold', fontsize=12) axe.set_ylabel('Latitude in degrees', color=self.M_BLUE, fontweight='bold', fontsize=12) # plot world for key in self.world.keys(): shape = self.world[key] axe.fill(shape['xDeg'], shape['yDeg'], color=self.M_BLUE, alpha=0.2) axe.plot(shape['xDeg'], shape['yDeg'], color=self.M_BLUE, lw=1, alpha=0.4) # mark the site location in the map lat = self.app.mount.obsSite.location.latitude.degrees lon = self.app.mount.obsSite.location.longitude.degrees axe.plot(lon, lat, marker='.', markersize=10, color=self.M_RED) # empty chart if no satellite is chosen if subpoint is None: axe.figure.canvas.draw() return False # drawing satellite subpoint path lat = subpoint.latitude.degrees lon = subpoint.longitude.degrees axe.plot(lon, lat, marker='o', markersize=1, linestyle='none', color=self.M_YELLOW) # show the actual position self.plotSatPosEarth, = axe.plot(lon[0], lat[0], marker=self.markerSatellite(), markersize=16, linewidth=2, fillstyle='none', linestyle='none', color=self.M_PINK) axe.figure.canvas.draw() return True def staticHorizon(self, axes=None): """ :param axes: matplotlib axes object :return: """ if not self.app.data.horizonP: return False alt, az = zip(*self.app.data.horizonP) axes.fill(az, alt, color=self.M_GREEN, zorder=-20, alpha=0.2) axes.plot(az, alt, color=self.M_GREEN, zorder=-20, lw=2, alpha=0.4) return True def drawHorizonView(self, difference=None): """ drawHorizonView shows the horizon and enable the users to explore a satellite passing by :param difference: :return: success """ figure = self.satHorizonMat.figure figure.clf() figure.subplots_adjust(left=0.2, right=0.85, bottom=0.2, top=0.9) axe = self.satHorizonMat.figure.add_subplot(1, 1, 1, facecolor=None) # add horizon limit if selected self.staticHorizon(axes=axe) axe.set_facecolor((0, 0, 0, 0)) axe.set_xlim(0, 360) axe.set_ylim(0, 90) axe.spines['bottom'].set_color(self.M_BLUE) axe.spines['top'].set_color(self.M_BLUE) axe.spines['left'].set_color(self.M_BLUE) axe.spines['right'].set_color(self.M_BLUE) axe.grid(True, color=self.M_GREY) axe.tick_params(axis='x', colors=self.M_BLUE, labelsize=12) axeTop = axe.twiny() axeTop.set_facecolor((0, 0, 0, 0)) axeTop.set_xlim(0, 360) axeTop.tick_params(axis='x', top=True, colors='#2090C0', labelsize=12) axeTop.set_xticks(np.arange(0, 361, 45)) axeTop.grid(axis='both', visible=False) axeTop.set_xticklabels(['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'N']) axeTop.spines['bottom'].set_color('#2090C0') axeTop.spines['top'].set_color('#2090C0') axeTop.spines['left'].set_color('#2090C0') axeTop.spines['right'].set_color('#2090C0') axe.set_xticks(np.arange(0, 361, 45)) axe.tick_params(axis='y', colors=self.M_BLUE, which='both', labelleft=True, labelright=True, labelsize=12) axe.set_xlabel('Azimuth in degrees', color=self.M_BLUE, fontweight='bold', fontsize=12) axe.set_ylabel('Altitude in degrees', color=self.M_BLUE, fontweight='bold', fontsize=12) # empty chart if no satellite is chosen if difference is None: axe.figure.canvas.draw() return False # orbital calculations alt, az, _ = difference.altaz() alt = alt.degrees az = az.degrees # draw path axe.plot(az, alt, marker='o', markersize=1, linestyle='none', color=self.M_YELLOW) # draw actual position self.plotSatPosHorizon, = axe.plot(az[0], alt[0], marker=self.markerSatellite(), markersize=16, linewidth=2, fillstyle=None, linestyle='none', color=self.M_PINK) axe.figure.canvas.draw() return True def drawSatellite(self): """ drawSatellite draws 3 different views of the actual satellite situation: a sphere a horizon view and an earth view. :return: True for test purpose """ timescale = self.app.mount.obsSite.ts forecast = np.arange(0, self.FORECAST_TIME, 0.005 * self.FORECAST_TIME / 3) / 24 now = timescale.now() timeVector = timescale.tt_jd(now.tt + forecast) if self.satellite is not None: observe = self.satellite.at(timeVector) subpoint = observe.subpoint() difference = (self.satellite - self.app.mount.obsSite.location).at(timeVector) else: observe = subpoint = difference = None self.drawSphere1(observe=observe) self.drawSphere2(observe=observe, subpoint=subpoint) self.drawEarth(subpoint=subpoint) self.drawHorizonView(difference=difference) return True
class MyApp(PyQt5.QtWidgets.QApplication): """ MyApp implements a custom notify handler to log errors, when C++ classes and python wrapper in PyQt5 environment mismatch. mostly this relates to the situation when a C++ object is already deleted, but the python wrapper still exists. so far I know that's the only chance to log this issues. in addition it writes mouse pressed and key pressed events in debug level to log including event and object name to be analyse the input methods. """ logger = logging.getLogger(__name__) log = CustomLogger(logger, {}) last = None def handleButtons(self, obj, returnValue): """ :param obj: :param returnValue: :return: """ if obj.objectName() == 'MainWindowWindow': return returnValue if obj == self.last: return returnValue else: self.last = obj if isinstance(obj, PyQt5.QtWidgets.QTabBar): self.log.warning( f'Click Tab : [{obj.tabText(obj.currentIndex())}]') elif isinstance(obj, PyQt5.QtWidgets.QComboBox): self.log.warning(f'Click DropDown: [{obj.objectName()}]') elif isinstance(obj, PyQt5.QtWidgets.QPushButton): self.log.warning(f'Click Button : [{obj.objectName()}]') elif isinstance(obj, PyQt5.QtWidgets.QRadioButton): self.log.warning( f'Click Radio : [{obj.objectName()}]:{obj.isChecked()}') elif isinstance(obj, PyQt5.QtWidgets.QGroupBox): self.log.warning( f'Click Group : [{obj.objectName()}]:{obj.isChecked()}') elif isinstance(obj, PyQt5.QtWidgets.QCheckBox): self.log.warning( f'Click Checkbox: [{obj.objectName()}]:{obj.isChecked()}') elif isinstance(obj, PyQt5.QtWidgets.QLineEdit): self.log.warning( f'Click EditLine: [{obj.objectName()}]:{obj.text()}') else: self.log.warning(f'Click Object : [{obj.objectName()}]') return returnValue def notify(self, obj, event): """ :param obj: :param event: :return: """ try: returnValue = PyQt5.QtWidgets.QApplication.notify(self, obj, event) except Exception as e: self.log.critical( '----------------------------------------------------') self.log.critical('Event: {0}'.format(event)) self.log.critical('EventType: {0}'.format(event.type())) self.log.critical('Exception error in event loop: {0}'.format(e)) self.log.critical( '----------------------------------------------------') returnValue = False if not isinstance(event, PyQt5.QtGui.QMouseEvent): return returnValue if not event.button(): return returnValue returnValue = self.handleButtons(obj, returnValue) return returnValue