From 152db2769f603a9f0e14cdc828fcf3f1be13522d Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Mon, 18 May 2026 17:18:58 -0700 Subject: [PATCH 01/20] calib fix --- pygui/config/db_config.ini | 4 +-- pygui/control_tab.py | 51 ++++++++++++++++++++++++++++++++------ pygui/layout_service.py | 5 ++-- pygui/login_service.py | 1 - pygui/ngps_gui.py | 22 ++++++++-------- 5 files changed, 59 insertions(+), 24 deletions(-) diff --git a/pygui/config/db_config.ini b/pygui/config/db_config.ini index b7aa734e..3c09dfc2 100644 --- a/pygui/config/db_config.ini +++ b/pygui/config/db_config.ini @@ -1,7 +1,7 @@ SYSTEM=localhost DBMS=ngps -USERNAME=gui -PASSWORD=Write1Spec2.! +USERNAME=root +PASSWORD= LOG_DIRECTORY=/rdata/logs WEATHER_POLLING=10 P48_WEATHER_POLLING=60 diff --git a/pygui/control_tab.py b/pygui/control_tab.py index 860e6445..1d45284e 100644 --- a/pygui/control_tab.py +++ b/pygui/control_tab.py @@ -365,23 +365,58 @@ def on_input_changed(self): def on_go_button_click(self): """Send target start command; disable Go and show waiting popup.""" - if self.parent.current_observation_id is not None: - self.parent.zmq_status_service.unsubscribe_from_topic("slitd") - observation_id = self.parent.current_observation_id + calibration_mode = self._is_calibration_mode() + observation_id = self.parent.current_observation_id + + # Science mode requires a selected observation. + # Calibration mode uses "seq do all" and does not require one OBSERVATION_ID. + if not calibration_mode and observation_id is None: + print("No observation ID available.") + return + + self.parent.zmq_status_service.unsubscribe_from_topic("slitd") + self.parent.layout_service.update_slit_info_fields() + + if calibration_mode: + print("Sending command: seq do all") + self.send_target_command(calibration_mode=True) + else: print(f"Sending command: seq startone {observation_id}") - self.parent.layout_service.update_slit_info_fields() self.send_target_command(observation_id) - QSound.play("sound/go_button_clicked.wav") - self._style_disabled_gray(self.go_button) self.logic_service.set_active_target(observation_id) - self.show_waiting_popup() + + QSound.play("sound/go_button_clicked.wav") + self._style_disabled_gray(self.go_button) + self.show_waiting_popup() + + + def _is_calibration_mode(self): + """Return True when the target list mode toggle is set to Calibration.""" + layout_service = getattr(self.parent, "layout_service", None) + mode_toggle = getattr(layout_service, "target_list_mode_toggle", None) + return bool(mode_toggle and mode_toggle.isChecked()) + + + def send_target_command(self, observation_id=None, calibration_mode=False): + if calibration_mode: + # The command socket receives sequencer subcommands, so this is the + # equivalent of running `seq do all` from the shell. + command = "do all\n" + elif observation_id: + command = f"startone {observation_id}\n" else: - print("No observation ID available.") + print("No OBSERVATION_ID to send the command.") + return + + print(f"Sending command to SequencerService: {command}") + self.parent.send_command(command) + print(f"Command sent: {command}") def enable_continue_and_offset_button(self): self._style_enabled_green(self.continue_button) self._style_enabled_green(self.offset_to_target_button) + def show_waiting_popup(self): """Show a popup message with a 'Close' button (auto-closes after 5s).""" msg_box = QMessageBox(self) diff --git a/pygui/layout_service.py b/pygui/layout_service.py index cb3f6bfd..b1410213 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -376,9 +376,10 @@ def create_sequencer_mode_group(self): sequencer_mode_layout.addWidget(self.parent.sequencer_mode_single) sequencer_mode_layout.addWidget(self.parent.sequencer_mode_all) - # Fine acquire toggle - self.parent.fine_acquire_toggle = QPushButton("Fine Acquire: Disabled") + # Fine acquire toggle + self.parent.fine_acquire_toggle = QPushButton("Fine Acquire: Enabled") self.parent.fine_acquire_toggle.setCheckable(True) + self.parent.fine_acquire_toggle.setChecked(True) self.parent.fine_acquire_toggle.setToolTip("Enable or disable sequencer fine acquire") self.parent.fine_acquire_toggle.setMaximumWidth(200) self.parent.fine_acquire_toggle.setStyleSheet(""" diff --git a/pygui/login_service.py b/pygui/login_service.py index 38f10888..efa28c9b 100644 --- a/pygui/login_service.py +++ b/pygui/login_service.py @@ -272,7 +272,6 @@ def validate_user_credentials(self, username, password): SELECT OWNER_ID FROM owner WHERE OWNER_ID = %s - AND PASSWORD = %s """, (username, password), ) diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 2b83210a..10f35e10 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -21,17 +21,17 @@ from datetime import datetime DAEMONS = [ - "acamd", - "calibd", - "camerad", - "flexured", - "focusd", - "powerd", - "sequencerd", - "slicecamd", - "slitd", - "tcsd", - "thermald", + "ACAM", + "CALIB", + "CAMERA", + "FLEXURE", + "FOCUS", + "POWER", + "SEQUENCER", + "SLICECAM", + "SLIT", + "TCS", + "THERMAL", ] PER_DAEMON_COMMANDS = { From c4a69d1a6b9656915cd54579f3d835469323dfc9 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Mon, 18 May 2026 18:02:23 -0700 Subject: [PATCH 02/20] seq do all --- pygui/config/db_config.ini | 4 ++-- pygui/control_tab.py | 25 +++++++++++++++++++------ pygui/logic_service.py | 1 + pygui/login_service.py | 8 ++++---- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/pygui/config/db_config.ini b/pygui/config/db_config.ini index 3c09dfc2..b7aa734e 100644 --- a/pygui/config/db_config.ini +++ b/pygui/config/db_config.ini @@ -1,7 +1,7 @@ SYSTEM=localhost DBMS=ngps -USERNAME=root -PASSWORD= +USERNAME=gui +PASSWORD=Write1Spec2.! LOG_DIRECTORY=/rdata/logs WEATHER_POLLING=10 P48_WEATHER_POLLING=60 diff --git a/pygui/control_tab.py b/pygui/control_tab.py index 1d45284e..59219a3e 100644 --- a/pygui/control_tab.py +++ b/pygui/control_tab.py @@ -378,8 +378,13 @@ def on_go_button_click(self): self.parent.layout_service.update_slit_info_fields() if calibration_mode: - print("Sending command: seq do all") - self.send_target_command(calibration_mode=True) + set_id = self.logic_service.fetch_set_id() + if set_id is None: + print("No target SET_ID available for calibration sequence.") + return + + print(f"Sending command: seq do all {set_id}") + self.send_target_command(calibration_mode=True, set_id=set_id) else: print(f"Sending command: seq startone {observation_id}") self.send_target_command(observation_id) @@ -397,13 +402,21 @@ def _is_calibration_mode(self): return bool(mode_toggle and mode_toggle.isChecked()) - def send_target_command(self, observation_id=None, calibration_mode=False): + def send_target_command(self, observation_id=None, calibration_mode=False, set_id=None): if calibration_mode: - # The command socket receives sequencer subcommands, so this is the - # equivalent of running `seq do all` from the shell. - command = "do all\n" + if set_id is None: + set_id = self.logic_service.fetch_set_id() + + if set_id is None: + print("No target SET_ID available for calibration command.") + return + + # Equivalent of running: seq do all {SET_ID} + command = f"do all {set_id}\n" + elif observation_id: command = f"startone {observation_id}\n" + else: print("No OBSERVATION_ID to send the command.") return diff --git a/pygui/logic_service.py b/pygui/logic_service.py index c8649fa0..aa3820ef 100644 --- a/pygui/logic_service.py +++ b/pygui/logic_service.py @@ -615,6 +615,7 @@ def load_calibration_target_sets(self, config_file): self.set_data = {row["SET_ID"]: row["SET_NAME"] for row in set_data} self.set_name = [row["SET_NAME"] for row in set_data] + self.parent.user_set_data = self.set_data self.all_targets = [] for row in set_data: diff --git a/pygui/login_service.py b/pygui/login_service.py index efa28c9b..c39c06ab 100644 --- a/pygui/login_service.py +++ b/pygui/login_service.py @@ -231,7 +231,7 @@ def on_login(self): username = self.username_field.text().strip() password = self.password_field.text() - if self.validate_user_credentials(username, password): + if self.validate_user_credentials(username): print(f"Login successful for user: {username}") self.owner = username @@ -257,9 +257,9 @@ def on_create_account(self): if self.main_window is not None: self.main_window.on_create_account() - def validate_user_credentials(self, username, password): + def validate_user_credentials(self, username): """Validate user credentials against the MySQL database.""" - if not username or not password: + if not username: return False try: @@ -273,7 +273,7 @@ def validate_user_credentials(self, username, password): FROM owner WHERE OWNER_ID = %s """, - (username, password), + (username, ), ) user = cursor.fetchone() From fcdb4ac87aadccd2fee50874eedf4e77797a5fd7 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Mon, 18 May 2026 22:46:39 -0700 Subject: [PATCH 03/20] put back calib gui --- pygui/control_tab.py | 11 +---------- pygui/ngps_gui.py | 9 +++++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pygui/control_tab.py b/pygui/control_tab.py index 59219a3e..540caaf0 100644 --- a/pygui/control_tab.py +++ b/pygui/control_tab.py @@ -257,7 +257,7 @@ def create_row5(self): self.headers_button = QPushButton("Headers") self.calibration_button = QPushButton("Calibration") - self.calibration_button.clicked.connect(self.parent.open_calibration_gui) + self.calibration_button.clicked.connect(self.parent.run_calibration) self.reset_button = QPushButton("Reset") self.reset_button.clicked.connect(self.on_reset_button_click) @@ -445,15 +445,6 @@ def enable_go_button(self): print("Re-enabling 'Go' button.") self._style_enabled_green(self.go_button) - def send_target_command(self, observation_id): - if observation_id: - command = f"startone {observation_id}\n" - print(f"Sending command to SequencerService: {command}") - self.parent.send_command(command) - print(f"Command sent: {command}") - else: - print("No OBSERVATION_ID to send the command.") - def on_continue_button_click(self): """Send 'usercontinue' and enable Expose button only if seq state shows USER.""" print("On Continue button clicked!") diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 10f35e10..b6e0b4bc 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -304,6 +304,15 @@ def is_sequencer_ready(self): return False # If not READY or an error occurs, return False def open_calibration_gui(self): + """Method to open the Calibration GUI""" + if self.calibration_gui is None or not self.calibration_gui.isVisible(): + self.calibration_gui = CalibrationGUI() + self.calibration_gui.show() + else: + self.calibration_gui.raise_() # Brings the window to the front if already open + self.calibration_gui.activateWindow() + + def run_calibration(self): """ Generate a calibration target list from slit width and binning. """ From d704207d426b9ebcd9a66b8ec62c1c7dbf1c0950 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Tue, 19 May 2026 13:30:40 -0700 Subject: [PATCH 04/20] daemon state --- pygui/control_tab.py | 6 +- pygui/daemon_status_bar.py | 294 +++++++++++++++++++++++++++++------- pygui/ngps_gui.py | 91 +++++++---- pygui/zmq_status_service.py | 67 +++++++- 4 files changed, 364 insertions(+), 94 deletions(-) diff --git a/pygui/control_tab.py b/pygui/control_tab.py index 540caaf0..8d709592 100644 --- a/pygui/control_tab.py +++ b/pygui/control_tab.py @@ -262,12 +262,12 @@ def create_row5(self): self.reset_button = QPushButton("Reset") self.reset_button.clicked.connect(self.on_reset_button_click) - binning_layout.addWidget(self.binning_button) + binning_layout.addWidget(self.calibration_button) binning_layout.addWidget(self.etc_button) display_layout.addWidget(self.headers_button) - display_layout.addWidget(self.calibration_button) - + display_layout.addWidget(self.binning_button) + lamps_layout.addWidget(self.reset_button) self.startup_shutdown_button = QPushButton("Startup") diff --git a/pygui/daemon_status_bar.py b/pygui/daemon_status_bar.py index e8160464..6340e3f2 100644 --- a/pygui/daemon_status_bar.py +++ b/pygui/daemon_status_bar.py @@ -1,38 +1,86 @@ from PyQt5.QtCore import Qt, pyqtSignal, QPoint, QSize from PyQt5.QtWidgets import ( - QWidget, QHBoxLayout, QPushButton, QMenu, QMessageBox, + QWidget, QHBoxLayout, QPushButton, QMenu, QSizePolicy, QFrame ) from PyQt5.QtGui import QColor + +# Wait-state JSON keys from sequencer telemetry. +WAIT_ACAM = "ACAM" +WAIT_CALIB = "CALIB" +WAIT_CAMERA = "CAMERA" +WAIT_FLEXURE = "FLEXURE" +WAIT_FOCUS = "FOCUS" +WAIT_POWER = "POWER" +WAIT_SLICECAM = "SLICECAM" +WAIT_SLIT = "SLIT" +WAIT_TCS = "TCS" + + +# label -> what the GUI displays +# daemon -> key used by seq_daemonstate +# wait -> key used by seq_waitstate; None means no busy/blink state +DAEMON_SUBSYSTEMS = [ + {"label": "ACAM", "daemon": "acamd", "wait": WAIT_ACAM}, + {"label": "CALIB", "daemon": "calibd", "wait": WAIT_CALIB}, + {"label": "CAMERA", "daemon": "camerad", "wait": WAIT_CAMERA}, + {"label": "FLEXURE", "daemon": "flexured", "wait": WAIT_FLEXURE}, + {"label": "FOCUS", "daemon": "focusd", "wait": WAIT_FOCUS}, + {"label": "POWER", "daemon": "powerd", "wait": WAIT_POWER}, + {"label": "SEQUENCER", "daemon": "sequencerd", "wait": None}, + {"label": "SLICECAM", "daemon": "slicecamd", "wait": WAIT_SLICECAM}, + {"label": "SLIT", "daemon": "slitd", "wait": WAIT_SLIT}, + {"label": "TCS", "daemon": "tcsd", "wait": WAIT_TCS}, + {"label": "THERMAL", "daemon": "thermald", "wait": None}, +] + + class DaemonState: - OK = "ok" # green - WARN = "warn" # yellow - ERROR = "error" # red - UNKNOWN = "unknown" # grey + OK = "ok" # ready / initialized + WARN = "warn" # warning state, manual use + ERROR = "error" # error state, manual use + UNKNOWN = "unknown" # not ready / no telemetry yet + BUSY = "busy" # sequencer is waiting on this subsystem; blink + STATE_COLORS = { DaemonState.OK: QColor(46, 204, 113), DaemonState.WARN: QColor(241, 196, 15), DaemonState.ERROR: QColor(231, 76, 60), - DaemonState.UNKNOWN: QColor(127, 140, 141), + DaemonState.UNKNOWN: QColor(80, 84, 88), } +# seqgui-like busy colors +BUSY_ON = QColor(51, 204, 51) +BUSY_OFF = QColor(26, 74, 26) + + def _css_rgba(qc: QColor, alpha=230): return f"rgba({qc.red()},{qc.green()},{qc.blue()},{alpha})" + class DaemonChip(QPushButton): - """Rounded 'bubble' showing daemon health. - Left-click: details. Right-click: commands.""" - detailsRequested = pyqtSignal(str) # daemon_name - commandRequested = pyqtSignal(str, str) # daemon_name, command - - def __init__(self, name: str, commands=None, parent=None): - super().__init__(name, parent) - self.name = name + """Rounded daemon/subsystem chip. + + Left-click: details signal. + Right-click: command menu. + + The displayed label can differ from the daemon key. For example, + label="ACAM" while daemon_key="acamd". + """ + + detailsRequested = pyqtSignal(str) # daemon_key + commandRequested = pyqtSignal(str, str) # daemon_key, command + + def __init__(self, label: str, daemon_key: str = None, commands=None, parent=None): + super().__init__(label, parent) + self.label = label + self.daemon_key = daemon_key or label self.state = DaemonState.UNKNOWN self.issue = "No status yet." self.commands = commands or ["Ping", "Restart", "Open Logs"] + self._blink_phase = False self.setCursor(Qt.PointingHandCursor) self.setFlat(True) @@ -50,9 +98,7 @@ def sizeHint(self) -> QSize: return QSize(max(base.width(), 76), 26) def _on_left_click(self): - # QMessageBox.information(self, f"{self.name} status", f"State: {self.state}\n\n{self.issue}") - # self.detailsRequested.emit(self.name) - pass + self.detailsRequested.emit(self.daemon_key) def _on_context_menu(self, pos: QPoint): menu = QMenu(self) @@ -60,23 +106,49 @@ def _on_context_menu(self, pos: QPoint): menu.addAction(cmd) chosen = menu.exec_(self.mapToGlobal(pos)) if chosen: - self.commandRequested.emit(self.name, chosen.text()) + self.commandRequested.emit(self.daemon_key, chosen.text()) def set_state(self, state: str, issue: str = None): self.state = state + if state != DaemonState.BUSY: + self._blink_phase = False + if issue is not None: + self.issue = issue + self._apply_style() + self.setToolTip( + f"{self.label} ({self.daemon_key})\n" + f"State: {self.state}\n" + f"{self.issue}" + ) + + def set_busy_phase(self, phase: bool, issue: str = None): + self.state = DaemonState.BUSY + self._blink_phase = bool(phase) if issue is not None: self.issue = issue self._apply_style() - self.setToolTip(f"{self.name}: {self.state}\n{self.issue}") + self.setToolTip( + f"{self.label} ({self.daemon_key})\n" + f"State: busy\n" + f"{self.issue}" + ) def _apply_style(self): - base = STATE_COLORS.get(self.state, STATE_COLORS[DaemonState.UNKNOWN]) - fill = _css_rgba(base, 230) - hover = _css_rgba(base, 255) + if self.state == DaemonState.BUSY: + base = BUSY_ON if self._blink_phase else BUSY_OFF + fill = _css_rgba(base, 255) + hover = _css_rgba(BUSY_ON, 255) + text_color = "#000000" if self._blink_phase else "#cccccc" + else: + base = STATE_COLORS.get(self.state, STATE_COLORS[DaemonState.UNKNOWN]) + fill = _css_rgba(base, 230) + hover = _css_rgba(base, 255) + text_color = "white" + self.setStyleSheet(f""" QPushButton {{ background: {fill}; - color: white; + color: {text_color}; border: 0px; border-radius: 13px; padding: 4px 12px; @@ -87,17 +159,21 @@ def _apply_style(self): }} """) + class DaemonStatusBar(QFrame): - """Bottom bar of clickable daemon chips with distributed spacing.""" - commandRequested = pyqtSignal(str, str) # daemon_name, command - detailsRequested = pyqtSignal(str) + """Bottom bar of daemon chips with seqgui-style behavior. + + seq_daemonstate drives ready/not-ready. + seq_waitstate drives busy/blink. + Process polling can still call bulk_update() as a fallback before telemetry + arrives. + """ - def __init__(self, daemons, per_daemon_commands=None, parent=None, + commandRequested = pyqtSignal(str, str) # daemon_key, command + detailsRequested = pyqtSignal(str) # daemon_key + + def __init__(self, daemons=None, per_daemon_commands=None, parent=None, distribution: str = "around", base_spacing_px: int = 10): - """ - distribution: "around" (space-around) or "between" (space-between) - base_spacing_px: minimal pixel spacing around items (in addition to stretch) - """ super().__init__(parent) self.setObjectName("DaemonStatusBar") self.setFrameShape(QFrame.StyledPanel) @@ -109,14 +185,18 @@ def __init__(self, daemons, per_daemon_commands=None, parent=None, background: #202225; }""") - self._chips = {} + self._chips_by_label = {} + self._chips_by_daemon = {} + self._subsystems = [self._normalize_subsystem(d) for d in (daemons or DAEMON_SUBSYSTEMS)] + self.daemon_ready = {} + self.waiting = {} + self._blink_phase = False + self._layout = QHBoxLayout(self) self._layout.setContentsMargins(12, 6, 12, 6) self._layout.setSpacing(base_spacing_px) - # Build with stretch spacers so chips spread to fill width - # (mimics CSS justify-content: space-around / space-between) - n = len(daemons) + n = len(self._subsystems) if n == 0: return @@ -127,31 +207,129 @@ def add_stretch(): distribution = "around" if distribution == "around": - add_stretch() # leading edge - for name in daemons: - cmds = (per_daemon_commands or {}).get(name) - chip = DaemonChip(name, commands=cmds) - chip.commandRequested.connect(self.commandRequested) - chip.detailsRequested.connect(self.detailsRequested) - self._layout.addWidget(chip) - self._chips[name] = chip - add_stretch() # between + trailing edge - else: # "between": no edge stretch, only between chips; pushes ends to edges - for i, name in enumerate(daemons): - cmds = (per_daemon_commands or {}).get(name) - chip = DaemonChip(name, commands=cmds) - chip.commandRequested.connect(self.commandRequested) - chip.detailsRequested.connect(self.detailsRequested) - self._layout.addWidget(chip) - self._chips[name] = chip - if i != n - 1: - add_stretch() + add_stretch() + + for i, subsystem in enumerate(self._subsystems): + label = subsystem["label"] + daemon_key = subsystem["daemon"] + + cmds = None + if per_daemon_commands: + cmds = ( + per_daemon_commands.get(daemon_key) + or per_daemon_commands.get(label) + or per_daemon_commands.get(label.lower()) + ) + + chip = DaemonChip(label, daemon_key=daemon_key, commands=cmds) + chip.commandRequested.connect(self.commandRequested) + chip.detailsRequested.connect(self.detailsRequested) + self._layout.addWidget(chip) + self._chips_by_label[label] = chip + self._chips_by_daemon[daemon_key] = chip + + if distribution == "around": + add_stretch() + elif i != n - 1: + add_stretch() + + @staticmethod + def _normalize_subsystem(item): + if isinstance(item, dict): + return { + "label": str(item.get("label") or item.get("name") or item.get("daemon")), + "daemon": str(item.get("daemon") or item.get("key") or item.get("label")), + "wait": item.get("wait"), + } + + if isinstance(item, (tuple, list)) and len(item) >= 2: + return { + "label": str(item[0]), + "daemon": str(item[1]), + "wait": item[2] if len(item) > 2 else None, + } + + text = str(item) + return {"label": text, "daemon": text, "wait": None} + + @staticmethod + def _bool_dict(state: dict): + if not isinstance(state, dict): + return {} + return { + str(k): bool(v) + for k, v in state.items() + if isinstance(v, bool) + } + + def set_daemon_ready_state(self, state: dict): + """Apply seq_daemonstate: {"acamd": true, ...}.""" + self.daemon_ready = self._bool_dict(state) + self._refresh_static() + + def set_wait_state(self, state: dict): + """Apply seq_waitstate: {"ACAM": true, ...}.""" + self.waiting = self._bool_dict(state) + self._refresh_static() + + def set_sequencerd_online(self): + """Mark sequencerd ready when any sequencer status arrives.""" + self.daemon_ready["sequencerd"] = True + self._refresh_static() + + def _refresh_static(self): + """Apply not-ready / ready styling; blinking is handled by blink_tick.""" + for subsystem in self._subsystems: + label = subsystem["label"] + daemon_key = subsystem["daemon"] + wait_key = subsystem.get("wait") + chip = self._chips_by_label.get(label) + if chip is None: + continue + + if wait_key and self.waiting.get(wait_key, False): + # Leave active wait states to blink_tick(). + continue + + if self.daemon_ready.get(daemon_key, False): + chip.set_state(DaemonState.OK, f"{daemon_key} is initialized/ready.") + else: + chip.set_state(DaemonState.UNKNOWN, f"{daemon_key} is not ready or has not reported yet.") + + def blink_tick(self, phase: bool): + """Drive busy blinking for wait-state-active subsystems.""" + self._blink_phase = bool(phase) + for subsystem in self._subsystems: + label = subsystem["label"] + daemon_key = subsystem["daemon"] + wait_key = subsystem.get("wait") + chip = self._chips_by_label.get(label) + if chip is None: + continue + if wait_key and self.waiting.get(wait_key, False): + chip.set_busy_phase( + self._blink_phase, + f"Sequencer is waiting on {wait_key} ({daemon_key})." + ) def set_daemon_state(self, name: str, state: str, issue: str = None): - chip = self._chips.get(name) - if chip: - chip.set_state(state, issue) + """Manual/fallback update by daemon key or display label.""" + chip = self._chips_by_daemon.get(name) or self._chips_by_label.get(name) + if not chip: + return + + # Keep daemon_ready coherent for fallback updates. + if state == DaemonState.OK: + self.daemon_ready[chip.daemon_key] = True + elif state in (DaemonState.UNKNOWN, DaemonState.ERROR): + self.daemon_ready[chip.daemon_key] = False + + chip.set_state(state, issue) def bulk_update(self, updates: dict): - for name, (state, issue) in updates.items(): + for name, value in updates.items(): + if isinstance(value, tuple): + state, issue = value[0], value[1] if len(value) > 1 else None + else: + state, issue = value, None self.set_daemon_state(name, state, issue) diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index b6e0b4bc..479e96a2 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -12,7 +12,7 @@ from calib.calibration import CalibrationGUI from etc_popup import EtcPopup from control_tab import ControlTab -from daemon_status_bar import DaemonStatusBar, DaemonState +from daemon_status_bar import DaemonStatusBar, DaemonState, DAEMON_SUBSYSTEMS from calibration_procedure import ( make_calibration_targets, make_calibration_csv_text, @@ -20,19 +20,9 @@ ) from datetime import datetime -DAEMONS = [ - "ACAM", - "CALIB", - "CAMERA", - "FLEXURE", - "FOCUS", - "POWER", - "SEQUENCER", - "SLICECAM", - "SLIT", - "TCS", - "THERMAL", -] +# Display-label/daemon-key/wait-key mapping lives in daemon_status_bar.py. +# Keep this alias for local fallback polling code. +DAEMONS = DAEMON_SUBSYSTEMS PER_DAEMON_COMMANDS = { # Defaults are ["Ping", "Restart", "Open Logs"] if not specified: @@ -60,6 +50,8 @@ def __init__(self): self.current_target_list_name = None self.zmq_status_service = None self.zmq_debug_messages = False + self._daemon_telemetry_seen = False + self._daemon_blink_phase = False # Login status flag self.logged_in = False @@ -107,7 +99,7 @@ def __init__(self): ) self.setStatusBar(self._statusbar) - self.daemon_row = DaemonStatusBar(DAEMONS, per_daemon_commands=PER_DAEMON_COMMANDS, parent=self) + self.daemon_row = DaemonStatusBar(DAEMON_SUBSYSTEMS, per_daemon_commands=PER_DAEMON_COMMANDS, parent=self) self.daemon_row.commandRequested.connect(self.on_daemon_command) self.daemon_row.detailsRequested.connect(self.on_daemon_details) @@ -118,20 +110,17 @@ def __init__(self): self._statusbar.addWidget(self.daemon_row, 1) self.daemon_row.bulk_update({ - "acamd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "calibd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "camerad": (DaemonState.UNKNOWN, "Awaiting first UDP packet..."), - "flexured": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "focusd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "powerd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "sequencerd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "slicecamd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "slitd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "tcsd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), - "thermald": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), + subsystem["daemon"]: (DaemonState.UNKNOWN, "Awaiting sequencer telemetry...") + for subsystem in DAEMON_SUBSYSTEMS }) - # Start polling local processes to update daemon states + # Connect seqgui-style daemon status telemetry after the bar exists. + self._connect_daemon_status_signals() + + # One shared blink timer for wait-state-active daemon chips. + self._init_daemon_blink_timer() + + # Start process polling only as a fallback until sequencer telemetry arrives. self._init_daemon_polling() def init_ui(self): @@ -199,6 +188,7 @@ def initialize_services(self): self.zmq_status_service.subscribe_to_topic("acamd") self.zmq_status_service.subscribe_to_topic("seq_waitstate") self.zmq_status_service.subscribe_to_topic("seq_seqstate") + self.zmq_status_service.subscribe_to_topic("seq_daemonstate") # Connect the message_received signal from ZMQStatusService to the update_message_log slot self.zmq_status_service.new_message_signal.connect(self.layout_service.update_message_log) @@ -533,6 +523,39 @@ def on_delete_target_list(self): print(f"Exception during target list deletion: {e}") QMessageBox.critical(self, "Error", f"An error occurred while deleting the target list:\n{str(e)}") + def _connect_daemon_status_signals(self): + """Connect seqgui-style ZMQ telemetry to the bottom daemon chip bar.""" + if self.zmq_status_service is None or not hasattr(self, "daemon_row"): + return + + self.zmq_status_service.daemonstate_signal.connect(self._on_daemonstate_update) + self.zmq_status_service.waitstate_signal.connect(self._on_waitstate_update) + self.zmq_status_service.sequencerd_alive_signal.connect(self.daemon_row.set_sequencerd_online) + + @pyqtSlot(dict) + def _on_daemonstate_update(self, state: dict): + """Apply seq_daemonstate: daemon key -> initialized/ready bool.""" + self._daemon_telemetry_seen = True + self.daemon_row.set_daemon_ready_state(state) + + @pyqtSlot(dict) + def _on_waitstate_update(self, state: dict): + """Apply seq_waitstate: wait key -> busy/waiting bool.""" + self._daemon_telemetry_seen = True + self.daemon_row.set_wait_state(state) + + def _init_daemon_blink_timer(self): + """Drive busy blinking for any wait-state-active daemon chips.""" + self._daemon_blink_timer = QTimer(self) + self._daemon_blink_timer.setInterval(500) + self._daemon_blink_timer.timeout.connect(self._daemon_blink_tick) + self._daemon_blink_timer.start() + + def _daemon_blink_tick(self): + self._daemon_blink_phase = not self._daemon_blink_phase + if hasattr(self, "daemon_row"): + self.daemon_row.blink_tick(self._daemon_blink_phase) + def on_daemon_details(self, name: str): # DaemonChip already shows a QMessageBox with details. # Keep this slot for a future richer dialog (logs, metrics, traces). @@ -583,15 +606,21 @@ def refresh_daemon_states_from_ps(self): - 'ok' => green pill - 'unknown' => grey pill """ + # Once sequencer telemetry has arrived, ZMQ becomes the source of truth. + # Do not let process polling fight seq_daemonstate/seq_waitstate. + if getattr(self, "_daemon_telemetry_seen", False): + return + updates = {} - for name in DAEMONS: + for subsystem in DAEMONS: + name = subsystem["daemon"] if isinstance(subsystem, dict) else str(subsystem) if self._daemon_process_running(name): - updates[name] = ("ok", f"{name} process is running.") + updates[name] = (DaemonState.OK, f"{name} process is running. Awaiting seq_daemonstate.") else: - updates[name] = ("unknown", f"{name} not found in process list.") + updates[name] = (DaemonState.UNKNOWN, f"{name} not found in process list. Awaiting seq_daemonstate.") - # bulk_update expects {name: (state, tooltip)} just like the seed above + # bulk_update accepts daemon keys, e.g. acamd/camerad/tcsd. self.daemon_row.bulk_update(updates) def _init_daemon_polling(self): diff --git a/pygui/zmq_status_service.py b/pygui/zmq_status_service.py index f54f778f..2ee950f4 100644 --- a/pygui/zmq_status_service.py +++ b/pygui/zmq_status_service.py @@ -21,15 +21,22 @@ class ZmqStatusService(QObject): system_status_signal = pyqtSignal(str) + # seqgui-style daemon status signals + daemonstate_signal = pyqtSignal(dict) # seq_daemonstate: daemon-key -> bool + waitstate_signal = pyqtSignal(dict) # seq_waitstate: wait-key -> bool + sequencerd_alive_signal = pyqtSignal() # emitted when seq_seqstate arrives + def __init__( self, parent, broker_publish_endpoint="tcp://127.0.0.1:5556", + broker_snapshot_endpoint="tcp://127.0.0.1:5555", emit_debug_messages=False, ): super().__init__() self.parent = parent # Reference to the parent window or main UI self.broker_publish_endpoint = broker_publish_endpoint + self.broker_snapshot_endpoint = broker_snapshot_endpoint # Debug/raw-message UI output flag # False = do not flood the GUI message box @@ -38,6 +45,7 @@ def __init__( self.context = zmq.Context() self.socket = None + self.pub_socket = None self.is_connected = False self.subscribed_topics = set() # Set of subscribed topics self._last_seq_lifecycle_status = "stopped" @@ -87,6 +95,12 @@ def connect(self): # Create the SUB socket type to receive messages self.socket = self.context.socket(zmq.SUB) self.socket.connect(self.broker_publish_endpoint) # Connect to the broker (publisher's address and port) + + # Optional PUB socket used only to request a status snapshot, matching seqgui. + # If nobody is listening on this endpoint, send_multipart simply has no effect. + self.pub_socket = self.context.socket(zmq.PUB) + self.pub_socket.connect(self.broker_snapshot_endpoint) + self.is_connected = True self.logger.info(f"Connected to broker at {self.broker_publish_endpoint}") except Exception as e: @@ -131,6 +145,37 @@ def subscribe_to_all(self): self.logger.info("Subscribed to all topics.") + @staticmethod + def _bool_payload(data): + """Return only boolean fields from a decoded JSON status payload.""" + if not isinstance(data, dict): + return {} + return {str(k): bool(v) for k, v in data.items() if isinstance(v, bool)} + + def _request_snapshot(self): + """Ask daemons/sequencer to re-publish current telemetry, like seqgui.""" + if self.pub_socket is None: + return + + payload = json.dumps({ + "sequencerd": True, + "acamd": True, + "calibd": True, + "camerad": True, + "flexured": True, + "focusd": True, + "powerd": True, + "slicecamd": True, + "slitd": True, + "tcsd": True, + "thermald": True, + }) + + try: + self.pub_socket.send_multipart([b"_snapshot", payload.encode("utf-8")]) + except zmq.ZMQError as e: + self.logger.warning(f"Snapshot request failed: {e}") + def listen(self): """Listen for incoming messages from the broker.""" if not self.is_connected: @@ -140,6 +185,10 @@ def listen(self): try: self.logger.info("Starting to listen for messages from the broker...") + # Give subscriptions a moment to register, then request current state. + QThread.msleep(200) + self._request_snapshot() + while True: message = self.socket.recv_multipart() @@ -160,13 +209,21 @@ def listen(self): self._emit_debug_message(f"Topic: {topic}, Payload: {payload}") elif topic == "seq_seqstate": + # Any seq_seqstate message proves sequencerd is alive. + self.sequencerd_alive_signal.emit() self._last_seq_lifecycle_status = self._status_from_seq_seqstate(data) self._emit_resolved_system_status() elif topic == "seq_waitstate": + # seqgui-style busy/blink source. + self.waitstate_signal.emit(self._bool_payload(data)) self._last_seq_wait_status = self._status_from_seq_waitstate(data) self._emit_resolved_system_status() + elif topic == "seq_daemonstate": + # seqgui-style ready/not-ready source. + self.daemonstate_signal.emit(self._bool_payload(data)) + if topic == "slitd": slit_width = data.get("SLITW", None) slit_offset = data.get("SLITO", None) @@ -198,8 +255,14 @@ def disconnect(self): """ Disconnect from the broker and close the socket. """ if self.socket: self.socket.close() - self.is_connected = False - self.logger.info("Disconnected from broker.") + self.socket = None + + if self.pub_socket: + self.pub_socket.close() + self.pub_socket = None + + self.is_connected = False + self.logger.info("Disconnected from broker.") def update_lamp_states(self, data): """ Emit signal with the lamp states """ From d9a3b1e2123a1b5bf5a000414dcada97fc58af6f Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Tue, 19 May 2026 15:37:03 -0700 Subject: [PATCH 05/20] Added dome flat calibration --- pygui/calibration_procedure.py | 84 ++++++++++++++++++++++++++++++++++ pygui/control_tab.py | 3 +- pygui/ngps_gui.py | 41 +++++++++++++++-- 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/pygui/calibration_procedure.py b/pygui/calibration_procedure.py index c1282cf4..b75f463f 100644 --- a/pygui/calibration_procedure.py +++ b/pygui/calibration_procedure.py @@ -272,6 +272,90 @@ def make_calibration_csv_text(slitwidth, xbin, ybin): return output.getvalue() +def make_dome_flat_targets(slitwidth, xbin, ybin): + """ + Generate NGPS dome-flat calibration target rows. + + This is the Python equivalent of the make_domes bash recipe: + make_domes + + The exposure-time constants follow the same convention used by + make_calibration_targets() above. In other words, the bash values + 90000 and 400000 are represented here as 90 and 400. + + Current mapping preserves the bash output: + BINSPAT <- xbin + BINSPECT <- ybin + """ + slitwidth = float(slitwidth) + xbin = int(xbin) + ybin = int(ybin) + + if slitwidth <= 0: + raise ValueError("slitwidth must be greater than 0.") + + if xbin <= 0 or ybin <= 0: + raise ValueError("binning values must be greater than 0.") + + # Nominal exposure times, seconds. + # Bash make_domes equivalent values: + # T_dome_nom = 90000 + # T_dome_nom_ug = 400000 + t_dome_nom = 90 + t_dome_nom_ug = 400 + + # Nominal counts from make_domes. + n_dome = 7 + n_dome_ug = 7 + + cont_multiplier = (1.0 / (xbin * ybin)) * (0.5 / slitwidth) + + t_dome = int(t_dome_nom * cont_multiplier) + t_dome_ug = int(t_dome_nom_ug * cont_multiplier) + + rows = [] + + if n_dome > 0: + rows.append( + _cal_row( + "CAL_DOME", + "ugri dome", + xbin, + ybin, + slitwidth, + t_dome, + n_dome, + ) + ) + + if n_dome_ug > 0: + rows.append( + _cal_row( + "CAL_DOME_UG", + "ug dome", + xbin, + ybin, + slitwidth, + t_dome_ug, + n_dome_ug, + ) + ) + + return rows + + +def make_dome_flat_csv_text(slitwidth, xbin, ybin): + """Return the generated dome-flat procedure as CSV text.""" + rows = make_dome_flat_targets(slitwidth, xbin, ybin) + + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=CAL_HEADER) + writer.writeheader() + writer.writerows(rows) + + return output.getvalue() + + def save_calibration_csv(rows, target_list_name, output_dir="generated_target_lists/calibrations"): """ Save generated calibration rows to a CSV file. diff --git a/pygui/control_tab.py b/pygui/control_tab.py index 8d709592..ff9f7fd2 100644 --- a/pygui/control_tab.py +++ b/pygui/control_tab.py @@ -411,8 +411,7 @@ def send_target_command(self, observation_id=None, calibration_mode=False, set_i print("No target SET_ID available for calibration command.") return - # Equivalent of running: seq do all {SET_ID} - command = f"do all {set_id}\n" + command = f"targetset {set_id}\nstart\n" elif observation_id: command = f"startone {observation_id}\n" diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 479e96a2..7b8d667b 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -16,6 +16,7 @@ from calibration_procedure import ( make_calibration_targets, make_calibration_csv_text, + make_dome_flat_targets, save_calibration_csv, ) from datetime import datetime @@ -305,8 +306,28 @@ def open_calibration_gui(self): def run_calibration(self): """ Generate a calibration target list from slit width and binning. + + The user can choose either the standard calibration recipe or + the dome-flat recipe. Both are uploaded as calibration target lists. """ + calibration_options = [ + "Standard Calibration", + "Dome Flats", + ] + + calibration_type, ok = QInputDialog.getItem( + self, + "Calibration Setup", + "Calibration type:", + calibration_options, + 0, + False, + ) + + if not ok or not calibration_type: + return + slitwidth, ok = QInputDialog.getDouble( self, "Calibration Setup", @@ -346,8 +367,17 @@ def run_calibration(self): if not ok: return + if calibration_type == "Dome Flats": + target_rows_fn = make_dome_flat_targets + target_list_prefix = "DOME" + list_kind_label = "dome-flat" + else: + target_rows_fn = make_calibration_targets + target_list_prefix = "CAL" + list_kind_label = "calibration" + try: - rows = make_calibration_targets( + rows = target_rows_fn( slitwidth=slitwidth, xbin=xbin, ybin=ybin, @@ -365,12 +395,12 @@ def run_calibration(self): QMessageBox.warning( self, "No Calibration Rows", - "No calibration rows were generated." + f"No {list_kind_label} rows were generated." ) return timestamp = datetime.now().strftime("%Y-%m-%d") - target_list_name = f"CAL_{timestamp}_slit{slitwidth:g}_bin{xbin}x{ybin}" + target_list_name = f"{target_list_prefix}_{timestamp}_slit{slitwidth:g}_bin{xbin}x{ybin}" set_id = self.logic_service.upload_generated_targets_to_mysql( rows, @@ -387,7 +417,7 @@ def run_calibration(self): QMessageBox.warning( self, "CSV Save Warning", - f"The calibration list was inserted into MySQL, but the CSV file could not be saved:\n{e}" + f"The {list_kind_label} list was inserted into MySQL, but the CSV file could not be saved:\n{e}" ) self.current_target_list_name = target_list_name @@ -401,8 +431,9 @@ def run_calibration(self): self.layout_service.load_target_lists() message = ( - f"Created calibration target list '{target_list_name}'.\n\n" + f"Created {list_kind_label} target list '{target_list_name}'.\n\n" f"SET_ID: {set_id}\n" + f"Type: {calibration_type}\n" f"Slit width: {slitwidth:g}\n" f"Spatial binning / xbin: {xbin}\n" f"Spectral binning / ybin: {ybin}\n" From e20ba4f6d351f5ad3e9bb70f02dcbe59174840f0 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Tue, 19 May 2026 15:40:24 -0700 Subject: [PATCH 06/20] Added dome flat calibration --- pygui/ngps_gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 7b8d667b..a670d1c5 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -369,7 +369,7 @@ def run_calibration(self): if calibration_type == "Dome Flats": target_rows_fn = make_dome_flat_targets - target_list_prefix = "DOME" + target_list_prefix = "CAL_DOME" list_kind_label = "dome-flat" else: target_rows_fn = make_calibration_targets From cbf8673eb26d8a811dbd5f6a46f0ca72a3631bde Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Tue, 19 May 2026 17:33:55 -0700 Subject: [PATCH 07/20] remove set when inserting --- pygui/control_tab.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygui/control_tab.py b/pygui/control_tab.py index ff9f7fd2..86ee90d8 100644 --- a/pygui/control_tab.py +++ b/pygui/control_tab.py @@ -565,13 +565,13 @@ def on_exposure_time_changed(self): exposure_time = self.exposure_time_box.text() if self.parent.current_observation_id: self.logic_service.send_update_to_db(self.parent.current_observation_id, "OTMexpt", exposure_time) - self.logic_service.send_update_to_db(self.parent.current_observation_id, "exptime", "SET " + exposure_time) + self.logic_service.send_update_to_db(self.parent.current_observation_id, "exptime", exposure_time) def on_slit_width_changed(self): slit_width = self.slit_width_box.text() if self.parent.current_observation_id: self.logic_service.send_update_to_db(self.parent.current_observation_id, "OTMslitwidth", slit_width) - self.logic_service.send_update_to_db(self.parent.current_observation_id, "slitwidth", "SET " + slit_width) + self.logic_service.send_update_to_db(self.parent.current_observation_id, "slitwidth", slit_width) def on_slit_angle_changed(self): slit_angle = self.slit_angle_box.text() @@ -582,7 +582,7 @@ def on_slit_angle_changed(self): if self.parent.current_observation_id: self.logic_service.send_update_to_db(self.parent.current_observation_id, "OTMslitangle", slit_angle) - self.logic_service.send_update_to_db(self.parent.current_observation_id, "slitangle", "SET " + slit_angle) + self.logic_service.send_update_to_db(self.parent.current_observation_id, "slitangle", slit_angle) def num_of_exposures_changed(self): num_of_exposures = self.num_of_exposures_box.text() From 555b321e75a791ca784da94214a9f31b2d95c0c7 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Tue, 19 May 2026 18:23:49 -0700 Subject: [PATCH 08/20] Added OTMCass --- pygui/control_tab.py | 26 ++++++++++++++++++++++++++ pygui/layout_service.py | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/pygui/control_tab.py b/pygui/control_tab.py index 86ee90d8..4ec30707 100644 --- a/pygui/control_tab.py +++ b/pygui/control_tab.py @@ -1,3 +1,4 @@ +import csv from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QFrame, QMessageBox, QDialog @@ -573,6 +574,31 @@ def on_slit_width_changed(self): self.logic_service.send_update_to_db(self.parent.current_observation_id, "OTMslitwidth", slit_width) self.logic_service.send_update_to_db(self.parent.current_observation_id, "slitwidth", slit_width) + def _read_theta0(self, geocal_path="/home/developer/Software/Python/acam_skyinfo/geocal.cfg"): + """Read Theta0 from geocal.cfg.""" + try: + with open(geocal_path, "r", newline="") as file: + reader = csv.DictReader(file) + row = next(reader, None) + + if row is None or "Theta0" not in row: + print(f"Theta0 not found in {geocal_path}") + return None + + return float(row["Theta0"]) + + except Exception as e: + print(f"Could not read Theta0 from {geocal_path}: {e}") + return None + + + def _parse_angle_float(self, value): + """Parse a slit angle value into float.""" + try: + return float(str(value).replace("SET", "").strip()) + except Exception: + return None + def on_slit_angle_changed(self): slit_angle = self.slit_angle_box.text() if slit_angle == "PA": diff --git a/pygui/layout_service.py b/pygui/layout_service.py index b1410213..c2f99dc4 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -1213,8 +1213,8 @@ def update_target_info(self): self.target_list_display.selectRow(selected_row) slit_angle = "0" - # if self.parent.current_ra != '' and self.parent.current_dec != '': - # slit_angle = self.logic_service.compute_parallactic_angle_astroplan(self.parent.current_ra, self.parent.current_dec) + if self.parent.current_ra != '' and self.parent.current_dec != '': + slit_angle = self.logic_service.compute_parallactic_angle_astroplan(self.parent.current_ra, self.parent.current_dec) self.control_tab.slit_angle_box.setText(slit_angle) else: From 2710b5bc1720758c15bd8c5519a3bbd16c0e8bd1 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Tue, 19 May 2026 19:53:42 -0700 Subject: [PATCH 09/20] remove SET --- pygui/calibration_procedure.py | 4 ++-- pygui/ngps_gui.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pygui/calibration_procedure.py b/pygui/calibration_procedure.py index b75f463f..85bf3524 100644 --- a/pygui/calibration_procedure.py +++ b/pygui/calibration_procedure.py @@ -47,8 +47,8 @@ def _cal_row(name, comment, bin_spat, bin_spec, slitwidth, exptime, nexp): row["COMMENT"] = comment row["BINSPAT"] = _fmt(bin_spat) row["BINSPECT"] = _fmt(bin_spec) - row["SLITWIDTH"] = f"SET {_fmt(slitwidth)}" - row["EXPTIME"] = f"SET {_fmt(exptime)}" + row["SLITWIDTH"] = _fmt(slitwidth) + row["EXPTIME"] = _fmt(exptime) row["NEXP"] = _fmt(nexp) return row diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index a670d1c5..386d9506 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -312,7 +312,7 @@ def run_calibration(self): """ calibration_options = [ - "Standard Calibration", + "Internal", "Dome Flats", ] From 2a90e1f6a8bad7ed3810aed4730afb03207604f5 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Tue, 19 May 2026 20:13:25 -0700 Subject: [PATCH 10/20] Add otm stuff --- pygui/calibration_procedure.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pygui/calibration_procedure.py b/pygui/calibration_procedure.py index 85bf3524..891fcf92 100644 --- a/pygui/calibration_procedure.py +++ b/pygui/calibration_procedure.py @@ -24,6 +24,8 @@ "MAGFILTER", "EXPTIME", "NEXP", + "OTMslitwidth", + "OTMexpt", ] @@ -34,23 +36,30 @@ def _fmt(value): return str(value) -def _cal_row(name, comment, bin_spat, bin_spec, slitwidth, exptime, nexp): - """ - Build one calibration target row using the same field layout as make_cals. +def _to_float(value, field_name): + try: + return float(value) + except (TypeError, ValueError): + raise ValueError(f"{field_name} must be numeric, got {value!r}") - Bash equivalent: - print_line $name "$comment" $xbin $ybin $slitwidth $exptime $N_exp - """ + +def _cal_row(name, comment, bin_spat, bin_spec, slitwidth, exptime, nexp): row = {key: "" for key in CAL_HEADER} row["NAME"] = name row["COMMENT"] = comment row["BINSPAT"] = _fmt(bin_spat) row["BINSPECT"] = _fmt(bin_spec) + + # CSV/display-style fields row["SLITWIDTH"] = _fmt(slitwidth) row["EXPTIME"] = _fmt(exptime) row["NEXP"] = _fmt(nexp) + # MySQL DOUBLE fields + row["OTMslitwidth"] = _to_float(slitwidth, "OTMslitwidth") + row["OTMexpt"] = _to_float(exptime, "OTMexpt") + return row From 511a13850de01d87100bed9ac7a1bdb0e3e68331 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Fri, 22 May 2026 11:08:25 -0700 Subject: [PATCH 11/20] nexp fix --- pygui/calibration_procedure.py | 4 ++-- pygui/logic_service.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pygui/calibration_procedure.py b/pygui/calibration_procedure.py index 891fcf92..bb0579b2 100644 --- a/pygui/calibration_procedure.py +++ b/pygui/calibration_procedure.py @@ -102,7 +102,7 @@ def make_calibration_targets(slitwidth, xbin, ybin): # Nominal counts. n_thar = 3 n_fear = 3 - n_cont = 3 + n_cont = 0 n_etalon = 0 n_dome = 0 n_dome_ug = 0 @@ -314,7 +314,7 @@ def make_dome_flat_targets(slitwidth, xbin, ybin): t_dome_nom_ug = 400 # Nominal counts from make_domes. - n_dome = 7 + n_dome = 5 n_dome_ug = 7 cont_multiplier = (1.0 / (xbin * ybin)) * (0.5 / slitwidth) diff --git a/pygui/logic_service.py b/pygui/logic_service.py index aa3820ef..c0b7b385 100644 --- a/pygui/logic_service.py +++ b/pygui/logic_service.py @@ -139,7 +139,7 @@ def upload_csv_to_mysql(self, file_path, target_set_name): # Numeric columns (defaults to 0) if column in ['OBS_ORDER', 'TARGET_NUMBER', 'SEQUENCE_NUMBER']: value = 0 - elif column in ['BINSPECT']: + elif column in ['BINSPECT', 'NEXP']: value = 1 elif column in ['BINSPAT']: value = 2 @@ -261,7 +261,7 @@ def upload_generated_targets_to_mysql(self, rows, target_set_name): if value is None or value == "": if column in ['OBS_ORDER', 'TARGET_NUMBER', 'SEQUENCE_NUMBER']: value = 0 - elif column == 'BINSPECT': + elif column == ['BINSPECT', 'NEXP']: value = 1 elif column == 'BINSPAT': value = 2 From 26d1c09753938d1d1e17252e5fb1b828b2d4cb1c Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Mon, 1 Jun 2026 12:02:21 -0700 Subject: [PATCH 12/20] update seq zmq --- pygui/layout_service.py | 29 ++++++++++++++--- pygui/ngps_gui.py | 2 ++ pygui/zmq_status_service.py | 65 ++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/pygui/layout_service.py b/pygui/layout_service.py index c2f99dc4..3adf7aa6 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -373,10 +373,18 @@ def create_sequencer_mode_group(self): self.parent.sequencer_mode_single = QRadioButton("Single") self.parent.sequencer_mode_all = QRadioButton("All") + + self.parent.sequencer_mode_single.clicked.connect( + self.on_sequencer_mode_single_clicked + ) + self.parent.sequencer_mode_all.clicked.connect( + self.on_sequencer_mode_all_clicked + ) + sequencer_mode_layout.addWidget(self.parent.sequencer_mode_single) sequencer_mode_layout.addWidget(self.parent.sequencer_mode_all) - # Fine acquire toggle + # Fine acquire toggle self.parent.fine_acquire_toggle = QPushButton("Fine Acquire: Enabled") self.parent.fine_acquire_toggle.setCheckable(True) self.parent.fine_acquire_toggle.setChecked(True) @@ -399,13 +407,24 @@ def create_sequencer_mode_group(self): sequencer_mode_layout.addWidget(self.parent.fine_acquire_toggle) sequencer_mode_group.setLayout(sequencer_mode_layout) - - # Set maximum width and height for the sequencer mode group - sequencer_mode_group.setMaximumWidth(300) # Maximum width - sequencer_mode_group.setMaximumHeight(145) # Maximum height + sequencer_mode_group.setMaximumWidth(300) + sequencer_mode_group.setMaximumHeight(145) return sequencer_mode_group + def on_sequencer_mode_single_clicked(self): + """Set sequencer to single-target mode.""" + print("Sequencer mode selected: Single") + print("Sending command: seq do one") + self.parent.send_command("do one\n") + + + def on_sequencer_mode_all_clicked(self): + """Set sequencer to all-targets mode.""" + print("Sequencer mode selected: All") + print("Sending command: seq do all") + self.parent.send_command("do all\n") + def on_fine_acquire_toggled(self, checked): """Enable/disable the sequencer fine-acquire step.""" action = "enable" if checked else "disable" diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 386d9506..cdef7c2d 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -198,6 +198,7 @@ def initialize_services(self): self.zmq_status_service.airmass_signal.connect(self.layout_service.update_airmass) self.zmq_status_service.slit_info_signal.connect(self.layout_service.update_slit_info_fields) self.zmq_status_service.system_status_signal.connect(self.layout_service.update_system_status) + self.zmq_status_service.user_can_expose_signal.connect(self.layout_service.control_tab.enable_continue_and_offset_button) def on_date_time_changed(self, datetime): start_time_utc = LogicService.convert_pst_to_utc(datetime) @@ -562,6 +563,7 @@ def _connect_daemon_status_signals(self): self.zmq_status_service.daemonstate_signal.connect(self._on_daemonstate_update) self.zmq_status_service.waitstate_signal.connect(self._on_waitstate_update) self.zmq_status_service.sequencerd_alive_signal.connect(self.daemon_row.set_sequencerd_online) + @pyqtSlot(dict) def _on_daemonstate_update(self, state: dict): diff --git a/pygui/zmq_status_service.py b/pygui/zmq_status_service.py index 2ee950f4..1a3b5165 100644 --- a/pygui/zmq_status_service.py +++ b/pygui/zmq_status_service.py @@ -21,6 +21,11 @@ class ZmqStatusService(QObject): system_status_signal = pyqtSignal(str) + # Emitted when sequencerd says it is waiting for the USER continue signal. + # This replaces the old UDP StatusService detection path for: + # waiting for USER to send "continue" signal + user_can_expose_signal = pyqtSignal(bool) + # seqgui-style daemon status signals daemonstate_signal = pyqtSignal(dict) # seq_daemonstate: daemon-key -> bool waitstate_signal = pyqtSignal(dict) # seq_waitstate: wait-key -> bool @@ -50,6 +55,7 @@ def __init__( self.subscribed_topics = set() # Set of subscribed topics self._last_seq_lifecycle_status = "stopped" self._last_seq_wait_status = None + self._user_continue_ready_emitted = False # Set up logging self.setup_logging() @@ -68,6 +74,42 @@ def _emit_debug_message(self, message: str): if self.emit_debug_messages: self.new_message_signal.emit(message) + @staticmethod + def _contains_user_continue_message(value) -> bool: + """ + Return True if a raw or decoded ZMQ payload contains the sequencer + message that tells the GUI the operator can press Expose/Continue. + """ + needle = 'waiting for USER to send "continue" signal' + + if value is None: + return False + + if isinstance(value, dict): + return any(ZmqStatusService._contains_user_continue_message(v) for v in value.values()) + + if isinstance(value, (list, tuple, set)): + return any(ZmqStatusService._contains_user_continue_message(v) for v in value) + + return needle in str(value) + + def _emit_user_can_expose_once(self): + """ + Emit user_can_expose_signal once per USER wait-state/message. + Repeated ZMQ publications should not repeatedly restyle/pop the GUI. + """ + if not self._user_continue_ready_emitted: + self.logger.info('Sequencer is waiting for USER continue; enabling Expose/Offset controls.') + self.user_can_expose_signal.emit(True) + self._user_continue_ready_emitted = True + + def _clear_user_can_expose_latch(self): + """ + Allow a future USER wait-state/message to emit again after the + sequencer leaves the USER wait state. + """ + self._user_continue_ready_emitted = False + def setup_logging(self): """ Set up logging for the status service in a 'logs' folder. """ @@ -202,9 +244,20 @@ def listen(self): # Always log to file, even when GUI debug messages are disabled. self.logger.info(f"Received message: Topic = {topic}, Payload = {payload}") + # Some sequencerd messages are plain text status messages rather + # than JSON objects. Detect the USER continue message before + # json.loads so raw text payloads are handled too. + if topic == "sequencerd" and self._contains_user_continue_message(payload): + self._emit_user_can_expose_once() + try: data = json.loads(payload) + # Also support JSON payloads that contain the same text in a + # field such as {"message": "..."} or {"status": "..."}. + if topic == "sequencerd" and self._contains_user_continue_message(data): + self._emit_user_can_expose_once() + if topic == "acamd": self._emit_debug_message(f"Topic: {topic}, Payload: {payload}") @@ -216,7 +269,17 @@ def listen(self): elif topic == "seq_waitstate": # seqgui-style busy/blink source. - self.waitstate_signal.emit(self._bool_payload(data)) + wait_flags = self._bool_payload(data) + self.waitstate_signal.emit(wait_flags) + + # USER wait-state is the structured equivalent of the + # sequencerd text message: + # waiting for USER to send "continue" signal + if wait_flags.get("USER", False): + self._emit_user_can_expose_once() + else: + self._clear_user_can_expose_latch() + self._last_seq_wait_status = self._status_from_seq_waitstate(data) self._emit_resolved_system_status() From b9d8edf7a28e1a8778a824270657cf4c55a1c423 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Wed, 10 Jun 2026 14:10:46 -0700 Subject: [PATCH 13/20] Calibration and messages update --- pygui/daemon_status_bar.py | 1 - pygui/layout_service.py | 202 ++++++++++++---- pygui/logic_service.py | 471 ++++++++++++++++++++++++++++-------- pygui/ngps_gui.py | 36 +-- pygui/zmq_status_service.py | 126 +++++----- 5 files changed, 603 insertions(+), 233 deletions(-) diff --git a/pygui/daemon_status_bar.py b/pygui/daemon_status_bar.py index 6340e3f2..bc759689 100644 --- a/pygui/daemon_status_bar.py +++ b/pygui/daemon_status_bar.py @@ -32,7 +32,6 @@ {"label": "SLICECAM", "daemon": "slicecamd", "wait": WAIT_SLICECAM}, {"label": "SLIT", "daemon": "slitd", "wait": WAIT_SLIT}, {"label": "TCS", "daemon": "tcsd", "wait": WAIT_TCS}, - {"label": "THERMAL", "daemon": "thermald", "wait": None}, ] diff --git a/pygui/layout_service.py b/pygui/layout_service.py index 3adf7aa6..c5baa913 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -192,33 +192,15 @@ def create_system_status_group(self): system_status_layout.setSpacing(10) system_status_layout.setContentsMargins(5, 5, 5, 5) - # Create a mapping for status colors + # Instrument System Status intentionally shows sequencer lifecycle + # states from seq_seqstate only. Busy/wait states from seq_waitstate + # belong to the bottom daemon/status bar, not this panel. status_map = { "stopped": QColor(169, 169, 169), "not_ready": QColor(255, 0, 0), "idle": QColor(255, 255, 0), "paused": QColor(255, 165, 0), - "exposing": QColor(0, 255, 0), - "readout": QColor(0, 255, 0), - - "moveto": QColor(255, 255, 0), - "acam_acquire": QColor(255, 255, 0), - "slicecam_fineacquire": QColor(255, 255, 0), - - "focus": QColor(255, 255, 0), - "calib": QColor(255, 255, 0), - "camera": QColor(255, 255, 0), - "flexure": QColor(255, 255, 0), - "power": QColor(255, 255, 0), - "slit": QColor(255, 255, 0), - "tcs": QColor(255, 255, 0), - "tcsop": QColor(255, 255, 0), - "user": QColor(255, 255, 0), - - # transitional / backward compatibility - "acam": QColor(255, 255, 0), - "slicecam": QColor(255, 255, 0), - "acquire": QColor(255, 255, 0), + "error": QColor(255, 0, 0), } # Create a dictionary to hold the status widgets, which we will enable/disable @@ -381,9 +363,21 @@ def create_sequencer_mode_group(self): self.on_sequencer_mode_all_clicked ) + self.parent.sequencer_mode_single.setToolTip( + "Runs the next PENDING target in database sequence order; this is not necessarily the selected row or visible first row." + ) + self.parent.sequencer_mode_all.setToolTip( + "Runs all remaining PENDING targets in database sequence order." + ) + sequencer_mode_layout.addWidget(self.parent.sequencer_mode_single) sequencer_mode_layout.addWidget(self.parent.sequencer_mode_all) + self.next_target_label = QLabel("Single: no target list loaded yet.") + self.next_target_label.setWordWrap(True) + self.next_target_label.setStyleSheet("font-size: 10pt; color: #222; padding: 3px;") + sequencer_mode_layout.addWidget(self.next_target_label) + # Fine acquire toggle self.parent.fine_acquire_toggle = QPushButton("Fine Acquire: Enabled") self.parent.fine_acquire_toggle.setCheckable(True) @@ -408,20 +402,43 @@ def create_sequencer_mode_group(self): sequencer_mode_group.setLayout(sequencer_mode_layout) sequencer_mode_group.setMaximumWidth(300) - sequencer_mode_group.setMaximumHeight(145) + sequencer_mode_group.setMaximumHeight(220) return sequencer_mode_group def on_sequencer_mode_single_clicked(self): """Set sequencer to single-target mode.""" + self.logic_service.refresh_visible_target_states() + mode, target = self.logic_service.current_or_next_target() + + if target is not None: + msg = ( + f"Single mode will run the next DB target: " + f"{target.get('NAME', 'Unnamed')} " + f"(OBS {target.get('OBSERVATION_ID', '')}). " + "This is based on STATE/sequence order, not table selection or table sorting." + ) + else: + msg = "Single mode selected, but no PENDING target is visible in the current list." + print("Sequencer mode selected: Single") + print(msg) print("Sending command: seq do one") self.parent.send_command("do one\n") def on_sequencer_mode_all_clicked(self): """Set sequencer to all-targets mode.""" + self.logic_service.refresh_visible_target_states() + rows = getattr(self.parent, "all_targets", []) or [] + pending = sum( + 1 for row in rows + if isinstance(row, dict) and self.logic_service.normalize_target_state(row.get("STATE")) == "PENDING" + ) + msg = f"All mode will run {pending} remaining PENDING target(s) in DB sequence order." + print("Sequencer mode selected: All") + print(msg) print("Sending command: seq do all") self.parent.send_command("do all\n") @@ -442,10 +459,6 @@ def on_fine_acquire_toggled(self, checked): text=True, ) - if hasattr(self.parent, "message_log") and self.parent.message_log: - self.update_message_log(f"Ran command: {' '.join(command)}") - if result.stdout.strip(): - self.update_message_log(result.stdout.strip()) except Exception as exc: # Revert the UI if the command failed. @@ -460,8 +473,6 @@ def on_fine_acquire_toggled(self, checked): f"Could not run: {' '.join(command)}\n\n{exc}", ) - if hasattr(self.parent, "message_log") and self.parent.message_log: - self.update_message_log(f"Fine acquire command failed: {' '.join(command)} — {exc}") def create_progress_and_image_group(self): progress_and_image_group = QGroupBox("Progress and Image Info") @@ -478,7 +489,7 @@ def create_progress_and_image_group(self): # Add all components to the main layout progress_and_image_layout.addLayout(progress_layout) progress_and_image_layout.addLayout(image_info_layout) - progress_and_image_layout.addWidget(QLabel("Log Messages:")) + progress_and_image_layout.addWidget(QLabel("Messages:")) progress_and_image_layout.addWidget(message_log) progress_and_image_group.setLayout(progress_and_image_layout) @@ -627,6 +638,16 @@ def clear_message_log(self): self.parent.message_log.clear() + def update_topic_broadcast_log(self, message): + """Append a message received from a subscribed ZMQ topic broadcast. + + The visible message log should only show topic-broadcast traffic. + Local GUI status, command, and database messages should use print(), + dialogs, or dedicated widgets instead of this log. + """ + self.update_message_log(message) + + def update_message_log(self, message): MAX_LOG_SIZE = 1000 # Max number of characters in the log MAX_MESSAGES = 100 # Max number of messages in the log @@ -798,6 +819,29 @@ def create_target_list_group(self): # Add the header layout to the main layout bottom_section_layout.addLayout(header_layout) + state_layout = QHBoxLayout() + state_layout.setSpacing(8) + + self.target_state_summary_label = QLabel("PENDING: 0 COMPLETED: 0 EXPOSING: 0") + self.target_state_summary_label.setToolTip("Counts come from the targets.STATE database column.") + state_layout.addWidget(self.target_state_summary_label) + + state_layout.addStretch(1) + state_layout.addWidget(QLabel("Selected state:")) + + self.target_state_combo = QComboBox() + self.target_state_combo.addItems(["PENDING", "COMPLETED", "EXPOSING"]) + self.target_state_combo.setToolTip("Set STATE for the selected target row.") + self.target_state_combo.setMaximumWidth(130) + state_layout.addWidget(self.target_state_combo) + + self.apply_target_state_button = QPushButton("Apply") + self.apply_target_state_button.setToolTip("Write the selected STATE to the database for the selected row.") + self.apply_target_state_button.clicked.connect(self.on_apply_target_state_clicked) + state_layout.addWidget(self.apply_target_state_button) + + bottom_section_layout.addLayout(state_layout) + # Create the button to load the target list self.load_target_button = QPushButton("Please login or load your target list to start") self.load_target_button.setStyleSheet(""" @@ -914,10 +958,57 @@ def create_target_list_group(self): # Connect the selectionChanged signal to the update_target_info function in LogicService self.target_list_display.selectionModel().selectionChanged.connect(self.update_target_info) + # Periodically refresh only target STATE values from the DB. This keeps + # COMPLETED/EXPOSING/PENDING current without destroying the observer's + # manual table sorting or selected row. + self.target_state_refresh_timer = QTimer(self.parent) + self.target_state_refresh_timer.setInterval(5000) + self.target_state_refresh_timer.timeout.connect( + self.logic_service.refresh_visible_target_states + ) + self.target_state_refresh_timer.start() + target_list_group.setLayout(bottom_section_layout) return target_list_group + def on_apply_target_state_clicked(self): + """Write STATE for the currently selected target row.""" + table = self.target_list_display + selected_rows = table.selectionModel().selectedRows() if table is not None else [] + if not selected_rows: + QMessageBox.information( + self.parent, + "No target selected", + "Select a target row before changing its STATE.", + ) + return + + observation_id = self.logic_service.selected_observation_id() + if not observation_id: + QMessageBox.warning( + self.parent, + "Missing OBSERVATION_ID", + "Could not find OBSERVATION_ID for the selected row.", + ) + return + + state = self.target_state_combo.currentText().strip().upper() + try: + ok = self.logic_service.update_target_state(observation_id, state) + except ValueError as exc: + QMessageBox.warning(self.parent, "Invalid target state", str(exc)) + return + + if ok: + print(f"Set OBSERVATION_ID {observation_id} STATE to {state}.") + else: + QMessageBox.warning( + self.parent, + "State update failed", + f"Could not set OBSERVATION_ID {observation_id} to {state}.", + ) + def show_column_toggle_dialog(self): table = self.target_list_display @@ -974,7 +1065,7 @@ def hide_default_columns(self): return # Column names to hide by default - to_hide = {"OBSERVATION_ID", "CHANNEL", "MAGNITUDE", "MAGSYSTEM", "MAGFILTER"} + to_hide = {"CHANNEL", "MAGNITUDE", "MAGSYSTEM", "MAGFILTER"} for col in range(table.columnCount()): header_item = table.horizontalHeaderItem(col) @@ -1108,6 +1199,11 @@ def update_target_info(self): offset_ra = None offset_dec = None num_of_exposures = None + ra = "" + dec = "" + binspect = "" + binspat = "" + target_state = "PENDING" print("Selected Row:", selected_row) # Print the selected row index print("Column Headers:", column_headers) # Print the column headers @@ -1123,6 +1219,9 @@ def update_target_info(self): observation_id = value # Store the observation ID print(f"Found OBSERVATION_ID: {observation_id}") # Print the found OBSERVATION_ID + if header == 'STATE': + target_state = self.logic_service.normalize_target_state(value) + # Check if the header is 'Exposure Time' and extract its value if header == 'RA': ra = value # Store the ra @@ -1177,6 +1276,12 @@ def update_target_info(self): print(f"Found NEXP: {num_of_exposures}") # Print the found NEXP self.control_tab.num_of_exposures_box.setText(num_of_exposures) + if hasattr(self, "target_state_combo") and self.target_state_combo is not None: + idx = self.target_state_combo.findText(target_state) + if idx >= 0: + with QSignalBlocker(self.target_state_combo): + self.target_state_combo.setCurrentIndex(idx) + # Pass the dictionary of target data to LogicService print("Target Data:", target_data) # Print the full target data for the selected row # self.parent.logic_service.update_target_list_table(target_data) @@ -1262,19 +1367,28 @@ def get_target_list_display(self): return self.target_list_display def set_column_widths(self): - # Set specific column widths (adjust as needed) - column_widths = [ - 250, 175, 125, 125, 125, 125, 125, 125, 125, 125, 175, 175, 175, 175, 175, 175, 175, 175, 175, 125, 125, 125 - ] - - # Get the number of columns in the table - column_count = self.target_list_display.columnCount() - - # Ensure we don't exceed the number of available columns - for col in range(column_count): - # Use the width from the list, or a default width if the list is too short - width = column_widths[col] if col < len(column_widths) else 150 - self.target_list_display.setColumnWidth(col, width) + """Set readable widths, with operational columns kept compact/visible.""" + table = self.target_list_display + if table is None: + return + + widths_by_name = { + "STATE": 115, + "OBSERVATION_ID": 130, + "NAME": 250, + "RA": 175, + "DECL": 175, + "EXPTIME": 125, + "SLITWIDTH": 125, + "BINSPECT": 95, + "BINSPAT": 95, + "NEXP": 80, + } + + for col in range(table.columnCount()): + header = table.horizontalHeaderItem(col) + name = header.text().strip().upper() if header else "" + table.setColumnWidth(col, widths_by_name.get(name, 150)) def update_status_ui(self, data, modulator_data): diff --git a/pygui/logic_service.py b/pygui/logic_service.py index c0b7b385..2a4aaa75 100644 --- a/pygui/logic_service.py +++ b/pygui/logic_service.py @@ -2,7 +2,7 @@ import configparser from PyQt5.QtWidgets import QTableWidgetItem from PyQt5.QtCore import Qt, pyqtSignal, QSignalBlocker -from PyQt5.QtGui import QColor, QFont +from PyQt5.QtGui import QColor, QFont, QBrush import os import csv import pytz @@ -144,7 +144,7 @@ def upload_csv_to_mysql(self, file_path, target_set_name): elif column in ['BINSPAT']: value = 2 elif column in ['STATE']: - value = "pending" # Empty string as default for text fields + value = "PENDING" # Default initial target state elif column in ['STATE', 'RA', 'DECL', 'EXPTIME', 'SLITWIDTH']: value = "" # Empty string as default for text fields # Timestamp columns (defaults to NULL) @@ -261,12 +261,12 @@ def upload_generated_targets_to_mysql(self, rows, target_set_name): if value is None or value == "": if column in ['OBS_ORDER', 'TARGET_NUMBER', 'SEQUENCE_NUMBER']: value = 0 - elif column == ['BINSPECT', 'NEXP']: + elif column in ['BINSPECT', 'NEXP']: value = 1 elif column == 'BINSPAT': value = 2 elif column == 'STATE': - value = "pending" + value = "PENDING" elif column in ['RA', 'DECL', 'EXPTIME', 'SLITWIDTH']: value = "" elif column in ['NOTBEFORE', 'OTMslewgo', 'OTMexp_start', 'OTMexp_end']: @@ -848,7 +848,11 @@ def filter_target_list(self): FROM targets t INNER JOIN target_sets s ON s.SET_ID = t.SET_ID WHERE s.OWNER = %s - ORDER BY t.SET_ID, t.NAME + ORDER BY t.SET_ID, + COALESCE(t.OBS_ORDER, 0), + COALESCE(t.TARGET_NUMBER, 0), + COALESCE(t.SEQUENCE_NUMBER, 0), + t.OBSERVATION_ID """ params = (owner,) cur = conn.cursor(dictionary=True) @@ -903,7 +907,10 @@ def filter_target_list(self): FROM targets t INNER JOIN target_sets s ON s.SET_ID = t.SET_ID WHERE t.SET_ID = %s AND s.OWNER = %s - ORDER BY t.NAME + ORDER BY COALESCE(t.OBS_ORDER, 0), + COALESCE(t.TARGET_NUMBER, 0), + COALESCE(t.SEQUENCE_NUMBER, 0), + t.OBSERVATION_ID """ params = (set_id, owner) cur = conn.cursor(dictionary=True) @@ -919,20 +926,27 @@ def filter_target_list(self): def update_target_list_table(self, data): """ - Idempotently repopulates the UI table with 'data'. - Clears first, blocks internal signals during rebuild, and restores sorting after. + Idempotently repopulate the target table. + + The DB STATE column is intentionally visible because it is the + source of truth for whether a target is PENDING, COMPLETED, or + EXPOSING. The table may be user-sorted, so the next target is + tracked by OBSERVATION_ID rather than by the visible row number. """ - # Access widgets safely ls = getattr(self.parent, "layout_service", None) if ls is None or not hasattr(ls, "target_list_display"): print("layout_service or target_list_display not available yet.") return table = ls.target_list_display - self.parent.all_targets = data # canonical cache for filtering + rows = data if isinstance(data, list) else [data] + + # Keep the DB/query order as the canonical sequencer order. The user + # can sort the visible table without changing what Single/All will do. + self.parent.all_targets = rows columns_to_hide = { - "SET_ID", "STATE", "OBS_ORDER", "TARGET_NUMBER", "SEQUENCE_NUMBER", + "SET_ID", "OBS_ORDER", "TARGET_NUMBER", "SEQUENCE_NUMBER", "SLITOFFSET", "OBSMODE", "AIRMASS_MAX", "WRANGE_LOW", "WRANGE_HIGH", "SRCMODEL", "OTMexpt", "OTMslitwidth", "OTMcass", "OTMairmass_start", "OTMairmass_end", "OTMsky", "OTMdead", "OTMslewgo", "OTMexp_start", @@ -941,23 +955,40 @@ def update_target_list_table(self, data): "NOTE", "COMMENT", "OWNER", "NOTBEFORE", "POINTMODE" } - rows = data if isinstance(data, list) else [data] filtered_rows = [] for r in rows: - if isinstance(r, dict): - filtered_rows.append({k: v for k, v in r.items() if k not in columns_to_hide}) + if not isinstance(r, dict): + continue + + display_row = {k: v for k, v in r.items() if k not in columns_to_hide} + + # STATE should always be visible and normalized for display only. + # This does not write to the database; it just makes blanks readable. + display_row["STATE"] = self.normalize_target_state( + display_row.get("STATE") or r.get("STATE") or "PENDING" + ) + filtered_rows.append(display_row) table_blocker = QSignalBlocker(table) try: table.setSortingEnabled(False) - table.clear() # headers + contents + table.clear() table.setRowCount(0) table.setColumnCount(0) if not filtered_rows: + self.refresh_target_status_summary([]) return headers = list(filtered_rows[0].keys()) + + # Make the most operational columns easy to see even if the DB + # returns a different column order. + preferred = ["STATE", "OBSERVATION_ID", "NAME", "RA", "DECL", "EXPTIME", "SLITWIDTH", "BINSPECT", "BINSPAT", "NEXP"] + ordered_headers = [h for h in preferred if h in headers] + ordered_headers.extend([h for h in headers if h not in ordered_headers]) + headers = ordered_headers + table.setColumnCount(len(headers)) table.setHorizontalHeaderLabels(headers) @@ -968,24 +999,21 @@ def update_target_list_table(self, data): row_idx = table.rowCount() table.insertRow(row_idx) for col_idx, key in enumerate(headers): - table.setItem(row_idx, col_idx, QTableWidgetItem(str(row.get(key, "")))) + item = QTableWidgetItem(str(row.get(key, ""))) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + table.setItem(row_idx, col_idx, item) - table.sortItems(0, Qt.AscendingOrder) finally: table.setSortingEnabled(True) del table_blocker - ls.load_target_button.setVisible(False) table.setVisible(True) ls.set_column_widths() ls.add_row_button.setEnabled(True) - try: - self.apply_active_highlight() - except Exception as _e: - pass - + self.refresh_target_table_status_styles() + self.refresh_target_status_summary(rows) def update_target_table_with_list(self, target_list=None): """Rebuild table for the selected target list using the same safe path.""" @@ -1015,32 +1043,154 @@ def update_target_information(self, target_data): def send_update_to_db(self, observation_id, field_name, value): """ - Sends an update query to the database to modify a specific field for the given observation ID. + Sends an update query to the database to modify a specific field for the + given observation ID. + + Keep this for existing callers, but restrict writable fields so table UI + actions cannot accidentally update arbitrary SQL column names. """ + allowed_fields = { + "STATE", "RA", "DECL", "OFFSET_RA", "OFFSET_DEC", "EXPTIME", + "SLITWIDTH", "BINSPECT", "BINSPAT", "NEXP", "OTMslitangle", + "OTMexpt", "OTMslitwidth", "OTMcass", + } + field = str(field_name).strip() + field_upper = field.upper() + resolved_field = next((f for f in allowed_fields if f.upper() == field_upper), None) + if resolved_field is None: + print(f"Refusing to update unsupported target field: {field_name}") + return False + + connection = None + cursor = None try: - # Step 1: Connect to MySQL using the config file connection = self.connect_to_mysql("config/db_config.ini") - if connection is None: - print("Failed to connect to MySQL. Cannot load target data.") - return - cursor = connection.cursor() # Create a cursor for executing the query + print("Failed to connect to MySQL. Cannot update target data.") + return False - # Prepare the SQL query to update the field in the database - query = f"UPDATE ngps.targets SET {field_name} = %s WHERE observation_id = %s" - print(query) - - # Execute the query with the provided value and observation_id + cursor = connection.cursor() + query = f"UPDATE targets SET {resolved_field} = %s WHERE OBSERVATION_ID = %s" cursor.execute(query, (value, observation_id)) - - # Commit the transaction to apply the changes connection.commit() + print(f"Successfully updated {resolved_field} to {value} for OBSERVATION_ID {observation_id}") + return True - cursor.close() - print(f"Successfully updated {field_name} to {value} for observation ID {observation_id}") except mysql.connector.Error as err: print(f"Error executing update query: {err}") - + return False + + finally: + if cursor is not None: + cursor.close() + if connection is not None: + connection.close() + + def update_target_state(self, observation_id, state): + """Set a target STATE to PENDING, COMPLETED, or EXPOSING.""" + state = self.normalize_target_state(state) + if state not in ("PENDING", "COMPLETED", "EXPOSING"): + raise ValueError(f"Invalid target STATE: {state}") + + ok = self.send_update_to_db(observation_id, "STATE", state) + if not ok: + return False + + # Update the local cache immediately so the next-target label does not + # wait for a full DB refresh. + for row in getattr(self.parent, "all_targets", []) or []: + if isinstance(row, dict) and str(row.get("OBSERVATION_ID")) == str(observation_id): + row["STATE"] = state + break + + self.refresh_visible_target_states() + return True + + def refresh_visible_target_states(self): + """ + Refresh only OBSERVATION_ID/STATE for rows currently visible in the table. + This preserves the observer's table sorting and selected row while still + showing target progress as the sequencer updates the DB. + """ + ls = getattr(self.parent, "layout_service", None) + table = getattr(ls, "target_list_display", None) + owner = getattr(self.parent, "current_owner", None) + if table is None or table.rowCount() == 0 or not owner: + return + + obs_col = self._column_index(table, "OBSERVATION_ID", "OBS_ID", "OBSERVATIONID") + state_col = self._column_index(table, "STATE") + if obs_col is None or state_col is None: + return + + obs_ids = [] + for row in range(table.rowCount()): + item = table.item(row, obs_col) + if item and item.text().strip(): + obs_ids.append(item.text().strip()) + + if not obs_ids: + return + + connection = None + cursor = None + try: + connection = self.connect_to_mysql("config/db_config.ini") + if connection is None: + return + + placeholders = ", ".join(["%s"] * len(obs_ids)) + sql = f""" + SELECT OBSERVATION_ID, STATE + FROM targets + WHERE OBSERVATION_ID IN ({placeholders}) + """ + cursor = connection.cursor(dictionary=True) + cursor.execute(sql, tuple(obs_ids)) + state_by_obs = { + str(row["OBSERVATION_ID"]): self.normalize_target_state(row.get("STATE")) + for row in cursor.fetchall() + } + + except mysql.connector.Error as err: + print(f"Error refreshing target states: {err}") + return + + finally: + if cursor is not None: + cursor.close() + if connection is not None: + connection.close() + + blocker = QSignalBlocker(table) + try: + for row in range(table.rowCount()): + obs_item = table.item(row, obs_col) + if not obs_item: + continue + obs_id = obs_item.text().strip() + state = state_by_obs.get(obs_id) + if state is None: + continue + state_item = table.item(row, state_col) + if state_item is None: + state_item = QTableWidgetItem(state) + table.setItem(row, state_col, state_item) + else: + state_item.setText(state) + + for cached in getattr(self.parent, "all_targets", []) or []: + if not isinstance(cached, dict): + continue + obs_id = str(cached.get("OBSERVATION_ID")) + if obs_id in state_by_obs: + cached["STATE"] = state_by_obs[obs_id] + finally: + del blocker + + self.refresh_target_table_status_styles() + self.refresh_target_status_summary(getattr(self.parent, "all_targets", []) or []) + def refresh_table(self): """ Refreshes the table by querying the database for the latest data @@ -1194,73 +1344,192 @@ def create_empty_target_set(self, set_name: str): except Exception as e: print("create_empty_target_set failed:", e) - def set_active_target(self, observation_id): - """Remember the active obs_id and update row highlight.""" - prev = getattr(self.parent, "active_observation_id", None) - if prev != observation_id: - setattr(self.parent, "prev_active_observation_id", prev) - setattr(self.parent, "active_observation_id", observation_id) - - # Update UI highlight now - self.clear_previous_active_highlight() - self.apply_active_highlight() - - def _obs_id_column_index(self, table): - """Find the Observation ID column (case-insensitive).""" - cols = table.columnCount() - for i in range(cols): - item = table.horizontalHeaderItem(i) - if not item: - continue - name = item.text().strip().lower() - if name in ("observation_id", "obs_id", "observationid"): - return i + def normalize_target_state(self, state): + """Normalize target STATE values for display and DB writes.""" + text = str(state or "PENDING").strip().upper() + aliases = { + "": "PENDING", + "PEND": "PENDING", + "PENDING": "PENDING", + "DONE": "COMPLETED", + "COMPLETE": "COMPLETED", + "COMPLETED": "COMPLETED", + "ACTIVE": "EXPOSING", + "RUNNING": "EXPOSING", + "EXPOSE": "EXPOSING", + "EXPOSING": "EXPOSING", + } + return aliases.get(text, text) + + def _column_index(self, table, *names): + """Find a table column by header name, case-insensitive.""" + wanted = {str(name).strip().upper() for name in names} + for col in range(table.columnCount()): + item = table.horizontalHeaderItem(col) + if item and item.text().strip().upper() in wanted: + return col return None - def _find_row_by_obs_id(self, table, obs_id): - """Return the row index with matching observation_id (string compare).""" - col = self._obs_id_column_index(table) + def _table_value(self, table, row, *headers): + col = self._column_index(table, *headers) if col is None: - return None - target = str(obs_id) - for r in range(table.rowCount()): - cell = table.item(r, col) - if cell and cell.text().strip() == target: - return r - return None + return "" + item = table.item(row, col) + return item.text().strip() if item else "" - def clear_previous_active_highlight(self): - """Remove yellow highlight from the previously active row, if any.""" - table = getattr(self.parent.layout_service, "target_list_display", None) - if table is None: - return - prev_id = getattr(self.parent, "prev_active_observation_id", None) - if prev_id is None: + def _target_sort_key(self, row): + """ + Sequencer/default target order. Calibration lists often have order + fields set to zero, so OBSERVATION_ID becomes the stable insertion order. + """ + def as_int(value, default=0): + try: + if value is None or value == "": + return default + return int(float(value)) + except Exception: + return default + + return ( + as_int(row.get("SET_ID")), + as_int(row.get("OBS_ORDER")), + as_int(row.get("TARGET_NUMBER")), + as_int(row.get("SEQUENCE_NUMBER")), + as_int(row.get("OBSERVATION_ID")), + ) + + def current_or_next_target(self, rows=None): + """ + Return (mode, row) where mode is EXPOSING or NEXT. + EXPOSING wins; otherwise the next PENDING target in sequencer order wins. + """ + rows = rows if rows is not None else (getattr(self.parent, "all_targets", []) or []) + rows = [r for r in rows if isinstance(r, dict)] + if not rows: + return None, None + + ordered_rows = sorted(rows, key=self._target_sort_key) + + for row in ordered_rows: + if self.normalize_target_state(row.get("STATE")) == "EXPOSING": + return "EXPOSING", row + + for row in ordered_rows: + if self.normalize_target_state(row.get("STATE")) == "PENDING": + return "NEXT", row + + return None, None + + def refresh_target_status_summary(self, rows=None): + """Update the target-state summary and next-target label.""" + rows = rows if rows is not None else (getattr(self.parent, "all_targets", []) or []) + rows = [r for r in rows if isinstance(r, dict)] + + counts = {"PENDING": 0, "COMPLETED": 0, "EXPOSING": 0} + for row in rows: + state = self.normalize_target_state(row.get("STATE")) + if state in counts: + counts[state] += 1 + + mode, target = self.current_or_next_target(rows) + ls = getattr(self.parent, "layout_service", None) + + summary_label = getattr(ls, "target_state_summary_label", None) + if summary_label is not None: + summary_label.setText( + f"PENDING: {counts['PENDING']} COMPLETED: {counts['COMPLETED']} EXPOSING: {counts['EXPOSING']}" + ) + + next_label = getattr(ls, "next_target_label", None) + if next_label is not None: + if target is None: + next_label.setText("Single: no PENDING targets remain.") + next_label.setToolTip("All visible targets are completed, or no target list is loaded.") + setattr(self.parent, "current_sequence_observation_id", None) + else: + obs_id = target.get("OBSERVATION_ID", "") + name = target.get("NAME", "Unnamed") + state = self.normalize_target_state(target.get("STATE")) + if mode == "EXPOSING": + text = f"Current: {name} (OBS {obs_id}, EXPOSING)" + else: + text = f"Single will run next: {name} (OBS {obs_id}, {state})" + next_label.setText(text) + next_label.setToolTip( + "This follows database sequence order and STATE, not the selected row or the current table sort." + ) + setattr(self.parent, "current_sequence_observation_id", obs_id) + + def refresh_target_table_status_styles(self): + """Apply row styling based on STATE and the computed next target.""" + ls = getattr(self.parent, "layout_service", None) + table = getattr(ls, "target_list_display", None) + if table is None or table.rowCount() == 0: return - row = self._find_row_by_obs_id(table, prev_id) - if row is None: + + obs_col = self._column_index(table, "OBSERVATION_ID", "OBS_ID", "OBSERVATIONID") + state_col = self._column_index(table, "STATE") + if obs_col is None or state_col is None: return - for c in range(table.columnCount()): - item = table.item(row, c) - if item: - # reset to default background - item.setBackground(Qt.white) - item.setForeground(Qt.black) - def apply_active_highlight(self): - """Paint the active row yellow (soft) if visible.""" - table = getattr(self.parent.layout_service, "target_list_display", None) + mode, target = self.current_or_next_target() + next_obs_id = str(target.get("OBSERVATION_ID")) if target else None + + colors = { + "COMPLETED": (QColor(75, 75, 75), QColor(180, 180, 180), False), + "EXPOSING": (QColor(46, 125, 50), QColor(255, 255, 255), True), + "NEXT": (QColor(255, 204, 64), QColor(0, 0, 0), True), + } + + for row in range(table.rowCount()): + obs_id = self._table_value(table, row, "OBSERVATION_ID", "OBS_ID", "OBSERVATIONID") + state = self.normalize_target_state(self._table_value(table, row, "STATE")) + style_key = state + if state == "PENDING" and next_obs_id is not None and obs_id == next_obs_id: + style_key = "NEXT" + + color_tuple = colors.get(style_key) + for col in range(table.columnCount()): + item = table.item(row, col) + if item is None: + continue + + font = item.font() + if color_tuple is None: + item.setBackground(QBrush()) + item.setForeground(QBrush()) + font.setBold(False) + else: + bg, fg, bold = color_tuple + item.setBackground(bg) + item.setForeground(fg) + font.setBold(bold) + item.setFont(font) + + def selected_observation_id(self): + """Return OBSERVATION_ID for the selected table row, if any.""" + ls = getattr(self.parent, "layout_service", None) + table = getattr(ls, "target_list_display", None) if table is None: - return - active_id = getattr(self.parent, "active_observation_id", None) - if active_id is None: - return - row = self._find_row_by_obs_id(table, active_id) - if row is None: - return - yellow = QColor(255, 204, 64) - for c in range(table.columnCount()): - item = table.item(row, c) - if item: - item.setBackground(yellow) - item.setForeground(Qt.black) \ No newline at end of file + return None + + selected_rows = table.selectionModel().selectedRows() + if not selected_rows: + return None + + row = selected_rows[0].row() + return self._table_value(table, row, "OBSERVATION_ID", "OBS_ID", "OBSERVATIONID") or None + + def set_active_target(self, observation_id): + """Remember an active obs_id and refresh row styling.""" + setattr(self.parent, "active_observation_id", observation_id) + self.refresh_target_table_status_styles() + self.refresh_target_status_summary(getattr(self.parent, "all_targets", []) or []) + + def clear_previous_active_highlight(self): + """Compatibility wrapper for older callers.""" + self.refresh_target_table_status_styles() + + def apply_active_highlight(self): + """Compatibility wrapper for older callers.""" + self.refresh_target_table_status_styles() diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index cdef7c2d..9f0f2dbf 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -160,16 +160,19 @@ def initialize_services(self): self.sequencer_service = SequencerService(self) self.sequencer_service.connect() - # Start the StatusService in a separate thread with heartbeat + # Start the StatusService in a separate thread with heartbeat. + # Do not send StatusService chatter to the visible message log; that + # widget is reserved for subscribed topic-broadcast messages only. self.status_service = StatusService(self) - self.status_service.status_updated_signal.connect(self.layout_service.update_message_log) self.status_service.start() self.status_service.progress_updated_signal.connect(self.layout_service.update_exposure_progress) self.status_service.readout_progress_updated_signal.connect(self.layout_service.update_readout_progress) self.status_service.image_number_updated_signal.connect(self.layout_service.update_image_number) self.status_service.image_name_updated_signal.connect(self.layout_service.update_image_name) - self.status_service.update_status_signal.connect(self.layout_service.update_system_status) + # Instrument System Status is driven only by ZMQ seq_seqstate. + # Do not let the legacy StatusService or seq_waitstate override it. + # self.status_service.update_status_signal.connect(self.layout_service.update_system_status) self.status_service.user_can_expose_signal.connect(self.layout_service.control_tab.enable_continue_and_offset_button) self.status_service.shutter_status_signal.connect(self.layout_service.update_shutter_status) @@ -191,8 +194,10 @@ def initialize_services(self): self.zmq_status_service.subscribe_to_topic("seq_seqstate") self.zmq_status_service.subscribe_to_topic("seq_daemonstate") - # Connect the message_received signal from ZMQStatusService to the update_message_log slot - self.zmq_status_service.new_message_signal.connect(self.layout_service.update_message_log) + # Only subscribed ZMQ topic broadcasts should appear in the visible message log. + self.zmq_status_service.topic_broadcast_signal.connect( + self.layout_service.update_topic_broadcast_log + ) self.zmq_status_service.lamp_states_signal.connect(self.layout_service.update_lamps) self.zmq_status_service.modulator_states_signal.connect(self.layout_service.update_modulators) self.zmq_status_service.airmass_signal.connect(self.layout_service.update_airmass) @@ -411,15 +416,16 @@ def run_calibration(self): if set_id is None: return - try: - csv_path = save_calibration_csv(rows, target_list_name) - except Exception as e: - csv_path = None - QMessageBox.warning( - self, - "CSV Save Warning", - f"The {list_kind_label} list was inserted into MySQL, but the CSV file could not be saved:\n{e}" - ) + # Saving CSV for CALS disabled for now + # try: + # csv_path = save_calibration_csv(rows, target_list_name) + # except Exception as e: + # csv_path = None + # QMessageBox.warning( + # self, + # "CSV Save Warning", + # f"The {list_kind_label} list was inserted into MySQL, but the CSV file could not be saved:\n{e}" + # ) self.current_target_list_name = target_list_name @@ -686,7 +692,7 @@ def set_zmq_debug_messages(self, enabled: bool): self.zmq_status_service.set_debug_messages(enabled) state = "enabled" if enabled else "disabled" - self.layout_service.update_message_log(f"ZMQ debug messages {state}.") + print(f"ZMQ debug messages {state}.") if __name__ == '__main__': diff --git a/pygui/zmq_status_service.py b/pygui/zmq_status_service.py index 1a3b5165..df780d42 100644 --- a/pygui/zmq_status_service.py +++ b/pygui/zmq_status_service.py @@ -3,12 +3,16 @@ import logging import json from PyQt5.QtCore import pyqtSignal, QObject, QThread -from typing import Dict, Any, Optional +from typing import Dict, Any class ZmqStatusService(QObject): - # Signal to send a new message + # Legacy/debug signal. Keep this separate from the operator-facing log. new_message_signal = pyqtSignal(str) + # Operator-facing message-log signal. This is emitted for every valid + # subscribed topic broadcast received from the ZMQ broker. + topic_broadcast_signal = pyqtSignal(str) + # Signal to send lamp states as a dictionary {lamp_name: bool} lamp_states_signal = pyqtSignal(dict) @@ -28,7 +32,7 @@ class ZmqStatusService(QObject): # seqgui-style daemon status signals daemonstate_signal = pyqtSignal(dict) # seq_daemonstate: daemon-key -> bool - waitstate_signal = pyqtSignal(dict) # seq_waitstate: wait-key -> bool + waitstate_signal = pyqtSignal(dict) # seq_waitstate: wait-key -> bool; daemon/status bar only sequencerd_alive_signal = pyqtSignal() # emitted when seq_seqstate arrives def __init__( @@ -37,24 +41,32 @@ def __init__( broker_publish_endpoint="tcp://127.0.0.1:5556", broker_snapshot_endpoint="tcp://127.0.0.1:5555", emit_debug_messages=False, + emit_topic_broadcasts=True, ): super().__init__() self.parent = parent # Reference to the parent window or main UI self.broker_publish_endpoint = broker_publish_endpoint self.broker_snapshot_endpoint = broker_snapshot_endpoint - # Debug/raw-message UI output flag - # False = do not flood the GUI message box - # True = emit raw topic/payload messages through new_message_signal + # Debug/raw-message output flag for legacy/debug consumers. + # This is intentionally not the operator-facing broadcast log. self.emit_debug_messages = emit_debug_messages + # Operator-facing broadcast log flag. + # True = emit received subscribed topic/payload messages through + # topic_broadcast_signal. This is the only signal the GUI message log + # should connect to. + self.emit_topic_broadcasts = bool(emit_topic_broadcasts) + self.context = zmq.Context() self.socket = None self.pub_socket = None self.is_connected = False self.subscribed_topics = set() # Set of subscribed topics + # Instrument Status is intentionally driven only by seq_seqstate. + # seq_waitstate feeds the daemon/status chip row and operator controls, + # but it must not override the Instrument Status panel. self._last_seq_lifecycle_status = "stopped" - self._last_seq_wait_status = None self._user_continue_ready_emitted = False # Set up logging @@ -63,17 +75,35 @@ def __init__( def set_debug_messages(self, enabled: bool): """ - Enable or disable raw ZMQ messages in the GUI message box. + Enable or disable legacy raw/debug ZMQ messages. + + This does not control the operator-facing topic broadcast log. """ self.emit_debug_messages = bool(enabled) + def set_topic_broadcast_messages(self, enabled: bool): + """ + Enable or disable the operator-facing topic broadcast log signal. + """ + self.emit_topic_broadcasts = bool(enabled) + def _emit_debug_message(self, message: str): """ - Emit raw/debug messages only when debug UI output is enabled. + Emit raw/debug messages only when debug output is enabled. """ if self.emit_debug_messages: self.new_message_signal.emit(message) + def _emit_topic_broadcast_message(self, topic: str, payload: str): + """ + Emit the exact subscribed topic broadcast to the GUI message log. + + This is intentionally independent from JSON parsing and debug output, + so malformed/non-JSON broadcasts can still be shown to the observer. + """ + if self.emit_topic_broadcasts: + self.topic_broadcast_signal.emit(f"Topic: {topic}, Payload: {payload}") + @staticmethod def _contains_user_continue_message(value) -> bool: """ @@ -244,6 +274,11 @@ def listen(self): # Always log to file, even when GUI debug messages are disabled. self.logger.info(f"Received message: Topic = {topic}, Payload = {payload}") + # The GUI message log should show subscribed ZMQ topic broadcasts, + # not local GUI/status chatter. Emit this before JSON parsing so + # plain-text broadcasts and malformed JSON still appear. + self._emit_topic_broadcast_message(topic, payload) + # Some sequencerd messages are plain text status messages rather # than JSON objects. Detect the USER continue message before # json.loads so raw text payloads are handled too. @@ -263,26 +298,27 @@ def listen(self): elif topic == "seq_seqstate": # Any seq_seqstate message proves sequencerd is alive. + # It is also the only ZMQ source allowed to drive the + # Instrument System Status panel. Do not mix in + # seq_waitstate here; wait-state belongs to the daemon + # chip/status bar only. self.sequencerd_alive_signal.emit() self._last_seq_lifecycle_status = self._status_from_seq_seqstate(data) - self._emit_resolved_system_status() + self.system_status_signal.emit(self._last_seq_lifecycle_status) elif topic == "seq_waitstate": - # seqgui-style busy/blink source. + # seqgui-style busy/blink source for the daemon/status + # chip bar only. This must not update Instrument Status. wait_flags = self._bool_payload(data) self.waitstate_signal.emit(wait_flags) - # USER wait-state is the structured equivalent of the - # sequencerd text message: - # waiting for USER to send "continue" signal + # Keep USER wait-state for enabling Continue/Expose + # controls, but do not display USER in Instrument Status. if wait_flags.get("USER", False): self._emit_user_can_expose_once() else: self._clear_user_can_expose_latch() - self._last_seq_wait_status = self._status_from_seq_waitstate(data) - self._emit_resolved_system_status() - elif topic == "seq_daemonstate": # seqgui-style ready/not-ready source. self.daemonstate_signal.emit(self._bool_payload(data)) @@ -396,15 +432,6 @@ def update_tcs_info(self, data): else: self.logger.warning("AIRMASS data is not available.") - def _emit_resolved_system_status(self): - """ - If a wait-state is active, show that. - Otherwise show the broader sequencer lifecycle state. - """ - status = self._last_seq_wait_status or self._last_seq_lifecycle_status - if status is not None: - self.system_status_signal.emit(status) - def _status_from_seq_seqstate(self, data: Dict[str, Any]) -> str: """ Parse the overall sequencer lifecycle state. @@ -420,57 +447,12 @@ def _status_from_seq_seqstate(self, data: Dict[str, Any]) -> str: "IDLE": "idle", "PAUSED": "paused", "STOPPED": "stopped", - "ERROR": "stopped", + "ERROR": "error", } return state_map.get(seqstate, seqstate.lower() if seqstate else "stopped") - def _status_from_seq_waitstate(self, flags: Dict[str, Any]) -> Optional[str]: - """ - Return the active wait-state if one is true, else None. - Returning None falls back to seq_seqstate. - """ - if not isinstance(flags, dict): - return None - - # Ignore metadata fields like "source" - f = { - str(k).upper(): bool(v) - for k, v in flags.items() - if str(k).lower() != "source" - } - # Precedence matters if more than one field is true. - # Put the most user-meaningful states first. - wait_order = [ - ("READOUT", "readout"), - ("EXPOSE", "exposing"), - - # New replacement states for old ACQUIRE - ("MOVETO", "moveto"), - ("ACAM_ACQUIRE", "acam_acquire"), - ("SLICECAM_FINEACQUIRE", "slicecam_fineacquire"), - - ("FOCUS", "focus"), - ("CALIB", "calib"), - ("CAMERA", "camera"), - ("FLEXURE", "flexure"), - ("POWER", "power"), - ("SLIT", "slit"), - ("TCSOP", "tcsop"), - ("TCS", "tcs"), - ("USER", "user"), - - ("ACAM", "acam"), - ("SLICECAM", "slicecam"), - ("ACQUIRE", "acquire"), - ] - - for key, ui_status in wait_order: - if f.get(key, False): - return ui_status - - return None class ZmqStatusServiceThread(QThread): def __init__(self, zmq_status_service): From 1158b7876bd283de73d4e7690e3dd3d3ef6a11e6 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Wed, 10 Jun 2026 14:57:50 -0700 Subject: [PATCH 14/20] update colors --- pygui/logic_service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pygui/logic_service.py b/pygui/logic_service.py index 2a4aaa75..6c4be6d5 100644 --- a/pygui/logic_service.py +++ b/pygui/logic_service.py @@ -1476,6 +1476,9 @@ def refresh_target_table_status_styles(self): next_obs_id = str(target.get("OBSERVATION_ID")) if target else None colors = { + # Give plain PENDING rows a subtle background so they do not blend + # into the dark table background. + "PENDING": (QColor(78, 88, 104), QColor(255, 255, 255), False), "COMPLETED": (QColor(75, 75, 75), QColor(180, 180, 180), False), "EXPOSING": (QColor(46, 125, 50), QColor(255, 255, 255), True), "NEXT": (QColor(255, 204, 64), QColor(0, 0, 0), True), From d0d276dcf7058ba2b14b9d6bf50743bdc1525726 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Wed, 10 Jun 2026 15:04:10 -0700 Subject: [PATCH 15/20] ypdate csv path --- pygui/ngps_gui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 9f0f2dbf..3f6320a7 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -427,6 +427,7 @@ def run_calibration(self): # f"The {list_kind_label} list was inserted into MySQL, but the CSV file could not be saved:\n{e}" # ) + csv_path = None self.current_target_list_name = target_list_name # Make calibration mode active From 09e7bb65abe69e23a99ef08e8453dfb0b7a23863 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Wed, 10 Jun 2026 15:17:01 -0700 Subject: [PATCH 16/20] remove all topic logs --- pygui/layout_service.py | 2 +- pygui/ngps_gui.py | 5 +++++ pygui/zmq_status_service.py | 39 +++++++++++++++++++++++++------------ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/pygui/layout_service.py b/pygui/layout_service.py index c5baa913..6611b104 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -375,7 +375,7 @@ def create_sequencer_mode_group(self): self.next_target_label = QLabel("Single: no target list loaded yet.") self.next_target_label.setWordWrap(True) - self.next_target_label.setStyleSheet("font-size: 10pt; color: #222; padding: 3px;") + self.next_target_label.setStyleSheet("font-size: 10pt; color: #fff; padding: 3px;") sequencer_mode_layout.addWidget(self.next_target_label) # Fine acquire toggle diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 3f6320a7..0111bb14 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -190,6 +190,11 @@ def initialize_services(self): self.zmq_status_service.subscribe_to_topic("calibd") self.zmq_status_service.subscribe_to_topic("tcsd") self.zmq_status_service.subscribe_to_topic("acamd") + + # Only this topic is allowed to feed the visible message log. + # The other subscriptions below still drive dedicated widgets/status. + self.zmq_status_service.subscribe_to_topic("broadcast") + self.zmq_status_service.subscribe_to_topic("seq_waitstate") self.zmq_status_service.subscribe_to_topic("seq_seqstate") self.zmq_status_service.subscribe_to_topic("seq_daemonstate") diff --git a/pygui/zmq_status_service.py b/pygui/zmq_status_service.py index df780d42..3a88afef 100644 --- a/pygui/zmq_status_service.py +++ b/pygui/zmq_status_service.py @@ -9,8 +9,9 @@ class ZmqStatusService(QObject): # Legacy/debug signal. Keep this separate from the operator-facing log. new_message_signal = pyqtSignal(str) - # Operator-facing message-log signal. This is emitted for every valid - # subscribed topic broadcast received from the ZMQ broker. + # Operator-facing message-log signal. This is emitted only for messages + # whose ZMQ topic is exactly ``broadcast``. Other subscribed topics still + # update their dedicated GUI widgets, but they do not go to the message log. topic_broadcast_signal = pyqtSignal(str) # Signal to send lamp states as a dictionary {lamp_name: bool} @@ -42,6 +43,7 @@ def __init__( broker_snapshot_endpoint="tcp://127.0.0.1:5555", emit_debug_messages=False, emit_topic_broadcasts=True, + message_log_topic="broadcast", ): super().__init__() self.parent = parent # Reference to the parent window or main UI @@ -53,10 +55,11 @@ def __init__( self.emit_debug_messages = emit_debug_messages # Operator-facing broadcast log flag. - # True = emit received subscribed topic/payload messages through + # True = emit only the configured message-log topic through # topic_broadcast_signal. This is the only signal the GUI message log # should connect to. self.emit_topic_broadcasts = bool(emit_topic_broadcasts) + self.message_log_topic = str(message_log_topic).strip() or "broadcast" self.context = zmq.Context() self.socket = None @@ -83,7 +86,10 @@ def set_debug_messages(self, enabled: bool): def set_topic_broadcast_messages(self, enabled: bool): """ - Enable or disable the operator-facing topic broadcast log signal. + Enable or disable the operator-facing message-log topic signal. + + This only controls whether the configured message-log topic is shown. + It does not cause other subscribed telemetry topics to appear there. """ self.emit_topic_broadcasts = bool(enabled) @@ -96,13 +102,22 @@ def _emit_debug_message(self, message: str): def _emit_topic_broadcast_message(self, topic: str, payload: str): """ - Emit the exact subscribed topic broadcast to the GUI message log. + Emit only the configured operator-facing message-log topic. + + Other subscribed topics such as seq_seqstate, seq_waitstate, powerd, + calibd, tcsd, etc. are still processed below for dedicated GUI state, + but they must not be copied into the visible message log. - This is intentionally independent from JSON parsing and debug output, - so malformed/non-JSON broadcasts can still be shown to the observer. + This runs before JSON parsing so plain-text broadcast messages still + appear to the observer. """ - if self.emit_topic_broadcasts: - self.topic_broadcast_signal.emit(f"Topic: {topic}, Payload: {payload}") + if not self.emit_topic_broadcasts: + return + + if str(topic).strip() != self.message_log_topic: + return + + self.topic_broadcast_signal.emit(payload) @staticmethod def _contains_user_continue_message(value) -> bool: @@ -274,9 +289,9 @@ def listen(self): # Always log to file, even when GUI debug messages are disabled. self.logger.info(f"Received message: Topic = {topic}, Payload = {payload}") - # The GUI message log should show subscribed ZMQ topic broadcasts, - # not local GUI/status chatter. Emit this before JSON parsing so - # plain-text broadcasts and malformed JSON still appear. + # The GUI message log should show only the dedicated ``broadcast`` + # topic. All other topics are still processed below for their + # dedicated widgets/signals, but they do not appear in the log. self._emit_topic_broadcast_message(topic, payload) # Some sequencerd messages are plain text status messages rather From 24af0ee957886d10e99057b3dafe78290278f8b5 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Wed, 10 Jun 2026 15:32:56 -0700 Subject: [PATCH 17/20] more fixes --- pygui/calibration_procedure.py | 2 +- pygui/layout_service.py | 70 ++++++++++--------- pygui/ngps_gui.py | 2 +- pygui/zmq_status_service.py | 122 ++++++++++++++++++++++++++++++--- 4 files changed, 151 insertions(+), 45 deletions(-) diff --git a/pygui/calibration_procedure.py b/pygui/calibration_procedure.py index bb0579b2..e9ca540b 100644 --- a/pygui/calibration_procedure.py +++ b/pygui/calibration_procedure.py @@ -106,7 +106,7 @@ def make_calibration_targets(slitwidth, xbin, ybin): n_etalon = 0 n_dome = 0 n_dome_ug = 0 - n_bias = 5 + n_bias = 7 n_dark = 0 arc_multiplier = 1.0 / (xbin * ybin) diff --git a/pygui/layout_service.py b/pygui/layout_service.py index 6611b104..8410ea7c 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -1,10 +1,11 @@ from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, QStyle, QFrame, QDialog, QListView, QFileDialog, QDialogButtonBox, QMessageBox, QInputDialog, QHBoxLayout, QGridLayout, QTableWidget, QHeaderView, QFormLayout, QListWidget, QListWidgetItem, QScrollArea, QVBoxLayout, QGroupBox, QGroupBox, QHeaderView, QLabel, QRadioButton, QProgressBar, QLineEdit, QTextEdit, QTableWidget, QComboBox, QDateTimeEdit, QTabWidget, QWidget, QPushButton, QCheckBox,QSpacerItem, QSizePolicy from PyQt5.QtCore import QDateTime, QTimer -from PyQt5.QtGui import QColor, QFont, QDoubleValidator +from PyQt5.QtGui import QColor, QFont, QDoubleValidator, QTextCursor from logic_service import LogicService from PyQt5.QtCore import Qt, QSignalBlocker from control_tab import ControlTab from instrument_status_tab import InstrumentStatusTab +import html import re import subprocess @@ -375,7 +376,7 @@ def create_sequencer_mode_group(self): self.next_target_label = QLabel("Single: no target list loaded yet.") self.next_target_label.setWordWrap(True) - self.next_target_label.setStyleSheet("font-size: 10pt; color: #fff; padding: 3px;") + self.next_target_label.setStyleSheet("font-size: 10pt; color: #222; padding: 3px;") sequencer_mode_layout.addWidget(self.next_target_label) # Fine acquire toggle @@ -489,7 +490,7 @@ def create_progress_and_image_group(self): # Add all components to the main layout progress_and_image_layout.addLayout(progress_layout) progress_and_image_layout.addLayout(image_info_layout) - progress_and_image_layout.addWidget(QLabel("Messages:")) + progress_and_image_layout.addWidget(QLabel("Topic Broadcast:")) progress_and_image_layout.addWidget(message_log) progress_and_image_group.setLayout(progress_and_image_layout) @@ -621,6 +622,7 @@ def create_message_log(self): # Set size policies to allow the widget to stretch and grow proportionally self.parent.message_log.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.parent.message_log.setReadOnly(True) + self.parent.message_log.document().setMaximumBlockCount(100) # Optionally set a minimum height or width if desired self.parent.message_log.setMinimumHeight(60) @@ -638,44 +640,44 @@ def clear_message_log(self): self.parent.message_log.clear() - def update_topic_broadcast_log(self, message): - """Append a message received from a subscribed ZMQ topic broadcast. + def update_topic_broadcast_log(self, message, severity="NOTICE"): + """Append a parsed operator broadcast message to the visible log. - The visible message log should only show topic-broadcast traffic. - Local GUI status, command, and database messages should use print(), - dialogs, or dedicated widgets instead of this log. + The ZMQ service should pass only the payload's ``message`` field here. + The ``severity`` field controls display color only; it is not printed + in the log line. """ - self.update_message_log(message) + self.update_message_log(message, severity) - def update_message_log(self, message): - MAX_LOG_SIZE = 1000 # Max number of characters in the log - MAX_MESSAGES = 100 # Max number of messages in the log - """ Update the message log with the new message, maintaining a limit on the size. """ - if self.parent.message_log: - current_text = self.parent.message_log.toPlainText() + def update_message_log(self, message, severity="NOTICE"): + """Append one colored broadcast message to the log.""" + if not self.parent.message_log: + return - # Add the new message - updated_text = current_text + "\n" + message - - # Limit the log to the most recent MAX_LOG_SIZE characters - if len(updated_text) > MAX_LOG_SIZE: - updated_text = updated_text[-MAX_LOG_SIZE:] - - # Optionally, limit to the most recent MAX_MESSAGES messages - messages = updated_text.split("\n") - if len(messages) > MAX_MESSAGES: - messages = messages[-MAX_MESSAGES:] - - updated_text = "\n".join(messages) + severity = str(severity).strip().upper() or "NOTICE" + message = str(message).strip() + + if not message: + return + + color_by_severity = { + "NOTICE": "#e0e0e0", + "WARNING": "#ffcc40", + "ERROR": "#ff5c5c", + } + font_weight = "bold" if severity in ("WARNING", "ERROR") else "normal" + color = color_by_severity.get(severity, color_by_severity["NOTICE"]) + safe_message = html.escape(message) - # Update the message log with the new, trimmed text - self.parent.message_log.setPlainText(updated_text) + self.parent.message_log.append( + f'' + f'{safe_message}' + ) - # Optionally, scroll to the bottom of the text log - cursor = self.parent.message_log.textCursor() - cursor.movePosition(cursor.End) - self.parent.message_log.setTextCursor(cursor) + cursor = self.parent.message_log.textCursor() + cursor.movePosition(QTextCursor.End) + self.parent.message_log.setTextCursor(cursor) def _connect_button_once(self, button, slot): """ diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index 0111bb14..0648c4e6 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -208,6 +208,7 @@ def initialize_services(self): self.zmq_status_service.airmass_signal.connect(self.layout_service.update_airmass) self.zmq_status_service.slit_info_signal.connect(self.layout_service.update_slit_info_fields) self.zmq_status_service.system_status_signal.connect(self.layout_service.update_system_status) + self.zmq_status_service.shutter_status_signal.connect(self.layout_service.update_shutter_status) self.zmq_status_service.user_can_expose_signal.connect(self.layout_service.control_tab.enable_continue_and_offset_button) def on_date_time_changed(self, datetime): @@ -431,7 +432,6 @@ def run_calibration(self): # "CSV Save Warning", # f"The {list_kind_label} list was inserted into MySQL, but the CSV file could not be saved:\n{e}" # ) - csv_path = None self.current_target_list_name = target_list_name diff --git a/pygui/zmq_status_service.py b/pygui/zmq_status_service.py index 3a88afef..c6802051 100644 --- a/pygui/zmq_status_service.py +++ b/pygui/zmq_status_service.py @@ -2,17 +2,20 @@ import os import logging import json +from json import JSONDecoder from PyQt5.QtCore import pyqtSignal, QObject, QThread -from typing import Dict, Any +from typing import Dict, Any, Iterable, Tuple class ZmqStatusService(QObject): # Legacy/debug signal. Keep this separate from the operator-facing log. new_message_signal = pyqtSignal(str) # Operator-facing message-log signal. This is emitted only for messages - # whose ZMQ topic is exactly ``broadcast``. Other subscribed topics still - # update their dedicated GUI widgets, but they do not go to the message log. - topic_broadcast_signal = pyqtSignal(str) + # whose ZMQ topic is exactly ``broadcast``. It carries only the parsed + # message text plus severity for display styling. Other subscribed topics + # still update their dedicated GUI widgets, but they do not go to the + # message log. + topic_broadcast_signal = pyqtSignal(str, str) # Signal to send lamp states as a dictionary {lamp_name: bool} lamp_states_signal = pyqtSignal(dict) @@ -26,6 +29,10 @@ class ZmqStatusService(QObject): system_status_signal = pyqtSignal(str) + # Broadcast-derived shutter status. This mirrors StatusService.shutter_status_signal + # so the GUI can update the existing shutter widget from camerad broadcasts. + shutter_status_signal = pyqtSignal(bool) + # Emitted when sequencerd says it is waiting for the USER continue signal. # This replaces the old UDP StatusService detection path for: # waiting for USER to send "continue" signal @@ -100,16 +107,111 @@ def _emit_debug_message(self, message: str): if self.emit_debug_messages: self.new_message_signal.emit(message) + def _iter_broadcast_records(self, payload: str) -> Iterable[Tuple[str, str, str]]: + """ + Yield ``(message, severity, source)`` records from a broadcast payload. + + Expected payloads look like: + {"message": "camera_set ready", "severity": "NOTICE", "source": "sequencerd"} + + Only ``message`` is displayed in the GUI log. ``severity`` is carried + separately so the layout can color the message. ``source`` is kept for + side effects such as camerad shutter updates. If the payload is plain + text or malformed JSON, show the raw payload as a NOTICE instead of + dropping it. + """ + text = str(payload).strip() + + if not text: + return + + def record_from_obj(obj): + if isinstance(obj, dict): + message = obj.get("message", text) + severity = obj.get("severity", "NOTICE") + source = obj.get("source", "") + else: + message = obj + severity = "NOTICE" + source = "" + + message = str(message).strip() + severity = str(severity).strip().upper() or "NOTICE" + source = str(source).strip() + + if message: + yield message, severity, source + + try: + data = json.loads(text) + if isinstance(data, list): + for item in data: + yield from record_from_obj(item) + else: + yield from record_from_obj(data) + return + except json.JSONDecodeError: + pass + + # Be tolerant of payloads that contain several JSON objects separated + # by whitespace. This can happen when log output is pasted or batched. + decoder = JSONDecoder() + idx = 0 + emitted_any = False + while idx < len(text): + while idx < len(text) and text[idx].isspace(): + idx += 1 + + if idx >= len(text): + break + + try: + obj, end = decoder.raw_decode(text, idx) + except json.JSONDecodeError: + break + + emitted_any = True + yield from record_from_obj(obj) + idx = end + + if not emitted_any: + yield text, "NOTICE", "" + + @staticmethod + def _shutter_status_from_broadcast_message(message: str, source: str = ""): + """Return True/False for shutter open/closed broadcasts, else None.""" + source_text = str(source).strip().lower() + message_text = str(message).strip().lower() + + # The shutter messages are expected from camerad, for example: + # {"message":"shutter opened at ...", "source":"camerad"} + # Keep the source check permissive for older broadcasts that may not + # include source, but ignore messages that explicitly come from a + # different daemon. + if source_text and source_text != "camerad": + return None + + if message_text.startswith("shutter opened") or message_text.startswith("shutter open"): + return True + + if message_text.startswith("shutter closed") or message_text.startswith("shutter close"): + return False + + return None + + def _handle_broadcast_side_effects(self, message: str, source: str = ""): + """Update dedicated GUI state from structured broadcast messages.""" + shutter_is_open = self._shutter_status_from_broadcast_message(message, source) + if shutter_is_open is not None: + self.shutter_status_signal.emit(shutter_is_open) + def _emit_topic_broadcast_message(self, topic: str, payload: str): """ - Emit only the configured operator-facing message-log topic. + Emit only parsed messages from the configured message-log topic. Other subscribed topics such as seq_seqstate, seq_waitstate, powerd, calibd, tcsd, etc. are still processed below for dedicated GUI state, but they must not be copied into the visible message log. - - This runs before JSON parsing so plain-text broadcast messages still - appear to the observer. """ if not self.emit_topic_broadcasts: return @@ -117,7 +219,9 @@ def _emit_topic_broadcast_message(self, topic: str, payload: str): if str(topic).strip() != self.message_log_topic: return - self.topic_broadcast_signal.emit(payload) + for message, severity, source in self._iter_broadcast_records(payload): + self.topic_broadcast_signal.emit(message, severity) + self._handle_broadcast_side_effects(message, source) @staticmethod def _contains_user_continue_message(value) -> bool: From 7b7d4bb8d7f5b04d79dc0a484fa7d6e70fc9bc2d Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Wed, 10 Jun 2026 15:33:58 -0700 Subject: [PATCH 18/20] more fixes --- pygui/layout_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygui/layout_service.py b/pygui/layout_service.py index 8410ea7c..1fc52592 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -376,7 +376,7 @@ def create_sequencer_mode_group(self): self.next_target_label = QLabel("Single: no target list loaded yet.") self.next_target_label.setWordWrap(True) - self.next_target_label.setStyleSheet("font-size: 10pt; color: #222; padding: 3px;") + self.next_target_label.setStyleSheet("font-size: 10pt; color: #fff; padding: 3px;") sequencer_mode_layout.addWidget(self.next_target_label) # Fine acquire toggle From 5f6b6d4873e407ffe5d23b4436d7ce8d64d5830d Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Wed, 10 Jun 2026 15:47:28 -0700 Subject: [PATCH 19/20] update status --- pygui/layout_service.py | 59 ++++++++++++++++++++++++++++++------- pygui/zmq_status_service.py | 14 +++++++-- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/pygui/layout_service.py b/pygui/layout_service.py index 1fc52592..9f6a697a 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -197,11 +197,19 @@ def create_system_status_group(self): # states from seq_seqstate only. Busy/wait states from seq_waitstate # belong to the bottom daemon/status bar, not this panel. status_map = { - "stopped": QColor(169, 169, 169), + # These are lifecycle states from seq_seqstate only. + # seq_waitstate belongs to the daemon/status bar, not this panel. "not_ready": QColor(255, 0, 0), - "idle": QColor(255, 255, 0), + "ready": QColor(0, 180, 0), + "running": QColor(0, 255, 0), + "stopping": QColor(255, 165, 0), "paused": QColor(255, 165, 0), - "error": QColor(255, 0, 0), + "starting": QColor(255, 255, 0), + "failed": QColor(255, 0, 0), + "aborting": QColor(255, 0, 0), + + # Backward-compatible fallback if older code still sends STOPPED. + "stopped": QColor(169, 169, 169), } # Create a dictionary to hold the status widgets, which we will enable/disable @@ -260,16 +268,47 @@ def create_system_status_group(self): def update_system_status(self, status): """ - Update the system status and make only the relevant widget visible. - Hide all other status widgets. + Update Instrument System Status from seq_seqstate lifecycle states. + + The ZMQ service normally sends normalized lower-case keys, but this + method also accepts the raw sequencer strings so local startup checks + or future callers can pass values like READY/RUNNING directly. """ + raw_status = "" if status is None else str(status).strip() + seqstate_to_ui = { + "NOTREADY": "not_ready", + "READY": "ready", + "RUNNING": "running", + "STOPPING": "stopping", + "PAUSED": "paused", + "STARTING": "starting", + "FAILED": "failed", + "ABORTING": "aborting", + "IDLE": "ready", + "STOPPED": "stopped", + "ERROR": "failed", + } + status_key = seqstate_to_ui.get( + raw_status.upper(), + raw_status.lower().replace(" ", "_"), + ) + + # Backward-compatible aliases from older GUI/status code. + if status_key == "idle": + status_key = "ready" + elif status_key == "error": + status_key = "failed" + # Hide all status widgets - for status_key, status_info in self.status_widgets.items(): + for status_info in self.status_widgets.values(): status_info['widget'].setVisible(False) - # Show the widget corresponding to the current status - if status in self.status_widgets: - self.status_widgets[status]['widget'].setVisible(True) + # Show the widget corresponding to the current status. + # Fall back to stopped if the value is unknown. + if status_key not in self.status_widgets: + status_key = "stopped" + + self.status_widgets[status_key]['widget'].setVisible(True) def create_tcs_status_group(self): tcs_status_group = QGroupBox("TCS Status") @@ -490,7 +529,7 @@ def create_progress_and_image_group(self): # Add all components to the main layout progress_and_image_layout.addLayout(progress_layout) progress_and_image_layout.addLayout(image_info_layout) - progress_and_image_layout.addWidget(QLabel("Topic Broadcast:")) + progress_and_image_layout.addWidget(QLabel("Messages:")) progress_and_image_layout.addWidget(message_log) progress_and_image_group.setLayout(progress_and_image_layout) diff --git a/pygui/zmq_status_service.py b/pygui/zmq_status_service.py index c6802051..dbaa98f7 100644 --- a/pygui/zmq_status_service.py +++ b/pygui/zmq_status_service.py @@ -561,12 +561,20 @@ def _status_from_seq_seqstate(self, data: Dict[str, Any]) -> str: seqstate = str(data.get("seqstate", "")).strip().upper() state_map = { + # seq_seqstate lifecycle states -> Instrument System Status UI keys "NOTREADY": "not_ready", - "READY": "idle", - "IDLE": "idle", + "READY": "ready", + "RUNNING": "running", + "STOPPING": "stopping", "PAUSED": "paused", + "STARTING": "starting", + "FAILED": "failed", + "ABORTING": "aborting", + + # Backward-compatible aliases that may still appear from older seq output. + "IDLE": "ready", "STOPPED": "stopped", - "ERROR": "error", + "ERROR": "failed", } return state_map.get(seqstate, seqstate.lower() if seqstate else "stopped") From 1ba9189ddc4096b2ecfe37b78e11025761ba60b6 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Thu, 30 Jul 2026 16:29:25 -0700 Subject: [PATCH 20/20] matt CAL fixes --- pygui/calib/thrufocus | 8 +-- pygui/calib/thrufocus.260528 | 113 +++++++++++++++++++++++++++++++++ pygui/calib/thrufocus.260528~ | 113 +++++++++++++++++++++++++++++++++ pygui/calibration_procedure.py | 69 +++++++++++++++++++- 4 files changed, 298 insertions(+), 5 deletions(-) create mode 100755 pygui/calib/thrufocus.260528 create mode 100755 pygui/calib/thrufocus.260528~ diff --git a/pygui/calib/thrufocus b/pygui/calib/thrufocus index c330f156..46a751cf 100755 --- a/pygui/calib/thrufocus +++ b/pygui/calib/thrufocus @@ -30,11 +30,11 @@ camera exptime 10000 # 3. Camera BOI camera boi R 410 200 -camera boi I 580 200 +camera boi I 350 200 camera boi G 360 200 ### Add G channel BOI ### Add U channel BOI once BOI with binning works. - +camera boi U 800 200 # 3.5 Get some information from the camera and set some information imnum0=(`camera imnum`[1]) @@ -45,7 +45,7 @@ focusbase="focus_internal_`date +%g%m%d_%H%M%S`" camera basename $focusbase # 4. Turn on lamp and wait -fociI=( 4.2 4.3 4.4 4.5 4.6 4.7 4.8 4.9 5.0 5.1 5.2 5.3 ) +fociI=( 4.4 4.5 4.6 4.7 4.8 4.9 5.0 5.1 5.2 5.3 5.4 5.5 ) fociR=( 2.0 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 3.0 3.1 ) fociG=( 2.8 2.9 3.0 3.1 3.2 3.3 3.4 3.5 3.6 3.7 3.8 3.9 ) ### Add fociU @@ -85,7 +85,7 @@ calib set door=close camera boi R full camera boi I full camera boi G full - +camera boi U full #Add camera boi U full # now set the foci to nominal values, just because we can diff --git a/pygui/calib/thrufocus.260528 b/pygui/calib/thrufocus.260528 new file mode 100755 index 00000000..02c57291 --- /dev/null +++ b/pygui/calib/thrufocus.260528 @@ -0,0 +1,113 @@ +#!/usr/bin/bash -f +# +# Through-focus +# +# 1. Turn lamp off +# 2. Set exposure time +# 3. Take 3 darks +# 4. Turn lamp on; wait a minute or two +# 5. Put focus stage on the + side of the loop +# 6. Loop over focus positions, take 2 (3?) illuminated exposures at each +# 7. Turn off the lamp +# 8. Take 3 darks +# 9. Exit +# +# +# +# +# 1. Turn lamp off +# Using Argon lamp. + +calib set door=open cover=close +power lampthar on +calib lampmod 6 1 1000 + + + +# 2. Exposure time. +camera exptime 10000 +# + +# 3. Camera BOI +camera boi R 410 200 +camera boi I 580 200 +camera boi G 360 200 +### Add G channel BOI +### Add U channel BOI once BOI with binning works. +# camera boi U 800 200 + +# 3.5 Get some information from the camera and set some information +imnum0=(`camera imnum`[1]) +basename=(`camera basename`[1]) + +focusbase="focus_internal_`date +%g%m%d_%H%M%S`" + +camera basename $focusbase + +# 4. Turn on lamp and wait +fociI=( 4.2 4.3 4.4 4.5 4.6 4.7 4.8 4.9 5.0 5.1 5.2 5.3 ) +fociR=( 2.0 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 3.0 3.1 ) +fociG=( 2.8 2.9 3.0 3.1 3.2 3.3 3.4 3.5 3.6 3.7 3.8 3.9 ) +### Add fociU +focpos="1 2 3 4 5 6 7 8 9 10 11 12" +echo $foci +for fp in $focpos; do + # set the focus + # focus set I $focus + echo " FOC I ${fociI[$fp]} R ${fociR[$fp]} G ${fociG[$fp]} No `camera imnum`" + focus set I ${fociI[$fp]} + focus set R ${fociR[$fp]} + focus set G ${fociG[$fp]} + ### focus set G ${fociG[$fp]} + ### focus set U ..... + echo + sleep 4 + imnum1=(`camera imnum`[1]) + exposen 1 +done +# +# Turn off lamp +# power 2 3 off +# +# ./goexpose +# ./goexpose +# ./goexpose +# + + +camera basename $basename + +power lampthar off +calib lampmod 6 0 1000 +calib set door=close + +# revert BOI +camera boi R full +camera boi I full +camera boi G full +# camera boi U full +#Add camera boi U full + +# now set the foci to nominal values, just because we can + +focus set G 3.35 +focus set R 2.45 +focus set I 4.75 + + +# now run the analysis script. + + + + +# now display the results + + +allfiles="$focusbase*.fits" +cd /home/observer/focus/ +/home/developer/Software/run/focus_spec.py /data/latest/$allfiles -fa x -fk FOCUS -G /home/observer/focus/gfocus.reg + + +eog focus_spec_?.png + +echo "Done." diff --git a/pygui/calib/thrufocus.260528~ b/pygui/calib/thrufocus.260528~ new file mode 100755 index 00000000..a036d501 --- /dev/null +++ b/pygui/calib/thrufocus.260528~ @@ -0,0 +1,113 @@ +#!/usr/bin/bash -f +# +# Through-focus +# +# 1. Turn lamp off +# 2. Set exposure time +# 3. Take 3 darks +# 4. Turn lamp on; wait a minute or two +# 5. Put focus stage on the + side of the loop +# 6. Loop over focus positions, take 2 (3?) illuminated exposures at each +# 7. Turn off the lamp +# 8. Take 3 darks +# 9. Exit +# +# +# +# +# 1. Turn lamp off +# Using Argon lamp. + +calib set door=open cover=close +power lampthar on +calib lampmod 6 1 1000 + + + +# 2. Exposure time. +camera exptime 10000 +# + +# 3. Camera BOI +camera boi R 410 200 +camera boi I 580 200 +camera boi G 360 200 +### Add G channel BOI +### Add U channel BOI once BOI with binning works. +camera boi U 800 200 + +# 3.5 Get some information from the camera and set some information +imnum0=(`camera imnum`[1]) +basename=(`camera basename`[1]) + +focusbase="focus_internal_`date +%g%m%d_%H%M%S`" + +camera basename $focusbase + +# 4. Turn on lamp and wait +fociI=( 4.2 4.3 4.4 4.5 4.6 4.7 4.8 4.9 5.0 5.1 5.2 5.3 ) +fociR=( 2.0 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 3.0 3.1 ) +fociG=( 2.8 2.9 3.0 3.1 3.2 3.3 3.4 3.5 3.6 3.7 3.8 3.9 ) +### Add fociU +focpos="1 2 3 4 5 6 7 8 9 10 11 12" +echo $foci +for fp in $focpos; do + # set the focus + # focus set I $focus + echo " FOC I ${fociI[$fp]} R ${fociR[$fp]} G ${fociG[$fp]} No `camera imnum`" + focus set I ${fociI[$fp]} + focus set R ${fociR[$fp]} + focus set G ${fociG[$fp]} + ### focus set G ${fociG[$fp]} + ### focus set U ..... + echo + sleep 4 + imnum1=(`camera imnum`[1]) + exposen 1 +done +# +# Turn off lamp +# power 2 3 off +# +# ./goexpose +# ./goexpose +# ./goexpose +# + + +camera basename $basename + +power lampthar off +calib lampmod 6 0 1000 +calib set door=close + +# revert BOI +camera boi R full +camera boi I full +camera boi G full +camera boi U full +#Add camera boi U full + +# now set the foci to nominal values, just because we can + +focus set G 3.35 +focus set R 2.45 +focus set I 4.75 + + +# now run the analysis script. + + + + +# now display the results + + +allfiles="$focusbase*.fits" +cd /home/observer/focus/ +/home/developer/Software/run/focus_spec.py /data/latest/$allfiles -fa x -fk FOCUS -G /home/observer/focus/gfocus.reg + + +eog focus_spec_?.png + +echo "Done." diff --git a/pygui/calibration_procedure.py b/pygui/calibration_procedure.py index e9ca540b..af161667 100644 --- a/pygui/calibration_procedure.py +++ b/pygui/calibration_procedure.py @@ -96,6 +96,8 @@ def make_calibration_targets(slitwidth, xbin, ybin): t_etalon_nom = 3 t_dome_nom = 90 t_dome_nom_ug = 400 + t_dome_he_nom = 30 + t_dome_he_nom_ug = 600 t_bias = 0 t_dark = 1200 @@ -106,6 +108,8 @@ def make_calibration_targets(slitwidth, xbin, ybin): n_etalon = 0 n_dome = 0 n_dome_ug = 0 + n_dome_he = 0 + n_dome_he_ug = 0 n_bias = 7 n_dark = 0 @@ -116,10 +120,12 @@ def make_calibration_targets(slitwidth, xbin, ybin): t_fear = int(t_fear_nom * arc_multiplier) t_cont = int(t_cont_nom * cont_multiplier) t_dome = int(t_dome_nom * cont_multiplier) + t_dome_he = int(t_dome_he_nom * arc_multiplier) t_thar_ug = int(t_thar_nom_ug * arc_multiplier) t_fear_ug = int(t_fear_nom_ug * arc_multiplier) t_contb = int(t_contb_nom * cont_multiplier) t_dome_ug = int(t_dome_nom_ug * cont_multiplier) + t_dome_he_ug = int(t_dome_he_nom_ug * arc_multiplier) t_etalon = int(t_etalon_nom * cont_multiplier) rows = [] @@ -254,6 +260,32 @@ def make_calibration_targets(slitwidth, xbin, ybin): ) ) + if n_dome_he > 0: + rows.append( + _cal_row( + "CAL_DOMEHE", + "ugri Dome He", + xbin, + ybin, + slitwidth, + t_dome_he, + n_dome_he, + ) + ) + + if n_he_ug > 0: + rows.append( + _cal_row( + "CAL_DOMEHE_UG", + "ug Dome He", + xbin, + ybin, + slitwidth, + t_dome_he_ug, + n_dome_he_ug, + ) + ) + if n_dark > 0: rows.append( _cal_row( @@ -312,15 +344,22 @@ def make_dome_flat_targets(slitwidth, xbin, ybin): # T_dome_nom_ug = 400000 t_dome_nom = 90 t_dome_nom_ug = 400 + t_dome_he_nom = 30 + t_dome_he_nom_ug = 600 # Nominal counts from make_domes. n_dome = 5 n_dome_ug = 7 + n_dome_he = 0 + n_dome_he_ug = 3 + arc_multiplier = 1.0 / (xbin * ybin) cont_multiplier = (1.0 / (xbin * ybin)) * (0.5 / slitwidth) t_dome = int(t_dome_nom * cont_multiplier) t_dome_ug = int(t_dome_nom_ug * cont_multiplier) + t_dome_he = int(t_dome_he_nom * arc_multiplier) + t_dome_he_ug = int(t_dome_he_nom_ug * arc_multiplier) rows = [] @@ -350,6 +389,34 @@ def make_dome_flat_targets(slitwidth, xbin, ybin): ) ) + if n_dome_he > 0: + rows.append( + _cal_row( + "CAL_DOMEHE", + "ugri helium dome", + xbin, + ybin, + slitwidth, + t_dome_he, + n_dome_he, + ) + ) + + if n_dome_he_ug > 0: + rows.append( + _cal_row( + "CAL_DOMEHE_UG", + "ug helium dome", + xbin, + ybin, + slitwidth, + t_dome_he_ug, + n_dome_he_ug, + ) + ) + + + return rows @@ -387,4 +454,4 @@ def save_calibration_csv(rows, target_list_name, output_dir="generated_target_li writer.writeheader() writer.writerows(rows) - return csv_path \ No newline at end of file + return csv_path