From ccd53ceacdb926b9fb97879aec3a343edf51c14f Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Tue, 7 Jan 2025 22:51:52 -0800 Subject: [PATCH 1/3] add udp connection add udp connection add udp connection add updates to smq fix udp speed add css add calib to menu bar typo change UI rework updatee fixed css for table fixed css for table move around move around startup btn database update fix fix fix fix fix fix fix hook to image num and name mepw mepw fix fix parent fix parent mepw mepw mepw mepw fix me fix me fix me fix me calib fix fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus fix focus meow meow meow meow more fixes more fixes QQ more fixes QQ fix UI colors add transaction add transaction add transaction add transaction remove early sql close remove early sql close remove early sql close calibration gui edge case no RA and Dec edge case no RA and Dec edge case no RA and Dec calib fixes convert to LST convert to LST fixes all recent NGPS machine updates calibration gui updates fix timestamp fix timestamp fix logging fix logging fix logging add zmq connections add slit info add slit info add slit info fix log, add binning to control panel fix log, add binning to control panel add acam add focus path update default binning values gui updates revert to no tabs on target list calibration target list calib update calibration target list calibration target list sql connction sql connction pending acam button fix update exposure bar update focus update lamp/mods exp time + move obs_id Update focus script db change db change db change db change db change db change have NA for slitw have NA for slitw have NA for slitw Big uplift Fix go button issue update layout update layout update focus script add seq state add seq state add seq wait state add shutter add spacing add spacing make it scrollable make it scrollable make it scrollable update db query update db query update db query update db query update db query update db query update db query update db query update db query update db query update db query update db query update db query finally finally finally finally? enable calibration moe: --- pygui/.gitignore | 2 +- pygui/calib/__init__.py | 0 pygui/calib/andor.sh | 11 + pygui/calib/calibration.py | 98 +- pygui/calib/fake | 1 + pygui/calib/getcalib | 102 +- pygui/calib/getcalib_flats | 99 +- pygui/calib/tabs/afternoon_tab.py | 319 +++- pygui/calib/tabs/async_command_thread.py | 51 + pygui/calib/tabs/calib_tab.py | 26 +- pygui/calib/tabs/commands_tab.py | 6 +- pygui/calib/tabs/focus_tab.py | 860 +++++++--- pygui/calib/thrufocus | 29 +- pygui/calib/thrufocus_old | 70 + pygui/config/sequencer_config.ini | 8 + pygui/control_tab.py | 718 ++++----- pygui/daemon_status_bar.py | 157 ++ pygui/etc_popup.py | 340 ++++ pygui/layout_service.py | 1872 ++++++++++++++++++++++ pygui/logic_service.py | 1132 +++++++++++++ pygui/login_service.py | 8 + pygui/menu_service.py | 12 +- pygui/ngps_gui.py | 294 +++- pygui/status_service.py | 273 ++-- pygui/styles.qss | 51 +- pygui/test.py | 35 + pygui/zmq_status_service.py | 264 +++ 27 files changed, 5827 insertions(+), 1011 deletions(-) create mode 100644 pygui/calib/__init__.py create mode 100755 pygui/calib/andor.sh create mode 100644 pygui/calib/tabs/async_command_thread.py create mode 100755 pygui/calib/thrufocus_old create mode 100644 pygui/config/sequencer_config.ini create mode 100644 pygui/daemon_status_bar.py create mode 100644 pygui/etc_popup.py create mode 100644 pygui/layout_service.py create mode 100644 pygui/logic_service.py create mode 100644 pygui/test.py create mode 100644 pygui/zmq_status_service.py diff --git a/pygui/.gitignore b/pygui/.gitignore index 82f92755..7b6caf34 100644 --- a/pygui/.gitignore +++ b/pygui/.gitignore @@ -159,4 +159,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ diff --git a/pygui/calib/__init__.py b/pygui/calib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pygui/calib/andor.sh b/pygui/calib/andor.sh new file mode 100755 index 00000000..81399b28 --- /dev/null +++ b/pygui/calib/andor.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Move to the working directory +cd /home/observer/focus || exit 1 + +prefix=$(find /data/latest/acam -maxdepth 1 -type f -name 'focusloop_*_*.fits' -print0 \ + | xargs -0 -n1 basename \ + | sed -E 's/^focusloop_([0-9]{8}-[0-9]{6})_.*/\1/' \ + | sort | tail -1) \ +&& /home/developer/Software/run/focus_andor.py -fk TELFOCUS "/data/latest/acam/focusloop_${prefix}_*.fits" + diff --git a/pygui/calib/calibration.py b/pygui/calib/calibration.py index 79215ad1..aa93939c 100644 --- a/pygui/calib/calibration.py +++ b/pygui/calib/calibration.py @@ -1,12 +1,20 @@ import sys -from PyQt5.QtWidgets import QMainWindow, QApplication, QTabWidget, QVBoxLayout, QLabel, QPushButton, QWidget, QDesktopWidget -from tabs.commands_tab import CommandsTab -from tabs.afternoon_tab import AfternoonTab -from tabs.focus_tab import FocusTab -from tabs.science_tab import ScienceTab import os +from PyQt5.QtWidgets import ( + QMainWindow, QApplication, QTabWidget, QDesktopWidget, + QVBoxLayout, QTextEdit, QWidget, QPushButton +) +from PyQt5.QtCore import pyqtSignal + +from calib.tabs.commands_tab import CommandsTab +from calib.tabs.afternoon_tab import AfternoonTab +from calib.tabs.focus_tab import FocusTab +from calib.tabs.science_tab import ScienceTab + class CalibrationGUI(QMainWindow): + log_signal = pyqtSignal(str) + def __init__(self): super().__init__() self.setWindowTitle("Calibrations") @@ -14,74 +22,100 @@ def __init__(self): self.initUI() def initUI(self): + # Create a QTextEdit for logging + self.log_text_edit = QTextEdit(self) + self.log_text_edit.setReadOnly(True) + self.log_text_edit.setPlaceholderText("Log messages will appear here...") + self.log_text_edit.setMinimumHeight(150) + + # Connect signal to safe GUI update method + self.log_signal.connect(self.append_log) + + # Define the thread-safe logging callback function + def log_message(msg): + self.log_signal.emit(msg) + # Create the QTabWidget tab_widget = QTabWidget() # Create the tabs - commands_tab = CommandsTab() - afternoon_tab = AfternoonTab() - focus_tab = FocusTab() - science_tab = ScienceTab() + self.afternoon_tab = AfternoonTab(log_message_callback=self.log_signal.emit) + self.commands_tab = CommandsTab() + self.focus_tab = FocusTab(log_message_callback=self.log_signal.emit) + self.science_tab = ScienceTab() # Add tabs to the tab widget - tab_widget.addTab(focus_tab, "Focus") - tab_widget.addTab(afternoon_tab, "Afternoon") - tab_widget.addTab(science_tab, "Science") - tab_widget.addTab(commands_tab, "Commands") - - # Set the tab widget as the central widget of the main window - self.setCentralWidget(tab_widget) - + tab_widget.addTab(self.afternoon_tab, "Afternoon") + tab_widget.addTab(self.focus_tab, "Focus") + tab_widget.addTab(self.science_tab, "Science") + tab_widget.addTab(self.commands_tab, "Commands") + + # Set up layout and widgets + main_layout = QVBoxLayout() + main_layout.addWidget(tab_widget, stretch=3) + main_layout.addWidget(self.log_text_edit, stretch=2) + + # Optionally, add a button to clear the log + clear_log_button = QPushButton("Clear Log", self) + clear_log_button.clicked.connect(self.clear_log) + main_layout.addWidget(clear_log_button, stretch=0) + + # Create a QWidget and set the layout + central_widget = QWidget(self) + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + self.load_stylesheet("styles.qss") - - # Adjust window size based on screen resolution self.adjust_window_size() + def append_log(self, message): + """Safely append a log message to the QTextEdit widget from the main thread.""" + self.log_text_edit.append(message) + + def clear_log(self): + """Clear the log messages.""" + self.log_text_edit.clear() + def adjust_window_size(self): - screen = QDesktopWidget().screenGeometry() # Get screen size + screen = QDesktopWidget().screenGeometry() width = screen.width() height = screen.height() - # Set a default window size as a percentage of screen size (for example, 80% width and 60% height) new_width = int(width * 0.4) new_height = int(height * 0.6) - - self.setFixedSize(new_width, new_height) # Set window size dynamically + self.setFixedSize(new_width, new_height) def resizeEvent(self, event): - # Maintain 4:3 aspect ratio on window resizing aspect_ratio = 4 / 3 current_width = self.width() current_height = self.height() - # Calculate the new width/height to preserve aspect ratio if current_width / current_height > aspect_ratio: - new_width = int(current_height * aspect_ratio) # Ensure integer type + new_width = int(current_height * aspect_ratio) new_height = current_height else: - new_height = int(current_width / aspect_ratio) # Ensure integer type + new_height = int(current_width / aspect_ratio) new_width = current_width - # Resize the window to maintain aspect ratio self.resize(new_width, new_height) - - # Call the original resizeEvent super().resizeEvent(event) def load_stylesheet(self, filename): - """Load and apply the stylesheet from a .qss file""" if os.path.exists(filename): with open(filename, "r") as file: stylesheet = file.read() self.setStyleSheet(stylesheet) else: - print(f"Stylesheet file {filename} not found.") + self.log_signal.emit(f"Stylesheet file {filename} not found.") + def main(): app = QApplication(sys.argv) main_window = CalibrationGUI() main_window.show() + main_window.log_signal.emit("Calibration started...") # Thread-safe initial log sys.exit(app.exec_()) + if __name__ == "__main__": main() diff --git a/pygui/calib/fake b/pygui/calib/fake index d5223a79..fa518fe6 100755 --- a/pygui/calib/fake +++ b/pygui/calib/fake @@ -1,3 +1,4 @@ +#!/usr/bin/bash -f echo "Executing \"$@\"" exec $@ sleep 0.5 diff --git a/pygui/calib/getcalib b/pygui/calib/getcalib index e0e70de2..07f3df4d 100755 --- a/pygui/calib/getcalib +++ b/pygui/calib/getcalib @@ -25,11 +25,11 @@ T_dark=10000 ### Nominal exposure counts. These should be command line or parameter ## file inputs, etc. -N_thar=2 -N_fear=2 +N_thar=3 +N_fear=3 N_cont=3 N_etalon=0 -N_dome=5 +N_dome=0 N_bias=7 N_dark=0 @@ -98,23 +98,23 @@ T_etalon=`/usr/bin/python3.9 -c "print(int($T_etalon_nom * $contmultiplier))"` # # close the cover, open the door -./fake calib set cover=close door=open +bash calib/fake calib set cover=close door=open # close the lollipops for i in $(/usr/bin/seq 1 6); do - ./fake calib lampmod $i 0 1000 + sh calib/fake calib lampmod $i 0 1000 # calib lampmod $i 0 1000 sleep 0.5 done if [ $dolamps -ne 0 ]; then # turn on the lamps -./fake power LAMPTHAR on -./fake power LAMPFEAR on -./fake power LAMPREDC on -./fake turn on dome hilamp here +bash calib/fake power LAMPTHAR on +bash calib/fake power LAMPFEAR on +bash calib/fake power LAMPREDC on +bash calib/fake turn on dome hilamp here fi ####### THAR @@ -129,16 +129,16 @@ basename=(`camera basename`[1]) echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=THAR//Calibration. ThAr lamp -./fake calib lampmod $tharmod 1 1000 -./fake camera exptime $T_thar -./fake exposen $N_thar +bash calib/fake camera key IMGTYPE=THAR//Calibration. ThAr lamp +bash calib/fake calib lampmod $tharmod 1 1000 +bash calib/fake camera exptime $T_thar +bash calib/fake exposen $N_thar # turn off the ThAr lamp -./fake calib lampmod $tharmod 0 1000 +bash calib/fake calib lampmod $tharmod 0 1000 if [ $dolamps -ne 0 ]; then -./fake power LAMPTHAR off +bash calib/fake power LAMPTHAR off fi fi @@ -153,17 +153,17 @@ firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) echo "First exposure: $fistex. Basename: $basename" -./fake camera key IMGTYPE=FEAR//Calibration. FeAr lamp -./fake calib lampmod $fearmod 1 1000 +bash calib/fake camera key IMGTYPE=FEAR//Calibration. FeAr lamp +bash calib/fake calib lampmod $fearmod 1 1000 -./fake camera exptime $T_fear -./fake exposen $N_fear +bash calib/fake camera exptime $T_fear +bash calib/fake exposen $N_fear # turn off the FeAr lamp -./fake calib lampmod $fearmod 0 1000 +bash calib/fake calib lampmod $fearmod 0 1000 if [ $dolamps -ne 0 ]; then -./fake power LAMPFEAR off +bash calib/fake power LAMPFEAR off fi fi @@ -181,14 +181,14 @@ firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=CONT//Calibration. Internal Continuum -./fake calib lampmod $redccont 1 1000 +bash calib/fake camera key IMGTYPE=CONT//Calibration. Internal Continuum +bash calib/fake calib lampmod $redccont 1 1000 -./fake camera exptime $T_cont -./fake exposen $N_cont +bash calib/fake camera exptime $T_cont +bash calib/fake exposen $N_cont # close the cont lollipop -./fake calib lampmod $redccont 0 1000 +bash calib/fake calib lampmod $redccont 0 1000 fi @@ -203,19 +203,19 @@ firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=ETALON//Calibration. Internal Etalon -./fake calib lampmod $redcetalon 1 1000 +bash calib/fake camera key IMGTYPE=ETALON//Calibration. Internal Etalon +bash calib/fake calib lampmod $redcetalon 1 1000 -./fake camera exptime $T_etalon -./fake exposen $N_etalon +bash calib/fake camera exptime $T_etalon +bash calib/fake exposen $N_etalon # close the cont lollipop -./fake calib lampmod $redcetalon 0 1000 +bash calib/fake calib lampmod $redcetalon 0 1000 fi ### shut down the internal continuum lamp after etalons if [ $dolamps -ne 0 ]; then -./fake power LAMPREDC off +bash calib/fake power LAMPREDC off fi ####### Dome flats!!!! @@ -231,20 +231,20 @@ firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=DOMEFLAT//Calibration. Dome Flat -./fake calib set door=close cover=open +bash calib/fake camera key IMGTYPE=DOMEFLAT//Calibration. Dome Flat +bash calib/fake calib set door=close cover=open -./fake camera exptime $T_dome -./fake exposen $N_dome +bash calib/fake camera exptime $T_dome +bash calib/fake exposen $N_dome # close cover after domes here, for safety. -./fake calib cover=close +bash calib/fake calib cover=close fi ### shut down the dome lamp, if required. if [ $dolamps -ne 0 ]; then -./fake Turn off dome hilamp here. +bash calib/fake Turn off dome hilamp here. fi #### Done with illuminated testing @@ -260,13 +260,13 @@ firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) echo "First exposure: $fistex. Basename: $basename" -./fake camera key IMGTYPE=BIAS//Calibration. Dome Flat -./fake calib set cover=close door=close -./fake camera shutter disable +bash calib/fake camera key IMGTYPE=BIAS//Calibration. Bias +bash calib/fake calib set cover=close door=close +bash calib/fake camera shutter disable -./fake camera exptime 0 -./fake exposen $N_bias +bash calib/fake camera exptime 0 +bash calib/fake exposen $N_bias fi @@ -282,20 +282,20 @@ echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=DARK//Calibration. Dome Flat -./fake calib set cover=close door=close -./fake camera shutter disable -./fake camera exptime $T_dark -./fake exposen $N_dark +bash calib/fake camera key IMGTYPE=DARK//Calibration. Dome Flat +bash calib/fake calib set cover=close door=close +bash calib/fake camera shutter disable +bash calib/fake camera exptime $T_dark +bash calib/fake exposen $N_dark fi echo echo "--- WRAP UP ---" -./fake camera shutter enable -./fake camera key IMGTYPE=SCI//Science -./fake camera exptime $origexptime +bash calib/fake camera shutter enable +bash calib/fake camera key IMGTYPE=SCI//Science +bash calib/fake camera exptime $origexptime echo "Done with cal set at `date`" diff --git a/pygui/calib/getcalib_flats b/pygui/calib/getcalib_flats index 97a03c92..8fa9fdef 100755 --- a/pygui/calib/getcalib_flats +++ b/pygui/calib/getcalib_flats @@ -1,4 +1,4 @@ - +#!/usr/bin/bash -f ### Matuszewski 24/11/16 ### First stab at a CAL acquisition ### Nothing fancy, no error checking, assumes instrument is OK to command @@ -92,22 +92,23 @@ T_etalon=`/usr/bin/python3.9 -c "print(int($T_etalon_nom * $contmultiplier))"` # # close the cover, open the door -./fake calib set cover=close door=open +bash calib/fake calib set cover=close door=open # close the lollipops -for i in $(/usr/bin/seq 1 6); - - # calib lampmod $i 0 1000 +# Close the lollipops +for i in $(/usr/bin/seq 1 6); do + # calib lampmod $i 0 1000 sleep 0.5 done + if [ $dolamps -ne 0 ]; then # turn on the lamps -./fake power LAMPTHAR on -./fake power LAMPFEAR on -./fake power LAMPREDC on -./fake turn on dome hilamp here +bash calib/fake power LAMPTHAR on +bash calib/fake power LAMPFEAR on +bash calib/fake power LAMPREDC on +bash calib/fake turn on dome hilamp here fi ####### THAR @@ -124,15 +125,15 @@ echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=THAR//Calibration. ThAr lamp -./fake calib lampmod $tharmod 1 1000 +bash calib/fake camera key IMGTYPE=THAR//Calibration. ThAr lamp +bash calib/fake calib lampmod $tharmod 1 1000 # turn off the ThAr lamp -./fake calib lampmod $tharmod 0 1000 +bash calib/fake calib lampmod $tharmod 0 1000 if [ $dolamps -ne 0 ]; then -./fake power LAMPTHAR off +bash calib/fake power LAMPTHAR off fi fi @@ -147,17 +148,17 @@ firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) echo "First exposure: $fistex. Basename: $basename" -./fake camera key IMGTYPE=FEAR//Calibration. FeAr lamp -./fake calib lampmod $fearmod 1 1000 +bash calib/fake camera key IMGTYPE=FEAR//Calibration. FeAr lamp +bash calib/fake calib lampmod $fearmod 1 1000 -./fake camera exptime $T_fear -./fake exposen $N_fear +bash calib/fake camera exptime $T_fear +bash calib/fake exposen $N_fear # turn off the FeAr lamp -./fake calib lampmod $fearmod 0 1000 +bash calib/fake calib lampmod $fearmod 0 1000 if [ $dolamps -ne 0 ]; then -./fake power LAMPFEAR off +bash calib/fake power LAMPFEAR off fi fi @@ -173,14 +174,14 @@ firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=CONT//Calibration. Internal Continuum -./fake calib lampmod $redccont 1 1000 +bash calib/fake camera key IMGTYPE=CONT//Calibration. Internal Continuum +bash calib/fake calib lampmod $redccont 1 1000 -./fake camera exptime $T_cont -./fake exposen $N_cont +bash calib/fake camera exptime $T_cont +bash calib/fake exposen $N_cont # close the cont lollipop -./fake calib lampmod $redccont 0 1000 +bash calib/fake calib lampmod $redccont 0 1000 fi @@ -195,18 +196,18 @@ firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=ETALON//Calibration. Internal Etalon -./fake calib lampmod $redcetalon 1 1000 +bash calib/fake camera key IMGTYPE=ETALON//Calibration. Internal Etalon +bash calib/fake calib lampmod $redcetalon 1 1000 # close the cont lollipop -./fake calib lampmod $redcetalon 0 1000 +bash calib/fake calib lampmod $redcetalon 0 1000 fi -### shut down the internal continuum lamp after etalons +### bash ut down the internal continuum lamp after etalons if [ $dolamps -ne 0 ]; then -./fake power LAMPREDC off +bash calib/fake power LAMPREDC off fi @@ -222,20 +223,20 @@ echo "Taking $N_dome continuum $T_dome (ms)" firstex=(`camera imnum`[1]) basename=(`camera basename`[1]) -./fake camera key IMGTYPE=DOMEFLAT//Calibration. Dome Flat -./fake calib set door=close cover=open +bash calib/fake camera key IMGTYPE=DOMEFLAT//Calibration. Dome Flat +bash calib/fake calib set door=close cover=open -./fake camera exptime $T_dome -./fake exposen $N_dome +bash calib/fake camera exptime $T_dome +bash calib/fake exposen $N_dome # close cover after domes here, for safety. -./fake calib cover=close +bash calib/fake calib cover=close fi -### shut down the dome lamp, if required. +### bash ut down the dome lamp, if required. if [ $dolamps -ne 0 ]; then -./fake Turn off dome hilamp here. +bash calib/fake Turn off dome hilamp here. fi #### Done with illuminated testing @@ -250,13 +251,13 @@ echo "Taking $N_bias 0s biases" echo "First exposure: $fistex. Basename: $basename" -./fake camera key IMGTYPE=BIAS//Calibration. Dome Flat -./fake calib set cover=close door=close -./fake camera shutter disable +bash calib/fake camera key IMGTYPE=BIAS//Calibration. Dome Flat +bash calib/fake calib set cover=close door=close +bash calib/fake camera shutter disable -./fake camera exptime 0 -./fake exposen $N_bias +bash calib/fake camera exptime 0 +bash calib/fake exposen $N_bias fi @@ -271,20 +272,20 @@ basename=(`camera basename`[1]) echo "First exposure: $firstex. Basename: $basename" -./fake camera key IMGTYPE=DARK//Calibration. Dome Flat -./fake calib set cover=close door=close -./fake camera shutter disable -./fake camera exptime $T_dark -./fake exposen $N_dark +bash calib/fake camera key IMGTYPE=DARK//Calibration. Dome Flat +bash calib/fake calib set cover=close door=close +bash calib/fake camera shutter disable +bash calib/fake camera exptime $T_dark +bash calib/fake exposen $N_dark fi echo echo "--- WRAP UP ---" -./fake camera shutter enable -./fake camera key IMGTYPE=SCI//Science -./fake camera exptime $origexptime +bash calib/fake camera shutter enable +bash calib/fake camera key IMGTYPE=SCI//Science +bash calib/fake camera exptime $origexptime echo "Done with cal set at `date`" diff --git a/pygui/calib/tabs/afternoon_tab.py b/pygui/calib/tabs/afternoon_tab.py index a02d5513..3dfe400c 100644 --- a/pygui/calib/tabs/afternoon_tab.py +++ b/pygui/calib/tabs/afternoon_tab.py @@ -1,9 +1,10 @@ -import subprocess -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QFormLayout, QSizePolicy, QHBoxLayout, QScrollArea +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QFormLayout, QSizePolicy, QHBoxLayout, QScrollArea, QFrame +from calib.tabs.async_command_thread import AsyncCommandThread class AfternoonTab(QWidget): - def __init__(self): + def __init__(self, log_message_callback): super().__init__() + self.log_message_callback = log_message_callback # Set log_message callback from the parent self.initUI() def initUI(self): @@ -26,6 +27,83 @@ def initUI(self): form_layout.setContentsMargins(10, 10, 10, 10) # Inner margins around form form_layout.setSpacing(10) # Spacing between rows (increased for better readability) + self.thrufocus_button = QPushButton("Run thrufocus", self) + self.thrufocus_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green color */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { + background-color: #45a049; /* Slightly darker green on hover */ + } + QPushButton:pressed { + background-color: #3e8e41; /* Darker green when pressed */ + } + """) + self.thrufocus_button.clicked.connect(self.run_thrufocus_script) + self.thrufocus_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally + form_layout.addRow("", self.thrufocus_button) # Place button below the input + # Add vertical spacing between sections + form_layout.addRow("", QLabel()) # Empty row for spacing + + # Getcalib Command Section (./getcalib) + self.getcalib_button = QPushButton("Get Calibration", self) + self.getcalib_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green color */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { + background-color: #45a049; /* Slightly darker green on hover */ + } + QPushButton:pressed { + background-color: #3e8e41; /* Darker green when pressed */ + } + """) + self.getcalib_button.clicked.connect(self.run_getcalib) + self.getcalib_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally + form_layout.addRow("", self.getcalib_button) # Place button below the input + + # Getcalib_flat Command Section (./getcalib_flat) + self.getcalib_flat_button = QPushButton("Get Calibration Flats", self) + self.getcalib_flat_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green color */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { + background-color: #45a049; /* Slightly darker green on hover */ + } + QPushButton:pressed { + background-color: #3e8e41; /* Darker green when pressed */ + } + """) + self.getcalib_flat_button.clicked.connect(self.run_getcalib_flat) + self.getcalib_flat_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally + form_layout.addRow("", self.getcalib_flat_button) # Place button below the input + + # Add vertical spacing between sections + form_layout.addRow("", QLabel()) # Empty row for spacing + + # Add white divider line before Slit Set + divider = QFrame(self) + divider.setFrameShape(QFrame.HLine) + divider.setFrameShadow(QFrame.Sunken) + divider.setStyleSheet('background-color: white;') # Set divider color to white + form_layout.addRow(divider) + + # Add vertical spacing between sections + form_layout.addRow("", QLabel()) # Empty row for spacing + # Slit Set Command Section (slit set ) self.slit_value_input = QLineEdit(self) self.slit_value_input.setPlaceholderText("Enter slit value") @@ -37,6 +115,9 @@ def initUI(self): slit_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally form_layout.addRow("", slit_button) # Place button below the input + # Add vertical spacing between sections + form_layout.addRow("", QLabel()) # Empty row for spacing + # Camera Bin Section (spatial binning and spectral binning) self.spatial_binning_input = QLineEdit(self) self.spatial_binning_input.setPlaceholderText("Enter spatial binning value") @@ -61,51 +142,43 @@ def initUI(self): # Add vertical spacing between sections form_layout.addRow("", QLabel()) # Empty row for spacing - # Thrufocus Script Section (./thrufocus | tee ) - self.log_file_input = QLineEdit(self) - self.log_file_input.setPlaceholderText("Enter output log file path") - self.log_file_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Input expands horizontally - form_layout.addRow("Log File Path:", self.log_file_input) - - thrufocus_button = QPushButton("Run thrufocus", self) - thrufocus_button.clicked.connect(self.run_thrufocus_script) - thrufocus_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally - form_layout.addRow("", thrufocus_button) # Place button below the input + # Focus Set Command Section (focus set ) + self.band_r_input = QLineEdit(self) + self.band_r_input.setPlaceholderText("R") + self.band_r_input.setReadOnly(True) + self.band_r_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Input expands horizontally + form_layout.addRow("Band:", self.band_r_input) + + self.value_r_input = QLineEdit(self) + self.value_r_input.setPlaceholderText("2.45") + self.value_r_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Input expands horizontally + form_layout.addRow("Focus Value:", self.value_r_input) + + focus_button = QPushButton("Set Focus Value (R Band)", self) + focus_button.clicked.connect(self.set_focus_r) + focus_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally + form_layout.addRow("", focus_button) # Place button below the input # Add vertical spacing between sections form_layout.addRow("", QLabel()) # Empty row for spacing # Focus Set Command Section (focus set ) - self.band_input = QLineEdit(self) - self.band_input.setPlaceholderText("Enter band (e.g., R, I)") - self.band_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Input expands horizontally - form_layout.addRow("Band:", self.band_input) + self.band_i_input = QLineEdit(self) + self.band_i_input.setPlaceholderText("I") + self.band_i_input.setReadOnly(True) + self.band_i_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Input expands horizontally + form_layout.addRow("Band:", self.band_i_input) self.value_input = QLineEdit(self) - self.value_input.setPlaceholderText("Enter focus value") + self.value_input.setPlaceholderText("4.85") self.value_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Input expands horizontally form_layout.addRow("Focus Value:", self.value_input) - focus_button = QPushButton("Set Focus Value", self) - focus_button.clicked.connect(self.set_focus) + focus_button = QPushButton("Set Focus Value (I Band)", self) + focus_button.clicked.connect(self.set_focus_i) focus_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally form_layout.addRow("", focus_button) # Place button below the input - # Add vertical spacing between sections - form_layout.addRow("", QLabel()) # Empty row for spacing - - # Getcalib Command Section (./getcalib) - getcalib_button = QPushButton("Get Calibration", self) - getcalib_button.clicked.connect(self.run_getcalib) - getcalib_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally - form_layout.addRow("", getcalib_button) # Place button below the input - - # Getcalib_flat Command Section (./getcalib_flat) - getcalib_flat_button = QPushButton("Get Calibration Flats", self) - getcalib_flat_button.clicked.connect(self.run_getcalib_flat) - getcalib_flat_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Button expands horizontally - form_layout.addRow("", getcalib_flat_button) # Place button below the input - # Create a QWidget to hold the form layout form_widget = QWidget(self) form_widget.setLayout(form_layout) @@ -121,79 +194,157 @@ def initUI(self): # Set the main layout self.setLayout(layout) - def execute_command(self, command): - """Runs the given command in the terminal""" - try: - print(f"Running command: {command}") - subprocess.run(command, shell=True, check=True) - except subprocess.CalledProcessError as e: - print(f"Error executing command: {e}") - - def start_afternoon_session(self): - print("Afternoon session started...") - def set_slit(self): - # Get input value for the slit set command slit_value = self.slit_value_input.text() - - # Ensure the slit value is provided if slit_value: command = f"slit set {slit_value}" - self.execute_command(command) + self.run_command_in_background(command) else: - print("Please provide a valid slit value.") + self.log_message("Please provide a valid slit value.") def set_spatial_binning(self): - # Get input value for spatial binning spatial_binning = self.spatial_binning_input.text() - - # Ensure the spatial binning value is provided if spatial_binning: command_row = f"camera bin row {spatial_binning}" - self.execute_command(command_row) + self.run_command_in_background(command_row) else: - print("Please provide a spatial binning value.") + self.log_message("Please provide a spatial binning value.") def set_spectral_binning(self): - # Get input value for spectral binning spectral_binning = self.spectral_binning_input.text() - - # Ensure the spectral binning value is provided if spectral_binning: command_col = f"camera bin col {spectral_binning}" - self.execute_command(command_col) + self.run_command_in_background(command_col) else: - print("Please provide a spectral binning value.") + self.log_message("Please provide a spectral binning value.") def run_thrufocus_script(self): - # Get the output log file path - log_file = self.log_file_input.text() + # disable + gray while running + self.thrufocus_button.setEnabled(False) + self.thrufocus_button.setStyleSheet(""" + QPushButton { background-color: lightgray; + color: black; + } + """) + + command = "bash calib/thrufocus" + + # start the async command and re-enable only when done + self.thread = AsyncCommandThread(command, self.log_message_callback) + self.thread.output_signal.connect(self.log_message_callback) + + # restore UI when the background task ends (success or error) + def _restore_button(): + self.thrufocus_button.setEnabled(True) + self.thrufocus_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { background-color: #45a049; } + QPushButton:pressed { background-color: #3e8e41; } + """) + + self.thread.finished.connect(_restore_button) + # if the thread class emits terminated, hook it too (safe to try) + try: + self.thread.terminated.connect(_restore_button) + except Exception: + pass - # Ensure the log file path is provided - if log_file: - command = f"bash thrufocus | tee {log_file}" - self.execute_command(command) - else: - print("Please provide a valid log file path.") + self.thread.start() - def set_focus(self): - # Get input values for the focus set command - band = self.band_input.text() - value = self.value_input.text() + def set_focus_r(self): + if self.value_r_input.placeholderText(): + value = self.value_r_input.placeholderText() + else: + value = self.value_r_input.text() + if value: + command = f"focus set R {value}" + self.run_command_in_background(command) + else: + self.log_message("Please provide both band and value for the focus set command.") - # Ensure both band and value are provided - if band and value: - command = f"focus set {band} {value}" - self.execute_command(command) + def set_focus_i(self): + if self.value_input.placeholderText(): + value = self.value_input.placeholderText() + else: + value = self.value_input.text() + if value: + command = f"focus set I {value}" + self.run_command_in_background(command) else: - print("Please provide both band and value for the focus set command.") + self.log_message("Please provide both band and value for the focus set command.") def run_getcalib(self): - """Runs the getcalib command""" - command = "bash getcalib" - self.execute_command(command) + # disable button + gray while running + self.getcalib_button.setEnabled(False) + self.getcalib_button.setStyleSheet("QPushButton { background-color: lightgray; color: black;}") + + command = "bash calib/getcalib" + self.thread_getcalib = AsyncCommandThread(command, self.log_message_callback) + self.thread_getcalib.output_signal.connect(self.log_message_callback) + + # restore when background task ends + def _restore_getcalib(): + self.getcalib_button.setEnabled(True) + self.getcalib_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { background-color: #45a049; } + QPushButton:pressed { background-color: #3e8e41; } + """) + + self.thread_getcalib.finished.connect(_restore_getcalib) + try: + self.thread_getcalib.terminated.connect(_restore_getcalib) + except Exception: + pass + + self.thread_getcalib.start() def run_getcalib_flat(self): - """Runs the getcalib_flat command""" - command = "bash getcalib_flat" - self.execute_command(command) + # disable button + gray while running + self.getcalib_flat_button.setEnabled(False) + self.getcalib_flat_button.setStyleSheet("QPushButton { background-color: lightgray; color: black;}") + + command = "bash calib/getcalib_flats" + self.thread_getcalib_flats = AsyncCommandThread(command, self.log_message_callback) + self.thread_getcalib_flats.output_signal.connect(self.log_message_callback) + + # restore when background task ends + def _restore_getcalib_flats(): + self.getcalib_flat_button.setEnabled(True) + self.getcalib_flat_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { background-color: #45a049; } + QPushButton:pressed { background-color: #3e8e41; } + """) + + self.thread_getcalib_flats.finished.connect(_restore_getcalib_flats) + try: + self.thread_getcalib_flats.terminated.connect(_restore_getcalib_flats) + except Exception: + pass + + self.thread_getcalib_flats.start() + + def run_command_in_background(self, command): + """Run the command in a background thread.""" + self.thread = AsyncCommandThread(command, self.log_message_callback) + self.thread.output_signal.connect(self.log_message_callback) + self.thread.start() diff --git a/pygui/calib/tabs/async_command_thread.py b/pygui/calib/tabs/async_command_thread.py new file mode 100644 index 00000000..2903e2df --- /dev/null +++ b/pygui/calib/tabs/async_command_thread.py @@ -0,0 +1,51 @@ +import asyncio +from PyQt5.QtCore import QThread, pyqtSignal + +class AsyncCommandThread(QThread): + output_signal = pyqtSignal(str) + + def __init__(self, command, log_message_callback, parent=None): + super().__init__(parent) + self.command = command + self.log_message = log_message_callback + + def run(self): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.run_command_in_background(self.command)) + + async def run_command_in_background(self, command): + """Run the command and stream output line-by-line.""" + try: + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + # Stream stdout + while True: + line = await process.stdout.readline() + if not line: + break + decoded = line.decode().rstrip() + self.output_signal.emit(decoded) + self.log_message(decoded) + + # Stream stderr + while True: + err_line = await process.stderr.readline() + if not err_line: + break + decoded_err = err_line.decode().rstrip() + self.output_signal.emit(f"[stderr] {decoded_err}") + self.log_message(f"[stderr] {decoded_err}") + + await process.wait() + if process.returncode == 0: + self.output_signal.emit("Command finished successfully.") + else: + self.output_signal.emit(f"Command exited with code {process.returncode}") + + except Exception as e: + self.output_signal.emit(f"Error running command: {str(e)}") diff --git a/pygui/calib/tabs/calib_tab.py b/pygui/calib/tabs/calib_tab.py index 4ab306b7..eaaac816 100644 --- a/pygui/calib/tabs/calib_tab.py +++ b/pygui/calib/tabs/calib_tab.py @@ -1,5 +1,6 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QComboBox, QHBoxLayout, QScrollArea import subprocess +import asyncio class CalibTab(QWidget): def __init__(self): @@ -101,12 +102,29 @@ def initUI(self): final_layout.addWidget(scroll_area) self.setLayout(final_layout) - def execute_command(self, command: str): - """Executes the given command in the terminal.""" + async def execute_command(self, command): + """Runs the given command in the terminal asynchronously.""" try: print(f"Running command: {command}") - subprocess.run(command, shell=True, check=True) # Execute the command in the terminal - except subprocess.CalledProcessError as e: + # Start the subprocess asynchronously + process = await asyncio.create_subprocess_shell(command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + + # Wait for the command to finish and get the output and errors + stdout, stderr = await process.communicate() + + # If the process has an error output, print it + if stderr: + print(f"Error executing command: {stderr.decode()}") + + # Otherwise, print the output + if stdout: + print(f"Command output: {stdout.decode()}") + + # Check the returncode for success/failure + if process.returncode != 0: + print(f"Command failed with return code {process.returncode}") + + except Exception as e: print(f"Error executing command: {e}") def execute_calib_command(self, command: str): diff --git a/pygui/calib/tabs/commands_tab.py b/pygui/calib/tabs/commands_tab.py index 8e5582ca..cc2efb2f 100644 --- a/pygui/calib/tabs/commands_tab.py +++ b/pygui/calib/tabs/commands_tab.py @@ -1,7 +1,7 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QTabWidget -from tabs.power_tab import PowerTab -from tabs.calib_tab import CalibTab -from tabs.focus_cmd_tab import FocusCmdTab +from calib.tabs.power_tab import PowerTab +from calib.tabs.calib_tab import CalibTab +from calib.tabs.focus_cmd_tab import FocusCmdTab class CommandsTab(QWidget): def __init__(self): diff --git a/pygui/calib/tabs/focus_tab.py b/pygui/calib/tabs/focus_tab.py index de30a0d7..ed1a3c75 100644 --- a/pygui/calib/tabs/focus_tab.py +++ b/pygui/calib/tabs/focus_tab.py @@ -1,208 +1,381 @@ -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QFormLayout, QFrame, QScrollArea, QSizePolicy, QHBoxLayout +import datetime +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton, QLineEdit, QFormLayout, QFrame, QScrollArea, QSizePolicy, QHBoxLayout, QSpacerItem +from PyQt5.QtCore import Qt, pyqtSignal import subprocess -from PyQt5.QtCore import Qt +from calib.tabs.async_command_thread import AsyncCommandThread class FocusTab(QWidget): - def __init__(self): + output_signal = pyqtSignal(str) + def __init__(self, log_message_callback): super().__init__() + self.log_message_callback = log_message_callback # Set log_message callback from the parent self.initUI() + def initUI(self): main_layout = QVBoxLayout() - - # Scroll Area Widget to make layout scrollable - scroll_area_widget = QWidget() # Create a widget that will be scrolled - scroll_area_layout = QVBoxLayout() # Layout for the scrollable widget - # Turn on Band of Interest Section - boi_label = QLabel("Turn on Band of Interest", self) - scroll_area_layout.addWidget(boi_label) - - # Form Layout for Parameterized BOI (camera boi {channel} {skip_row} {rows}) - boi_form_layout = QFormLayout() - self.channel_input = QLineEdit(self) - self.channel_input.setPlaceholderText("Enter channel (for parameterized BOI)") - self.channel_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - boi_form_layout.addRow("Channel:", self.channel_input) + main_layout.setSpacing(10) # Set spacing between sections to make it readable - self.skip_rows_input = QLineEdit(self) - self.skip_rows_input.setPlaceholderText("Rows to skip") - self.skip_rows_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - boi_form_layout.addRow("Skip Rows:", self.skip_rows_input) + # Create a horizontal layout for the label to center it + label_layout = QHBoxLayout() + label_layout.setContentsMargins(0, 0, 0, 0) # Remove outer margins for the label layout - self.rows_input = QLineEdit(self) - self.rows_input.setPlaceholderText("Rows to read") - self.rows_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - boi_form_layout.addRow("Rows to Read:", self.rows_input) + # Afternoon Tab Label + label = QLabel("Focus", self) + label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) # Keep label fixed in height + label_layout.addWidget(label) - scroll_area_layout.addLayout(boi_form_layout) + main_layout.addLayout(label_layout) # Add label layout to the main layout - # Camera BOI Button with parameters (Centered) - boi_button = QPushButton("Activate BOI", self) - boi_button.setFixedWidth(200) # Set a fixed width for the button - - # Create a horizontal layout for the button to center it - button_layout = QHBoxLayout() - button_layout.addWidget(boi_button) - button_layout.setAlignment(boi_button, Qt.AlignCenter) # Center the button + # Scroll Area Widget to make layout scrollable + scroll_area_widget = QWidget() # Create a widget that will be scrolled + scroll_area_layout = QFormLayout() # Use a QFormLayout to organize the sections + + scroll_area_layout.setContentsMargins(15, 15, 15, 15) # Inner margins around form + scroll_area_layout.setSpacing(10) # Spacing between rows (increased for better readability) + + # Run Focus Button (Before Band of Interest Section) + # self.run_focus_button = QPushButton("Run Focus", self) + # self.run_focus_button.setStyleSheet(""" + # QPushButton { + # background-color: #4CAF50; /* Green color */ + # color: white; + # border-radius: 8px; + # padding: 10px; + # border: none; + # } + # QPushButton:hover { + # background-color: #45a049; /* Slightly darker green on hover */ + # } + # QPushButton:pressed { + # background-color: #3e8e41; /* Darker green when pressed */ + # } + # """) + # self.run_focus_button.clicked.connect(self.run_focus) + # self.run_focus_button.setFixedHeight(45) + # self.run_focus_button.setFixedWidth(300) + # self.run_focus_button_layout = QHBoxLayout() + # self.run_focus_button_layout.addWidget(self.run_focus_button) + # self.run_focus_button_layout.setAlignment(self.run_focus_button, Qt.AlignCenter) # Center the button + # scroll_area_layout.addRow(self.run_focus_button_layout) + + # Run ACAM Focus Button (Before Band of Interest Section) + self.run_acam_focus_button = QPushButton("Run ACAM Focus", self) + self.run_acam_focus_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green color */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { + background-color: #45a049; /* Slightly darker green on hover */ + } + QPushButton:pressed { + background-color: #3e8e41; /* Darker green when pressed */ + } + """) + self.run_acam_focus_button.clicked.connect(self.run_focus_acam) + self.run_acam_focus_button.setFixedHeight(45) + self.run_acam_focus_button.setFixedWidth(325) + self.run_acam_focus_button_layout = QHBoxLayout() + self.run_acam_focus_button_layout.addWidget(self.run_acam_focus_button) + self.run_acam_focus_button_layout.setAlignment(self.run_acam_focus_button, Qt.AlignCenter) # Center the button + scroll_area_layout.addRow(self.run_acam_focus_button_layout) + + + # Camstep Focus Command + scroll_area_layout.addRow(QLabel("camstep Focus")) - boi_button.clicked.connect(self.activate_boi) - scroll_area_layout.addLayout(button_layout) + self.focus_value_input = QLineEdit(self) + self.focus_value_input.setPlaceholderText("1") + self.focus_upper_input = QLineEdit(self) + self.focus_upper_input.setPlaceholderText("Upper bound") + self.focus_lower_input = QLineEdit(self) + self.focus_lower_input.setPlaceholderText("Lower bound") + self.focus_step_input = QLineEdit(self) + self.focus_step_input.setPlaceholderText("0.2") + + camstep_button = QPushButton("camstep Focus (General)", self) + camstep_button.clicked.connect(self.camstep_focus) + camstep_button.setFixedHeight(45) + camstep_button.setFixedWidth(325) # Half-width button + camstep_button_layout = QHBoxLayout() + camstep_button_layout.addWidget(camstep_button) + camstep_button_layout.setAlignment(camstep_button, Qt.AlignCenter) # Right-align the button + scroll_area_layout.addRow("Image Number:", self.focus_value_input) + scroll_area_layout.addRow("Upper Bound:", self.focus_upper_input) + scroll_area_layout.addRow("Lower Bound:", self.focus_lower_input) + scroll_area_layout.addRow("Step:", self.focus_step_input) + scroll_area_layout.addRow(camstep_button_layout) - # Full BOI Section - self.full_channel_input = QLineEdit(self) - self.full_channel_input.setPlaceholderText("Enter channel") - self.full_channel_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - scroll_area_layout.addWidget(QLabel("Enter channel for full BOI:")) - scroll_area_layout.addWidget(self.full_channel_input) - - # Full BOI Button (Centered) - boi_full_button = QPushButton("Activate BOI (Full)", self) - boi_full_button.setFixedWidth(200) # Set a fixed width for the button - full_button_layout = QHBoxLayout() - full_button_layout.addWidget(boi_full_button) - full_button_layout.setAlignment(boi_full_button, Qt.AlignCenter) # Center the button - - boi_full_button.clicked.connect(self.activate_boi_full) - scroll_area_layout.addLayout(full_button_layout) - - # Divider (horizontal line) to separate sections + # Camstep Focus Button (ACAM) + camstep_acam_button = QPushButton("camstep Focus (ACAM)", self) + camstep_acam_button.clicked.connect(self.camstep_focus_acam) + camstep_acam_button.setFixedHeight(45) + camstep_acam_button.setFixedWidth(325) # Half-width button + camstep_acam_button_layout = QHBoxLayout() + camstep_acam_button_layout.addWidget(camstep_acam_button) + camstep_acam_button_layout.setAlignment(camstep_acam_button, Qt.AlignCenter) # Right-align the button + scroll_area_layout.addRow(camstep_acam_button_layout) + + # Band of Interest Section + scroll_area_layout.addRow(QLabel("Band of Interest (R)")) + + # R Band Form Layout + self.channel_r_input = QLineEdit(self) + self.channel_r_input.setPlaceholderText("R") + self.channel_r_input.setReadOnly(True) + self.skip_rows_r_input = QLineEdit(self) + self.skip_rows_r_input.setPlaceholderText("400") + self.rows_r_input = QLineEdit(self) + self.rows_r_input.setPlaceholderText("200") + + # Add R Band inputs and spacer + spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) + scroll_area_layout.addRow("Channel (R):", self.channel_r_input) + scroll_area_layout.addRow("Skip Rows (R):", self.skip_rows_r_input) + scroll_area_layout.addRow("Rows to Read (R):", self.rows_r_input) + scroll_area_layout.addItem(spacer) + + # BOI Activation Button for R (right-aligned) + boi_r_button = QPushButton("Activate BOI (R)", self) + boi_r_button.clicked.connect(self.activate_boi_r) + boi_r_button.setFixedHeight(45) + boi_r_button.setFixedWidth(325) # Set fixed width (adjust as needed) + boi_r_button_layout = QHBoxLayout() + boi_r_button_layout.addWidget(boi_r_button) + boi_r_button_layout.setAlignment(boi_r_button, Qt.AlignCenter) # Right-align the button + scroll_area_layout.addRow(boi_r_button_layout) + + # Band of Interest Section for "I" band + scroll_area_layout.addRow(QLabel("Band of Interest (I)")) + + # I Band Form Layout + self.channel_i_input = QLineEdit(self) + self.channel_i_input.setPlaceholderText("I") + self.channel_i_input.setReadOnly(True) + self.skip_rows_i_input = QLineEdit(self) + self.skip_rows_i_input.setPlaceholderText("580") + self.rows_i_input = QLineEdit(self) + self.rows_i_input.setPlaceholderText("200") + + # Add I Band inputs and spacer + spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) + scroll_area_layout.addRow("Channel (I):", self.channel_i_input) + scroll_area_layout.addRow("Skip Rows (I):", self.skip_rows_i_input) + scroll_area_layout.addRow("Rows to Read (I):", self.rows_i_input) + scroll_area_layout.addItem(spacer) + + # BOI Activation Button for I (right-aligned) + boi_i_button = QPushButton("Activate BOI (I)", self) + boi_i_button.clicked.connect(self.activate_boi_i) + boi_i_button.setFixedHeight(45) + boi_i_button.setFixedWidth(325) # Set fixed width (adjust as needed) + boi_i_button_layout = QHBoxLayout() + boi_i_button_layout.addWidget(boi_i_button) + boi_i_button_layout.setAlignment(boi_i_button, Qt.AlignCenter) # Right-align the button + scroll_area_layout.addRow(boi_i_button_layout) + + # Add white divider line divider = QFrame(self) divider.setFrameShape(QFrame.HLine) divider.setFrameShadow(QFrame.Sunken) - scroll_area_layout.addWidget(divider) + divider.setStyleSheet('background-color: white;') # Set divider color to white + scroll_area_layout.addRow(divider) + + # Add white divider line + divider2 = QFrame(self) + divider2.setFrameShape(QFrame.HLine) + divider2.setFrameShadow(QFrame.Sunken) + divider2.setStyleSheet('background-color: white;') # Set divider color to white + + # Add white divider line + divider3 = QFrame(self) + divider3.setFrameShape(QFrame.HLine) + divider3.setFrameShadow(QFrame.Sunken) + divider3.setStyleSheet('background-color: white;') # Set divider color to white + + # Add white divider line + divider4 = QFrame(self) + divider4.setFrameShape(QFrame.HLine) + divider4.setFrameShadow(QFrame.Sunken) + divider4.setStyleSheet('background-color: white;') # Set divider color to white # Camera and Focus Section - commands_label = QLabel("Camera and Focus Commands", self) - scroll_area_layout.addWidget(commands_label) - - # Form Layout for Camera Bin Command - bin_form_layout = QFormLayout() - self.axis_input = QLineEdit(self) - self.axis_input.setPlaceholderText("Enter axis (for camera bin)") - self.axis_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - bin_form_layout.addRow("Axis:", self.axis_input) - - self.binfactor_input = QLineEdit(self) - self.binfactor_input.setPlaceholderText("Bin factor") - self.binfactor_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - bin_form_layout.addRow("Bin Factor:", self.binfactor_input) - - scroll_area_layout.addLayout(bin_form_layout) - - # Camera Bin Button (Centered) - bin_button = QPushButton("Activate Camera Bin", self) - bin_button.setFixedWidth(200) # Set a fixed width for the button - bin_button_layout = QHBoxLayout() - bin_button_layout.addWidget(bin_button) - bin_button_layout.setAlignment(bin_button, Qt.AlignCenter) # Center the button - - bin_button.clicked.connect(self.activate_bin) - scroll_area_layout.addLayout(bin_button_layout) - - # Camera Exptime Command Input Fields - exptime_form_layout = QFormLayout() + scroll_area_layout.addRow(QLabel("Camera and Focus Commands")) + + # Camera Bin Command Form (Row Bin) + self.axis_input_row = QLineEdit(self) + self.axis_input_row.setPlaceholderText("row") + self.axis_input_row.setReadOnly(True) + self.binfactor_input_row = QLineEdit(self) + self.binfactor_input_row.setPlaceholderText("4") + + row_bin_button = QPushButton("Activate Row Bin", self) + row_bin_button.clicked.connect(self.activate_row_bin) + row_bin_button.setFixedHeight(50) + row_bin_button.setFixedWidth(325) # Half-width button + row_bin_button_layout = QHBoxLayout() + row_bin_button_layout.addWidget(row_bin_button) + row_bin_button_layout.setAlignment(row_bin_button, Qt.AlignCenter) # Center the button + + # Add Row Bin Form and button to layout + scroll_area_layout.addRow("Axis (Row Bin):", self.axis_input_row) + scroll_area_layout.addRow("Row Bin Factor:", self.binfactor_input_row) + scroll_area_layout.addRow(row_bin_button_layout) + + # Camera Bin Command Form (Col Bin) + self.axis_input_col = QLineEdit(self) + self.axis_input_col.setPlaceholderText("col") + self.axis_input_col.setReadOnly(True) + self.binfactor_input_col = QLineEdit(self) + self.binfactor_input_col.setPlaceholderText("1") + + col_bin_button = QPushButton("Activate Col Bin", self) + col_bin_button.clicked.connect(self.activate_col_bin) + col_bin_button.setFixedHeight(45) + col_bin_button.setFixedWidth(325) # Half-width button + col_bin_button_layout = QHBoxLayout() + col_bin_button_layout.addWidget(col_bin_button) + col_bin_button_layout.setAlignment(col_bin_button, Qt.AlignCenter) # Center the button + + # Add Col Bin Form and button to layout + scroll_area_layout.addRow("Axis (Col Bin):", self.axis_input_col) + scroll_area_layout.addRow("Col Bin Factor:", self.binfactor_input_col) + scroll_area_layout.addRow(col_bin_button_layout) + + scroll_area_layout.addRow(divider2) + + # Exposure Time Command self.exptime_input = QLineEdit(self) - self.exptime_input.setPlaceholderText("Exposure time (in msec)") - self.exptime_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - exptime_form_layout.addRow("Exposure Time (ms):", self.exptime_input) - scroll_area_layout.addLayout(exptime_form_layout) - - # Exposure Time Button (Centered) + self.exptime_input.setPlaceholderText("10000") exptime_button = QPushButton("Set Camera Exposure Time", self) - exptime_button.setFixedWidth(200) # Set a fixed width for the button + exptime_button.clicked.connect(self.set_exptime) + exptime_button.setFixedHeight(45) + exptime_button.setFixedWidth(325) # Half-width button exptime_button_layout = QHBoxLayout() exptime_button_layout.addWidget(exptime_button) - exptime_button_layout.setAlignment(exptime_button, Qt.AlignCenter) # Center the button - - exptime_button.clicked.connect(self.set_exptime) - scroll_area_layout.addLayout(exptime_button_layout) + exptime_button_layout.setAlignment(exptime_button, Qt.AlignCenter) # Right-align the button + scroll_area_layout.addRow("Exposure Time (ms):", self.exptime_input) + scroll_area_layout.addRow(exptime_button_layout) + scroll_area_layout.addRow(divider3) - # Slit Set Command Input Fields - slit_form_layout = QFormLayout() + # Slit Set Command self.slit_width_input = QLineEdit(self) - self.slit_width_input.setPlaceholderText("Slit width") - self.slit_width_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - slit_form_layout.addRow("Slit Width:", self.slit_width_input) - + self.slit_width_input.setPlaceholderText("5") self.slit_offset_input = QLineEdit(self) - self.slit_offset_input.setPlaceholderText("Slit offset") - self.slit_offset_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - slit_form_layout.addRow("Slit Offset:", self.slit_offset_input) - - scroll_area_layout.addLayout(slit_form_layout) - - # Slit Button (Centered) + self.slit_offset_input.setPlaceholderText("3") slit_button = QPushButton("Set Slit", self) - slit_button.setFixedWidth(200) # Set a fixed width for the button + slit_button.clicked.connect(self.set_slit) + slit_button.setFixedHeight(45) + slit_button.setFixedWidth(325) # Half-width button slit_button_layout = QHBoxLayout() slit_button_layout.addWidget(slit_button) - slit_button_layout.setAlignment(slit_button, Qt.AlignCenter) # Center the button - - slit_button.clicked.connect(self.set_slit) - scroll_area_layout.addLayout(slit_button_layout) - - # Camstep Focus Command Input Fields (General) - camstep_form_layout = QFormLayout() - self.focus_value_input = QLineEdit(self) - self.focus_value_input.setPlaceholderText("Focus loop value") - self.focus_value_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - camstep_form_layout.addRow("Focus Value:", self.focus_value_input) - - self.focus_upper_input = QLineEdit(self) - self.focus_upper_input.setPlaceholderText("Upper bound") - self.focus_upper_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - camstep_form_layout.addRow("Upper Bound:", self.focus_upper_input) - - self.focus_lower_input = QLineEdit(self) - self.focus_lower_input.setPlaceholderText("Lower bound") - self.focus_lower_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - camstep_form_layout.addRow("Lower Bound:", self.focus_lower_input) - - self.focus_step_input = QLineEdit(self) - self.focus_step_input.setPlaceholderText("Focus step") - self.focus_step_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - camstep_form_layout.addRow("Focus Step:", self.focus_step_input) - - scroll_area_layout.addLayout(camstep_form_layout) - - # Camstep Focus Button (General) - camstep_button = QPushButton("Camstep Focus (General)", self) - camstep_button.setFixedWidth(200) # Set a fixed width for the button - camstep_button_layout = QHBoxLayout() - camstep_button_layout.addWidget(camstep_button) - camstep_button_layout.setAlignment(camstep_button, Qt.AlignCenter) # Center the button + slit_button_layout.setAlignment(slit_button, Qt.AlignCenter) # Right-align the button + scroll_area_layout.addRow("Slit Width:", self.slit_width_input) + scroll_area_layout.addRow("Slit Offset:", self.slit_offset_input) + scroll_area_layout.addRow(slit_button_layout) - camstep_button.clicked.connect(self.camstep_focus) - scroll_area_layout.addLayout(camstep_button_layout) - - # Camstep Focus Button (ACAM) - camstep_acam_button = QPushButton("Camstep Focus (ACAM)", self) - camstep_acam_button.setFixedWidth(200) # Set a fixed width for the button - camstep_acam_button_layout = QHBoxLayout() - camstep_acam_button_layout.addWidget(camstep_acam_button) - camstep_acam_button_layout.setAlignment(camstep_acam_button, Qt.AlignCenter) # Center the button - - camstep_acam_button.clicked.connect(self.camstep_focus_acam) - scroll_area_layout.addLayout(camstep_acam_button_layout) - - # TCS Set Focus Command Input Fields (tcs setfocus ) - tcs_form_layout = QFormLayout() + # TCS Set Focus Command self.tcs_focus_value_input = QLineEdit(self) self.tcs_focus_value_input.setPlaceholderText("Set TCS focus value") - self.tcs_focus_value_input.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - tcs_form_layout.addRow("TCS Focus Value:", self.tcs_focus_value_input) - scroll_area_layout.addLayout(tcs_form_layout) - - # TCS Set Focus Button (Centered) tcs_button = QPushButton("Set TCS Focus", self) - tcs_button.setFixedWidth(200) # Set a fixed width for the button + tcs_button.clicked.connect(self.set_tcs_focus) + tcs_button.setFixedHeight(45) + tcs_button.setFixedWidth(325) # Half-width button tcs_button_layout = QHBoxLayout() tcs_button_layout.addWidget(tcs_button) - tcs_button_layout.setAlignment(tcs_button, Qt.AlignCenter) # Center the button + tcs_button_layout.setAlignment(tcs_button, Qt.AlignCenter) # Right-align the button + scroll_area_layout.addRow("TCS Focus Value:", self.tcs_focus_value_input) + scroll_area_layout.addRow(tcs_button_layout) + + scroll_area_layout.addRow(divider4) + + # Band of Interest Section + scroll_area_layout.addRow(QLabel("Full BOI")) + # Full BOI Section + full_boi_button = QPushButton("Activate Full BOI R Band", self) + full_boi_button.clicked.connect(self.activate_boi_r_full) + full_boi_button.setFixedHeight(45) + full_boi_button.setFixedWidth(325) # Half-width button + full_boi_button_layout = QHBoxLayout() + full_boi_button_layout.addWidget(full_boi_button) + full_boi_button_layout.setAlignment(full_boi_button, Qt.AlignCenter) + scroll_area_layout.addRow(full_boi_button_layout) + + # Full BOI Section + full_boi_i_button = QPushButton("Activate Full BOI I Band", self) + full_boi_i_button.clicked.connect(self.activate_boi_i_full) + full_boi_i_button.setFixedHeight(45) + full_boi_i_button.setFixedWidth(325) # Half-width button + full_boi_i_button_layout = QHBoxLayout() + full_boi_i_button_layout.addWidget(full_boi_i_button) + full_boi_i_button_layout.setAlignment(full_boi_i_button, Qt.AlignCenter) + scroll_area_layout.addRow(full_boi_i_button_layout) + + # Add a button to run the focus_andor.py command + run_focus_andor_button = QPushButton("Analyze Focus", self) + run_focus_andor_button.clicked.connect(self.run_focus_andor) + run_focus_andor_button.setFixedHeight(45) + run_focus_andor_button.setFixedWidth(325) + run_focus_andor_button_layout = QHBoxLayout() + run_focus_andor_button_layout.addWidget(run_focus_andor_button) + run_focus_andor_button_layout.setAlignment(run_focus_andor_button, Qt.AlignCenter) # Center the button + scroll_area_layout.addRow(run_focus_andor_button_layout) + + # Add a button to spectrograph open images with eog + open_images_button = QPushButton("Open Spectrograph Focus Images", self) + open_images_button.setStyleSheet(""" + QPushButton { + background-color: #007BFF; /* Blue color */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { + background-color: #0056b3; /* Slightly darker blue on hover */ + } + QPushButton:pressed { + background-color: #004085; /* Darker blue when pressed */ + } + """) + open_images_button.clicked.connect(self.open_focus_images) + open_images_button.setFixedHeight(45) + open_images_button.setFixedWidth(325) + open_images_button_layout = QHBoxLayout() + open_images_button_layout.addWidget(open_images_button) + open_images_button_layout.setAlignment(open_images_button, Qt.AlignCenter) # Center the button + scroll_area_layout.addRow(open_images_button_layout) + + # Add a button to acam open images with eog + acam_open_images_button = QPushButton("Open ACAM Focus Images", self) + acam_open_images_button.setStyleSheet(""" + QPushButton { + background-color: #007BFF; /* Blue color */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { + background-color: #0056b3; /* Slightly darker blue on hover */ + } + QPushButton:pressed { + background-color: #004085; /* Darker blue when pressed */ + } + """) + acam_open_images_button.clicked.connect(self.open_focus_acam_images) + acam_open_images_button.setFixedHeight(45) + acam_open_images_button.setFixedWidth(325) + acam_open_images_layout = QHBoxLayout() + acam_open_images_layout.addWidget(acam_open_images_button) + acam_open_images_layout.setAlignment(acam_open_images_button, Qt.AlignCenter) # Center the button + scroll_area_layout.addRow(acam_open_images_layout) - tcs_button.clicked.connect(self.set_tcs_focus) - scroll_area_layout.addLayout(tcs_button_layout) # Set scrollable widget layout scroll_area_widget.setLayout(scroll_area_layout) @@ -218,92 +391,361 @@ def initUI(self): # Set the layout of the main window self.setLayout(main_layout) - def run_command(self, command_list): - """Helper function to run terminal command and handle errors""" - try: - result = subprocess.run(command_list, check=True, text=True, capture_output=True) - print(f"Command output: {result.stdout}") - except subprocess.CalledProcessError as e: - print(f"Command failed with error: {e.stderr}") + # Final stretch to force scrolling if content exceeds visible area + scroll_area_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding) - def activate_boi(self): - channel = self.channel_input.text() - skip_rows = self.skip_worms_input.text() - rows = self.rows_input.text() + def activate_boi_r(self): + # Get the user input or use the placeholder (default values) + channel = self.channel_r_input.text() or self.channel_r_input.placeholderText() + skip_rows = self.skip_rows_r_input.text() or self.skip_rows_r_input.placeholderText() + rows = self.rows_r_input.text() or self.rows_r_input.placeholderText() + + if channel and skip_rows and rows: + command = f"camera boi {channel} {skip_rows} {rows}" + self.run_command_in_background(command) + else: + print("Please provide valid input for channel, rows to skip, and rows to read.") + + def activate_boi_i(self): + # Use placeholder text if no input provided + channel = self.channel_i_input.text() or self.channel_i_input.placeholderText() + skip_rows = self.skip_rows_i_input.text() or self.skip_rows_i_input.placeholderText() + rows = self.rows_i_input.text() or self.rows_i_input.placeholderText() if channel and skip_rows and rows: command = f"camera boi {channel} {skip_rows} {rows}" - self.run_command(command.split()) + self.run_command_in_background(command) else: print("Please provide valid input for channel, rows to skip, and rows to read.") - def activate_boi_full(self): - full_channel = self.full_channel_input.text() + def activate_boi_r_full(self): + command_r = f"camera boi R full" + self.run_command_in_background(command_r) + + def activate_boi_i_full(self): + command_i = f"camera boi I full" + self.run_command_in_background(command_i) + + def activate_row_bin(self): + # Use placeholder text if no input provided + axis = self.axis_input_row.text() or self.axis_input_row.placeholderText() + binfactor = self.binfactor_input_row.text() or self.binfactor_input_row.placeholderText() - if full_channel: - command = f"camera boi {full_channel} full" - self.run_command(command.split()) + if axis and binfactor: + command = f"camera bin {axis} {binfactor}" + self.run_command_in_background(command) else: - print("Please provide a valid input for the channel.") + print("Please provide valid input for axis and bin factor.") - def activate_bin(self): - axis = self.axis_input.text() - binfactor = self.binfactor_input.text() + def activate_col_bin(self): + # Use placeholder text if no input provided + axis = self.axis_input_col.text() or self.axis_input_col.placeholderText() + binfactor = self.binfactor_input_col.text() or self.binfactor_input_col.placeholderText() if axis and binfactor: command = f"camera bin {axis} {binfactor}" - self.run_command(command.split()) + self.run_command_in_background(command) else: print("Please provide valid input for axis and bin factor.") def set_exptime(self): - exptime = self.exptime_input.text() + # Use placeholder text if no input provided + exptime = self.exptime_input.text() or self.exptime_input.placeholderText() if exptime: command = f"camera exptime {exptime}" - self.run_command(command.split()) + self.run_command_in_background(command) else: print("Please provide a valid input for exposure time.") def set_slit(self): - width = self.slit_width_input.text() - offset = self.slit_offset_input.text() + # Use placeholder text if no input provided + width = self.slit_width_input.text() or self.slit_width_input.placeholderText() + offset = self.slit_offset_input.text() or self.slit_offset_input.placeholderText() if width and offset: command = f"slit set {width} {offset}" - self.run_command(command.split()) + self.run_command_in_background(command) else: print("Please provide valid input for slit width and offset.") def camstep_focus(self): - value = self.focus_value_input.text() - upper = self.focus_upper_input.text() - lower = self.focus_lower_input.text() - step = self.focus_step_input.text() + # Use placeholder text if no input provided + value = self.focus_value_input.text() or self.focus_value_input.placeholderText() + upper = self.focus_upper_input.text() or self.focus_upper_input.placeholderText() + lower = self.focus_lower_input.text() or self.focus_lower_input.placeholderText() + step = self.focus_step_input.text() or self.focus_step_input.placeholderText() if value and upper and lower and step: - command = f"camstep focus all focusloop {value} {upper} {lower} {step}" - self.run_command(command.split()) + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + label = f"focusloop_{timestamp}" + command = f"camstep focus all {label} {value} {upper} {lower} {step}" + self.run_command_in_background(command) else: print("Please provide valid input for focus loop parameters.") def camstep_focus_acam(self): - value = self.focus_value_input.text() - upper = self.focus_upper_input.text() - lower = self.focus_lower_input.text() - step = self.focus_step_input.text() + # Use placeholder text if no input provided + value = self.focus_value_input.text() or self.focus_value_input.placeholderText() + upper = self.focus_upper_input.text() or self.focus_upper_input.placeholderText() + lower = self.focus_lower_input.text() or self.focus_lower_input.placeholderText() + step = self.focus_step_input.text() or self.focus_step_input.placeholderText() if value and upper and lower and step: - command = f"camstep focus acam focusloop {value} {upper} {lower} {step}" - self.run_command(command.split()) + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + label = f"focusloop_{timestamp}" + command = f"camstep focus acam {label} {value} {upper} {lower} {step}" + self.run_command_in_background(command) else: print("Please provide valid input for ACAM focus loop parameters.") def set_tcs_focus(self): - value = self.tcs_focus_value_input.text() + # Use placeholder text if no input provided + value = self.tcs_focus_value_input.text() or self.tcs_focus_value_input.placeholderText() if value: command = f"tcs setfocus {value}" - self.run_command(command.split()) + self.run_command_in_background(command) else: print("Please provide a valid input for the TCS focus.") + + def set_basename(self, basename="ngps"): + """Set the basename.""" + if basename: + command = f"camera basename {basename}" + self.run_command_in_background(command) + + def run_focus_andor(self): + """Run the focus_andor.py script with specified arguments.""" + command = "bash calib/andor.sh" + self.run_command_in_background(command) + + def open_focus_images(self): + """Run the exact eog command to open images.""" + command = "eog /home/observer/focus/focus_spec_I.png /home/observer/focus/focus_spec_R.png" + + try: + # Run the command exactly as it is + subprocess.run(command, shell=True, check=True) + print("Command executed successfully.") + except subprocess.CalledProcessError as e: + print(f"Error occurred: {e.stderr}") + + def open_focus_acam_images(self): + """Run the exact eog command to open images.""" + command = "eog /home/observer/focus/focus_andor_FWHM_acam.png" + + try: + # Run the command exactly as it is + subprocess.run(command, shell=True, check=True) + print("Command executed successfully.") + except subprocess.CalledProcessError as e: + print(f"Error occurred: {e.stderr}") + + # Event handler methods for R and I bands + def run_focus(self): + # This method will run all the other buttons when clicked + print("Running Focus...") + + + self.run_focus_button.setEnabled(False) + self.run_focus_button.setStyleSheet(""" + QPushButton { + background-color: lightgray; + } + """) + + # Set basename + command = f"camera basename focus" + self.run_command(command) + # Activate BOI R + channel = self.channel_r_input.text() or self.channel_r_input.placeholderText() + skip_rows = self.skip_rows_r_input.text() or self.skip_rows_r_input.placeholderText() + rows = self.rows_r_input.text() or self.rows_r_input.placeholderText() + + if channel and skip_rows and rows: + command = f"camera boi {channel} {skip_rows} {rows}" + self.run_command(command) + else: + print("Please provide valid input for channel, rows to skip, and rows to read.") + + # Activate BOI I + channel = self.channel_i_input.text() or self.channel_i_input.placeholderText() + skip_rows = self.skip_rows_i_input.text() or self.skip_rows_i_input.placeholderText() + rows = self.rows_i_input.text() or self.rows_i_input.placeholderText() + + if channel and skip_rows and rows: + command = f"camera boi {channel} {skip_rows} {rows}" + self.run_command(command) + + # Activate Row BIN + axis = self.axis_input_row.text() or self.axis_input_row.placeholderText() + binfactor = self.binfactor_input_row.text() or self.binfactor_input_row.placeholderText() + + if axis and binfactor: + command = f"camera bin {axis} {binfactor}" + self.run_command(command) + else: + print("Please provide valid input for axis and bin factor.") + + # Activate COL BIN + axis = self.axis_input_col.text() or self.axis_input_col.placeholderText() + binfactor = self.binfactor_input_col.text() or self.binfactor_input_col.placeholderText() + + if axis and binfactor: + command = f"camera bin {axis} {binfactor}" + self.run_command(command) + else: + print("Please provide valid input for axis and bin factor.") + + # Set EXPTime + exptime = self.exptime_input.text() or self.exptime_input.placeholderText() + + if exptime: + command = f"camera exptime {exptime}" + self.run_command(command) + else: + print("Please provide a valid input for exposure time.") + + # Set slitwidth + width = self.slit_width_input.text() or self.slit_width_input.placeholderText() + offset = self.slit_offset_input.text() or self.slit_offset_input.placeholderText() + + if width and offset: + command = f"slit set {width} {offset}" + self.run_command(command) + else: + print("Please provide valid input for slit width and offset.") + + + # Set CAM focus CAMSTEP + value = self.focus_value_input.text() or self.focus_value_input.placeholderText() + upper = self.focus_upper_input.text() or self.focus_upper_input.placeholderText() + lower = self.focus_lower_input.text() or self.focus_lower_input.placeholderText() + step = self.focus_step_input.text() or self.focus_step_input.placeholderText() + + if value and upper and lower and step: + command = f"camstep focus all focusloop {value} {upper} {lower} {step}" + self.run_command(command) + else: + print("Please provide valid input for focus loop parameters.") + + + # Set CAM focus ACAM + value = self.focus_value_input.text() or self.focus_value_input.placeholderText() + upper = self.focus_upper_input.text() or self.focus_upper_input.placeholderText() + lower = self.focus_lower_input.text() or self.focus_lower_input.placeholderText() + step = self.focus_step_input.text() or self.focus_step_input.placeholderText() + + if value and upper and lower and step: + command = f"camstep focus acam focusloop {value} {upper} {lower} {step}" + self.run_command(command) + else: + print("Please provide valid input for ACAM focus loop parameters.") + + + command = f"camera basename ngps" + self.run_command(command) + + command_r = f"camera boi R full" + self.run_command(command_r) + + command_i = f"camera boi I full" + self.run_command(command_i) + + # Run the focus_andor.py script with specified arguments. + command = "bash calib/andor.sh" + self.run_command(command) + + self.open_focus_images() + + self.run_focus_button.setEnabled(True) + self.run_focus_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green color */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { + background-color: #45a049; /* Slightly darker green on hover */ + } + QPushButton:pressed { + background-color: #3e8e41; /* Darker green when pressed */ + } + """) + + def run_focus_acam(self): + # This method will run all the other buttons when clicked + print("Running Focus ACAM...") + self.run_acam_focus_button.setEnabled(False) + self.run_acam_focus_button.setStyleSheet(""" + QPushButton { + background-color: lightgray; + } + """) + + command = f"camera basename focus" + self.run_command(command) + + # Set CAM focus ACAM + value = self.focus_value_input.text() or self.focus_value_input.placeholderText() + upper = self.focus_upper_input.text() or self.focus_upper_input.placeholderText() + lower = self.focus_lower_input.text() or self.focus_lower_input.placeholderText() + step = self.focus_step_input.text() or self.focus_step_input.placeholderText() + + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + label = f"focusloop_{timestamp}" + + if value and upper and lower and step: + command = f"camstep focus acam {label} {value} {upper} {lower} {step}" + self.run_command(command) + else: + print("Please provide valid input for ACAM focus loop parameters.") + + + command = f"camera basename ngps" + self.run_command(command) + + # Run the focus_andor.py script with specified arguments. + command = f"bash calib/andor.sh {label}" + self.run_command(command) + + self.open_focus_acam_images() + + self.run_acam_focus_button.setEnabled(True) + self.run_acam_focus_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green color */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { + background-color: #45a049; /* Slightly darker green on hover */ + } + QPushButton:pressed { + background-color: #3e8e41; /* Darker green when pressed */ + } + """) + + def run_command_in_background(self, command): + """Run the command in a background thread.""" + self.thread = AsyncCommandThread(command, self.log_message_callback) + self.thread.output_signal.connect(self.log_message_callback) + self.thread.start() + + def run_command(self, command): + """Run command.""" + self.output_signal.connect(self.log_message_callback) + + try: + # Run the command exactly as it is + result = subprocess.run(command, shell=True, check=True) + #self.output_signal.emit(result) + print("Command executed successfully.") + except subprocess.CalledProcessError as e: + print(f"Error occurred: {e.stderr}") + #self.output_signal.emit(e.stderr) diff --git a/pygui/calib/thrufocus b/pygui/calib/thrufocus index 16e4d36e..2a1bb0e9 100755 --- a/pygui/calib/thrufocus +++ b/pygui/calib/thrufocus @@ -29,9 +29,16 @@ camera exptime 10000 # # 3. Camera BOI -camera boi R 420 200 +camera boi R 410 200 camera boi I 580 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.4 4.5 4.6 4.7 4.8 4.9 5.0 5.1 5.2 5.3 5.4 5.5 ) @@ -46,8 +53,8 @@ for fp in $focpos; do focus set R ${fociR[$fp]} echo sleep 4 - - exposen 1 + imnum1=(`camera imnum`[1]) + exposen 1 done # # Turn off lamp @@ -59,6 +66,8 @@ done # +camera basename $basename + power lampthar off calib lampmod 6 0 1000 calib set door=close @@ -67,4 +76,16 @@ calib set door=close camera boi R full camera boi I full +# 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 --range $imnum0 $imnum1 + + +eog focus_spec_*.png + echo "Done." diff --git a/pygui/calib/thrufocus_old b/pygui/calib/thrufocus_old new file mode 100755 index 00000000..16e4d36e --- /dev/null +++ b/pygui/calib/thrufocus_old @@ -0,0 +1,70 @@ +#!/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 420 200 +camera boi I 580 200 +# + +# 4. Turn on lamp and wait +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 ) +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]} No `camera imnum`" + focus set I ${fociI[$fp]} + focus set R ${fociR[$fp]} + echo + sleep 4 + + exposen 1 +done +# +# Turn off lamp +# power 2 3 off +# +# ./goexpose +# ./goexpose +# ./goexpose +# + + +power lampthar off +calib lampmod 6 0 1000 +calib set door=close + +# revert BOI +camera boi R full +camera boi I full + +echo "Done." diff --git a/pygui/config/sequencer_config.ini b/pygui/config/sequencer_config.ini new file mode 100644 index 00000000..984e1147 --- /dev/null +++ b/pygui/config/sequencer_config.ini @@ -0,0 +1,8 @@ +SERVERNAME=localhost +INSTRUMENT_NAME=NGPS +COMMAND_SERVERPORT=8000 +BLOCKING_SERVERPORT=9000 +ASYNC_HOST=239.1.1.234 +ASYNC_SERVERPORT=1300 +BASENAME=ngps_image +LOG_DIRECTORY=/data/logs diff --git a/pygui/control_tab.py b/pygui/control_tab.py index 823e2044..7758a06b 100644 --- a/pygui/control_tab.py +++ b/pygui/control_tab.py @@ -1,133 +1,192 @@ -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QFrame, QMessageBox +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, + QLineEdit, QFrame, QMessageBox, QDialog +) from PyQt5.QtCore import Qt, QTimer from PyQt5.QtMultimedia import QSound from logic_service import LogicService -import astropy.units as u import subprocess -class ControlTab(QWidget): + +class ControlTab(QDialog): def __init__(self, parent): super().__init__(parent) self.parent = parent + + # state flags + self.is_paused = False + + # build UI self.create_control_tab() + + # services self.logic_service = LogicService(self.parent) - def create_control_tab(self): - # Create the main layout for the Control tab - control_layout = QVBoxLayout() + # ----------------------------- + # Style helpers (centralized) + # ----------------------------- + def _style_enabled_green(self, btn: QPushButton): + btn.setEnabled(True) + btn.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; + color: white; + font-weight: bold; + padding: 10px; + border: none; + border-radius: 5px; + } + QPushButton:hover { background-color: #388E3C; } + QPushButton:pressed { background-color: #2C6B2F; } + """) + + def _style_disabled_gray(self, btn: QPushButton): + btn.setEnabled(False) + btn.setStyleSheet(""" + QPushButton { + background-color: #D3D3D3; + color: black; + font-weight: bold; + padding: 10px; + border: none; + border-radius: 5px; + } + """) - # Row 1: Target Name Label and Refresh Button - row1_widget = self.create_row1() - control_layout.addWidget(row1_widget) + def _style_black(self, btn: QPushButton): + btn.setEnabled(True) + btn.setStyleSheet(""" + QPushButton { + background-color: #000000; + border: none; + color: white; + font-weight: bold; + } + QPushButton:hover { background-color: #333333; } + QPushButton:pressed { background-color: #555555; } + """) - # Add a thin gray line between rows - self.add_separator_line(control_layout) + def _apply_startup_style(self): + self.startup_shutdown_button.setText("Startup") + self._style_enabled_green(self.startup_shutdown_button) - # Row 2: Exposure Time, Slit Width, and Confirm Button - row2_widget = self.create_row2() - control_layout.addWidget(row2_widget) + def _apply_shutdown_style(self): + self.startup_shutdown_button.setText("Shutdown") + self._style_black(self.startup_shutdown_button) - # Add a thin gray line between rows - self.add_separator_line(control_layout) + # ----------------------------- + # UI construction + # ----------------------------- + def create_control_tab(self): + control_layout = QVBoxLayout() - # Row 3: Go Button - row3_widget = self.create_row3() - control_layout.addWidget(row3_widget) + control_layout.addWidget(self.create_row1()) + self.add_separator_line(control_layout) - # Add a thin gray line between rows + control_layout.addWidget(self.create_row2()) self.add_separator_line(control_layout) - # Row 4: Pause, Stop Now, and Expose Buttons - row4_widget = self.create_row4() - control_layout.addWidget(row4_widget) + control_layout.addWidget(self.create_row3()) + self.add_separator_line(control_layout) - # Add a thin gray line between rows + control_layout.addWidget(self.create_row4()) self.add_separator_line(control_layout) - # Row 5: Binning, Headers, Display, Temp, Lamps, and Startup Buttons - row5_widget = self.create_row5() - control_layout.addWidget(row5_widget) + control_layout.addWidget(self.create_row5()) - # Set the layout for the control tab self.setLayout(control_layout) - - # Connect the input fields to methods for handling changes self.connect_input_fields() def create_row1(self): - """Create Row 1 layout with Target Name Label and Refresh Button""" - row1_layout = QHBoxLayout() + """Row 1: Target Name & RA/Dec (stacked)""" + row1_layout = QVBoxLayout() row1_layout.setContentsMargins(0, 0, 0, 0) - # Create the QLabel with default text self.target_name_label = QLabel("Selected Target: Not Selected") self.target_name_label.setAlignment(Qt.AlignCenter) + self.ra_dec_label = QLabel("RA: Not Set, Dec: Not Set") + self.ra_dec_label.setAlignment(Qt.AlignCenter) + row1_layout.addWidget(self.target_name_label) + row1_layout.addWidget(self.ra_dec_label) row1_widget = QWidget() row1_widget.setLayout(row1_layout) return row1_widget def create_row2(self): - """Create Row 2 layout with Exposure Time, Slit Width, and Confirm Button""" + """Row 2: Exposure, Slit Width/Angle, Binning, #Exposures, Confirm""" row2_layout = QVBoxLayout() row2_layout.setContentsMargins(0, 0, 0, 0) row2_layout.setSpacing(5) - # Exposure Time and Slit Width fields + # Exposure time self.exposure_time_label = QLabel("Exposure Time:") self.exposure_time_box = QLineEdit() self.exposure_time_box.setPlaceholderText("Enter Exposure Time") self.exposure_time_box.setFixedWidth(120) - exposure_time_layout = QHBoxLayout() exposure_time_layout.addWidget(self.exposure_time_label) exposure_time_layout.addWidget(self.exposure_time_box) + # Slit width self.slit_width_label = QLabel("Slit Width:") self.slit_width_box = QLineEdit() self.slit_width_box.setPlaceholderText("Enter Slit Width") self.slit_width_box.setFixedWidth(120) - slit_width_layout = QHBoxLayout() slit_width_layout.addWidget(self.slit_width_label) slit_width_layout.addWidget(self.slit_width_box) + # Slit angle self.slit_angle_label = QLabel("Slit Angle:") self.slit_angle_box = QLineEdit() - self.slit_angle_box.setPlaceholderText("Enter Slit Width") + self.slit_angle_box.setPlaceholderText("Enter Slit Angle") self.slit_angle_box.setFixedWidth(120) - slit_angle_layout = QHBoxLayout() slit_angle_layout.addWidget(self.slit_angle_label) slit_angle_layout.addWidget(self.slit_angle_box) - + # Binning (spectral/spatial) + self.bin_spect_label = QLabel("Bin Spectral:") + self.bin_spect_box = QLineEdit() + self.bin_spect_box.setPlaceholderText("Enter Value") + self.bin_spect_box.setFixedWidth(120) + + self.bin_spat_label = QLabel("Bin Spatial:") + self.bin_spat_box = QLineEdit() + self.bin_spat_box.setPlaceholderText("Enter Value") + self.bin_spat_box.setFixedWidth(120) + + bin_spect_layout = QHBoxLayout() + bin_spect_layout.addWidget(self.bin_spect_label) + bin_spect_layout.addWidget(self.bin_spect_box) + bin_spat_layout = QHBoxLayout() + bin_spat_layout.addWidget(self.bin_spat_label) + bin_spat_layout.addWidget(self.bin_spat_box) + + # Number of exposures + self.num_of_exposures_label = QLabel("Number of Exposures:") + self.num_of_exposures_box = QLineEdit() + self.num_of_exposures_box.setPlaceholderText("Enter Number of Exposures") + self.num_of_exposures_box.setFixedWidth(120) + num_of_exposures_layout = QHBoxLayout() + num_of_exposures_layout.addWidget(self.num_of_exposures_label) + num_of_exposures_layout.addWidget(self.num_of_exposures_box) + + # Assemble row2_layout.addLayout(exposure_time_layout) row2_layout.addLayout(slit_width_layout) row2_layout.addLayout(slit_angle_layout) + row2_layout.addLayout(bin_spat_layout) + row2_layout.addLayout(bin_spect_layout) + row2_layout.addLayout(num_of_exposures_layout) - # Confirm Button + # Confirm button self.confirm_button = QPushButton("Confirm Changes") - self.confirm_button.setEnabled(False) + self._style_disabled_gray(self.confirm_button) self.confirm_button.clicked.connect(self.on_confirm_changes) - self.confirm_button.setStyleSheet(""" - QPushButton { - background-color: #D3D3D3; /* Light gray when disabled */ - color: black; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; /* Optional: Round corners */ - } - QPushButton:hover { - background-color: #D3D3D3; /* No hover effect when disabled */ - } - QPushButton:pressed { - background-color: #D3D3D3; /* No pressed effect when disabled */ - } - """) row2_layout.addWidget(self.confirm_button) row2_widget = QWidget() @@ -135,26 +194,23 @@ def create_row2(self): return row2_widget def create_row3(self): - """Create Row 3 layout with Go Button and Offset To Target Button""" + """Row 3: Go / Offset / Expose""" row3_layout = QHBoxLayout() row3_layout.setContentsMargins(0, 0, 0, 0) row3_layout.setSpacing(10) - # Go Button self.go_button = QPushButton("Go") self.go_button.clicked.connect(self.on_go_button_click) - self.go_button.setEnabled(False) + self._style_disabled_gray(self.go_button) - # Offset To Target Button self.offset_to_target_button = QPushButton("Offset") self.offset_to_target_button.clicked.connect(self.on_offset_to_target_click) - self.offset_to_target_button.setEnabled(False) + self._style_disabled_gray(self.offset_to_target_button) - # Continue Target Button - self.continue_button = QPushButton("Continue") + self.continue_button = QPushButton("Expose") self.continue_button.clicked.connect(self.on_continue_button_click) - self.continue_button.setEnabled(False) - + self._style_disabled_gray(self.continue_button) + row3_layout.addWidget(self.go_button) row3_layout.addWidget(self.offset_to_target_button) row3_layout.addWidget(self.continue_button) @@ -164,17 +220,15 @@ def create_row3(self): return row3_widget def create_row4(self): - """Create Row 4 layout with Pause, Stop Now, and Expose Buttons""" + """Row 4: Repeat / Pause / Stop Now / Abort""" row4_layout = QHBoxLayout() row4_layout.setSpacing(10) - # Buttons self.repeat_button = QPushButton("Repeat") self.pause_button = QPushButton("Pause") self.abort_button = QPushButton("Abort") self.stop_now_button = QPushButton("Stop Now") - # Connect the buttons to their corresponding slots self.repeat_button.clicked.connect(self.on_repeat_button_click) self.pause_button.clicked.connect(self.on_pause_button_click) self.abort_button.clicked.connect(self.on_abort_button_click) @@ -189,68 +243,41 @@ def create_row4(self): row4_widget.setLayout(row4_layout) return row4_widget - def on_repeat_button_click(self): - """Handle Stop Now button click.""" - print("Repeating now...") - command = f"repeat\n" - self.parent.send_command(command) - - def on_pause_button_click(self): - """Toggle between Pause and Resume when the Pause button is clicked.""" - if hasattr(self, 'is_paused') and self.is_paused: - # If currently paused, resume and change button text to "Pause" - self.is_paused = False - self.pause_button.setText("Pause") - print("Resuming action...") - command = f"resume\n" - self.parent.send_command(command) - else: - # If not paused, pause and change button text to "Resume" - self.is_paused = True - self.pause_button.setText("Resume") - print("Pausing action...") - command = f"pause\n" - self.parent.send_command(command) - - def on_stop_now_button_click(self): - """Handle Stop Now button click.""" - print("Stopping now...") - command = f"stop\n" - self.parent.send_command(command) - def create_row5(self): - """Create Row 5 layout with Binning, Headers, Display, Temp, Lamps, and Startup Buttons""" + """Row 5: Binning / Headers & Calibration / Reset & Startup""" row5_layout = QHBoxLayout() row5_layout.setSpacing(10) - # Create vertical layouts for each pair of buttons binning_layout = QVBoxLayout() - headers_layout = QVBoxLayout() display_layout = QVBoxLayout() - temp_layout = QVBoxLayout() lamps_layout = QVBoxLayout() - # Create the buttons self.binning_button = QPushButton("Binning") + + self.etc_button = QPushButton("Run ETC") + self.etc_button.clicked.connect(self.parent.open_etc_popup) + self.headers_button = QPushButton("Headers") - self.display_button = QPushButton("Display") - self.temp_button = QPushButton("Temp") - self.startup_button = QPushButton("Lamps") - self.shutdown_button = QPushButton("Reset") - self.startup_button.clicked.connect(self.on_lamps_button_click) - self.shutdown_button.clicked.connect(self.on_reset_button_click) - - # Add buttons to each vertical layout + + self.calibration_button = QPushButton("Calibration") + self.calibration_button.clicked.connect(self.parent.open_calibration_gui) + + 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.headers_button) + binning_layout.addWidget(self.etc_button) + + display_layout.addWidget(self.headers_button) + display_layout.addWidget(self.calibration_button) - display_layout.addWidget(self.display_button) - display_layout.addWidget(self.temp_button) + lamps_layout.addWidget(self.reset_button) - lamps_layout.addWidget(self.startup_button) - lamps_layout.addWidget(self.shutdown_button) + self.startup_shutdown_button = QPushButton("Startup") + self._apply_startup_style() + self.startup_shutdown_button.clicked.connect(self.toggle_startup_shutdown) + lamps_layout.addWidget(self.startup_shutdown_button) - # Add the vertical layouts to the main row layout row5_layout.addLayout(binning_layout) row5_layout.addLayout(display_layout) row5_layout.addLayout(lamps_layout) @@ -259,9 +286,11 @@ def create_row5(self): row5_widget.setLayout(row5_layout) return row5_widget - + # ----------------------------- + # Utility wiring + # ----------------------------- def add_separator_line(self, layout): - """ Helper method to add a thin light gray line (separator) between rows. """ + """Thin divider line between rows.""" separator = QFrame() separator.setFrameShape(QFrame.HLine) separator.setFrameShadow(QFrame.Sunken) @@ -270,388 +299,235 @@ def add_separator_line(self, layout): layout.addWidget(separator) def connect_input_fields(self): - """Connect input fields (Exposure Time and Slit Width) to change methods""" + """Enable Confirm when fields change.""" self.exposure_time_box.textChanged.connect(self.on_input_changed) self.slit_width_box.textChanged.connect(self.on_input_changed) self.slit_angle_box.textChanged.connect(self.on_input_changed) + self.num_of_exposures_box.textChanged.connect(self.on_input_changed) + self.bin_spect_box.textChanged.connect(self.on_input_changed) + self.bin_spat_box.textChanged.connect(self.on_input_changed) - def on_continue_button_click(self): - """Handle the 'Expose' button click and check for 'USER' in command output""" - print("On Continue button clicked!") - - # Send the usercontinue command - command = f"usercontinue\n" - self.parent.send_command(command) - - try: - # Running the 'seq state' command and capturing its output - result = subprocess.check_output(['seq', 'state'], text=True) - print(result) # Optional: print the output for debugging - - # Check if 'USER' is in the output - if 'USER' in result: - # Enable button if 'USER' is found - self.continue_button.setEnabled(True) - self.continue_button.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; /* Green when enabled */ - color: white; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; - } - QPushButton:hover { - background-color: #45a049; - } - QPushButton:pressed { - background-color: #3e8e41; - } - """) - else: - # Disable button if 'USER' is not found - self.continue_button.setEnabled(False) - self.continue_button.setStyleSheet(""" - QPushButton { - background-color: #D3D3D3; /* Light gray when disabled */ - color: black; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; - } - QPushButton:hover { - background-color: #D3D3D3; /* No hover effect when disabled */ - } - QPushButton:pressed { - background-color: #D3D3D3; /* No pressed effect when disabled */ - } - """) - except subprocess.CalledProcessError as e: - print(f"Error running command: {e}") - # Disable button in case of an error - self.continue_button.setEnabled(False) - self.continue_button.setStyleSheet(""" - QPushButton { - background-color: #D3D3D3; /* Light gray when disabled */ - color: black; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; - } - QPushButton:hover { - background-color: #D3D3D3; /* No hover effect when disabled */ - } - QPushButton:pressed { - background-color: #D3D3D3; /* No pressed effect when disabled */ - } - """) - - def on_abort_button_click(self): - """Handle the 'Expose' button click""" - print("Abort button clicked!") - command = f"abort\n" - self.parent.send_command(command) - self.continue_button.setEnabled(False) - self.continue_button.setStyleSheet(""" - QPushButton { - background-color: #D3D3D3; /* Light gray when disabled */ - color: black; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; /* Optional: Round corners */ - } - QPushButton:hover { - background-color: #D3D3D3; /* No hover effect when disabled */ - } - QPushButton:pressed { - background-color: #D3D3D3; /* No pressed effect when disabled */ - } - """) + # ----------------------------- + # Button slots / actions + # ----------------------------- + def on_repeat_button_click(self): + print("Repeating now...") + self.parent.send_command("repeat\n") - def on_lamps_button_click(self): - """Handle the 'Startup' button click""" - print("Startup button clicked!") - # command = f"startup\n" - # self.parent.send_command(command) + def on_pause_button_click(self): + if self.is_paused: + self.is_paused = False + self.pause_button.setText("Pause") + print("Resuming action...") + self.parent.send_command("resume\n") + else: + self.is_paused = True + self.pause_button.setText("Resume") + print("Pausing action...") + self.parent.send_command("pause\n") + self.parent.layout_service.update_system_status("paused") + + def on_stop_now_button_click(self): + print("Stopping now...") + self.parent.send_command("stop\n") + self._style_disabled_gray(self.go_button) + self._style_disabled_gray(self.offset_to_target_button) + self._style_disabled_gray(self.continue_button) def on_reset_button_click(self): - """Handle the 'Startup' button click""" print("Reset button clicked!") self.logic_service.refresh_table() + def toggle_startup_shutdown(self): + current_text = self.startup_shutdown_button.text() + if current_text == "Startup": + self._apply_shutdown_style() + print("Startup button clicked!") + self.parent.send_command("startup\n") + else: + confirm = QMessageBox.question( + self, + "Confirm System Shutdown", + "Are you sure you want to shut down the system?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if confirm == QMessageBox.Yes: + self._apply_startup_style() + print("Shutdown button clicked!") + self.parent.send_command("shutdown\n") + else: + print("Shutdown canceled.") + def on_offset_to_target_click(self): - """Handle the Offset To Target button click event""" print("Offset To Target button clicked!") - command = f"targetoffset\n" - print(f"Sending command to SequencerService: {command}") # Print the command being sent - # Call send_command method from SequencerService - self.parent.send_command(command) - self.offset_to_target_button.setEnabled(False) - self.offset_to_target_button.setStyleSheet(""" - QPushButton { - background-color: #D3D3D3; /* Light gray when disabled */ - color: black; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; /* Optional: Round corners */ - } - QPushButton:hover { - background-color: #D3D3D3; /* No hover effect when disabled */ - } - QPushButton:pressed { - background-color: #D3D3D3; /* No pressed effect when disabled */ - } - """) + cmd = "targetoffset\n" + print(f"Sending command to SequencerService: {cmd}") + self.parent.send_command(cmd) + self._style_disabled_gray(self.offset_to_target_button) def on_input_changed(self): - """Enable the Confirm button when the user modifies input fields""" - self.confirm_button.setEnabled(True) - self.confirm_button.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; /* Green when enabled */ - color: white; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; - } - QPushButton:hover { - background-color: #388E3C; /* Darker green when hovered */ - } - QPushButton:pressed { - background-color: #2C6B2F; /* Even darker green when pressed */ - } - """) + self._style_enabled_green(self.confirm_button) def on_go_button_click(self): - """Slot to handle 'Go' button click and send the target command.""" + """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 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.go_button.setEnabled(False) - # Disable the button immediately after the user clicks it - self.go_button.setStyleSheet(""" - QPushButton { - background-color: #D3D3D3; /* Light gray when disabled */ - color: black; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; /* Optional: Round corners */ - } - QPushButton:hover { - background-color: #D3D3D3; /* No hover effect when disabled */ - } - QPushButton:pressed { - background-color: #D3D3D3; /* No pressed effect when disabled */ - } - """) - - # Show a popup message + self._style_disabled_gray(self.go_button) + self.logic_service.set_active_target(observation_id) self.show_waiting_popup() - self.offset_to_target_button.setEnabled(True) - self.offset_to_target_button.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; /* Green when enabled */ - color: white; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; - } - QPushButton:hover { - background-color: #388E3C; /* Darker green when hovered */ - } - QPushButton:pressed { - background-color: #2C6B2F; /* Even darker green when pressed */ - } - """) - self.continue_button.setEnabled(True) - self.continue_button.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; /* Green when enabled */ - color: white; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; - } - QPushButton:hover { - background-color: #388E3C; /* Darker green when hovered */ - } - QPushButton:pressed { - background-color: #2C6B2F; /* Even darker green when pressed */ - } - """) - - # Start a QTimer to re-enable the button after 60 seconds - # self.timer = QTimer(self) - # self.timer.setSingleShot(True) # Ensure the timer only runs once - # self.timer.timeout.connect(self.enable_go_button) - # self.timer.start(60000) # Timeout after 60 seconds (60000 ms) - else: print("No observation ID available.") + 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.""" + """Show a popup message with a 'Close' button (auto-closes after 5s).""" msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Information) msg_box.setText("Waiting for TCS Operator...") msg_box.setWindowTitle("Information") - - # Add an "Ok" button (or "Close" if you prefer) msg_box.setStandardButtons(QMessageBox.Ok) - - # Set a QTimer to close the message box after 5 seconds, but keep the "Ok" button available QTimer.singleShot(5000, msg_box.close) - - # Execute the message box, the user can close it manually by clicking "Ok" msg_box.exec_() - + def enable_go_button(self): - """Method to re-enable the 'Go' button after 60 seconds.""" - print("60 seconds have passed. Re-enabling 'Go' button.") - - # Re-enable the button and reset its appearance - self.go_button.setEnabled(True) - self.go_button.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; /* Green when enabled */ - color: white; - font-weight: bold; - padding: 10px; - border: none; - border-radius: 5px; /* Optional: Round corners */ - } - QPushButton:hover { - background-color: #388E3C; /* Darker green when hovered */ - } - QPushButton:pressed { - background-color: #2C6B2F; /* Even darker green when pressed */ - } - """) - + """Re-enable 'Go' button (kept for external timer hooks if you re-enable later).""" + print("Re-enabling 'Go' button.") + self._style_enabled_green(self.go_button) + def send_target_command(self, observation_id): - """ Method to send the command to the SequencerService """ if observation_id: - # Build the command string command = f"startone {observation_id}\n" - print(f"Sending command to SequencerService: {command}") # Print the command being sent - # Call send_command method from SequencerService + print(f"Sending command to SequencerService: {command}") self.parent.send_command(command) - print(f"Command sent: {command}") # Print confirmation of command sent + print(f"Command sent: {command}") else: - print("No OBSERVATION_ID to send the command.") # Print if no observation ID is found + 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!") + self.offset_to_target_button.setEnabled(False) + self.parent.zmq_status_service.subscribe_to_topic("slitd") + self.parent.send_command("usercontinue\n") + + try: + result = subprocess.check_output(['seq', 'state'], text=True) + print(result) + if 'USER' in result: + self._style_enabled_green(self.continue_button) + else: + self._style_disabled_gray(self.continue_button) + except subprocess.CalledProcessError as e: + print(f"Error running command: {e}") + self._style_disabled_gray(self.continue_button) + def on_abort_button_click(self): + """Abort sequence; re-enable/disable buttons appropriately.""" + print("Abort button clicked!") + self.parent.send_command("abort\n") + self._style_enabled_green(self.go_button) + self._style_disabled_gray(self.offset_to_target_button) + self._style_disabled_gray(self.continue_button) + + # ----------------------------- + # DB update helpers (unchanged logic) + # ----------------------------- def on_confirm_changes(self): - """Handle the confirmation of changes made to the input fields""" + """Confirm input changes and push updates; also enables Go button.""" exposure_time = self.exposure_time_box.text() slit_width = self.slit_width_box.text() slit_angle = self.slit_angle_box.text() - - if exposure_time and slit_width and slit_angle: - # Handle the confirmed changes, e.g., update internal state or UI - print(f"Confirmed Exposure Time: {exposure_time}, Slit Width: {slit_width}, Slit Angle: {slit_angle}") + num_of_exposures = self.num_of_exposures_box.text() + bin_spect = self.bin_spect_box.text() + bin_spat = self.bin_spat_box.text() + + if exposure_time and slit_width and slit_angle and num_of_exposures and bin_spect and bin_spat: + print(f"Confirmed Exposure Time: {exposure_time}, Slit Width: {slit_width}, " + f"Slit Angle: {slit_angle}, Number of Exposures: {num_of_exposures}") self.on_exposure_time_changed() self.on_slit_width_changed() - self.on_slit_angle_changed() # Handle slit angle change as well - - # Disable the button again after confirmation - self.confirm_button.setEnabled(False) - self.confirm_button.setStyleSheet(""" - QPushButton { - background-color: lightgray; - } - """) + self.on_slit_angle_changed() + self.num_of_exposures_changed() + self.bin_spect_changed() + self.bin_spat_changed() + self._style_disabled_gray(self.confirm_button) + elif exposure_time and slit_width: - # Handle the confirmed changes, e.g., update internal state or UI print(f"Confirmed Exposure Time: {exposure_time}, Slit Width: {slit_width}, Slit Angle: {slit_angle}") self.on_exposure_time_changed() self.on_slit_width_changed() QSound.play("sound/exposure_slit_width_set.wav") - - # Disable the button again after confirmation - self.confirm_button.setEnabled(False) - self.confirm_button.setStyleSheet(""" - QPushButton { - background-color: lightgray; - } - """) + self._style_disabled_gray(self.confirm_button) + elif exposure_time: - # Handle the confirmed changes for exposure time print(f"Confirmed Exposure Time: {exposure_time}") self.on_exposure_time_changed() QSound.play("sound/exposure_set.wav") - - # Disable the button again after confirmation - self.confirm_button.setEnabled(False) - self.confirm_button.setStyleSheet(""" - QPushButton { - background-color: lightgray; - } - """) + self._style_disabled_gray(self.confirm_button) elif slit_width: - # Handle the confirmed changes for slit width print(f"Confirmed Slit Width: {slit_width}") self.on_slit_width_changed() QSound.play("sound/slit_width_set.wav") - - # Disable the button again after confirmation - self.confirm_button.setEnabled(False) - self.confirm_button.setStyleSheet(""" - QPushButton { - background-color: lightgray; - } - """) + self._style_disabled_gray(self.confirm_button) elif slit_angle: - # Handle the confirmed changes for slit angle print(f"Confirmed Slit Angle: {slit_angle}") self.on_slit_angle_changed() - - # Disable the button again after confirmation - self.confirm_button.setEnabled(False) - self.confirm_button.setStyleSheet(""" - QPushButton { - background-color: lightgray; - } - """) + self._style_disabled_gray(self.confirm_button) else: - # Handle the case where one or more fields are empty print("Please enter valid values for all fields.") + if getattr(self.parent, "current_target_list_name", None): + print(f"Current target list: {self.parent.current_target_list_name}") + self.logic_service.update_target_table_with_list(self.parent.current_target_list_name) + + self._style_enabled_green(self.go_button) + def on_exposure_time_changed(self): - # Retrieve the exposure time and send the query to the database exposure_time = self.exposure_time_box.text() - if (self.parent.current_observation_id): + 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", exposure_time) + self.logic_service.send_update_to_db(self.parent.current_observation_id, "exptime", "SET " + exposure_time) def on_slit_width_changed(self): - # Retrieve the slit width and send the query to the database slit_width = self.slit_width_box.text() - if (self.parent.current_observation_id): + 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", slit_width) + self.logic_service.send_update_to_db(self.parent.current_observation_id, "slitwidth", "SET " + slit_width) def on_slit_angle_changed(self): - # Retrieve the slit width and send the query to the database slit_angle = self.slit_angle_box.text() - if(slit_angle == "PA"): + if slit_angle == "PA": slit_angle = self.logic_service.compute_parallactic_angle_astroplan(self.parent.current_ra, self.parent.current_dec) print(f"Parallactic Angle: {slit_angle}") self.slit_angle_box.setText(slit_angle) - if (self.parent.current_observation_id): + 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", slit_angle) + self.logic_service.send_update_to_db(self.parent.current_observation_id, "slitangle", "SET " + slit_angle) + + def num_of_exposures_changed(self): + num_of_exposures = self.num_of_exposures_box.text() + if self.parent.current_observation_id: + self.logic_service.send_update_to_db(self.parent.current_observation_id, "nexp", num_of_exposures) + + def bin_spect_changed(self): + bin_spect = self.bin_spect_box.text() + if self.parent.current_observation_id: + self.logic_service.send_update_to_db(self.parent.current_observation_id, "BINSPECT", bin_spect) + + def bin_spat_changed(self): + bin_spat = self.bin_spat_box.text() + if self.parent.current_observation_id: + self.logic_service.send_update_to_db(self.parent.current_observation_id, "BINSPAT", bin_spat) diff --git a/pygui/daemon_status_bar.py b/pygui/daemon_status_bar.py new file mode 100644 index 00000000..e8160464 --- /dev/null +++ b/pygui/daemon_status_bar.py @@ -0,0 +1,157 @@ +from PyQt5.QtCore import Qt, pyqtSignal, QPoint, QSize +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QPushButton, QMenu, QMessageBox, + QSizePolicy, QFrame +) +from PyQt5.QtGui import QColor + +class DaemonState: + OK = "ok" # green + WARN = "warn" # yellow + ERROR = "error" # red + UNKNOWN = "unknown" # grey + +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), +} + +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 + self.state = DaemonState.UNKNOWN + self.issue = "No status yet." + self.commands = commands or ["Ping", "Restart", "Open Logs"] + + self.setCursor(Qt.PointingHandCursor) + self.setFlat(True) + self.setFocusPolicy(Qt.NoFocus) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.setMinimumHeight(26) + self._apply_style() + + self.clicked.connect(self._on_left_click) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_context_menu) + + def sizeHint(self) -> QSize: + base = super().sizeHint() + 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 + + def _on_context_menu(self, pos: QPoint): + menu = QMenu(self) + for cmd in self.commands: + menu.addAction(cmd) + chosen = menu.exec_(self.mapToGlobal(pos)) + if chosen: + self.commandRequested.emit(self.name, chosen.text()) + + def set_state(self, state: str, issue: str = None): + self.state = state + if issue is not None: + self.issue = issue + self._apply_style() + self.setToolTip(f"{self.name}: {self.state}\n{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) + self.setStyleSheet(f""" + QPushButton {{ + background: {fill}; + color: white; + border: 0px; + border-radius: 13px; + padding: 4px 12px; + font-weight: 600; + }} + QPushButton:hover {{ + background: {hover}; + }} + """) + +class DaemonStatusBar(QFrame): + """Bottom bar of clickable daemon chips with distributed spacing.""" + commandRequested = pyqtSignal(str, str) # daemon_name, command + detailsRequested = pyqtSignal(str) + + def __init__(self, daemons, 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) + self.setFrameShadow(QFrame.Plain) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setStyleSheet(""" + #DaemonStatusBar { + border-top: 1px solid #444; + background: #202225; + }""") + + self._chips = {} + 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) + if n == 0: + return + + def add_stretch(): + self._layout.addStretch(1) + + if distribution not in ("around", "between"): + 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() + + def set_daemon_state(self, name: str, state: str, issue: str = None): + chip = self._chips.get(name) + if chip: + chip.set_state(state, issue) + + def bulk_update(self, updates: dict): + for name, (state, issue) in updates.items(): + self.set_daemon_state(name, state, issue) diff --git a/pygui/etc_popup.py b/pygui/etc_popup.py new file mode 100644 index 00000000..a70ea9b0 --- /dev/null +++ b/pygui/etc_popup.py @@ -0,0 +1,340 @@ +from PyQt5.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, QSpacerItem, QSizePolicy, QFrame +from PyQt5.QtCore import Qt +import subprocess +import re + +class EtcPopup(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("ETC") + self.setFixedSize(600, 600) # Increased width for better alignment + + # Main layout for the dialog + self.main_layout = QVBoxLayout() + self.main_layout.setSpacing(12) + self.main_layout.setContentsMargins(10, 10, 10, 10) + + # Initialize the form components + self.init_widgets() + self.init_layout() + self.setLayout(self.main_layout) + + def init_widgets(self): + """Initialize all the widgets needed for the form.""" + # Create input fields and widgets + self.magnitude_input = QLineEdit() + self.filter_dropdown = QComboBox() + self.filter_dropdown.addItems(["U", "G", "R", "I"]) + self.system_field = QLineEdit("AB") + self.system_field.setReadOnly(True) + + self.sky_mag_input = QLineEdit() + self.snr_input = QLineEdit() + + self.slit_width_input = QLineEdit() + self.slit_dropdown = QComboBox() + self.slit_dropdown.addItems(["SET", "LOSS", "SNR", "RES", "AUTO"]) + + self.range_input_start = QLineEdit() + self.range_input_end = QLineEdit() + self.no_slicer_checkbox = QCheckBox("No Slicer") + + self.seeing_input = QLineEdit() + self.airmass_input = QLineEdit() + + self.exptime_input = QLineEdit() + self.resolution_input = QLineEdit() + + # Buttons + self.run_button = QPushButton("Run ETC") + self.save_button = QPushButton("Save") + self.save_button.setEnabled(False) # Initially disable the Save button + + def init_layout(self): + """Add widgets to the layout.""" + # Add input rows for each section + self.main_layout.addLayout(self.create_input_row("Magnitude:", self.magnitude_input, self.filter_dropdown, self.system_field)) + + # Call create_sky_mag_snr_layout for Sky Mag and SNR fields + self.main_layout.addLayout(self.create_sky_mag_snr_layout()) + + self.main_layout.addLayout(self.create_input_row("Slit Width:", self.slit_width_input, self.slit_dropdown)) + self.main_layout.addLayout(self.create_range_layout()) + + # Modified the "Seeing" and "Airmass" row + self.main_layout.addLayout(self.create_seeing_airmass_layout()) + + # Modified the "Exp Time" and "Resolution" row + self.main_layout.addLayout(self.create_exptime_resolution_layout()) + + # Add a divider line + divider_line = QFrame() + divider_line.setFrameShape(QFrame.HLine) + divider_line.setFrameShadow(QFrame.Sunken) + self.main_layout.addWidget(divider_line) + + # Add buttons + self.add_buttons() + + # Add a spacer to ensure widgets aren't squished + spacer = QSpacerItem(20, 30, QSizePolicy.Minimum, QSizePolicy.Expanding) + self.main_layout.addItem(spacer) + + def create_input_row(self, label_text, *widgets): + """Create a horizontal layout with a label and widgets.""" + row_layout = QHBoxLayout() + label = self.create_aligned_label(label_text) + row_layout.addWidget(label) + for widget in widgets: + row_layout.addWidget(widget) + widget.setFixedHeight(35) + widget.setFixedWidth(110) + widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + return row_layout + + def create_sky_mag_snr_layout(self): + """Create a layout for Sky Mag and SNR with labels next to the input fields.""" + layout = QHBoxLayout() + + # Add Sky Mag label and input + sky_mag_label = self.create_aligned_label("Sky Mag:") + layout.addWidget(sky_mag_label) + layout.addWidget(self.sky_mag_input) + + # Add SNR label and input next to it + snr_label = self.create_aligned_label("SNR:") + layout.addWidget(snr_label) + layout.addWidget(self.snr_input) + + self.sky_mag_input.setFixedHeight(35) + self.sky_mag_input.setFixedWidth(110) + self.snr_input.setFixedHeight(35) + self.snr_input.setFixedWidth(110) + + return layout + + def create_seeing_airmass_layout(self): + """Create a layout for 'Seeing' and 'Airmass' next to each other.""" + layout = QHBoxLayout() + + # Add Seeing label and input + seeing_label = self.create_aligned_label("Seeing:") + layout.addWidget(seeing_label) + layout.addWidget(self.seeing_input) + + # Add Airmass label and input next to it + airmass_label = self.create_aligned_label("Airmass:") + layout.addWidget(airmass_label) + layout.addWidget(self.airmass_input) + + self.seeing_input.setFixedHeight(35) + self.seeing_input.setFixedWidth(110) + self.airmass_input.setFixedHeight(35) + self.airmass_input.setFixedWidth(110) + + return layout + + def create_exptime_resolution_layout(self): + """Create a layout for 'Exp Time' and 'Resolution' next to each other.""" + layout = QHBoxLayout() + + # Add Exp Time label and input + exptime_label = self.create_aligned_label("Exp Time:") + layout.addWidget(exptime_label) + layout.addWidget(self.exptime_input) + + # Add Resolution label and input next to it + resolution_label = self.create_aligned_label("Resolution:") + layout.addWidget(resolution_label) + layout.addWidget(self.resolution_input) + + self.exptime_input.setFixedHeight(35) + self.exptime_input.setFixedWidth(110) + self.resolution_input.setFixedHeight(35) + self.resolution_input.setFixedWidth(110) + + return layout + + def create_range_layout(self): + """Create the range row layout.""" + range_layout = QHBoxLayout() + range_layout.setSpacing(10) + range_layout.addWidget(self.create_aligned_label("Range:")) + range_layout.addWidget(self.range_input_start) + range_layout.addWidget(QLabel("-")) + range_layout.addWidget(self.range_input_end) + range_layout.addWidget(self.no_slicer_checkbox) + return range_layout + + def create_aligned_label(self, text): + """Create a label with right alignment.""" + label = QLabel(text) + label.setFixedWidth(110) + label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + label.setStyleSheet("font-size: 14pt;") + return label + + def add_buttons(self): + """Add buttons to the layout.""" + button_row_layout = QHBoxLayout() + button_row_layout.setSpacing(10) + + self.run_button.setFixedSize(110, 45) + self.run_button.clicked.connect(self.run_etc) + + self.save_button.setFixedSize(100, 45) + self.save_button.clicked.connect(self.save_etc) + + button_row_layout.addWidget(self.run_button) + button_row_layout.addWidget(self.save_button) + + # Add a spacer after the buttons to create margin below + spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) + button_row_layout.addItem(spacer) + + self.main_layout.addLayout(button_row_layout) + + def validate_inputs(self): + """Validates user inputs in the ETC tab and highlights invalid fields.""" + + # Helper function to check if the input is a valid float and not empty + def is_valid_float(text): + if text.strip() == '': # Check if the text is empty + return False + try: + float(text) # Try to convert to float + return True + except ValueError: + return False # Return False if the conversion fails + + # Reset all fields to default state (clear previous error highlighting) + self.magnitude_input.setStyleSheet("") + self.sky_mag_input.setStyleSheet("") + self.snr_input.setStyleSheet("") + self.slit_width_input.setStyleSheet("") + self.range_input_start.setStyleSheet("") + self.range_input_end.setStyleSheet("") + + try: + # Check if all numeric inputs are valid + if not is_valid_float(self.magnitude_input.text()): + self.magnitude_input.setStyleSheet("border: 1px solid red;") # Highlight invalid field + raise ValueError("Magnitude must be a valid number.") + magnitude = float(self.magnitude_input.text()) + + if not is_valid_float(self.sky_mag_input.text()): + self.sky_mag_input.setStyleSheet("border: 1px solid red;") # Highlight invalid field + raise ValueError("Sky Mag must be a valid number.") + sky_mag = float(self.sky_mag_input.text()) + + if not is_valid_float(self.snr_input.text()): + self.snr_input.setStyleSheet("border: 1px solid red;") # Highlight invalid field + raise ValueError("SNR must be a valid number.") + snr = float(self.snr_input.text()) + + if not is_valid_float(self.slit_width_input.text()): + self.slit_width_input.setStyleSheet("border: 1px solid red;") # Highlight invalid field + raise ValueError("Slit Width must be a valid number.") + slit_width = float(self.slit_width_input.text()) + + if not is_valid_float(self.range_input_start.text()): + self.range_input_start.setStyleSheet("border: 1px solid red;") # Highlight invalid field + raise ValueError("Range Start must be a valid number.") + range_start = float(self.range_input_start.text()) + + if not is_valid_float(self.range_input_end.text()): + self.range_input_end.setStyleSheet("border: 1px solid red;") # Highlight invalid field + raise ValueError("Range End must be a valid number.") + range_end = float(self.range_input_end.text()) + + # Ensure range_start is less than range_end + if range_start >= range_end: + self.range_input_start.setStyleSheet("border: 1px solid red;") # Highlight invalid field + self.range_input_end.setStyleSheet("border: 1px solid red;") # Highlight invalid field + raise ValueError("Range start must be less than range end.") + + # Check for valid values (you can adjust this for your specific needs) + if magnitude <= 0 or sky_mag <= 0 or snr <= 0 or slit_width <= 0: + raise ValueError("Magnitude, Sky Mag, SNR, and Slit Width must be positive values.") + + self.save_button.setEnabled(True) + return True # All inputs are valid + + except ValueError as e: + # Show error message + error_msg = f"Invalid input: {str(e)}" + print(error_msg) + return False # Input is invalid + + + def run_etc(self): + """Handles the logic for the 'Run ETC' button.""" + + # Validate inputs before running the command + if not self.validate_inputs(): + return # If inputs are invalid, do not proceed + + # Collecting all necessary data from input fields + filter_value = self.filter_dropdown.currentText() # e.g., "G" + magnitude_value = self.magnitude_input.text() # e.g., "18.0" + sky_mag_value = self.sky_mag_input.text() # e.g., "21.4" + snr_value = self.snr_input.text() # e.g., "10" + slit_width_value = self.slit_width_input.text() # e.g., "0.5" + slit_option = self.slit_dropdown.currentText() # e.g., "SET X" + seeing_value = self.seeing_input.text() + airmass_value = self.airmass_input.text() + mag_system_value = self.system_field.text() # e.g., "AB" + mag_filter_value = "match" # e.g., "match" + + # Handling the range inputs + range_start_value = self.range_input_start.text() + range_end_value = self.range_input_end.text() + + # Construct the command string + command = f"python3 ETC/ETC_main.py {filter_value} {range_start_value} {range_end_value} SNR {snr_value} " \ + f"-slit {slit_option} {slit_width_value} -seeing {seeing_value} 500 -airmass {airmass_value} " \ + f"-skymag {sky_mag_value} -mag {magnitude_value} -magsystem {mag_system_value} -magfilter {mag_filter_value}" + + # Print the command for debugging + print(f"Running command: {command}") + + # Run the command and capture the output + try: + result = subprocess.run(command, shell=True, capture_output=True, text=True) + output = result.stdout.strip() # Get the output from the command + print(f"Command output: {output}") + + # Extract EXPTIME and RESOLUTION from the output using regex + exptime_match = re.search(r"EXPTIME=([0-9.]+) s", output) + resolution_match = re.search(r"RESOLUTION=([0-9.]+)", output) + + if exptime_match: + exptime = float(exptime_match.group(1)) + exptime_rounded = round(exptime) # Round EXPTIME to the nearest integer + self.exptime_input.setText(str(exptime_rounded)) # Update GUI field with rounded EXPTIME + + if resolution_match: + resolution = float(resolution_match.group(1)) + resolution_rounded = round(resolution) # Round RESOLUTION to the nearest integer + self.resolution_input.setText(str(resolution_rounded)) # Update GUI field with rounded RESOLUTION + + except subprocess.CalledProcessError as e: + # Handle errors if the command fails + print(f"Error running ETC: {e}") + + # Display the result in the results display (GUI) + result_text = f"Running ETC with the following parameters:\n{command}\n\n" \ + f"EXPTIME: {self.exptime_input.text()}\n" \ + f"RESOLUTION: {self.resolution_input.text()}" + print(result_text) + self.save_button.setEnabled(True) + + def save_etc(self): + exptime = self.exptime_input.text() + resolution = self.resolution_input.text() + if (self.parent.current_observation_id): + self.logic_service.send_update_to_db(self.parent.current_observation_id, "OTMexpt", exptime) + self.logic_service.send_update_to_db(self.parent.current_observation_id, "exptime", exptime) + self.logic_service.send_update_to_db(self.parent.current_observation_id, "OTMres", resolution) + self.save_button.setEnabled(False) diff --git a/pygui/layout_service.py b/pygui/layout_service.py new file mode 100644 index 00000000..39ced60f --- /dev/null +++ b/pygui/layout_service.py @@ -0,0 +1,1872 @@ +from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, 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 logic_service import LogicService +from PyQt5.QtCore import Qt, QSignalBlocker +from control_tab import ControlTab +from instrument_status_tab import InstrumentStatusTab +import re + +class LayoutService: + def __init__(self, parent): + self.parent = parent + self.logic_service = LogicService(self.parent) + self.target_list_display = None + self.target_list_name = QComboBox() + self.target_list_name.setView(QListView()) + self.target_list_name.setMaxVisibleItems(16) + self.target_list_name.view().setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.add_row_button = QPushButton() + self.save_button = QPushButton() + self.lamp_checkboxes = {} + self.modulator_checkboxes = {} + + # Create the control tab instance + self.control_tab = ControlTab(self.parent) + + # Create the instrument status tab instance + self.instrument_status_tab = InstrumentStatusTab(self.parent) + + + def get_screen_size_ratio(self): + # Get the user's screen size + # screen = self.parent.screen() + # screen_size = screen.geometry() + + # Use the screen width and height to calculate dynamic ratio (you can adjust these ratios) + screen_width = 800 + screen_height = 600 + + # Define dynamic ratio logic based on the screen size + # For example, adjust the layout ratio based on screen size + if screen_width > 2000: + # For large screens, give more space to the main layout + ratio = (5, 2, 1) + elif screen_width > 1500: + # For medium-sized screens, keep the ratio balanced + ratio = (4, 3, 1) + else: + # For smaller screens, prioritize the left column + ratio = (3, 2, 1) + + return ratio + + def create_layout(self): + # Get the layout ratio based on the screen size + layout_ratio = self.get_screen_size_ratio() + + # Main horizontal layout for overall structure + main_layout = QHBoxLayout() + + # Create a vertical layout to place Column 1 on top of Column 2 + top_layout = QVBoxLayout() + + # First Column (Column 1) should contain top_section_layout and second_column_top_half side by side + first_column_layout = self.create_first_column() + top_layout.addLayout(first_column_layout, stretch=layout_ratio[0]) # Column 1 + + # Second Column (Column 2) should only contain the target_list_group + second_column_layout = self.create_second_column_for_target_list() + top_layout.addLayout(second_column_layout, stretch=layout_ratio[1]) # Column 2 (only target list) + + # Add the vertical top_layout to the main_layout + main_layout.addLayout(top_layout, stretch=layout_ratio[0] + layout_ratio[1]) # Top section (Columns 1 and 2 stacked) + + # Third Column (1/5 width, for tabs, etc.) + third_column_layout = self.create_third_column() + main_layout.addLayout(third_column_layout, stretch=layout_ratio[2]) + + # Set the main layout + central_widget = QWidget() + self.parent.setCentralWidget(central_widget) + central_widget.setLayout(main_layout) + + return main_layout + + def create_first_column(self): + first_column_layout = QHBoxLayout() # Use QHBoxLayout to make top_section_layout and second_column_top_half side by side + first_column_layout.setObjectName("column-left") + first_column_layout.setSpacing(10) + + # Add top_section_layout (Instrument System Status, Sequencer Mode, Progress & Image Info) + top_section_layout = self.create_top_section() + first_column_layout.addLayout(top_section_layout) + + # Add create_second_column_top_half (top part of the second column) + second_column_top_half = self.create_second_column_top_half() + first_column_layout.addWidget(second_column_top_half) + + return first_column_layout + + def create_second_column_for_target_list(self): + second_column_layout = QVBoxLayout() + second_column_layout.setObjectName("column-right") + second_column_layout.setSpacing(10) + + # Only add the target list group in the second column + target_list_group = self.create_target_list_group() + second_column_layout.addWidget(target_list_group) + + return second_column_layout + + def create_third_column(self): + third_column_layout = QVBoxLayout() + third_column_layout.setObjectName("column-sidebar") + third_column_layout.setSpacing(10) + + # Add widgets to the third column, e.g., tabs, buttons, etc. + # For simplicity, let's assume it's a placeholder widget: + sidebar_widget = QWidget() + third_column_layout.addWidget(sidebar_widget) + + return third_column_layout + + def create_third_column(self): + third_column_layout = QVBoxLayout() + + # Create the QTabWidget and the Control tab + self.parent.tabs = QTabWidget() + self.parent.control_tab = QWidget() + self.parent.status_tab = QWidget() + self.parent.engineering_tab = QWidget() + + # Add the tabs to the QTabWidget + self.parent.tabs.addTab(self.parent.control_tab, "Control") + # self.parent.tabs.addTab(self.parent.status_tab, "Status") + + # Add the QTabWidget to the third column layout + third_column_layout.addWidget(self.parent.tabs) + + # Now, create and set up the layout for the Control tab + # Create a layout for the Control tab using the ControlTab class + self.control_tab = ControlTab(self.parent) # Create the control tab instance + control_layout = QVBoxLayout() # You can define a custom layout for the control tab here if needed + control_layout.addWidget(self.control_tab) # Add the ControlTab widget to the layout + self.parent.control_tab.setLayout(control_layout) # Set the layout for the control tab widget + + self.status_tab = InstrumentStatusTab(self.parent) # Create the control tab instance + status_layout = QVBoxLayout() # You can define a custom layout for the control tab here if needed + status_layout.addWidget(self.status_tab) # Add the ControlTab widget to the layout + self.parent.status_tab.setLayout(status_layout) # Set the layout for the control tab widget + return third_column_layout + + def create_top_section(self): + top_section_layout = QHBoxLayout() + top_section_layout.setSpacing(10) + + # Left side: Instrument System Status and Sequencer Mode + left_top_layout = self.create_left_top_layout() + top_section_layout.addLayout(left_top_layout) + + # Right side: Progress & Image Info + right_top_layout = self.create_right_top_layout() + top_section_layout.addLayout(right_top_layout) + + return top_section_layout + + def create_left_top_layout(self): + left_top_layout = QVBoxLayout() + left_top_layout.setSpacing(10) + + # Instrument System Status + system_status_group = self.create_system_status_group() + system_status_group.setContentsMargins(0, 45, 0, 0) + left_top_layout.addWidget(system_status_group) + + # TCS Status + tcs_status_group = self.create_tcs_status_group() + left_top_layout.addWidget(tcs_status_group) + + # Sequencer Mode + sequencer_mode_group = self.create_sequencer_mode_group() + left_top_layout.addWidget(sequencer_mode_group) + + # Target List Dropdown Mode + target_dropdown_group = self.create_target_dropdown_group() + left_top_layout.addWidget(target_dropdown_group) + + return left_top_layout + + def create_right_top_layout(self): + right_top_layout = QVBoxLayout() + right_top_layout.setContentsMargins(15, 0, 0, 15) + + # Progress and Image Info Group + progress_and_image_group = self.create_progress_and_image_group() + right_top_layout.addWidget(progress_and_image_group) + + return right_top_layout + + def create_system_status_group(self): + system_status_group = QGroupBox("Instrument System Status") + system_status_layout = QVBoxLayout() + system_status_layout.setSpacing(10) + system_status_layout.setContentsMargins(5, 5, 5, 5) + + # Create a mapping for status colors + status_map = { + "stopped": QColor(169, 169, 169), # Grey + "idle": QColor(255, 255, 0), # Yellow + "paused": QColor(255, 165, 0), # Orange + "exposing": QColor(0, 255, 0), # Green + "readout": QColor(0, 255, 0), # Green + "acquire": QColor(255, 255, 0), # Yellow + "focus": QColor(255, 255, 0), # Yellow + "calib": QColor(255, 255, 0), # Yellow + "user": QColor(255, 255, 0), # Yellow + } + + # Create a dictionary to hold the status widgets, which we will enable/disable + self.status_widgets = {} + + # Create status widgets and add them to the layout + for status, color in status_map.items(): + # Create a QWidget to contain the status layout + status_widget = QWidget() + + # Create a layout for the status (color + label) + status_layout = QHBoxLayout() + status_layout.setSpacing(10) # Reduced space between color and label + status_layout.setContentsMargins(0, 0, 0, 0) # Remove margins for the status layout + + # Color indicator + status_color_rect = QWidget() + status_color_rect.setFixedSize(16, 16) # Smaller color indicator + status_color_rect.setStyleSheet(f"background-color: {color.name()};") + + # Label showing the status + status_label = QLabel(status.capitalize()) + status_label.setMargin(0) # Remove extra margin around the label + + # Layout for each status (color + label) + status_layout.addWidget(status_color_rect) + status_layout.addWidget(status_label) + + # Set the layout for the status widget + status_widget.setLayout(status_layout) + + # Add the status widget to the main layout + system_status_layout.addWidget(status_widget) + + # Store status widgets in a dictionary for later use + self.status_widgets[status] = { + 'widget': status_widget, + 'color_rect': status_color_rect, + 'label': status_label, + 'color': color + } + + # Hide the widget initially (default is 'stopped') + status_widget.setVisible(False) + + system_status_group.setLayout(system_status_layout) + + # Set maximum width and height for the system status group + system_status_group.setMaximumWidth(300) # Maximum width + system_status_group.setMaximumHeight(250) # Maximum height + + # Ensure only the 'stopped' status is fully active by default + self.update_system_status("stopped") + + return system_status_group + + def update_system_status(self, status): + """ + Update the system status and make only the relevant widget visible. + Hide all other status widgets. + """ + # Hide all status widgets + for status_key, status_info in self.status_widgets.items(): + 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) + + def create_tcs_status_group(self): + tcs_status_group = QGroupBox("TCS Status") + tcs_status_layout = QVBoxLayout() + tcs_status_layout.setSpacing(3) # Reduced space between items in the layout + tcs_status_layout.setContentsMargins(5, 5, 5, 5) # Reduced margins around the layout + + # Create a mapping for status colors + tcs_status_map = { + "idle": QColor(255, 255, 0), # Yellow + "on": QColor(0, 255, 0), # Green + "tracking": QColor(0, 255, 0), # Green + "paused": QColor(255, 165, 0), # Orange + "error": QColor(255, 0, 0), # Red + } + + # Create a dictionary to hold the status widgets, which we will enable/disable + self.tcs_status_widgets = {} + + # Create status widgets and add them to the layout + for status, color in tcs_status_map.items(): + # Create a QWidget to contain the status layout + status_widget = QWidget() + + # Create a layout for the status (color + label) + status_layout = QHBoxLayout() + status_layout.setSpacing(10) # Reduced space between color and label + status_layout.setContentsMargins(0, 0, 0, 0) # Remove margins for the status layout + + # Color indicator + status_color_rect = QWidget() + status_color_rect.setFixedSize(16, 16) # Smaller color indicator + status_color_rect.setStyleSheet(f"background-color: {color.name()};") + + # Label showing the status + status_label = QLabel(status.capitalize()) + status_label.setMargin(0) # Remove extra margin around the label + + # Layout for each status (color + label) + status_layout.addWidget(status_color_rect) + status_layout.addWidget(status_label) + + # Set the layout for the status widget + status_widget.setLayout(status_layout) + + # Add the status widget to the main layout + tcs_status_layout.addWidget(status_widget) + + # Store status widgets in a dictionary for later use + self.tcs_status_widgets[status] = { + 'widget': status_widget, + 'color_rect': status_color_rect, + 'label': status_label, + 'color': color + } + + # Hide the widget initially (default is 'idle') + status_widget.setVisible(False) + + tcs_status_group.setLayout(tcs_status_layout) + + # Set maximum width and height for the TCS status group + tcs_status_group.setMaximumWidth(300) # Maximum width + tcs_status_group.setMaximumHeight(250) # Maximum height + + # Ensure only the 'idle' status is fully active by default + self.update_tcs_status("on") + + return tcs_status_group + + def update_tcs_status(self, status): + """Update the TCS status and make the appropriate widget visible.""" + # Hide all widgets initially + for status_key, status_info in self.tcs_status_widgets.items(): + status_info['widget'].setVisible(False) + + # Show the widget corresponding to the current status + if status in self.tcs_status_widgets: + self.tcs_status_widgets[status]['widget'].setVisible(True) + + def create_sequencer_mode_group(self): + sequencer_mode_group = QGroupBox("Sequencer Mode") + sequencer_mode_layout = QVBoxLayout() + + self.parent.sequencer_mode_single = QRadioButton("Single") + self.parent.sequencer_mode_all = QRadioButton("All") + sequencer_mode_layout.addWidget(self.parent.sequencer_mode_single) + sequencer_mode_layout.addWidget(self.parent.sequencer_mode_all) + + 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(100) # Maximum height + + return sequencer_mode_group + + def create_progress_and_image_group(self): + progress_and_image_group = QGroupBox("Progress and Image Info") + progress_and_image_layout = QVBoxLayout() + progress_and_image_layout.setSpacing(15) + + # Create the progress layout with separate rows for exposure and overhead + progress_layout = self.create_progress_layout() + + # Create the image info layout and message log as before + image_info_layout = self.create_image_info_layout() + message_log = self.create_message_log() + + # 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(message_log) + + progress_and_image_group.setLayout(progress_and_image_layout) + + # Set maximum width and height for the progress and image info group + progress_and_image_group.setMaximumWidth(750) # Maximum width + progress_and_image_group.setMaximumHeight(350) # Maximum height + + return progress_and_image_group + + def create_progress_layout(self): + progress_layout = QVBoxLayout() # Use QVBoxLayout for vertical stacking + + # --- Exposure Progress --- + exposure_layout = QHBoxLayout() # Horizontal layout for exposure row + + self.parent.exposure_progress = QProgressBar() + self.parent.exposure_progress.setRange(0, 100) + self.parent.exposure_progress.setValue(0) + self.parent.exposure_progress.setMaximumWidth(600) + self.parent.exposure_progress.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.parent.exposure_progress.setTextVisible(True) # Enable text display + self.parent.exposure_progress.setFormat("0% (0 sec remaining)") # Initial display + + exposure_layout.setSpacing(5) + exposure_layout.addWidget(QLabel("Exposure Progress:")) + exposure_layout.addWidget(self.parent.exposure_progress) + + # --- Readout/Overhead Progress --- + overhead_layout = QHBoxLayout() # Horizontal layout for overhead row + + self.parent.overhead_progress = QProgressBar() + self.parent.overhead_progress.setValue(0) + self.parent.overhead_progress.setRange(0, 100) + self.parent.overhead_progress.setMaximumWidth(300) + self.parent.overhead_progress.setTextVisible(True) # Optional: show % on readout bar + + overhead_layout.setSpacing(0) + overhead_layout.addWidget(QLabel("Readout Progress:")) + overhead_layout.addSpacing(8) + overhead_layout.addWidget(self.parent.overhead_progress) + + self.parent.shutter_label = QLabel("Shutter:") + self.parent.shutter_label.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + + self.parent.shutter_box = QLabel("CLOSED") + self.parent.shutter_box.setAlignment(Qt.AlignCenter) + self.parent.shutter_box.setFixedWidth(90) + self.parent.shutter_box.setStyleSheet( + "border: 1px solid gray; padding: 2px; background-color: #ccc; color: black;" + ) + + overhead_layout.addSpacing(12) + overhead_layout.addWidget(self.parent.shutter_label) + overhead_layout.addSpacing(6) + overhead_layout.addWidget(self.parent.shutter_box) + + # Stack both layouts + progress_layout.addLayout(exposure_layout) + progress_layout.addLayout(overhead_layout) + + return progress_layout + + def update_exposure_progress(self, progress_percentage, remaining_sec): + """Update the exposure progress bar with percentage and remaining time.""" + label_text = f"{progress_percentage}% ({remaining_sec} sec remaining)" + self.parent.exposure_progress.setValue(progress_percentage) + self.parent.exposure_progress.setFormat(label_text) + + def update_readout_progress(self, progress_percentage): + """Update the readout progress bar based on the received percentage.""" + self.parent.overhead_progress.setValue(progress_percentage) + + def update_shutter_status(self, is_open): + if isinstance(is_open, str): + up = is_open.strip().upper() + is_open = True if up in ("OPEN", "OPENED", "O") else False if up in ("CLOSE","CLOSED","C") else False + + if is_open: + self.parent.shutter_box.setText("OPEN") + self.parent.shutter_box.setStyleSheet( + "border: 1px solid gray; padding: 2px; background-color: #cccccc; color: black;" + ) + else: + self.parent.shutter_box.setText("CLOSED") + self.parent.shutter_box.setStyleSheet( + "border: 1px solid gray; padding: 2px; background-color: #cccccc; color: black;" + ) + + + def create_image_info_layout(self): + image_info_layout = QHBoxLayout() + image_info_layout.setSpacing(10) + + # Create the QLineEdit widgets + self.parent.image_name = QLineEdit("N/A") + self.parent.image_name.setReadOnly(True) + self.parent.image_number = QLineEdit("N/A") + self.parent.image_number.setReadOnly(True) + + # Set the image_name widget to stretch and fill available space + self.parent.image_name.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + # Set the image_number widget to be smaller + self.parent.image_number.setFixedWidth(80) # You can adjust the width as needed + + image_info_layout.addWidget(QLabel("Image Dir:")) + image_info_layout.addWidget(self.parent.image_name) + image_info_layout.addWidget(QLabel("Image Number:")) + image_info_layout.addWidget(self.parent.image_number) + + return image_info_layout + + def update_image_number(self, image_number): + """Update the current image number.""" + self.parent.image_number.setText(str(image_number)) + + def update_image_name(self, image_name): + """Update the current image name.""" + self.parent.image_name.setText(str(image_name)) + + def create_message_log(self): + self.parent.message_log = QTextEdit(self.parent) + + # 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) + + # Optionally set a minimum height or width if desired + self.parent.message_log.setMinimumHeight(60) + self.parent.message_log.setMinimumWidth(200) + + # Set up a timer to clear the message log every 10 minutes (600000 milliseconds) + self.clear_timer = QTimer(self.parent) + self.clear_timer.timeout.connect(self.clear_message_log) + self.clear_timer.start(600000) # 600000 ms = 10 minutes + + return self.parent.message_log + + def clear_message_log(self): + """ Clears the message log after a certain timeout. """ + self.parent.message_log.clear() + + + 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() + + # 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) + + # Update the message log with the new, trimmed text + self.parent.message_log.setPlainText(updated_text) + + # 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) + + def create_target_list_group(self): + target_list_group = QGroupBox() + bottom_section_layout = QVBoxLayout() + bottom_section_layout.setSpacing(5) + + # Create a horizontal layout for the label and the (+) button + header_layout = QHBoxLayout() + header_layout.setSpacing(10) # Set the space between the label and the button + + # Create the label with the "Target List" text + target_list_label = QLabel("Target List") + header_layout.addWidget(target_list_label) + + # Create the (+) button to add a new row (small button) + self.add_row_button = QPushButton("+") + self.add_row_button.setToolTip("Add a new row") + self.add_row_button.setFixedSize(25, 25) # Make the button small + self.add_row_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; + border: none; + color: white; + font-weight: bold; + font-size: 18px; + border-radius: 12px; + } + QPushButton:hover { + background-color: #388E3C; + } + QPushButton:pressed { + background-color: #2C6B2F; + } + QPushButton:disabled { + background-color: #D3D3D3; /* Grey background when disabled */ + color: #A9A9A9; /* Grey text color when disabled */ + } + """) + self.add_row_button.clicked.connect(self.add_new_row) # Connect to function to add a new row + self.add_row_button.setEnabled(False) + # Add the (+) button next to the label + header_layout.addWidget(self.add_row_button) + + # Add the header layout to the main layout + bottom_section_layout.addLayout(header_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(""" + QPushButton { + background-color: #FFCC40; + border: none; + color: black; + font-weight: bold; + padding: 10px; + } + QPushButton:hover { + background-color: #FF9900; + } + QPushButton:pressed { + background-color: #FF6600; + } + """) + + # Center the button by using a QHBoxLayout + button_layout = QHBoxLayout() + button_layout.addWidget(self.load_target_button) + button_layout.setAlignment(self.load_target_button, Qt.AlignCenter) + + self.load_target_button.clicked.connect(self.parent.on_login) # Connect to load CSV functionality + bottom_section_layout.addWidget(self.load_target_button) + + # Create the QTableWidget for the target list + self.target_list_display = QTableWidget() + self.target_list_display.setStyleSheet(""" + /* PyQt Table Styling */ + QTableWidget, QTableView { + background-color: #444444; /* Dark gray background for the table */ + color: #e0e0e0; /* Light gray text */ + font-size: 14pt; + font-weight: bold; + } + + /* Table Header */ + QHeaderView { + background-color: #555555; /* Slightly lighter gray for the header */ + color: #e0e0e0; + border: 1px solid #888888; /* Border around the header */ + font-weight: bold; + font-size: 16pt; + } + + QHeaderView::section { + padding: 4px; + border: 1px solid #888888; + background-color: #555555; + } + QScrollBar:vertical, QScrollBar:horizontal { + border: 2px solid grey; + background: #F0F0F0; + width: 20px; + height: 20px; + } + QScrollBar::handle:vertical, QScrollBar::handle:horizontal { + background: #FFCC40; + border-radius: 10px; + } + QScrollBar::add-line, QScrollBar::sub-line { + border: none; + background: none; + } + + * Highlighting the focus item with a subtle border */ + QTableWidget::item:focus, QTableView::item:focus { + border: 1px solid #00ccff; /* Light cyan border for focused item */ + background-color: #005b99; /* Slightly darker blue for focused row */ + } + /* Ensure selected row color covers the entire row */ + QTableWidget::item:selected:active, QTableView::item:selected:active { + background-color: #0066cc; /* Blue background for the entire selected row */ + } + """) + self.target_list_display.setRowCount(0) # Set to 0 initially + self.target_list_display.setColumnCount(0) # Set column count to 0 initially + + # Create a placeholder column for the target data + self.target_list_display.setHorizontalHeaderLabels([]) # Initially no headers + + # Remove the bold font from headers + header = self.target_list_display.horizontalHeader() + header.setFont(QFont("Arial", 10, QFont.Normal)) # Set font to normal (non-bold) + + # Enable sorting on column headers + self.target_list_display.setSortingEnabled(True) + + # Enable horizontal scrolling if the content exceeds the available width + self.target_list_display.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.target_list_display.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + # Allow manual resizing of the columns (on the horizontal header) + header.setSectionResizeMode(QHeaderView.Interactive) + + # Disable editing of table cells + self.target_list_display.setEditTriggers(QAbstractItemView.NoEditTriggers) + + # Set selection mode to select entire rows when a cell is clicked + self.target_list_display.setSelectionBehavior(QAbstractItemView.SelectRows) + + # Enable horizontal scrolling by adding the table to a scroll area + scroll_area = QScrollArea() + scroll_area.setWidget(self.target_list_display) + scroll_area.setWidgetResizable(True) # Ensure that the scroll area resizes with the window + + # Add the scroll area to the layout instead of the table directly + bottom_section_layout.addWidget(scroll_area) + + # Initially, hide the table + self.target_list_display.setVisible(False) + + # Connect the selectionChanged signal to the update_target_info function in LogicService + self.target_list_display.selectionModel().selectionChanged.connect(self.update_target_info) + + target_list_group.setLayout(bottom_section_layout) + + return target_list_group + + def on_row_selected(self): + # Get the selected row's index + selected_rows = self.parent.target_list_display.selectionModel().selectedRows() + if selected_rows: + selected_row = selected_rows[0].row() # Get the first selected row (you can handle multi-row selection here) + # Pass the selected row to LogicService + self.parent.logic_service.update_target_information(selected_row) + + def add_new_row(self): + # Dialog + dialog = QDialog(self.parent) + dialog.setWindowTitle("Add New Target") + layout = QVBoxLayout(dialog) + + form = QFormLayout() + layout.addLayout(form) + + # Fields + name_le = QLineEdit() + ra_le = QLineEdit() + decl_le = QLineEdit() + off_ra_le = QLineEdit() + off_dec_le = QLineEdit() + exptime_le = QLineEdit() + slitwidth_le = QLineEdit() + mag_le = QLineEdit() + + # Placeholders + name_le.setPlaceholderText("e.g. NGC 1234") + ra_le.setPlaceholderText("e.g. 12 34 56.7 or 188.736 (deg)") + decl_le.setPlaceholderText("e.g. +12 34 56 or +12.582 (deg)") + off_ra_le.setPlaceholderText("arcsec (optional)") + off_dec_le.setPlaceholderText("arcsec (optional)") + exptime_le.setPlaceholderText("seconds") + slitwidth_le.setPlaceholderText("arcsec") + mag_le.setPlaceholderText("e.g. 17.2 (optional)") + + # Numeric validators (optional, allow blanks) + dv = QDoubleValidator() + for w in (off_ra_le, off_dec_le, exptime_le, slitwidth_le, mag_le): + w.setValidator(dv) + + # Required labels + form.addRow(QLabel("Name*"), name_le) + form.addRow(QLabel("RA*"), ra_le) + form.addRow(QLabel("Decl*"), decl_le) + form.addRow("Offset RA", off_ra_le) + form.addRow("Offset Dec", off_dec_le) + form.addRow("EXPTime", exptime_le) + form.addRow("Slitwidth", slitwidth_le) + form.addRow("Magnitude", mag_le) + + # Buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + layout.addWidget(buttons) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + + if dialog.exec_() != QDialog.Accepted: + return + + # Read & basic validate + target_name = name_le.text().strip() + ra = ra_le.text().strip() + decl = decl_le.text().strip() + exp = exptime_le.text().strip() + slitwidth = slitwidth_le.text().strip() + + if not target_name or not ra or not decl or not exp or not slitwidth: + QMessageBox.warning(self.parent, "Missing fields", "Name, RA, Decl, Exptime, Slitwidth are required.") + return + + # Convert numeric optionals (blank -> None) + def _num(txt): + txt = txt.strip() + return float(txt) if txt else None + + try: + offset_ra = _num(off_ra_le.text()) + offset_dec = _num(off_dec_le.text()) + exptime = _num(exptime_le.text()) + slitwidth = _num(slitwidth_le.text()) + magnitude = _num(mag_le.text()) + except ValueError: + QMessageBox.warning(self.parent, "Invalid number", "One or more numeric fields are invalid.") + return + + # Ensure we have a current SET_ID + set_id = self.logic_service.fetch_set_id(getattr(self.parent, "current_target_list_name", None)) + if set_id is None: + QMessageBox.warning(self.parent, "No target list selected", "Please select a valid target list first.") + return + + # Insert into DB + self.logic_service.insert_target_to_db( + target_name, ra, decl, offset_ra, offset_dec, exptime, slitwidth, magnitude + ) + + # Refresh the table for the currently selected set (DB-backed fetch) + if hasattr(self.logic_service, "filter_target_list"): + self.logic_service.filter_target_list() + + + + def update_target_info(self): + # Get the selected row's index + selected_rows = self.target_list_display.selectionModel().selectedRows() + if selected_rows: + selected_row = selected_rows[0].row() # Get the first selected row (you can handle multi-row selection here) + + # Get the column headers dynamically + column_headers = [self.target_list_display.horizontalHeaderItem(i).text() for i in range(self.target_list_display.columnCount())] + + # Create a dictionary to hold the target data from the selected row + target_data = {} + + # Variables to store the target values + observation_id = None + exposure_time = None + slit_width = None + target_name = None + offset_ra = None + offset_dec = None + num_of_exposures = None + + print("Selected Row:", selected_row) # Print the selected row index + print("Column Headers:", column_headers) # Print the column headers + + for col_index, header in enumerate(column_headers): + # Get the value from the selected row in each column + item = self.target_list_display.item(selected_row, col_index) + value = item.text() if item else "" # Get text or default to an empty string + target_data[header] = value # Add the data to the dictionary + + # Check if the header is 'OBSERVATION_ID' and extract its value + if header == 'OBSERVATION_ID': + observation_id = value # Store the observation ID + print(f"Found OBSERVATION_ID: {observation_id}") # Print the found OBSERVATION_ID + + # Check if the header is 'Exposure Time' and extract its value + if header == 'RA': + ra = value # Store the ra + print(f"Found RA: {ra}") # Print the found ra + + # Check if the header is 'Exposure Time' and extract its value + if header == 'DECL': + dec = value # Store the dec + print(f"Found DEC: {dec}") # Print the found dec + + # Check if the header is 'Exposure Time' and extract its value + if header == 'EXPTIME': + exposure_time = value # Store the exposure time + print(f"Found Exposure Time: {exposure_time}") # Print the found exposure time + self.control_tab.exposure_time_box.setText(re.sub(r'[a-zA-Z\s]', '', exposure_time)) + + # Check if the header is 'Slit Width' and extract its value + if header == 'SLITWIDTH': + slit_width = value # Store the slit width + print(f"Found Slit Width: {slit_width}") # Print the found slit width + self.control_tab.slit_width_box.setText(re.sub(r'[a-zA-Z\s]', '', slit_width)) + + # Check if the header is 'RA' and extract its value + if header == 'OFFSET_RA': + offset_ra = value # Store the RA + print(f"Found OFFSET_RA: {offset_ra}") # Print the found RA + + # Check if the header is 'DEC' and extract its value + if header == 'OFFSET_DEC': + offset_dec = value # Store the DEC + print(f"Found OFFSET_DEC: {offset_dec}") # Print the found DEC + + # Check if the header is 'BINSPECT' and extract its value + if header == 'BINSPECT': + binspect = value + print(f"Found BINSPECT: {binspect}") + self.control_tab.bin_spect_box.setText(binspect) + + # Check if the header is 'BINSPAT' and extract its value + if header == 'BINSPAT': + binspat = value + print(f"Found BINSPAT: {binspat}") + self.control_tab.bin_spat_box.setText(binspat) + + # If the header is 'NAME', store it as the target name + if header == 'NAME': + target_name = value + + # Check if the header is 'NEXP' and extract its value + if header == 'NEXP': + num_of_exposures = value # Store the NEXP + print(f"Found NEXP: {num_of_exposures}") # Print the found NEXP + self.control_tab.num_of_exposures_box.setText(num_of_exposures) + + # 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) + + # Call to set the column widths (adjust them as needed) + self.set_column_widths() + + if observation_id: + # Store the observation_id in a class variable for later use when the "Go" button is clicked + self.parent.current_observation_id = observation_id + self.parent.current_ra = ra + self.parent.current_dec = dec + self.parent.current_offset_ra = offset_ra + self.parent.current_offset_dec = offset_dec + self.parent.num_of_exposures = num_of_exposures + self.parent.current_bin_spect = binspect + self.parent.current_bin_spat = binspat + + if target_name: + self.control_tab.target_name_label.setText(f"Selected Target: {target_name}") + self.control_tab.ra_dec_label.setText(f"RA: {ra}, Dec: {dec}") + else: + self.control_tab.setText("Selected Target: Not Selected") + self.control_tab.ra_dec_label.setText(f"RA: Not Set, Dec: Not Set") + + # Enable the "Go" button when a row is selected + self.control_tab.go_button.setEnabled(True) # Enable the "Go" button + self.control_tab.go_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green when enabled */ + color: white; + font-weight: bold; + padding: 10px; + border: none; + border-radius: 5px; /* Optional: Round corners */ + } + QPushButton:hover { + background-color: #388E3C; /* Darker green when hovered */ + } + QPushButton:pressed { + background-color: #2C6B2F; /* Even darker green when pressed */ + } + """) + self.control_tab.offset_to_target_button.setEnabled(False) + self.control_tab.continue_button.setEnabled(False) + # Select the row after updating the table (to highlight it) + 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) + self.control_tab.slit_angle_box.setText(slit_angle) + + else: + # Disable the "Go" button when no row is selected + print("No row selected.") # Print when no row is selected + self.control_tab.go_button.setEnabled(False) + self.control_tab.go_button.setStyleSheet(""" + QPushButton { + background-color: #D3D3D3; /* Light gray when disabled */ + color: black; + font-weight: bold; + padding: 10px; + border: none; + border-radius: 5px; /* Optional: Round corners */ + } + QPushButton:hover { + background-color: #D3D3D3; /* No hover effect when disabled */ + } + QPushButton:pressed { + background-color: #D3D3D3; /* No pressed effect when disabled */ + } + """) + + # Getter method to access target_list_display from LogicService + 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) + + + def update_status_ui(self, data, modulator_data): + """ Update the UI based on the received data and modulator data. """ + if not isinstance(data, dict) or not isinstance(modulator_data, dict): + self.logger.error("Invalid data format received.") + return + + # List of lamps to process + lamps = ["LAMPBLUC", "LAMPFEAR", "LAMPREDC", "LAMPTHAR"] # List of lamps in the message + + for lamp in lamps: + # Update the lamp checkbox + lamp_checkbox = self.lamp_checkboxes.get(lamp) + if lamp_checkbox: + # Set the checkbox state based on the value from the payload (True/False) + lamp_checkbox.setChecked(data.get(lamp, False)) # Will be unchecked if not found + + # Update the modulator checkbox based on the corresponding modulator state in modulator_data + modulator_checkbox = self.modulator_checkboxes.get(lamp) + if modulator_checkbox: + # Get the modulator status from modulator_data (e.g., MODBLCON, MODFEAR, etc.) + modulator_key = f"MOD{lamp[4:]}" # For LAMPBLUC, this would give "MODBLCON" + + modulator_status = modulator_data.get(modulator_key, "off") + + # Determine if the modulator is on or off based on the modulator status + modulator_checkbox.setChecked(modulator_status.startswith("on")) # "on" means checked, "off" means unchecked + + def update_slit_info_fields(self, slit_width=None, slit_offset=None): + if slit_width is None or slit_offset is None: + self.slit_width_input.setText("N/A") + else: + self.slit_width_input.setText(f"{slit_width:.3f} / {slit_offset:.3f}") + + def create_second_column_top_half(self): + """Create the top half of the second column with 'Status', Calibration Lamps, and additional status fields""" + + second_column_top_half_layout = QVBoxLayout() + + # Create the "Status" section + status_group = QGroupBox("Status") + status_layout = QVBoxLayout() + + # Create Calibration Lamps section + calibration_lamps_group = QGroupBox("Calibration Lamps") + calibration_lamps_layout = QVBoxLayout() + + # Define lamps and their corresponding statuses + lamps = ["ThAR", "FeAr", "RedCont", "BlueCont"] + + # Create the header row for "Lamps On/Off" and "Modulator On/Off" + header_layout = QHBoxLayout() + + lamps_header = QLabel("Lamps") + lamps_header.setAlignment(Qt.AlignCenter) + header_layout.addWidget(lamps_header) + + modulator_header = QLabel("Modulator") + modulator_header.setAlignment(Qt.AlignCenter) + header_layout.addWidget(modulator_header) + + calibration_lamps_layout.addLayout(header_layout) + + # For each lamp, create two separate HBoxes: one for Lamp and one for Modulator, with a separator bar between them + for lamp in lamps: + lamp_layout = QHBoxLayout() # Main layout for each lamp's row + + lamp_side_layout = QHBoxLayout() + lamp_name = QLabel(lamp) + lamp_side_layout.addWidget(lamp_name) + + lamp_checkbox = QCheckBox("On/Off") + lamp_checkbox.setChecked(False) # Default to Off + lamp_checkbox.setEnabled(False) + lamp_side_layout.addWidget(lamp_checkbox) + + separator = QFrame() + separator.setFrameShape(QFrame.VLine) # Vertical line + separator.setFrameShadow(QFrame.Sunken) + separator.setStyleSheet("background-color: white;") + separator.setLineWidth(1) + + modulator_side_layout = QHBoxLayout() + modulator_checkbox = QCheckBox("On/Off") + modulator_checkbox.setChecked(False) # Default to Off + modulator_checkbox.setEnabled(False) + modulator_side_layout.addWidget(modulator_checkbox) + + lamp_layout.addLayout(lamp_side_layout) + lamp_layout.addWidget(separator) + lamp_layout.addLayout(modulator_side_layout) + + # Store checkboxes for later updates + self.lamp_checkboxes[lamp] = lamp_checkbox + self.modulator_checkboxes[lamp] = modulator_checkbox + + calibration_lamps_layout.addLayout(lamp_layout) + + # Add the Calibration Lamps section to the status layout + calibration_lamps_group.setLayout(calibration_lamps_layout) + status_layout.addWidget(calibration_lamps_group) + + # ---------------------------- + # Add the Seeing and Airmass status fields in the first row + # ---------------------------- + + # Create a horizontal layout to arrange Seeing and Airmass side by side + first_row_layout = QHBoxLayout() + + # Seeing (arcsec) field + seeing_layout = QVBoxLayout() + seeing_label = QLabel("Seeing (arcsec):") + self.seeing_input = QLineEdit() + self.seeing_input.setReadOnly(True) # Make it read-only + self.seeing_input.setText("N/A") # Placeholder text or dynamically updated value + + seeing_layout.addWidget(seeing_label) + seeing_layout.addWidget(self.seeing_input) + + # Airmass field + airmass_layout = QVBoxLayout() + airmass_label = QLabel("Airmass:") + self.airmass_input = QLineEdit() + self.airmass_input.setReadOnly(True) # Make it read-only + self.airmass_input.setText("N/A") # Placeholder text or dynamically updated value + + airmass_layout.addWidget(airmass_label) + airmass_layout.addWidget(self.airmass_input) + + # Add the individual layouts to the first row layout + first_row_layout.addLayout(seeing_layout) + first_row_layout.addLayout(airmass_layout) + + # Add the first row layout to the status layout + status_layout.addLayout(first_row_layout) + + # ---------------------------- + # Add the Binning and Slit Width Offset status fields in the second row + # ---------------------------- + + # Create a second row layout for Binning and Slit Width Offset + second_row_layout = QHBoxLayout() + + # Binning field + binning_layout = QVBoxLayout() + binning_label = QLabel("Binning:") + self.binning_input = QLineEdit() + self.binning_input.setReadOnly(True) # Make it read-only + self.binning_input.setText("N/A") # Placeholder text or dynamically updated value + + binning_layout.addWidget(binning_label) + binning_layout.addWidget(self.binning_input) + + # Slit Width Offset field + slit_width_layout = QVBoxLayout() + slit_width_label = QLabel("Slitwidth & Offset:") + self.slit_width_input = QLineEdit() + self.slit_width_input.setReadOnly(True) # Make it read-only + self.slit_width_input.setText("N/A") # Placeholder text or dynamically updated value + + slit_width_layout.addWidget(slit_width_label) + slit_width_layout.addWidget(self.slit_width_input) + + # Add the individual layouts to the second row layout + second_row_layout.addLayout(binning_layout) + second_row_layout.addLayout(slit_width_layout) + + # Add the second row layout to the status layout + status_layout.addLayout(second_row_layout) + + # Set the status section layout and add it to the second column + status_group.setLayout(status_layout) + second_column_top_half_layout.addWidget(status_group) + + # Optional: Set maximum size for the group if needed (can be adjusted depending on available space) + status_group.setMaximumHeight(350) # Adjust based on design + status_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Allow vertical resizing if needed + + # Return the layout for the second column's top half + second_column_top_half = QWidget() + second_column_top_half.setLayout(second_column_top_half_layout) + + return second_column_top_half + + def update_airmass(self, airmass): + """Update the airmass based on the incoming TCS status.""" + self.airmass_input.setText(str(airmass)) + + + def create_planning_info_group(self): + # Create a group box for planning information + planning_group = QGroupBox("Planning Information") + planning_layout = QHBoxLayout() + + # Create the left and right planning columns + left_planning_column = self.create_left_planning_column() + right_planning_column = self.create_right_planning_column() + + # Add the columns to the layout with stretch factors to control space allocation + planning_layout.addLayout(left_planning_column, stretch=2) # Left column takes more space + # Add a spacer to create a minimum gap between the left and right columns + spacer = QSpacerItem(20, 0, QSizePolicy.Fixed, QSizePolicy.Minimum) + planning_layout.addItem(spacer) + planning_layout.addLayout(right_planning_column, stretch=1) # Right column takes less space + + # Set the layout for the entire planning group + planning_group.setLayout(planning_layout) + + # Optional: Set maximum size and size policy for the entire planning group (optional) + planning_group.setMaximumHeight(350) # Max height + planning_group.setMaximumWidth(700) # Max width + + return planning_group + + def create_left_planning_column(self): + # Create the main vertical layout for the left planning column + left_planning_column = QVBoxLayout() + left_planning_column.setSpacing(10) + left_planning_column.setContentsMargins(0, 0, 0, 0) + + # Create the Start Date & Time edit + self.parent.start_date_time_edit = QDateTimeEdit() + self.parent.start_date_time_edit.setDateTime(QDateTime.currentDateTime()) + self.parent.start_date_time_edit.setDisplayFormat("MM/dd/yyyy h:mm AP") + self.parent.start_date_time_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + # Seeing input + self.parent.seeing = QLineEdit("1.0") + self.parent.seeing.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + # Airmass limit input + self.parent.airmass_limit = QLineEdit("2.0") + self.parent.airmass_limit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + # Target List Type dropdown (NEW) + target_list_type_layout = QHBoxLayout() + target_list_type_label = QLabel("Target List Type") + target_list_type_label.setMaximumHeight(40) + + self.target_list_type_dropdown = QComboBox() + self.target_list_type_dropdown.addItems(["Science", "Calibration"]) + self.target_list_type_dropdown.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + # For now, just print when it changes + self.target_list_type_dropdown.currentIndexChanged.connect( + lambda: print(f"Target List Type switched to: {self.target_list_type_dropdown.currentText()}") + ) + + target_list_type_layout.addWidget(target_list_type_label) + target_list_type_layout.addWidget(self.target_list_type_dropdown) + + # Target List name dropdown + self.target_list_name.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + # Start Date layout + start_date_layout = QVBoxLayout() + label = QLabel("Start Date & Time (PST)") + label.setMaximumHeight(40) + start_date_layout.addWidget(label) + start_date_layout.addWidget(self.parent.start_date_time_edit) + start_date_layout.setAlignment(Qt.AlignLeft) + + # Seeing layout + seeing_layout = QHBoxLayout() + seeing_layout.addWidget(QLabel("Seeing (arcsec)")) + seeing_layout.addWidget(self.parent.seeing) + seeing_layout.setAlignment(Qt.AlignLeft) + + # Airmass layout + airmass_layout = QHBoxLayout() + airmass_layout.addWidget(QLabel("Airmass Limit")) + airmass_layout.addWidget(self.parent.airmass_limit) + airmass_layout.setAlignment(Qt.AlignLeft) + + # Target List layout + target_list_layout = QVBoxLayout() + target_label = QLabel("Target List") + target_label.setMaximumHeight(40) + target_list_layout.addWidget(target_label) + target_list_layout.addWidget(self.target_list_name) + target_list_layout.setAlignment(Qt.AlignLeft) + + # Add layouts to the main column + left_planning_column.addLayout(start_date_layout) + left_planning_column.addLayout(seeing_layout) + left_planning_column.addLayout(airmass_layout) + left_planning_column.addLayout(target_list_type_layout) + left_planning_column.addLayout(target_list_layout) + + # Load target lists when first created + self.load_target_lists() + + return left_planning_column + + def load_target_lists(self, target_lists=None): + """Populate the ComboBox with target lists, switching between Science and Calibration modes.""" + try: + if self.target_list_mode_toggle.isChecked(): + # Calibration mode + print("Loading Calibration target lists...") + target_lists = self.logic_service.load_calibration_target_sets("config/db_config.ini") + + if not isinstance(target_lists, (list, tuple)): + print("Error: Calibration data is not a valid iterable.") + target_lists = [] + + else: + # Science mode + print("Loading Science target lists...") + if target_lists is None: + target_lists = self.logic_service.load_mysql_and_fetch_target_sets("config/db_config.ini") + + if not isinstance(target_lists, (list, tuple)): + print("Error: Fetched data is not a valid iterable.") + target_lists = [] + + except Exception as e: + print(f"Error fetching target lists: {e}") + target_lists = [] + + # Fallback if no data is found + if not target_lists: + target_lists = ["No Target Lists Available"] + self.add_row_button.setEnabled(False) + else: + self.add_row_button.setEnabled(True) + + # Populate the combo box + if isinstance(self.target_list_name, QComboBox): + self.target_list_name.blockSignals(True) + self.target_list_name.clear() + + for set_name in target_lists: + self.target_list_name.addItem(set_name) + + # Sentinels at the end + self.target_list_name.addItem("Upload new target list") + self.target_list_name.addItem("Create empty target list") + + # Prefer selecting what LogicService marked as current + preferred = getattr(self.parent, "current_target_list_name", None) + if preferred: + idx = self.target_list_name.findText(str(preferred)) + if idx >= 0: + self.target_list_name.setCurrentIndex(idx) + else: + self.target_list_name.setCurrentIndex(0 if target_lists else -1) + else: + self.target_list_name.setCurrentIndex(0 if target_lists else -1) + + self.target_list_name.blockSignals(False) + + # Rewire handler safely and trigger once + try: + self.target_list_name.currentIndexChanged.disconnect() + except TypeError: + pass + self.target_list_name.currentIndexChanged.connect( + lambda *_: self.on_target_set_changed() + ) + self.on_target_set_changed() + + + def upload_new_target_list(self): + """Handle creating a new target list, uploading CSV, and creating a new target set.""" + # remember where we were, so we can revert on cancel + prev_idx = self.target_list_name.currentIndex() + + # 1) File dialog (single file is enough) + file_dialog = QFileDialog(self.parent) + file_dialog.setFileMode(QFileDialog.ExistingFile) # was ExistingFiles + file_dialog.setNameFilter("CSV Files (*.csv)") + + if file_dialog.exec_() == QFileDialog.Accepted: + file_path = file_dialog.selectedFiles()[0] + + # 2) Ask for a name + target_set_name, ok = QInputDialog.getText(self.parent, "Enter Target Set Name", "Target Set Name:") + if ok and target_set_name: + # Avoid cascaded signals while we clear/update the combo + with QSignalBlocker(self.target_list_name): + self.target_list_name.clear() + # 3) Do the upload (your upload already refreshes + rebuilds lists) + self.logic_service.upload_csv_to_mysql(file_path, target_set_name) + # (Optional) set the selection to the new name without firing handler + with QSignalBlocker(self.target_list_name): + self.target_list_name.setCurrentText(target_set_name) + self.parent.reload_table() + else: + # Cancelled name → put the combo back to a normal item + with QSignalBlocker(self.target_list_name): + self.target_list_name.setCurrentIndex(max(0, min(prev_idx, self.target_list_name.count() - 2))) + print("Target set creation cancelled or no name provided.") + else: + # Cancelled file → put the combo back to a normal item + with QSignalBlocker(self.target_list_name): + self.target_list_name.setCurrentIndex(max(0, min(prev_idx, self.target_list_name.count() - 2))) + print("File selection cancelled.") + + + def on_target_set_changed(self, *_): + combo = self.target_list_name + idx = combo.currentIndex() + text = combo.currentText().strip() + + # enable Add Row only on real sets + self.add_row_button.setEnabled(text not in ("Upload new target list", "Create empty target list", "No Target Lists Available", "")) + + last_real = max(0, combo.count() - 3) + + if text == "Upload new target list": + with QSignalBlocker(combo): + combo.setCurrentIndex(last_real) + self.upload_new_target_list() + return + + if text == "Create empty target list": + with QSignalBlocker(combo): + combo.setCurrentIndex(last_real) + name, ok = QInputDialog.getText(self.parent, "Create empty target list", "Target list name:") + if ok and name.strip(): + # create empty set; UI refreshes to show the newest (empty) set + self.logic_service.create_empty_target_set(name.strip()) + return + + # Real selection → remember name and filter from DB + self.parent.current_target_list_name = text + self.logic_service.filter_target_list() + + + def on_target_set_changed(self, *_): + combo = self.target_list_name + idx = combo.currentIndex() + text = combo.currentText().strip() + + # enable Add Row only on real sets + self.add_row_button.setEnabled(text not in ("Upload new target list", "Create empty target list", "No Target Lists Available", "")) + + last_real = max(0, combo.count() - 3) + + if text == "Upload new target list": + with QSignalBlocker(combo): + combo.setCurrentIndex(last_real) + self.upload_new_target_list() + return + + if text == "Create empty target list": + with QSignalBlocker(combo): + combo.setCurrentIndex(last_real) + name, ok = QInputDialog.getText(self.parent, "Create empty target list", "Target list name:") + if ok and name.strip(): + # create empty set; UI refreshes to show the newest (empty) set + self.logic_service.create_empty_target_set(name.strip()) + return + + # Real selection → remember name and filter from DB + self.parent.current_target_list_name = text + self.logic_service.filter_target_list() + self.hide_default_columns() + + def create_right_planning_column(self): + right_planning_column = QVBoxLayout() + right_planning_column.setSpacing(10) + right_planning_column.setContentsMargins(0, 0, 0, 0) # Optional: Remove margins for better alignment + + # Create and add widgets to the right column + self.parent.utc_start_time = QLineEdit("12:00:00 UTC") + self.parent.utc_start_time.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Ensure this expands correctly + + self.parent.twilight_button = QPushButton("Twilight") + self.parent.twilight_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Same width for both buttons + + self.parent.twilight_auto_checkbox = QCheckBox("Auto") + + self.parent.fetch_live_button = QPushButton("Fetch Live") + self.parent.fetch_live_button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Same width for both buttons + + self.parent.fetch_live_auto_checkbox = QCheckBox("Auto") + + # Create horizontal layouts for Twilight and Fetch Live buttons + checkboxes + twilight_layout = QHBoxLayout() + twilight_layout.addWidget(self.parent.twilight_button) + twilight_layout.addWidget(self.parent.twilight_auto_checkbox) + twilight_layout.setAlignment(Qt.AlignLeft) # Align Twilight button and checkbox to the left + + fetch_live_layout = QHBoxLayout() + fetch_live_layout.addWidget(self.parent.fetch_live_button) + fetch_live_layout.addWidget(self.parent.fetch_live_auto_checkbox) + fetch_live_layout.setAlignment(Qt.AlignLeft) # Align Fetch Live button and checkbox to the left + + # Add the widgets to the right column layout + right_planning_column.addWidget(QLabel("UTC Start Time")) + right_planning_column.addWidget(self.parent.utc_start_time) + right_planning_column.addLayout(twilight_layout) # Twilight button and checkbox side by side + right_planning_column.addLayout(fetch_live_layout) # Fetch Live button and checkbox side by side + + # If there's an additional layout like checkboxes, add it + check_x_layout = self.create_check_x_layout() # Assuming this method exists + right_planning_column.addLayout(check_x_layout) + + # Add stretch for proportional resizing (optional) + right_planning_column.setStretch(0, 1) + + return right_planning_column + + + def create_check_x_layout(self): + check_x_layout = QHBoxLayout() + check_x_layout.setSpacing(10) + + # Checkmark button + self.parent.giant_checkmark_button = QPushButton("\u2713") + self.parent.giant_checkmark_button.setStyleSheet(""" + QPushButton { + background-color: green; + color: white; + font-size: 30px; + width: 30px; + height: 30px; + text-align: center; + border: none; + border-radius: 10px; + } + QPushButton:hover { + background-color: darkgreen; + } + """) + + # X button + self.parent.giant_x_button = QPushButton("\u2717") + self.parent.giant_x_button.setStyleSheet(""" + QPushButton { + background-color: red; /* Red background */ + color: white; /* White text color */ + font-size: 30px; + width: 30px; + height: 30px; + text-align: center; + border: none; + border-radius: 10px; + } + QPushButton:hover { + background-color: darkred; /* Dark red background on hover */ + } + """) + + # Add buttons to the layout + check_x_layout.addWidget(self.parent.giant_checkmark_button) + check_x_layout.addWidget(self.parent.giant_x_button) + + return check_x_layout + + + def create_etc_tab(self): + """Create the layout and components for the 'ETC' tab with aligned labels and input boxes.""" + + # Main vertical layout for stacking all the components + etc_layout = QVBoxLayout() + etc_layout.setSpacing(12) + etc_layout.setContentsMargins(10, 10, 10, 10) # Add some space around the whole layout + + # Common dimensions + uniform_input_width = 110 # Set the width of all input fields to half of the previous value + widget_height = 35 # Set a reasonable widget height + dropdown_width = 60 # Keep dropdown width consistent with input fields + + # Helper function to create aligned labels + def create_aligned_label(text): + label = QLabel(text) + label.setFixedWidth(uniform_input_width) # Fixed width to align with the input fields + label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + label.setStyleSheet("font-size: 14pt;") # Set font size for consistency + return label + + # Function to create a row with label and input field(s) + def create_input_row(label_text, *widgets): + row_layout = QHBoxLayout() + label = create_aligned_label(label_text) + row_layout.addWidget(label) + for widget in widgets: + row_layout.addWidget(widget) + widget.setFixedHeight(widget_height) # Uniform height for all widgets + widget.setFixedWidth(uniform_input_width) # Set the same width for all widgets + widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) # Prevent stretching + return row_layout + + # Row 0: Magnitude, Filter, and System + self.magnitude_input = QLineEdit() + self.magnitude_input.setFixedWidth(uniform_input_width) + + self.filter_dropdown = QComboBox() + self.filter_dropdown.addItems(["U", "G", "R", "I"]) + self.filter_dropdown.setFixedWidth(dropdown_width) + + self.system_field = QLineEdit("AB") + self.system_field.setReadOnly(True) + self.system_field.setFixedWidth(uniform_input_width) + + input_row_0 = create_input_row("Magnitude:", self.magnitude_input, self.filter_dropdown, self.system_field) + etc_layout.addLayout(input_row_0) + + # Row 1: Sky Mag and SNR (labels in one line with input fields) + self.sky_mag_input = QLineEdit() + self.sky_mag_input.setFixedWidth(uniform_input_width) + + self.snr_input = QLineEdit() + self.snr_input.setFixedWidth(uniform_input_width) + self.snr_input.setFixedHeight(widget_height) + + # Add 'SNR' label next to 'Sky Mag' input field + input_row_1 = create_input_row("Sky Mag:", self.sky_mag_input) + input_row_1.addWidget(create_aligned_label("SNR: ")) # Add 'SNR' label next to 'Sky Mag' + input_row_1.addWidget(self.snr_input) + etc_layout.addLayout(input_row_1) + + # Row 2: Slit Width and Slit Width Dropdown + self.slit_width_input = QLineEdit() + self.slit_width_input.setFixedWidth(uniform_input_width) + + self.slit_dropdown = QComboBox() + self.slit_dropdown.addItems(["SET", "LOSS", "SNR", "RES", "AUTO"]) + self.slit_dropdown.setFixedWidth(dropdown_width) + + input_row_2 = create_input_row("Slit Width:", self.slit_width_input, self.slit_dropdown) + etc_layout.addLayout(input_row_2) + + # Row 3: Range and No Slicer + self.range_input_start = QLineEdit() + self.range_input_start.setFixedWidth(uniform_input_width) + self.range_input_start.setFixedHeight(widget_height) + + range_dash = QLabel("-") + range_dash.setFixedWidth(10) + + self.range_input_end = QLineEdit() + self.range_input_end.setFixedWidth(uniform_input_width) + self.range_input_end.setFixedHeight(widget_height) + + self.no_slicer_checkbox = QCheckBox("No Slicer") + self.no_slicer_checkbox.setFixedHeight(widget_height) + + range_layout = QHBoxLayout() + range_layout.setSpacing(10) + range_layout.addWidget(create_aligned_label("Range:")) + range_layout.addWidget(self.range_input_start) + range_layout.addWidget(range_dash) + range_layout.addWidget(self.range_input_end) + range_layout.addWidget(self.no_slicer_checkbox) + + etc_layout.addLayout(range_layout) + + # Row 4: Seeing (arcsec) and Airmass + self.seeing_input = QLineEdit() + self.seeing_input.setFixedWidth(uniform_input_width) + + self.airmass_input = QLineEdit() + self.airmass_input.setFixedWidth(uniform_input_width) + self.airmass_input.setFixedHeight(widget_height) + + input_row_4 = create_input_row("Seeing:", self.seeing_input) + input_row_4.addWidget(create_aligned_label("Airmass: ")) # Add 'SNR' label next to 'Sky Mag' + input_row_4.addWidget(self.airmass_input) + etc_layout.addLayout(input_row_4) + + # Row 5: EXPTIME and Resolution + self.exptime_input = QLineEdit() + self.exptime_input.setFixedWidth(uniform_input_width) + + self.resolution_input = QLineEdit() + self.resolution_input.setFixedWidth(uniform_input_width) + self.resolution_input.setFixedHeight(widget_height) + + input_row_5 = create_input_row("Exp Time:", self.exptime_input) + input_row_5.addWidget(create_aligned_label("Resolution: ")) # Add 'SNR' label next to 'Sky Mag' + input_row_5.addWidget(self.resolution_input) + etc_layout.addLayout(input_row_5) + + # Divider line between the two columns + divider_line = QFrame() + divider_line.setFrameShape(QFrame.HLine) + divider_line.setFrameShadow(QFrame.Sunken) + + etc_layout.addWidget(divider_line) + + # Buttons in the layout + button_row_layout = QHBoxLayout() + button_row_layout.setSpacing(10) + + run_button = QPushButton("Run ETC") + run_button.setFixedSize(110, 45) + run_button.clicked.connect(self.run_etc) + + save_button = QPushButton("Save") + save_button.setFixedSize(100, 45) + save_button.setEnabled(False) # Initially disable the Save button + save_button.clicked.connect(self.save_etc) + + button_row_layout.addWidget(run_button) + button_row_layout.addWidget(save_button) + + # Add a spacer after the buttons to create margin below + spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) + button_row_layout.addItem(spacer) + + etc_layout.addLayout(button_row_layout) + + # Set the layout for the ETC tab + self.parent.etc.setLayout(etc_layout) + + # Add a spacer to ensure widgets aren't squished + spacer = QSpacerItem(20, 30, QSizePolicy.Minimum, QSizePolicy.Expanding) + etc_layout.addItem(spacer) + + def create_target_dropdown_group(self): + # Create the group box for Target List + target_dropdown_group = QGroupBox("Target List") + target_dropdown_layout = QVBoxLayout() + target_dropdown_layout.setSpacing(10) + + # Create the toggle button + self.target_list_mode_toggle = QPushButton("Mode: Science") + self.target_list_mode_toggle.setCheckable(True) + self.target_list_mode_toggle.setMaximumWidth(200) + + # Set toggle styles (optional) + self.target_list_mode_toggle.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; + color: white; + font-weight: bold; + border-radius: 6px; + padding: 4px; + } + QPushButton:checked { + background-color: #2196F3; + } + """) + + # Toggle between "Science" and "Calibration" + self.target_list_mode_toggle.toggled.connect(self.on_toggle_target_mode) + + # Add the toggle button to the layout + target_dropdown_layout.addWidget(self.target_list_mode_toggle) + + # Add the target list name combo box + self.target_list_name = QComboBox() + self.target_list_name.setMaximumWidth(250) + target_dropdown_layout.addWidget(self.target_list_name) + + # Load the initial target lists + self.load_target_lists() + + target_dropdown_group.setLayout(target_dropdown_layout) + target_dropdown_group.setMaximumWidth(300) + target_dropdown_group.setMaximumHeight(150) + + return target_dropdown_group + + def on_toggle_target_mode(self, checked): + # Update button text + if checked: + self.target_list_mode_toggle.setText("Mode: Calibration") + else: + self.target_list_mode_toggle.setText("Mode: Science") + + # Reload target lists based on the new mode + self.load_target_lists() + + def update_lamps(self, lamp_states): + """Update the lamp checkboxes based on the received state.""" + + # Mapping of incoming lamp names to checkbox names + lamp_name_map = { + 'LAMPBLUC': 'BlueCont', + 'LAMPFEAR': 'FeAr', + 'LAMPREDC': 'RedCont', + 'LAMPTHAR': 'ThAR' + } + + for lamp, state in lamp_states.items(): + mapped_lamp = lamp_name_map.get(lamp) + + if mapped_lamp: + checkbox = self.lamp_checkboxes.get(mapped_lamp) + + if checkbox: + checkbox.setChecked(state) + checkbox.setText("On" if state else "Off") + + # Optional: apply a visual style for on/off + if state: + checkbox.setStyleSheet(""" + QCheckBox::indicator:checked { + background-color: green; + border: 2px solid darkgreen; + } + """) + else: + checkbox.setStyleSheet(""" + QCheckBox::indicator:unchecked { + background-color: gray; + border: 2px solid darkgray; + } + """) + else: + print(f"Checkbox for {mapped_lamp} not found") + else: + print(f"Lamp {lamp} not mapped to a checkbox") + + def update_modulators(self, modulator_states): + """Update the modulator checkboxes based on the received state.""" + + # Mapping of incoming modulator names to checkbox names + modulator_name_map = { + 'MODTHAR': 'ThAR', + 'MODFEAR': 'FeAr', + 'MODRDCON': 'RedCont', + 'MODBLCON': 'BlueCont' + } + + for modulator, state in modulator_states.items(): + mapped_modulator = modulator_name_map.get(modulator) + + if mapped_modulator: + checkbox = self.modulator_checkboxes.get(mapped_modulator) + + if checkbox: + print(f"Setting {mapped_modulator} checkbox to {state}") + checkbox.setChecked(state) + checkbox.setText("On" if state else "Off") + + # Style the checkbox based on whether it's checked or not + if state: + checkbox.setStyleSheet(""" + QCheckBox::indicator:checked { + background-color: green; + border: 2px solid darkgreen; + } + """) + else: + checkbox.setStyleSheet(""" + QCheckBox::indicator:unchecked { + background-color: gray; + border: 2px solid darkgray; + } + """) + else: + print(f"Checkbox for {mapped_modulator} not found") + else: + print(f"Modulator {modulator} not mapped to a checkbox") + diff --git a/pygui/logic_service.py b/pygui/logic_service.py new file mode 100644 index 00000000..10b86ab2 --- /dev/null +++ b/pygui/logic_service.py @@ -0,0 +1,1132 @@ +import mysql.connector +import configparser +from PyQt5.QtWidgets import QTableWidgetItem +from PyQt5.QtCore import Qt, pyqtSignal, QSignalBlocker +from PyQt5.QtGui import QColor, QFont +import os +import csv +import pytz +from astropy.coordinates import SkyCoord, EarthLocation +from astropy.time import Time +from astroplan import Observer +import astropy.units as u +import datetime +from contextlib import contextmanager + +class LogicService: + def __init__(self, parent): + self.parent = parent # reference to the parent window or main UI + self.connection = None + self.all_targets = [] + self.target_list_set = {} + self.target_list_display = None + + def _connect_unique(self, signal, slot): + """Disconnect slot if already connected, then connect once.""" + try: + signal.disconnect(slot) + except TypeError: + pass + signal.connect(slot) + + def _get_target_combo(self): + """ + Return the QComboBox used for target list selection, whether it lives on + the main window or inside layout_service. Returns None if not found. + """ + return getattr(self.parent, "current_target_list_name", None) + + @contextmanager + def _maybe_block(self, widget): + """Context manager to block signals if widget is not None.""" + blocker = None + if widget is not None: + blocker = QSignalBlocker(widget) + try: + yield + finally: + if blocker is not None: + del blocker + + + @staticmethod + def convert_pst_to_utc(datetime): + # Convert from PST to UTC + timezone_pst = pytz.timezone('US/Pacific') + timezone_utc = pytz.utc + start_time_pst = datetime.toPyDateTime() + start_time_pst = timezone_pst.localize(start_time_pst) + start_time_utc = start_time_pst.astimezone(timezone_utc) + return start_time_utc + + def load_csv_and_update_target_list(self, file_path): + # Step 1: Read CSV file + try: + with open(file_path, 'r') as file: + reader = csv.DictReader(file) # Read CSV into a dictionary + data = list(reader) # Convert to a list of rows + + # Step 2: Update the table with the CSV data + self.update_target_list_table(data) + + except Exception as e: + print(f"Error loading CSV file: {e}") + + def upload_csv_to_mysql(self, file_path, target_set_name): + """Upload the CSV to MySQL and associate it with a new target set.""" + + # Step 1: Read the CSV + try: + with open(file_path, 'r') as file: + reader = csv.DictReader(file) + data = list(reader) # Convert CSV to list of dictionaries + print(f"CSV file loaded. Total rows: {len(data)}") + except Exception as e: + print(f"Error reading CSV file: {e}") + return + + # Step 2: Insert a new entry into the `target_sets` table + try: + connection = self.connect_to_mysql("config/db_config.ini") # Assuming you have a method to connect to the DB + + if connection is None: + print("Failed to connect to MySQL. Cannot refresh table.") + return + + cursor = self.connection.cursor() + cursor.execute("INSERT INTO target_sets (SET_NAME, OWNER, SET_CREATION_TIMESTAMP) VALUES (%s, %s, NOW())", + (target_set_name, self.parent.current_owner)) # Use the current owner's info + self.connection.commit() + + # Step 3: Fetch the new SET_ID (for the newly inserted target set) + cursor.execute("SELECT LAST_INSERT_ID()") + set_id = cursor.fetchone()[0] + print(f"New target set created with SET_ID: {set_id}") + cursor.close() + + # Step 4: Dynamically construct the insert query + cursor = self.connection.cursor() + + # Get the columns in the CSV file (i.e., DictReader's fieldnames) + csv_columns = reader.fieldnames # List of column names from the CSV + print(f"CSV Columns: {csv_columns}") + + # Define all columns from your table `targets` (based on your schema) + all_columns = [ + 'OBSERVATION_ID', 'STATE', 'OBS_ORDER', 'TARGET_NUMBER', 'SEQUENCE_NUMBER', 'NAME', + 'RA', 'DECL', 'OFFSET_RA', 'OFFSET_DEC', 'EXPTIME', 'SLITWIDTH', 'SLITOFFSET', 'OBSMODE', + 'BINSPECT', 'BINSPAT', 'SLITANGLE', 'AIRMASS_MAX', 'WRANGE_LOW', 'WRANGE_HIGH', 'CHANNEL', + 'MAGNITUDE', 'MAGSYSTEM', 'MAGFILTER', 'SRCMODEL', 'OTMexpt', 'OTMslitwidth', 'OTMcass', + 'OTMairmass_start', 'OTMairmass_end', 'OTMsky', 'OTMdead', 'OTMslewgo', 'OTMexp_start', + 'OTMexp_end', 'OTMpa', 'OTMwait', 'OTMflag', 'OTMlast', 'OTMslew', 'OTMmoon', 'OTMSNR', + 'OTMres', 'OTMseeing', 'OTMslitangle', 'NOTE', 'COMMENT', 'OWNER', 'NOTBEFORE', 'POINTMODE', 'PRIORITY' + ] + + # Step 5: Loop through each row and dynamically generate the insert query + for idx, row in enumerate(data): + row_data = [set_id] # Start with the SET_ID as the first element in row_data + insert_columns = ['SET_ID'] # Always include SET_ID + insert_placeholders = ['%s'] # Placeholder for SET_ID + + print(f"Inserting row {idx + 1}: {row}") + + # Step 6: Add only non-empty fields to the insert query + for column in all_columns: + value = row.get(column) # Get the value from the CSV, default to None if not present + + # Handle missing columns (apply defaults based on column type) + if value is None or value == '': + # Numeric columns (defaults to 0) + if column in ['OBS_ORDER', 'TARGET_NUMBER', 'SEQUENCE_NUMBER']: + value = 0 + elif column in ['BINSPECT']: + value = 1 + elif column in ['BINSPAT']: + value = 2 + elif column in ['STATE']: + value = "pending" # Empty string as default for text fields + elif column in ['STATE', 'RA', 'DECL', 'EXPTIME', 'SLITWIDTH']: + value = "" # Empty string as default for text fields + # Timestamp columns (defaults to NULL) + elif column in ['NOTBEFORE', 'OTMslewgo', 'OTMexp_start', 'OTMexp_end']: + value = None # Default to NULL for timestamps + else: + value = None # Default to NULL for other columns without a defined default + + # Special case: if `OFFSET_RA` or `OFFSET_DEC` are empty, set them to 0.0 + if column in ['OFFSET_RA', 'OFFSET_DEC'] and (value == '' or value is None): + value = 0.0 # Default to 0.0 if empty or None + + # Special case: if "PRIORITY" is empty, set it to "1" + if column == "PRIORITY" and (value == '' or value is None): + value = "1" # Default to "1" if empty or None + + # Add the column and value to the query + insert_columns.append(column) + insert_placeholders.append('%s') + row_data.append(value) + + # Build the dynamic insert query + insert_columns_str = ", ".join(insert_columns) + insert_placeholders_str = ", ".join(insert_placeholders) + insert_query = f""" + INSERT INTO targets ({insert_columns_str}) + VALUES ({insert_placeholders_str}) + """ + + print(f"Executing query: {insert_query}") + print(f"With data: {row_data}") + + # Execute the insert query with dynamically generated values + cursor.execute(insert_query, row_data) + + # Commit the transaction + self.connection.commit() + + print(f"Successfully uploaded {len(data)} targets to the new set {target_set_name}.") + # Emit the signal after the upload is complete + self.fetch_and_update_target_list() + + except mysql.connector.Error as err: + print(f"Error inserting data into MySQL: {err}") + self.parent.show_popup("Error uploading target list! Please try again.") + + def get_or_create_target_set(self, connection, target_set_name): + """ + Checks if a target set with the given name exists. If it exists, returns the existing `SET_ID`. + If it doesn't exist, creates a new target set and returns the new `SET_ID`. + """ + try: + cursor = connection.cursor() + + # Check if the target set already exists by name + query = "SELECT SET_ID FROM target_sets WHERE SET_NAME = %s" + cursor.execute(query, (target_set_name,)) + result = cursor.fetchone() + + if result: + # If the target set exists, return the existing SET_ID + set_id = result[0] + print(f"Found existing target set with SET_ID: {set_id}") + else: + # If not, create a new target set and get the new SET_ID + set_id = self.create_target_set(connection, target_set_name) + + cursor.close() + return set_id + + except mysql.connector.Error as err: + print(f"Error checking or creating target set: {err}") + return None + + def create_target_set(self, connection, target_set_name): + """ + Creates a new entry in the `target_sets` table and returns the new `SET_ID`. + """ + try: + cursor = connection.cursor() + + # Get the current timestamp for the new target set creation + current_timestamp = pytz.utc.localize(datetime.datetime.utcnow()).strftime('%Y-%m-%d %H:%M:%S') + + # Insert the new target set into the `target_sets` table + query = """ + INSERT INTO target_sets (SET_NAME, SET_CREATION_TIMESTAMP) + VALUES (%s, %s) + """ + cursor.execute(query, (target_set_name, current_timestamp)) + + # Commit the transaction to make sure the SET_ID is generated + connection.commit() + + # Get the newly created SET_ID + cursor.execute("SELECT LAST_INSERT_ID();") + set_id = cursor.fetchone()[0] + + cursor.close() + + print(f"Created new target set with SET_ID: {set_id}") + return set_id + + except mysql.connector.Error as err: + print(f"Error creating target set: {err}") + return None + + def insert_targets_into_mysql(self, connection, data, set_id): + """ + Inserts the data into the `targets` table, linking each target to the given `SET_ID`. + """ + try: + cursor = connection.cursor() + + # Insert each target in the CSV into the `targets` table, linking it to the `SET_ID` + for row in data: + # Add the SET_ID to the row data before inserting + row['SET_ID'] = set_id + + # Prepare the SQL query for insertion: matching columns + columns = ', '.join(row.keys()) # Column names from CSV (and SET_ID) + values = ', '.join(['%s'] * len(row)) # Placeholder for values + query = f"INSERT INTO targets ({columns}) VALUES ({values})" + + # Execute the insert query + cursor.execute(query, tuple(row.values())) + + # Commit the transaction + connection.commit() + print(f"Successfully uploaded {len(data)} rows to the 'targets' table.") + + except mysql.connector.Error as err: + print(f"Error inserting data into MySQL: {err}") + + finally: + cursor.close() + + def read_config(self, config_file): + """ + Reads configuration from the provided file. + This function should load and return the configuration (host, user, password, etc.) + """ + # Assuming a simple INI format; you'd implement the actual reading logic here + # This is a simplified version for demonstration + config = {} + try: + with open(config_file, "r") as f: + for line in f: + if line.strip() and not line.startswith(";"): + key, value = line.split("=") + config[key.strip()] = value.strip() + except Exception as e: + print(f"Error reading config file: {e}") + return config + + def connect_to_mysql(self, config_file): + """ + Connect to MySQL using the configuration file and return the connection object. + Logs connection and error details. + """ + db_config = self.read_config(config_file) + + # Log the connection attempt + print("Attempting to connect to MySQL database...") + + try: + # Connect to MySQL without selecting a database + self.connection = mysql.connector.connect( + host=db_config["SYSTEM"], # Hostname from the config file + user=db_config["USERNAME"], # MySQL user from the config file + password=db_config["PASSWORD"], # Password from the config file + ) + + # Log that the connection to the MySQL server was successful + print(f"Successfully connected to MySQL server: {db_config['SYSTEM']}.") + + # Select the database after establishing the connection + cursor = self.connection.cursor() + cursor.execute(f"USE {db_config['DBMS']};") # Ensure we are using the correct database + print(f"Successfully selected database: {db_config['DBMS']}.") + + # Optionally, you can verify the database selection with the following: + cursor.execute("SELECT DATABASE();") + current_db = cursor.fetchone()[0] + print(f"Currently connected to database: {current_db}") + + cursor.close() + + # Return the connection object after ensuring the correct database is selected + return self.connection + + except mysql.connector.Error as err: + # If an error occurs, log the error and set connection to None + print(f"Error connecting to MySQL: {err}") + self.connection = None + + def close_connection(self): + """ + Closes the current MySQL connection if it exists. + """ + if self.connection: + self.connection.close() + print("MySQL connection closed.") + self.connection = None + + + def load_data_from_mysql(self, connection, target_table): + """ + Loads target data from MySQL using the provided connection. + Returns the fetched rows from the target table. + """ + try: + cursor = connection.cursor(dictionary=True) # Use dictionary for column name access + + # Query the target data from the database (modify as needed) + cursor.execute(f"SELECT * FROM {target_table}") + rows = cursor.fetchall() # Fetch all rows + + cursor.close() + return rows + except mysql.connector.Error as err: + print(f"Error executing query: {err}") + return [] + + def load_mysql_and_update_target_list(self, config_file): + """ + Loads target data from MySQL and updates the target list table. + Prevents duplicate signal connections and cascading refreshes. + """ + connection = self.connect_to_mysql(config_file) + if connection is None: + print("Failed to connect to MySQL. Cannot load target data.") + return + + db_config = self.read_config(config_file) + target_table = db_config.get("TARGET_TABLE") + rows = self.load_data_from_mysql(connection, target_table) + if not rows: + print(f"No data found in the {target_table} table.") + return + + # Update table (idempotent + signal-safe) + self.update_target_list_table(rows) + + # Build / refresh the combo quietly, then wire the filter once + combo = self._get_target_combo() + with self._maybe_block(combo): + if combo is not None: + target_list_names = sorted({str(row.get('SET_ID')) for row in rows if 'SET_ID' in row}) + combo.clear() + combo.addItem("All") + combo.addItems(target_list_names) + + if combo is not None: + self._connect_unique(combo.currentIndexChanged, self.filter_target_list) + + + def load_mysql_and_fetch_target_sets(self, config_file): + """ + Load ALL target sets for current user (newest first), but auto-select the most recent. + Returns a list of SET_NAMEs for the combo; stores {SET_ID: SET_NAME} mapping on parent. + """ + username = getattr(self.parent, "current_owner", None) + if not username: + print("No owner information found. Cannot load target sets.") + return [] + + conn = self.connect_to_mysql(config_file) + if conn is None: + print("Failed to connect to MySQL.") + return [] + + try: + cur = conn.cursor(dictionary=True) + cur.execute(""" + SELECT SET_ID, SET_NAME, + COALESCE(SET_CREATION_TIMESTAMP,'1970-01-01 00:00:00') AS ts + FROM target_sets + WHERE OWNER = %s + ORDER BY ts DESC, SET_ID DESC + """, (username,)) + sets = cur.fetchall() + cur.close() + + if not sets: + print(f"No target sets found for user '{username}'.") + self.set_data = {} + self.set_name = [] + setattr(self.parent, "user_set_data", {}) + return [] + + # Map + list of names + self.set_data = {row["SET_ID"]: row["SET_NAME"] for row in sets} + self.set_name = [row["SET_NAME"] for row in sets] + self.parent.user_set_data = self.set_data + + # Auto-select the newest by name (the first row) + self.parent.current_target_list_name = sets[0]["SET_NAME"] + + return self.set_name + + except mysql.connector.Error as err: + print(f"Database error: {err}") + return [] + finally: + conn.close() + + + def load_calibration_target_sets(self, config_file): + """ + Loads calibration target sets (SET_NAME starting with 'CAL_') for the current user. + Returns a list of filtered SET_NAMEs for UI population. + """ + username = getattr(self.parent, "current_owner", None) + if not username: + print("No owner information found. Cannot load calibration target sets.") + return [] + + connection = self.connect_to_mysql(config_file) + if connection is None: + print("Failed to connect to MySQL.") + return [] + + try: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT SET_ID, SET_NAME FROM target_sets WHERE OWNER = %s AND SET_NAME LIKE 'CAL\\_%'", + (username,) + ) + set_data = cursor.fetchall() + + if not set_data: + print(f"No calibration sets found for user '{username}'.") + return [] + + 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.all_targets = [] + for row in set_data: + cursor.execute("SELECT * FROM targets WHERE SET_ID = %s", (row["SET_ID"],)) + targets = cursor.fetchall() + self.all_targets.extend(targets) + + print(f"Fetched {len(self.set_name)} calibration target sets.") + return self.set_name + + except mysql.connector.Error as err: + print(f"Database error: {err}") + return [] + finally: + connection.close() + + + def fetch_set_id(self, target_list=None): + """ + Resolve the current target list to a SET_ID using parent.current_target_list_name + (or an explicit target_list arg). Handles numeric IDs or names. + """ + # Use explicit arg if provided; otherwise read the string you store on the parent + name_or_id = target_list + if name_or_id is None: + name_or_id = getattr(self.parent, "current_target_list_name", None) + + if name_or_id is None: + return None + + s = str(name_or_id).strip() + + # Ignore non-real selections/sentinels + if s in ("All", "Create a new target list", "No Target Lists Available", ""): + return None + + # If it's already an ID (string of digits or int), return it + if isinstance(name_or_id, int) or s.isdigit(): + try: + return int(name_or_id) + except Exception: + return int(s) + + # Try in-memory mapping first: {SET_ID: SET_NAME} + mapping = getattr(self.parent, "user_set_data", {}) or {} + for sid, name in mapping.items(): + if str(name).strip().lower() == s.lower(): + try: + return int(sid) + except Exception: + return sid # sid may already be int + + # Last resort: DB lookup by (OWNER, SET_NAME) + owner = getattr(self.parent, "current_owner", None) + if not owner: + return None + + conn = self.connect_to_mysql("config/db_config.ini") + if not conn: + return None + + try: + cur = conn.cursor() + cur.execute( + "SELECT SET_ID FROM target_sets WHERE OWNER = %s AND SET_NAME = %s LIMIT 1", + (owner, s) + ) + row = cur.fetchone() + cur.close() + if row: + return int(row[0]) + except Exception as e: + print("fetch_set_id DB lookup failed:", e) + + return None + + + def fetch_and_update_target_list(self): + """After creating/uploading a set: refresh ALL sets, select newest, and show its rows.""" + username = getattr(self.parent, "current_owner", None) + if not username: + print("No owner information found. Cannot fetch target list.") + return + + conn = self.connect_to_mysql("config/db_config.ini") + if not conn: + print("Failed to connect to MySQL. Cannot fetch target list.") + return + + try: + cur = conn.cursor(dictionary=True) + cur.execute(""" + SELECT SET_ID, SET_NAME, + COALESCE(SET_CREATION_TIMESTAMP,'1970-01-01 00:00:00') AS ts + FROM target_sets + WHERE OWNER = %s + ORDER BY ts DESC, SET_ID DESC + """, (username,)) + sets = cur.fetchall() + + if not sets: + self.set_data = {} + self.set_name = [] + self.parent.user_set_data = {} + if hasattr(self.parent, "layout_service"): + self.parent.layout_service.load_target_lists([]) + self.update_target_list_table([]) + return + + # Update mapping + names (ALL sets) + self.set_data = {row["SET_ID"]: row["SET_NAME"] for row in sets} + self.set_name = [row["SET_NAME"] for row in sets] + self.parent.user_set_data = self.set_data + + # Select newest + latest = sets[0] + self.parent.current_target_list_name = latest["SET_NAME"] + + # Update combo with ALL sets + if hasattr(self.parent, "layout_service"): + self.parent.layout_service.load_target_lists(self.set_name) + + # Show rows for newest set + cur2 = conn.cursor(dictionary=True) + cur2.execute("SELECT * FROM targets WHERE SET_ID = %s", (latest["SET_ID"],)) + rows = cur2.fetchall() + cur2.close() + self.update_target_list_table(rows) + + print(f"Showing latest set '{latest['SET_NAME']}' with {len(rows)} rows; {len(self.set_name)} sets in combo.") + except mysql.connector.Error as err: + print(f"Database error: {err}") + finally: + conn.close() + + + def insert_target_to_db(self, target_name, ra, decl, + offset_ra=None, offset_dec=None, + exptime=None, slitwidth=None, magnitude=None): + """ + Insert or update a target in ngps.targets for the current SET_ID. + Uses an UPSERT to avoid duplicates if called multiple times. + Requires a UNIQUE key (e.g., (SET_ID, Name, RA, Decl)). + """ + if not target_name or not ra or not decl: + print("Error: Name, RA, and Decl are required fields.") + return + + set_id = self.fetch_set_id() + if set_id is None: + print("Error: Unable to fetch set_id. No matching target list found.") + return + + # Reuse an existing connection if possible + conn = getattr(self, "connection", None) + if conn is None or not conn.is_connected(): + conn = self.connect_to_mysql("config/db_config.ini") + if conn is None: + print("Failed to connect to MySQL. Cannot insert target data.") + return + close_after = True + else: + close_after = False + + try: + cursor = conn.cursor() + query = """ + INSERT INTO ngps.targets + (SET_ID, Name, RA, Decl, Offset_RA, Offset_Dec, EXPTime, Slitwidth, Magnitude) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + Offset_RA = VALUES(Offset_RA), + Offset_Dec = VALUES(Offset_Dec), + EXPTime = VALUES(EXPTime), + Slitwidth = VALUES(Slitwidth), + Magnitude = VALUES(Magnitude) + """ + data = ( + set_id, target_name, ra, decl, + offset_ra if offset_ra is not None else None, + offset_dec if offset_dec is not None else None, + exptime if exptime is not None else None, + slitwidth if slitwidth is not None else None, + magnitude if magnitude is not None else None + ) + cursor.execute(query, data) + conn.commit() + print(f"Upserted target '{target_name}' in SET_ID {set_id}.") + except mysql.connector.IntegrityError as err: + # Fires if the UNIQUE key is different from the values you expect + print(f"Integrity error on upsert: {err}") + except mysql.connector.Error as err: + print(f"Error executing insert/upsert query: {err}") + finally: + try: + cursor.close() + except Exception: + pass + if close_after: + try: + conn.close() + except Exception: + pass + + + def filter_target_list(self): + """ + On set selection, fetch targets for that set (or all sets) for the current user. + Works whether the combo shows SET_IDs or SET_NAMEs. + """ + combo = self.parent.current_target_list_name + selected = combo if combo is not None else "All" + owner = getattr(self.parent, "current_owner", None) + + if not owner: + print("No current_owner set; cannot fetch targets.") + return + + conn = self.connect_to_mysql("config/db_config.ini") + if conn is None: + print("Failed to connect to MySQL. Cannot fetch targets for selected set.") + return + + try: + if selected == "All": + print("WHAT!?") + sql = """ + SELECT t.* + 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 + """ + params = (owner,) + cur = conn.cursor(dictionary=True) + print("Executing filter query:", sql, params) + cur.execute(sql, params) + rows = cur.fetchall() + cur.close() + self.update_target_list_table(rows) + return + + set_id = None + + # 1) prefer itemData if you stored it + try: + idx = combo.currentIndex() + data = combo.itemData(idx) + if isinstance(data, (int, str)) and str(data).isdigit(): + set_id = int(data) + except Exception: + pass + + # 2) if the visible text is an ID + if set_id is None and selected.isdigit(): + set_id = int(selected) + + # 3) try the in-memory mapping {SET_ID: SET_NAME} + if set_id is None: + mapping = getattr(self.parent, "user_set_data", {}) or {} + for k, v in mapping.items(): + if str(v).strip() == selected: + set_id = k + break + + # 4) last-resort: DB lookup by name for this owner + if set_id is None: + cur = conn.cursor() + cur.execute( + "SELECT SET_ID FROM target_sets WHERE OWNER = %s AND SET_NAME = %s", + (owner, selected) + ) + r = cur.fetchone() + cur.close() + if r: + set_id = r[0] + + if set_id is None: + print(f"Could not resolve selection '{selected}' to a SET_ID; leaving table unchanged.") + return + + sql = """ + SELECT t.* + 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 + """ + params = (set_id, owner) + cur = conn.cursor(dictionary=True) + print("Executing filter query:", sql, params) + cur.execute(sql, params) + rows = cur.fetchall() + cur.close() + + self.update_target_list_table(rows) + + except mysql.connector.Error as err: + print(f"Database error during filter: {err}") + + 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. + """ + # 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 + + columns_to_hide = { + "SET_ID", "STATE", "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", + "OTMexp_end", "OTMpa", "OTMwait", "OTMflag", "OTMlast", "OTMslew", + "OTMmoon", "OTMSNR", "OTMres", "OTMseeing", "OTMslitangle", + "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}) + + table_blocker = QSignalBlocker(table) + try: + table.setSortingEnabled(False) + table.clear() # headers + contents + table.setRowCount(0) + table.setColumnCount(0) + + if not filtered_rows: + return + + headers = list(filtered_rows[0].keys()) + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) + + header_view = table.horizontalHeader() + header_view.setFont(QFont("Arial", 10, QFont.Normal)) + + for row in filtered_rows: + 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, "")))) + + 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 + + + def update_target_table_with_list(self, target_list=None): + """Rebuild table for the selected target list using the same safe path.""" + ls = getattr(self.parent, "layout_service", None) + if ls is None: + print("layout_service not available.") + return + + # Find set_id for target_list + set_id = None + for key, val in getattr(self.parent, "user_set_data", {}).items(): + if val == target_list: + set_id = key + break + if set_id is None: + print("set_id not found for the given target_list") + return + + src = getattr(self.parent, "all_targets", []) + filtered = [row for row in src if row.get('SET_ID') == set_id] + self.update_target_list_table(filtered) + + def update_target_information(self, target_data): + # Pass the dictionary of target data to LayoutService to update the list + self.parent.layout_service.no_target_label.setVisible(False) + self.parent.layout_service.update_target_info_form(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. + """ + 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 + + # 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.execute(query, (value, observation_id)) + + # Commit the transaction to apply the changes + connection.commit() + + 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}") + + def refresh_table(self): + """ + Refreshes the table by querying the database for the latest data + and updating the QTableWidget with the new data. + """ + try: + # Example query to fetch the latest target data + connection = self.connect_to_mysql("config/db_config.ini") # Assuming you have a method to connect to the DB + + if connection is None: + print("Failed to connect to MySQL. Cannot refresh table.") + return + + cursor = connection.cursor() + + # Fetch all the latest data from the database (e.g., all target data) + cursor.execute("SELECT observation_id, name, exptime, slitwidth FROM ngps.targets") # Adjust query as needed + rows = cursor.fetchall() + + target_list_display = self.parent.layout_service.target_list_display + # Clear existing table data + target_list_display.setRowCount(0) # Reset row count + + # Add new data to the table + for row in rows: + row_position = target_list_display.rowCount() + target_list_display.insertRow(row_position) + for column, value in enumerate(row): + target_list_display.setItem(row_position, column, QTableWidgetItem(str(value))) + + cursor.close() + print("Table refreshed successfully!") + + except mysql.connector.Error as err: + print(f"Error while refreshing table: {err}") + + def compute_parallactic_angle_astroplan(self, ra, dec, location=None, time=None): + """ + Calculate the parallactic angle for a given RA/Dec using Astroplan. + + ra, dec: strings like "01 15 56.19", "+36 00 06.53" (sexagesimal) + Also tolerates colon-separated; no manual unit suffixes needed. + location: astropy.coordinates.EarthLocation (defaults to Palomar) + time: astropy.time.Time (defaults to current UTC) + + Returns: string degrees with 2 decimals (e.g., "123.45") + """ + + # Location (respect provided, else Palomar) + if location is None: + print("No location provided, using default Palomar Observatory location.") + location = EarthLocation(lat=33.3563 * u.deg, lon=-116.8648 * u.deg, height=1706 * u.m) + + print(f"Type of location: {type(location)}") + if not isinstance(location, EarthLocation): + raise TypeError(f"Expected EarthLocation, got {type(location)}") + + # Observer + try: + observer = Observer(location=location, name="Observer", timezone="UTC") + print(f"Observer created with location: {observer.location}") + except Exception as e: + print(f"Error creating observer: {e}") + raise + + # Time (respect provided) + if time is None: + print("No time provided, using current UTC time.") + time = Time.now() + print(f"Type of time: {type(time)}") + if not isinstance(time, Time): + raise TypeError(f"Expected astropy.time.Time, got {type(time)}") + + # RA/Dec parsing (no manual string surgery) + print(f"Original RA: {ra}") + print(f"Original Dec: {dec}") + try: + # Primary: treat RA as hourangle, Dec as degrees (handles 'HH MM SS', 'HH:MM:SS', etc.) + target_coords = SkyCoord(ra, dec, unit=(u.hourangle, u.deg), frame='icrs') + except Exception as e1: + # Fallback: if RA was given in decimal degrees instead of hours + try: + target_coords = SkyCoord(ra, dec, unit=(u.deg, u.deg), frame='icrs') + except Exception as e2: + print(f"Error creating SkyCoord (hourangle/deg): {e1}\nAlso failed deg/deg: {e2}") + raise + print(f"SkyCoord for target: {target_coords.to_string('hmsdms')}") + + # Parallactic angle + try: + pa = observer.parallactic_angle(time, target_coords) # Angle + pa_deg = pa.to(u.deg).value + print(f"Parallactic angle: {pa_deg:.2f} deg") + except Exception as e: + print(f"Error calculating parallactic angle: {e}") + raise + + # Return as string with two decimals to match your existing usage + return f"{pa_deg:.2f}" + + def delete_target_list_by_name(self, target_list_name): + """ + Deletes all rows from the database that belong to the target list with the given name. + """ + self.connection = self.connect_to_mysql("config/db_config.ini") + cursor = self.connection.cursor() + + query = "DELETE FROM target_sets WHERE SET_NAME = %s" + cursor.execute(query, (target_list_name,)) + self.connection.commit() + + deleted_count = cursor.rowcount + + cursor.close() + self.connection.close() + + return deleted_count + + def create_empty_target_set(self, set_name: str): + """ + Create a new empty target set for the current user, then refresh UI to show it. + """ + set_name = (set_name or "").strip() + if not set_name: + print("Empty set name; aborting.") + return + + owner = getattr(self.parent, "current_owner", None) + if not owner: + print("No owner; cannot create target set.") + return + + conn = self.connect_to_mysql("config/db_config.ini") + if conn is None: + print("DB connect failed; cannot create target set.") + return + + try: + cur = conn.cursor() + cur.execute( + "INSERT INTO target_sets (SET_NAME, OWNER, SET_CREATION_TIMESTAMP) VALUES (%s, %s, NOW())", + (set_name, owner), + ) + conn.commit() + cur.close() + print(f"Created empty target set '{set_name}' for owner '{owner}'.") + # Show only the most recent set (this will be the one we just created) + self.fetch_and_update_target_list() + # Keep the name around for fetch_set_id callers that read it + setattr(self.parent, "current_target_list_name", set_name) + 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 + 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) + 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 + + 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: + return + row = self._find_row_by_obs_id(table, prev_id) + if row 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) + 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 diff --git a/pygui/login_service.py b/pygui/login_service.py index 03fae2ff..c3df9051 100644 --- a/pygui/login_service.py +++ b/pygui/login_service.py @@ -96,17 +96,21 @@ def __init__(self, parent=None, connection=None): self.login_button = QPushButton("Login", self) self.cancel_button = QPushButton("Cancel", self) + self.account_button = QPushButton("Create Account", self) + self.forgot_button = QPushButton("Forgot Password", self) # Layout layout = QFormLayout() layout.addRow("Username:", self.username_field) layout.addRow("Password:", self.password_field) layout.addRow(self.login_button, self.cancel_button) + layout.addRow(self.account_button, self.forgot_button) self.setLayout(layout) # Connect signals self.login_button.clicked.connect(self.on_login) self.cancel_button.clicked.connect(self.reject) + self.account_button.clicked.connect(self.on_create_account) self.owner = None @@ -129,6 +133,10 @@ def on_cancel(self): """Handles the cancel action.""" print("Login cancelled") self.reject() # Close the dialog without doing anything (cancel) + + def on_create_account(self): + self.reject() + self.parent.on_create_account() def validate_user_credentials(self, username, password): """Validate user credentials against the MySQL database using an existing connection.""" diff --git a/pygui/menu_service.py b/pygui/menu_service.py index 1cc7e4e1..3f4cec94 100644 --- a/pygui/menu_service.py +++ b/pygui/menu_service.py @@ -38,9 +38,14 @@ def create_menus(self): # Tools Menu tools_menu = self.menubar.addMenu('Tools') - settings_action = QAction('Settings', self.menubar) + settings_action = QAction('Calibration', self.menubar) + settings_action.triggered.connect(self.parent.open_calibration_gui) tools_menu.addAction(settings_action) + etc_action = QAction('ETC', self.menubar) + etc_action.triggered.connect(self.parent.open_etc_popup) + tools_menu.addAction(etc_action) + # User Menu user_menu = self.menubar.addMenu('User') @@ -57,6 +62,11 @@ def create_menus(self): # Targets Menu targets_menu = self.menubar.addMenu('Target List') + # Delete Target List Action + delete_target_list_action = QAction('Delete Target List', self.menubar) + delete_target_list_action.triggered.connect(self.parent.on_delete_target_list) + targets_menu.addAction(delete_target_list_action) + # Help Menu help_menu = self.menubar.addMenu('Help') help_action = QAction('Help Contents', self.menubar) diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index deb782e1..a0a5dad6 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -1,16 +1,41 @@ import sys import subprocess -from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QFileDialog, QMessageBox, QDialog, QDesktopWidget -from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QFileDialog, QMessageBox, QDialog, QDesktopWidget, QHBoxLayout, QInputDialog, QStatusBar, QSizePolicy +from PyQt5.QtCore import Qt, pyqtSlot from menu_service import MenuService from logic_service import LogicService from layout_service import LayoutService -from instrument_status_service import InstrumentStatusService from sequencer_service import SequencerService from login_service import LoginDialog, CreateAccountDialog -from status_service import StatusService, StatusServiceThread +from zmq_status_service import ZmqStatusService, ZmqStatusServiceThread +from status_service import StatusService +from calib.calibration import CalibrationGUI +from etc_popup import EtcPopup +from control_tab import ControlTab +from daemon_status_bar import DaemonStatusBar, DaemonState +DAEMONS = [ + "acamd", + "calibd", + "camerad", + "flexured", + "focusd", + "powerd", + "sequencerd", + "slicecamd", + "slitd", + "tcsd", + "thermald", +] + +PER_DAEMON_COMMANDS = { + # Defaults are ["Ping", "Restart", "Open Logs"] if not specified: + "powerd": ["Ping", "Restart", "Power Cycle", "Open Logs"], + "camerad": ["Ping", "Restart", "Resync UDP", "Open Logs"], + "tcsd": ["Ping", "Restart", "Reconnect TCS", "Open Logs"], +} + class NgpsGUI(QMainWindow): def __init__(self): super().__init__() @@ -21,9 +46,14 @@ def __init__(self): self.current_dec = None self.current_offset_ra = None self.current_offset_dec = None + self.current_bin_spect = None + self.current_bin_spat = None + self.num_of_exposures = None self.user_set_data = {} self.all_targets = None self.current_owner = None + self.current_target_list_name = None + self.zmq_status_service = None # Login status flag self.logged_in = False @@ -31,6 +61,9 @@ def __init__(self): # Initialize the UI self.init_ui() + # Initialize the Calibration GUI + self.calibration_gui = None # Will hold the reference to CalibrationGUI instance + # Check sequencer state on startup if self.is_sequencer_ready(): print("Sequencer is READY.") @@ -42,8 +75,8 @@ def __init__(self): screen_width, screen_height = screen_geometry.width(), screen_geometry.height() # Set window size relative to screen size (e.g., 80% of screen size) - window_width = int(screen_width * 0.2) - window_height = int(screen_height * 0.2) + window_width = int(screen_width * 0.8) # Use 80% for the left side content + window_height = int(screen_height * 0.8) # Adjust height as well # Set the geometry (position + size) self.setGeometry(int(screen_width * 0.1), int(screen_height * 0.1), window_width, window_height) @@ -51,27 +84,48 @@ def __init__(self): # Load and apply the QSS stylesheet self.load_stylesheet("styles.qss") - # Initialize the InstrumentStatusService - self.instrument_status_service = InstrumentStatusService(self) - - # Initialize the SequencerService - self.sequencer_service = SequencerService(self) - self.sequencer_service.connect() - - # Initialize the StatusService - self.status_service = StatusService(self) - self.status_service.connect() + # Show ControlTab as a separate window + # self.show_control_tab() - # Start the StatusService in a separate thread - self.status_service_thread = StatusServiceThread(self.status_service) - self.status_service_thread.start() - - # Subscribe to a specific topic (optional) - self.status_service.subscribe() - # Connect the message_received signal from StatusService to the update_message_log slot - self.status_service.new_message_signal.connect(self.layout_service.update_message_log) + # Initialize services + self.initialize_services() + + # self.on_login() - self.setWindowState(Qt.WindowMaximized) + # Status bar with daemon chips + self._statusbar = QStatusBar(self) + self._statusbar.setSizeGripEnabled(False) # remove bottom-right resize grip + self._statusbar.setContentsMargins(0, 0, 0, 0) # no outer margins + self._statusbar.setStyleSheet( # no padding/borders around items + "QStatusBar{padding:0;margin:0;} QStatusBar::item{border:0;}" + ) + self.setStatusBar(self._statusbar) + + self.daemon_row = DaemonStatusBar(DAEMONS, 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) + + # ✨ Make it expand to full width + self.daemon_row.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + # Use addWidget with stretch, not addPermanentWidget + self._statusbar.addWidget(self.daemon_row, 1) + + # Optional: seed initial states so the UI isn't empty + 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..."), + }) + def init_ui(self): # Set up Menu @@ -79,10 +133,6 @@ def init_ui(self): menu_service = MenuService(self, menubar) menu_service.create_menus() - - # Initialize the message log - self.message_log = None - # Set up Layout self.layout_service = LayoutService(self) main_layout = self.layout_service.create_layout() @@ -95,10 +145,57 @@ def init_ui(self): # Add layout to central widget central_widget = QWidget() central_widget.setLayout(main_layout) + + # Create a QHBoxLayout to hold the main layout and control tab side-by-side + main_window_layout = QHBoxLayout() + + # Add the central widget (main content) to the left side + main_window_layout.addWidget(central_widget) + + # Create a QWidget to hold the layout and set it as the central widget + central_widget = QWidget() + central_widget.setLayout(main_window_layout) self.setCentralWidget(central_widget) - # Connect the DateTimeEdit to the on_date_time_changed function - self.start_date_time_edit.dateTimeChanged.connect(self.on_date_time_changed) + def initialize_services(self): + + # Initialize the SequencerService + self.sequencer_service = SequencerService(self) + self.sequencer_service.connect() + + # Start the StatusService in a separate thread with heartbeat + 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) + 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) + + # Initialize the ZMQStatusService + self.zmq_status_service = ZmqStatusService(self) + self.zmq_status_service.connect() + + # # Start the ZMQStatusService in a separate thread + self.zmq_status_service_thread = ZmqStatusServiceThread(self.zmq_status_service) + self.zmq_status_service_thread.start() + self.zmq_status_service.subscribe_to_topic("powerd") + self.zmq_status_service.subscribe_to_topic("calibd") + self.zmq_status_service.subscribe_to_topic("tcsd") + self.zmq_status_service.subscribe_to_topic("acamd") + self.zmq_status_service.subscribe_to_topic("seq_waitstate") + + # 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) + 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) + 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) def on_date_time_changed(self, datetime): start_time_utc = LogicService.convert_pst_to_utc(datetime) @@ -134,7 +231,9 @@ def on_login(self): self.current_owner = self.login_dialog.owner # After loading data, populate the target lists dropdown self.layout_service.load_target_lists(self.login_dialog.set_name) - + + # if self.layout_service.control_tab.startup_shutdown_button.text() == "Startup": + # self.layout_service.control_tab.toggle_startup_shutdown() def load_mysql_data(self, all_targets): """Load data from MySQL after successful login.""" @@ -155,7 +254,7 @@ def send_command(self, command): self.sequencer_service.send_command(command) def reload_table(self): - self.logic_service.fetch_and_update_target_list() + self.logic_service.filter_target_list() def is_sequencer_ready(self): """Check if the sequencer state is READY.""" @@ -167,8 +266,8 @@ def is_sequencer_ready(self): # Check if the output starts with "READY" if state.startswith('READY'): - self.layout_service.startup_shutdown_button.setText("Shutdown") - self.layout_service.startup_shutdown_button.setStyleSheet(""" + self.layout_service.control_tab.startup_shutdown_button.setText("Shutdown") + self.layout_service.control_tab.startup_shutdown_button.setStyleSheet(""" QPushButton { background-color: #000000; /* Black for shutdown */ border: none; @@ -183,13 +282,138 @@ def is_sequencer_ready(self): background-color: #555555; } """) + self.layout_service.update_system_status("idle") return True # Indicate that the sequencer is ready except subprocess.CalledProcessError as e: print(f"Error checking sequencer state: {e}") 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 open_etc_popup(self): + """Opens the EtcPopup when the button is clicked.""" + self.etc_popup = EtcPopup(self) # Pass the parent as the current MainWindow + self.etc_popup.exec_() + + def show_popup(self, message): + """Show a popup message on the screen.""" + # Create a QMessageBox + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Information) + msg_box.setWindowTitle("Status Update") + msg_box.setText(message) + msg_box.setStandardButtons(QMessageBox.Ok) + msg_box.exec_() + + # def show_control_tab(self): + # """ + # This method creates and shows the ControlTab as a separate, closable popup window. + # """ + # control_window = ControlTab(self) + # control_window.setGeometry(1400, 100, 400, 600) + # # Show the ControlTab window as a popup + # control_window.show() + + def on_delete_target_list(self): + # Pull available target list names from user_set_data + target_list_names = list(self.user_set_data.values()) + if not target_list_names: + QMessageBox.warning(self, "No Lists", "No target lists available to delete.") + return + + # Open a popup to select a list + selected_list, ok = QInputDialog.getItem( + self, + "Delete Target List", + "Select a target list to delete:", + target_list_names, + editable=False + ) + + if not ok or not selected_list: + return # Cancelled + + confirm = QMessageBox.question( + self, + "Confirm Delete", + f"Are you sure you want to delete target list '{selected_list}'?", + QMessageBox.Yes | QMessageBox.No + ) + + if confirm != QMessageBox.Yes: + return + + try: + deleted = self.logic_service.delete_target_list_by_name(selected_list) + + if deleted > 0: + # Remove from in-memory structures + self.all_targets = [ + row for row in self.all_targets + if row.get("NAME") != selected_list + ] + self.user_set_data = { + k: v for k, v in self.user_set_data.items() if v != selected_list + } + + # Remove from dropdown + dropdown = self.layout_service.target_list_name + idx = dropdown.findText(selected_list) + if idx != -1: + dropdown.removeItem(idx) + + # Clear the table + table = self.layout_service.target_list_display + table.clearContents() + table.setRowCount(0) + + # Reset current selection + if self.current_target_list_name == selected_list: + self.current_target_list_name = None + + QMessageBox.information(self, "Deleted", f"Target list '{selected_list}' was deleted.") + else: + QMessageBox.warning(self, "Not Found", f"No target list named '{selected_list}' was found.") + + except Exception as e: + 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 on_daemon_details(self, name: str): + # DaemonChip already shows a QMessageBox with details. + # Keep this slot for a future richer dialog (logs, metrics, traces). + pass + + def on_daemon_command(self, name: str, command: str): + # TODO: route to your ZMQ/UDP send logic + # For now, just log/print. Replace with sequencer/logic service hooks as needed. + print(f"[daemon-cmd] {name}: {command}") + + def apply_status_update(self, update: dict): + """ + Expected dict shape: + {"name": "acamd", "state": "ok|warn|error|unknown", "issue": "..."} + Call this from ZMQ status feed or camerad UDP listener. + """ + name = update.get("name") + state = update.get("state", DaemonState.UNKNOWN) + issue = update.get("issue", "") + if name: + self.daemon_row.set_daemon_state(name, state, issue) + + + + if __name__ == '__main__': app = QApplication(sys.argv) window = NgpsGUI() window.show() sys.exit(app.exec_()) + diff --git a/pygui/status_service.py b/pygui/status_service.py index b16cadc3..5f2f664a 100644 --- a/pygui/status_service.py +++ b/pygui/status_service.py @@ -1,115 +1,170 @@ -import zmq -import os -import logging -from PyQt5.QtCore import pyqtSignal, QObject, QThread +import socket +import threading +import time +from PyQt5.QtCore import pyqtSignal, QObject, QTimer +from PyQt5.QtWidgets import QMessageBox +import struct +import re +import select class StatusService(QObject): - # Signal to send a new message - new_message_signal = pyqtSignal(str) + # Signals to communicate with the main GUI thread + status_updated_signal = pyqtSignal(str) + progress_updated_signal = pyqtSignal(int, int) # Signal to update exposure progress bar (0-100) + readout_progress_updated_signal = pyqtSignal(int) # Signal to update readout progress bar (0-100) + image_number_updated_signal = pyqtSignal(int) # Signal to update image number + image_name_updated_signal = pyqtSignal(str) + update_status_signal = pyqtSignal(str) + user_can_expose_signal = pyqtSignal(bool) + shutter_status_signal = pyqtSignal(bool) - def __init__(self, parent, broker_publish_endpoint="tcp://127.0.0.1:5556"): + def __init__(self, parent, ip="239.1.1.234", port=1300, update_interval=5, heartbeat_timeout=3, max_heartbeat_misses=3, timeout_duration=1800): super().__init__() - self.parent = parent # Reference to the parent window or main UI - self.broker_publish_endpoint = broker_publish_endpoint - self.context = zmq.Context() # Create the ZeroMQ context - self.socket = None - self.is_connected = False - - # Set up logging - self.setup_logging() - self.logger.info("StatusService initialized.") - - def setup_logging(self): - """ Set up logging for the status service in a 'logs' folder. """ - - # Ensure the 'logs' directory exists - log_dir = 'logs' - if not os.path.exists(log_dir): - os.makedirs(log_dir) # Create the logs directory if it doesn't exist - - # Set up logging to a file inside the 'logs' folder - log_file = os.path.join(log_dir, 'status_service.log') - - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - filename=log_file, # Log file inside 'logs' folder - filemode='a' # 'a' to append, 'w' to overwrite - ) - - self.logger = logging.getLogger(__name__) - - def connect(self): - """ Connect to the broker using the SUB socket (not XSUB). """ - try: - self.logger.info(f"Connecting to broker at {self.broker_publish_endpoint}...") - # 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) - self.socket.setsockopt_string(zmq.SUBSCRIBE, "") # Subscribe to all messages (default behavior) - self.is_connected = True - self.logger.info(f"Connected to broker at {self.broker_publish_endpoint}") - except Exception as e: - self.logger.error(f"Failed to connect to broker: {e}") - self.is_connected = False - raise e - - def subscribe(self, topic=None): - """ Subscribe to a specific topic. If no topic is provided, subscribe to all topics. """ - if not self.is_connected: - self.logger.warning("Not connected to broker. Call 'connect()' first.") - return - - # If topic is provided, subscribe to that topic - if topic: - self.socket.setsockopt_string(zmq.SUBSCRIBE, topic) - self.logger.info(f"Subscribed to topic: {topic}") - else: - # If no topic is specified, subscribe to all messages - self.socket.setsockopt_string(zmq.SUBSCRIBE, "") - self.logger.info("Subscribed to all topics.") - - def listen(self): - """ Listen for incoming messages from the broker. """ - if not self.is_connected: - self.logger.warning("Not connected to broker. Call 'connect()' first.") - return - - try: - self.logger.info("Starting to listen for messages from the broker...") - while True: - message = self.socket.recv_string() # Receive the message as a string - self.logger.info(f"Received message: {message}") - - # Check if the message contains a space to separate topic and payload - if ' ' in message: - topic, payload = message.split(' ', 1) # Split the message assuming space-separated topic and payload - self.logger.info(f"Processed message: Topic = {topic}, Payload = {payload}") - # Emit the message to the UI thread - self.new_message_signal.emit(f"Topic: {topic}, Payload: {payload}") - else: - # If there is no space, treat the whole message as the topic - self.logger.info(f"Processed message with no payload: Topic = {message}") - self.new_message_signal.emit(f"Topic: {message}, Payload: None") - - except Exception as e: - self.logger.error(f"Error while listening for messages: {e}") - finally: - self.disconnect() - - 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.") - -class StatusServiceThread(QThread): - def __init__(self, status_service): - super().__init__() - self.status_service = status_service + self.parent = parent # reference to the parent window or main UI + self.ip = ip + self.port = port + self.update_interval = update_interval + self.heartbeat_timeout = heartbeat_timeout + self.max_heartbeat_misses = max_heartbeat_misses + self.timeout_duration = timeout_duration + self.sock = None + self.status = "Waiting for status" + self.heartbeat_misses = 0 + self.running = False + self.last_message = None + self.last_emitted_message = None + self.last_received_time = time.time() + + # Worker thread for communication + self.thread = threading.Thread(target=self.run) + self.thread.daemon = True + self.timer = QTimer(self) # Timer for periodic timeout checks + self.timer.setInterval(self.update_interval * 1000) # Set interval in milliseconds + self.timer.timeout.connect(self.check_timeout) # Connect to timeout check function + + def _create_socket(self): + """Creates a UDP socket and joins the multicast group.""" + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.settimeout(self.heartbeat_timeout) # Timeout for receiving responses (heartbeat timeout) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind(("", self.port)) + + mreq = struct.pack("4s4s", socket.inet_aton(self.ip), socket.inet_aton("0.0.0.0")) + self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + def start(self): + """Start the status service thread.""" + self.running = True + self.thread.start() + self.timer.start() # Start the timer for periodic checks + + def stop(self): + """Stop the status service thread.""" + self.running = False + if self.thread.is_alive(): + self.thread.join() + self.timer.stop() # Stop the periodic timer + + def get_status(self): + """Return the most recent status.""" + return self.status def run(self): - # Start listening for messages in the StatusService - self.status_service.logger.info("Starting StatusService thread...") - self.status_service.listen() + """Run the status service in a separate thread.""" + self._create_socket() + + while self.running: + try: + ready_to_read, _, _ = select.select([self.sock], [], [], self.heartbeat_timeout) + if ready_to_read: + data, addr = self.sock.recvfrom(1024) + message = data.decode("utf-8") + self.last_received_time = time.time() + + if message != self.last_message: + self.last_message = message + self._handle_message(message) + + except Exception as e: + self.status = f"Error: {str(e)}" + self.heartbeat_misses += 1 + + # No sleep here to avoid delays, just check for timeouts + self.check_timeout() + + def check_timeout(self): + """Check for timeouts and update status accordingly.""" + if time.time() - self.last_received_time >= self.timeout_duration: + self.status = "No response (Timeout)" + self.heartbeat_misses += 1 + if self.heartbeat_misses >= self.max_heartbeat_misses: + self.status = "Heartbeat lost - Service Disconnected" + self.heartbeat_misses = 0 + + # Emit the updated status to the GUI if it's a new message + if self.status != self.last_emitted_message: + self.status_updated_signal.emit(self.status) + self.last_emitted_message = self.status + + def _handle_message(self, message): + """Handle the incoming message and decide what to do with it.""" + if message == "SEQSTATE: READY": + self.parent.show_popup("NGPS is Ready.") + self.update_status_signal.emit("idle") + elif message.startswith("EXPTIME"): + self._parse_exptime_message(message) + elif message.startswith("PIXELCOUNT"): + self._parse_pixelcount_message(message) + elif message.startswith("CAMERAD:IMNUM"): + match = re.match(r"CAMERAD:IMNUM:(\d+)", message) + self.image_number_updated_signal.emit(int(match.group(1))) + elif message.startswith("CAMERAD:IMNAME"): + match = re.match(r"CAMERAD:IMNAME:(/.*)", message) + self.image_name_updated_signal.emit(str(match.group(1))) + elif "ready for next exposure" in message: + self.progress_updated_signal.emit(int(0), int(0)) + self.readout_progress_updated_signal.emit(int(0)) + elif '''waiting for USER to send "continue" signal''' in message: + self.user_can_expose_signal.emit(True) + elif "NOTICE:shutter opened" in message: + self.shutter_status_signal.emit(True) + elif "NOTICE:shutter closed" in message: + self.shutter_status_signal.emit(False) + elif "instrument is shut down" in message: + self.parent.show_popup("NGPS is Shutdown.") + self.update_status_signal.emit("stopped") + else: + self.status = message + self.heartbeat_misses = 0 + + def _parse_exptime_message(self, message): + """Parse EXPTIME message and update the exposure progress and time left.""" + match = re.match(r"EXPTIME:(\d+) (\d+) (\d+)", message) + if match: + exposure_time_ms = int(match.group(1)) # Remaining time in ms + max_time_ms = int(match.group(2)) # Max time in ms + progress = int(match.group(3)) # Progress percentage + + # Convert to seconds + exposure_time_sec = exposure_time_ms / 1000 + max_time_sec = max_time_ms / 1000 + + # Emit signal with progress and remaining time in seconds + self.progress_updated_signal.emit(progress, exposure_time_sec) + + # self.log_message( + # f"{progress}% complete — {exposure_time_sec:.1f} min remaining of {max_time_sec:.1f} min total" + # ) + + def _parse_pixelcount_message(self, message): + """Parse PIXELCOUNT message and update the readout progress.""" + match = re.match(r"PIXELCOUNT_\d+:(\d+) (\d+) (\d+)", message) + if match: + current_count = int(match.group(1)) + total_count = int(match.group(2)) + progress = int(match.group(3)) + + if total_count > 0: + progress_percentage = (current_count / total_count) * 100 + progress_percentage = min(max(progress_percentage, 0), 100) + self.readout_progress_updated_signal.emit(int(progress_percentage)) diff --git a/pygui/styles.qss b/pygui/styles.qss index afe0b232..def2bb54 100644 --- a/pygui/styles.qss +++ b/pygui/styles.qss @@ -23,7 +23,7 @@ QMenuBar::item { } QMenuBar::item:selected { - background-color: #4CAF50; /* Highlight menu item with green */ + background-color:rgb(54, 122, 56); /* Highlight menu item with green */ color: white; /* White text on selected item */ } @@ -62,8 +62,8 @@ QCheckBox::indicator { } QCheckBox::indicator:checked { - background-color: #4CAF50; /* Green background when checked */ - border-color: #4CAF50; /* Green border when checked */ + background-color:rgb(58, 136, 60); /* Green background when checked */ + border-color:rgb(46, 107, 48); /* Green border when checked */ } QCheckBox::indicator:unchecked { @@ -131,7 +131,7 @@ QProgressBar { } QProgressBar::chunk { - background-color: #7e7e7e; /* Lighter gray for the progress chunk */ + background-color:rgb(42, 101, 44); /* Lighter green for the progress chunk */ width: 10px; margin: 0.5px; } @@ -141,10 +141,6 @@ QTabWidget::pane { border: 1px solid #888888; /* Light border around tabs */ } -QTabWidget::tab-bar { - alignment: center; -} - QTabWidget::tab { background-color: #555555; color: #e0e0e0; @@ -212,3 +208,42 @@ QScrollBar::add-line, QScrollBar::sub-line { background-color: #3a3a3a; /* Darker gray */ } +/* PyQt Table Styling */ +QTableWidget, QTableView { + background-color: #444444; /* Dark gray background for the table */ + color: #e0e0e0; /* Light gray text */ + font-size: 14pt; + font-weight: bold; +} + +/* Table Header */ +QHeaderView { + background-color: #555555; /* Slightly lighter gray for the header */ + color: #e0e0e0; + border: 1px solid #888888; /* Border around the header */ + font-weight: bold; +} + +QHeaderView::section { + padding: 8px; + border: 1px solid #888888; + background-color: #555555; +} + +#DaemonStatusBar { + border-top: 1px solid #3a3a3a; + background: #1e1f22; +} + +QComboBox QAbstractItemView { + background-color: #555555; + color: #e0e0e0; + border: 1px solid #777777; + selection-background-color: rgb(54, 122, 56); + selection-color: white; + min-height: 384px; +} + +QComboBox { + combobox-popup: 0; /* force non-native Qt popup so QSS + maxVisibleItems apply */ +} \ No newline at end of file diff --git a/pygui/test.py b/pygui/test.py new file mode 100644 index 00000000..1c209de9 --- /dev/null +++ b/pygui/test.py @@ -0,0 +1,35 @@ +from astropy.coordinates import SkyCoord, EarthLocation +from astropy.time import Time +from astroplan import Observer +import astropy.units as u +def compute_parallactic_angle_astroplan(ra, dec, location=None, time=None): + """ + Calculate the parallactic angle for a given RA and Dec using Astroplan. + @param ra: Right Ascension as a space-separated string (e.g., "23 08 44.55"). + @param dec: Declination as a space-separated string (e.g., "+36 22 12.90"). + @param location: Observatory location (Astropy EarthLocation). Defaults to Palomar Observatory. + @param time: Observation time (Astropy Time). Defaults to current UTC time. + @return: Parallactic Angle (Astropy Quantity, angle with unit) + """ + # Default location: Palomar Observatory + if location is None: + location = EarthLocation(lat=33.3563 * u.deg, lon=-116.8648 * u.deg, height=1706 * u.m) + observer = Observer(location=location, name="Observer", timezone="UTC") + # Current UTC time + if time is None: + time = Time.now() + ra = ra.replace(" ", "h", 1).replace(" ", "m", 1) + "s" + dec = dec.replace(" ", "d", 1).replace(" ", "m", 1) + "s" + target_coords = SkyCoord(ra=ra, dec=dec, frame='icrs') + parallactic_angle = observer.parallactic_angle(time, target_coords) + print(f"PA: {parallactic_angle}") + return parallactic_angle + +# Example input: +ra="00 00 00.86" +dec="+40 00 17.68" +#ra = "23 08 44.55" +#dec = "+36 22 12.90" +pa = compute_parallactic_angle_astroplan(ra, dec) +print(f"Type PA: {type(pa)}") +print(f"Parallactic Angle: {pa.to(u.deg):.2f}") diff --git a/pygui/zmq_status_service.py b/pygui/zmq_status_service.py new file mode 100644 index 00000000..819d8eef --- /dev/null +++ b/pygui/zmq_status_service.py @@ -0,0 +1,264 @@ +import zmq +import os +import logging +import json +from PyQt5.QtCore import pyqtSignal, QObject, QThread +from typing import Dict + +class ZmqStatusService(QObject): + # Signal to send a new message + new_message_signal = pyqtSignal(str) + + # Signal to send lamp states as a dictionary {lamp_name: bool} + lamp_states_signal = pyqtSignal(dict) + + # Signal to send modulator states as a dictionary {modulator_name: bool} + modulator_states_signal = pyqtSignal(dict) + + airmass_signal = pyqtSignal(float) + + slit_info_signal = pyqtSignal(float, float) + + system_status_signal = pyqtSignal(str) + + def __init__(self, parent, broker_publish_endpoint="tcp://127.0.0.1:5556"): + super().__init__() + self.parent = parent # Reference to the parent window or main UI + self.broker_publish_endpoint = broker_publish_endpoint + self.context = zmq.Context() # Create the ZeroMQ context + self.socket = None + self.is_connected = False + self.subscribed_topics = set() # Set of subscribed topics + + # Set up logging + self.setup_logging() + self.logger.info("StatusService initialized.") + + def setup_logging(self): + """ Set up logging for the status service in a 'logs' folder. """ + + # Ensure the 'logs' directory exists + log_dir = 'logs' + if not os.path.exists(log_dir): + os.makedirs(log_dir) # Create the logs directory if it doesn't exist + + # Set up logging to a file inside the 'logs' folder + log_file = os.path.join(log_dir, 'zmq_status_service.log') + + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + filename=log_file, # Log file inside 'logs' folder + filemode='a' # 'a' to append, 'w' to overwrite + ) + + self.logger = logging.getLogger(__name__) + + def connect(self): + """ Connect to the broker using the SUB socket (not XSUB). """ + try: + self.logger.info(f"Connecting to broker at {self.broker_publish_endpoint}...") + # 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) + self.is_connected = True + self.logger.info(f"Connected to broker at {self.broker_publish_endpoint}") + except Exception as e: + self.logger.error(f"Failed to connect to broker: {e}") + self.is_connected = False + raise e + + def subscribe_to_topic(self, topic): + """ Subscribe to a specific topic. """ + if not self.is_connected: + self.logger.warning("Not connected to broker. Call 'connect()' first.") + return + + if topic not in self.subscribed_topics: + self.socket.setsockopt_string(zmq.SUBSCRIBE, topic) # Subscribe to the given topic + self.subscribed_topics.add(topic) + self.logger.info(f"Subscribed to topic: {topic}") + else: + self.logger.info(f"Already subscribed to topic: {topic}") + + def unsubscribe_from_topic(self, topic): + """ Unsubscribe from a specific topic. """ + if not self.is_connected: + self.logger.warning("Not connected to broker. Call 'connect()' first.") + return + + if topic in self.subscribed_topics: + self.socket.setsockopt_string(zmq.UNSUBSCRIBE, topic) # Unsubscribe from the given topic + self.subscribed_topics.remove(topic) + self.logger.info(f"Unsubscribed from topic: {topic}") + else: + self.logger.info(f"Not subscribed to topic: {topic}") + + def subscribe_to_all(self): + """ Subscribe to all topics. """ + if not self.is_connected: + self.logger.warning("Not connected to broker. Call 'connect()' first.") + return + + self.socket.setsockopt_string(zmq.SUBSCRIBE, "") # Subscribe to all messages + self.subscribed_topics.clear() # Clear the current subscriptions + self.logger.info("Subscribed to all topics.") + + def listen(self): + """ Listen for incoming messages from the broker. """ + if not self.is_connected: + self.logger.warning("Not connected to broker. Call 'connect()' first.") + return + + try: + self.logger.info("Starting to listen for messages from the broker...") + while True: + message = self.socket.recv_multipart() # Receive the message as multipart (topic, payload) + if len(message) == 2: # Ensure there are exactly two parts: topic and payload + topic = message[0].decode('utf-8') # The topic is the first part (byte array -> string) + payload = message[1].decode('utf-8') # The payload is the second part + + self.logger.info(f"Received message: Topic = {topic}, Payload = {payload}") + + # Assuming the payload is a JSON string, parse it into a dictionary + try: + data = json.loads(payload) + # Emit the message to the UI thread + + # If the topic is "acamd" + if topic == "acamd": + self.new_message_signal.emit(f"Topic: {topic}, Payload: {payload}") + + # If the topic is "seq_daemonstate" + if topic == "seq_waitstate": + status = self._status_from_seq_waitstate(data) + self.system_status_signal.emit(status) + + # If the topic is "slitd" + if topic == "slitd": + slit_width = data.get("SLITW", None) + slit_offset = data.get("SLITO", None) + if slit_width is not None and slit_offset is not None: + self.slit_info_signal.emit(slit_width, slit_offset) + + # If the topic is "calibd", update modulator states + if topic == "calibd": + self.new_message_signal.emit(f"Topic: {topic}, Payload: {payload}") + self.update_modulator_states(data) + + # If the topic is "powerinfo", update lamp states + if topic == "powerd": + self.new_message_signal.emit(f"Topic: {topic}, Payload: {payload}") + self.update_lamp_states(data) # Update lamp statesi + + # If the topic is "tcsd", handle TCS information + if topic == "tcsd": + self.update_tcs_info(data) + + except json.JSONDecodeError as e: + self.logger.error(f"Error parsing JSON payload: {e}") + else: + self.logger.warning("Received malformed message (not two parts).") + + except Exception as e: + self.logger.error(f"Error while listening for messages: {e}") + finally: + self.disconnect() + + 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.") + + def update_lamp_states(self, data): + """ Emit signal with the lamp states """ + if not isinstance(data, dict): + self.logger.error("Invalid data format received for lamp states.") + return + + lamp_states = {} + # List of lamps we are interested in + lamp_keys = ["LAMPBLUC", "LAMPFEAR", "LAMPREDC", "LAMPTHAR"] + + for lamp_key in lamp_keys: + # Get the lamp state from the data (default to False if not found) + lamp_states[lamp_key] = data.get(lamp_key, False) + + # Emit the signal for lamp states + self.lamp_states_signal.emit(lamp_states) + print(f"Emitting modulator states: {lamp_states}") + + def update_modulator_states(self, modulator_data): + """ Emit signal with the modulator states """ + if not isinstance(modulator_data, dict): + self.logger.error("Invalid data format received for modulator states.") + return + + modulator_states = {} + # List of modulators we are interested in + modulator_keys = ["MODTHAR", "MODFEAR", "MODRDCON", "MODBLCON"] + + for modulator_key in modulator_keys: + # Get the status from the modulator data + modulator_status = modulator_data.get(modulator_key, "err") # Default to "err" if not found + + # If "err" is in the status, consider it as the hardware not ready + if "err" in modulator_status: + modulator_states[modulator_key] = False # Hardware is not ready, so set to False (off) + else: + # Otherwise, treat the modulator as "on" or "off" + # If it contains the term "on", we consider it as True (on) + modulator_states[modulator_key] = modulator_status.startswith("on") + + # Emit the signal for modulator states + self.modulator_states_signal.emit(modulator_states) + print(f"Emitting modulator states: {modulator_states}") + + def update_tcs_info(self, data): + """ Handle and process the 'tcsd' message and payload. """ + # Extract relevant data or handle it as needed + airmass = data.get('AIRMASS', None) + alt = data.get('ALT', None) + az = data.get('AZ', None) + dec = data.get('DEC', None) + domeaz = data.get('DOMEAZ', None) + domeshut = data.get('DOMESHUT', None) + ra = data.get('RA', None) + raoffset = data.get('RAOFFSET', None) + zenangle = data.get('ZENANGLE', None) + + # Here you can process or log the data, for example: + # print(f"Processing TCS info: AIRMASS = {airmass}, ALT = {alt}, AZ = {az}, RAOFFSET = {raoffset}, etc.") + + # Emit the TCS info to the UI thread (TODO) + # self.new_message_signal.emit(f"TCS Info: {data}") + + # Emit the AIRMASS value as a dedicated signal if available + if airmass is not None: + self.airmass_signal.emit(airmass) + else: + self.logger.warning("AIRMASS data is not available.") + + def _status_from_seq_waitstate(self, flags: Dict[str, bool]) -> str: + f = {k: bool(v) for k, v in (flags or {}).items()} + + if f.get("READOUT"): return "readout" + if f.get("EXPOSE"): return "exposing" + if f.get("ACQUIRE"): return "acquire" + if f.get("FOCUS"): return "focus" + if f.get("CALIB"): return "calib" + if f.get("USER"): return "user" + return "idle" + + +class ZmqStatusServiceThread(QThread): + def __init__(self, zmq_status_service): + super().__init__() + self.zmq_status_service = zmq_status_service + + def run(self): + # Start listening for messages in the StatusService + self.zmq_status_service.logger.info("Starting ZMQStatusService thread...") + self.zmq_status_service.listen() From 887ec3ccfc1b659e07c055c3eefb733ef493d430 Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Sun, 26 Oct 2025 23:56:13 -0700 Subject: [PATCH 2/3] enable calibration moe: starting editable fields --- pygui/layout_service.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/pygui/layout_service.py b/pygui/layout_service.py index 39ced60f..7f008040 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -699,6 +699,18 @@ def create_target_list_group(self): header = self.target_list_display.horizontalHeader() header.setFont(QFont("Arial", 10, QFont.Normal)) # Set font to normal (non-bold) + fm = self.target_list_display.fontMetrics() + row_h = fm.height() + 12 # padding for bigger font + vh = self.target_list_display.verticalHeader() + vh.setDefaultSectionSize(row_h) + vh.setMinimumSectionSize(row_h) + + hh = self.target_list_display.horizontalHeader() + hh.setSectionResizeMode(QHeaderView.Interactive) # user resizable + hh.setDefaultSectionSize(int(fm.averageCharWidth() * 12 + 24)) # roomy cols + hh.setMinimumSectionSize(int(fm.averageCharWidth() * 8 + 16)) + hh.setStretchLastSection(True) + # Enable sorting on column headers self.target_list_display.setSortingEnabled(True) @@ -709,8 +721,11 @@ def create_target_list_group(self): # Allow manual resizing of the columns (on the horizontal header) header.setSectionResizeMode(QHeaderView.Interactive) - # Disable editing of table cells - self.target_list_display.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.target_list_display.setEditTriggers( + QAbstractItemView.DoubleClicked + | QAbstractItemView.SelectedClicked + | QAbstractItemView.EditKeyPressed + ) # Set selection mode to select entire rows when a cell is clicked self.target_list_display.setSelectionBehavior(QAbstractItemView.SelectRows) From e4fc57d65cfc612e713f668a3285a00cf292b94d Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Mon, 27 Oct 2025 00:24:45 -0700 Subject: [PATCH 3/3] starting editable fields starting editable fields starting editable fields starting editable fields revert back update camera row/col to spec/spat add spec/spat to afternoon tab update target list UI and acam focus Update daemon state put back prod info --- pygui/calib/getcalib | 4 +- pygui/calib/getcalib_flats | 4 +- pygui/calib/tabs/afternoon_tab.py | 4 +- pygui/calib/tabs/focus_tab.py | 161 ++++++++++++++++-------- pygui/layout_service.py | 199 +++++++++++++++++++----------- pygui/ngps_gui.py | 54 +++++++- pygui/sequencer_service.py | 12 +- pygui/styles.qss | 3 +- 8 files changed, 300 insertions(+), 141 deletions(-) diff --git a/pygui/calib/getcalib b/pygui/calib/getcalib index 07f3df4d..4837dea2 100755 --- a/pygui/calib/getcalib +++ b/pygui/calib/getcalib @@ -52,12 +52,12 @@ echo "Current slit width = $slitwidth arcsec" ### This binning stuff below should be replaced ### Get spectral binning -xbin=(`camera bin col`[1]) +xbin=(`camera bin spec`[1]) ### Get spatial binning -ybin=(`camera bin row`[1]) +ybin=(`camera bin spat`[1]) diff --git a/pygui/calib/getcalib_flats b/pygui/calib/getcalib_flats index 8fa9fdef..2aea77ed 100755 --- a/pygui/calib/getcalib_flats +++ b/pygui/calib/getcalib_flats @@ -51,12 +51,12 @@ echo "Current slit width = $slitwidth arcsec" ### This binning stuff below should be replaced ### Get spectral binning -xbin=(`camera bin col`[1]) +xbin=(`camera bin spec`[1]) ### Get spatial binning -ybin=(`camera bin row`[1]) +ybin=(`camera bin spat`[1]) arcmultiplier=`echo "scale=3; 1.00/($xbin*$ybin)" | bc` diff --git a/pygui/calib/tabs/afternoon_tab.py b/pygui/calib/tabs/afternoon_tab.py index 3dfe400c..87d5de81 100644 --- a/pygui/calib/tabs/afternoon_tab.py +++ b/pygui/calib/tabs/afternoon_tab.py @@ -205,7 +205,7 @@ def set_slit(self): def set_spatial_binning(self): spatial_binning = self.spatial_binning_input.text() if spatial_binning: - command_row = f"camera bin row {spatial_binning}" + command_row = f"camera bin spat {spatial_binning}" self.run_command_in_background(command_row) else: self.log_message("Please provide a spatial binning value.") @@ -213,7 +213,7 @@ def set_spatial_binning(self): def set_spectral_binning(self): spectral_binning = self.spectral_binning_input.text() if spectral_binning: - command_col = f"camera bin col {spectral_binning}" + command_col = f"camera bin spec {spectral_binning}" self.run_command_in_background(command_col) else: self.log_message("Please provide a spectral binning value.") diff --git a/pygui/calib/tabs/focus_tab.py b/pygui/calib/tabs/focus_tab.py index ed1a3c75..4848a5f5 100644 --- a/pygui/calib/tabs/focus_tab.py +++ b/pygui/calib/tabs/focus_tab.py @@ -677,59 +677,86 @@ def run_focus(self): """) def run_focus_acam(self): - # This method will run all the other buttons when clicked - print("Running Focus ACAM...") + """Run the full ACAM focus sequence in the background (like thrufocus).""" + + # Disable + gray the button while running self.run_acam_focus_button.setEnabled(False) self.run_acam_focus_button.setStyleSheet(""" - QPushButton { - background-color: lightgray; - } - """) + QPushButton { + background-color: lightgray; + color: black; + border-radius: 8px; + padding: 10px; + border: none; + } + """) - command = f"camera basename focus" - self.run_command(command) - - # Set CAM focus ACAM + self.log_message_callback("Running ACAM focus sequence...\n") + + # Read parameters (use placeholder if field is empty) value = self.focus_value_input.text() or self.focus_value_input.placeholderText() upper = self.focus_upper_input.text() or self.focus_upper_input.placeholderText() lower = self.focus_lower_input.text() or self.focus_lower_input.placeholderText() - step = self.focus_step_input.text() or self.focus_step_input.placeholderText() - + step = self.focus_step_input.text() or self.focus_step_input.placeholderText() + + if not (value and upper and lower and step): + self.log_message_callback("Please provide valid input for ACAM focus loop parameters.\n") + # restore button immediately since we didn't start anything + self.run_acam_focus_button.setEnabled(True) + self.run_acam_focus_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { background-color: #45a049; } + QPushButton:pressed { background-color: #3e8e41; } + """) + return + + # Build label and combined shell command (all steps in one go) timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") label = f"focusloop_{timestamp}" - if value and upper and lower and step: - command = f"camstep focus acam {label} {value} {upper} {lower} {step}" - self.run_command(command) - else: - print("Please provide valid input for ACAM focus loop parameters.") - - - command = f"camera basename ngps" - self.run_command(command) - - # Run the focus_andor.py script with specified arguments. - command = f"bash calib/andor.sh {label}" - self.run_command(command) + command = ( + f"camera basename focus && " + f"camstep focus acam {label} {value} {upper} {lower} {step} && " + f"camera basename ngps && " + f"bash calib/andor.sh {label}" + ) + + # Start async command, logging output to the calibration GUI + self.thread_acam_focus = AsyncCommandThread(command, self.log_message_callback) + self.thread_acam_focus.output_signal.connect(self.log_message_callback) + + # Restore UI + open image when the background task ends + def _restore_acam_focus(): + self.run_acam_focus_button.setEnabled(True) + self.run_acam_focus_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; /* Green */ + color: white; + border-radius: 8px; + padding: 10px; + border: none; + } + QPushButton:hover { background-color: #45a049; } + QPushButton:pressed { background-color: #3e8e41; } + """) + # Show the resulting ACAM focus plot (still uses eog) + self.open_focus_acam_images() + + self.thread_acam_focus.finished.connect(_restore_acam_focus) + try: + # If AsyncCommandThread also emits terminated, hook that too (like afternoon_tab) + self.thread_acam_focus.terminated.connect(_restore_acam_focus) + except Exception: + pass - self.open_focus_acam_images() + self.thread_acam_focus.start() - self.run_acam_focus_button.setEnabled(True) - self.run_acam_focus_button.setStyleSheet(""" - QPushButton { - background-color: #4CAF50; /* Green color */ - color: white; - border-radius: 8px; - padding: 10px; - border: none; - } - QPushButton:hover { - background-color: #45a049; /* Slightly darker green on hover */ - } - QPushButton:pressed { - background-color: #3e8e41; /* Darker green when pressed */ - } - """) def run_command_in_background(self, command): """Run the command in a background thread.""" @@ -738,14 +765,42 @@ def run_command_in_background(self, command): self.thread.start() def run_command(self, command): - """Run command.""" - self.output_signal.connect(self.log_message_callback) - - try: - # Run the command exactly as it is - result = subprocess.run(command, shell=True, check=True) - #self.output_signal.emit(result) - print("Command executed successfully.") - except subprocess.CalledProcessError as e: - print(f"Error occurred: {e.stderr}") - #self.output_signal.emit(e.stderr) + """Run a command synchronously, sending all output to the GUI log.""" + # Make sure the signal is connected once + try: + self.output_signal.disconnect() + except TypeError: + # Wasn't connected yet – that's fine + pass + + self.output_signal.connect(self.log_message_callback) + + try: + self.output_signal.emit(f"$ {command}\n") + + result = subprocess.run( + command, + shell=True, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if result.stdout: + self.output_signal.emit(result.stdout) + if result.stderr: + self.output_signal.emit(result.stderr) + + self.output_signal.emit("Command executed successfully.\n") + + except subprocess.CalledProcessError as e: + msg = "" + if e.stdout: + msg += e.stdout + if e.stderr: + msg += e.stderr + if not msg: + msg = f"Error occurred while running: {command}\n" + self.output_signal.emit(msg) + diff --git a/pygui/layout_service.py b/pygui/layout_service.py index 7f008040..84db5df2 100644 --- a/pygui/layout_service.py +++ b/pygui/layout_service.py @@ -1,4 +1,4 @@ -from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, 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.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 logic_service import LogicService @@ -109,18 +109,6 @@ def create_second_column_for_target_list(self): return second_column_layout - def create_third_column(self): - third_column_layout = QVBoxLayout() - third_column_layout.setObjectName("column-sidebar") - third_column_layout.setSpacing(10) - - # Add widgets to the third column, e.g., tabs, buttons, etc. - # For simplicity, let's assume it's a placeholder widget: - sidebar_widget = QWidget() - third_column_layout.addWidget(sidebar_widget) - - return third_column_layout - def create_third_column(self): third_column_layout = QVBoxLayout() @@ -137,17 +125,17 @@ def create_third_column(self): # Add the QTabWidget to the third column layout third_column_layout.addWidget(self.parent.tabs) - # Now, create and set up the layout for the Control tab - # Create a layout for the Control tab using the ControlTab class - self.control_tab = ControlTab(self.parent) # Create the control tab instance - control_layout = QVBoxLayout() # You can define a custom layout for the control tab here if needed - control_layout.addWidget(self.control_tab) # Add the ControlTab widget to the layout - self.parent.control_tab.setLayout(control_layout) # Set the layout for the control tab widget - - self.status_tab = InstrumentStatusTab(self.parent) # Create the control tab instance - status_layout = QVBoxLayout() # You can define a custom layout for the control tab here if needed - status_layout.addWidget(self.status_tab) # Add the ControlTab widget to the layout - self.parent.status_tab.setLayout(status_layout) # Set the layout for the control tab widget + # Set up the layout for the Control tab + self.control_tab = ControlTab(self.parent) + control_layout = QVBoxLayout() + control_layout.addWidget(self.control_tab) + self.parent.control_tab.setLayout(control_layout) + + # Create the Instrument tab + self.status_tab = InstrumentStatusTab(self.parent) + status_layout = QVBoxLayout() + status_layout.addWidget(self.status_tab) + self.parent.status_tab.setLayout(status_layout) return third_column_layout def create_top_section(self): @@ -406,31 +394,31 @@ def create_progress_and_image_group(self): return progress_and_image_group def create_progress_layout(self): - progress_layout = QVBoxLayout() # Use QVBoxLayout for vertical stacking + progress_layout = QVBoxLayout() - # --- Exposure Progress --- - exposure_layout = QHBoxLayout() # Horizontal layout for exposure row + # Exposure Progress + exposure_layout = QHBoxLayout() self.parent.exposure_progress = QProgressBar() self.parent.exposure_progress.setRange(0, 100) self.parent.exposure_progress.setValue(0) self.parent.exposure_progress.setMaximumWidth(600) self.parent.exposure_progress.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.parent.exposure_progress.setTextVisible(True) # Enable text display - self.parent.exposure_progress.setFormat("0% (0 sec remaining)") # Initial display + self.parent.exposure_progress.setTextVisible(True) + self.parent.exposure_progress.setFormat("0%") exposure_layout.setSpacing(5) exposure_layout.addWidget(QLabel("Exposure Progress:")) exposure_layout.addWidget(self.parent.exposure_progress) - # --- Readout/Overhead Progress --- + # Readout/Overhead Progress overhead_layout = QHBoxLayout() # Horizontal layout for overhead row self.parent.overhead_progress = QProgressBar() self.parent.overhead_progress.setValue(0) self.parent.overhead_progress.setRange(0, 100) self.parent.overhead_progress.setMaximumWidth(300) - self.parent.overhead_progress.setTextVisible(True) # Optional: show % on readout bar + self.parent.overhead_progress.setTextVisible(True) overhead_layout.setSpacing(0) overhead_layout.addWidget(QLabel("Readout Progress:")) @@ -443,9 +431,13 @@ def create_progress_layout(self): self.parent.shutter_box = QLabel("CLOSED") self.parent.shutter_box.setAlignment(Qt.AlignCenter) self.parent.shutter_box.setFixedWidth(90) - self.parent.shutter_box.setStyleSheet( - "border: 1px solid gray; padding: 2px; background-color: #ccc; color: black;" - ) + self.parent.shutter_box.setStyleSheet(""" + border: 1px solid gray; + border-radius: 6px; + padding: 2px 6px; + background-color: #ccc; + color: black; + """) overhead_layout.addSpacing(12) overhead_layout.addWidget(self.parent.shutter_label) @@ -499,7 +491,7 @@ def create_image_info_layout(self): self.parent.image_name.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) # Set the image_number widget to be smaller - self.parent.image_number.setFixedWidth(80) # You can adjust the width as needed + self.parent.image_number.setFixedWidth(80) image_info_layout.addWidget(QLabel("Image Dir:")) image_info_layout.addWidget(self.parent.image_name) @@ -610,6 +602,23 @@ def create_target_list_group(self): # Add the (+) button next to the label header_layout.addWidget(self.add_row_button) + self.column_toggle_button = QPushButton("⚙") + self.column_toggle_button.setToolTip("Show / hide target list fields") + self.column_toggle_button.setFixedSize(22, 22) + self.column_toggle_button.setStyleSheet(""" + QPushButton { + border: 1px solid #888; + border-radius: 3px; + padding: 0px; + font-size: 12px; + } + QPushButton:hover { + background-color: #f0f0f0; + } + """) + self.column_toggle_button.clicked.connect(self.show_column_toggle_dialog) + header_layout.addWidget(self.column_toggle_button) + # Add the header layout to the main layout bottom_section_layout.addLayout(header_layout) @@ -660,15 +669,15 @@ def create_target_list_group(self): } QHeaderView::section { - padding: 4px; + padding: 2px; border: 1px solid #888888; background-color: #555555; } QScrollBar:vertical, QScrollBar:horizontal { border: 2px solid grey; background: #F0F0F0; - width: 20px; - height: 20px; + width: 16px; + height: 16px; } QScrollBar::handle:vertical, QScrollBar::handle:horizontal { background: #FFCC40; @@ -697,19 +706,7 @@ def create_target_list_group(self): # Remove the bold font from headers header = self.target_list_display.horizontalHeader() - header.setFont(QFont("Arial", 10, QFont.Normal)) # Set font to normal (non-bold) - - fm = self.target_list_display.fontMetrics() - row_h = fm.height() + 12 # padding for bigger font - vh = self.target_list_display.verticalHeader() - vh.setDefaultSectionSize(row_h) - vh.setMinimumSectionSize(row_h) - - hh = self.target_list_display.horizontalHeader() - hh.setSectionResizeMode(QHeaderView.Interactive) # user resizable - hh.setDefaultSectionSize(int(fm.averageCharWidth() * 12 + 24)) # roomy cols - hh.setMinimumSectionSize(int(fm.averageCharWidth() * 8 + 16)) - hh.setStretchLastSection(True) + header.setFont(QFont("Arial", 9, QFont.Normal)) # Set font to normal (non-bold) # Enable sorting on column headers self.target_list_display.setSortingEnabled(True) @@ -721,11 +718,8 @@ def create_target_list_group(self): # Allow manual resizing of the columns (on the horizontal header) header.setSectionResizeMode(QHeaderView.Interactive) - self.target_list_display.setEditTriggers( - QAbstractItemView.DoubleClicked - | QAbstractItemView.SelectedClicked - | QAbstractItemView.EditKeyPressed - ) + # Disable editing of table cells + self.target_list_display.setEditTriggers(QAbstractItemView.NoEditTriggers) # Set selection mode to select entire rows when a cell is clicked self.target_list_display.setSelectionBehavior(QAbstractItemView.SelectRows) @@ -748,6 +742,73 @@ def create_target_list_group(self): return target_list_group + def show_column_toggle_dialog(self): + table = self.target_list_display + + # If there are no columns yet, bail out nicely + if table.columnCount() == 0: + QMessageBox.information( + self.parent, + "No columns", + "There are no target list columns to toggle yet." + ) + return + + dialog = QDialog(self.parent) + dialog.setWindowTitle("Show / hide target list fields") + + layout = QVBoxLayout(dialog) + + # Build a checkbox for each column, based on header text + checkboxes = [] + for col in range(table.columnCount()): + header_item = table.horizontalHeaderItem(col) + header_text = header_item.text() if header_item else f"Column {col + 1}" + + cb = QCheckBox(header_text) + # Checked == visible + cb.setChecked(not table.isColumnHidden(col)) + layout.addWidget(cb) + checkboxes.append((col, cb)) + + # OK/Cancel buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + layout.addWidget(buttons) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + + if dialog.exec_() != QDialog.Accepted: + return # user cancelled + + # Apply the chosen visibility + for col, cb in checkboxes: + table.setColumnHidden(col, not cb.isChecked()) + + # Optionally re-apply your column widths (visible ones keep their widths) + self.set_column_widths() + + def hide_default_columns(self): + """Hide noisy / advanced columns in the target list by default.""" + # Make this safe if called before the table is created + table = getattr(self, "target_list_display", None) + if table is None: + return + + if table.columnCount() == 0: + return + + # Column names to hide by default + to_hide = {"OBSERVATION_ID", "CHANNEL", "MAGNITUDE", "MAGSYSTEM", "MAGFILTER"} + + for col in range(table.columnCount()): + header_item = table.horizontalHeaderItem(col) + if header_item is None: + continue + + header_text = header_item.text().strip().upper() + if header_text in to_hide: + table.setColumnHidden(col, True) + def on_row_selected(self): # Get the selected row's index selected_rows = self.parent.target_list_display.selectionModel().selectedRows() @@ -794,10 +855,10 @@ def add_new_row(self): form.addRow(QLabel("Name*"), name_le) form.addRow(QLabel("RA*"), ra_le) form.addRow(QLabel("Decl*"), decl_le) + form.addRow(QLabel("EXPTime*"), exptime_le) + form.addRow(QLabel("Slitwidth*"), slitwidth_le) form.addRow("Offset RA", off_ra_le) form.addRow("Offset Dec", off_dec_le) - form.addRow("EXPTime", exptime_le) - form.addRow("Slitwidth", slitwidth_le) form.addRow("Magnitude", mag_le) # Buttons @@ -828,8 +889,8 @@ def _num(txt): try: offset_ra = _num(off_ra_le.text()) offset_dec = _num(off_dec_le.text()) - exptime = _num(exptime_le.text()) - slitwidth = _num(slitwidth_le.text()) + exptime = "SET " + exptime_le.text() + slitwidth = "SET " + slitwidth_le.text() magnitude = _num(mag_le.text()) except ValueError: QMessageBox.warning(self.parent, "Invalid number", "One or more numeric fields are invalid.") @@ -850,7 +911,6 @@ def _num(txt): if hasattr(self.logic_service, "filter_target_list"): self.logic_service.filter_target_list() - def update_target_info(self): # Get the selected row's index @@ -990,8 +1050,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: @@ -1076,6 +1136,7 @@ def create_second_column_top_half(self): # Create the "Status" section status_group = QGroupBox("Status") status_layout = QVBoxLayout() + status_layout.setSpacing(10) # Create Calibration Lamps section calibration_lamps_group = QGroupBox("Calibration Lamps") @@ -1136,10 +1197,6 @@ def create_second_column_top_half(self): calibration_lamps_group.setLayout(calibration_lamps_layout) status_layout.addWidget(calibration_lamps_group) - # ---------------------------- - # Add the Seeing and Airmass status fields in the first row - # ---------------------------- - # Create a horizontal layout to arrange Seeing and Airmass side by side first_row_layout = QHBoxLayout() @@ -1170,10 +1227,6 @@ def create_second_column_top_half(self): # Add the first row layout to the status layout status_layout.addLayout(first_row_layout) - # ---------------------------- - # Add the Binning and Slit Width Offset status fields in the second row - # ---------------------------- - # Create a second row layout for Binning and Slit Width Offset second_row_layout = QHBoxLayout() @@ -1395,7 +1448,7 @@ def load_target_lists(self, target_lists=None): lambda *_: self.on_target_set_changed() ) self.on_target_set_changed() - + self.hide_default_columns() def upload_new_target_list(self): """Handle creating a new target list, uploading CSV, and creating a new target set.""" @@ -1462,7 +1515,7 @@ def on_target_set_changed(self, *_): # Real selection → remember name and filter from DB self.parent.current_target_list_name = text self.logic_service.filter_target_list() - + self.hide_default_columns() def on_target_set_changed(self, *_): combo = self.target_list_name diff --git a/pygui/ngps_gui.py b/pygui/ngps_gui.py index a0a5dad6..7bdcc47a 100644 --- a/pygui/ngps_gui.py +++ b/pygui/ngps_gui.py @@ -1,7 +1,7 @@ import sys import subprocess from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QFileDialog, QMessageBox, QDialog, QDesktopWidget, QHBoxLayout, QInputDialog, QStatusBar, QSizePolicy -from PyQt5.QtCore import Qt, pyqtSlot +from PyQt5.QtCore import Qt, pyqtSlot, QTimer from menu_service import MenuService from logic_service import LogicService from layout_service import LayoutService @@ -111,7 +111,6 @@ def __init__(self): # Use addWidget with stretch, not addPermanentWidget self._statusbar.addWidget(self.daemon_row, 1) - # Optional: seed initial states so the UI isn't empty self.daemon_row.bulk_update({ "acamd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), "calibd": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), @@ -126,6 +125,8 @@ def __init__(self): "thermald": (DaemonState.UNKNOWN, "Awaiting first heartbeat..."), }) + # Start polling local processes to update daemon states + self._init_daemon_polling() def init_ui(self): # Set up Menu @@ -408,7 +409,56 @@ def apply_status_update(self, update: dict): if name: self.daemon_row.set_daemon_state(name, state, issue) + def _daemon_process_running(self, name: str) -> bool: + """ + Return True if a process with this name appears to be running + on the local machine. + + Adjust the pgrep pattern if your daemon binary names differ. + """ + try: + # -f: match against full command line; easier if daemons are Python scripts + result = subprocess.run( + ["pgrep", "-f", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return result.returncode == 0 + except Exception as e: + print(f"[daemon-check] Failed to check {name!r}: {e}") + return False + + def refresh_daemon_states_from_ps(self): + """ + Check each known daemon with pgrep and push an update into the + DaemonStatusBar. + + Convention: + - 'ok' => green pill + - 'unknown' => grey pill + """ + updates = {} + + for name in DAEMONS: + if self._daemon_process_running(name): + updates[name] = ("ok", f"{name} process is running.") + else: + updates[name] = ("unknown", f"{name} not found in process list.") + + # bulk_update expects {name: (state, tooltip)} just like the seed above + self.daemon_row.bulk_update(updates) + + def _init_daemon_polling(self): + """ + Set up a QTimer to periodically refresh daemon states. + """ + self._daemon_timer = QTimer(self) + self._daemon_timer.setInterval(5000) # ms; tweak if you want slower/faster + self._daemon_timer.timeout.connect(self.refresh_daemon_states_from_ps) + # Do one immediate check so the row isn't stale on startup + self.refresh_daemon_states_from_ps() + self._daemon_timer.start() if __name__ == '__main__': diff --git a/pygui/sequencer_service.py b/pygui/sequencer_service.py index 722bdee7..e343c4dd 100644 --- a/pygui/sequencer_service.py +++ b/pygui/sequencer_service.py @@ -6,12 +6,12 @@ def __init__(self, parent): self.parent = parent # Hardcoded values for the server connection - self.server_name = 'localhost' # Hardcoded value - self.command_server_port = 9000 # Hardcoded value - self.async_host = '239.1.1.234' # Hardcoded value - self.async_server_port = 1300 # Hardcoded value - self.basename = 'ngps_image' # Hardcoded value - self.log_directory = '/data/logs' # Hardcoded value + self.server_name = 'localhost' + self.command_server_port = 9000 + self.async_host = '239.1.1.234' + self.async_server_port = 1300 + self.basename = 'ngps_image' + self.log_directory = '/data/logs' #self.setup_logging() diff --git a/pygui/styles.qss b/pygui/styles.qss index def2bb54..b78b5b94 100644 --- a/pygui/styles.qss +++ b/pygui/styles.qss @@ -225,9 +225,10 @@ QHeaderView { } QHeaderView::section { - padding: 8px; + padding: 6px; border: 1px solid #888888; background-color: #555555; + font-size: 14pt; } #DaemonStatusBar {