From fe20c406dda6ce2eff426386469f35f940f35619 Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 4 Dec 2024 15:19:40 -0500 Subject: [PATCH 01/10] renamed StarPlot.attitude_changed -> StarPlot.attitude_changed_eq and added a new StarPlot.attitude_changed --- aperoll/widgets/main_window.py | 2 +- aperoll/widgets/star_plot.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/aperoll/widgets/main_window.py b/aperoll/widgets/main_window.py index 273c55e..c7725d0 100644 --- a/aperoll/widgets/main_window.py +++ b/aperoll/widgets/main_window.py @@ -153,7 +153,7 @@ def __init__(self, opts=None): # noqa: PLR0915 self.parameters.reset.connect(self._reset) self.parameters.draw_test.connect(self._draw_test) self.parameters.parameters_changed.connect(self._parameters_changed) - self.plot.attitude_changed.connect(self.parameters.set_ra_dec) + self.plot.attitude_changed_eq.connect(self.parameters.set_ra_dec) self._data = Data(self.parameters.proseco_args()) self.outdir = Path(os.getcwd()) diff --git a/aperoll/widgets/star_plot.py b/aperoll/widgets/star_plot.py index c8f6cc6..4e73069 100644 --- a/aperoll/widgets/star_plot.py +++ b/aperoll/widgets/star_plot.py @@ -675,7 +675,8 @@ def show_catalog(self, show=True): class StarPlot(QtW.QWidget): - attitude_changed = QtC.pyqtSignal(float, float, float) + attitude_changed_eq = QtC.pyqtSignal(float, float, float) + attitude_changed = QtC.pyqtSignal(Quat) include_star = QtC.pyqtSignal(int, str, object) update_proseco = QtC.pyqtSignal() @@ -709,7 +710,8 @@ def __init__(self, parent=None): def _attitude_changed(self): if self.scene.attitude is not None: - self.attitude_changed.emit( + self.attitude_changed.emit(self.scene.attitude) + self.attitude_changed_eq.emit( self.scene.attitude.ra, self.scene.attitude.dec, self.scene.attitude.roll, From 7483fdd55ac4c943b627ef88aaa9ba6b0e251c6b Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 4 Dec 2024 18:22:22 -0500 Subject: [PATCH 02/10] add utils.single_entry decorator for functions that emit signals, and utils.log_exception --- aperoll/utils.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/aperoll/utils.py b/aperoll/utils.py index bb9e870..d01d973 100644 --- a/aperoll/utils.py +++ b/aperoll/utils.py @@ -1,3 +1,6 @@ +import functools +import traceback + import numpy as np from chandra_aca.transform import ( pixels_to_yagzag, @@ -20,6 +23,62 @@ class AperollException(RuntimeError): pass +def log_exception(msg, exc, level="DEBUG"): + import logging + trace = traceback.extract_tb(exc.__traceback__) + level = logging.__getattribute__(level) + logger.log(level, f"{msg}: {exc}") + for step in trace: + logger.log(level, f" in {step.filename}:{step.lineno}/{step.name}:") + logger.log(level, f" {step.line}") + + +def single_entry(func): + """ + Decorator to prevent a function from being called again before it finished (recursively). + + This can be used in a function that emits a signal. If the signal triggers a second function + which in turn calls the first function, that can potentially create an infinite loop. This + decorator just drops the second call. + + NOTES: + + - This decorator assumes that the decorated function has no return value. + - This prevents any concurrent use of the funtion. + + This is not completely fail-safe, because the second function call might happen after the first + function finished. This happens if the connection is queued. Most connections are direct + (the slot is called immediately) but they can be queued (the slot is called later) if the caller + and callee are in different threads, or if the signals is explicitly created as queued. + + Generally speaking, this is a hack and should not be needed, but it is useful. + + There are a few ways to break the recursion other than using this decorator: + - make sure the setters do not enter the function if there will be no effect + (i.e.: a set_value function checks the current value and returns if it is the same). + - Have two signals, one that is emitted in response to "internal" changes and another that is + emitted in response to external changes. One can then respond to internal changes only + (e.g.: The LineEdit class as a textEdited signal emitted whenever the text is edited in the + widget and a textChanged signal emitted whenever the text is changed using setText). + - Have two slots: one is private and emits a signal, and the other is public and does not emit. + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + if not wrapper.busy: + wrapper.busy = True + res = func(*args, **kwargs) + wrapper.busy = False + return res + except Exception: + wrapper.busy = False + raise + + wrapper.busy = False + return wrapper + + def get_camera_fov_frame(): """ Paths that correspond ot the edges of the ACA CCD and the quadrant boundaries. From c37c41857eb6e5c2a43239a661bba2576ef6081c Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 4 Dec 2024 17:20:23 -0500 Subject: [PATCH 03/10] Fix StarPlot and Parameters to avoid infinite recursion when emitting signals. --- aperoll/widgets/parameters.py | 16 +++++++++------- aperoll/widgets/star_plot.py | 36 +++++++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/aperoll/widgets/parameters.py b/aperoll/widgets/parameters.py index 9f70778..30eb1f1 100644 --- a/aperoll/widgets/parameters.py +++ b/aperoll/widgets/parameters.py @@ -29,9 +29,10 @@ def __init__(self, parent): super().__init__(parent) self._prev_text = self.text() - def setText(self, text): + def setText(self, text, emit=False): super().setText(text) - self._check_value() + # in the following, emit=False so the signal is not emitted + self._check_value(emit=emit) def keyPressEvent(self, event): super().keyPressEvent(event) @@ -42,10 +43,11 @@ def focusOutEvent(self, event): super().focusOutEvent(event) self._check_value() - def _check_value(self): + def _check_value(self, emit=True): if self.text() != self._prev_text: self._prev_text = self.text() - self.value_changed.emit(self.text()) + if emit: + self.value_changed.emit(self.text()) def get_default_parameters(): @@ -451,9 +453,9 @@ def _do_it(self): self.do_it.emit() def set_ra_dec(self, ra, dec, roll): - self.ra_edit.setText(f"{ra:.8f}") - self.dec_edit.setText(f"{dec:.8f}") - self.roll_edit.setText(f"{roll:.8f}") + self.ra_edit.setText(f"{ra:.8f}", emit=True) + self.dec_edit.setText(f"{dec:.8f}", emit=True) + self.roll_edit.setText(f"{roll:.8f}", emit=True) def include_star(self, star, type, include): if include is True: diff --git a/aperoll/widgets/star_plot.py b/aperoll/widgets/star_plot.py index 4e73069..d8e40b9 100644 --- a/aperoll/widgets/star_plot.py +++ b/aperoll/widgets/star_plot.py @@ -5,7 +5,7 @@ import tables from astropy import units as u from astropy.table import Table, vstack -from cxotime import CxoTime +from cxotime import CxoTime, convert_time_format from PyQt5 import QtCore as QtC from PyQt5 import QtGui as QtG from PyQt5 import QtWidgets as QtW @@ -83,6 +83,7 @@ class StarFieldState: class StarView(QtW.QGraphicsView): include_star = QtC.pyqtSignal(int, str, object) + include_slot = QtC.pyqtSignal(int, bool) update_proseco = QtC.pyqtSignal() def __init__(self, scene=None): @@ -253,7 +254,7 @@ def contextMenuEvent(self, event): # noqa: PLR0912, PLR0915 excl_action.setChecked(star.included["guide"] is False) menu.addAction(excl_action) - if centroid is not None: + if centroid is not None and not centroid.fiducial: incl_action = QtW.QAction( f"include slot {centroid.imgnum}", menu, checkable=True ) @@ -299,6 +300,7 @@ def contextMenuEvent(self, event): # noqa: PLR0912, PLR0915 if result is not None: if centroid is not None and result.text().startswith("include slot"): centroid.set_excluded(not result.isChecked()) + self.include_slot.emit(centroid.imgnum, not centroid.excluded) elif stars and result.text().split()[0] in ["include", "exclude"]: action, action_type = result.text().split() if action == "include": @@ -441,6 +443,7 @@ def set_onboard_attitude_slot(self, attitude): def get_state(self): return self._state + @utils.single_entry def set_state(self, state_name): # copy self.states[state_name] so self.states[state_name] is not modified self._state = replace(self.states[state_name]) @@ -558,7 +561,7 @@ def shift_scene(self, dx, dy): # this does roughly the same: # yag, zag = pixels_to_yagzag(-dx, dy) # dq = Quat(equatorial=[(yag - YZ_ORIGIN[0]) / 3600, (zag - YZ_ORIGIN[1]) / 3600, 0]) - self.set_attitude(self._attitude * dq) + self.set_attitude(self._attitude * dq, emit=True) def rotate_scene(self, angle, around=None): """ @@ -568,7 +571,7 @@ def rotate_scene(self, angle, around=None): return dq = Quat(equatorial=[0, 0, -angle]) - self.set_attitude(self._attitude * dq) + self.set_attitude(self._attitude * dq, emit=True) def set_star_positions(self): if self._stars is not None and self._attitude is not None: @@ -595,12 +598,16 @@ def get_time(self): def add_catalog(self, starcat): self.catalog.reset(starcat) - def set_attitude(self, attitude): + @utils.single_entry + def set_attitude(self, attitude, emit=False): """ Set the attitude of the scene, rotating the items to the given attitude. """ - - if attitude != self._attitude: + # here I had the following line, and it crashed with a RecursionError + # if attitude != self._attitude: + q1 = None if self._attitude is None else self._attitude.q + q2 = None if attitude is None else Quat(attitude).q + if not np.all(q1 == q2): if self.catalog is not None: self.catalog.set_pos_for_attitude(attitude) @@ -617,7 +624,8 @@ def set_attitude(self, attitude): self._attitude = attitude self.update_stars() - self.attitude_changed.emit() + if emit: + self.attitude_changed.emit() def get_attitude(self): return self._attitude @@ -642,6 +650,7 @@ def set_onboard_attitude(self, attitude): """ if self.onboard_attitude_slot == "attitude": self.attitude = attitude + self.attitude_changed.emit() elif self.onboard_attitude_slot == "alternate_attitude": self.alternate_attitude = attitude @@ -678,6 +687,7 @@ class StarPlot(QtW.QWidget): attitude_changed_eq = QtC.pyqtSignal(float, float, float) attitude_changed = QtC.pyqtSignal(Quat) include_star = QtC.pyqtSignal(int, str, object) + include_slot = QtC.pyqtSignal(int, bool) update_proseco = QtC.pyqtSignal() def __init__(self, parent=None): @@ -706,6 +716,7 @@ def __init__(self, parent=None): self.scene.changed.connect(self.view.set_visibility) self.view.include_star.connect(self.include_star) + self.view.include_slot.connect(self.include_slot) self.view.update_proseco.connect(self.update_proseco) def _attitude_changed(self): @@ -732,8 +743,13 @@ def set_onboard_attitude(self, attitude): self.scene.set_onboard_attitude(attitude) def set_time(self, t): - self._time = CxoTime(t) - self.scene.time = self._time + # making this check to avoid infinite recursion + t1 = None if self._time is None else self._time.date + t2 = None if t is None else convert_time_format(t, fmt_out="date") + if t1 != t2: + t = CxoTime(t) + self._time = t + self.scene.time = t def highlight(self, agasc_ids): self._highlight = agasc_ids From 76c849c80de2b0abaead198bb6bd2159134fe425 Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 27 Nov 2024 12:46:04 -0500 Subject: [PATCH 04/10] add proseco parameters widget, attitude widget, aperoll/widgets/proseco_view.py and aperoll/proseco_data.py --- aperoll/proseco_data.py | 211 ++++++++++ aperoll/scripts/aperoll_main.py | 9 +- aperoll/widgets/attitude_widget.py | 390 ++++++++++++++++++ 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, 1278 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..feef429 --- /dev/null +++ b/aperoll/proseco_data.py @@ -0,0 +1,211 @@ +# from PyQt5 import QtCore as QtC, QtWidgets as QtW, QtGui as QtG +import os +import pickle +import tarfile +import traceback +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 + +from aperoll.utils import logger + + +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: + try: + 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, + } + except Exception as exc: + logger.debug(f"ProsecoData failed calling proseco ({type(exc).__name__}): {exc}") + trace = traceback.extract_tb(exc.__traceback__) + for step in trace: + logger.debug(f" in {step.filename}:{step.lineno}/{step.name}:") + logger.debug(f" {step.line}") + raise Exception(f"ProsecoData failed calling proseco: {exc}") from None + return {} + + def run_sparkles(self): + if self.proseco and self.proseco["catalog"]: + try: + 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" + except Exception as exc: + logger.debug(f"ProsecoData failed calling sparkles ({type(exc).__name__}): {exc}") + trace = traceback.extract_tb(exc.__traceback__) + for step in trace: + logger.debug(f" in {step.filename}:{step.lineno}/{step.name}:") + logger.debug(f" {step.line}") + raise Exception(f"ProsecoData failed calling sparkles: {exc}") from None + + + 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..fbde3c4 --- /dev/null +++ b/aperoll/widgets/attitude_widget.py @@ -0,0 +1,390 @@ +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, content_margins=None, stretch=False): + if spacing is not None: + layout.setSpacing(spacing) + if content_margins is not None: + layout.setContentsMargins(*content_margins) + 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() + fm = QtG.QFontMetrics(font) + font_size = fm.width("M") + # font_height = fm.width("M") + self.setMinimumWidth(width * font_size) + self.setMinimumHeight( + size * fm.lineSpacing() + fm.lineSpacing() // 2 + 2 + # size * fm.lineSpacing() + fm.lineSpacing() // 2 + 2 * self.frameWidth() + ) + + 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 UnpaddedLabel(QtW.QLabel): + def __init__(self, text, parent=None): + super().__init__(text, parent) + self.setStyleSheet("QLabel { padding: 0px; }") + + +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() + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + margins = (20, 0, 20, 0) + layout_q = vstack( + hstack( + vstack( + QtW.QSpacerItem(0, 5), + UnpaddedLabel("Q1"), + UnpaddedLabel("Q2"), + UnpaddedLabel("Q3"), + UnpaddedLabel("Q4"), + spacing=0, + content_margins=margins, + stretch=True, + ), + vstack( + self._q, + spacing=0, + content_margins=margins, + # stretch=True, + ), + ), + content_margins=(0, 0, 0, 0), + stretch=True, + ) + + layout_eq = vstack( + hstack( + vstack( + QtW.QSpacerItem(0, 3), + UnpaddedLabel("ra "), + UnpaddedLabel("dec "), + UnpaddedLabel("roll"), + spacing=0, + content_margins=margins, + stretch=True, + ), + vstack( + self._eq, + spacing=0, + content_margins=margins, + # stretch=True, + ), + ), + content_margins=(0, 0, 0, 0), + stretch=True, + ) + + layout_sun = vstack( + hstack( + vstack( + QtW.QSpacerItem(0, 3), + UnpaddedLabel("pitch"), + UnpaddedLabel("yaw"), + UnpaddedLabel("roll"), + spacing=0, + content_margins=margins, + stretch=True, + ), + vstack( + self._sun_pos, + spacing=0, + content_margins=margins, + # stretch=True, + ), + ), + content_margins=(0, 0, 0, 0), + 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 None: + self._clear() + if emit: + self.attitude_cleared.emit() + return + if ( + 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: + 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 = """ -""" - - -class WebPage(QtWe.QWebEnginePage): - def __init__(self, parent=None): - super().__init__(parent) - - # trick from https://stackoverflow.com/questions/54920726/how-make-any-link-blank-open-in-same-window-using-qwebengine - def createWindow(self, _type): - page = WebPage(self) - page.urlChanged.connect(self.on_url_changed) - return page - - @QtC.pyqtSlot(QtC.QUrl) - def on_url_changed(self, url): - page = self.sender() - self.setUrl(url) - page.deleteLater() - - -class MainWindow(QtW.QMainWindow): - def __init__(self, opts=None): # noqa: PLR0915 - super().__init__() - opts = {} if opts is None else opts - opts = {k: opts[k] for k in opts if opts[k] is not None} - - self.opts = opts - - self._main = QtW.QWidget() - self.setCentralWidget(self._main) - - self.menu_bar = QtW.QMenuBar() - self.setMenuBar(self.menu_bar) - - self.fileMenu = self.menu_bar.addMenu("&File") - export_action = QtW.QAction("&Export Pickle", self) - export_action.triggered.connect(self._export_proseco) - self.fileMenu.addAction(export_action) - export_action = QtW.QAction("&Export Sparkles", self) - export_action.triggered.connect(self._export_sparkles) - self.fileMenu.addAction(export_action) - - self.web_page = None - - self.plot = StarPlot() - self.parameters = Parameters(**opts) - self.starcat_review = StarcatReview() - - layout = QtW.QVBoxLayout(self._main) - layout_2 = QtW.QHBoxLayout() - - layout.addWidget(self.parameters) - layout_2.addWidget(self.starcat_review) - layout_2.addWidget(self.plot) - layout.addLayout(layout_2) - - layout.setStretch(0, 1) # the dialog on top should not stretch much - layout.setStretch(1, 10) - self._main.setLayout(layout) - - self.plot.include_star.connect(self.parameters.include_star) - # self.plot.exclude_star.connect(self.parameters.exclude_star) - - self.parameters.do_it.connect(self._run_proseco) - self.plot.update_proseco.connect(self._run_proseco) - self.parameters.run_sparkles.connect(self._run_sparkles) - self.parameters.reset.connect(self._reset) - self.parameters.draw_test.connect(self._draw_test) - self.parameters.parameters_changed.connect(self._parameters_changed) - self.plot.attitude_changed_eq.connect(self.parameters.set_ra_dec) - - self._data = Data(self.parameters.proseco_args()) - self.outdir = Path(os.getcwd()) - - self._init() - - starcat = None - if "file" in opts: - filename = opts.get("file") - catalogs = {} - if filename.endswith(".pkl"): - with open(filename, "rb") as fh: - catalogs = pickle.load(fh) - elif filename.endswith(".pkl.gz"): - with gzip.open(filename, "rb") as fh: - catalogs = pickle.load(fh) - if catalogs: - obsids = [int(np.round(float(k))) for k in catalogs] - if "obsid" not in opts or opts["obsid"] is None: - starcat = catalogs[obsids[0]] - else: - starcat = catalogs[opts["obsid"]] - aca = starcat.get_review_table() - sparkles.core.check_catalog(aca) - - if starcat is not None: - self.plot.set_catalog(starcat) - self.starcat_review.set_catalog(aca) - # make sure the catalog is not overwritten automatically - self.plot.scene.state.auto_proseco = False - - if self.plot.scene.state.auto_proseco: - self._run_proseco() - - def closeEvent(self, event): - if self.web_page is not None: - del self.web_page - self.web_page = None - event.accept() - - def _parameters_changed(self): - proseco_args = self.parameters.proseco_args() - self.plot.set_base_attitude(proseco_args["att"]) - self._data.reset(proseco_args) - if self.plot.scene.state.auto_proseco and not self.plot.view.moving: - self._run_proseco() - - def _init(self): - if self.parameters.values: - ra, dec = self.parameters.values["ra"], self.parameters.values["dec"] - roll = self.parameters.values["roll"] - time = CxoTime(self.parameters.values["date"]) - aca_attitude = Quat( - equatorial=(float(ra / u.deg), float(dec / u.deg), roll) - ) - self.plot.set_base_attitude(aca_attitude) - self.plot.set_time(time) - self.plot.scene.state = "Proseco" - - def _reset(self): - self.parameters.set_parameters(**self.opts) - self.starcat_review.reset() - self._data.reset(self.parameters.proseco_args()) - self._init() - - def _draw_test(self): - if self.parameters.values: - ra, dec = self.parameters.values["ra"], self.parameters.values["dec"] - roll = self.parameters.values["roll"] - aca_attitude = Quat( - equatorial=(float(ra / u.deg), float(dec / u.deg), roll) - ) - dq = self.plot._base_attitude.dq(aca_attitude) - self.plot.show_test_stars( - ra_offset=dq.ra, dec_offset=dq.dec, roll_offset=dq.roll - ) - - def _run_proseco(self): - """ - Display the star catalog. - """ - if self._data.proseco: - self.starcat_review.set_catalog(self._data.proseco["aca"]) - self.plot.set_catalog(self._data.proseco["catalog"]) - - def _export_proseco(self): - """ - Save the star catalog in a pickle file. - """ - if self._data.proseco: - catalog = self._data.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._data.export_proseco(dialog.selectedFiles()[0]) - - def _export_sparkles(self): - """ - Save the sparkles report to a tarball. - """ - if self._data.sparkles: - catalog = self._data.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._data.export_sparkles(dialog.selectedFiles()[0]) - - def _run_sparkles(self): - """ - Display the sparkles report in a web browser. - """ - if self._data.sparkles: - try: - w = QtW.QMainWindow(self) - w.resize(1400, 1000) - web = QtWe.QWebEngineView(w) - w.setCentralWidget(web) - self.web_page = WebPage() - web.setPage(self.web_page) - url = self._data.sparkles / "index.html" - web.load(QtC.QUrl(f"file://{url}")) - web.show() - w.show() - except Exception as e: - logger.warning(e) - - -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 Data: - 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 export_proseco(self, outfile): - if self.proseco: - outfile = self._dir / outfile - catalog = self.proseco["catalog"] - if catalog: - with open(outfile, "wb") as fh: - pickle.dump({catalog.obsid: catalog}, fh) - - def export_sparkles(self, outfile): - if self.sparkles: - outfile = self._dir / outfile - if self.sparkles: - dest = Path(outfile.name.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 run_proseco(self): - if self.parameters: - catalog = get_aca_catalog(**self.parameters) - aca = catalog.get_review_table() - sparkles.core.check_catalog(aca) - - return { - "catalog": catalog, - "aca": aca, - } - 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" diff --git a/aperoll/widgets/parameters.py b/aperoll/widgets/parameters.py deleted file mode 100644 index 18aa7cb..0000000 --- a/aperoll/widgets/parameters.py +++ /dev/null @@ -1,524 +0,0 @@ -import gzip -import json -import pickle -from pprint import pformat - -import maude -import numpy as np -import Ska.Sun as sun -from astropy import units as u -from cxotime.cxotime import CxoTime -from kadi.commands.observations import get_detector_and_sim_offset -from PyQt5 import QtCore as QtC -from PyQt5 import QtWidgets as QtW -from Quaternion import Quat - -from aperoll.utils import AperollException, logger - - -class LineEdit(QtW.QLineEdit): - """ - A QLineEdit with a signal emitted when pressing Enter, loosing focus or calling setText. - - The signal is emitted only if the text changed. - """ - - value_changed = QtC.pyqtSignal(str) - - def __init__(self, parent): - super().__init__(parent) - self._prev_text = self.text() - - def setText(self, text, emit=False): - super().setText(text) - # in the following, emit=False so the signal is not emitted - self._check_value(emit=emit) - - def keyPressEvent(self, event): - super().keyPressEvent(event) - if event.key() == QtC.Qt.Key_Return: - self._check_value() - - def focusOutEvent(self, event): - super().focusOutEvent(event) - self._check_value() - - def _check_value(self, emit=True): - if self.text() != self._prev_text: - self._prev_text = self.text() - if emit: - self.value_changed.emit(self.text()) - - -def get_default_parameters(): - """ - Get default initial parameters from current telemetry. - """ - - msid_list = ["3TSCPOS", "AACCCDPT"] + [f"aoattqt{i}".upper() for i in range(1, 5)] - msids = maude.get_msids(msid_list) - data = {msid: msids["data"][i]["values"][-1] for i, msid in enumerate(msid_list)} - q = Quat(q=[data[f"AOATTQT{i}"] for i in range(1, 5)]) - - instrument, sim_offset = get_detector_and_sim_offset(data["3TSCPOS"]) - t_ccd = (data["AACCCDPT"] - 32) * 5 / 9 - - result = { - "date": CxoTime().date, - "attitude": q, - "ra": q.ra, - "dec": q.dec, - "roll": q.roll, - "instrument": instrument, - "sim_offset": sim_offset, - "t_ccd": t_ccd, - "obsid": 0, - "man_angle": 0, - "dither_acq_y": 16, - "dither_acq_z": 16, - "dither_guide_y": 16, - "dither_guide_z": 16, - "n_fid": 0, - "n_guide": 8, - } - - return result - - -def get_parameters_from_yoshi(filename, obsid=None): - """ - Get initial parameters from a Yoshi JSON file. - """ - - with open(filename) as fh: - contents = json.load(fh) - if obsid is not None: - contents = [obs for obs in contents if obs["obsid"] == obsid] - if not contents: - raise AperollException(f"OBSID {obsid} not found in {filename}") - - if not contents: - raise AperollException(f"No entries found in {filename}") - - yoshi_params = contents[0] # assuming there is only one entry - - yoshi_params["date"] = yoshi_params["obs_date"] - yoshi_params["ra"] = yoshi_params["ra_targ"] - yoshi_params["dec"] = yoshi_params["dec_targ"] - yoshi_params["roll"] = yoshi_params["roll_targ"] - yoshi_params["instrument"] = yoshi_params["detector"] - for key in [ - "obs_date", - "ra_targ", - "dec_targ", - "roll_targ", - "detector", - ]: - del yoshi_params[key] - - if abs(yoshi_params.get("obsid", 0)) < 38000: - yoshi_params["n_fid"] = "3" - yoshi_params["n_guide"] = "5" - else: - yoshi_params["n_fid"] = "0" - yoshi_params["n_guide"] = "8" - - att = Quat( - equatorial=(yoshi_params["ra"], yoshi_params["dec"], yoshi_params["roll"]) - ) - - default = get_default_parameters() - parameters = { - "obsid": yoshi_params.get("obsid", default.get("obsid", 0)), - "man_angle": yoshi_params.get("man_angle", default.get("man_angle", 0)), - "dither_acq_y": yoshi_params.get("dither_y", default.get("dither_acq_y", 16)), - "dither_acq_z": yoshi_params.get("dither_z", default.get("dither_acq_z", 16)), - "dither_guide_y": yoshi_params.get( - "dither_y", default.get("dither_guide_y", 16) - ), - "dither_guide_z": yoshi_params.get( - "dither_z", default.get("dither_guide_z", 16) - ), - "date": yoshi_params.get("date", default["date"]), - "attitude": att, - "ra": yoshi_params.get("ra", default["attitude"].ra), - "dec": yoshi_params.get("dec", default["attitude"].dec), - "roll": yoshi_params.get("roll", default["attitude"].roll), - "t_ccd": yoshi_params.get("t_ccd", default["t_ccd"]), - "instrument": yoshi_params.get("instrument", default["instrument"]), - "n_guide": yoshi_params["n_guide"], - "n_fid": yoshi_params["n_fid"], - } - return parameters - - -def get_parameters_from_pickle(filename, obsid=None): - """ - Get initial parameters from a proseco pickle file. - """ - open_fcn = open if filename.endswith(".pkl") else gzip.open - with open_fcn(filename, "rb") as fh: - catalogs = pickle.load(fh) - - if not catalogs: - raise AperollException(f"No entries found in {filename}") - - if obsid is None: - # this is ugly but it works whether the keys are strings of floats or ints - obsid = int(np.round(float(list(catalogs.keys())[0]))) - - if float(obsid) not in catalogs: - raise AperollException(f"OBSID {obsid} not found in {filename}") - - catalog = catalogs[float(obsid)] - - parameters = { - "obsid": obsid, - "man_angle": catalog.man_angle, - "dither_acq_y": catalog.dither_acq.y, - "dither_acq_z": catalog.dither_acq.z, - "dither_guide_y": catalog.dither_guide.y, - "dither_guide_z": catalog.dither_guide.z, - "date": CxoTime( - catalog.date - ).date, # date is not guaranteed to be a fixed type in pickle - "attitude": catalog.att, - "ra": catalog.att.ra, - "dec": catalog.att.dec, - "roll": catalog.att.roll, - "t_ccd_acq": catalog.t_ccd_acq, - "t_ccd_guide": catalog.t_ccd_guide, - "instrument": catalog.detector, - "n_guide": catalog.n_guide, - "n_fid": catalog.n_fid, - } - return parameters - - -class Parameters(QtW.QWidget): - do_it = QtC.pyqtSignal() - run_sparkles = QtC.pyqtSignal() - draw_test = QtC.pyqtSignal() - reset = QtC.pyqtSignal() - parameters_changed = QtC.pyqtSignal() - - def __init__(self, **kwargs): # noqa: PLR0915 - super().__init__() - self.date_edit = LineEdit(self) - self.obsid_edit = LineEdit(self) - self.ra_edit = LineEdit(self) - self.dec_edit = LineEdit(self) - self.roll_edit = LineEdit(self) - self.n_guide_edit = LineEdit(self) - self.n_fid_edit = LineEdit(self) - self.n_t_ccd_edit = LineEdit(self) - self.man_angle_edit = LineEdit(self) - self.instrument_edit = QtW.QComboBox(self) - self.instrument_edit.addItems(["ACIS-S", "ACIS-I", "HRC-S", "HRC-I"]) - self.do = QtW.QPushButton("Get Catalog") - self.reset_button = QtW.QPushButton("Reset") - self.run_sparkles_button = QtW.QPushButton("Run Sparkles") - self.draw_test_button = QtW.QPushButton("Draw Test") - self.include = { - "acq": QtW.QListWidget(self), - "guide": QtW.QListWidget(self), - } - self.exclude = { - "acq": QtW.QListWidget(self), - "guide": QtW.QListWidget(self), - } - self.dither_acq_y_edit = LineEdit(self) - self.dither_acq_z_edit = LineEdit(self) - self.dither_guide_y_edit = LineEdit(self) - self.dither_guide_z_edit = LineEdit(self) - - self.date_edit.value_changed.connect(self._values_changed) - self.obsid_edit.value_changed.connect(self._values_changed) - self.ra_edit.value_changed.connect(self._values_changed) - self.dec_edit.value_changed.connect(self._values_changed) - self.roll_edit.value_changed.connect(self._values_changed) - self.n_guide_edit.value_changed.connect(self._values_changed) - self.n_fid_edit.value_changed.connect(self._values_changed) - self.n_t_ccd_edit.value_changed.connect(self._values_changed) - self.man_angle_edit.value_changed.connect(self._values_changed) - self.instrument_edit.currentIndexChanged.connect(self._values_changed) - self.include["acq"].model().rowsInserted.connect(self._values_changed) - self.include["guide"].model().rowsInserted.connect(self._values_changed) - self.exclude["acq"].model().rowsInserted.connect(self._values_changed) - self.exclude["guide"].model().rowsInserted.connect(self._values_changed) - self.include["acq"].model().rowsRemoved.connect(self._values_changed) - self.include["guide"].model().rowsRemoved.connect(self._values_changed) - self.exclude["acq"].model().rowsRemoved.connect(self._values_changed) - self.exclude["guide"].model().rowsRemoved.connect(self._values_changed) - self.dither_acq_y_edit.value_changed.connect(self._values_changed) - self.dither_acq_z_edit.value_changed.connect(self._values_changed) - self.dither_guide_y_edit.value_changed.connect(self._values_changed) - self.dither_guide_z_edit.value_changed.connect(self._values_changed) - - layout = QtW.QHBoxLayout() - - info_group_box = QtW.QGroupBox() - info_group_box_layout = QtW.QGridLayout() - info_group_box_layout.addWidget(QtW.QLabel("OBSID"), 0, 0, 1, 1) - info_group_box_layout.addWidget(self.obsid_edit, 0, 1, 1, 2) - info_group_box_layout.addWidget(QtW.QLabel("date"), 1, 0, 1, 1) - info_group_box_layout.addWidget(self.date_edit, 1, 1, 1, 2) - info_group_box_layout.addWidget(QtW.QLabel("instrument"), 2, 0, 1, 1) - info_group_box_layout.addWidget(self.instrument_edit, 2, 1, 1, 2) - for i in range(3): - info_group_box_layout.setColumnStretch(i, 2) - - info_group_box.setLayout(info_group_box_layout) - - layout.addWidget(info_group_box) - - attitude_group_box = QtW.QWidget() - attitude_group_box_layout = QtW.QGridLayout() - attitude_group_box_layout.addWidget(QtW.QLabel("ra"), 0, 0, 1, 1) - attitude_group_box_layout.addWidget(QtW.QLabel("dec"), 1, 0, 1, 1) - attitude_group_box_layout.addWidget(QtW.QLabel("roll"), 2, 0, 1, 1) - attitude_group_box_layout.addWidget(self.ra_edit, 0, 1, 1, 1) - attitude_group_box_layout.addWidget(self.dec_edit, 1, 1, 1, 1) - attitude_group_box_layout.addWidget(self.roll_edit, 2, 1, 1, 1) - attitude_group_box.setLayout(attitude_group_box_layout) - for i in range(3): - attitude_group_box_layout.setColumnStretch(i, 10) - - info_2_group_box = QtW.QWidget() - info_2_group_box_layout = QtW.QGridLayout() - info_2_group_box_layout.addWidget(QtW.QLabel("n_guide"), 0, 0, 1, 1) - info_2_group_box_layout.addWidget(self.n_guide_edit, 0, 1, 1, 1) - info_2_group_box_layout.addWidget(QtW.QLabel("n_fid"), 0, 2, 1, 1) - info_2_group_box_layout.addWidget(self.n_fid_edit, 0, 3, 1, 1) - info_2_group_box_layout.addWidget(QtW.QLabel("t_ccd"), 1, 0, 1, 1) - info_2_group_box_layout.addWidget(self.n_t_ccd_edit, 1, 1, 1, 1) - info_2_group_box_layout.addWidget(QtW.QLabel("Man. angle"), 1, 2, 1, 1) - info_2_group_box_layout.addWidget(self.man_angle_edit, 1, 3, 1, 1) - info_2_group_box.setLayout(info_2_group_box_layout) - for i in range(4): - info_2_group_box_layout.setColumnStretch(i, 1) - - dither_group_box = QtW.QWidget() - dither_group_box_layout = QtW.QGridLayout() - dither_group_box_layout.addWidget(QtW.QLabel(""), 0, 0, 1, 4) - dither_group_box_layout.addWidget(QtW.QLabel("y"), 0, 4, 1, 4) - dither_group_box_layout.addWidget(QtW.QLabel("z"), 0, 8, 1, 4) - dither_group_box_layout.addWidget(QtW.QLabel("acq"), 1, 0, 1, 4) - dither_group_box_layout.addWidget(self.dither_acq_y_edit, 1, 4, 1, 4) - dither_group_box_layout.addWidget(self.dither_acq_z_edit, 1, 8, 1, 4) - - dither_group_box_layout.addWidget(QtW.QLabel("guide"), 2, 0, 1, 4) - dither_group_box_layout.addWidget(self.dither_guide_y_edit, 2, 4, 1, 4) - dither_group_box_layout.addWidget(self.dither_guide_z_edit, 2, 8, 1, 4) - dither_group_box.setLayout(dither_group_box_layout) - - tab_2 = QtW.QTabWidget() - tab_2.addTab(attitude_group_box, "Attitude") - tab_2.addTab(dither_group_box, "Dither") - tab_2.addTab(info_2_group_box, "Other") - tab_2.setCurrentIndex(0) - layout.addWidget(tab_2) - - tab = QtW.QTabWidget() - tab.addTab(self.include["acq"], "Include Acq.") - tab.addTab(self.exclude["acq"], "Exclude Acq.") - tab.addTab(self.include["guide"], "Include Guide") - tab.addTab(self.exclude["guide"], "Exclude Guide") - tab.setCurrentIndex(0) - layout.addWidget(tab) - - controls_group_box = QtW.QGroupBox() - controls_group_box_layout = QtW.QVBoxLayout() - controls_group_box_layout.addWidget(self.do) - controls_group_box_layout.addWidget(self.run_sparkles_button) - controls_group_box_layout.addWidget(self.reset_button) - controls_group_box.setLayout(controls_group_box_layout) - - layout.addWidget(controls_group_box) - - self.setLayout(layout) - - self.do.clicked.connect(self._do_it) - self.draw_test_button.clicked.connect(self._draw_test) - self.run_sparkles_button.clicked.connect(self.run_sparkles) - self.reset_button.clicked.connect(self.reset) - - self.set_parameters(**kwargs) - - def set_parameters(self, **kwargs): - if "file" in kwargs and ( - kwargs["file"].endswith(".pkl") or kwargs["file"].endswith(".pkl.gz") - ): - params = get_parameters_from_pickle( - kwargs["file"], obsid=kwargs.get("obsid", None) - ) - elif "file" in kwargs and kwargs["file"].endswith(".json"): - params = get_parameters_from_yoshi( - kwargs["file"], obsid=kwargs.get("obsid", None) - ) - else: - params = get_default_parameters() - # obsid is a command-line argument, so I set it here - if "obsid" in kwargs: - params["obsid"] = kwargs["obsid"] - - logger.debug(pformat(params)) - self.obsid_edit.setText(f"{params['obsid']}") - self.man_angle_edit.setText(f"{params['man_angle']}") - self.dither_acq_y_edit.setText(f"{params['dither_acq_y']}") - self.dither_acq_z_edit.setText(f"{params['dither_acq_z']}") - self.dither_guide_y_edit.setText(f"{params['dither_guide_y']}") - self.dither_guide_z_edit.setText(f"{params['dither_guide_z']}") - self.date_edit.setText(kwargs.get("date", params["date"])) - self.ra_edit.setText(f"{params['ra']:.5f}") - self.dec_edit.setText(f"{params['dec']:.5f}") - self.roll_edit.setText(f"{params['roll']:.5f}") - self.n_guide_edit.setText(f"{params['n_guide']}") - self.n_fid_edit.setText(f"{params['n_fid']}") - self.n_t_ccd_edit.setText(f"{params['t_ccd']:.2f}") - self.instrument_edit.setCurrentText(params["instrument"]) - - self.values = self._validate() - - def _draw_test(self): - self.values = self._validate() - if self.values: - self.draw_test.emit() - - def _values_changed(self): - # values are empty if validation fails, but the signal is still emitted to notify anyone - # that the values have changed - self.values = self._validate(quiet=True) - self.parameters_changed.emit() - - def _validate(self, quiet=False): - try: - n_fid = int(self.n_fid_edit.text()) - n_guide = int(self.n_guide_edit.text()) - obsid = int(self.obsid_edit.text()) - assert self.date_edit.text() != "", "No date" - assert self.ra_edit.text() != "", "No RA" - assert self.dec_edit.text() != "", "No dec" - assert n_fid + n_guide == 8, "n_fid + n_guide != 8" - ra = float(self.ra_edit.text()) * u.deg - dec = float(self.dec_edit.text()) * u.deg - time = CxoTime(self.date_edit.text()) - if self.roll_edit.text() == "": - roll = sun.nominal_roll(ra, dec, time) - else: - roll = float(self.roll_edit.text()) - return { - "date": self.date_edit.text(), - "ra": ra, - "dec": dec, - "roll": roll, - "n_guide": n_guide, - "n_fid": n_fid, - "t_ccd": float(self.n_t_ccd_edit.text()), - "instrument": self.instrument_edit.currentText(), - "obsid": obsid, - "exclude_ids_acq": [ - int(self.exclude["acq"].item(i).text()) - for i in range(self.exclude["acq"].count()) - ], - "include_ids_acq": [ - int(self.include["acq"].item(i).text()) - for i in range(self.include["acq"].count()) - ], - "exclude_ids_guide": [ - int(self.exclude["guide"].item(i).text()) - for i in range(self.exclude["guide"].count()) - ], - "include_ids_guide": [ - int(self.include["guide"].item(i).text()) - for i in range(self.include["guide"].count()) - ], - "dither_acq": ( - float(self.dither_acq_y_edit.text()), - float(self.dither_acq_z_edit.text()), - ), - "dither_guide": ( - float(self.dither_guide_y_edit.text()), - float(self.dither_guide_z_edit.text()), - ), - "man_angle": float(self.man_angle_edit.text()), - } - except Exception as e: - if not quiet: - logger.warning(e) - return {} - - def _do_it(self): - self.values = self._validate() - if self.values: - self.do_it.emit() - - def set_ra_dec(self, ra, dec, roll): - self.ra_edit.setText(f"{ra:.8f}", emit=True) - self.dec_edit.setText(f"{dec:.8f}", emit=True) - self.roll_edit.setText(f"{roll:.8f}", emit=True) - - def include_star(self, star, type, include): - if include is True: - self._include_star(star, type, True) - self._exclude_star(star, type, False) - elif include is False: - self._include_star(star, type, False) - self._exclude_star(star, type, True) - else: - self._include_star(star, type, include=False) - self._exclude_star(star, type, exclude=False) - - def _include_star(self, star, type, include): - items = self.include[type].findItems(f"{star}", QtC.Qt.MatchExactly) - if include: - if not items: - self.include[type].addItem(f"{star}") - else: - for it in items: - self.include[type].takeItem(self.include[type].row(it)) - - def _exclude_star(self, star, type, exclude): - items = self.exclude[type].findItems(f"{star}", QtC.Qt.MatchExactly) - if exclude: - if not items: - self.exclude[type].addItem(f"{star}") - else: - for it in items: - self.exclude[type].takeItem(self.exclude[type].row(it)) - - def proseco_args(self): - obsid = self.values["obsid"] - ra, dec = self.values["ra"], self.values["dec"] - roll = self.values["roll"] - time = CxoTime(self.values["date"]) - - aca_attitude = Quat(equatorial=(float(ra / u.deg), float(dec / u.deg), roll)) - - args = { - "obsid": obsid, - "att": aca_attitude, - "date": time, - "n_fid": self.values["n_fid"], - "n_guide": self.values["n_guide"], - "dither_acq": self.values["dither_acq"], - "dither_guide": self.values["dither_guide"], - "t_ccd_acq": self.values["t_ccd"], - "t_ccd_guide": self.values["t_ccd"], - "man_angle": self.values["man_angle"], - "detector": self.values["instrument"], - "sim_offset": 0, # docs say this is optional, but it does not seem to be - "focus_offset": 0, # docs say this is optional, but it does not seem to be - "dyn_bgd_n_faint": 2, - } - - for key in [ - "exclude_ids_guide", - "include_ids_guide", - "exclude_ids_acq", - "include_ids_acq", - ]: - if self.values[key]: - args[key] = self.values[key] - - return args diff --git a/aperoll/widgets/proseco_view.py b/aperoll/widgets/proseco_view.py index f29eb7a..30497ca 100644 --- a/aperoll/widgets/proseco_view.py +++ b/aperoll/widgets/proseco_view.py @@ -7,18 +7,18 @@ from Quaternion import Quat from aperoll.proseco_data import ProsecoData -from aperoll.utils import logger +from aperoll.utils import ( + get_default_parameters, + get_parameters_from_pickle, + get_parameters_from_yoshi, + logger, +) from aperoll.widgets.attitude_widget import ( AttitudeWidget, QuatRepresentation, ) from aperoll.widgets.error_message import ErrorMessage -from aperoll.widgets.parameters import ( - LineEdit, - get_default_parameters, - get_parameters_from_pickle, - get_parameters_from_yoshi, -) +from aperoll.widgets.line_edit import LineEdit from aperoll.widgets.proseco_params import ProsecoParams from aperoll.widgets.star_plot import StarPlot from aperoll.widgets.starcat_review import StarcatReview diff --git a/aperoll/widgets/star_plot.py b/aperoll/widgets/star_plot.py index d8e40b9..2012bea 100644 --- a/aperoll/widgets/star_plot.py +++ b/aperoll/widgets/star_plot.py @@ -788,7 +788,7 @@ def set_centroids(self, centroids): def main(): - from aperoll.widgets.parameters import get_default_parameters + from aperoll.utils import get_default_parameters params = get_default_parameters() From 57f5cfc664ad56d9cad1fa2384bd6add919cb909 Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Thu, 30 Jan 2025 11:26:27 -0500 Subject: [PATCH 07/10] Added method StarView.include_slot and renamed signal include_slot -> slot_included. While at it, also renamed signal include_star -> star_included --- aperoll/widgets/star_plot.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/aperoll/widgets/star_plot.py b/aperoll/widgets/star_plot.py index 2012bea..9d5e360 100644 --- a/aperoll/widgets/star_plot.py +++ b/aperoll/widgets/star_plot.py @@ -82,8 +82,8 @@ class StarFieldState: class StarView(QtW.QGraphicsView): - include_star = QtC.pyqtSignal(int, str, object) - include_slot = QtC.pyqtSignal(int, bool) + star_included = QtC.pyqtSignal(int, str, object) + slot_included = QtC.pyqtSignal(int, bool) update_proseco = QtC.pyqtSignal() def __init__(self, scene=None): @@ -100,6 +100,17 @@ def __init__(self, scene=None): self._draw_frame = False + def include_slot(self, slot, include): + fov = self.scene().main_fov.centroids[slot] + alt_fov = self.scene().alternate_fov.centroids[slot] + exclude = not include + if fov.excluded == exclude and alt_fov.excluded == exclude: + # nothing to do + return + fov.set_excluded(exclude) + alt_fov.set_excluded(exclude) + self.slot_included.emit(slot, include) + def _get_draw_frame(self): return self._draw_frame @@ -299,15 +310,14 @@ def contextMenuEvent(self, event): # noqa: PLR0912, PLR0915 result = menu.exec_(event.globalPos()) if result is not None: if centroid is not None and result.text().startswith("include slot"): - centroid.set_excluded(not result.isChecked()) - self.include_slot.emit(centroid.imgnum, not centroid.excluded) + self.include_slot(centroid.imgnum, result.isChecked()) elif stars and result.text().split()[0] in ["include", "exclude"]: action, action_type = result.text().split() if action == "include": star.included[action_type] = True if result.isChecked() else None elif action == "exclude": star.included[action_type] = False if result.isChecked() else None - self.include_star.emit( + self.star_included.emit( star.star["AGASC_ID"], action_type, star.included[action_type] ) elif result.text() == "Show FOV": @@ -686,8 +696,8 @@ def show_catalog(self, show=True): class StarPlot(QtW.QWidget): attitude_changed_eq = QtC.pyqtSignal(float, float, float) attitude_changed = QtC.pyqtSignal(Quat) - include_star = QtC.pyqtSignal(int, str, object) - include_slot = QtC.pyqtSignal(int, bool) + star_included = QtC.pyqtSignal(int, str, object) + slot_included = QtC.pyqtSignal(int, bool) update_proseco = QtC.pyqtSignal() def __init__(self, parent=None): @@ -715,8 +725,8 @@ def __init__(self, parent=None): self.scene.attitude_changed.connect(self.view.set_item_scale) self.scene.changed.connect(self.view.set_visibility) - self.view.include_star.connect(self.include_star) - self.view.include_slot.connect(self.include_slot) + self.view.star_included.connect(self.star_included) + self.view.slot_included.connect(self.slot_included) self.view.update_proseco.connect(self.update_proseco) def _attitude_changed(self): @@ -786,6 +796,8 @@ def show_fov(self, show=True): def set_centroids(self, centroids): self.scene.set_centroids(centroids) + def include_slot(self, slot, include): + self.view.include_slot(slot, include) def main(): from aperoll.utils import get_default_parameters From ec40a64d3826a7f36c75b3ed7cb46e178068d3de Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 29 Jan 2025 16:43:25 -0500 Subject: [PATCH 08/10] remove emitting signal from StarPlot.set_onboard_attitude --- aperoll/widgets/star_plot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/aperoll/widgets/star_plot.py b/aperoll/widgets/star_plot.py index 9d5e360..b2ae2fd 100644 --- a/aperoll/widgets/star_plot.py +++ b/aperoll/widgets/star_plot.py @@ -660,7 +660,6 @@ def set_onboard_attitude(self, attitude): """ if self.onboard_attitude_slot == "attitude": self.attitude = attitude - self.attitude_changed.emit() elif self.onboard_attitude_slot == "alternate_attitude": self.alternate_attitude = attitude From 210cae281177393cad6ae875ed8cf78ce712d32d Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 29 Jan 2025 16:44:27 -0500 Subject: [PATCH 09/10] defensively check that Quat is not None when setting attitude in proseco params --- aperoll/widgets/proseco_params.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aperoll/widgets/proseco_params.py b/aperoll/widgets/proseco_params.py index 9dced7f..78b3a98 100644 --- a/aperoll/widgets/proseco_params.py +++ b/aperoll/widgets/proseco_params.py @@ -66,6 +66,8 @@ def get_date(self): date = property(get_date, set_date) def set_attitude(self, attitude, emit=True): + if attitude is None: + return # calling self.set_value so I can skip emitting the signal self.set_value("att", Quat(attitude).equatorial.tolist(), emit=emit) From 53c2d1da89ef63c07e69d86bd10705bd97d7c24d Mon Sep 17 00:00:00 2001 From: Javier Gonzalez Date: Wed, 29 Jan 2025 16:46:43 -0500 Subject: [PATCH 10/10] added AttitudeWidget.set_read_only method --- aperoll/widgets/attitude_widget.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aperoll/widgets/attitude_widget.py b/aperoll/widgets/attitude_widget.py index fbde3c4..e730409 100644 --- a/aperoll/widgets/attitude_widget.py +++ b/aperoll/widgets/attitude_widget.py @@ -373,6 +373,10 @@ def _display_attitude_at_date(self, attitude, date): roll = off_nominal_roll(attitude, date) self._sun_pos.set_values([pitch, yaw, roll]) + def set_read_only(self, read_only=True): + self._q.setReadOnly(read_only) + self._eq.setReadOnly(read_only) + def _clear(self): self._q.reset() self._eq.reset()