pyobs-gui 2.0.0.dev1__py3-none-any.whl → 2.0.0.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyobs_gui/acquisitionwidget.py +152 -0
- pyobs_gui/autofocuswidget.py +109 -0
- pyobs_gui/autoguidingwidget.py +150 -0
- pyobs_gui/base.py +59 -4
- pyobs_gui/camerawidget.py +26 -10
- pyobs_gui/compassmovewidget.py +13 -6
- pyobs_gui/datadisplaywidget.py +2 -2
- pyobs_gui/filterwidget.py +4 -1
- pyobs_gui/fitsheaderswidget.py +7 -5
- pyobs_gui/gui.py +2 -2
- pyobs_gui/mainwindow.py +222 -21
- pyobs_gui/modewidget.py +21 -19
- pyobs_gui/modulegui.py +2 -2
- pyobs_gui/qt/acquisitionwidget.ui +173 -0
- pyobs_gui/qt/acquisitionwidget_ui.py +147 -0
- pyobs_gui/qt/autofocuswidget.ui +163 -0
- pyobs_gui/qt/autofocuswidget_ui.py +132 -0
- pyobs_gui/qt/autoguidingwidget.ui +161 -0
- pyobs_gui/qt/autoguidingwidget_ui.py +131 -0
- pyobs_gui/qt/mainwindow.ui +24 -30
- pyobs_gui/qt/mainwindow_ui.py +17 -23
- pyobs_gui/statuswidget.py +177 -30
- pyobs_gui/telescopewidget.py +116 -26
- pyobs_gui/weatherwidget.py +67 -75
- {pyobs_gui-2.0.0.dev1.dist-info → pyobs_gui-2.0.0.dev2.dist-info}/METADATA +2 -2
- {pyobs_gui-2.0.0.dev1.dist-info → pyobs_gui-2.0.0.dev2.dist-info}/RECORD +28 -19
- {pyobs_gui-2.0.0.dev1.dist-info → pyobs_gui-2.0.0.dev2.dist-info}/WHEEL +1 -1
- {pyobs_gui-2.0.0.dev1.dist-info → pyobs_gui-2.0.0.dev2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import qasync # type: ignore
|
|
6
|
+
from PySide6 import QtCore, QtWidgets # type: ignore
|
|
7
|
+
|
|
8
|
+
os.environ["QT_API"] = "PySide6"
|
|
9
|
+
from matplotlib import pyplot as plt
|
|
10
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
11
|
+
from matplotlib.ticker import MaxNLocator
|
|
12
|
+
|
|
13
|
+
from pyobs.interfaces import (
|
|
14
|
+
AcquisitionAttempt,
|
|
15
|
+
AcquisitionResult,
|
|
16
|
+
AcquisitionState,
|
|
17
|
+
IAcquisition,
|
|
18
|
+
IRunning,
|
|
19
|
+
OffsetFrame,
|
|
20
|
+
)
|
|
21
|
+
from pyobs.interfaces.IRunning import RunningState
|
|
22
|
+
from .base import BaseWidget
|
|
23
|
+
from .qt.acquisitionwidget_ui import Ui_AcquisitionWidget
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AcquisitionWidget(BaseWidget, Ui_AcquisitionWidget):
|
|
29
|
+
signal_update_gui = QtCore.Signal()
|
|
30
|
+
|
|
31
|
+
def __init__(self, **kwargs: Any):
|
|
32
|
+
BaseWidget.__init__(self, **kwargs)
|
|
33
|
+
self.setupUi(self) # type: ignore
|
|
34
|
+
|
|
35
|
+
# cached state
|
|
36
|
+
self._attempts: list[AcquisitionAttempt] = []
|
|
37
|
+
self._running = False
|
|
38
|
+
self._result: AcquisitionResult | None = None
|
|
39
|
+
|
|
40
|
+
# add plots: distance vs. attempt, and the 2D offset trajectory
|
|
41
|
+
self.figure, (self.ax, self.ax2) = plt.subplots(1, 2)
|
|
42
|
+
layout = QtWidgets.QVBoxLayout(self.framePlot)
|
|
43
|
+
self.canvas = FigureCanvas(self.figure)
|
|
44
|
+
layout.addWidget(self.canvas)
|
|
45
|
+
self.framePlot.setLayout(layout)
|
|
46
|
+
|
|
47
|
+
# connect signals
|
|
48
|
+
self.signal_update_gui.connect(self.update_gui)
|
|
49
|
+
self.buttonAcquire.clicked.connect(self._acquire)
|
|
50
|
+
self.buttonAbort.clicked.connect(self._abort)
|
|
51
|
+
|
|
52
|
+
# button colors
|
|
53
|
+
self.colorize_button(self.buttonAcquire, QtCore.Qt.GlobalColor.green)
|
|
54
|
+
self.colorize_button(self.buttonAbort, QtCore.Qt.GlobalColor.red)
|
|
55
|
+
|
|
56
|
+
async def _init(self) -> None:
|
|
57
|
+
await self.comm.subscribe_state(self.module, IRunning, self._on_running_state)
|
|
58
|
+
await self.comm.subscribe_state(self.module, IAcquisition, self._on_acquisition_state)
|
|
59
|
+
|
|
60
|
+
# permitted methods (ACLs)
|
|
61
|
+
await self._fetch_permitted_methods()
|
|
62
|
+
|
|
63
|
+
def _on_running_state(self, state: RunningState) -> None:
|
|
64
|
+
if state.running and not self._running:
|
|
65
|
+
# rising edge -> a new run started (possibly triggered elsewhere), clear the plot
|
|
66
|
+
self._attempts = []
|
|
67
|
+
self._result = None
|
|
68
|
+
self._running = state.running
|
|
69
|
+
self.signal_update_gui.emit()
|
|
70
|
+
|
|
71
|
+
def _on_acquisition_state(self, state: AcquisitionState) -> None:
|
|
72
|
+
self._attempts = state.attempts
|
|
73
|
+
self._result = state.result
|
|
74
|
+
self.signal_update_gui.emit()
|
|
75
|
+
|
|
76
|
+
def update_gui(self) -> None:
|
|
77
|
+
self.buttonAcquire.setEnabled(not self._running and self.permitted("acquire_target"))
|
|
78
|
+
self.buttonAbort.setEnabled(self._running and self.permitted("abort"))
|
|
79
|
+
self.labelStatus.setText("Acquiring..." if self._running else "Acquired." if self._result else "Idle")
|
|
80
|
+
|
|
81
|
+
if self._result:
|
|
82
|
+
self.labelResultRa.setText(f"{self._result.ra:.5f}")
|
|
83
|
+
self.labelResultDec.setText(f"{self._result.dec:.5f}")
|
|
84
|
+
self.labelResultAlt.setText(f"{self._result.alt:.3f}")
|
|
85
|
+
self.labelResultAz.setText(f"{self._result.az:.3f}")
|
|
86
|
+
if self._result.offset_frame is not None:
|
|
87
|
+
label = "RA/Dec offset:" if self._result.offset_frame == OffsetFrame.RA_DEC else "Alt/Az offset:"
|
|
88
|
+
self.labelOffsetType.setText(label)
|
|
89
|
+
self.labelResultOffset.setText(f"({self._result.offset_lon:+.5f}, {self._result.offset_lat:+.5f})")
|
|
90
|
+
else:
|
|
91
|
+
self.labelOffsetType.setText("Offset:")
|
|
92
|
+
self.labelResultOffset.setText("")
|
|
93
|
+
else:
|
|
94
|
+
self.labelResultRa.setText("")
|
|
95
|
+
self.labelResultDec.setText("")
|
|
96
|
+
self.labelResultAlt.setText("")
|
|
97
|
+
self.labelResultAz.setText("")
|
|
98
|
+
self.labelOffsetType.setText("Offset:")
|
|
99
|
+
self.labelResultOffset.setText("")
|
|
100
|
+
|
|
101
|
+
self.ax.clear()
|
|
102
|
+
if self._attempts:
|
|
103
|
+
self.ax.plot(
|
|
104
|
+
[a.attempt for a in self._attempts],
|
|
105
|
+
[a.distance for a in self._attempts],
|
|
106
|
+
marker="o",
|
|
107
|
+
color="tab:blue",
|
|
108
|
+
)
|
|
109
|
+
self.ax.set_xlabel("Attempt")
|
|
110
|
+
self.ax.set_ylabel("Distance to target [arcsec]")
|
|
111
|
+
self.ax.xaxis.set_major_locator(MaxNLocator(integer=True))
|
|
112
|
+
self.ax.grid(linestyle=":", alpha=0.5)
|
|
113
|
+
self.ax.set_axisbelow(True)
|
|
114
|
+
|
|
115
|
+
self.ax2.clear()
|
|
116
|
+
offset_attempts = [a for a in self._attempts if a.offset_frame is not None]
|
|
117
|
+
points = [(a.offset_lon, a.offset_lat) for a in offset_attempts]
|
|
118
|
+
if offset_attempts and offset_attempts[0].offset_frame == OffsetFrame.RA_DEC:
|
|
119
|
+
xlabel, ylabel = "RA offset [deg]", "Dec offset [deg]"
|
|
120
|
+
elif offset_attempts and offset_attempts[0].offset_frame == OffsetFrame.ALT_AZ:
|
|
121
|
+
xlabel, ylabel = "Alt offset [deg]", "Az offset [deg]"
|
|
122
|
+
else:
|
|
123
|
+
xlabel, ylabel = "Offset 1 [deg]", "Offset 2 [deg]"
|
|
124
|
+
if points:
|
|
125
|
+
xs, ys = zip(*points, strict=True)
|
|
126
|
+
self.ax2.axhline(0, color="gray", linewidth=0.5)
|
|
127
|
+
self.ax2.axvline(0, color="gray", linewidth=0.5)
|
|
128
|
+
self.ax2.plot(xs, ys, marker="o", color="tab:orange")
|
|
129
|
+
self.ax2.plot(xs[0], ys[0], marker="s", color="tab:red", markersize=8, linestyle="", label="start")
|
|
130
|
+
self.ax2.plot(xs[-1], ys[-1], marker="*", color="tab:green", markersize=12, linestyle="", label="latest")
|
|
131
|
+
self.ax2.legend(fontsize="small")
|
|
132
|
+
self.ax2.set_aspect("equal", adjustable="datalim")
|
|
133
|
+
self.ax2.set_xlabel(xlabel)
|
|
134
|
+
self.ax2.set_ylabel(ylabel)
|
|
135
|
+
self.ax2.grid(linestyle=":", alpha=0.5)
|
|
136
|
+
self.ax2.set_axisbelow(True)
|
|
137
|
+
|
|
138
|
+
self.figure.tight_layout()
|
|
139
|
+
self.canvas.draw()
|
|
140
|
+
|
|
141
|
+
@qasync.asyncSlot() # type: ignore
|
|
142
|
+
async def _acquire(self) -> None:
|
|
143
|
+
async with self.comm.proxy(self.module, IAcquisition) as proxy:
|
|
144
|
+
await proxy.acquire_target()
|
|
145
|
+
|
|
146
|
+
@qasync.asyncSlot() # type: ignore
|
|
147
|
+
async def _abort(self) -> None:
|
|
148
|
+
async with self.comm.proxy(self.module, IAcquisition) as proxy:
|
|
149
|
+
await proxy.abort()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = ["AcquisitionWidget"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import qasync # type: ignore
|
|
6
|
+
from PySide6 import QtCore, QtWidgets # type: ignore
|
|
7
|
+
|
|
8
|
+
os.environ["QT_API"] = "PySide6"
|
|
9
|
+
from matplotlib import pyplot as plt
|
|
10
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
11
|
+
|
|
12
|
+
from pyobs.events import Event, FocusFoundEvent
|
|
13
|
+
from pyobs.interfaces import AutoFocusPoint, AutoFocusState, IAutoFocus, IRunning, RunningState
|
|
14
|
+
from .base import BaseWidget
|
|
15
|
+
from .qt.autofocuswidget_ui import Ui_AutoFocusWidget
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AutoFocusWidget(BaseWidget, Ui_AutoFocusWidget):
|
|
21
|
+
signal_update_gui = QtCore.Signal()
|
|
22
|
+
|
|
23
|
+
def __init__(self, **kwargs: Any):
|
|
24
|
+
BaseWidget.__init__(self, **kwargs)
|
|
25
|
+
self.setupUi(self) # type: ignore
|
|
26
|
+
|
|
27
|
+
# cached state
|
|
28
|
+
self._points: list[AutoFocusPoint] = []
|
|
29
|
+
self._running = False
|
|
30
|
+
self._last_result: tuple[float, float] | None = None
|
|
31
|
+
|
|
32
|
+
# add plot
|
|
33
|
+
self.figure, self.ax = plt.subplots()
|
|
34
|
+
layout = QtWidgets.QVBoxLayout(self.framePlot)
|
|
35
|
+
self.canvas = FigureCanvas(self.figure)
|
|
36
|
+
layout.addWidget(self.canvas)
|
|
37
|
+
self.framePlot.setLayout(layout)
|
|
38
|
+
|
|
39
|
+
# connect signals
|
|
40
|
+
self.signal_update_gui.connect(self.update_gui)
|
|
41
|
+
self.buttonRunAutoFocus.clicked.connect(self._run_auto_focus)
|
|
42
|
+
self.buttonAbort.clicked.connect(self._abort)
|
|
43
|
+
|
|
44
|
+
# button colors
|
|
45
|
+
self.colorize_button(self.buttonRunAutoFocus, QtCore.Qt.GlobalColor.green)
|
|
46
|
+
self.colorize_button(self.buttonAbort, QtCore.Qt.GlobalColor.red)
|
|
47
|
+
|
|
48
|
+
async def _init(self) -> None:
|
|
49
|
+
await self.comm.subscribe_state(self.module, IRunning, self._on_running_state)
|
|
50
|
+
await self.comm.subscribe_state(self.module, IAutoFocus, self._on_autofocus_state)
|
|
51
|
+
await self.register_event(FocusFoundEvent, self._on_focus_found)
|
|
52
|
+
|
|
53
|
+
# permitted methods (ACLs)
|
|
54
|
+
await self._fetch_permitted_methods()
|
|
55
|
+
|
|
56
|
+
def _on_running_state(self, state: RunningState) -> None:
|
|
57
|
+
if state.running and not self._running:
|
|
58
|
+
# rising edge -> a new run started (possibly triggered elsewhere), clear the plot
|
|
59
|
+
self._points = []
|
|
60
|
+
self._last_result = None
|
|
61
|
+
self._running = state.running
|
|
62
|
+
self.signal_update_gui.emit()
|
|
63
|
+
|
|
64
|
+
def _on_autofocus_state(self, state: AutoFocusState) -> None:
|
|
65
|
+
self._points = state.points
|
|
66
|
+
self.signal_update_gui.emit()
|
|
67
|
+
|
|
68
|
+
async def _on_focus_found(self, event: Event, sender: str) -> bool:
|
|
69
|
+
if sender != self.module or not isinstance(event, FocusFoundEvent):
|
|
70
|
+
return False
|
|
71
|
+
self._last_result = (event.focus, event.error or 0.0)
|
|
72
|
+
self.signal_update_gui.emit()
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def update_gui(self) -> None:
|
|
76
|
+
self.buttonRunAutoFocus.setEnabled(not self._running and self.permitted("auto_focus"))
|
|
77
|
+
self.buttonAbort.setEnabled(self._running and self.permitted("abort"))
|
|
78
|
+
self.labelStatus.setText(
|
|
79
|
+
"Running..."
|
|
80
|
+
if self._running
|
|
81
|
+
else f"Focus: {self._last_result[0]:.3f} ± {self._last_result[1]:.3f} mm"
|
|
82
|
+
if self._last_result
|
|
83
|
+
else "Idle"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.ax.clear()
|
|
87
|
+
if self._points:
|
|
88
|
+
self.ax.scatter([p.focus for p in self._points], [p.value for p in self._points], color="tab:blue")
|
|
89
|
+
if self._last_result and not self._running:
|
|
90
|
+
self.ax.axvline(self._last_result[0], color="tab:green", linestyle="--", label="fitted focus")
|
|
91
|
+
self.ax.legend()
|
|
92
|
+
self.ax.set_xlabel("Focus [mm]")
|
|
93
|
+
self.ax.set_ylabel("Metric")
|
|
94
|
+
self.ax.grid(linestyle=":", alpha=0.5)
|
|
95
|
+
self.ax.set_axisbelow(True)
|
|
96
|
+
self.canvas.draw()
|
|
97
|
+
|
|
98
|
+
@qasync.asyncSlot() # type: ignore
|
|
99
|
+
async def _run_auto_focus(self) -> None:
|
|
100
|
+
async with self.comm.proxy(self.module, IAutoFocus) as proxy:
|
|
101
|
+
await proxy.auto_focus(self.spinCount.value(), self.spinStep.value(), self.spinExposureTime.value())
|
|
102
|
+
|
|
103
|
+
@qasync.asyncSlot() # type: ignore
|
|
104
|
+
async def _abort(self) -> None:
|
|
105
|
+
async with self.comm.proxy(self.module, IAutoFocus) as proxy:
|
|
106
|
+
await proxy.abort()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = ["AutoFocusWidget"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
from collections import deque
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import qasync # type: ignore
|
|
8
|
+
from PySide6 import QtCore, QtWidgets # type: ignore
|
|
9
|
+
|
|
10
|
+
os.environ["QT_API"] = "PySide6"
|
|
11
|
+
from matplotlib import pyplot as plt
|
|
12
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
|
13
|
+
from matplotlib.ticker import MaxNLocator
|
|
14
|
+
|
|
15
|
+
from pyobs.interfaces import GuidingState, IAutoGuiding, IExposureTime, IRunning, OffsetFrame
|
|
16
|
+
from pyobs.interfaces.IExposureTime import ExposureTimeState
|
|
17
|
+
from pyobs.interfaces.IRunning import RunningState
|
|
18
|
+
from .base import BaseWidget
|
|
19
|
+
from .qt.autoguidingwidget_ui import Ui_AutoGuidingWidget
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_HISTORY_LENGTH = 50
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AutoGuidingWidget(BaseWidget, Ui_AutoGuidingWidget):
|
|
27
|
+
signal_update_gui = QtCore.Signal()
|
|
28
|
+
|
|
29
|
+
def __init__(self, **kwargs: Any):
|
|
30
|
+
BaseWidget.__init__(self, **kwargs)
|
|
31
|
+
self.setupUi(self) # type: ignore
|
|
32
|
+
|
|
33
|
+
# cached state
|
|
34
|
+
self._running = False
|
|
35
|
+
self._loop_closed = False
|
|
36
|
+
self._exposure_time: float | None = None
|
|
37
|
+
self._offset_frame: OffsetFrame | None = None
|
|
38
|
+
self._offset: tuple[float, float] | None = None # last (lon, lat), arcsec
|
|
39
|
+
self._offset_history: deque[tuple[float, float]] = deque(maxlen=_HISTORY_LENGTH)
|
|
40
|
+
|
|
41
|
+
# add plots: offset magnitude vs. sample, and the lon/lat scatter
|
|
42
|
+
self.figure, (self.ax, self.ax2) = plt.subplots(1, 2)
|
|
43
|
+
layout = QtWidgets.QVBoxLayout(self.framePlot)
|
|
44
|
+
self.canvas = FigureCanvas(self.figure)
|
|
45
|
+
layout.addWidget(self.canvas)
|
|
46
|
+
self.framePlot.setLayout(layout)
|
|
47
|
+
|
|
48
|
+
# connect signals
|
|
49
|
+
self.signal_update_gui.connect(self.update_gui)
|
|
50
|
+
self.buttonStart.clicked.connect(self._start)
|
|
51
|
+
self.buttonStop.clicked.connect(self._stop)
|
|
52
|
+
self.spinExposureTime.init_modified(self.labelExposureTime).committed.connect(self._set_exposure_time)
|
|
53
|
+
|
|
54
|
+
# button colors
|
|
55
|
+
self.colorize_button(self.buttonStart, QtCore.Qt.GlobalColor.green)
|
|
56
|
+
self.colorize_button(self.buttonStop, QtCore.Qt.GlobalColor.red)
|
|
57
|
+
|
|
58
|
+
async def _init(self) -> None:
|
|
59
|
+
await self.comm.subscribe_state(self.module, IRunning, self._on_running_state)
|
|
60
|
+
await self.comm.subscribe_state(self.module, IExposureTime, self._on_exptime_state)
|
|
61
|
+
await self.comm.subscribe_state(self.module, IAutoGuiding, self._on_guiding_state)
|
|
62
|
+
|
|
63
|
+
# permitted methods (ACLs)
|
|
64
|
+
await self._fetch_permitted_methods()
|
|
65
|
+
|
|
66
|
+
def _on_running_state(self, state: RunningState) -> None:
|
|
67
|
+
if state.running and not self._running:
|
|
68
|
+
# rising edge -> a new run started (possibly triggered elsewhere), clear the history
|
|
69
|
+
self._offset_history.clear()
|
|
70
|
+
self._running = state.running
|
|
71
|
+
self.signal_update_gui.emit()
|
|
72
|
+
|
|
73
|
+
def _on_exptime_state(self, state: ExposureTimeState) -> None:
|
|
74
|
+
# only follow live updates while the spin box isn't mid-edit, so a user's in-progress
|
|
75
|
+
# value isn't clobbered by a state update from elsewhere
|
|
76
|
+
was_synced = self._exposure_time is None or self.spinExposureTime.value() == self._exposure_time
|
|
77
|
+
self._exposure_time = state.exposure_time
|
|
78
|
+
self.labelExposureTime.setText(f"{state.exposure_time:.1f}")
|
|
79
|
+
if was_synced:
|
|
80
|
+
self.spinExposureTime.setValue(state.exposure_time)
|
|
81
|
+
self.signal_update_gui.emit()
|
|
82
|
+
|
|
83
|
+
def _on_guiding_state(self, state: GuidingState) -> None:
|
|
84
|
+
self._loop_closed = state.loop_closed
|
|
85
|
+
self._offset_frame = state.offset_frame
|
|
86
|
+
if state.offset_lon is not None and state.offset_lat is not None:
|
|
87
|
+
self._offset = (state.offset_lon * 3600, state.offset_lat * 3600)
|
|
88
|
+
self._offset_history.append(self._offset)
|
|
89
|
+
self.signal_update_gui.emit()
|
|
90
|
+
|
|
91
|
+
def update_gui(self) -> None:
|
|
92
|
+
self.buttonStart.setEnabled(not self._running and self.permitted("start"))
|
|
93
|
+
self.buttonStop.setEnabled(self._running and self.permitted("stop"))
|
|
94
|
+
self.spinExposureTime.setEnabled(self.permitted("set_exposure_time"))
|
|
95
|
+
self.labelLoopState.setText(
|
|
96
|
+
"Closed loop" if self._running and self._loop_closed else "Open loop" if self._running else "Stopped"
|
|
97
|
+
)
|
|
98
|
+
self.labelOffset.setText(f"({self._offset[0]:+.2f}, {self._offset[1]:+.2f}) arcsec" if self._offset else "")
|
|
99
|
+
|
|
100
|
+
self.ax.clear()
|
|
101
|
+
if self._offset_history:
|
|
102
|
+
magnitudes = [math.sqrt(lon**2 + lat**2) for lon, lat in self._offset_history]
|
|
103
|
+
self.ax.plot(range(1, len(magnitudes) + 1), magnitudes, marker="o", color="tab:blue")
|
|
104
|
+
self.ax.set_xlabel("Sample")
|
|
105
|
+
self.ax.set_ylabel("Offset magnitude [arcsec]")
|
|
106
|
+
self.ax.xaxis.set_major_locator(MaxNLocator(integer=True))
|
|
107
|
+
self.ax.grid(linestyle=":", alpha=0.5)
|
|
108
|
+
self.ax.set_axisbelow(True)
|
|
109
|
+
|
|
110
|
+
self.ax2.clear()
|
|
111
|
+
if self._offset_frame == OffsetFrame.RA_DEC:
|
|
112
|
+
xlabel, ylabel = "RA offset [arcsec]", "Dec offset [arcsec]"
|
|
113
|
+
elif self._offset_frame == OffsetFrame.ALT_AZ:
|
|
114
|
+
xlabel, ylabel = "Alt offset [arcsec]", "Az offset [arcsec]"
|
|
115
|
+
else:
|
|
116
|
+
xlabel, ylabel = "Offset 1 [arcsec]", "Offset 2 [arcsec]"
|
|
117
|
+
if self._offset_history:
|
|
118
|
+
xs, ys = zip(*self._offset_history, strict=True)
|
|
119
|
+
self.ax2.axhline(0, color="gray", linewidth=0.5)
|
|
120
|
+
self.ax2.axvline(0, color="gray", linewidth=0.5)
|
|
121
|
+
self.ax2.plot(xs, ys, marker="o", linestyle="", color="tab:orange")
|
|
122
|
+
self.ax2.plot(xs[-1], ys[-1], marker="*", color="tab:green", markersize=12, linestyle="", label="latest")
|
|
123
|
+
self.ax2.legend(fontsize="small")
|
|
124
|
+
self.ax2.set_aspect("equal", adjustable="datalim")
|
|
125
|
+
self.ax2.set_xlabel(xlabel)
|
|
126
|
+
self.ax2.set_ylabel(ylabel)
|
|
127
|
+
self.ax2.grid(linestyle=":", alpha=0.5)
|
|
128
|
+
self.ax2.set_axisbelow(True)
|
|
129
|
+
|
|
130
|
+
self.figure.tight_layout()
|
|
131
|
+
self.canvas.draw()
|
|
132
|
+
|
|
133
|
+
@qasync.asyncSlot() # type: ignore
|
|
134
|
+
async def _start(self) -> None:
|
|
135
|
+
async with self.comm.proxy(self.module, IAutoGuiding) as proxy:
|
|
136
|
+
await proxy.start()
|
|
137
|
+
|
|
138
|
+
@qasync.asyncSlot() # type: ignore
|
|
139
|
+
async def _stop(self) -> None:
|
|
140
|
+
async with self.comm.proxy(self.module, IAutoGuiding) as proxy:
|
|
141
|
+
await proxy.stop()
|
|
142
|
+
|
|
143
|
+
@qasync.asyncSlot() # type: ignore
|
|
144
|
+
async def _set_exposure_time(self) -> None:
|
|
145
|
+
value = self.spinExposureTime.value()
|
|
146
|
+
async with self.comm.proxy(self.module, IExposureTime) as proxy:
|
|
147
|
+
await proxy.set_exposure_time(value)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = ["AutoGuidingWidget"]
|
pyobs_gui/base.py
CHANGED
|
@@ -5,6 +5,7 @@ import logging
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Callable, Type, TypeVar, overload
|
|
6
6
|
|
|
7
7
|
import pyobs.utils.exceptions as exc
|
|
8
|
+
from pyobs.interfaces import IModule
|
|
8
9
|
from pyobs.object import create_object
|
|
9
10
|
from pyobs.utils.enums import ModuleState
|
|
10
11
|
from PySide6 import QtCore, QtGui, QtWidgets # type: ignore
|
|
@@ -16,6 +17,8 @@ if TYPE_CHECKING:
|
|
|
16
17
|
|
|
17
18
|
from astroplan import Observer
|
|
18
19
|
from pyobs.comm import Comm
|
|
20
|
+
from pyobs.events import Event
|
|
21
|
+
from pyobs.interfaces import FitsHeaderEntry
|
|
19
22
|
from pyobs.vfs import VirtualFileSystem
|
|
20
23
|
|
|
21
24
|
log = logging.getLogger(__name__)
|
|
@@ -150,6 +153,16 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
|
|
|
150
153
|
# has it been initialized?
|
|
151
154
|
self._initialized = False
|
|
152
155
|
|
|
156
|
+
# methods this GUI is permitted to invoke on self.module; None until fetched or if the
|
|
157
|
+
# fetch failed, meaning "treat everything as permitted"
|
|
158
|
+
self._permitted_methods: set[str] | None = None
|
|
159
|
+
|
|
160
|
+
# (event_class, handler) pairs registered via self.register_event(), so discard() can
|
|
161
|
+
# unregister them all -- comm.register_event() has no automatic per-client teardown like
|
|
162
|
+
# subscribe_state()/subscribe_presence() do, since a handler isn't inherently tied to one
|
|
163
|
+
# remote module, so whoever registers it is responsible for unregistering it
|
|
164
|
+
self._registered_event_handlers: list[tuple[type[Event], Callable[[Event, str], Any]]] = []
|
|
165
|
+
|
|
153
166
|
def resizeEvent(self, a0: QtGui.QResizeEvent) -> None:
|
|
154
167
|
if self.extract_window_button:
|
|
155
168
|
self.extract_window_button.move(self.width() - 20, 0)
|
|
@@ -203,6 +216,32 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
|
|
|
203
216
|
self.sidebar_widgets.append(widget)
|
|
204
217
|
self.sidebar_layout.insertWidget(len(self.sidebar_widgets) - 1, widget)
|
|
205
218
|
|
|
219
|
+
async def register_event(self, event_class: type[Event], handler: Callable[[Event, str], Any]) -> None:
|
|
220
|
+
"""Register an event handler through comm, tracked so discard() can unregister it later.
|
|
221
|
+
|
|
222
|
+
Widgets that are created/destroyed per connected client (see DEFAULT_WIDGETS in
|
|
223
|
+
mainwindow.py) must use this instead of calling self.comm.register_event() directly,
|
|
224
|
+
so their handler stops firing once the widget is discarded.
|
|
225
|
+
"""
|
|
226
|
+
await self.comm.register_event(event_class, handler)
|
|
227
|
+
self._registered_event_handlers.append((event_class, handler))
|
|
228
|
+
|
|
229
|
+
async def discard(self) -> None:
|
|
230
|
+
"""Tear down everything this widget registered with comm, and recursively discard
|
|
231
|
+
its sidebar widgets.
|
|
232
|
+
|
|
233
|
+
Must be called (e.g. from mainwindow._client_disconnected) whenever a widget is
|
|
234
|
+
removed from the UI -- otherwise a handler registered via register_event() lingers
|
|
235
|
+
in Comm._event_handlers forever, keeping this widget alive and still reacting to
|
|
236
|
+
events for as long as the app runs.
|
|
237
|
+
"""
|
|
238
|
+
for event_class, handler in self._registered_event_handlers:
|
|
239
|
+
await self.comm.unregister_event(event_class, handler)
|
|
240
|
+
self._registered_event_handlers.clear()
|
|
241
|
+
|
|
242
|
+
for widget in self.sidebar_widgets:
|
|
243
|
+
await widget.discard()
|
|
244
|
+
|
|
206
245
|
def showEvent(self, event: QtGui.QShowEvent) -> None:
|
|
207
246
|
# run in loop
|
|
208
247
|
asyncio.create_task(self._showEvent(event))
|
|
@@ -250,7 +289,7 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
|
|
|
250
289
|
# sleep a little
|
|
251
290
|
await asyncio.sleep(1)
|
|
252
291
|
|
|
253
|
-
except (exc.
|
|
292
|
+
except (exc.PyobsError, IndexError):
|
|
254
293
|
# ignore these and sleep a little
|
|
255
294
|
await asyncio.sleep(1)
|
|
256
295
|
|
|
@@ -269,7 +308,7 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
|
|
|
269
308
|
# call method
|
|
270
309
|
try:
|
|
271
310
|
await method(*args, **kwargs)
|
|
272
|
-
except exc.
|
|
311
|
+
except exc.PyobsError as e:
|
|
273
312
|
await self.show_error(e)
|
|
274
313
|
except Exception as e:
|
|
275
314
|
log.exception("An error occurred.")
|
|
@@ -278,7 +317,7 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
|
|
|
278
317
|
# enable widgets
|
|
279
318
|
self._enable_buttons.emit(disable, True)
|
|
280
319
|
|
|
281
|
-
async def show_error(self, exception: exc.
|
|
320
|
+
async def show_error(self, exception: exc.PyobsError) -> None:
|
|
282
321
|
err = str(exception)
|
|
283
322
|
title, message = err.split(":") if ":" in err else ("Error", err)
|
|
284
323
|
await QAsyncMessageBox.warning(self, title, message)
|
|
@@ -287,7 +326,23 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
|
|
|
287
326
|
for w in widgets:
|
|
288
327
|
w.setEnabled(enable)
|
|
289
328
|
|
|
290
|
-
def
|
|
329
|
+
async def _fetch_permitted_methods(self) -> None:
|
|
330
|
+
# leaves _permitted_methods at None on failure, so permitted() falls back to "everything
|
|
331
|
+
# allowed" - an actual denial still surfaces via ForbiddenError in _background_task
|
|
332
|
+
if not hasattr(IModule, "get_permitted_methods"):
|
|
333
|
+
# pyobs-core older than the ACL feature (still within our supported version range)
|
|
334
|
+
return
|
|
335
|
+
try:
|
|
336
|
+
async with self.comm.proxy(self.module, IModule) as proxy:
|
|
337
|
+
self._permitted_methods = set(await proxy.get_permitted_methods())
|
|
338
|
+
except exc.PyobsError:
|
|
339
|
+
self._permitted_methods = None
|
|
340
|
+
|
|
341
|
+
def permitted(self, method: str) -> bool:
|
|
342
|
+
"""Returns whether this GUI is permitted to invoke the given method on self.module."""
|
|
343
|
+
return self._permitted_methods is None or method in self._permitted_methods
|
|
344
|
+
|
|
345
|
+
def get_fits_headers(self, namespaces: list[str] | None = None, **kwargs: Any) -> dict[str, FitsHeaderEntry]:
|
|
291
346
|
"""Returns FITS header for the current status of this module.
|
|
292
347
|
|
|
293
348
|
Args:
|
pyobs_gui/camerawidget.py
CHANGED
|
@@ -25,6 +25,8 @@ from pyobs.interfaces import (
|
|
|
25
25
|
GainState,
|
|
26
26
|
IExposure,
|
|
27
27
|
ExposureState,
|
|
28
|
+
IDataSequence,
|
|
29
|
+
DataSequenceState,
|
|
28
30
|
)
|
|
29
31
|
from pyobs.utils.enums import ImageType, ImageFormat, ExposureStatus
|
|
30
32
|
from pyobs.vfs import VirtualFileSystem
|
|
@@ -153,6 +155,9 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
|
|
|
153
155
|
if proxy is not None:
|
|
154
156
|
await self.comm.subscribe_state(self.module, IExposureTime, self._update_exposure_time)
|
|
155
157
|
|
|
158
|
+
if await self.comm.has_proxy(self.module, IDataSequence):
|
|
159
|
+
await self.comm.subscribe_state(self.module, IDataSequence, self._update_sequence)
|
|
160
|
+
|
|
156
161
|
# update GUI
|
|
157
162
|
self.signal_update_gui.emit()
|
|
158
163
|
|
|
@@ -185,6 +190,10 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
|
|
|
185
190
|
self.spinExpTime.setValue(state.exposure_time)
|
|
186
191
|
self.update_gui()
|
|
187
192
|
|
|
193
|
+
def _update_sequence(self, state: DataSequenceState):
|
|
194
|
+
self.exposures_left = state.count_left
|
|
195
|
+
self.update_gui()
|
|
196
|
+
|
|
188
197
|
@qasync.asyncSlot()
|
|
189
198
|
async def _window_changed(self):
|
|
190
199
|
print("ok")
|
|
@@ -238,19 +247,21 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
|
|
|
238
247
|
|
|
239
248
|
@qasync.asyncSlot() # type: ignore
|
|
240
249
|
async def expose(self) -> None:
|
|
241
|
-
|
|
242
|
-
|
|
250
|
+
broadcast = self.checkBroadcast.isChecked()
|
|
251
|
+
count = self.spinCount.value()
|
|
252
|
+
|
|
253
|
+
# if the module can grab a counted sequence server-side, let it -- progress comes
|
|
254
|
+
# back via the DataSequenceState subscription
|
|
255
|
+
async with self.comm.safe_proxy(self.module, IDataSequence) as proxy:
|
|
256
|
+
if proxy is not None:
|
|
257
|
+
await proxy.grab_sequence(count, broadcast)
|
|
258
|
+
return
|
|
243
259
|
|
|
244
|
-
#
|
|
260
|
+
# fall back to a client-side loop for modules that don't support IDataSequence
|
|
261
|
+
self.exposures_left = count
|
|
245
262
|
while self.exposures_left > 0:
|
|
246
|
-
# expose
|
|
247
|
-
broadcast = self.checkBroadcast.isChecked()
|
|
248
263
|
await self.datadisplay.grab_data(broadcast)
|
|
249
|
-
|
|
250
|
-
# decrement number of exposures left
|
|
251
264
|
self.exposures_left -= 1
|
|
252
|
-
|
|
253
|
-
# signal GUI update
|
|
254
265
|
self.signal_update_gui.emit()
|
|
255
266
|
|
|
256
267
|
@qasync.asyncSlot() # type: ignore
|
|
@@ -262,7 +273,12 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
|
|
|
262
273
|
|
|
263
274
|
# got exposures left?
|
|
264
275
|
if self.exposures_left > 1:
|
|
265
|
-
#
|
|
276
|
+
# soft-stop the sequence server-side (current grab finishes normally), if
|
|
277
|
+
# supported; otherwise just stop the client-side loop after the current grab
|
|
278
|
+
async with self.comm.safe_proxy(self.module, IDataSequence) as proxy:
|
|
279
|
+
if proxy is not None:
|
|
280
|
+
await proxy.abort_sequence()
|
|
281
|
+
return
|
|
266
282
|
self.exposures_left = 0
|
|
267
283
|
else:
|
|
268
284
|
async with self.comm.safe_proxy(self.module, IAbortable) as proxy:
|
pyobs_gui/compassmovewidget.py
CHANGED
|
@@ -42,20 +42,27 @@ class CompassMoveWidget(BaseWidget, Ui_CompassMoveWidget):
|
|
|
42
42
|
has_offsets_radec = await self.comm.has_proxy(self.module, IOffsetsRaDec)
|
|
43
43
|
if has_offsets_altaz and has_pointing_altaz:
|
|
44
44
|
async with self.comm.proxy(self.module, IPointingAltAz) as p:
|
|
45
|
-
|
|
45
|
+
altaz_state = await p.wait_for_state(IPointingAltAz, timeout=5.0)
|
|
46
|
+
if altaz_state is None:
|
|
47
|
+
return
|
|
46
48
|
altaz = SkyCoord(
|
|
47
|
-
alt=alt * u.degree,
|
|
48
|
-
az=az * u.degree,
|
|
49
|
+
alt=altaz_state.alt * u.degree,
|
|
50
|
+
az=altaz_state.az * u.degree,
|
|
49
51
|
obstime=Time.now(),
|
|
50
52
|
location=self.observer.location,
|
|
51
53
|
frame="altaz",
|
|
52
54
|
)
|
|
53
55
|
async with self.comm.proxy(self.module, IOffsetsAltAz) as p:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
offset_state = await p.wait_for_state(IOffsetsAltAz, timeout=5.0)
|
|
57
|
+
if offset_state is None:
|
|
58
|
+
return
|
|
59
|
+
off_ra, off_dec = offset_altaz_to_radec(altaz, offset_state.alt, offset_state.az)
|
|
56
60
|
elif has_offsets_radec:
|
|
57
61
|
async with self.comm.proxy(self.module, IOffsetsRaDec) as p:
|
|
58
|
-
|
|
62
|
+
offset_state = await p.wait_for_state(IOffsetsRaDec, timeout=5.0)
|
|
63
|
+
if offset_state is None:
|
|
64
|
+
return
|
|
65
|
+
off_ra, off_dec = offset_state.ra, offset_state.dec
|
|
59
66
|
else:
|
|
60
67
|
return
|
|
61
68
|
|
pyobs_gui/datadisplaywidget.py
CHANGED
|
@@ -77,8 +77,8 @@ class DataDisplayWidget(BaseWidget, Ui_DataDisplayWidget):
|
|
|
77
77
|
raise ValueError("Unknown type")
|
|
78
78
|
|
|
79
79
|
# subscribe to events
|
|
80
|
-
await self.
|
|
81
|
-
await self.
|
|
80
|
+
await self.register_event(NewImageEvent, self._on_new_data)
|
|
81
|
+
await self.register_event(NewSpectrumEvent, self._on_new_data)
|
|
82
82
|
|
|
83
83
|
async def grab_data(self, broadcast: bool) -> None:
|
|
84
84
|
"""Grab data."""
|
pyobs_gui/filterwidget.py
CHANGED
|
@@ -38,6 +38,9 @@ class FilterWidget(BaseWidget, Ui_FilterWidget):
|
|
|
38
38
|
await self.comm.subscribe_state(self.module, IFilters, self._on_filter_state)
|
|
39
39
|
await self.comm.subscribe_state(self.module, IMotion, self._on_motion_state)
|
|
40
40
|
|
|
41
|
+
# permitted methods (ACLs)
|
|
42
|
+
await self._fetch_permitted_methods()
|
|
43
|
+
|
|
41
44
|
def _on_filter_state(self, state: FilterState) -> None:
|
|
42
45
|
self._filter = state.filter
|
|
43
46
|
self.signal_update_gui.emit()
|
|
@@ -56,7 +59,7 @@ class FilterWidget(BaseWidget, Ui_FilterWidget):
|
|
|
56
59
|
MotionStatus.IDLE,
|
|
57
60
|
MotionStatus.POSITIONED,
|
|
58
61
|
]
|
|
59
|
-
self.buttonSetFilter.setEnabled(initialized)
|
|
62
|
+
self.buttonSetFilter.setEnabled(initialized and self.permitted("set_filter"))
|
|
60
63
|
|
|
61
64
|
@qasync.asyncSlot() # type: ignore
|
|
62
65
|
async def set_filter(self) -> None:
|