Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 25010c3

Browse files
committedJan 28, 2025·
add proseco parameters widget, attitude widget, aperoll/widgets/proseco_view.py and aperoll/proseco_data.py
1 parent 714ffcc commit 25010c3

9 files changed

+1234
-12
lines changed
 

‎aperoll/proseco_data.py

+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# from PyQt5 import QtCore as QtC, QtWidgets as QtW, QtGui as QtG
2+
import os
3+
import pickle
4+
import tarfile
5+
from pathlib import Path
6+
from tempfile import TemporaryDirectory
7+
8+
import PyQt5.QtWidgets as QtW
9+
import sparkles
10+
from proseco import get_aca_catalog
11+
from ska_helpers import utils
12+
13+
14+
class CachedVal:
15+
def __init__(self, func):
16+
self._func = func
17+
self.reset()
18+
19+
def reset(self):
20+
self._value = utils.LazyVal(self._func)
21+
22+
@property
23+
def val(self):
24+
return self._value.val
25+
26+
27+
class ProsecoData:
28+
"""
29+
Class to deal with calling Proseco/Sparkles, temporary directories and exporting the results.
30+
31+
Parameters
32+
----------
33+
parameters : dict
34+
The parameters to pass to Proseco. Optional.
35+
"""
36+
37+
def __init__(self, parameters=None) -> None:
38+
self._proseco = CachedVal(self.run_proseco)
39+
self._sparkles = CachedVal(self.run_sparkles)
40+
self._parameters = parameters
41+
self._tmp_dir = TemporaryDirectory()
42+
self._dir = Path(self._tmp_dir.name)
43+
44+
def reset(self, parameters):
45+
self._parameters = parameters
46+
self._proseco.reset()
47+
self._sparkles.reset()
48+
49+
@property
50+
def proseco(self):
51+
return self._proseco.val
52+
53+
@property
54+
def sparkles(self):
55+
return self._sparkles.val
56+
57+
def set_parameters(self, parameters):
58+
self.reset(parameters.copy())
59+
60+
def get_parameters(self):
61+
return self._parameters
62+
63+
parameters = property(get_parameters, set_parameters)
64+
65+
def export_proseco(self, outfile=None):
66+
if self.proseco and self.proseco["catalog"]:
67+
catalog = self.proseco["catalog"]
68+
if outfile is None:
69+
outfile = f"aperoll-proseco-obsid_{catalog.obsid:.0f}.pkl"
70+
with open(outfile, "wb") as fh:
71+
pickle.dump({catalog.obsid: catalog}, fh)
72+
73+
def export_sparkles(self, outfile=None):
74+
if self.sparkles:
75+
if outfile is None:
76+
catalog = self.proseco["catalog"]
77+
outfile = Path(f"aperoll-sparkles-obsid_{catalog.obsid:.0f}.tar.gz")
78+
dest = Path(str(outfile).replace(".tar", "").replace(".gz", ""))
79+
with tarfile.open(outfile, "w") as tar:
80+
for name in self.sparkles.glob("**/*"):
81+
tar.add(
82+
name,
83+
arcname=dest / name.relative_to(self._dir / "sparkles"),
84+
)
85+
86+
def export_proseco_dialog(self):
87+
"""
88+
Save the star catalog in a pickle file.
89+
"""
90+
if self.proseco:
91+
catalog = self.proseco["catalog"]
92+
dialog = QtW.QFileDialog(
93+
caption="Export Pickle",
94+
directory=str(
95+
Path(os.getcwd()) / f"aperoll-proseco-obsid_{catalog.obsid:.0f}.pkl"
96+
),
97+
)
98+
dialog.setAcceptMode(QtW.QFileDialog.AcceptSave)
99+
dialog.setDefaultSuffix("pkl")
100+
rc = dialog.exec()
101+
if rc:
102+
self.export_proseco(dialog.selectedFiles()[0])
103+
104+
def export_sparkles_dialog(self):
105+
"""
106+
Save the sparkles report to a tarball.
107+
"""
108+
if self.sparkles:
109+
catalog = self.proseco["catalog"]
110+
# for some reason, the extension hidden but it works
111+
dialog = QtW.QFileDialog(
112+
caption="Export Pickle",
113+
directory=str(
114+
Path(os.getcwd())
115+
/ f"aperoll-sparkles-obsid_{catalog.obsid:.0f}.tar.gz"
116+
),
117+
)
118+
dialog.setAcceptMode(QtW.QFileDialog.AcceptSave)
119+
dialog.setDefaultSuffix(".tgz")
120+
rc = dialog.exec()
121+
if rc:
122+
self.export_sparkles(dialog.selectedFiles()[0])
123+
124+
def run_proseco(self):
125+
if self._parameters:
126+
params = self._parameters.copy()
127+
# remove some optional arguments and let proseco deal with it.
128+
keys = [
129+
"exclude_ids_acq",
130+
"include_ids_acq",
131+
"exclude_ids_guide",
132+
"include_ids_guide",
133+
]
134+
for key in keys:
135+
if not params[key]:
136+
del params[key]
137+
catalog = get_aca_catalog(**params)
138+
aca_review = catalog.get_review_table()
139+
sparkles.core.check_catalog(aca_review)
140+
141+
return {
142+
"catalog": catalog,
143+
"review_table": aca_review,
144+
}
145+
return {}
146+
147+
def run_sparkles(self):
148+
if self.proseco and self.proseco["catalog"]:
149+
sparkles.run_aca_review(
150+
"Exploration",
151+
acars=[self.proseco["catalog"].get_review_table()],
152+
report_dir=self._dir / "sparkles",
153+
report_level="all",
154+
roll_level="none",
155+
)
156+
return self._dir / "sparkles"
157+
158+
def open_export_proseco_dialog(self):
159+
"""
160+
Save the star catalog in a pickle file.
161+
"""
162+
if self.proseco:
163+
catalog = self.proseco["catalog"]
164+
dialog = QtW.QFileDialog(
165+
self,
166+
"Export Pickle",
167+
str(self.outdir / f"aperoll-obsid_{catalog.obsid:.0f}.pkl"),
168+
)
169+
dialog.setAcceptMode(QtW.QFileDialog.AcceptSave)
170+
dialog.setDefaultSuffix("pkl")
171+
rc = dialog.exec()
172+
if rc:
173+
self.export_proseco(dialog.selectedFiles()[0])
174+
175+
def open_export_sparkles_dialog(self):
176+
"""
177+
Save the sparkles report to a tarball.
178+
"""
179+
if self.sparkles:
180+
catalog = self.proseco["catalog"]
181+
# for some reason, the extension hidden but it works
182+
dialog = QtW.QFileDialog(
183+
self,
184+
"Export Pickle",
185+
str(self.outdir / f"aperoll-obsid_{catalog.obsid:.0f}.tgz"),
186+
)
187+
dialog.setAcceptMode(QtW.QFileDialog.AcceptSave)
188+
dialog.setDefaultSuffix(".tgz")
189+
rc = dialog.exec()
190+
if rc:
191+
self.export_sparkles(dialog.selectedFiles()[0])

‎aperoll/scripts/aperoll_main.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from PyQt5 import QtWidgets as QtW
55

66
from aperoll.utils import AperollException, logger
7-
from aperoll.widgets.main_window import MainWindow
7+
from aperoll.widgets.proseco_view import ProsecoView
88

99

1010
def get_parser():
@@ -14,7 +14,7 @@ def get_parser():
1414
parse.add_argument("file", nargs="?", default=None)
1515
parse.add_argument("--obsid", help="Specify the OBSID", type=int)
1616
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
17-
levels = [lvl.lower() for lvl in levels]
17+
levels += [lvl.lower() for lvl in levels]
1818
parse.add_argument(
1919
"--log-level", help="Set the log level", default="INFO", choices=levels
2020
)
@@ -25,11 +25,12 @@ def main():
2525
parser = get_parser()
2626
args = parser.parse_args()
2727

28-
logger.setLevel(args.log_level)
28+
logger.setLevel(args.log_level.upper())
2929

3030
try:
3131
app = QtW.QApplication([])
32-
w = MainWindow(opts=vars(args))
32+
w = QtW.QMainWindow()
33+
w.setCentralWidget(ProsecoView(opts=vars(args)))
3334
w.resize(1500, 1000)
3435
w.show()
3536
app.exec()

‎aperoll/widgets/attitude_widget.py

+366
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
import json
2+
from enum import Enum
3+
4+
import numpy as np
5+
from cxotime import CxoTime
6+
from PyQt5 import QtCore as QtC
7+
from PyQt5 import QtGui as QtG
8+
from PyQt5 import QtWidgets as QtW
9+
from Quaternion import Quat, normalize
10+
from ska_sun import get_sun_pitch_yaw, off_nominal_roll
11+
12+
from aperoll.widgets.error_message import ErrorMessage
13+
14+
15+
class QuatRepresentation(Enum):
16+
QUATERNION = "Quaternion"
17+
EQUATORIAL = "Equatorial"
18+
SUN = "Sun Position"
19+
20+
21+
def stack(layout, *items, spacing=None, stretch=False):
22+
if spacing is not None:
23+
layout.setSpacing(spacing)
24+
for item in items:
25+
if isinstance(item, QtW.QWidget):
26+
layout.addWidget(item)
27+
elif isinstance(item, QtW.QLayout):
28+
layout.addLayout(item)
29+
elif isinstance(item, QtW.QSpacerItem):
30+
layout.addItem(item)
31+
else:
32+
print(f"unknown type {type(item)}")
33+
if stretch:
34+
layout.addStretch()
35+
return layout
36+
37+
38+
def hstack(*items, **kwargs):
39+
return stack(QtW.QHBoxLayout(), *items, **kwargs)
40+
41+
42+
def vstack(*items, **kwargs):
43+
return stack(QtW.QVBoxLayout(), *items, **kwargs)
44+
45+
46+
class TextEdit(QtW.QTextEdit):
47+
values_changed = QtC.pyqtSignal(list)
48+
49+
def __init__(self, size=4, digits=12, width=None, parent=None):
50+
super().__init__(parent=parent)
51+
self.installEventFilter(self)
52+
self.setSizePolicy(
53+
QtW.QSizePolicy(
54+
QtW.QSizePolicy.MinimumExpanding,
55+
# QtW.QSizePolicy.Fixed
56+
QtW.QSizePolicy.Ignored,
57+
)
58+
)
59+
width = width or digits
60+
font = self.font()
61+
font_size = QtG.QFontMetrics(font).width("M")
62+
63+
self.setMinimumWidth(width * font_size)
64+
self.setMinimumHeight(size * 20)
65+
66+
self.fmt = f"{{:.{digits}f}}"
67+
self.length = size
68+
self._vals = None
69+
70+
self.reset()
71+
72+
def sizeHint(self):
73+
return QtC.QSize(125, 20)
74+
75+
def get_values(self):
76+
return self._vals
77+
78+
def set_values(self, values):
79+
if values is None:
80+
self.reset()
81+
return
82+
if not hasattr(values, "__iter__"):
83+
raise ValueError("values must be an iterable")
84+
values = np.array(values)
85+
if len(values) != self.length:
86+
raise ValueError(f"expected {self.length} values, got {len(values)}")
87+
if np.all(values == self._vals):
88+
return
89+
self._vals = values
90+
self._display_values()
91+
92+
values = property(get_values, set_values)
93+
94+
def _parse_values(self, text):
95+
"""
96+
Parse a string to get the values.
97+
98+
The string usually comes from the text box, or from the clipboard.
99+
"""
100+
# we expect a string of floats separated by commas or whitespace with length == self.length
101+
unknown = set(text) - set("-e0123456789., \n\t")
102+
if unknown:
103+
raise ValueError(f"invalid characters: {unknown}")
104+
vals = [float(s.strip()) for s in text.replace(",", " ").split()]
105+
if len(vals) != self.length:
106+
raise ValueError(f"expected {self.length} values, got {len(vals)}")
107+
return vals
108+
109+
def _update_values(self):
110+
# take the text, parse it, and set the values
111+
try:
112+
vals = self._parse_values(self.toPlainText())
113+
self._vals = vals
114+
pos = self.textCursor().position()
115+
self._display_values()
116+
cursor = self.textCursor()
117+
cursor.setPosition(pos)
118+
self.setTextCursor(cursor)
119+
self.values_changed.emit(self._vals)
120+
except ValueError as exc:
121+
error_dialog = ErrorMessage(title="Value Error", message=str(exc))
122+
error_dialog.exec()
123+
124+
def _display_values(self):
125+
"""
126+
Display the values in the text box.
127+
"""
128+
text = "\n".join(self.fmt.format(v) for v in self._vals)
129+
self.setPlainText(text)
130+
131+
def reset(self):
132+
"""
133+
Clear the contents of the text box and set the values to None.
134+
"""
135+
self._vals = None
136+
self.setPlainText("\n".join("" for _ in range(self.length)))
137+
138+
def focusOutEvent(self, event):
139+
super().focusOutEvent(event)
140+
# originally I had the following, but this causes a horrible error on exit which I still
141+
# need to investigate
142+
# self._update_values()
143+
144+
def keyPressEvent(self, event):
145+
"""
146+
Listen for Key_Return to save and escape to discard changes.
147+
"""
148+
if event.key() == QtC.Qt.Key_Return:
149+
self._update_values()
150+
elif event.key() == QtC.Qt.Key_Escape:
151+
# discard any changes to the text box
152+
self._display_values()
153+
elif event.matches(QtG.QKeySequence.Copy):
154+
# copy the selected text (if it is selected) or all values to the clipboard
155+
# when copying all the values, they are converted to a json string
156+
cursor = self.textCursor()
157+
if cursor.hasSelection():
158+
text = cursor.selectedText()
159+
QtW.QApplication.clipboard().setText(text)
160+
else:
161+
vals = self._parse_values(self.toPlainText())
162+
text = json.dumps(vals)
163+
QtW.QApplication.clipboard().setText(text)
164+
else:
165+
return super().keyPressEvent(event)
166+
167+
def insertFromMimeData(self, data):
168+
"""
169+
Insert data from the clipboard.
170+
"""
171+
try:
172+
# if this succeeds, presumably we are pasting the whole thing, so values are set
173+
vals = json.loads(data.text())
174+
self.set_values(vals)
175+
except ValueError:
176+
# if it fails, paste it and the user can edit it
177+
self.insertPlainText(data.text())
178+
179+
180+
class AttitudeWidget(QtW.QWidget):
181+
attitude_changed = QtC.pyqtSignal(Quat)
182+
attitude_cleared = QtC.pyqtSignal()
183+
184+
def __init__(self, parent=None, columns=None):
185+
super(AttitudeWidget, self).__init__(parent)
186+
187+
if columns is None:
188+
columns = {
189+
QuatRepresentation.QUATERNION: 0,
190+
QuatRepresentation.EQUATORIAL: 1,
191+
QuatRepresentation.SUN: 2,
192+
}
193+
194+
self._q = TextEdit()
195+
self._eq = TextEdit(size=3, digits=5, width=8)
196+
self._sun_pos = TextEdit(size=3, digits=5, width=8)
197+
198+
self._q.values_changed.connect(self._set_attitude)
199+
self._eq.values_changed.connect(self._set_attitude)
200+
201+
self._sun_pos.setReadOnly(True)
202+
203+
self._set_layout(columns)
204+
205+
self._attitude = None
206+
self._date = None
207+
208+
def _set_layout(self, columns):
209+
layout = QtW.QHBoxLayout()
210+
self.setLayout(layout)
211+
212+
layout_q = vstack(
213+
hstack(
214+
vstack(
215+
QtW.QSpacerItem(0, 5),
216+
QtW.QLabel("Q1"),
217+
QtW.QLabel("Q2"),
218+
QtW.QLabel("Q3"),
219+
QtW.QLabel("Q4"),
220+
spacing=0,
221+
stretch=True,
222+
),
223+
vstack(
224+
self._q,
225+
spacing=0,
226+
# stretch=True,
227+
),
228+
),
229+
stretch=True,
230+
)
231+
232+
layout_eq = vstack(
233+
hstack(
234+
vstack(
235+
QtW.QSpacerItem(0, 3),
236+
QtW.QLabel("ra "),
237+
QtW.QLabel("dec "),
238+
QtW.QLabel("roll"),
239+
spacing=0,
240+
stretch=True,
241+
),
242+
vstack(
243+
self._eq,
244+
spacing=0,
245+
# stretch=True,
246+
),
247+
),
248+
stretch=True,
249+
)
250+
251+
layout_sun = vstack(
252+
hstack(
253+
vstack(
254+
QtW.QSpacerItem(0, 3),
255+
QtW.QLabel("pitch"),
256+
QtW.QLabel("yaw"),
257+
QtW.QLabel("roll"),
258+
spacing=0,
259+
stretch=True,
260+
),
261+
vstack(
262+
self._sun_pos,
263+
spacing=0,
264+
# stretch=True,
265+
),
266+
),
267+
stretch=True,
268+
)
269+
270+
layouts = {
271+
QuatRepresentation.QUATERNION: layout_q,
272+
QuatRepresentation.EQUATORIAL: layout_eq,
273+
QuatRepresentation.SUN: layout_sun,
274+
}
275+
name = {
276+
QuatRepresentation.QUATERNION: "Quaternion",
277+
QuatRepresentation.EQUATORIAL: "Equatorial",
278+
QuatRepresentation.SUN: "Sun",
279+
}
280+
281+
self.tab_widgets = {
282+
col: QtW.QTabWidget() for col in set(columns.values()) if col is not None
283+
}
284+
285+
for representation, col in columns.items():
286+
if col is None:
287+
continue
288+
w = QtW.QWidget()
289+
w.setLayout(layouts[representation])
290+
self.tab_widgets[col].addTab(w, name[representation])
291+
292+
for widget in self.tab_widgets.values():
293+
layout.addWidget(widget)
294+
widget.setCurrentIndex(0)
295+
296+
self.update()
297+
298+
def get_attitude(self):
299+
return self._attitude
300+
301+
def set_attitude(self, attitude):
302+
self._set_attitude(attitude, emit=False)
303+
304+
def _set_attitude(self, attitude, emit=True):
305+
# work around the requirement that q be normalized
306+
if (
307+
attitude is not None
308+
and not isinstance(attitude, Quat)
309+
and len(attitude) == 4
310+
):
311+
attitude = normalize(attitude)
312+
# this check is to break infinite recursion because in the connections
313+
q1 = None if attitude is None else Quat(attitude).q
314+
q2 = None if self._attitude is None else self._attitude.q
315+
if np.any(q1 != q2):
316+
self._attitude = Quat(attitude)
317+
self._display_attitude_at_date(self._attitude, self._date)
318+
if emit:
319+
if attitude is None:
320+
self.attitude_cleared.emit()
321+
else:
322+
self.attitude_changed.emit(self._attitude)
323+
324+
attitude = property(get_attitude, set_attitude)
325+
326+
def set_date(self, date):
327+
date = None if date is None else CxoTime(date)
328+
if self._date == date:
329+
return
330+
self._date = date
331+
self._display_attitude_at_date(self._attitude, self._date)
332+
333+
def get_date(self):
334+
return self._date
335+
336+
date = property(get_date, set_date)
337+
338+
def _display_attitude_at_date(self, attitude, date):
339+
if attitude is None:
340+
self._clear()
341+
return
342+
self._q.set_values(attitude.q)
343+
self._eq.set_values(attitude.equatorial)
344+
345+
if date is None:
346+
self._sun_pos.reset()
347+
else:
348+
pitch, yaw = get_sun_pitch_yaw(attitude.ra, attitude.dec, date)
349+
roll = off_nominal_roll(attitude, date)
350+
self._sun_pos.set_values([pitch, yaw, roll])
351+
352+
def _clear(self):
353+
self._q.reset()
354+
self._eq.reset()
355+
self._sun_pos.reset()
356+
357+
358+
if __name__ == "__main__":
359+
app = QtW.QApplication([])
360+
widget = AttitudeWidget()
361+
q = Quat([344.571937, 1.026897, 302.0])
362+
widget.set_attitude(q)
363+
widget.set_date("2021:001:00:00:00")
364+
widget.resize(1200, 200)
365+
widget.show()
366+
app.exec()

‎aperoll/widgets/error_message.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from PyQt5 import QtCore as QtC
2+
from PyQt5 import QtWidgets as QtW
3+
4+
5+
class ErrorMessage(QtW.QDialog):
6+
"""
7+
Dialog to configure data fetching.
8+
"""
9+
10+
def __init__(self, title="", message=""):
11+
QtW.QDialog.__init__(self)
12+
self.setLayout(QtW.QVBoxLayout())
13+
self.resize(QtC.QSize(400, 300))
14+
15+
text = f"""<h1> {title} </h1>
16+
17+
<p>{message}</p>
18+
"""
19+
text_box = QtW.QTextBrowser()
20+
text_box.setText(text)
21+
22+
button_box = QtW.QDialogButtonBox(QtW.QDialogButtonBox.Ok)
23+
button_box.accepted.connect(self.accept)
24+
self.layout().addWidget(text_box)
25+
self.layout().addWidget(button_box)
26+
27+
28+
if __name__ == "__main__":
29+
from aca_view.tests.utils import qt
30+
31+
with qt():
32+
app = ErrorMessage("This is the title", "This is the message")
33+
app.resize(1200, 800)
34+
app.show()

‎aperoll/widgets/json_editor.py

+163
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import json
2+
3+
from pygments import highlight
4+
from pygments.formatters import HtmlFormatter
5+
from pygments.lexers import get_lexer_by_name
6+
from PyQt5 import QtCore as QtC
7+
from PyQt5 import QtWidgets as QtW
8+
9+
from aperoll.utils import AperollException
10+
from aperoll.widgets.error_message import ErrorMessage
11+
12+
13+
class ValidationError(AperollException):
14+
pass
15+
16+
17+
class JsonEditor(QtW.QWidget):
18+
"""
19+
A widget to edit parameters.
20+
21+
The parameters are stored as a dictionary and displayed as a JSON string in a text editor.
22+
The widget provides the text editor and two buttons to save and discard changes.
23+
24+
Derived classes can override the `default_params` method to provide default parameters and
25+
the `validate` method to validate the parameters before saving. The `params_changed` signal
26+
is emitted when the parameters are saved. If there is an error in the JSON, an error dialog
27+
is shown.
28+
"""
29+
30+
params_changed = QtC.pyqtSignal(dict)
31+
32+
def __init__(self, show_buttons=False):
33+
super().__init__()
34+
35+
self.installEventFilter(self)
36+
37+
self.text_widget = QtW.QTextEdit()
38+
self.discard_button = QtW.QPushButton("Discard")
39+
self.discard_button.clicked.connect(self.display_text)
40+
self.save_button = QtW.QPushButton("Save")
41+
self.save_button.clicked.connect(self.save)
42+
43+
layout = QtW.QVBoxLayout()
44+
layout.addWidget(self.text_widget)
45+
if show_buttons:
46+
h_layout = QtW.QHBoxLayout()
47+
layout.addLayout(h_layout)
48+
h_layout.addWidget(self.discard_button)
49+
h_layout.addWidget(self.save_button)
50+
self.setLayout(layout)
51+
52+
self._parameters = {}
53+
54+
self.reset()
55+
56+
def display_text(self):
57+
"""
58+
Display the parameters in the text editor.
59+
"""
60+
lexer = get_lexer_by_name("json")
61+
formatter = HtmlFormatter(full=False)
62+
code = json.dumps(self._parameters, indent=2)
63+
args_str = highlight(code, lexer, formatter)
64+
style = formatter.get_style_defs()
65+
self.text_widget.document().setDefaultStyleSheet(
66+
style
67+
) # NOTE: not using self.styleSheet
68+
self.text_widget.setText(args_str)
69+
70+
def reset(self):
71+
"""
72+
Set the parameters to the default values.
73+
"""
74+
self._parameters = self.default_params()
75+
self.display_text()
76+
77+
def save(self):
78+
"""
79+
Set the parameter values from the text editor.
80+
"""
81+
try:
82+
params = json.loads(self.text_widget.toPlainText())
83+
self.validate(params)
84+
except json.JSONDecodeError as exc:
85+
error_dialog = ErrorMessage(title="JSON Error", message=str(exc))
86+
error_dialog.exec()
87+
return
88+
except ValidationError as exc:
89+
msg = str(exc) # .replace(",", "</br>")
90+
error_dialog = ErrorMessage(title="Validation Error", message=msg)
91+
error_dialog.exec()
92+
return
93+
if params == self._parameters:
94+
return
95+
self._parameters = params
96+
self.params_changed.emit(self.default_params())
97+
98+
def eventFilter(self, obj, event):
99+
"""
100+
Listen for ctrl-S to save and escape to discard changes.
101+
"""
102+
if obj == self and event.type() == QtC.QEvent.KeyPress:
103+
if (
104+
event.key() == QtC.Qt.Key_S
105+
and event.modifiers() == QtC.Qt.ControlModifier
106+
):
107+
self.save()
108+
return True
109+
elif (
110+
event.key() == QtC.Qt.Key_Z
111+
and event.modifiers() == QtC.Qt.ControlModifier
112+
):
113+
self.display_text()
114+
return True
115+
elif event.key() == QtC.Qt.Key_Escape:
116+
self.display_text()
117+
return True
118+
return super().eventFilter(obj, event)
119+
120+
@staticmethod
121+
def validate(params):
122+
"""
123+
Validate the parameters before saving. Raises an exception if the parameters are invalid.
124+
"""
125+
126+
@classmethod
127+
def default_params(cls):
128+
"""
129+
Default parameters to show.
130+
"""
131+
params = {}
132+
return params
133+
134+
def get_parameters(self):
135+
return self._parameters
136+
137+
def set_parameters(self, parameters):
138+
self._parameters = parameters
139+
self.params_changed.emit(self._parameters)
140+
141+
parameters = property(get_parameters, set_parameters)
142+
143+
def __getitem__(self, key):
144+
return self._parameters[key]
145+
146+
def __setitem__(self, key, value):
147+
self.set_value(key, value, emit=True)
148+
149+
def set_value(self, key, value, emit=True):
150+
if self._parameters[key] != value:
151+
self._parameters[key] = value
152+
self.display_text()
153+
if emit:
154+
self.params_changed.emit(self._parameters)
155+
156+
157+
if __name__ == "__main__":
158+
from aca_view.tests.utils import qt
159+
160+
with qt():
161+
app = JsonEditor()
162+
app.resize(1200, 800)
163+
app.show()

‎aperoll/widgets/main_window.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from .parameters import Parameters
2323
from .star_plot import StarPlot
24-
from .starcat_view import StarcatView
24+
from .starcat_review import StarcatReview
2525

2626
STYLE = """
2727
<style>
@@ -130,13 +130,13 @@ def __init__(self, opts=None): # noqa: PLR0915
130130

131131
self.plot = StarPlot()
132132
self.parameters = Parameters(**opts)
133-
self.starcat_view = StarcatView()
133+
self.starcat_review = StarcatReview()
134134

135135
layout = QtW.QVBoxLayout(self._main)
136136
layout_2 = QtW.QHBoxLayout()
137137

138138
layout.addWidget(self.parameters)
139-
layout_2.addWidget(self.starcat_view)
139+
layout_2.addWidget(self.starcat_review)
140140
layout_2.addWidget(self.plot)
141141
layout.addLayout(layout_2)
142142

@@ -181,7 +181,7 @@ def __init__(self, opts=None): # noqa: PLR0915
181181

182182
if starcat is not None:
183183
self.plot.set_catalog(starcat)
184-
self.starcat_view.set_catalog(aca)
184+
self.starcat_review.set_catalog(aca)
185185
# make sure the catalog is not overwritten automatically
186186
self.plot.scene.state.auto_proseco = False
187187

@@ -215,7 +215,7 @@ def _init(self):
215215

216216
def _reset(self):
217217
self.parameters.set_parameters(**self.opts)
218-
self.starcat_view.reset()
218+
self.starcat_review.reset()
219219
self._data.reset(self.parameters.proseco_args())
220220
self._init()
221221

@@ -236,7 +236,7 @@ def _run_proseco(self):
236236
Display the star catalog.
237237
"""
238238
if self._data.proseco:
239-
self.starcat_view.set_catalog(self._data.proseco["aca"])
239+
self.starcat_review.set_catalog(self._data.proseco["aca"])
240240
self.plot.set_catalog(self._data.proseco["catalog"])
241241

242242
def _export_proseco(self):

‎aperoll/widgets/proseco_params.py

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from cxotime import CxoTime
2+
from Quaternion import Quat
3+
4+
from aperoll.widgets.json_editor import JsonEditor, ValidationError
5+
6+
7+
class ProsecoParams(JsonEditor):
8+
"""
9+
A widget to edit Proseco parameters.
10+
"""
11+
12+
def __init__(self, show_buttons=False):
13+
super().__init__(show_buttons)
14+
self.setMinimumHeight(400)
15+
16+
@classmethod
17+
def default_params(cls):
18+
params = {
19+
"date": None,
20+
"att": None,
21+
"detector": None,
22+
"sim_offset": None,
23+
"focus_offset": None,
24+
"t_ccd_acq": None,
25+
"t_ccd_guide": None,
26+
"obsid": 0,
27+
"man_angle": 0,
28+
"dither_acq": (16, 16),
29+
"dither_guide": (16, 16),
30+
"n_acq": 8,
31+
"n_fid": 0,
32+
"n_guide": 8,
33+
"exclude_ids_acq": [],
34+
"include_ids_acq": [],
35+
"exclude_ids_guide": [],
36+
"include_ids_guide": [],
37+
# "monitors": None, # proseco chokes on this
38+
}
39+
return params
40+
41+
@staticmethod
42+
def validate(params):
43+
errors = []
44+
if params["date"] is None:
45+
errors.append("No date")
46+
if params["att"] is None:
47+
errors.append("No attitude")
48+
if params["detector"] is None:
49+
errors.append("No detector")
50+
if errors:
51+
raise ValidationError(", ".join(errors))
52+
53+
# the following are methods to set some parameters that can be set in other parts of the GUI:
54+
# - the user can drag the star view to set the attitude,
55+
# - the attitude, the date, t_ccd and maybe others can be set from telemetry
56+
# - excluded/included stars can be set from the star view
57+
#
58+
# They have the option to skip the params_changed signal.
59+
60+
def set_date(self, date, emit=True):
61+
self.set_value("date", CxoTime(date).date, emit=emit)
62+
63+
def get_date(self):
64+
return CxoTime(self["date"])
65+
66+
date = property(get_date, set_date)
67+
68+
def set_attitude(self, attitude, emit=True):
69+
# calling self.set_value so I can skip emitting the signal
70+
self.set_value("att", Quat(attitude).equatorial.tolist(), emit=emit)
71+
72+
def get_attitude(self):
73+
if self["att"] is not None:
74+
return Quat(self["att"])
75+
76+
attitude = property(get_attitude, set_attitude)
77+
78+
def set_detector(self, instrument, emit=True):
79+
self.set_value("detector", instrument, emit=emit)
80+
81+
def set_instrument(self, instrument, emit=True):
82+
self.set_value("detector", instrument, emit=emit)
83+
84+
def set_t_ccd(self, t_ccd, emit=True):
85+
self.set_value("t_ccd_guide", float(t_ccd), emit=emit)
86+
self.set_value("t_ccd_acq", float(t_ccd), emit=emit)
87+
88+
def set_t_ccd_acq(self, t_ccd, emit=True):
89+
self.set_value("t_ccd_acq", float(t_ccd), emit=emit)
90+
91+
def set_t_ccd_guide(self, t_ccd, emit=True):
92+
self.set_value("t_ccd_guide", float(t_ccd), emit=emit)
93+
94+
def set_obsid(self, obsid, emit=True):
95+
# convenience method that should work with 1234 or "1234" or "1234.0" or 1234.1
96+
self.set_value("obsid", int(float(obsid) // 1), emit=emit)
97+
98+
def include_star(self, star, type, include):
99+
"""
100+
Force-include/exclude stars from the acq or guide list.
101+
102+
Note that the state for a particular star is True, False or None. The possibilities are:
103+
104+
- `include` is True: star will be included.
105+
- `include` is False: star will be excluded.
106+
- `include` is None: star will be neither included nor excluded.
107+
108+
Parameters
109+
----------
110+
star : int
111+
The AGASC star ID.
112+
type : str
113+
Either "acq" or "guide".
114+
include : bool
115+
Whether to include or exclude the star. True, False or None.
116+
"""
117+
if include is True:
118+
if star not in self._parameters[f"include_ids_{type}"]:
119+
self._parameters[f"include_ids_{type}"].append(star)
120+
if star in self._parameters[f"exclude_ids_{type}"]:
121+
self._parameters[f"exclude_ids_{type}"].remove(star)
122+
elif include is False:
123+
if star in self._parameters[f"include_ids_{type}"]:
124+
self._parameters[f"include_ids_{type}"].remove(star)
125+
if star not in self._parameters[f"exclude_ids_{type}"]:
126+
self._parameters[f"exclude_ids_{type}"].append(star)
127+
else:
128+
if star in self._parameters[f"include_ids_{type}"]:
129+
self._parameters[f"include_ids_{type}"].remove(star)
130+
if star in self._parameters[f"exclude_ids_{type}"]:
131+
self._parameters[f"exclude_ids_{type}"].remove(star)
132+
133+
134+
if __name__ == "__main__":
135+
from aca_view.tests.utils import qt
136+
137+
with qt():
138+
app = ProsecoParams()
139+
app.resize(1200, 800)
140+
app.show()

‎aperoll/widgets/proseco_view.py

+294
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import functools
2+
from pprint import pformat
3+
4+
import PyQt5.QtWebEngineWidgets as QtWe
5+
from PyQt5 import QtCore as QtC
6+
from PyQt5 import QtWidgets as QtW
7+
from Quaternion import Quat
8+
9+
from aperoll.proseco_data import ProsecoData
10+
from aperoll.utils import logger
11+
from aperoll.widgets.attitude_widget import (
12+
AttitudeWidget,
13+
QuatRepresentation,
14+
)
15+
from aperoll.widgets.error_message import ErrorMessage
16+
from aperoll.widgets.parameters import (
17+
LineEdit,
18+
get_default_parameters,
19+
get_parameters_from_pickle,
20+
get_parameters_from_yoshi,
21+
)
22+
from aperoll.widgets.proseco_params import ProsecoParams
23+
from aperoll.widgets.star_plot import StarPlot
24+
from aperoll.widgets.starcat_review import StarcatReview
25+
26+
27+
class WebPage(QtWe.QWebEnginePage):
28+
def __init__(self, parent=None):
29+
super().__init__(parent)
30+
31+
# trick from https://stackoverflow.com/questions/54920726/how-make-any-link-blank-open-in-same-window-using-qwebengine
32+
def createWindow(self, _type):
33+
page = WebPage(self)
34+
page.urlChanged.connect(self.on_url_changed)
35+
return page
36+
37+
@QtC.pyqtSlot(QtC.QUrl)
38+
def on_url_changed(self, url):
39+
page = self.sender()
40+
self.setUrl(url)
41+
page.deleteLater()
42+
43+
44+
class ProsecoView(QtW.QWidget):
45+
def __init__(self, opts=None): # noqa: PLR0915
46+
super().__init__()
47+
opts = {} if opts is None else opts
48+
opts = {k: opts[k] for k in opts if opts[k] is not None}
49+
50+
self.create_widgets()
51+
self.set_connections()
52+
self.set_layout()
53+
self.add_menu()
54+
55+
self.opts = opts
56+
self.set_parameters(**self.opts)
57+
58+
self._auto_proseco()
59+
60+
def add_menu(self):
61+
"""
62+
Add menu actions to toolbar.
63+
"""
64+
application = QtW.QApplication.instance()
65+
main_windows = [
66+
w for w in application.topLevelWidgets() if isinstance(w, QtW.QMainWindow)
67+
]
68+
for window in main_windows:
69+
menu_bar = window.menuBar()
70+
actions = [
71+
action
72+
for action in menu_bar.actions()
73+
if action.text().replace("&", "") == "File"
74+
]
75+
if actions:
76+
file_menu = actions[0].menu()
77+
else:
78+
file_menu = menu_bar.addMenu("&File")
79+
export_action = QtW.QAction("&Export Pickle", self)
80+
export_action.triggered.connect(self.data.export_proseco_dialog)
81+
file_menu.addAction(export_action)
82+
export_action = QtW.QAction("&Export Sparkles", self)
83+
export_action.triggered.connect(self.data.export_sparkles_dialog)
84+
file_menu.addAction(export_action)
85+
86+
def create_widgets(self):
87+
"""
88+
Creates all the widgets.
89+
"""
90+
self.data = ProsecoData()
91+
92+
self.star_plot = StarPlot(self)
93+
self.star_plot.scene.state = "Proseco"
94+
95+
self.starcat_review = StarcatReview()
96+
self.date_edit = LineEdit(self)
97+
self.obsid_edit = LineEdit(self)
98+
self.attitude_widget = AttitudeWidget(
99+
self,
100+
columns={
101+
QuatRepresentation.EQUATORIAL: 0,
102+
QuatRepresentation.QUATERNION: 0,
103+
QuatRepresentation.SUN: 0,
104+
},
105+
)
106+
self.proseco_params_widget = ProsecoParams()
107+
self.instrument_edit = QtW.QComboBox(self)
108+
self.instrument_edit.addItems(["ACIS-S", "ACIS-I", "HRC-S", "HRC-I"])
109+
110+
self.get_catalog_button = QtW.QPushButton("Get Catalog")
111+
self.reset_button = QtW.QPushButton("Reset")
112+
self.run_sparkles_button = QtW.QPushButton("Run Sparkles")
113+
114+
def set_connections(self):
115+
"""
116+
Connects signals to slots.
117+
"""
118+
# connect proseco params to all other widgets
119+
# this is the main connection, when the proseco params change, all widgets update,
120+
# proseco can be called, and whatever else.
121+
self.proseco_params_widget.params_changed.connect(self._values_changed)
122+
123+
# connect widgets to proseco params
124+
self.date_edit.value_changed.connect(self.proseco_params_widget.set_date)
125+
self.attitude_widget.attitude_changed.connect(
126+
self.proseco_params_widget.set_attitude
127+
)
128+
self.obsid_edit.value_changed.connect(
129+
functools.partial(self.proseco_params_widget.set_obsid)
130+
)
131+
self.instrument_edit.currentTextChanged.connect(
132+
functools.partial(self.proseco_params_widget.__setitem__, "detector")
133+
)
134+
135+
# Special case when we drag the star field. We do not want to trigger a new proseco call
136+
# so we skip emitting the params_changed signal
137+
self.star_plot.attitude_changed.connect(
138+
lambda att: self.proseco_params_widget.set_attitude(att, emit=False)
139+
)
140+
# and because we skipped the connection, we explicitly connect the star plot to the attitude
141+
# widget
142+
self.star_plot.attitude_changed.connect(self.attitude_widget.set_attitude)
143+
144+
# connect buttons
145+
self.get_catalog_button.clicked.connect(self._get_catalog)
146+
self.reset_button.clicked.connect(self._reset)
147+
self.run_sparkles_button.clicked.connect(self._run_sparkles)
148+
149+
# auto-update catalog whenever the attitude changes (other than in the proseco params)
150+
self.star_plot.update_proseco.connect(self._auto_proseco)
151+
self.attitude_widget.attitude_changed.connect(self._auto_proseco)
152+
153+
def set_layout(self):
154+
"""
155+
Arranges the widgets.
156+
"""
157+
v_layout = QtW.QVBoxLayout()
158+
159+
general_info_layout = QtW.QGridLayout()
160+
general_info_layout.addWidget(QtW.QLabel("OBSID"), 0, 0, 1, 1)
161+
general_info_layout.addWidget(self.obsid_edit, 0, 1, 1, 2)
162+
general_info_layout.addWidget(QtW.QLabel("date"), 1, 0, 1, 1)
163+
general_info_layout.addWidget(self.date_edit, 1, 1, 1, 2)
164+
general_info_layout.addWidget(QtW.QLabel("instrument"), 2, 0, 1, 1)
165+
general_info_layout.addWidget(self.instrument_edit, 2, 1, 1, 2)
166+
167+
v_layout.addLayout(general_info_layout, 1)
168+
v_layout.addWidget(QtW.QLabel("Attitude"))
169+
v_layout.addWidget(self.attitude_widget, 0)
170+
v_layout.addWidget(QtW.QLabel("Proseco parameters"))
171+
v_layout.addWidget(self.proseco_params_widget, 1)
172+
173+
controls_group_box = QtW.QGroupBox()
174+
controls_group_box_layout = QtW.QHBoxLayout()
175+
controls_group_box_layout.addWidget(self.get_catalog_button)
176+
controls_group_box_layout.addWidget(self.run_sparkles_button)
177+
controls_group_box_layout.addWidget(self.reset_button)
178+
controls_group_box.setLayout(controls_group_box_layout)
179+
180+
v_layout.addWidget(controls_group_box)
181+
182+
layout = QtW.QHBoxLayout()
183+
layout.addLayout(v_layout, 1)
184+
layout.addWidget(self.starcat_review, 4)
185+
layout.addWidget(self.star_plot, 4)
186+
187+
self.setLayout(layout)
188+
189+
def set_parameters(self, **kwargs):
190+
"""
191+
Set the initial parameters.
192+
"""
193+
if "file" in kwargs and (
194+
kwargs["file"].endswith(".pkl") or kwargs["file"].endswith(".pkl.gz")
195+
):
196+
logger.debug(f"Loading parameters from {kwargs['file']}")
197+
params = get_parameters_from_pickle(
198+
kwargs["file"], obsid=kwargs.get("obsid", None)
199+
)
200+
elif "file" in kwargs and kwargs["file"].endswith(".json"):
201+
logger.debug(f"Loading parameters from {kwargs['file']}")
202+
params = get_parameters_from_yoshi(
203+
kwargs["file"], obsid=kwargs.get("obsid", None)
204+
)
205+
else:
206+
logger.debug(f"Using default parameters (file={kwargs.get('file', None)})")
207+
params = get_default_parameters()
208+
# obsid is a command-line argument, so I set it here
209+
if "obsid" in kwargs:
210+
params["obsid"] = kwargs["obsid"]
211+
212+
logger.debug(pformat(params))
213+
self.proseco_params_widget.date = params["date"]
214+
self.proseco_params_widget.attitude = Quat(
215+
[params["ra"], params["dec"], params["roll"]]
216+
)
217+
self.proseco_params_widget["obsid"] = int(params["obsid"])
218+
self.proseco_params_widget["detector"] = params["instrument"]
219+
self.proseco_params_widget["t_ccd_acq"] = params["t_ccd"]
220+
self.proseco_params_widget["t_ccd_guide"] = params["t_ccd"]
221+
self.proseco_params_widget["man_angle"] = params["man_angle"]
222+
self.proseco_params_widget["dither_acq"] = (
223+
params["dither_acq_y"],
224+
params["dither_acq_z"],
225+
)
226+
self.proseco_params_widget["dither_guide"] = (
227+
params["dither_guide_y"],
228+
params["dither_guide_z"],
229+
)
230+
self.proseco_params_widget["n_acq"] = int(params.get("n_acq", 8))
231+
self.proseco_params_widget["n_fid"] = int(params.get("n_fid", 0))
232+
self.proseco_params_widget["n_guide"] = int(params.get("n_guide", 8))
233+
self.proseco_params_widget["sim_offset"] = int(params.get("sim_offset", 0))
234+
self.proseco_params_widget["focus_offset"] = int(params.get("focus_offset", 0))
235+
236+
def _values_changed(self):
237+
self.star_plot.set_time(self.proseco_params_widget["date"])
238+
self.star_plot.set_base_attitude(self.proseco_params_widget.attitude)
239+
self.obsid_edit.setText(f"{self.proseco_params_widget['obsid']}")
240+
self.date_edit.setText(self.proseco_params_widget["date"])
241+
self.attitude_widget.date = self.proseco_params_widget.date
242+
self.attitude_widget.attitude = self.proseco_params_widget.attitude
243+
self.instrument_edit.setCurrentText(self.proseco_params_widget["detector"])
244+
245+
if self.star_plot.scene.state.auto_proseco:
246+
self._auto_proseco()
247+
248+
def _auto_proseco(self):
249+
self._get_catalog(interactive=False)
250+
251+
def _get_catalog(self, interactive=True):
252+
try:
253+
self.data.parameters = self.proseco_params_widget._parameters
254+
if self.data.proseco:
255+
self.starcat_review.set_catalog(self.data.proseco["review_table"])
256+
self.star_plot.set_catalog(self.data.proseco["catalog"])
257+
except Exception as exc:
258+
if interactive:
259+
msg = ErrorMessage(title="Error", message=str(exc))
260+
msg.exec()
261+
262+
def _reset(self):
263+
self.set_parameters(**self.opts)
264+
self.starcat_review.reset()
265+
266+
def _run_sparkles(self):
267+
if self.data.sparkles:
268+
try:
269+
w = QtW.QMainWindow(self)
270+
w.resize(1400, 1000)
271+
web = QtWe.QWebEngineView(w)
272+
w.setCentralWidget(web)
273+
self.web_page = WebPage()
274+
web.setPage(self.web_page)
275+
url = self.data.sparkles / "index.html"
276+
web.load(QtC.QUrl(f"file://{url}"))
277+
web.show()
278+
w.show()
279+
except Exception as e:
280+
logger.warning(e)
281+
282+
283+
if __name__ == "__main__":
284+
# this is just a simple and quick way to test the widget, not intended for normal use.
285+
import sys
286+
287+
kwargs = {"file": sys.argv[1]} if sys.argv[1:] else {}
288+
logger.setLevel("INFO")
289+
app = QtW.QApplication([])
290+
widget = QtW.QMainWindow()
291+
widget.setCentralWidget(ProsecoView(**kwargs))
292+
widget.resize(1400, 800)
293+
widget.show()
294+
app.exec()

‎aperoll/widgets/starcat_view.py ‎aperoll/widgets/starcat_review.py

+35-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# from PyQt5 import QtCore as QtC, QtWidgets as QtW, QtGui as QtG
2+
import traceback
3+
24
import PyQt5.QtGui as QtG
35
import PyQt5.QtWebEngineWidgets as QtWe
46
import PyQt5.QtWidgets as QtW
57
from PyQt5 import QtCore as QtC
8+
from sparkles.core import ACAReviewTable, check_catalog
69

710
STYLE = """
811
<style>
@@ -85,7 +88,7 @@ def on_url_changed(self, url):
8588
page.deleteLater()
8689

8790

88-
class StarcatView(QtW.QTextEdit):
91+
class StarcatReview(QtW.QTextEdit):
8992
def __init__(self, catalog=None, parent=None):
9093
super().__init__(parent)
9194
font = QtG.QFont("Courier New") # setting a fixed-width font (close enough)
@@ -101,7 +104,37 @@ def set_catalog(self, catalog):
101104
if catalog is None:
102105
self.setText("")
103106
else:
104-
self.setText(f"{STYLE}<pre>{catalog.get_text_pre()}</pre>")
107+
try:
108+
self.the_cat = catalog
109+
if not (
110+
catalog.acqs
111+
and catalog.guides
112+
and catalog.dither_acq
113+
and catalog.dither_guide
114+
):
115+
lines = catalog.pformat()
116+
lines += [
117+
"\n\n<span class='critical'> No review performed "
118+
"(acqs/guides are empty or dither_acq/guide are None) <span>"
119+
]
120+
text = "\n".join(lines)
121+
else:
122+
aca = ACAReviewTable(catalog)
123+
check_catalog(aca)
124+
text = aca.get_text_pre()
125+
except Exception as exc:
126+
lines = [
127+
"<span class='critical'>A review could not be performed:</span>",
128+
"",
129+
f'<span class="critical"> {type(exc).__name__} {exc} </span>',
130+
]
131+
trace = traceback.extract_tb(exc.__traceback__)
132+
for step in trace:
133+
lines.append(f" in {step.filename}:{step.lineno}/{step.name}:")
134+
lines.append(f" {step.line}")
135+
text = "\n".join(lines)
136+
137+
self.setText(f"{STYLE}<pre>{text}</pre>")
105138

106139
def resizeEvent(self, _size):
107140
super().resizeEvent(_size)

0 commit comments

Comments
 (0)
Please sign in to comment.