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():