Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 825b87a

Browse files
committedJan 28, 2025·
add find attitude widget
1 parent bfa4e97 commit 825b87a

File tree

2 files changed

+433
-26
lines changed

2 files changed

+433
-26
lines changed
 
+396
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
import io
2+
import logging
3+
from pprint import pformat
4+
5+
import numpy as np
6+
from astropy.io import ascii
7+
from astropy.table import Table
8+
from cxotime import CxoTime
9+
from find_attitude import Constraints, find_attitude_solutions
10+
from PyQt5 import QtCore as QtC
11+
from PyQt5 import QtGui as QtG
12+
from PyQt5 import QtWidgets as QtW
13+
from ska_sun import get_sun_pitch_yaw
14+
15+
from aperoll.utils import AperollException, log_exception, logger
16+
from aperoll.widgets.attitude_widget import (
17+
AttitudeWidget,
18+
QuatRepresentation,
19+
hstack,
20+
vstack,
21+
)
22+
from aperoll.widgets.error_message import ErrorMessage
23+
from aperoll.widgets.parameters import (
24+
LineEdit,
25+
)
26+
27+
28+
class NoSolutionError(AperollException):
29+
pass
30+
31+
32+
class Catalog(Table):
33+
def __init__(self, date=None, att=None, **kwargs):
34+
super().__init__(**kwargs)
35+
self.date = date
36+
self.att = att
37+
38+
39+
class OptionalParameter(QtW.QWidget):
40+
def __init__(
41+
self,
42+
label,
43+
widget=None,
44+
parent=None,
45+
enabled=False,
46+
layout="v",
47+
default="",
48+
formatter=lambda val: f"{val}",
49+
value_type=float
50+
):
51+
super().__init__(parent)
52+
self.formatter = formatter
53+
self.value_type = value_type
54+
self.default = default
55+
self.label = QtW.QLabel(label)
56+
self.widget = LineEdit(self) if widget is None else widget
57+
self.widget.setText(f"{default}")
58+
self.widget.setEnabled(enabled)
59+
self.checkbox = QtW.QCheckBox()
60+
self.checkbox.setChecked(enabled)
61+
stack = hstack if layout == "h" else vstack
62+
hs = hstack(
63+
self.checkbox,
64+
self.label,
65+
stretch=True,
66+
)
67+
hs.setSpacing(10)
68+
hs.setContentsMargins(0, 0, 0, 0)
69+
lyt = stack(
70+
hs,
71+
self.widget,
72+
)
73+
s = 0
74+
lyt.setSpacing(0)
75+
lyt.setContentsMargins(s, 0, s, 0)
76+
77+
self.setLayout(lyt)
78+
self.checkbox.stateChanged.connect(self.setEnabled)
79+
80+
def setEnabled(self, enabled):
81+
self.widget.setEnabled(enabled)
82+
self.checkbox.setChecked(enabled)
83+
84+
def isEnabled(self):
85+
return self.checkbox.isChecked()
86+
87+
enabled = property(isEnabled, setEnabled)
88+
89+
def setText(self, text):
90+
self.widget.setText(text)
91+
92+
def setValue(self, value):
93+
self.widget.setText(self.formatter(value))
94+
95+
def reset(self):
96+
self.setValue(self.default)
97+
98+
def value(self):
99+
return self.value_type(self.widget.text()) if self.enabled else None
100+
101+
class FindAttitudeWidget(QtW.QWidget):
102+
103+
found_attitude = QtC.pyqtSignal(Catalog)
104+
105+
def __init__(self):
106+
super().__init__()
107+
108+
self.date_edit = LineEdit(self)
109+
self.date_edit.setReadOnly(True)
110+
self.tolerance_edit = LineEdit(self)
111+
self.tolerance_edit.setText("3")
112+
113+
self.rough_attitude_estimate = OptionalParameter(
114+
"Rough Attitude Estimate (q1, q2, q3, q4)",
115+
value_type=lambda val: np.array([float(x) for x in val.split(",")]),
116+
)
117+
self.sun_pitch = OptionalParameter("Sun Pitch (degrees)")
118+
self.sun_pitch_sigma = OptionalParameter(
119+
"Sun Pitch Uncertainty (degrees)", default=1.5
120+
)
121+
self.radial_uncertainty = OptionalParameter(
122+
"Radial Uncertainty (degrees)", default=4.0
123+
)
124+
self.max_off_nominal_roll = OptionalParameter(
125+
"Max Off-nominal roll (degrees)", default=2.0
126+
)
127+
self.min_n_stars = OptionalParameter("Min N stars", default=2)
128+
self.max_mag_diff = OptionalParameter("Max Mag diff", default=1.5)
129+
130+
self.attitude_widget = AttitudeWidget(
131+
self,
132+
columns={
133+
QuatRepresentation.EQUATORIAL: 0,
134+
QuatRepresentation.QUATERNION: 0,
135+
QuatRepresentation.SUN: 0,
136+
},
137+
)
138+
139+
self.text_widget = QtW.QTextEdit()
140+
self.text_widget.setReadOnly(True)
141+
self.text_widget.setHorizontalScrollBarPolicy(QtC.Qt.ScrollBarAlwaysOff)
142+
143+
font = self.text_widget.document().defaultFont()
144+
font.setPixelSize(12)
145+
font.setFamily("Courier New")
146+
fm = QtG.QFontMetrics(font)
147+
height = 12 * fm.lineSpacing()
148+
width = QtG.QFontMetrics(font).width("M" * 60)
149+
self.text_widget.document().setDefaultFont(font)
150+
self.text_widget.setMinimumSize(width, height)
151+
152+
self.auto_update_params = QtW.QCheckBox()
153+
self.update_params_button = QtW.QPushButton("Update Params")
154+
self.find_attitude_button = QtW.QPushButton("Find Attitude")
155+
156+
self._set_layout()
157+
158+
self.update_params_button.clicked.connect(self.update_params)
159+
self.find_attitude_button.clicked.connect(self.find_attitude)
160+
161+
def _set_layout(self):
162+
date_box = QtW.QGroupBox("Date")
163+
date_box.setLayout(
164+
vstack(
165+
self.date_edit,
166+
)
167+
)
168+
169+
button_box = QtW.QGroupBox()
170+
button_box.setLayout(
171+
hstack(
172+
self.auto_update_params,
173+
QtW.QLabel("auto-update params"),
174+
QtW.QSpacerItem(
175+
0,
176+
0,
177+
hPolicy=QtW.QSizePolicy.Expanding,
178+
vPolicy=QtW.QSizePolicy.Minimum,
179+
),
180+
self.update_params_button,
181+
self.find_attitude_button,
182+
)
183+
)
184+
185+
tolerance_box = QtW.QGroupBox("Tolerance")
186+
tolerance_box.setLayout(
187+
vstack(
188+
self.tolerance_edit,
189+
)
190+
)
191+
192+
att_box = QtW.QGroupBox("Latest Solution")
193+
att_box.setLayout(
194+
vstack(
195+
self.attitude_widget,
196+
)
197+
)
198+
199+
table_box = QtW.QGroupBox("Star data")
200+
table_box.setLayout(vstack(self.text_widget))
201+
202+
other_box = QtW.QGroupBox("Advanced Parameters")
203+
grid = QtW.QGridLayout()
204+
grid.setContentsMargins(10, 10, 10, 0)
205+
# grid.setSpacing(0)
206+
grid.setVerticalSpacing(10)
207+
grid.setHorizontalSpacing(0)
208+
209+
grid.addWidget(self.rough_attitude_estimate, 0, 0, 1, 2)
210+
grid.addWidget(self.sun_pitch, 1, 0, 1, 1)
211+
grid.addWidget(self.sun_pitch_sigma, 1, 1, 1, 1)
212+
grid.addWidget(self.radial_uncertainty, 2, 0)
213+
grid.addWidget(self.max_off_nominal_roll, 2, 1)
214+
grid.addWidget(self.min_n_stars, 3, 0)
215+
grid.addWidget(self.max_mag_diff, 3, 1)
216+
217+
other_box.setLayout(grid)
218+
219+
layout = QtW.QHBoxLayout()
220+
layout.addStretch()
221+
layout.addLayout(
222+
vstack(
223+
hstack(date_box, tolerance_box),
224+
table_box,
225+
other_box,
226+
QtW.QSpacerItem(
227+
0,
228+
0,
229+
hPolicy=QtW.QSizePolicy.Minimum,
230+
vPolicy=QtW.QSizePolicy.Expanding,
231+
),
232+
att_box,
233+
button_box,
234+
stretch=False,
235+
)
236+
)
237+
layout.addStretch()
238+
self.setLayout(layout)
239+
self.reset()
240+
241+
def set_attitude(self, attitude):
242+
self.attitude_widget.set_attitude(attitude)
243+
if attitude is None:
244+
self.rough_attitude_estimate.enabled = False
245+
self.sun_pitch.enabled = False
246+
self.sun_pitch_sigma.enabled = False
247+
self.rough_attitude_estimate.reset()
248+
self.sun_pitch.reset()
249+
else:
250+
q = ", ".join([f"{x:.12f}" for x in attitude.q])
251+
self.rough_attitude_estimate.setText(q)
252+
pitch, _ = get_sun_pitch_yaw(attitude.ra, attitude.dec, self.date)
253+
self.sun_pitch.setText(f"{pitch:.2f}")
254+
255+
def get_attitude(self):
256+
self.attitude_widget.get_attitude()
257+
258+
attitude = property(get_attitude, set_attitude)
259+
260+
def set_date(self, date):
261+
date = CxoTime(date).date if date is not None else ""
262+
if date != self.date_edit.text():
263+
self.date_edit.setText(date)
264+
265+
def get_date(self):
266+
text = self.date_edit.text()
267+
if text:
268+
return CxoTime(self.date_edit.text())
269+
270+
date = property(get_date, set_date)
271+
272+
def reset(self):
273+
self.set_date(None)
274+
self.attitude = None
275+
self.table = Table()
276+
self.table["slot"] = np.arange(8)
277+
self.table["yang"] = np.ma.masked_all(8, dtype=float)
278+
self.table["zang"] = np.ma.masked_all(8, dtype=float)
279+
self.table["mag"] = np.ma.masked_all(8, dtype=float)
280+
self.table["enabled"] = np.ones(8, dtype=bool)
281+
self.table["yang"].format = "8.2f"
282+
self.table["zang"].format = "8.2f"
283+
self.table["mag"].format = "5.2f"
284+
self.display_text()
285+
286+
def update_params(self):
287+
self.set_date(None)
288+
self.attitude = None
289+
self.table = Table()
290+
self.table["slot"] = np.arange(8)
291+
self.table["yang"] = np.ma.masked_all(8, dtype=float)
292+
self.table["zang"] = np.ma.masked_all(8, dtype=float)
293+
self.table["mag"] = np.ma.masked_all(8, dtype=float)
294+
self.table["enabled"] = np.ones(8, dtype=bool)
295+
self.table["yang"].format = "8.2f"
296+
self.table["zang"].format = "8.2f"
297+
self.table["mag"].format = "5.2f"
298+
self.display_text()
299+
300+
def set_centroids(self, centroids):
301+
if not np.all(centroids["IMGNUM"] == np.arange(8)):
302+
logger.debug(
303+
f"Got centroids that do not match: IMGNUM={centroids['IMGNUM']}"
304+
)
305+
return
306+
self.table["yang"][:] = centroids["YAGS"]
307+
self.table["zang"][:] = centroids["ZAGS"]
308+
self.table["mag"][:] = centroids["AOACMAG"]
309+
self.table["enabled"][centroids["IMGFID"]] = False
310+
self.display_text()
311+
312+
def display_text(self):
313+
buf = io.StringIO()
314+
ascii.write(
315+
self.table,
316+
format="fixed_width_two_line",
317+
header_rows=["name", "dtype"],
318+
output=buf,
319+
)
320+
self.text_widget.setText(buf.getvalue())
321+
322+
def read_text(self):
323+
buf = io.StringIO(self.text_widget.text())
324+
self.table = ascii.read(
325+
buf,
326+
format="fixed_width_two_line",
327+
header_rows=["name", "dtype"],
328+
output=buf,
329+
)
330+
331+
def enable_slot(self, slot, enable):
332+
self.table["enabled"][slot] = enable
333+
self.display_text()
334+
335+
def find_attitude(self):
336+
try:
337+
logger.debug("Find Attitude")
338+
print(logger.level)
339+
340+
constraints = self.get_constraints()
341+
constraints = Constraints(**constraints)
342+
343+
stars = self.table[self.table["enabled"]][["slot", "yang", "zang", "mag"]].copy()
344+
stars.rename_columns(
345+
["yang", "zang", "mag"],
346+
["YAG", "ZAG", "MAG_ACA"],
347+
)
348+
349+
logger.debug("Star data")
350+
logger.debug("\n" + "\n".join(stars.pformat()))
351+
logger.debug("Constraints")
352+
logger.debug("\n" + pformat(constraints))
353+
354+
solutions = find_attitude_solutions(
355+
np.asarray(stars),
356+
tolerance=float(self.tolerance_edit.text()),
357+
constraints=constraints
358+
)
359+
if not solutions:
360+
raise NoSolutionError("No solutions found")
361+
logger.debug(f"Found {len(solutions)} solutions")
362+
363+
solution = solutions[0]
364+
catalog = Catalog(
365+
date=self.date,
366+
att=solution["att_fit"],
367+
)
368+
table = solution["summary"][~solution["summary"]["m_agasc_id"].mask]
369+
catalog["slot"] = table["slot"]
370+
catalog["id"] = table["m_agasc_id"]
371+
catalog["type"] = "GUI"
372+
catalog["yang"] = table["m_yag"]
373+
catalog["zang"] = table["m_zag"]
374+
catalog["mag"] = table["m_mag"]
375+
376+
self.found_attitude.emit(catalog)
377+
except NoSolutionError as exc:
378+
log_exception("No solutions found", exc, level="ERROR")
379+
error_dialog = ErrorMessage(title="Error", message=str(exc))
380+
error_dialog.exec()
381+
except Exception as exc:
382+
log_exception("Error finding attitude", exc, level="ERROR")
383+
error_dialog = ErrorMessage(title="Error finding attitude", message=str(exc))
384+
error_dialog.exec()
385+
386+
def get_constraints(self):
387+
parameters = {
388+
"att": self.rough_attitude_estimate.value(),
389+
"pitch": self.sun_pitch.value(),
390+
"pitch_err": self.sun_pitch_sigma.value(),
391+
"att_err": self.radial_uncertainty.value(),
392+
"off_nom_roll_max": self.max_off_nominal_roll.value(),
393+
"min_stars": self.min_n_stars.value(),
394+
"mag_err": self.max_mag_diff.value(),
395+
}
396+
return parameters

‎aperoll/widgets/telemetry_view.py

+37-26
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import functools
2+
import pprint
23

34
import numpy as np
45
from cxotime import CxoTime
@@ -17,6 +18,7 @@
1718
)
1819
from aperoll.widgets.catalog_widget import CatalogWidget
1920
from aperoll.widgets.error_message import ErrorMessage
21+
from aperoll.widgets.find_attitude_widget import FindAttitudeWidget
2022
from aperoll.widgets.parameters import (
2123
LineEdit,
2224
)
@@ -45,6 +47,7 @@ def create_widgets(self):
4547

4648
self.starcat_review = StarcatReview()
4749
self.catalog_widget = CatalogWidget()
50+
self.find_attitude_widget = FindAttitudeWidget()
4851
self.date_edit = LineEdit(self)
4952
self.date_edit.setReadOnly(True)
5053
self.obsid_edit = LineEdit(self)
@@ -78,10 +81,6 @@ def create_widgets(self):
7881
self.proseco_params_widget = ProsecoParams(show_buttons=True)
7982
self.instrument_edit = QtW.QComboBox(self)
8083
self.instrument_edit.addItems(["ACIS-S", "ACIS-I", "HRC-S", "HRC-I"])
81-
self.starcat_source_edit = QtW.QComboBox(self)
82-
self.starcat_source_edit.addItems(["Kadi", "Proseco", "Centroids"])
83-
self.find_atttitude_tol_edit = LineEdit(self)
84-
self.find_atttitude_tol_edit.setText("3")
8584

8685
self.get_catalog_button = QtW.QPushButton("Get Catalog")
8786
self.find_attitude_button = QtW.QPushButton("Find Attitude")
@@ -93,7 +92,7 @@ def create_widgets(self):
9392
self.telemetry_attitude_button.setChecked(True)
9493

9594
self.attitude_source_edit = QtW.QComboBox(self)
96-
self.attitude_source_edit.addItems(["Telemetry", "User", "Centroids"])
95+
self.attitude_source_edit.addItems(["Telemetry", "User", "Find Attitude"])
9796

9897
def get_attitude_source(self):
9998
return self.attitude_source_edit.currentText()
@@ -107,10 +106,10 @@ def set_attitude_source(self, source):
107106
attitude_source = property(get_attitude_source, set_attitude_source)
108107

109108
def get_starcat_source(self):
110-
return self.starcat_source_edit.currentText()
109+
return self.catalog_widget.starcat_source
111110

112111
def set_starcat_source(self, source):
113-
self.starcat_source_edit.setCurrentText(source)
112+
self.catalog_widget.starcat_source = source
114113

115114
starcat_source = property(get_starcat_source, set_starcat_source)
116115

@@ -143,13 +142,18 @@ def set_connections(self):
143142
self.star_plot.attitude_changed.connect(self.view_attitude_widget.set_attitude)
144143

145144
# connect buttons
146-
self.get_catalog_button.clicked.connect(lambda _check: self._get_catalog(interactive=True))
145+
self.get_catalog_button.clicked.connect(
146+
lambda _check: self._get_catalog(interactive=True)
147+
)
147148

148149
self.attitude_source_edit.currentTextChanged.connect(self.set_attitude_source)
149150

150-
self.star_plot.include_slot.connect(self.catalog_widget.enable_slot)
151+
self.star_plot.include_slot.connect(self.find_attitude_widget.enable_slot)
151152
self.star_plot.include_star.connect(self.proseco_params_widget.include_star)
152153

154+
self.find_attitude_button.clicked.connect(self._find_attitude)
155+
self.find_attitude_widget.found_attitude.connect(self._attitude_found)
156+
153157
def set_layout(self):
154158
"""
155159
Arranges the widgets.
@@ -170,16 +174,8 @@ def set_layout(self):
170174
controls_group_box.setLayout(
171175
vstack(
172176
hstack(
173-
vstack(
174-
QtW.QLabel("attitude source"),
175-
QtW.QLabel("catalog source"),
176-
QtW.QLabel("Find-attitude tolerance"),
177-
),
178-
vstack(
179-
self.attitude_source_edit,
180-
self.starcat_source_edit,
181-
self.find_atttitude_tol_edit,
182-
),
177+
QtW.QLabel("attitude source"),
178+
self.attitude_source_edit,
183179
),
184180
hstack(self.get_catalog_button, self.find_attitude_button),
185181
)
@@ -224,9 +220,10 @@ def set_layout(self):
224220
v_layout_left.addWidget(controls_group_box)
225221

226222
self.main_tab.addTab(self.star_plot, "Star Plot")
227-
self.main_tab.addTab(self.catalog_widget, "Catalog")
223+
self.main_tab.addTab(self.find_attitude_widget, "Find Attitude")
228224
self.main_tab.addTab(self.proseco_params_widget, "Proseco Parameters")
229225
self.main_tab.addTab(self.starcat_review, "Catalog Review")
226+
self.main_tab.addTab(self.catalog_widget, "Catalog")
230227

231228
layout = QtW.QHBoxLayout()
232229
layout.addLayout(v_layout_left, 1)
@@ -240,15 +237,23 @@ def _set_user_attitude(self):
240237

241238
def _find_attitude(self):
242239
# catalog should be cleared before setting new catalog, in case there is an error
243-
pass
240+
self.catalog_widget.reset()
241+
self.find_attitude_widget.find_attitude()
242+
243+
def _attitude_found(self, solution):
244+
logger.debug("Solution found")
245+
logger.debug(solution.att)
246+
logger.debug(solution.date)
247+
logger.debug(pprint.pformat(solution))
248+
self.catalog_widget.set_catalog(solution)
249+
244250

245251
def _include_star(self, star_id, action, include):
246252
print(f"Include star {star_id} {action} {include}")
247253

248254
def _include_slot(self, slot_id, include):
249255
print(f"Include slot {slot_id} {include}")
250256

251-
252257
def _get_catalog(self, interactive=True):
253258
try:
254259
# catalog should be cleared before setting new catalog, in case there is an error
@@ -277,16 +282,16 @@ def set_time(self, time):
277282
self.view_attitude_widget.set_date(time)
278283
self.date_edit.setText(CxoTime(time).date)
279284
self._update_delta_quat()
280-
self.catalog_widget.set_date(time)
285+
self.find_attitude_widget.set_date(time)
281286

282287
def set_centroids(self, centroids):
283288
self.star_plot.set_centroids(centroids)
284-
self.catalog_widget.set_centroids(centroids)
289+
self.find_attitude_widget.set_centroids(centroids)
285290

286291
def set_onboard_attitude(self, att):
287292
self.onboard_attitude_widget.set_attitude(att)
288293
self.star_plot.set_onboard_attitude(att)
289-
self.catalog_widget.set_attitude(att)
294+
self.find_attitude_widget.set_attitude(att)
290295
self._update_delta_quat()
291296

292297
def set_telemetry(self, telemetry):
@@ -416,6 +421,7 @@ def _test_data():
416421

417422

418423
if __name__ == "__main__":
424+
import logging
419425
import os
420426
import sys
421427

@@ -425,7 +431,12 @@ def _test_data():
425431
import qdarkstyle
426432

427433
# this is just a simple and quick way to test the widget, not intended for normal use.
428-
logger.setLevel("INFO")
434+
logger.setLevel("DEBUG")
435+
436+
# find_attitude logger has level="INFO" by default
437+
# I would rather redirect that logger's info into debug.
438+
logging.getLogger("find_attitude").setLevel(logging.WARNING)
439+
429440
app = QtW.QApplication([])
430441
app.setStyleSheet(qdarkstyle.load_stylesheet())
431442

0 commit comments

Comments
 (0)
Please sign in to comment.