From 2a885179f56b4ee6df4546c021a6fbd29120f74d Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 27 Nov 2024 12:46:04 -0500 Subject: [PATCH] add proseco parameters widget, attitude widget, aperoll/widgets/proseco_view.py and aperoll/proseco_data.py --- aperoll/proseco_data.py | 191 +++++++++ aperoll/scripts/aperoll_main.py | 9 +- aperoll/widgets/attitude_widget.py | 366 ++++++++++++++++++ aperoll/widgets/error_message.py | 34 ++ aperoll/widgets/json_editor.py | 163 ++++++++ aperoll/widgets/main_window.py | 12 +- aperoll/widgets/proseco_params.py | 140 +++++++ aperoll/widgets/proseco_view.py | 294 ++++++++++++++ .../{starcat_view.py => starcat_review.py} | 37 +- 9 files changed, 1234 insertions(+), 12 deletions(-) create mode 100644 aperoll/proseco_data.py create mode 100644 aperoll/widgets/attitude_widget.py create mode 100644 aperoll/widgets/error_message.py create mode 100644 aperoll/widgets/json_editor.py create mode 100644 aperoll/widgets/proseco_params.py create mode 100644 aperoll/widgets/proseco_view.py rename aperoll/widgets/{starcat_view.py => starcat_review.py} (66%) diff --git a/aperoll/proseco_data.py b/aperoll/proseco_data.py new file mode 100644 index 0000000..5c602d5 --- /dev/null +++ b/aperoll/proseco_data.py @@ -0,0 +1,191 @@ +# from PyQt5 import QtCore as QtC, QtWidgets as QtW, QtGui as QtG +import os +import pickle +import tarfile +from pathlib import Path +from tempfile import TemporaryDirectory + +import PyQt5.QtWidgets as QtW +import sparkles +from proseco import get_aca_catalog +from ska_helpers import utils + + +class CachedVal: + def __init__(self, func): + self._func = func + self.reset() + + def reset(self): + self._value = utils.LazyVal(self._func) + + @property + def val(self): + return self._value.val + + +class ProsecoData: + """ + Class to deal with calling Proseco/Sparkles, temporary directories and exporting the results. + + Parameters + ---------- + parameters : dict + The parameters to pass to Proseco. Optional. + """ + + def __init__(self, parameters=None) -> None: + self._proseco = CachedVal(self.run_proseco) + self._sparkles = CachedVal(self.run_sparkles) + self._parameters = parameters + self._tmp_dir = TemporaryDirectory() + self._dir = Path(self._tmp_dir.name) + + def reset(self, parameters): + self._parameters = parameters + self._proseco.reset() + self._sparkles.reset() + + @property + def proseco(self): + return self._proseco.val + + @property + def sparkles(self): + return self._sparkles.val + + def set_parameters(self, parameters): + self.reset(parameters.copy()) + + def get_parameters(self): + return self._parameters + + parameters = property(get_parameters, set_parameters) + + def export_proseco(self, outfile=None): + if self.proseco and self.proseco["catalog"]: + catalog = self.proseco["catalog"] + if outfile is None: + outfile = f"aperoll-proseco-obsid_{catalog.obsid:.0f}.pkl" + with open(outfile, "wb") as fh: + pickle.dump({catalog.obsid: catalog}, fh) + + def export_sparkles(self, outfile=None): + if self.sparkles: + if outfile is None: + catalog = self.proseco["catalog"] + outfile = Path(f"aperoll-sparkles-obsid_{catalog.obsid:.0f}.tar.gz") + dest = Path(str(outfile).replace(".tar", "").replace(".gz", "")) + with tarfile.open(outfile, "w") as tar: + for name in self.sparkles.glob("**/*"): + tar.add( + name, + arcname=dest / name.relative_to(self._dir / "sparkles"), + ) + + def export_proseco_dialog(self): + """ + Save the star catalog in a pickle file. + """ + if self.proseco: + catalog = self.proseco["catalog"] + dialog = QtW.QFileDialog( + caption="Export Pickle", + directory=str( + Path(os.getcwd()) / f"aperoll-proseco-obsid_{catalog.obsid:.0f}.pkl" + ), + ) + dialog.setAcceptMode(QtW.QFileDialog.AcceptSave) + dialog.setDefaultSuffix("pkl") + rc = dialog.exec() + if rc: + self.export_proseco(dialog.selectedFiles()[0]) + + def export_sparkles_dialog(self): + """ + Save the sparkles report to a tarball. + """ + if self.sparkles: + catalog = self.proseco["catalog"] + # for some reason, the extension hidden but it works + dialog = QtW.QFileDialog( + caption="Export Pickle", + directory=str( + Path(os.getcwd()) + / f"aperoll-sparkles-obsid_{catalog.obsid:.0f}.tar.gz" + ), + ) + dialog.setAcceptMode(QtW.QFileDialog.AcceptSave) + dialog.setDefaultSuffix(".tgz") + rc = dialog.exec() + if rc: + self.export_sparkles(dialog.selectedFiles()[0]) + + def run_proseco(self): + if self._parameters: + params = self._parameters.copy() + # remove some optional arguments and let proseco deal with it. + keys = [ + "exclude_ids_acq", + "include_ids_acq", + "exclude_ids_guide", + "include_ids_guide", + ] + for key in keys: + if not params[key]: + del params[key] + catalog = get_aca_catalog(**params) + aca_review = catalog.get_review_table() + sparkles.core.check_catalog(aca_review) + + return { + "catalog": catalog, + "review_table": aca_review, + } + return {} + + def run_sparkles(self): + if self.proseco and self.proseco["catalog"]: + sparkles.run_aca_review( + "Exploration", + acars=[self.proseco["catalog"].get_review_table()], + report_dir=self._dir / "sparkles", + report_level="all", + roll_level="none", + ) + return self._dir / "sparkles" + + def open_export_proseco_dialog(self): + """ + Save the star catalog in a pickle file. + """ + if self.proseco: + catalog = self.proseco["catalog"] + dialog = QtW.QFileDialog( + self, + "Export Pickle", + str(self.outdir / f"aperoll-obsid_{catalog.obsid:.0f}.pkl"), + ) + dialog.setAcceptMode(QtW.QFileDialog.AcceptSave) + dialog.setDefaultSuffix("pkl") + rc = dialog.exec() + if rc: + self.export_proseco(dialog.selectedFiles()[0]) + + def open_export_sparkles_dialog(self): + """ + Save the sparkles report to a tarball. + """ + if self.sparkles: + catalog = self.proseco["catalog"] + # for some reason, the extension hidden but it works + dialog = QtW.QFileDialog( + self, + "Export Pickle", + str(self.outdir / f"aperoll-obsid_{catalog.obsid:.0f}.tgz"), + ) + dialog.setAcceptMode(QtW.QFileDialog.AcceptSave) + dialog.setDefaultSuffix(".tgz") + rc = dialog.exec() + if rc: + self.export_sparkles(dialog.selectedFiles()[0]) diff --git a/aperoll/scripts/aperoll_main.py b/aperoll/scripts/aperoll_main.py index 0adf94c..6220c7e 100755 --- a/aperoll/scripts/aperoll_main.py +++ b/aperoll/scripts/aperoll_main.py @@ -4,7 +4,7 @@ from PyQt5 import QtWidgets as QtW from aperoll.utils import AperollException, logger -from aperoll.widgets.main_window import MainWindow +from aperoll.widgets.proseco_view import ProsecoView def get_parser(): @@ -14,7 +14,7 @@ def get_parser(): parse.add_argument("file", nargs="?", default=None) parse.add_argument("--obsid", help="Specify the OBSID", type=int) levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - levels = [lvl.lower() for lvl in levels] + levels += [lvl.lower() for lvl in levels] parse.add_argument( "--log-level", help="Set the log level", default="INFO", choices=levels ) @@ -25,11 +25,12 @@ def main(): parser = get_parser() args = parser.parse_args() - logger.setLevel(args.log_level) + logger.setLevel(args.log_level.upper()) try: app = QtW.QApplication([]) - w = MainWindow(opts=vars(args)) + w = QtW.QMainWindow() + w.setCentralWidget(ProsecoView(opts=vars(args))) w.resize(1500, 1000) w.show() app.exec() diff --git a/aperoll/widgets/attitude_widget.py b/aperoll/widgets/attitude_widget.py new file mode 100644 index 0000000..f682b7e --- /dev/null +++ b/aperoll/widgets/attitude_widget.py @@ -0,0 +1,366 @@ +import json +from enum import Enum + +import numpy as np +from cxotime import CxoTime +from PyQt5 import QtCore as QtC +from PyQt5 import QtGui as QtG +from PyQt5 import QtWidgets as QtW +from Quaternion import Quat, normalize +from ska_sun import get_sun_pitch_yaw, off_nominal_roll + +from aperoll.widgets.error_message import ErrorMessage + + +class QuatRepresentation(Enum): + QUATERNION = "Quaternion" + EQUATORIAL = "Equatorial" + SUN = "Sun Position" + + +def stack(layout, *items, spacing=None, stretch=False): + if spacing is not None: + layout.setSpacing(spacing) + for item in items: + if isinstance(item, QtW.QWidget): + layout.addWidget(item) + elif isinstance(item, QtW.QLayout): + layout.addLayout(item) + elif isinstance(item, QtW.QSpacerItem): + layout.addItem(item) + else: + print(f"unknown type {type(item)}") + if stretch: + layout.addStretch() + return layout + + +def hstack(*items, **kwargs): + return stack(QtW.QHBoxLayout(), *items, **kwargs) + + +def vstack(*items, **kwargs): + return stack(QtW.QVBoxLayout(), *items, **kwargs) + + +class TextEdit(QtW.QTextEdit): + values_changed = QtC.pyqtSignal(list) + + def __init__(self, size=4, digits=12, width=None, parent=None): + super().__init__(parent=parent) + self.installEventFilter(self) + self.setSizePolicy( + QtW.QSizePolicy( + QtW.QSizePolicy.MinimumExpanding, + # QtW.QSizePolicy.Fixed + QtW.QSizePolicy.Ignored, + ) + ) + width = width or digits + font = self.font() + font_size = QtG.QFontMetrics(font).width("M") + + self.setMinimumWidth(width * font_size) + self.setMinimumHeight(size * 20) + + self.fmt = f"{{:.{digits}f}}" + self.length = size + self._vals = None + + self.reset() + + def sizeHint(self): + return QtC.QSize(125, 20) + + def get_values(self): + return self._vals + + def set_values(self, values): + if values is None: + self.reset() + return + if not hasattr(values, "__iter__"): + raise ValueError("values must be an iterable") + values = np.array(values) + if len(values) != self.length: + raise ValueError(f"expected {self.length} values, got {len(values)}") + if np.all(values == self._vals): + return + self._vals = values + self._display_values() + + values = property(get_values, set_values) + + def _parse_values(self, text): + """ + Parse a string to get the values. + + The string usually comes from the text box, or from the clipboard. + """ + # we expect a string of floats separated by commas or whitespace with length == self.length + unknown = set(text) - set("-e0123456789., \n\t") + if unknown: + raise ValueError(f"invalid characters: {unknown}") + vals = [float(s.strip()) for s in text.replace(",", " ").split()] + if len(vals) != self.length: + raise ValueError(f"expected {self.length} values, got {len(vals)}") + return vals + + def _update_values(self): + # take the text, parse it, and set the values + try: + vals = self._parse_values(self.toPlainText()) + self._vals = vals + pos = self.textCursor().position() + self._display_values() + cursor = self.textCursor() + cursor.setPosition(pos) + self.setTextCursor(cursor) + self.values_changed.emit(self._vals) + except ValueError as exc: + error_dialog = ErrorMessage(title="Value Error", message=str(exc)) + error_dialog.exec() + + def _display_values(self): + """ + Display the values in the text box. + """ + text = "\n".join(self.fmt.format(v) for v in self._vals) + self.setPlainText(text) + + def reset(self): + """ + Clear the contents of the text box and set the values to None. + """ + self._vals = None + self.setPlainText("\n".join("" for _ in range(self.length))) + + def focusOutEvent(self, event): + super().focusOutEvent(event) + # originally I had the following, but this causes a horrible error on exit which I still + # need to investigate + # self._update_values() + + def keyPressEvent(self, event): + """ + Listen for Key_Return to save and escape to discard changes. + """ + if event.key() == QtC.Qt.Key_Return: + self._update_values() + elif event.key() == QtC.Qt.Key_Escape: + # discard any changes to the text box + self._display_values() + elif event.matches(QtG.QKeySequence.Copy): + # copy the selected text (if it is selected) or all values to the clipboard + # when copying all the values, they are converted to a json string + cursor = self.textCursor() + if cursor.hasSelection(): + text = cursor.selectedText() + QtW.QApplication.clipboard().setText(text) + else: + vals = self._parse_values(self.toPlainText()) + text = json.dumps(vals) + QtW.QApplication.clipboard().setText(text) + else: + return super().keyPressEvent(event) + + def insertFromMimeData(self, data): + """ + Insert data from the clipboard. + """ + try: + # if this succeeds, presumably we are pasting the whole thing, so values are set + vals = json.loads(data.text()) + self.set_values(vals) + except ValueError: + # if it fails, paste it and the user can edit it + self.insertPlainText(data.text()) + + +class AttitudeWidget(QtW.QWidget): + attitude_changed = QtC.pyqtSignal(Quat) + attitude_cleared = QtC.pyqtSignal() + + def __init__(self, parent=None, columns=None): + super(AttitudeWidget, self).__init__(parent) + + if columns is None: + columns = { + QuatRepresentation.QUATERNION: 0, + QuatRepresentation.EQUATORIAL: 1, + QuatRepresentation.SUN: 2, + } + + self._q = TextEdit() + self._eq = TextEdit(size=3, digits=5, width=8) + self._sun_pos = TextEdit(size=3, digits=5, width=8) + + self._q.values_changed.connect(self._set_attitude) + self._eq.values_changed.connect(self._set_attitude) + + self._sun_pos.setReadOnly(True) + + self._set_layout(columns) + + self._attitude = None + self._date = None + + def _set_layout(self, columns): + layout = QtW.QHBoxLayout() + self.setLayout(layout) + + layout_q = vstack( + hstack( + vstack( + QtW.QSpacerItem(0, 5), + QtW.QLabel("Q1"), + QtW.QLabel("Q2"), + QtW.QLabel("Q3"), + QtW.QLabel("Q4"), + spacing=0, + stretch=True, + ), + vstack( + self._q, + spacing=0, + # stretch=True, + ), + ), + stretch=True, + ) + + layout_eq = vstack( + hstack( + vstack( + QtW.QSpacerItem(0, 3), + QtW.QLabel("ra "), + QtW.QLabel("dec "), + QtW.QLabel("roll"), + spacing=0, + stretch=True, + ), + vstack( + self._eq, + spacing=0, + # stretch=True, + ), + ), + stretch=True, + ) + + layout_sun = vstack( + hstack( + vstack( + QtW.QSpacerItem(0, 3), + QtW.QLabel("pitch"), + QtW.QLabel("yaw"), + QtW.QLabel("roll"), + spacing=0, + stretch=True, + ), + vstack( + self._sun_pos, + spacing=0, + # stretch=True, + ), + ), + stretch=True, + ) + + layouts = { + QuatRepresentation.QUATERNION: layout_q, + QuatRepresentation.EQUATORIAL: layout_eq, + QuatRepresentation.SUN: layout_sun, + } + name = { + QuatRepresentation.QUATERNION: "Quaternion", + QuatRepresentation.EQUATORIAL: "Equatorial", + QuatRepresentation.SUN: "Sun", + } + + self.tab_widgets = { + col: QtW.QTabWidget() for col in set(columns.values()) if col is not None + } + + for representation, col in columns.items(): + if col is None: + continue + w = QtW.QWidget() + w.setLayout(layouts[representation]) + self.tab_widgets[col].addTab(w, name[representation]) + + for widget in self.tab_widgets.values(): + layout.addWidget(widget) + widget.setCurrentIndex(0) + + self.update() + + def get_attitude(self): + return self._attitude + + def set_attitude(self, attitude): + self._set_attitude(attitude, emit=False) + + def _set_attitude(self, attitude, emit=True): + # work around the requirement that q be normalized + if ( + attitude is not None + and not isinstance(attitude, Quat) + and len(attitude) == 4 + ): + attitude = normalize(attitude) + # this check is to break infinite recursion because in the connections + q1 = None if attitude is None else Quat(attitude).q + q2 = None if self._attitude is None else self._attitude.q + if np.any(q1 != q2): + self._attitude = Quat(attitude) + self._display_attitude_at_date(self._attitude, self._date) + if emit: + if attitude is None: + self.attitude_cleared.emit() + else: + self.attitude_changed.emit(self._attitude) + + attitude = property(get_attitude, set_attitude) + + def set_date(self, date): + date = None if date is None else CxoTime(date) + if self._date == date: + return + self._date = date + self._display_attitude_at_date(self._attitude, self._date) + + def get_date(self): + return self._date + + date = property(get_date, set_date) + + def _display_attitude_at_date(self, attitude, date): + if attitude is None: + self._clear() + return + self._q.set_values(attitude.q) + self._eq.set_values(attitude.equatorial) + + if date is None: + self._sun_pos.reset() + else: + pitch, yaw = get_sun_pitch_yaw(attitude.ra, attitude.dec, date) + roll = off_nominal_roll(attitude, date) + self._sun_pos.set_values([pitch, yaw, roll]) + + def _clear(self): + self._q.reset() + self._eq.reset() + self._sun_pos.reset() + + +if __name__ == "__main__": + app = QtW.QApplication([]) + widget = AttitudeWidget() + q = Quat([344.571937, 1.026897, 302.0]) + widget.set_attitude(q) + widget.set_date("2021:001:00:00:00") + widget.resize(1200, 200) + widget.show() + app.exec() diff --git a/aperoll/widgets/error_message.py b/aperoll/widgets/error_message.py new file mode 100644 index 0000000..6cd3007 --- /dev/null +++ b/aperoll/widgets/error_message.py @@ -0,0 +1,34 @@ +from PyQt5 import QtCore as QtC +from PyQt5 import QtWidgets as QtW + + +class ErrorMessage(QtW.QDialog): + """ + Dialog to configure data fetching. + """ + + def __init__(self, title="", message=""): + QtW.QDialog.__init__(self) + self.setLayout(QtW.QVBoxLayout()) + self.resize(QtC.QSize(400, 300)) + + text = f"""

{title}

+ +

{message}

+ """ + text_box = QtW.QTextBrowser() + text_box.setText(text) + + button_box = QtW.QDialogButtonBox(QtW.QDialogButtonBox.Ok) + button_box.accepted.connect(self.accept) + self.layout().addWidget(text_box) + self.layout().addWidget(button_box) + + +if __name__ == "__main__": + from aca_view.tests.utils import qt + + with qt(): + app = ErrorMessage("This is the title", "This is the message") + app.resize(1200, 800) + app.show() diff --git a/aperoll/widgets/json_editor.py b/aperoll/widgets/json_editor.py new file mode 100644 index 0000000..18deeec --- /dev/null +++ b/aperoll/widgets/json_editor.py @@ -0,0 +1,163 @@ +import json + +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import get_lexer_by_name +from PyQt5 import QtCore as QtC +from PyQt5 import QtWidgets as QtW + +from aperoll.utils import AperollException +from aperoll.widgets.error_message import ErrorMessage + + +class ValidationError(AperollException): + pass + + +class JsonEditor(QtW.QWidget): + """ + A widget to edit parameters. + + The parameters are stored as a dictionary and displayed as a JSON string in a text editor. + The widget provides the text editor and two buttons to save and discard changes. + + Derived classes can override the `default_params` method to provide default parameters and + the `validate` method to validate the parameters before saving. The `params_changed` signal + is emitted when the parameters are saved. If there is an error in the JSON, an error dialog + is shown. + """ + + params_changed = QtC.pyqtSignal(dict) + + def __init__(self, show_buttons=False): + super().__init__() + + self.installEventFilter(self) + + self.text_widget = QtW.QTextEdit() + self.discard_button = QtW.QPushButton("Discard") + self.discard_button.clicked.connect(self.display_text) + self.save_button = QtW.QPushButton("Save") + self.save_button.clicked.connect(self.save) + + layout = QtW.QVBoxLayout() + layout.addWidget(self.text_widget) + if show_buttons: + h_layout = QtW.QHBoxLayout() + layout.addLayout(h_layout) + h_layout.addWidget(self.discard_button) + h_layout.addWidget(self.save_button) + self.setLayout(layout) + + self._parameters = {} + + self.reset() + + def display_text(self): + """ + Display the parameters in the text editor. + """ + lexer = get_lexer_by_name("json") + formatter = HtmlFormatter(full=False) + code = json.dumps(self._parameters, indent=2) + args_str = highlight(code, lexer, formatter) + style = formatter.get_style_defs() + self.text_widget.document().setDefaultStyleSheet( + style + ) # NOTE: not using self.styleSheet + self.text_widget.setText(args_str) + + def reset(self): + """ + Set the parameters to the default values. + """ + self._parameters = self.default_params() + self.display_text() + + def save(self): + """ + Set the parameter values from the text editor. + """ + try: + params = json.loads(self.text_widget.toPlainText()) + self.validate(params) + except json.JSONDecodeError as exc: + error_dialog = ErrorMessage(title="JSON Error", message=str(exc)) + error_dialog.exec() + return + except ValidationError as exc: + msg = str(exc) # .replace(",", "
") + error_dialog = ErrorMessage(title="Validation Error", message=msg) + error_dialog.exec() + return + if params == self._parameters: + return + self._parameters = params + self.params_changed.emit(self.default_params()) + + def eventFilter(self, obj, event): + """ + Listen for ctrl-S to save and escape to discard changes. + """ + if obj == self and event.type() == QtC.QEvent.KeyPress: + if ( + event.key() == QtC.Qt.Key_S + and event.modifiers() == QtC.Qt.ControlModifier + ): + self.save() + return True + elif ( + event.key() == QtC.Qt.Key_Z + and event.modifiers() == QtC.Qt.ControlModifier + ): + self.display_text() + return True + elif event.key() == QtC.Qt.Key_Escape: + self.display_text() + return True + return super().eventFilter(obj, event) + + @staticmethod + def validate(params): + """ + Validate the parameters before saving. Raises an exception if the parameters are invalid. + """ + + @classmethod + def default_params(cls): + """ + Default parameters to show. + """ + params = {} + return params + + def get_parameters(self): + return self._parameters + + def set_parameters(self, parameters): + self._parameters = parameters + self.params_changed.emit(self._parameters) + + parameters = property(get_parameters, set_parameters) + + def __getitem__(self, key): + return self._parameters[key] + + def __setitem__(self, key, value): + self.set_value(key, value, emit=True) + + def set_value(self, key, value, emit=True): + if self._parameters[key] != value: + self._parameters[key] = value + self.display_text() + if emit: + self.params_changed.emit(self._parameters) + + +if __name__ == "__main__": + from aca_view.tests.utils import qt + + with qt(): + app = JsonEditor() + app.resize(1200, 800) + app.show() diff --git a/aperoll/widgets/main_window.py b/aperoll/widgets/main_window.py index c7725d0..2f5cbcb 100644 --- a/aperoll/widgets/main_window.py +++ b/aperoll/widgets/main_window.py @@ -21,7 +21,7 @@ from .parameters import Parameters from .star_plot import StarPlot -from .starcat_view import StarcatView +from .starcat_review import StarcatReview STYLE = """