diff --git a/aperoll/__init__.py b/aperoll/__init__.py index e69de29..f103005 100644 --- a/aperoll/__init__.py +++ b/aperoll/__init__.py @@ -0,0 +1,3 @@ +import ska_helpers + +__version__ = ska_helpers.get_version(__package__) diff --git a/aperoll/scripts/aperoll_main.py b/aperoll/scripts/aperoll_main.py index 885dfce..0adf94c 100755 --- a/aperoll/scripts/aperoll_main.py +++ b/aperoll/scripts/aperoll_main.py @@ -3,6 +3,7 @@ # from PyQt5 import QtCore as QtC, QtWidgets as QtW, QtGui as QtG from PyQt5 import QtWidgets as QtW +from aperoll.utils import AperollException, logger from aperoll.widgets.main_window import MainWindow @@ -10,20 +11,34 @@ def get_parser(): import argparse parse = argparse.ArgumentParser() - parse.add_argument("--date", default="2021-08-23 00:00:00.000") - parse.add_argument("--ra", default="6.45483333") - parse.add_argument("--dec", default="-26.03683611") + 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] + parse.add_argument( + "--log-level", help="Set the log level", default="INFO", choices=levels + ) return parse def main(): - args = get_parser().parse_args() - - app = QtW.QApplication([]) - w = MainWindow(opts=vars(args)) - w.resize(1500, 1000) - w.show() - app.exec() + parser = get_parser() + args = parser.parse_args() + + logger.setLevel(args.log_level) + + try: + app = QtW.QApplication([]) + w = MainWindow(opts=vars(args)) + w.resize(1500, 1000) + w.show() + app.exec() + except AperollException as e: + logger.error(f"Error: {e}") + parser.exit(1) + except FileNotFoundError as e: + logger.error(f"Error: {e}") + parser.exit(1) if __name__ == "__main__": diff --git a/aperoll/utils.py b/aperoll/utils.py new file mode 100644 index 0000000..925dfd5 --- /dev/null +++ b/aperoll/utils.py @@ -0,0 +1,7 @@ +from ska_helpers import logging + +logger = logging.basic_logger("aperoll") + + +class AperollException(RuntimeError): + pass diff --git a/aperoll/widgets/main_window.py b/aperoll/widgets/main_window.py index d8262df..8ab6232 100644 --- a/aperoll/widgets/main_window.py +++ b/aperoll/widgets/main_window.py @@ -1,17 +1,90 @@ # from PyQt5 import QtCore as QtC, QtWidgets as QtW, QtGui as QtG +import gzip +import os +import pickle +import tarfile from pathlib import Path from tempfile import TemporaryDirectory +import numpy as np import PyQt5.QtWebEngineWidgets as QtWe +import PyQt5.QtWidgets as QtW +import sparkles from astropy import units as u from cxotime import CxoTime from proseco import get_aca_catalog from PyQt5 import QtCore as QtC -from PyQt5 import QtWidgets as QtW from Quaternion import Quat +from ska_helpers import utils + +from aperoll.utils import logger from .parameters import Parameters from .star_plot import StarPlot +from .starcat_view import StarcatView + +STYLE = """ + +""" class WebPage(QtWe.QWebEnginePage): @@ -31,38 +104,83 @@ def on_url_changed(self, url): page.deleteLater() -class MainWindow(QtW.QWidget): - def __init__(self, opts=None): +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.web_page = None + self.opts = opts - self._tmp_dir = TemporaryDirectory() - self._dir = Path(self._tmp_dir.name) + 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_view = StarcatView() + + layout = QtW.QVBoxLayout(self._main) + layout_2 = QtW.QHBoxLayout() - layout = QtW.QHBoxLayout(self) layout.addWidget(self.parameters) - layout.addWidget(self.plot) + layout_2.addWidget(self.starcat_view) + 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) - layout.setStretch(0, 1) - layout.setStretch(1, 4) - self.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._do_it) + self.parameters.do_it.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.connect(self.parameters.set_ra_dec) + self._data = Data(self.parameters.proseco_args()) + self.outdir = Path(os.getcwd()) + self._init() - # try: - # self._do_it() - # except Exception as e: - # print(e) - # pass + + 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, update=False) + self.starcat_view.set_catalog(aca) def closeEvent(self, event): if self.web_page is not None: @@ -70,26 +188,26 @@ def closeEvent(self, event): self.web_page = None event.accept() + def _parameters_changed(self): + self._data.reset(self.parameters.proseco_args()) + def _init(self): - print("parameters:", self.parameters.values) if self.parameters.values: - # obsid = self.parameters.values["obsid"] ra, dec = self.parameters.values["ra"], self.parameters.values["dec"] roll = self.parameters.values["roll"] time = CxoTime(self.parameters.values["date"]) - - # aca_attitude = calc_aca_from_targ( - # Quat(equatorial=(float(ra / u.deg), float(dec / u.deg), nominal_roll)), - # 0, - # 0 - # ) aca_attitude = Quat( equatorial=(float(ra / u.deg), float(dec / u.deg), roll) ) - print("ra, dec, roll =", (float(ra / u.deg), float(dec / u.deg), roll)) self.plot.set_base_attitude(aca_attitude, update=False) self.plot.set_time(time, update=True) + def _reset(self): + self.parameters.set_parameters(**self.opts) + self.starcat_view.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"] @@ -97,84 +215,147 @@ def _draw_test(self): aca_attitude = Quat( equatorial=(float(ra / u.deg), float(dec / u.deg), roll) ) - # self.plot.show_test_stars_q(aca_attitude) 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 _do_it(self): - print("parameters:", self.parameters.values) - if self.parameters.values: - obsid = self.parameters.values["obsid"] - ra, dec = self.parameters.values["ra"], self.parameters.values["dec"] - roll = self.parameters.values["roll"] - time = CxoTime(self.parameters.values["date"]) + def _run_proseco(self): + """ + Display the star catalog. + """ + if self._data.proseco: + self.starcat_view.set_catalog(self._data.proseco["aca"]) + self.plot.set_catalog(self._data.proseco["catalog"], update=False) - # aca_attitude = calc_aca_from_targ( - # Quat(equatorial=(float(ra / u.deg), float(dec / u.deg), nominal_roll)), - # 0, - # 0 - # ) - aca_attitude = Quat( - equatorial=(float(ra / u.deg), float(dec / u.deg), roll) - ) - print("ra, dec, roll =", (float(ra / u.deg), float(dec / u.deg), roll)) - from pprint import pprint - - pprint( - { - "obsid": obsid, - "att": aca_attitude, - "date": time, - "n_fid": self.parameters.values["n_fid"], - "n_guide": self.parameters.values["n_guide"], - "dither_acq": (16, 16), # standard dither with ACIS - "dither_guide": (16, 16), # standard dither with ACIS - "t_ccd_acq": self.parameters.values["t_ccd"], - "t_ccd_guide": self.parameters.values["t_ccd"], - "man_angle": 0, # what is a sensible number to use?? - "detector": self.parameters.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 - } + 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"), ) - catalog = get_aca_catalog( - obsid=obsid, - att=aca_attitude, - date=time, - n_fid=self.parameters.values["n_fid"], - n_guide=self.parameters.values["n_guide"], - dither_acq=(16, 16), # standard dither with ACIS - dither_guide=(16, 16), # standard dither with ACIS - t_ccd_acq=self.parameters.values["t_ccd"], - t_ccd_guide=self.parameters.values["t_ccd"], - man_angle=0, # what is a sensible number to use?? - detector=self.parameters.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 + 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 - # run_aca_review( - # 'Exploration', - # acars=[catalog.get_review_table()], - # report_dir=self._dir / 'sparkles', - # report_level='all', - # roll_level='none', - # ) - # print(f'sparkles report at {self._dir / "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._dir / 'sparkles' / 'index.html' - # web.load(QtC.QUrl(f'file://{url}')) - # web.show() - # w.show() - # except Exception as e: - # print(e) - - self.plot.set_catalog(catalog, update=False) + +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 index 2b76c72..245577f 100644 --- a/aperoll/widgets/parameters.py +++ b/aperoll/widgets/parameters.py @@ -1,19 +1,62 @@ +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): + super().setText(text) + self._check_value() + def keyPressEvent(self, event): + super().keyPressEvent(event) + if event.key() == QtC.Qt.Key_Return: + self._check_value() -def get_parameters(): - import maude - from Quaternion import Quat + def focusOutEvent(self, event): + super().focusOutEvent(event) + self._check_value() + + def _check_value(self): + if self.text() != self._prev_text: + self._prev_text = self.text() + 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)]) - from kadi.commands.observations import get_detector_and_sim_offset instrument, sim_offset = get_detector_and_sim_offset(data["3TSCPOS"]) t_ccd = (data["AACCCDPT"] - 32) * 5 / 9 @@ -21,95 +64,335 @@ def get_parameters(): 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, } - from pprint import pprint - pprint(result) 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": catalog.t_ccd, + "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 - kwargs = {} - super().__init__() - self.date_label = QtW.QLabel("date") - self.date_edit = QtW.QLineEdit(self) - self.ra_label = QtW.QLabel("ra") - self.ra_edit = QtW.QLineEdit(self) - self.dec_label = QtW.QLabel("dec") - self.dec_edit = QtW.QLineEdit(self) - self.roll_label = QtW.QLabel("roll") - self.roll_edit = QtW.QLineEdit(self) - self.n_guide_label = QtW.QLabel("n_guide") - self.n_guide_edit = QtW.QLineEdit(self) - self.n_fid_label = QtW.QLabel("n_fid") - self.n_fid_edit = QtW.QLineEdit(self) - self.n_t_ccd_label = QtW.QLabel("t_ccd") - self.n_t_ccd_edit = QtW.QLineEdit(self) - self.instrument_label = QtW.QLabel("instrument") + 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("Starcheck") + 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") - v_layout = QtW.QVBoxLayout(self) - layout = QtW.QGridLayout() - layout.addWidget(self.date_label, 0, 0) - layout.addWidget(self.date_edit, 0, 1) - layout.addWidget(self.ra_label, 1, 0) - layout.addWidget(self.ra_edit, 1, 1) - layout.addWidget(self.dec_label, 2, 0) - layout.addWidget(self.dec_edit, 2, 1) - layout.addWidget(self.roll_label, 3, 0) - layout.addWidget(self.roll_edit, 3, 1) - layout.addWidget(self.n_guide_label, 4, 0) - layout.addWidget(self.n_guide_edit, 4, 1) - layout.addWidget(self.n_fid_label, 5, 0) - layout.addWidget(self.n_fid_edit, 5, 1) - layout.addWidget(self.n_t_ccd_label, 6, 0) - layout.addWidget(self.n_t_ccd_edit, 6, 1) - layout.addWidget(self.instrument_label, 7, 0) - layout.addWidget(self.instrument_edit, 7, 1) - layout.addWidget(self.do, 8, 1) - layout.addWidget(self.draw_test_button, 9, 1) - v_layout.addLayout(layout) - v_layout.addStretch(0) - - params = get_parameters() + 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.setText(kwargs.get("date", params["date"])) - self.ra_edit.setText(kwargs.get("ra", f"{params['attitude'].ra:.8f}")) - self.dec_edit.setText(kwargs.get("dec", f"{params['attitude'].dec:.8f}")) - self.roll_edit.setText(kwargs.get("roll", f"{params['attitude'].roll:.8f}")) - self.n_guide_edit.setText(kwargs.get("n_guide", "5")) - self.n_fid_edit.setText(kwargs.get("n_fid", "3")) - self.n_t_ccd_edit.setText(kwargs.get("t_ccd", f"{params['t_ccd']:.2f}")) - self.instrument_edit.setCurrentText( - kwargs.get("instrument", params["instrument"]) - ) - - self.setLayout(v_layout) + 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.values = self._validate() 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 = 0 if n_fid else 38000 + 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" @@ -131,10 +414,35 @@ def _validate(self, quiet=False): "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: - print(e) + logger.warning(e) return {} def _do_it(self): @@ -146,3 +454,67 @@ 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}") + + 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 + } + + 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/star_plot.py b/aperoll/widgets/star_plot.py index 7e7d2bf..4f42b4b 100644 --- a/aperoll/widgets/star_plot.py +++ b/aperoll/widgets/star_plot.py @@ -44,11 +44,75 @@ def get_stars(starcat_time, quaternion, radius=2): return stars +class Star(QtW.QGraphicsEllipseItem): + def __init__(self, star, parent=None, highlight=False): + s = symsize(star["MAG_ACA"]) + # note that the coordinate system is (row, -col) + rect = QtC.QRectF(star["row"] - s / 2, -star["col"] - s / 2, s, s) + super().__init__(rect, parent) + self.star = star + self.highlight = highlight + color = self.color() + self.setBrush(QtG.QBrush(color)) + self.setPen(QtG.QPen(color)) + self.included = { + "acq": None, + "guide": None, + } + + def __repr__(self): + return f"Star({self.star['AGASC_ID']})" + + def color(self): + if self.highlight: + return QtG.QColor("red") + if self.star["MAG_ACA"] > 10.5: + return QtG.QColor("lightGray") + if self.bad(): + return QtG.QColor(255, 99, 71, 191) + return QtG.QColor("black") + + def bad(self): + ok = ( + (self.star["CLASS"] == 0) + & (self.star["MAG_ACA"] > 5.3) + & (self.star["MAG_ACA"] < 11.0) + & (~np.isclose(self.star["COLOR1"], 0.7)) + & (self.star["MAG_ACA_ERR"] < 100.0) # mag_err is in 0.01 mag + & (self.star["ASPQ1"] < 40) + & ( # Less than 2 arcsec centroid offset due to nearby spoiler + self.star["ASPQ2"] == 0 + ) + & (self.star["POS_ERR"] < 3000) # Position error < 3.0 arcsec + & ( + (self.star["VAR"] == -9999) | (self.star["VAR"] == 5) + ) # Not known to vary > 0.2 mag + ) + return not ok + + def text(self): + return ( + "
" + f"ID: {self.star['AGASC_ID']}\n" + f"mag: {self.star['MAG_ACA']:.2f} +- {self.star['MAG_ACA_ERR']/100:.2}\n" + f"color: {self.star['COLOR1']:.2f}\n" + f"ASPQ1: {self.star['ASPQ1']}\n" + f"ASPQ2: {self.star['ASPQ2']}\n" + f"class: {self.star['CLASS']}\n" + f"pos err: {self.star['POS_ERR']/1000} mas\n" + f"VAR: {self.star['VAR']}" + "" + ) + + class StarView(QtW.QGraphicsView): roll_changed = QtC.pyqtSignal(float) + include_star = QtC.pyqtSignal(int, str, object) def __init__(self, scene=None): super().__init__(scene) + # mouseTracking is set so we can show tooltips + self.setMouseTracking(True) self._start = None self._rotating = False @@ -56,6 +120,15 @@ def __init__(self, scene=None): def mouseMoveEvent(self, event): pos = event.pos() + + items = [item for item in self.items(event.pos()) if isinstance(item, Star)] + if items: + global_pos = event.globalPos() + # supposedly, the following should cause the tooltip to stay for a long time + # but it is the same + # QtW.QToolTip.showText(global_pos, items[0].text(), self, QtC.QRect(), 1000000000) + QtW.QToolTip.showText(global_pos, items[0].text()) + if self._start is None: return @@ -95,12 +168,14 @@ def mouseMoveEvent(self, event): self._start = pos def mouseReleaseEvent(self, event): - self._start = None + if event.button() == QtC.Qt.LeftButton: + self._start = None def mousePressEvent(self, event): - self._moving = False - self._rotating = False - self._start = event.pos() + if event.button() == QtC.Qt.LeftButton: + self._moving = False + self._rotating = False + self._start = event.pos() def wheelEvent(self, event): scale = 1 + 0.5 * event.angleDelta().y() / 360 @@ -240,9 +315,47 @@ def re_center(self): transform = self.viewportTransform().rotate(-self.get_roll_offset()) self.setTransform(transform) + def contextMenuEvent(self, event): + items = [item for item in self.items(event.pos()) if isinstance(item, Star)] + if not items: + return + item = items[0] + + menu = QtW.QMenu() + + incl_action = QtW.QAction("include acq", menu, checkable=True) + incl_action.setChecked(item.included["acq"] is True) + menu.addAction(incl_action) + + excl_action = QtW.QAction("exclude acq", menu, checkable=True) + excl_action.setChecked(item.included["acq"] is False) + menu.addAction(excl_action) + + incl_action = QtW.QAction("include guide", menu, checkable=True) + incl_action.setChecked(item.included["guide"] is True) + menu.addAction(incl_action) + + excl_action = QtW.QAction("exclude guide", menu, checkable=True) + excl_action.setChecked(item.included["guide"] is False) + menu.addAction(excl_action) + + result = menu.exec_(event.globalPos()) + if result is not None: + action, action_type = result.text().split() + if items: + if action == "include": + item.included[action_type] = True if result.isChecked() else None + elif action == "exclude": + item.included[action_type] = False if result.isChecked() else None + self.include_star.emit( + item.star["AGASC_ID"], action_type, item.included[action_type] + ) + event.accept() + class StarPlot(QtW.QWidget): attitude_changed = QtC.pyqtSignal(float, float, float) + include_star = QtC.pyqtSignal(int, str, object) def __init__(self, parent=None): super().__init__(parent) @@ -268,7 +381,7 @@ def __init__(self, parent=None): # "current attitude" refers to the attitude taking into account the viewport's position self._current_attitude = None self._time = None - self._highlight = None + self._highlight = [] self._catalog = None self._catalog_items = [] @@ -277,6 +390,8 @@ def __init__(self, parent=None): self.scene.sceneRectChanged.connect(self._radec_changed) self.view.roll_changed.connect(self._roll_changed) + self.view.include_star.connect(self.include_star) + def _radec_changed(self): # RA/dec change when the scene rectangle changes, and its given by the rectangle's center # the base attitude corresponds to RA/dec at the origin, se we take the displacement @@ -392,6 +507,7 @@ def show_test_stars_q(self, q, N=100, mag=10): def show_stars(self): self.scene.clear() + self._catalog_items = [] if self._base_attitude is None or self._time is None: return self.stars = get_stars(self._time, self._base_attitude) @@ -401,17 +517,10 @@ def show_stars(self): ) black_pen = QtG.QPen() black_pen.setWidth(2) - black_brush = QtG.QBrush(QtG.QColor("black")) - red_pen = QtG.QPen(QtG.QColor("red")) - red_brush = QtG.QBrush(QtG.QColor("red")) for star in self.stars: - s = symsize(star["MAG_ACA"]) - # note that the coordinate system is (row, -col) - rect = QtC.QRectF(star["row"] - s / 2, -star["col"] - s / 2, s, s) - if self._highlight is not None and star["AGASC_ID"] in self._highlight: - self.scene.addEllipse(rect, red_pen, red_brush) - else: - self.scene.addEllipse(rect, black_pen, black_brush) + self.scene.addItem( + Star(star, highlight=star["AGASC_ID"] in self._highlight) + ) # self.view.centerOn(QtC.QPointF(self._origin[0], self._origin[1])) self.view.re_center() @@ -435,6 +544,14 @@ def show_catalog(self): fids = cat[cat["type"] == "FID"] mon_wins = cat[cat["type"] == "MON"] + for star in cat: + txt = self.scene.addText( + f"{star['idx']}", + QtG.QFont("Arial", 32), + ) + txt.setPos(star["row"] + 16, -star["col"] - 40) + txt.setDefaultTextColor(QtG.QColor("red")) + self._catalog_items.append(txt) for gui_star in gui_stars: w = 20 # note that the coordinate system is (row, -col) diff --git a/aperoll/widgets/starcat_view.py b/aperoll/widgets/starcat_view.py new file mode 100644 index 0000000..cef526b --- /dev/null +++ b/aperoll/widgets/starcat_view.py @@ -0,0 +1,121 @@ +# from PyQt5 import QtCore as QtC, QtWidgets as QtW, QtGui as QtG +import PyQt5.QtGui as QtG +import PyQt5.QtWebEngineWidgets as QtWe +import PyQt5.QtWidgets as QtW +from PyQt5 import QtCore as QtC + +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 StarcatView(QtW.QTextEdit): + def __init__(self, catalog=None, parent=None): + super().__init__(parent) + font = QtG.QFont("Courier New") # setting a fixed-width font (close enough) + font.setPixelSize(12) # setting a pixel size so it can be changed later + self.setFont(font) + + self.set_catalog(catalog) + + def reset(self): + self.set_catalog(None) + + def set_catalog(self, catalog): + if catalog is None: + self.setText("") + else: + self.setText(f"{STYLE}
{catalog.get_text_pre()}") + + def resizeEvent(self, _size): + super().resizeEvent(_size) + font = self.font() + header = ( + "idx slot id type sz p_acq mag mag_err " + "maxmag yang zang row col dim res halfw" + ) + n_lines = 35 + scale_x = float(0.9 * self.width()) / QtG.QFontMetrics(font).width(header) + scale_y = float(0.9 * self.height()) / ( + n_lines * QtG.QFontMetrics(font).height() + ) + pix_size = int(font.pixelSize() * min(scale_x, scale_y)) + if pix_size > 0: + font.setPixelSize(pix_size) + self.setFont(font)