diff --git a/aperoll/star_field_items.py b/aperoll/star_field_items.py index e27e172..5ae2bbf 100644 --- a/aperoll/star_field_items.py +++ b/aperoll/star_field_items.py @@ -14,7 +14,10 @@ from PyQt5 import QtGui as QtG from PyQt5 import QtWidgets as QtW +from aperoll import utils + __all__ = [ + "star_field_position", "Star", "Catalog", "FidLight", @@ -22,10 +25,69 @@ "GuideStar", "AcqStar", "MonBox", + "Centroid", + "CameraOutline", + "FieldOfView", ] +def star_field_position( + attitude=None, + yag=None, + zag=None, + ra=None, + dec=None, + row=None, + col=None, +): + """ + Calculate the position of an item in the star_field. + + This function completely determines the position of an item in the star field, given the + attitude. Some items' positions are determined by yag/zag (like centroids), others' are + determined by RA/Dec (like stars), and others are given in pixels (like bad pixels). + + Catalog elements (guide/acq/fids) use RA/Dec to make sure they always point to the star in the + catalog even when the attitude changes. + + Parameters + ---------- + attitude : Quaternion, optional + The attitude of the scene. This is required if the position is given by RA/dec. + yag : float, optional + The Y angle in degrees. + zag : float, optional + The Z angle in degrees. + ra : float, optional + The RA in degrees. + dec : float, optional + The Dec in degrees. + row : float, optional + The row in pixels. + col : float, optional + The column in pixels. + + Returns + ------- + x, y : float + The position of the item in the scene. + """ + if ra is not None and dec is not None: + if attitude is None: + raise ValueError("Attitude must be given if RA/Dec is given.") + yag, zag = radec_to_yagzag(ra, dec, attitude) + if yag is not None and zag is not None: + row, col = yagzag_to_pixels(yag, zag, allow_bad=True) + if row is None or col is None: + raise ValueError("Either YAG/ZAG, RA/Dec or row/col must be given.") + # note that the coordinate system is (row, -col), which is (-yag, -zag) + return row, -col + + def symsize(mag): + """ + Symbol size for a star of a given magnitude. + """ # map mags to figsizes, defining # mag 6 as 40 and mag 11 as 3 # interp should leave it at the bounding value outside @@ -34,6 +96,29 @@ def symsize(mag): class Star(QtW.QGraphicsEllipseItem): + """ + QGraphicsItem representing a star. + + Stars are depicted as circles, and the color is automatically set based on the magnitude + (faint stars are gray) and whether the star is an acquisition or guide star candidate: + + - If the star is maked as "highlighted" it is drawn bright red. + - Faint stars (mag > 10.5 are light gray). + - Stars that are not acquisition of guide candidates are tomato red. + - All others are black. + + This class also handles the tooltip that shows up when one hovers over the star. + + Parameters + ---------- + star : astropy.table.Row + One row from the AGASC table. + parent : QGraphicsItem, optional + The parent item. + highlight : bool, optional + If True, the star is highlighted in red. + """ + def __init__(self, star, parent=None, highlight=False): s = symsize(star["MAG_ACA"]) rect = QtC.QRectF(-s / 2, -s / 2, s, s) @@ -72,12 +157,12 @@ 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"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"pos err: {self.star['POS_ERR'] / 1000} mas\n"
             f"VAR:     {self.star['VAR']}"
             "
" ) @@ -89,18 +174,36 @@ class Catalog(QtW.QGraphicsItem): Note that the position of the catalog is ALLWAYS (0,0) and the item positions need to be set separately for a given attitude. + + Parameters + ---------- + catalog : astropy.table.Table, optional + A star catalog. The following columns are used: idx, type, yang, zang, halfw. """ - def __init__(self, catalog, parent=None): + def __init__(self, catalog=None, parent=None): super().__init__(parent) + self.reset(catalog) + self.setZValue(50) + + def reset(self, catalog): + self.starcat = None + for item in self.childItems(): + item.setParentItem(None) + item.hide() + + if catalog is None: + return + self.starcat = catalog.copy() # will add some columns cat = Table(self.starcat) - # item positions are set from row/col - cat["row"], cat["col"] = yagzag_to_pixels( - cat["yang"], cat["zang"], allow_bad=True - ) - # when attitude changes, the positions (row, col) are recalculated from (ra, dec) + + # If the attitude changes (e.g. if we rotate the star field, changing the roll angle) + # the yang/zang values will not be the same as the ones originally in the catalog, + # because the corresponding star moved in the CCD. To keep track of that, we project back + # to get the corresponding RA/dec values assuming the attitude in the catalog. + # Later, when attitude changes, the positions are recalculated from (ra, dec) # so these items move with the corresponding star. cat["ra"], cat["dec"] = yagzag_to_radec( cat["yang"], cat["zang"], self.starcat.att @@ -116,6 +219,8 @@ def __init__(self, catalog, parent=None): self.mon_stars = [MonBox(mon_box, self) for mon_box in mon_wins] self.fid_lights = [FidLight(fid, self) for fid in fids] + self.set_pos_for_attitude(self.scene().attitude) + def setPos(self, *_args, **_kwargs): # the position of the catalog is ALLWAYS (0,0) pass @@ -132,25 +237,94 @@ def set_pos_for_attitude(self, attitude): # item positions are relative to the parent's position (self) # but the parent's position is (or should be) always (0, 0) for item in self.childItems(): - yag, zag = radec_to_yagzag( - item.starcat_row["ra"], item.starcat_row["dec"], attitude + x, y = star_field_position( + attitude, + ra=item.starcat_row["ra"], + dec=item.starcat_row["dec"], ) - row, col = yagzag_to_pixels(yag, zag, allow_bad=True) - # item.setPos(-yag, -zag) - item.setPos(row, -col) + item.setPos(x, y) def boundingRect(self): + # this item draws nothing, it just holds children, but this is a required method. return QtC.QRectF(0, 0, 1, 1) def paint(self, _painter, _option, _widget): - # this item draws nothing, it just holds children + # this item draws nothing, it just holds children, but this is a required method. pass def __repr__(self): return repr(self.starcat) +class Centroid(QtW.QGraphicsEllipseItem): + """ + QGraphicsItem representing a centroid. + + Centroids are depicted as blue circles with an X inside. + + Parameters + ---------- + imgnum : int + The image number (0-7). + parent : QGraphicsItem, optional + The parent item. + """ + + def __init__(self, imgnum, parent=None, fiducial=False): + self.imgnum = imgnum + self.excluded = False + s = 18 + w = 3 + rect = QtC.QRectF(-s, -s, 2 * s, 2 * s) + super().__init__(rect, parent) + color = QtG.QColor("blue") + pen = QtG.QPen(color, w) + self.setPen(pen) + + s /= np.sqrt(2) + self._line_1 = QtW.QGraphicsLineItem(-s, -s, s, s, self) + self._line_2 = QtW.QGraphicsLineItem(s, -s, -s, s, self) + + self._label = QtW.QGraphicsTextItem(f"{imgnum}", self) + self._label.setFont(QtG.QFont("Arial", 30)) + self._label.setPos(30, -30) + + self.fiducial = fiducial + self._set_style() + + def _set_style(self): + color = QtG.QColor("red") if self.fiducial else QtG.QColor("blue") + color.setAlpha(85 if self.excluded else 255) + pen = self.pen() + pen.setColor(color) + self.setPen(pen) + self._line_1.setPen(pen) + self._line_2.setPen(pen) + self._label.setDefaultTextColor(color) + + def set_excluded(self, excluded): + self.excluded = excluded + self._set_style() + + def set_fiducial(self, fiducial): + self.fiducial = fiducial + self._set_style() + + class FidLight(QtW.QGraphicsEllipseItem): + """ + QGraphicsItem representing a fiducial light. + + Fiducial lights are depicted as red circles with a cross inside. + + Parameters + ---------- + imgnum : int + The image number (0-7). + parent : QGraphicsItem, optional + The parent item. + """ + def __init__(self, fid, parent=None): self.starcat_row = fid s = 27 @@ -160,8 +334,6 @@ def __init__(self, fid, parent=None): self.fid = fid pen = QtG.QPen(QtG.QColor("red"), w) self.setPen(pen) - # self.setPos(-fid["yang"], -fid["zang"]) - self.setPos(fid["row"], -fid["col"]) line = QtW.QGraphicsLineItem(-s, 0, s, 0, self) line.setPen(pen) @@ -170,14 +342,25 @@ def __init__(self, fid, parent=None): class StarcatLabel(QtW.QGraphicsTextItem): + """ + QGraphicsItem representing a label for a star in the star catalog. + + The label is the star's index in the catalog. + + Parameters + ---------- + star : astropy.table.Row + The proseco catalog row. + parent : QGraphicsItem, optional + The parent item. + """ + def __init__(self, star, parent=None): self.starcat_row = star super().__init__(f"{star['idx']}", parent) self._offset = 30 self.setFont(QtG.QFont("Arial", 30)) self.setDefaultTextColor(QtG.QColor("red")) - # self.setPos(-star["yang"], -star["zang"]) - self.setPos(star["row"], -star["col"]) def setPos(self, x, y): rect = self.boundingRect() @@ -187,6 +370,19 @@ def setPos(self, x, y): class GuideStar(QtW.QGraphicsEllipseItem): + """ + QGraphicsItem representing a guide star. + + Guide stars are depicted as green circles. + + Parameters + ---------- + star : astropy.table.Row + The proseco catalog row. + parent : QGraphicsItem, optional + The parent item. + """ + def __init__(self, star, parent=None): self.starcat_row = star s = 27 @@ -194,22 +390,44 @@ def __init__(self, star, parent=None): rect = QtC.QRectF(-s, -s, s * 2, s * 2) super().__init__(rect, parent) self.setPen(QtG.QPen(QtG.QColor("green"), w)) - # self.setPos(-star["yang"], -star["zang"]) - self.setPos(star["row"], -star["col"]) class AcqStar(QtW.QGraphicsRectItem): + """ + QGraphicsItem representing an acquisition star. + + Acquisition stars are depicted as blue rectangles with width given by "halfw". + + Parameters + ---------- + star : astropy.table.Row + The proseco catalog row. + parent : QGraphicsItem, optional + The parent item. + """ + def __init__(self, star, parent=None): self.starcat_row = star hw = star["halfw"] / 5 w = 5 super().__init__(-hw, -hw, hw * 2, hw * 2, parent) self.setPen(QtG.QPen(QtG.QColor("blue"), w)) - # self.setPos(-star["yang"], -star["zang"]) - self.setPos(star["row"], -star["col"]) class MonBox(QtW.QGraphicsRectItem): + """ + QGraphicsItem representing an monitoring star. + + Monitoring stars are depicted as orange rectangles with width given by "halfw". + + Parameters + ---------- + star : astropy.table.Row + The proseco catalog row. + parent : QGraphicsItem, optional + The parent item. + """ + def __init__(self, star, parent=None): self.starcat_row = star # starcheck convention was to plot monitor boxes at 2X halfw @@ -217,4 +435,312 @@ def __init__(self, star, parent=None): w = 5 super().__init__(-(hw * 2), -(hw * 2), hw * 4, hw * 4, parent) self.setPen(QtG.QPen(QtG.QColor(255, 165, 0), w)) - self.setPos(star["row"], -star["col"]) + + +class FieldOfView(QtW.QGraphicsItem): + """ + QGraphicsItem that groups together other items related to a (hypothetical) attitude. + + Items managed by this class: + + - CameraOutline: the outline of the ACA CCD. + - Centroids: the centroids of the stars. + + Parameters + ---------- + attitude : Quaternion, optional + The attitude of the camera associated with this FieldOfView. + alternate_outline : bool, optional + Boolean flag to use a simpler outline for the camera. + """ + + def __init__(self, attitude=None, alternate_outline=False): + super().__init__() + self.camera_outline = None + self.alternate_outline = alternate_outline + self.attitude = attitude + self.centroids = [Centroid(i, parent=self) for i in range(8)] + self._centroids = np.array( + # pixels right outside the CCD by default + [ + (0, 511, 511, 511, 511, 14, -2490, 2507, False, False) + for _ in self.centroids + ], + dtype=[ + ("IMGNUM", int), + ("IMGROW0_8X8", float), + ("IMGCOL0_8X8", float), + ("IMGROW0", int), + ("IMGCOL0", int), + ("AOACMAG", float), + ("YAGS", float), + ("ZAGS", float), + ("IMGFID", bool), + ("excluded", bool), + ], + ) + self._centroids["IMGNUM"] = np.arange(8) + self.show_centroids = True + self.setZValue(80) + + def get_attitude(self): + """ + Get the attitude of the camera associated with this FieldOfView. + """ + return self._attitude + + def set_attitude(self, attitude): + """ + Set the attitude of the camera associated with this FieldOfView. + """ + if hasattr(self, "_attitude") and attitude == self._attitude: + return + self._attitude = attitude + if self.camera_outline is None: + self.camera_outline = CameraOutline( + attitude, parent=self, simple=self.alternate_outline + ) + else: + self.camera_outline.attitude = attitude + if self.scene() is not None and self.scene().attitude is not None: + self.set_pos_for_attitude(self.scene().attitude) + + attitude = property(get_attitude, set_attitude) + + def set_pos_for_attitude(self, attitude): + """ + Set the position of all items in the field of view for the given attitude. + + This method is called when the attitude of the scene changes. The position of all items is + recalculated based on the new attitude. + + Parameters + ---------- + attitude : Quaternion + The attitude of the scene. + """ + if self.camera_outline is not None: + self.camera_outline.set_pos_for_attitude(attitude) + self._set_centroid_pos_for_attitude(attitude) + + def _set_centroid_pos_for_attitude(self, attitude): + yag, zag = self._centroids["YAGS"], self._centroids["ZAGS"] + if attitude == self.attitude: + x, y = star_field_position(yag=yag, zag=zag) + else: + # `self.attitude` is the attitude represented by this field of view, but the scene's + # attitude is `attitude`. We first project back to get the ra/dec pointed to by the + # centroids using `self.attitude`, and calculate the position in the scene coordinate + # system using those ra/dec values + ra, dec = yagzag_to_radec(yag, zag, self.attitude) + x, y = star_field_position(attitude=attitude, ra=ra, dec=dec) + + self.set_show_centroids(self._centroids_visible) + + for i, centroid in enumerate(self.centroids): + centroid.setPos(x[i], y[i]) + + def boundingRect(self): + # this item draws nothing, it just holds children, but this is a required method. + return QtC.QRectF(0, 0, 1, 1) + + def paint(self, _painter, _option, _widget): + # this item draws nothing, it just holds children, but this is a required method. + pass + + def set_centroids(self, centroids): + """ + Set the centroid values (usually from telemetry). + + Parameters + ---------- + centroids : astropy.table.Table + A table with the following columns: IMGNUM, AOACMAG, YAGS, ZAGS, IMGFID. + """ + missing_cols = {"IMGNUM", "AOACMAG", "YAGS", "ZAGS", "IMGFID"} - set( + centroids.dtype.names + ) + if missing_cols: + raise ValueError(f"Missing columns in centroids: {missing_cols}") + + cols = list(centroids.dtype.names) + for col in cols: + self._centroids[col] = centroids[col] + self._set_centroid_pos_for_attitude(self.scene().attitude) + + def set_show_centroids(self, show=True): + self._centroids_visible = show + # centroids are hidden if they fall outside the CCD + row, col = yagzag_to_pixels( + self._centroids["YAGS"], self._centroids["ZAGS"], allow_bad=True + ) + off_ccd = (row < -511) | (row > 511) | (col < -511) | (col > 511) + for i, centroid in enumerate(self.centroids): + centroid.set_fiducial(self._centroids["IMGFID"][i]) + if off_ccd[i]: + centroid.setVisible(False) + else: + centroid.setVisible(show) + + def get_show_centroids(self): + return self._centroids_visible + + show_centroids = property(get_show_centroids, set_show_centroids) + + def get_centroid_table(self): + """ + Returns the centroid data as an astropy table, including whether each centroid is visible. + + This is here for debugging purposes. + """ + table = Table() + table["IMGNUM"] = self._centroids["IMGNUM"] + table["AOACMAG"] = self._centroids["AOACMAG"] + table["YAG"] = self._centroids["YAGS"] + table["ZAG"] = self._centroids["ZAGS"] + table["IMGFID"] = self._centroids["IMGFID"] + table["excluded"] = self._centroids["excluded"] + table["visible"] = [centroid.isVisible() for centroid in self.centroids] + + return table + + +class CameraOutline(QtW.QGraphicsItem): + """ + A QGraphicsItem that represents the outline (the edges) of the ACA CCD. + + This is a graphics item to represent a hypothetical outline of the ACA CCD if the camera were + set to a given attitude. + + To calculate the position of the edges in the scene, the edges are first mapped to RA/dec, + and these RA/dec are later used to calculate the position in the scene coordinate system. + """ + + def __init__(self, attitude, parent=None, simple=False): + super().__init__(parent) + self.simple = simple + self._frame = utils.get_camera_fov_frame() + self.attitude = attitude + self.setZValue(100) + + def boundingRect(self): + # roughly half of the CCD size in arcsec (including some margin) + w = 2650 + return QtC.QRectF(-w, -w, 2 * w, 2 * w) + + def paint(self, painter, _option, _widget): + if "x" not in self._frame["edge_1"]: + if self.scene() is not None and self.scene().attitude is not None: + self.set_pos_for_attitude(self.scene().attitude) + else: + # attitude has not been set, not drawing + return + + color = "lightGray" if self.simple else "black" + pen = QtG.QPen(QtG.QColor(color)) + pen.setWidth(1) + pen.setCosmetic(True) + + # I want to use antialising for these lines regardless of what is set for the scene, + # because they are large and otherwise look hideous. It will be reset at the end. + anti_aliasing_set = painter.testRenderHint(QtG.QPainter.Antialiasing) + painter.setRenderHint(QtG.QPainter.Antialiasing, True) + + painter.setPen(pen) + for i in range(len(self._frame["edge_1"]["x"]) - 1): + painter.drawLine( + QtC.QPointF( + self._frame["edge_1"]["x"][i], self._frame["edge_1"]["y"][i] + ), + QtC.QPointF( + self._frame["edge_1"]["x"][i + 1], + self._frame["edge_1"]["y"][i + 1], + ), + ) + if self.simple: + painter.setRenderHint(QtG.QPainter.Antialiasing, anti_aliasing_set) + return + + for i in range(len(self._frame["edge_2"]["x"]) - 1): + painter.drawLine( + QtC.QPointF( + self._frame["edge_2"]["x"][i], self._frame["edge_2"]["y"][i] + ), + QtC.QPointF( + self._frame["edge_2"]["x"][i + 1], + self._frame["edge_2"]["y"][i + 1], + ), + ) + + magenta_pen = QtG.QPen(QtG.QColor("magenta")) + magenta_pen.setCosmetic(True) + magenta_pen.setWidth(1) + painter.setPen(magenta_pen) + for i in range(len(self._frame["cross_2"]["x"]) - 1): + painter.drawLine( + QtC.QPointF( + self._frame["cross_2"]["x"][i], self._frame["cross_2"]["y"][i] + ), + QtC.QPointF( + self._frame["cross_2"]["x"][i + 1], + self._frame["cross_2"]["y"][i + 1], + ), + ) + for i in range(len(self._frame["cross_1"]["x"]) - 1): + painter.drawLine( + QtC.QPointF( + self._frame["cross_1"]["x"][i], self._frame["cross_1"]["y"][i] + ), + QtC.QPointF( + self._frame["cross_1"]["x"][i + 1], + self._frame["cross_1"]["y"][i + 1], + ), + ) + + painter.setRenderHint(QtG.QPainter.Antialiasing, anti_aliasing_set) + + def set_pos_for_attitude(self, attitude): + """ + Set the item position given the scene attitude. + + Note that the given attitude is NOT the attitude of the camera represented by this outline. + It's the origin of the scene coordinate system. + """ + if self._attitude is None: + raise Exception("FieldOfView attitude is not set. Can't set position.") + + for key in self._frame: + self._frame[key]["x"], self._frame[key]["y"] = star_field_position( + attitude, + ra=self._frame[key]["ra"], + dec=self._frame[key]["dec"], + ) + + self.update() + + def set_attitude(self, attitude): + """ + Set the attitude of the camera corresponding to this outline. + + Note that this is not the attitude of the scene coordinate system. + """ + if hasattr(self, "_attitude") and attitude == self._attitude: + return + self._attitude = attitude + if self._attitude is None: + for key in self._frame: + self._frame[key]["ra"] = None + self._frame[key]["dec"] = None + else: + for key in self._frame: + self._frame[key]["ra"], self._frame[key]["dec"] = yagzag_to_radec( + self._frame[key]["yag"], self._frame[key]["zag"], self._attitude + ) + + def get_attitude(self): + """ + Get the attitude of the camera corresponding to this outline. + """ + return self._attitude + + attitude = property(get_attitude, set_attitude) diff --git a/aperoll/utils.py b/aperoll/utils.py index e7ae757..bb9e870 100644 --- a/aperoll/utils.py +++ b/aperoll/utils.py @@ -1,5 +1,8 @@ import numpy as np -from chandra_aca.transform import pixels_to_yagzag, yagzag_to_pixels +from chandra_aca.transform import ( + pixels_to_yagzag, + yagzag_to_pixels, +) from ska_helpers import logging logger = logging.basic_logger("aperoll") @@ -59,9 +62,9 @@ def get_camera_fov_frame(): "col": cross_1[1], } - for key in frame: - frame[key]["yag"], frame[key]["zag"] = pixels_to_yagzag( - frame[key]["row"], frame[key]["col"], allow_bad=True + for value in frame.values(): + value["yag"], value["zag"] = pixels_to_yagzag( + value["row"], value["col"], allow_bad=True ) return frame diff --git a/aperoll/widgets/main_window.py b/aperoll/widgets/main_window.py index f44d900..273c55e 100644 --- a/aperoll/widgets/main_window.py +++ b/aperoll/widgets/main_window.py @@ -148,6 +148,7 @@ def __init__(self, opts=None): # noqa: PLR0915 # 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) @@ -181,6 +182,11 @@ def __init__(self, opts=None): # noqa: PLR0915 if starcat is not None: self.plot.set_catalog(starcat) self.starcat_view.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: @@ -192,6 +198,8 @@ 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: @@ -203,6 +211,7 @@ def _init(self): ) 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) diff --git a/aperoll/widgets/star_plot.py b/aperoll/widgets/star_plot.py index d00d689..c8f6cc6 100644 --- a/aperoll/widgets/star_plot.py +++ b/aperoll/widgets/star_plot.py @@ -1,12 +1,10 @@ +from dataclasses import dataclass, replace + import agasc import numpy as np import tables from astropy import units as u from astropy.table import Table, vstack -from chandra_aca.transform import ( - radec_to_yagzag, - yagzag_to_pixels, -) from cxotime import CxoTime from PyQt5 import QtCore as QtC from PyQt5 import QtGui as QtG @@ -14,11 +12,78 @@ from Quaternion import Quat from aperoll import utils -from aperoll.star_field_items import Catalog, Star +from aperoll.star_field_items import ( + Catalog, + Centroid, + FieldOfView, + Star, + star_field_position, +) + + +@dataclass +class StarFieldState: + """ + Dataclass to hold the state of the star field. + + The state includes all flags that determine the visibility of different elements and the + behavior of the star field. + """ + + name: str = "" + # regular attitude updates are used to set `attitude`, `alternate_attitude` or None. + onboard_attitude_slot: str | None = "attitude" + enable_fov: bool = True + enable_alternate_fov: bool = False + enable_catalog: bool = False + enable_centroids: bool = True + enable_alternate_fov_centroids: bool = False + auto_proseco: bool = False + + +_COMMON_STATES = [ + { + # use case: real-time telemetry. + # (attitude set to on-board attitude, catalog off, centroids on) + "name": "Telemetry", + "onboard_attitude_slot": "attitude", + "enable_fov": True, + "enable_alternate_fov": False, + "enable_catalog": False, + "enable_centroids": True, + "enable_alternate_fov_centroids": False, + "auto_proseco": False, + }, + { + # use case: standard aperoll use. + # (on-board attitude never updated, catalog on, centroids off, auto-proseco) + "name": "Proseco", + "onboard_attitude_slot": None, + "enable_fov": True, + "enable_alternate_fov": False, + "enable_catalog": True, + "enable_centroids": False, + "enable_alternate_fov_centroids": False, + "auto_proseco": True, + }, + { + # use case: "alternate" real-time telemetry + # (attitude set to user attitude, but showing the camera outline in the alternate FOV) + "name": "Alternate FOV", + "onboard_attitude_slot": "alternate_attitude", + "enable_fov": True, + "enable_alternate_fov": True, + "enable_catalog": False, + "enable_centroids": False, + "enable_alternate_fov_centroids": True, + "auto_proseco": False, + }, +] class StarView(QtW.QGraphicsView): include_star = QtC.pyqtSignal(int, str, object) + update_proseco = QtC.pyqtSignal() def __init__(self, scene=None): super().__init__(scene) @@ -32,7 +97,7 @@ def __init__(self, scene=None): self._rotating = False self._moving = False - self._draw_frame = True + self._draw_frame = False def _get_draw_frame(self): return self._draw_frame @@ -65,6 +130,8 @@ def mouseMoveEvent(self, event): self._moving = True if self._moving or self._rotating: + if self.scene().onboard_attitude_slot == "attitude": + self.scene().onboard_attitude_slot = "alternate_attitude" end_pos = self.mapToScene(pos) start_pos = self.mapToScene(self._start) if self._moving: @@ -86,6 +153,8 @@ def mouseMoveEvent(self, event): def mouseReleaseEvent(self, event): if event.button() == QtC.Qt.LeftButton: self._start = None + if (self._moving or self._rotating) and self.scene().state.auto_proseco: + self.update_proseco.emit() def mousePressEvent(self, event): if event.button() == QtC.Qt.LeftButton: @@ -93,6 +162,11 @@ def mousePressEvent(self, event): self._rotating = False self._start = event.pos() + def get_moving(self): + return self._moving or self._rotating + + moving = property(get_moving) + def wheelEvent(self, event): scale = 1 + 0.5 * event.angleDelta().y() / 360 if scale < 0: @@ -151,41 +225,109 @@ def drawForeground(self, painter, _rect): ) painter.setRenderHint(QtG.QPainter.Antialiasing, anti_aliasing_set) - def contextMenuEvent(self, event): - items = [item for item in self.items(event.pos()) if isinstance(item, Star)] - if not items: - return - item = items[0] + def contextMenuEvent(self, event): # noqa: PLR0912, PLR0915 + stars = [item for item in self.items(event.pos()) if isinstance(item, Star)] + star = stars[0] if stars else None + + centroids = [ + item for item in self.items(event.pos()) if isinstance(item, Centroid) + ] + centroid = centroids[0] if centroids else None menu = QtW.QMenu() - incl_action = QtW.QAction("include acq", menu, checkable=True) - incl_action.setChecked(item.included["acq"] is True) - menu.addAction(incl_action) + if star is not None: + incl_action = QtW.QAction("include acq", menu, checkable=True) + incl_action.setChecked(star.included["acq"] is True) + menu.addAction(incl_action) + + excl_action = QtW.QAction("exclude acq", menu, checkable=True) + excl_action.setChecked(star.included["acq"] is False) + menu.addAction(excl_action) + + incl_action = QtW.QAction("include guide", menu, checkable=True) + incl_action.setChecked(star.included["guide"] is True) + menu.addAction(incl_action) + + excl_action = QtW.QAction("exclude guide", menu, checkable=True) + excl_action.setChecked(star.included["guide"] is False) + menu.addAction(excl_action) - excl_action = QtW.QAction("exclude acq", menu, checkable=True) - excl_action.setChecked(item.included["acq"] is False) - menu.addAction(excl_action) + if centroid is not None: + incl_action = QtW.QAction( + f"include slot {centroid.imgnum}", menu, checkable=True + ) + incl_action.setChecked(not centroid.excluded) + menu.addAction(incl_action) + + show_catalog_action = QtW.QAction("Show Catalog", menu, checkable=True) + show_catalog_action.setChecked(self.scene().state.enable_catalog) + menu.addAction(show_catalog_action) - incl_action = QtW.QAction("include guide", menu, checkable=True) - incl_action.setChecked(item.included["guide"] is True) - menu.addAction(incl_action) + show_fov_action = QtW.QAction("Show Alt FOV", menu, checkable=True) + show_fov_action.setChecked(self.scene().state.enable_alternate_fov) + menu.addAction(show_fov_action) - excl_action = QtW.QAction("exclude guide", menu, checkable=True) - excl_action.setChecked(item.included["guide"] is False) - menu.addAction(excl_action) + show_alt_fov_action = QtW.QAction("Show FOV", menu, checkable=True) + show_alt_fov_action.setChecked(self.scene().state.enable_fov) + menu.addAction(show_alt_fov_action) + + show_centroids_action = QtW.QAction("Show Centroids", menu, checkable=True) + show_centroids_action.setChecked(self.scene().main_fov.show_centroids) + menu.addAction(show_centroids_action) + + show_alt_centroids_action = QtW.QAction( + "Show Alt Centroids", menu, checkable=True + ) + show_alt_centroids_action.setChecked(self.scene().alternate_fov.show_centroids) + menu.addAction(show_alt_centroids_action) + + set_auto_proseco_action = QtW.QAction("Auto-proseco", menu, checkable=True) + set_auto_proseco_action.setChecked(self.scene().state.auto_proseco) + menu.addAction(set_auto_proseco_action) + + reset_fov_action = QtW.QAction("Reset Alt FOV", menu, checkable=False) + menu.addAction(reset_fov_action) + + config_menu = menu.addMenu("Quick Config") + + for state in self.scene().states.values(): + action = QtW.QAction(state.name, config_menu) + config_menu.addAction(action) result = menu.exec_(event.globalPos()) if result is not None: - action, action_type = result.text().split() - if items: + if centroid is not None and result.text().startswith("include slot"): + centroid.set_excluded(not result.isChecked()) + elif stars and result.text().split()[0] in ["include", "exclude"]: + action, action_type = result.text().split() if action == "include": - item.included[action_type] = True if result.isChecked() else None + star.included[action_type] = True if result.isChecked() else None elif action == "exclude": - item.included[action_type] = False if result.isChecked() else None + star.included[action_type] = False if result.isChecked() else None self.include_star.emit( - item.star["AGASC_ID"], action_type, item.included[action_type] + star.star["AGASC_ID"], action_type, star.included[action_type] ) + elif result.text() == "Show FOV": + self.scene().enable_fov(result.isChecked()) + elif result.text() == "Show Alt FOV": + self.scene().enable_alternate_fov(result.isChecked()) + elif result.text() == "Show Catalog": + self.scene().enable_catalog(result.isChecked()) + elif result.text() == "Show Centroids": + self.scene().main_fov.show_centroids = result.isChecked() + elif result.text() == "Show Alt Centroids": + self.scene().alternate_fov.show_centroids = result.isChecked() + elif result.text() == "Reset Alt FOV": + self.scene().alternate_fov.set_attitude(self.scene().attitude) + elif result.text() == "Auto-proseco": + self.scene().state.auto_proseco = result.isChecked() + if result.isChecked(): + self.update_proseco.emit() + elif result.text() in self.scene().states: + self.scene().set_state(result.text()) + else: + print(f"Action {result.text()} not implemented") event.accept() def resizeEvent(self, event): @@ -207,8 +349,6 @@ def set_visibility(self): side = max(np.abs(tl.x() - br.x()), np.abs(tl.y() - br.y())) radius = 1.5 * (side / 2) * 5 / 3600 # 5 arcsec per pixel, radius in degree - self.draw_frame = radius < 6 - r = agasc.sphere_dist( self.scene().attitude.ra, self.scene().attitude.dec, @@ -224,6 +364,9 @@ def set_visibility(self): else: item["graphic_item"].show() + self.scene().show_fov(radius < 6) + self.scene().show_alternate_fov(radius < 6) + def set_item_scale(self): if self.scene()._stars is not None: # when zooming out (scaling < 1), the graphic items should not get too small @@ -258,6 +401,7 @@ def scale(self, sx, sy): class StarField(QtW.QGraphicsScene): attitude_changed = QtC.pyqtSignal() + state_changed = QtC.pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) @@ -265,11 +409,53 @@ def __init__(self, parent=None): self._attitude = None self._time = None self._stars = None - self._catalog = None + + self.catalog = Catalog() + self.addItem(self.catalog) self._healpix_indices = set() self._update_radius = 2 + # alternate fov added first so it never hides the main fov + self.alternate_fov = FieldOfView(alternate_outline=True) + self.addItem(self.alternate_fov) + + self.main_fov = FieldOfView() + self.addItem(self.main_fov) + + self.states = { + value["name"]: StarFieldState(**value) for value in _COMMON_STATES + } + self.set_state("Telemetry") + + def get_onboard_attitude_slot(self): + return self.state.onboard_attitude_slot + + def set_onboard_attitude_slot(self, attitude): + self.state.onboard_attitude_slot = attitude + + onboard_attitude_slot = property( + get_onboard_attitude_slot, set_onboard_attitude_slot + ) + + def get_state(self): + return self._state + + 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]) + + self.enable_fov(self._state.enable_fov) + self.enable_alternate_fov(self._state.enable_alternate_fov) + self.enable_catalog(self._state.enable_catalog) + self.alternate_fov.show_centroids = self._state.enable_alternate_fov_centroids + self.main_fov.show_centroids = self._state.enable_centroids + self.onboard_attitude_slot = self._state.onboard_attitude_slot + + self.state_changed.emit(state_name) + + state = property(get_state, set_state) + def update_stars(self, radius=None): if radius is not None: self._update_radius = radius @@ -388,15 +574,13 @@ def set_star_positions(self): if self._stars is not None and self._attitude is not None: # The calculation of row/col is done here so it can be vectorized # if done for each item, it is much slower. - self._stars["yang"], self._stars["zang"] = radec_to_yagzag( - self._stars["RA_PMCORR"], self._stars["DEC_PMCORR"], self._attitude + x, y = star_field_position( + self._attitude, + ra=self._stars["RA_PMCORR"], + dec=self._stars["DEC_PMCORR"], ) - self._stars["row"], self._stars["col"] = yagzag_to_pixels( - self._stars["yang"], self._stars["zang"], allow_bad=True - ) - for item in self._stars: - # note that the coordinate system is (row, -col), which is (-yag, -zag) - item["graphic_item"].setPos(item["row"], -item["col"]) + for idx, item in enumerate(self._stars): + item["graphic_item"].setPos(x[idx], y[idx]) def set_time(self, time): if time != self._time: @@ -408,14 +592,29 @@ def get_time(self): time = property(get_time, set_time) + def add_catalog(self, starcat): + self.catalog.reset(starcat) + def set_attitude(self, attitude): """ Set the attitude of the scene, rotating the items to the given attitude. """ if attitude != self._attitude: - if self._catalog is not None: - self._catalog.set_pos_for_attitude(attitude) + if self.catalog is not None: + self.catalog.set_pos_for_attitude(attitude) + + # the main FOV is always at the base attitude + self.main_fov.set_attitude(attitude) + self.main_fov.set_pos_for_attitude(attitude) + if self.alternate_fov.attitude is None: + # by default the alternate FOV is at the initial base attitude. + self.alternate_fov.set_attitude(attitude) + if self.alternate_fov.attitude is not None: + # after the first time, the alternate FOV is at the same attitude + # it is just moved around. + self.alternate_fov.set_pos_for_attitude(attitude) + self._attitude = attitude self.update_stars() self.attitude_changed.emit() @@ -425,17 +624,60 @@ def get_attitude(self): attitude = property(get_attitude, set_attitude) - def add_catalog(self, starcat): - if self._catalog is not None: - self.removeItem(self._catalog) + def set_alternate_attitude(self, attitude=None): + self.alternate_fov.set_attitude( + attitude if attitude is not None else self.attitude + ) - self._catalog = Catalog(starcat) - self.addItem(self._catalog) + def get_alternate_attitude(self): + return self.alternate_fov.attitude + + alternate_attitude = property(get_alternate_attitude, set_alternate_attitude) + + def set_onboard_attitude(self, attitude): + """ + Set the on-board attitude from telemetry. + + Sometimes the attitude from telemetry is not the same as the base attitude. + """ + if self.onboard_attitude_slot == "attitude": + self.attitude = attitude + elif self.onboard_attitude_slot == "alternate_attitude": + self.alternate_attitude = attitude + + def enable_alternate_fov(self, enable=True): + self.state.enable_alternate_fov = enable + self.alternate_fov.setVisible(enable) + + def show_alternate_fov(self, show=True): + if self.state.enable_alternate_fov: + self.alternate_fov.setVisible(show) + + def enable_fov(self, enable=True): + self.state.enable_fov = enable + self.main_fov.setVisible(enable) + + def show_fov(self, show=True): + if self.state.enable_fov: + self.main_fov.setVisible(show) + + def set_centroids(self, centroids): + self.main_fov.set_centroids(centroids) + self.alternate_fov.set_centroids(centroids) + + def enable_catalog(self, enable=True): + self.state.enable_catalog = enable + self.catalog.setVisible(enable) + + def show_catalog(self, show=True): + if self.state.enable_catalog: + self.catalog.setVisible(show) class StarPlot(QtW.QWidget): attitude_changed = QtC.pyqtSignal(float, float, float) include_star = QtC.pyqtSignal(int, str, object) + update_proseco = QtC.pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -463,6 +705,7 @@ def __init__(self, parent=None): self.scene.changed.connect(self.view.set_visibility) self.view.include_star.connect(self.include_star) + self.view.update_proseco.connect(self.update_proseco) def _attitude_changed(self): if self.scene.attitude is not None: @@ -476,10 +719,16 @@ def set_base_attitude(self, q): """ Sets the base attitude - The base attitude is the attitude corresponding to the origin of the scene. + The base attitude is the attitude of the scene. """ self.scene.set_attitude(q) + def set_onboard_attitude(self, attitude): + """ + Set the on-board attitude from telemetry. + """ + self.scene.set_onboard_attitude(attitude) + def set_time(self, t): self._time = CxoTime(t) self.scene.time = self._time @@ -488,13 +737,36 @@ def highlight(self, agasc_ids): self._highlight = agasc_ids def set_catalog(self, catalog): + if ( + self._catalog is not None + and (len(self._catalog) == len(catalog)) + and (self._catalog.dtype == catalog.dtype) + and np.all(self._catalog == catalog) + ): + return + # setting the time might not be exactly right in general, because time can come directly + # from telemetry, and the catalog date might be much earlier, but this is needed for the + # standard aperoll use case. In telemetry mode, the difference will not matter. self.set_time(catalog.date) self._catalog = catalog self.show_catalog() - def show_catalog(self): + def show_catalog(self, show=True): if self._catalog is not None: self.scene.add_catalog(self._catalog) + self.scene.show_catalog(show) + + def set_alternate_attitude(self, attitude=None): + self.scene.set_alternate_attitude(attitude) + + def show_alternate_fov(self, show=True): + self.scene.show_alternate_fov(show) + + def show_fov(self, show=True): + self.scene.show_fov(show) + + def set_centroids(self, centroids): + self.scene.set_centroids(centroids) def main():