From 1762737aba94971df27ccac1ec8b11a44b8883b5 Mon Sep 17 00:00:00 2001 From: Sayan Dhani Date: Thu, 28 May 2026 12:06:43 +0200 Subject: [PATCH 1/5] Added some safety interlocks and added the Powersupply tab in GUI --- cold.py | 31 ++- coldroom/marta_coldroom.py | 6 +- coldroom/safety.py | 138 +++++++++++- power_supply/power_supply.ui | 345 ++++++++++++++---------------- power_supply/power_supply_ctrl.py | 47 ++-- 5 files changed, 356 insertions(+), 211 deletions(-) diff --git a/cold.py b/cold.py index 3005456..08a3a01 100755 --- a/cold.py +++ b/cold.py @@ -23,6 +23,7 @@ from caen.caenGUIall import caenGUIall from Inner_tracker_GUI.caenGUIall_v2 import caenGUI8LV from db.module_db import ModuleDB +from power_supply.power_supply_ctrl import PowerSupplyController from db.utils import * @@ -175,6 +176,10 @@ def setup_ui(self): self.IT_caen_tab = caenGUI8LV() self.tab_widget.addTab(self.IT_caen_tab, "IT CAEN") + # Add Power Supply tab + self.power_supply_tab = PowerSupplyController() + self.tab_widget.addTab(self.power_supply_tab, "Power Supply") + # Pre-fill settings with values from system self.load_settings_to_ui() self.get_ring_id() @@ -190,7 +195,8 @@ def setup_ui(self): def get_ring_id(self): self.number_of_modules = 0 # Add default at the start - self.ring_id=get_ring_from_cable("I1") #hardcode the single harting cable I1 + # self.ring_id=get_ring_from_cable("I1") #hardcode the single harting cable I1 + self.ring_id=get_ring_from_cable("I1", db_url=self.module_db.db_url) #hardcode the single harting cable I1 if self.ring_id == None: ring_history_file = os.path.join(os.path.dirname(__file__), "ring_history.txt") @@ -325,10 +331,10 @@ def configure_line_edit(field_name, placeholder): button.clicked.connect(self.toggle_coldroom_temp_control) button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_run_start") - if button: + if button and self.system._martacoldroom: button.clicked.connect(self.system._martacoldroom.run) button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_run_stop") - if button: + if button and self.system._martacoldroom: button.clicked.connect(self.system._martacoldroom.stop) configure_line_edit("coldroom_temp_LE", "-30°C to 30°C") @@ -802,9 +808,12 @@ def update_ui(self): if "CO2" in co2_data: if co2_data["CO2"] > 800: is_safe = False - door_msg += "CO2 levels safe: False" + # door_msg += "CO2 levels safe: False" + door_msg += "CO2 levels safe: NO" + door_msg += f" (Current CO2: {co2_data['CO2']:.1f} ppm > 800 ppm)" else: - door_msg += "CO2 levels safe: True" + # door_msg += "CO2 levels safe: True" + door_msg += "CO2 levels safe: Yes" safe_to_open_led.setStyleSheet("background-color: green;" if is_safe else "background-color: red;") logger.debug(f"Updated safe to open LED: {'green' if is_safe else 'red'} (is_safe={is_safe})") self.system._martacoldroom.publish_door_safety_status(is_safe) @@ -1142,11 +1151,15 @@ def set_coldroom_humidity(self): if 0 <= value <= 50: # Humidity range from UI validator if self.system._martacoldroom: self.system._martacoldroom.set_humidity(value) - msg = f"Set coldroom humidity to {value}%" - self.statusBar().showMessage(msg) - logger.info(msg) + msg = f"Set coldroom humidity to {value}%" + self.statusBar().showMessage(msg) + logger.info(msg) + else: + msg = "MARTA Cold Room client not initialized" + self.statusBar().showMessage(msg) + logger.error(msg) else: - msg = "MARTA Cold Room client not initialized" + msg = "Humidity must be between 0% and 50%" self.statusBar().showMessage(msg) logger.error(msg) except ValueError: diff --git a/coldroom/marta_coldroom.py b/coldroom/marta_coldroom.py index 006372b..6071678 100644 --- a/coldroom/marta_coldroom.py +++ b/coldroom/marta_coldroom.py @@ -157,7 +157,9 @@ def on_message(self, client, userdata, msg): if self._system.has_valid_status(): self._system.safety_flags["door_locked"] = not check_dew_point(self._system.status) self._system.safety_flags["sleep"] = check_door_status(self._system.status) - self._system.safety_flags["door_safe"] = check_door_safe_to_open(self._system.status) + self._system.safety_flags["door_safe"] = check_door_safe_to_open( + self._system.status, self._system.status.get("caen", {}), {"HV": []} + ) logger.debug(f"Safety flags updated: {self._system.safety_flags}") def publish_cmd(self, command, target, payload): @@ -169,7 +171,7 @@ def publish_cmd(self, command, target, payload): target (str): Either 'marta' or 'coldroom' or 'cleanroom' payload: The command payload """ - logger.debug("Publishing", command, target, payload) + logger.debug(f"Publishing {command} {target} {payload}") if target == "marta": # Add MARTA topic topic = f"{self.TOPIC_BASE_MARTA}cmd/{command}" elif target == "cleanroom": # Add cleanroom topic diff --git a/coldroom/safety.py b/coldroom/safety.py index 3a3f2e9..a1e3591 100644 --- a/coldroom/safety.py +++ b/coldroom/safety.py @@ -63,7 +63,7 @@ def check_dew_point(system_status): return min_temperature > reference_dew_point + delta except Exception as e: logger.debug(f"Error in check_dew_point: {str(e)}") - return False # Conservative approach + return False, "Error checking dew point safety" # Conservative approach def check_door_status(system_status): @@ -73,7 +73,7 @@ def check_door_status(system_status): return system_status["coldroom"]["CmdDoorUnlock_Reff"] == 1 # Door is open except Exception as e: logger.debug(f"Error in check_door_status: {str(e)}") - return False # Conservative approach + return False, "Error checking door status" # Conservative approach def check_light_status(system_status): @@ -83,7 +83,7 @@ def check_light_status(system_status): return system_status["coldroom"]["light"] == 1 # Light is on except Exception as e: logger.debug(f"Error in check_light_status: {str(e)}") - return False # Conservative approach + return False, "Error checking light status" # Conservative approach def check_any_hv_on(caen_ch_status, used_channels): @@ -98,7 +98,7 @@ def check_any_hv_on(caen_ch_status, used_channels): return hv_on except Exception as e: logger.debug(f"Error in check_any_hv_on: {str(e)}") - return True + return True, "Error checking high voltage status" # Conservative approach - if we can't check, assume it's unsafe def check_cleanroom_expired(elapsed_time, threshold=600): @@ -123,12 +123,20 @@ def check_door_safe_to_open(system_status, caen_ch_status, used_channels): log_msg += f"!!! Warning: Cleanroom data expired, not able to check dew point !!!\n" else: dew_point_safe = check_dew_point(system_status) - log_msg += f"Dew point safe: {dew_point_safe}\n" + if not dew_point_safe: + log_msg += f"!!! Warning: Dew point conditions are not safe for opening door !!!\n" + log_msg += f"Dew point safe: NO\n" + else: + log_msg += f"Dew point safe: YES\n" # 2. Check if high voltage is off hv_on = check_any_hv_on(caen_ch_status, used_channels) hv_safe = not hv_on - log_msg += f"High voltage safe: {hv_safe}\n" + if hv_safe == False: + log_msg += f"!!! Warning: High voltage is ON, not safe to open door !!!\n" + log_msg += f"High voltage safe: NO\n" + else: + log_msg += f"High voltage safe: YES\n" # 3. Check if light is off (light should be off when opening door) # light_off = not check_light_status(system_status) @@ -148,7 +156,7 @@ def check_door_safe_to_open(system_status, caen_ch_status, used_channels): except Exception as e: logger.debug(f"Error in check_door_safe_to_open: {str(e)}") - return False # Conservative approach - if we can't check, assume it's unsafe + return False, "Error checking door safety" def check_light_safe_to_turn_on(system_status, caen_ch_status, used_channels): @@ -198,4 +206,118 @@ def check_marta_safe(system_status): # if condition then : action # if ! check_lv_safe_on and something_on : switch_all_lv_off() -#when performing an active safety action here we send a msg to the alarm topic "/alarm" \ No newline at end of file +#when performing an active safety action here we send a msg to the alarm topic "/alarm" + +def check_lv_safe_on(caen_ch_status, used_channels): + """ + Check if any LV channel is on. + Returns True if any LV channel is on, False if all are off. + """ + try: + for channel in used_channels["LV"]: + if channel is None: + continue + ch_str = f"caen_{channel}_IsOn" + if bool(caen_ch_status.get(ch_str, False)): + return True + return False + except Exception as e: + logger.debug(f"Error in check_lv_safe_on: {str(e)}") + return True # Conservative - assume LV is on if we can't check + + +def check_marta_on_for_ot(system_status): + """ + Check if MARTA is running for OT (Outer Tracker). + Returns True if MARTA is connected and operational. + """ + try: + if "marta" not in system_status: + return False + fsm_state = system_status["marta"].get("fsm_state", "") + return fsm_state not in ("DISCONNECTED", "NONE", "") + except Exception as e: + logger.debug(f"Error in check_marta_on_for_ot: {str(e)}") + return False + + +def check_marta_on_for_it(system_status): + """ + Check if MARTA is running for IT (Inner Tracker). + Returns True if MARTA is connected and operational. + """ + try: + if "marta" not in system_status: + return False + fsm_state = system_status["marta"].get("fsm_state", "") + return fsm_state not in ("DISCONNECTED", "NONE", "") + except Exception as e: + logger.debug(f"Error in check_marta_on_for_it: {str(e)}") + return False + + +def switch_all_lv_off(caen, used_channels): + """ + Turn off all LV channels. + Returns True if all commands were sent successfully. + """ + try: + for channel in used_channels["LV"]: + if channel is None: + continue + logger.warning(f"Safety interlock: turning off LV channel {channel}") + caen.off(channel) + return True + except Exception as e: + logger.error(f"Error in switch_all_lv_off: {str(e)}") + return False + + +def soft_interlock_loop(system_status, caen_ch_status, used_channels, caen, publish_alarm=None): + """ + Soft interlock loop - monitors safety conditions and takes protective action. + + High-level logic: + if LV is on AND MARTA is not running: + → switch all LV off + → publish alarm message to /alarm topic + + When performing an active safety action, a message is sent to the alarm topic "/alarm" + via the publish_alarm callback. + + Args: + system_status (dict): Full system status including MARTA, coldroom, etc. + caen_ch_status (dict): CAEN channel status with caen_{channel}_IsOn keys. + used_channels (dict): Active channel list {"LV": [...], "HV": [...]}. + caen: CAEN control object with on()/off() methods. + publish_alarm (callable, optional): Function to publish alarm messages. + Signature: publish_alarm(message_string) + + Returns: + tuple: (is_safe: bool, message: str) + """ + try: + lv_on = check_lv_safe_on(caen_ch_status, used_channels) + marta_ot = check_marta_on_for_ot(system_status) + marta_it = check_marta_on_for_it(system_status) + marta_on = marta_ot or marta_it + + log_msg = f"Soft interlock: LV_on={lv_on}, MARTA_OT={marta_ot}, MARTA_IT={marta_it}" + + if lv_on and not marta_on: + alarm_msg = ( + "SAFETY INTERLOCK: LV channels are on but MARTA is not running. " + "Turning off all LV channels to prevent module damage." + ) + logger.warning(alarm_msg) + switch_all_lv_off(caen, used_channels) + if publish_alarm: + publish_alarm(alarm_msg) + return False, alarm_msg + + return True, log_msg + + except Exception as e: + err_msg = f"Error in soft_interlock_loop: {str(e)}" + logger.error(err_msg) + return False, err_msg \ No newline at end of file diff --git a/power_supply/power_supply.ui b/power_supply/power_supply.ui index 19fab89..82cb2b2 100644 --- a/power_supply/power_supply.ui +++ b/power_supply/power_supply.ui @@ -1,7 +1,7 @@ - MainWindow - + PowerSupply + 0 @@ -11,184 +11,171 @@ - MainWindow + Power Supply - - - - - - Power Supply Control - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - Set Voltage Value - - - Qt::AlignCenter - - - - - - - ? - - - Qt::AlignCenter - - - - - - - Set Current - - - - - - - ON - - - - - - - ? - - - Qt::AlignCenter - - - - - - - ? - - - Qt::AlignCenter - - - - - - - Set Voltage - - - - - - - - - - Set Curent Value - - - Qt::AlignCenter - - - - - - - ? - - - Qt::AlignCenter - - - - - - - - 30 - 30 - - - - - 30 - 30 - - - - background-color: red; - - - QFrame::NoFrame - - - QFrame::Raised - - - - - - - Voltage ( v ) - - - Qt::AlignCenter - - - - - - - OFF - - - - - - - Current Status ( i ) - - - Qt::AlignCenter - - - - - - - - - - - - 0 - 0 - 696 - 22 - - - - + + + + + Power Supply Control + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Set Voltage Value + + + Qt::AlignCenter + + + + + + + ? + + + Qt::AlignCenter + + + + + + + Set Current + + + + + + + ON + + + + + + + ? + + + Qt::AlignCenter + + + + + + + ? + + + Qt::AlignCenter + + + + + + + Set Voltage + + + + + + + + + + Set Curent Value + + + Qt::AlignCenter + + + + + + + ? + + + Qt::AlignCenter + + + + + + + + 30 + 30 + + + + + 30 + 30 + + + + background-color: red; + + + QFrame::NoFrame + + + QFrame::Raised + + + + + + + Voltage ( v ) + + + Qt::AlignCenter + + + + + + + OFF + + + + + + + Current Status ( i ) + + + Qt::AlignCenter + + + + + + + diff --git a/power_supply/power_supply_ctrl.py b/power_supply/power_supply_ctrl.py index 39dbab8..162b571 100644 --- a/power_supply/power_supply_ctrl.py +++ b/power_supply/power_supply_ctrl.py @@ -1,7 +1,8 @@ +import os import sys import logging import pyvisa as visa -from PyQt5.QtWidgets import QApplication, QMainWindow, QMessageBox +from PyQt5.QtWidgets import QApplication, QWidget, QMessageBox from PyQt5.uic import loadUi from PyQt5.QtCore import QTimer @@ -16,11 +17,12 @@ ) logger = logging.getLogger(__name__) -class PowerSupplyController(QMainWindow): - def __init__(self): - super(PowerSupplyController, self).__init__() +class PowerSupplyController(QWidget): + def __init__(self, parent=None): + super(PowerSupplyController, self).__init__(parent) # Load the UI file - loadUi('power_supply.ui', self) + ui_path = os.path.join(os.path.dirname(__file__), 'power_supply.ui') + loadUi(ui_path, self) # Initialize status dictionary self.status = { @@ -30,15 +32,21 @@ def __init__(self): 'set_voltage': 0.0, 'set_current': 0.0 } + self.connected = False + + # Set up timer early so closeEvent doesn't crash on connection failure + self.timer = QTimer(self) # Connect to the power supply self.connect_to_power_supply() - + if not self.connected: + self.timer.stop() + return + # Connect UI signals to slots self.setup_ui_connections() # Set up a timer to periodically update measurements - self.timer = QTimer(self) self.timer.timeout.connect(self.update_measurements) self.timer.start(1000) # Update every 1000ms (1 second) @@ -57,11 +65,11 @@ def connect_to_power_supply(self): ip_address = '192.168.0.16' self.rm = visa.ResourceManager() self.power_supply = self.rm.open_resource(f'TCPIP0::{ip_address}::INSTR') + self.connected = True print("Successfully connected to power supply") except Exception as e: logger.error(f"Failed connection: {e}") QMessageBox.critical(self, "Connection Error", f"Failed to connect to power supply: {e}") - self.close() def setup_ui_connections(self): """Connect UI signals to slots""" @@ -84,7 +92,8 @@ def update_ui(self): def update_measurements(self): """Periodically update the voltage and current measurements""" - # if self.status['power'] == 'ON': + if not self.connected: + return try: # Read actual voltage and current voltage = self.power_supply.query('MEAS:VOLT?\x00').strip() @@ -99,11 +108,11 @@ def update_measurements(self): self.log_status("Updated measurements") except Exception as e: logger.error(f"Error reading measurements: {e}") - QMessageBox.critical(self, "Measurement Error", f"Failed to read measurements: {e}") - print(f"Error reading measurements: {e}") def set_voltage(self): """Set the voltage value from the line edit""" + if not self.connected: + return try: voltage = float(self.powsup_voltage_LE.text()) self.power_supply.write(f'APPLY {voltage}\x00') @@ -119,6 +128,8 @@ def set_voltage(self): def set_current(self): """Set the current value from the line edit""" + if not self.connected: + return try: current = float(self.powsup_current_LE.text()) self.power_supply.write(f'SOUR:CURR {current}\x00') @@ -134,6 +145,8 @@ def set_current(self): def power_on(self): """Turn the power supply ON""" + if not self.connected: + return try: self.power_supply.write('OUTP:STAT ON\x00') self.status['power'] = 'ON' @@ -147,6 +160,8 @@ def power_on(self): def power_off(self): """Turn the power supply OFF""" + if not self.connected: + return try: self.power_supply.write('OUTP:STAT OFF\x00') self.status['power'] = 'OFF' @@ -163,7 +178,8 @@ def power_off(self): def closeEvent(self, event): """Ensure proper cleanup when closing the application""" try: - self.timer.stop() + if hasattr(self, 'timer'): + self.timer.stop() if hasattr(self, 'power_supply'): self.power_off() # Turn off power supply when closing self.power_supply.close() @@ -177,9 +193,14 @@ def closeEvent(self, event): def main(): app = QApplication(sys.argv) - window = PowerSupplyController() + window = QMainWindow() + controller = PowerSupplyController() + window.setCentralWidget(controller) + window.setWindowTitle("Power Supply") + window.resize(controller.width(), controller.height()) window.show() sys.exit(app.exec_()) if __name__ == '__main__': + from PyQt5.QtWidgets import QMainWindow main() From 12049757b65482c9f9066aa98742ddb9d30cac40 Mon Sep 17 00:00:00 2001 From: Sayan Dhani Date: Thu, 28 May 2026 15:33:20 +0200 Subject: [PATCH 2/5] Added some safety interlocks and added the Powersupply tab in GUI --- cold.py | 51 +++++++++++++ coldroom/marta_coldroom.ui | 73 +++++++++++++++++++ coldroom/safety.py | 143 ++++++++++++++++++++++++++++++------- 3 files changed, 242 insertions(+), 25 deletions(-) diff --git a/cold.py b/cold.py index 08a3a01..0a3f5e3 100755 --- a/cold.py +++ b/cold.py @@ -19,6 +19,7 @@ check_door_status, check_light_safe_to_turn_on, check_marta_safe, + soft_interlock_loop, ) from caen.caenGUIall import caenGUIall from Inner_tracker_GUI.caenGUIall_v2 import caenGUI8LV @@ -50,6 +51,11 @@ def __init__(self): self.update_timer.timeout.connect(self.update_ui) self.update_timer.start(1000) # Update every second + # Setup soft interlock timer (5-second interval, independent of UI updates) + self.soft_interlock_timer = QTimer() + self.soft_interlock_timer.timeout.connect(self.soft_interlock_check) + self.soft_interlock_timer.start(5000) + # Connect to MQTT broker at startup self.connect_mqtt() @@ -63,6 +69,11 @@ def closeEvent(self, event): self.update_timer.stop() logger.debug("Stopped update timer") + # Stop the soft interlock timer + if hasattr(self, "soft_interlock_timer"): + self.soft_interlock_timer.stop() + logger.debug("Stopped soft interlock timer") + # Cleanup Thermal Camera tab if it exists if hasattr(self, "thermal_camera_tab"): self.thermal_camera_tab.cleanup() @@ -371,6 +382,14 @@ def configure_line_edit(field_name, placeholder): label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "coldroom_door_state_label") if label: logger.debug("Connected door state label") + # Soft interlock LED + soft_interlock_led = self.marta_coldroom_tab.findChild(QtWidgets.QFrame, "soft_interlock_LED") + if soft_interlock_led: + logger.debug("Connected soft interlock LED") + # Soft interlock message label + soft_interlock_msg = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "soft_interlock_msg") + if soft_interlock_msg: + logger.debug("Connected soft interlock message label") # Temperature setpoint controls button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_temp_set_PB") @@ -557,6 +576,29 @@ def connect_mqtt(self): self.statusBar().showMessage(error_msg) logger.error(error_msg) + def soft_interlock_check(self): + logger.info("Called soft Interlock") + used_caen_channels = self.modules_list_tab.get_used_channels() + logger.info(f"Used CAEN channels for safety checks: {used_caen_channels}") + alarm_publish = (lambda msg: self.system._martacoldroom._client.publish("/alarm", msg)) if self.system._martacoldroom else None + logger.info(f"Alarm publish function: {alarm_publish}") + is_safe, msg = soft_interlock_loop(self.system.status, self.caen_tab.last_response, used_caen_channels, self.caen_tab, publish_alarm=alarm_publish) + logger.info(f"Soft interlock result: is_safe={is_safe}, msg={msg}") + + soft_interlock_led = self.marta_coldroom_tab.findChild(QtWidgets.QFrame, "soft_interlock_LED") + if soft_interlock_led: + status_color = "green" if is_safe else "red" + soft_interlock_led.setStyleSheet("background-color: white;") + QTimer.singleShot(500, lambda led=soft_interlock_led, color=status_color: led.setStyleSheet(f"background-color: {color};")) + logger.info(f"Updated soft interlock LED: {status_color} with pulse") + + soft_interlock_msg_label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "soft_interlock_msg") + if soft_interlock_msg_label: + soft_interlock_msg_label.setText(msg) + logger.info(f"Updated soft interlock message: {msg}") + + logger.info("Completed soft interlock loop") + def update_ui(self): """Update UI with current system status""" try: @@ -915,6 +957,15 @@ def update_ui(self): # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> self.modules_list_tab.marta_safe, self.modules_list_tab.marta_log_msg = check_marta_safe(self.system.status) + + # logger.info("Called soft Interlock") + # used_caen_channels = self.modules_list_tab.get_used_channels() + # logger.info(f"Used CAEN channels for safety checks: {used_caen_channels}") + # alarm_publish = (lambda msg: self.system._martacoldroom._client.publish("/alarm", msg)) if self.system._martacoldroom else None + # logger.info(f"Alarm publish function: {alarm_publish}") + # soft_interlock_loop(self.system.status, self.caen_tab.last_response, used_caen_channels, self.caen_tab, publish_alarm=alarm_publish) + # logger.info("Completed soft interlock loop") + # Update MARTA CO2 Plant values if "marta" in self.system.status: marta = self.system.status["marta"] diff --git a/coldroom/marta_coldroom.ui b/coldroom/marta_coldroom.ui index 35c8ea8..4eacd73 100644 --- a/coldroom/marta_coldroom.ui +++ b/coldroom/marta_coldroom.ui @@ -94,6 +94,79 @@ + + + + + + + + + 60 + 60 + + + + + 300 + 300 + + + + background-color: red; + + + QFrame::NoFrame + + + QFrame::Raised + + + + + + + + 160 + 60 + + + + + 16 + + + + Soft Interlock + + + Qt::AlignCenter + + + + + + + + + Qt::Vertical + + + + + + + + 16 + + + + - + + + + + diff --git a/coldroom/safety.py b/coldroom/safety.py index a1e3591..bd1e3f6 100644 --- a/coldroom/safety.py +++ b/coldroom/safety.py @@ -1,4 +1,5 @@ import os +from tkinter import NO, YES, YES import yaml import logging @@ -208,7 +209,7 @@ def check_marta_safe(system_status): #when performing an active safety action here we send a msg to the alarm topic "/alarm" -def check_lv_safe_on(caen_ch_status, used_channels): +def Is_any_lv_on(caen_ch_status, used_channels): """ Check if any LV channel is on. Returns True if any LV channel is on, False if all are off. @@ -219,40 +220,104 @@ def check_lv_safe_on(caen_ch_status, used_channels): continue ch_str = f"caen_{channel}_IsOn" if bool(caen_ch_status.get(ch_str, False)): + logger.info(f"LV channel {channel} is ON") return True return False except Exception as e: - logger.debug(f"Error in check_lv_safe_on: {str(e)}") + logger.debug(f"Error in Is_any_lv_on: {str(e)}") return True # Conservative - assume LV is on if we can't check +def Is_it_safe_to_on_lv(system_status, caen_ch_status, used_channels): + """ + Check if it's safe to turn on LV channels based on multiple safety conditions. + Returns True if it's safe to turn on LV channels, False otherwise. + """ + log_msg = "" + try: + # Check if we have all necessary data + if "coldroom" not in system_status: + return False # Conservative approach - if we can't check, assume it's unsafe -def check_marta_on_for_ot(system_status): + # Check if MARTA is running + marta_safe, marta_msg = check_marta_safe(system_status) + log_msg += f"MARTA safe: {marta_safe} ({marta_msg})\n" + + # It's safe to turn on LV channels if MARTA is safe + is_safe = marta_safe + return is_safe, log_msg + + except Exception as e: + logger.debug(f"Error in Is_it_safe_to_on_lv: {str(e)}") + return False, "Error checking LV safety" + + +# def check_lv_safe_on(caen_ch_status, used_channels): +# """ +# Check if any LV channel is on. +# Returns True if any LV channel is on, False if all are off. +# """ +# try: +# for channel in used_channels["LV"]: +# if channel is None: +# continue +# ch_str = f"caen_{channel}_IsOn" +# if bool(caen_ch_status.get(ch_str, False)): +# return True +# return False +# except Exception as e: +# logger.debug(f"Error in check_lv_safe_on: {str(e)}") +# return True # Conservative - assume LV is on if we can't check + + +def check_marta_on_for_OT(system_status): """ Check if MARTA is running for OT (Outer Tracker). Returns True if MARTA is connected and operational. """ try: - if "marta" not in system_status: + if "marta" not in system_status or "serviceroom" not in system_status: return False + + logger.info(f"Checking MARTA OT status: {system_status['marta'].get('fsm_state', 'N/A')}, OT valve: {system_status['serviceroom'].get('outer_valve', 'N/A')}") fsm_state = system_status["marta"].get("fsm_state", "") - return fsm_state not in ("DISCONNECTED", "NONE", "") + OT_valve = system_status["serviceroom"].get("outer_valve", 0) + + if fsm_state not in ("DISCONNECTED", "NONE", ""): + if OT_valve == 1: # Outer valve is open, so OT is likely running + logger.info("MARTA FSM state is good and OT valve is open, treating as running") + return True + else: + logger.debug("MARTA FSM state is good but OT valve is closed, treating as not running") + return False + else: + return False except Exception as e: - logger.debug(f"Error in check_marta_on_for_ot: {str(e)}") + logger.debug(f"Error in check_marta_on_for_OT: {str(e)}") return False -def check_marta_on_for_it(system_status): +def check_marta_on_for_IT(system_status): """ Check if MARTA is running for IT (Inner Tracker). Returns True if MARTA is connected and operational. """ try: - if "marta" not in system_status: + if "marta" not in system_status or "serviceroom" not in system_status: return False + logger.info(f"Checking MARTA IT status: {system_status['marta'].get('fsm_state', 'N/A')}, IT valve: {system_status['serviceroom'].get('inner_valve', 'N/A')}") fsm_state = system_status["marta"].get("fsm_state", "") - return fsm_state not in ("DISCONNECTED", "NONE", "") + IT_valve = system_status["serviceroom"].get("inner_valve", 0) + if fsm_state not in ("DISCONNECTED", "NONE", ""): + if IT_valve == 1: # Inner valve is open, so IT is likely running + logger.info("MARTA FSM state is good and IT valve is open, treating as running") + return True + else: + logger.debug("MARTA FSM state is good but IT valve is closed, treating as not running") + return False + else: + return False except Exception as e: - logger.debug(f"Error in check_marta_on_for_it: {str(e)}") + logger.debug(f"Error in check_marta_on_for_IT: {str(e)}") return False @@ -263,6 +328,7 @@ def switch_all_lv_off(caen, used_channels): """ try: for channel in used_channels["LV"]: + logger.info(f"Attempting to turn off LV channel {channel}") if channel is None: continue logger.warning(f"Safety interlock: turning off LV channel {channel}") @@ -297,23 +363,50 @@ def soft_interlock_loop(system_status, caen_ch_status, used_channels, caen, publ tuple: (is_safe: bool, message: str) """ try: - lv_on = check_lv_safe_on(caen_ch_status, used_channels) - marta_ot = check_marta_on_for_ot(system_status) - marta_it = check_marta_on_for_it(system_status) + lv_on = Is_any_lv_on(caen_ch_status, used_channels) + lv_safe_to_on = Is_it_safe_to_on_lv(system_status, caen_ch_status, used_channels) + marta_ot = check_marta_on_for_OT(system_status) + marta_it = check_marta_on_for_IT(system_status) marta_on = marta_ot or marta_it + + if lv_on: + lv_status = "ON" + else: + lv_status = "OFF" + + if marta_ot: + marta_ot_status = "RUNNING" + else: + marta_ot_status = "NOT RUNNING" - log_msg = f"Soft interlock: LV_on={lv_on}, MARTA_OT={marta_ot}, MARTA_IT={marta_it}" - - if lv_on and not marta_on: - alarm_msg = ( - "SAFETY INTERLOCK: LV channels are on but MARTA is not running. " - "Turning off all LV channels to prevent module damage." - ) - logger.warning(alarm_msg) - switch_all_lv_off(caen, used_channels) - if publish_alarm: - publish_alarm(alarm_msg) - return False, alarm_msg + if marta_it: + marta_it_status = "RUNNING" + else: + marta_it_status = "NOT RUNNING" + + log_msg = f"\nSoft interlock: LV_on={lv_status}, MARTA_OT={marta_ot_status}, MARTA_IT={marta_it_status}\n" + logger.info(log_msg) + + if lv_safe_to_on == False: + log_msg += "\n!!! Warning: Conditions are not safe to turn on LV channels !!!\n" + log_msg += f"\nLV safe to on: NO\n" + # switch_all_lv_off(caen, used_channels) + else: + log_msg += f"\nLV safe to on: YES\n" + + if lv_on and not marta_on: + alarm_msg = ( + "SAFETY INTERLOCK: LV channels are on but MARTA is not running. " + "Turning off all LV channels to prevent module damage." + ) + logger.warning(alarm_msg) + # switch_all_lv_off(caen, used_channels) + if publish_alarm: + publish_alarm(alarm_msg) + return False, alarm_msg + else: + log_msg += "\nAll safety conditions met.\n" + logger.info(log_msg) return True, log_msg From 55894c60e72156432496275ba7a84cdb02b23719 Mon Sep 17 00:00:00 2001 From: Sayan Dhani Date: Thu, 28 May 2026 15:38:49 +0200 Subject: [PATCH 3/5] Added some safety interlocks and added the Powersupply tab in GUI --- cold.py | 640 +++++++++++++++++++++++++++++++++------------ coldroom/safety.py | 102 +++++--- 2 files changed, 546 insertions(+), 196 deletions(-) diff --git a/cold.py b/cold.py index 0a3f5e3..0f6bc41 100755 --- a/cold.py +++ b/cold.py @@ -27,7 +27,6 @@ from power_supply.power_supply_ctrl import PowerSupplyController from db.utils import * - # Configure logging logger = logging.getLogger("integration") @@ -137,7 +136,9 @@ def setup_ui(self): # Load the MARTA Cold Room tab from UI file # Create a temporary QMainWindow to load the UI temp_window = QtWidgets.QMainWindow() - marta_ui_file = os.path.join(os.path.dirname(__file__), "coldroom", "marta_coldroom.ui") + marta_ui_file = os.path.join( + os.path.dirname(__file__), "coldroom", "marta_coldroom.ui" + ) uic.loadUi(marta_ui_file, temp_window) # Create a QWidget for our tab and get the central widget from temp_window @@ -172,14 +173,18 @@ def setup_ui(self): self.tab_widget.addTab(self.module_db.ui.tab_2, "Module Inventory") self.tab_widget.addTab(self.module_db.ui.moduleDetailsTab, "Module Details") self.module_db.ui.viewDetailsPB.clicked.connect( - lambda: self.tab_widget.setCurrentIndex(self.tab_widget.indexOf(self.module_db.ui.moduleDetailsTab)) + lambda: self.tab_widget.setCurrentIndex( + self.tab_widget.indexOf(self.module_db.ui.moduleDetailsTab) + ) ) self.module_db.ui.selectModulePB.setEnabled(False) self.modules_list_tab.db_url = self.module_db.db_url # Load settings tab from UI file self.settings_tab = QtWidgets.QWidget() - settings_ui_file = os.path.join(os.path.dirname(__file__), "coldroom", "settings_coldroom.ui") + settings_ui_file = os.path.join( + os.path.dirname(__file__), "coldroom", "settings_coldroom.ui" + ) uic.loadUi(settings_ui_file, self.settings_tab) self.tab_widget.addTab(self.settings_tab, "Settings") @@ -207,10 +212,14 @@ def setup_ui(self): def get_ring_id(self): self.number_of_modules = 0 # Add default at the start # self.ring_id=get_ring_from_cable("I1") #hardcode the single harting cable I1 - self.ring_id=get_ring_from_cable("I1", db_url=self.module_db.db_url) #hardcode the single harting cable I1 + self.ring_id = get_ring_from_cable( + "I1", db_url=self.module_db.db_url + ) # hardcode the single harting cable I1 if self.ring_id == None: - ring_history_file = os.path.join(os.path.dirname(__file__), "ring_history.txt") - + ring_history_file = os.path.join( + os.path.dirname(__file__), "ring_history.txt" + ) + if os.path.exists(ring_history_file): with open(ring_history_file, "r") as f: lines = f.readlines() @@ -218,8 +227,8 @@ def get_ring_id(self): self.ring_id = lines[-1].strip() else: self.ring_id = None - - #TODO: move this to the right place so it works also when ring ID LineEdit is updated + + # TODO: move this to the right place so it works also when ring ID LineEdit is updated if self.ring_id != None: self.modules_list_tab.ring_id_LE.setText(self.ring_id) logger.info(f"Loaded ring ID from history: {self.ring_id}") @@ -231,7 +240,10 @@ def get_ring_id(self): elif self.ring_id.startswith("L3_"): self.number_of_modules = 36 self.modules_list_tab.populate_from_config( - self.caen_tab, self.mounted_modules, self.number_of_modules, self.thermal_camera_tab + self.caen_tab, + self.mounted_modules, + self.number_of_modules, + self.thermal_camera_tab, ) return self.ring_id @@ -249,9 +261,13 @@ def setup_ring_id(self): elif self.ring_id.startswith("L3_"): self.number_of_modules = 36 else: - self.message_box.setText("Invalid ring ID format. Must start with L1_, L2_, or L3_.") + self.message_box.setText( + "Invalid ring ID format. Must start with L1_, L2_, or L3_." + ) self.message_box.exec_() - self.modules_list_tab.populate_from_config(self.caen_tab, self.mounted_modules, self.number_of_modules) + self.modules_list_tab.populate_from_config( + self.caen_tab, self.mounted_modules, self.number_of_modules + ) def save_ring_id(self): ring_history_file = os.path.join(os.path.dirname(__file__), "ring_history.txt") @@ -260,22 +276,30 @@ def save_ring_id(self): logger.info(f"Ring ID {self.ring_id} saved successfully.") def get_mounted_modules(self): - self.mounted_modules = get_modules_on_ring(self.ring_id, db_url=self.module_db.db_url) + self.mounted_modules = get_modules_on_ring( + self.ring_id, db_url=self.module_db.db_url + ) for module_name in self.mounted_modules: - self.mounted_modules[module_name].update(get_module_endpoints(module_name, db_url=self.module_db.db_url)) self.mounted_modules[module_name].update( - {"speed": get_module_speed(module_name, db_url=self.module_db.db_url)} + get_module_endpoints(module_name, db_url=self.module_db.db_url) ) self.mounted_modules[module_name].update( - {"fuseId": get_module_fuse_id(module_name, db_url=self.module_db.db_url)} + {"speed": get_module_speed(module_name, db_url=self.module_db.db_url)} ) self.mounted_modules[module_name].update( { - "temperature_offsets": get_module(module_name, db_url=self.module_db.db_url).get( - "temperature_offsets", {} + "fuseId": get_module_fuse_id( + module_name, db_url=self.module_db.db_url ) } ) + self.mounted_modules[module_name].update( + { + "temperature_offsets": get_module( + module_name, db_url=self.module_db.db_url + ).get("temperature_offsets", {}) + } + ) logger.debug(f"Mounted modules for ring {self.ring_id}: {self.mounted_modules}") return self.mounted_modules @@ -283,11 +307,21 @@ def load_settings_to_ui(self): # Fill settings UI with current values self.settings_tab.brokerLineEdit.setText(self.system.settings["mqtt"]["broker"]) self.settings_tab.portSpinBox.setValue(self.system.settings["mqtt"]["port"]) - self.settings_tab.martaTopicLineEdit.setText(self.system.settings["MARTA"]["mqtt_topic"]) - self.settings_tab.coldroomTopicLineEdit.setText(self.system.settings["Coldroom"]["mqtt_topic"]) - self.settings_tab.co2SensorTopicLineEdit.setText(self.system.settings["Coldroom"]["co2_sensor_topic"]) - self.settings_tab.thermalCameraTopicLineEdit.setText(self.system.settings["ThermalCamera"]["mqtt_topic"]) - self.settings_tab.cleanroomTopicLineEdit.setText(self.system.settings["Cleanroom"]["mqtt_topic"]) + self.settings_tab.martaTopicLineEdit.setText( + self.system.settings["MARTA"]["mqtt_topic"] + ) + self.settings_tab.coldroomTopicLineEdit.setText( + self.system.settings["Coldroom"]["mqtt_topic"] + ) + self.settings_tab.co2SensorTopicLineEdit.setText( + self.system.settings["Coldroom"]["co2_sensor_topic"] + ) + self.settings_tab.thermalCameraTopicLineEdit.setText( + self.system.settings["ThermalCamera"]["mqtt_topic"] + ) + self.settings_tab.cleanroomTopicLineEdit.setText( + self.system.settings["Cleanroom"]["mqtt_topic"] + ) def connect_signals(self): # Module list @@ -304,66 +338,88 @@ def configure_line_edit(field_name, placeholder): le.setStyleSheet("QLineEdit { color: grey; }") # Connect signals - le.textChanged.connect(lambda: le.setStyleSheet("QLineEdit { color: black; }")) - le.editingFinished.connect(lambda: le.setPlaceholderText(placeholder) if le.text() == "" else None) + le.textChanged.connect( + lambda: le.setStyleSheet("QLineEdit { color: black; }") + ) + le.editingFinished.connect( + lambda: ( + le.setPlaceholderText(placeholder) if le.text() == "" else None + ) + ) # Light controls - self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_light_on_PB").clicked.connect( - self.coldroom_light_on - ) - self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_light_off_PB").clicked.connect( - self.coldroom_light_off - ) + self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_light_on_PB" + ).clicked.connect(self.coldroom_light_on) + self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_light_off_PB" + ).clicked.connect(self.coldroom_light_off) # Dry air controls - self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_dry_air_on_PB").clicked.connect( - self.coldroom_dry_air_on - ) - self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_dry_air_off_PB").clicked.connect( - self.coldroom_dry_air_off - ) + self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_dry_air_on_PB" + ).clicked.connect(self.coldroom_dry_air_on) + self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_dry_air_off_PB" + ).clicked.connect(self.coldroom_dry_air_off) # Dry air bypass controls - self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_dry_air_bypass_on_PB").clicked.connect( - self.coldroom_dry_air_bypass_on - ) - self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_dry_air_bypass_off_PB").clicked.connect( - self.coldroom_dry_air_bypass_off - ) + self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_dry_air_bypass_on_PB" + ).clicked.connect(self.coldroom_dry_air_bypass_on) + self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_dry_air_bypass_off_PB" + ).clicked.connect(self.coldroom_dry_air_bypass_off) # Door controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_door_toggle_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_door_toggle_PB" + ) if button: button.clicked.connect(self.toggle_coldroom_door) # Temperature controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_temp_ctrl_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_temp_ctrl_PB" + ) if button: button.clicked.connect(self.toggle_coldroom_temp_control) - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_run_start") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_run_start" + ) if button and self.system._martacoldroom: button.clicked.connect(self.system._martacoldroom.run) - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_run_stop") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_run_stop" + ) if button and self.system._martacoldroom: button.clicked.connect(self.system._martacoldroom.stop) configure_line_edit("coldroom_temp_LE", "-30°C to 30°C") configure_line_edit("coldroom_humidity_LE", "0% to 50%") # Temperature setpoint label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "coldroom_temp_set_point_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "coldroom_temp_set_point_label" + ) if label: logger.debug("Connected temperature set point label") # Humidity setpoint label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "coldroom_humidity_set_point_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "coldroom_humidity_set_point_label" + ) if label: logger.debug("Connected humidity set point label") # Temperature control LED - ctrl_temp_led = self.marta_coldroom_tab.findChild(QtWidgets.QFrame, "ctrl_temp_LED") + ctrl_temp_led = self.marta_coldroom_tab.findChild( + QtWidgets.QFrame, "ctrl_temp_LED" + ) if ctrl_temp_led: logger.debug("Connected temperature control LED") # Humidity control LED - ctrl_humidity_led = self.marta_coldroom_tab.findChild(QtWidgets.QFrame, "ctrl_humidity_LED") + ctrl_humidity_led = self.marta_coldroom_tab.findChild( + QtWidgets.QFrame, "ctrl_humidity_LED" + ) if ctrl_humidity_led: logger.debug("Connected humidity control LED") # Light control LED @@ -375,144 +431,206 @@ def configure_line_edit(field_name, placeholder): if dry_air_led: logger.debug("Connected dry air control LED") # Safe to open LED - safe_to_open_led = self.marta_coldroom_tab.findChild(QtWidgets.QFrame, "safe_to_open_LED") + safe_to_open_led = self.marta_coldroom_tab.findChild( + QtWidgets.QFrame, "safe_to_open_LED" + ) if safe_to_open_led: logger.debug("Connected safe to open LED") # Door state label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "coldroom_door_state_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "coldroom_door_state_label" + ) if label: logger.debug("Connected door state label") # Soft interlock LED - soft_interlock_led = self.marta_coldroom_tab.findChild(QtWidgets.QFrame, "soft_interlock_LED") + soft_interlock_led = self.marta_coldroom_tab.findChild( + QtWidgets.QFrame, "soft_interlock_LED" + ) if soft_interlock_led: logger.debug("Connected soft interlock LED") # Soft interlock message label - soft_interlock_msg = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "soft_interlock_msg") + soft_interlock_msg = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "soft_interlock_msg" + ) if soft_interlock_msg: logger.debug("Connected soft interlock message label") # Temperature setpoint controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_temp_set_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_temp_set_PB" + ) if button: button.clicked.connect(self.set_coldroom_temperature) # Humidity controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_humidity_ctrl_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_humidity_ctrl_PB" + ) if button: button.clicked.connect(self.toggle_coldroom_humidity_control) # Humidity setpoint controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_humidity_set_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_humidity_set_PB" + ) if button: button.clicked.connect(self.set_coldroom_humidity) # Run controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_run_toggle_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_run_toggle_PB" + ) if button: button.clicked.connect(self.toggle_coldroom_run) # Reset alarms - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "coldroom_reset_alarms_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "coldroom_reset_alarms_PB" + ) if button: button.clicked.connect(self.reset_coldroom_alarms) # MARTA CO2 Plant controls # Temperature controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_temp_set_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_temp_set_PB" + ) if button: button.clicked.connect(self.set_marta_temperature) - configure_line_edit("marta_temp_LE", "-30°C to 18°C") # Placeholder for temperature input + configure_line_edit( + "marta_temp_LE", "-30°C to 18°C" + ) # Placeholder for temperature input # Speed controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_speed_set_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_speed_set_PB" + ) if button: button.clicked.connect(self.set_marta_speed) - configure_line_edit("marta_speed_LE", "5000 to 6000 RPM") # Placeholder for speed input + configure_line_edit( + "marta_speed_LE", "5000 to 6000 RPM" + ) # Placeholder for speed input # Flow controls - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_flow_set_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_flow_set_PB" + ) if button: button.clicked.connect(self.set_marta_flow) - configure_line_edit("marta_flow_LE", "0 to 5 L/min") # Placeholder for flow input + configure_line_edit( + "marta_flow_LE", "0 to 5 L/min" + ) # Placeholder for flow input # Supply temperature label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "marta_temp_supply_value_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "marta_temp_supply_value_label" + ) if label: logger.debug("Connected supply temperature label") # Return temperature label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "marta_temp_return_value_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "marta_temp_return_value_label" + ) if label: logger.debug("Connected return temperature label") # Supply pressure label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "marta_pressure_supply_value_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "marta_pressure_supply_value_label" + ) if label: logger.debug("Connected supply pressure label") # Return pressure label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "marta_pressure_return_value_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "marta_pressure_return_value_label" + ) if label: logger.debug("Connected return pressure label") # Speed label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "marta_speed_value_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "marta_speed_value_label" + ) if label: logger.debug("Connected speed label") # Temperature Set Point Label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "marta_temp_set_point_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "marta_temp_set_point_label" + ) if label: logger.debug("Connected temperature set point label") # Speed Set Point Label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "marta_speed_set_point_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "marta_speed_set_point_label" + ) if label: logger.debug("Connected speed set point label") # Flow Set Point Label - label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "marta_flow_set_point_label") + label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "marta_flow_set_point_label" + ) if label: logger.debug("Connected flow set point label") # Other MARTA controls # Start chiller button - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_chiller_start_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_chiller_start_PB" + ) if button: button.clicked.connect(self.start_marta_chiller) # Start CO2 button - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_co2_start_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_co2_start_PB" + ) if button: button.clicked.connect(self.start_marta_co2) # Stop CO2 button - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_co2_stop_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_co2_stop_PB" + ) if button: button.clicked.connect(self.stop_marta_co2) # Stop chiller button - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_chiller_stop_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_chiller_stop_PB" + ) if button: button.clicked.connect(self.stop_marta_chiller) # Clear alarms button - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_alarms_clear_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_alarms_clear_PB" + ) if button: button.clicked.connect(self.clear_marta_alarms) # Reconnect button - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_reconnect_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_reconnect_PB" + ) if button: button.clicked.connect(self.reconnect_marta) # Refresh button - button = self.marta_coldroom_tab.findChild(QtWidgets.QPushButton, "marta_refresh_PB") + button = self.marta_coldroom_tab.findChild( + QtWidgets.QPushButton, "marta_refresh_PB" + ) if button: button.clicked.connect(self.refresh_marta) # Flow active checkbox - checkbox = self.marta_coldroom_tab.findChild(QtWidgets.QCheckBox, "marta_flow_active_CB") + checkbox = self.marta_coldroom_tab.findChild( + QtWidgets.QCheckBox, "marta_flow_active_CB" + ) if checkbox: checkbox.clicked.connect(self.toggle_marta_flow_active) @@ -526,28 +644,40 @@ def configure_line_edit(field_name, placeholder): logger.debug(f"Found checkbox: {checkbox.objectName()}") # Update validators to match placeholder ranges - temp_lineedit_coldroom = self.marta_coldroom_tab.findChild(QtWidgets.QLineEdit, "coldroom_temp_LE") + temp_lineedit_coldroom = self.marta_coldroom_tab.findChild( + QtWidgets.QLineEdit, "coldroom_temp_LE" + ) if temp_lineedit_coldroom: - temp_lineedit_coldroom.setValidator(QtGui.QDoubleValidator(-30, 30, 2)) # Matches placeholder + temp_lineedit_coldroom.setValidator( + QtGui.QDoubleValidator(-30, 30, 2) + ) # Matches placeholder - humid_lineedit_coldroom = self.marta_coldroom_tab.findChild(QtWidgets.QLineEdit, "coldroom_humidity_LE") + humid_lineedit_coldroom = self.marta_coldroom_tab.findChild( + QtWidgets.QLineEdit, "coldroom_humidity_LE" + ) if humid_lineedit_coldroom: humid_lineedit_coldroom.setValidator(QtGui.QDoubleValidator(0, 50, 2)) # Add input validation for numeric fields - temp_lineedit = self.marta_coldroom_tab.findChild(QtWidgets.QLineEdit, "marta_temp_LE") + temp_lineedit = self.marta_coldroom_tab.findChild( + QtWidgets.QLineEdit, "marta_temp_LE" + ) if temp_lineedit: validator = QtGui.QDoubleValidator(-30, 18, 2) # min, max, decimals temp_lineedit.setValidator(validator) # Add input validation for numeric fields - speed_lineedit = self.marta_coldroom_tab.findChild(QtWidgets.QLineEdit, "marta_speed_LE") + speed_lineedit = self.marta_coldroom_tab.findChild( + QtWidgets.QLineEdit, "marta_speed_LE" + ) if speed_lineedit: validator = QtGui.QDoubleValidator(5000, 6000, 0) # min, max, decimals speed_lineedit.setValidator(validator) # Add input validation for numeric fields - flow_lineedit = self.marta_coldroom_tab.findChild(QtWidgets.QLineEdit, "marta_flow_LE") + flow_lineedit = self.marta_coldroom_tab.findChild( + QtWidgets.QLineEdit, "marta_flow_LE" + ) if flow_lineedit: validator = QtGui.QDoubleValidator(0, 5, 2) # min, max, decimals flow_lineedit.setValidator(validator) @@ -580,19 +710,38 @@ def soft_interlock_check(self): logger.info("Called soft Interlock") used_caen_channels = self.modules_list_tab.get_used_channels() logger.info(f"Used CAEN channels for safety checks: {used_caen_channels}") - alarm_publish = (lambda msg: self.system._martacoldroom._client.publish("/alarm", msg)) if self.system._martacoldroom else None + alarm_publish = ( + (lambda msg: self.system._martacoldroom._client.publish("/alarm", msg)) + if self.system._martacoldroom + else None + ) logger.info(f"Alarm publish function: {alarm_publish}") - is_safe, msg = soft_interlock_loop(self.system.status, self.caen_tab.last_response, used_caen_channels, self.caen_tab, publish_alarm=alarm_publish) + is_safe, msg = soft_interlock_loop( + self.system.status, + self.caen_tab.last_response, + used_caen_channels, + self.caen_tab, + publish_alarm=alarm_publish, + ) logger.info(f"Soft interlock result: is_safe={is_safe}, msg={msg}") - soft_interlock_led = self.marta_coldroom_tab.findChild(QtWidgets.QFrame, "soft_interlock_LED") + soft_interlock_led = self.marta_coldroom_tab.findChild( + QtWidgets.QFrame, "soft_interlock_LED" + ) if soft_interlock_led: status_color = "green" if is_safe else "red" soft_interlock_led.setStyleSheet("background-color: white;") - QTimer.singleShot(500, lambda led=soft_interlock_led, color=status_color: led.setStyleSheet(f"background-color: {color};")) + QTimer.singleShot( + 500, + lambda led=soft_interlock_led, color=status_color: led.setStyleSheet( + f"background-color: {color};" + ), + ) logger.info(f"Updated soft interlock LED: {status_color} with pulse") - soft_interlock_msg_label = self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "soft_interlock_msg") + soft_interlock_msg_label = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "soft_interlock_msg" + ) if soft_interlock_msg_label: soft_interlock_msg_label.setText(msg) logger.info(f"Updated soft interlock message: {msg}") @@ -621,43 +770,79 @@ def update_ui(self): logger.debug(f"Updating Cleanroom UI with status: {cleanroom}") # Temperature - label = central.findChild(QtWidgets.QLabel, "cleanroom_temp_value_label") - if label and "temperature" in cleanroom and cleanroom["temperature"] is not None: + label = central.findChild( + QtWidgets.QLabel, "cleanroom_temp_value_label" + ) + if ( + label + and "temperature" in cleanroom + and cleanroom["temperature"] is not None + ): temp_value = cleanroom["temperature"] label.setText(f"{temp_value:.1f}") logger.debug(f"Updated Cleanroom temperature: {temp_value}") # Humidity - label = central.findChild(QtWidgets.QLabel, "cleanroom_humidity_value_label") - if label and "humidity" in cleanroom and cleanroom["humidity"] is not None: + label = central.findChild( + QtWidgets.QLabel, "cleanroom_humidity_value_label" + ) + if ( + label + and "humidity" in cleanroom + and cleanroom["humidity"] is not None + ): humid_value = cleanroom["humidity"] label.setText(f"{humid_value:.1f}") logger.debug(f"Updated Cleanroom humidity: {humid_value}") # Dewpoint - label = central.findChild(QtWidgets.QLabel, "cleanroom_dewpoint_value_label") - if label and "dewpoint" in cleanroom and cleanroom["dewpoint"] is not None: + label = central.findChild( + QtWidgets.QLabel, "cleanroom_dewpoint_value_label" + ) + if ( + label + and "dewpoint" in cleanroom + and cleanroom["dewpoint"] is not None + ): dewpoint = cleanroom["dewpoint"] label.setText(f"{dewpoint:.1f}") logger.debug(f"Updated Cleanroom dewpoint: {dewpoint}") # Pressure - label = central.findChild(QtWidgets.QLabel, "cleanroom_pressure_value_label") - if label and "pressure" in cleanroom and cleanroom["pressure"] is not None: + label = central.findChild( + QtWidgets.QLabel, "cleanroom_pressure_value_label" + ) + if ( + label + and "pressure" in cleanroom + and cleanroom["pressure"] is not None + ): pressure = cleanroom["pressure"] label.setText(f"{pressure:.1f}") logger.debug(f"Updated Cleanroom pressure: {pressure}") - label = central.findChild(QtWidgets.QLabel, "cleanroom_last_update_value_label") - if label and "last_update" in cleanroom and cleanroom["last_update"] is not None: + label = central.findChild( + QtWidgets.QLabel, "cleanroom_last_update_value_label" + ) + if ( + label + and "last_update" in cleanroom + and cleanroom["last_update"] is not None + ): last_update = cleanroom["last_update"] label.setText(f"{last_update}") logger.debug(f"Updated Cleanroom last update: {last_update}") - label = central.findChild(QtWidgets.QLabel, "cleanroom_last_update_value_label_2") + label = central.findChild( + QtWidgets.QLabel, "cleanroom_last_update_value_label_2" + ) if label: - delta_t_update = self.system._martacoldroom._cleanroom_last_update_elapsed_time - delta_t_update = time.strftime("%H:%M:%S", time.gmtime(delta_t_update)) + delta_t_update = ( + self.system._martacoldroom._cleanroom_last_update_elapsed_time + ) + delta_t_update = time.strftime( + "%H:%M:%S", time.gmtime(delta_t_update) + ) label.setText(delta_t_update) logger.debug(f"Updated Cleanroom delta t update: {delta_t_update}") @@ -669,15 +854,21 @@ def update_ui(self): coldroom = self.system.status["coldroom"] logger.debug(f"Updating Coldroom UI with status: {coldroom}") - temp_control_active = coldroom.get("ch_temperature", {}).get("status", False) - hum_control_active = coldroom.get("ch_humidity", {}).get("status", False) + temp_control_active = coldroom.get("ch_temperature", {}).get( + "status", False + ) + hum_control_active = coldroom.get("ch_humidity", {}).get( + "status", False + ) # =========================================================================================== COLDROOM TEMPERATURE PROCESS =========================================================================================== # Temperature Current Value if "ch_temperature" in coldroom: # Current temperature - label = central.findChild(QtWidgets.QLabel, "coldroom_temp_value_label") + label = central.findChild( + QtWidgets.QLabel, "coldroom_temp_value_label" + ) if label: temp_value = coldroom["ch_temperature"].get("value", "?") label.setText(f"{temp_value:.1f}") @@ -687,7 +878,9 @@ def update_ui(self): if "ch_temperature" in coldroom: if "status" in coldroom["ch_temperature"]: # Update Temperature control LED - ctrl_temp_led = central.findChild(QtWidgets.QFrame, "ctrl_temp_LED") + ctrl_temp_led = central.findChild( + QtWidgets.QFrame, "ctrl_temp_LED" + ) if ctrl_temp_led: ctrl_temp_led.setStyleSheet( "background-color: green;" @@ -719,7 +912,9 @@ def update_ui(self): # Temperature setpoint label if "ch_temperature" in coldroom: if "setpoint" in coldroom["ch_temperature"]: - label = central.findChild(QtWidgets.QLabel, "coldroom_temp_set_point_label") + label = central.findChild( + QtWidgets.QLabel, "coldroom_temp_set_point_label" + ) if label: temp_setpoint = coldroom["ch_temperature"]["setpoint"] label.setText(f"{temp_setpoint:.1f}") @@ -730,7 +925,9 @@ def update_ui(self): # Humidity Current Value if "ch_humidity" in coldroom: # Current humidity - label = central.findChild(QtWidgets.QLabel, "coldroom_humidity_value_label") + label = central.findChild( + QtWidgets.QLabel, "coldroom_humidity_value_label" + ) if label: humid_value = coldroom["ch_humidity"].get("value", "?") label.setText(f"{humid_value:.1f}") @@ -740,7 +937,9 @@ def update_ui(self): if "ch_humidity" in coldroom: if "status" in coldroom["ch_humidity"]: # Update Humidity control LED - ctrl_humidity_led = central.findChild(QtWidgets.QFrame, "ctrl_humidity_LED") + ctrl_humidity_led = central.findChild( + QtWidgets.QFrame, "ctrl_humidity_LED" + ) if ctrl_humidity_led: ctrl_humidity_led.setStyleSheet( "background-color: green;" @@ -752,7 +951,9 @@ def update_ui(self): ) # Humidity setpoint lineedit - lineedit = central.findChild(QtWidgets.QLineEdit, "coldroom_humidity_LE") + lineedit = central.findChild( + QtWidgets.QLineEdit, "coldroom_humidity_LE" + ) if lineedit: user_has_entered_value = bool(lineedit.text().strip()) # Case 1: User is actively typing - preserve their input @@ -772,7 +973,9 @@ def update_ui(self): # Humidity setpoint label if "ch_humidity" in coldroom: if "setpoint" in coldroom["ch_humidity"]: - label = central.findChild(QtWidgets.QLabel, "coldroom_humidity_set_point_label") + label = central.findChild( + QtWidgets.QLabel, "coldroom_humidity_set_point_label" + ) if label: humid_setpoint = coldroom["ch_humidity"]["setpoint"] label.setText(f"{humid_setpoint:.1f}") @@ -780,7 +983,9 @@ def update_ui(self): # =========================================================================================== COLDROOM DEWPOINT PROCESS =========================================================================================== # Dewpoint (from coldroom data) - label = central.findChild(QtWidgets.QLabel, "coldroom_dewpoint_value_label") + label = central.findChild( + QtWidgets.QLabel, "coldroom_dewpoint_value_label" + ) if label and "dew_point_c" in coldroom: dewpoint = coldroom["dew_point_c"] label.setText(f"{dewpoint:.1f}") @@ -793,32 +998,50 @@ def update_ui(self): light_led = central.findChild(QtWidgets.QFrame, "light_LED") if light_led: light_led.setStyleSheet( - "background-color: yellow;" if coldroom["light"] else "background-color: black;" + "background-color: yellow;" + if coldroom["light"] + else "background-color: black;" ) # LED is yellow when ON - logger.debug(f"Updated light LED: {'yellow' if coldroom['light'] else 'black'}") + logger.debug( + f"Updated light LED: {'yellow' if coldroom['light'] else 'black'}" + ) if bool(coldroom["light"]) is False: logger.debug("Light off, check if it is safe") - used_caen_channels = self.modules_list_tab.get_used_channels() + used_caen_channels = ( + self.modules_list_tab.get_used_channels() + ) is_safe = check_light_safe_to_turn_on( - self.system.status, self.caen_tab.last_response, used_caen_channels + self.system.status, + self.caen_tab.last_response, + used_caen_channels, + ) + button = central.findChild( + QtWidgets.QPushButton, "coldroom_light_on_PB" ) - button = central.findChild(QtWidgets.QPushButton, "coldroom_light_on_PB") if not is_safe: logger.debug("not safe") if button: button.setEnabled(False) - logger.debug("Disabled light button due to safety check") + logger.debug( + "Disabled light button due to safety check" + ) else: if button: button.setEnabled(True) - logger.debug("Enabled light button after safety check") + logger.debug( + "Enabled light button after safety check" + ) # Dry air bypass - dryair_bypass_led = central.findChild(QtWidgets.QFrame, "dryair_bypass_LED") + dryair_bypass_led = central.findChild( + QtWidgets.QFrame, "dryair_bypass_LED" + ) coldroom_air = self.system.status.get("coldroomair", {}) if dryair_bypass_led: if "air_bypass_status" in coldroom_air: dryair_bypass_led.setStyleSheet( - "background-color: green;" if coldroom_air["air_bypass_status"] else "background-color: red;" + "background-color: green;" + if coldroom_air["air_bypass_status"] + else "background-color: red;" ) logger.debug( f"Updated dry air bypass LED: {'green' if coldroom_air['air_bypass_status'] else 'red'}" @@ -831,18 +1054,24 @@ def update_ui(self): # Door status and LED if "CmdDoorUnlock_Reff" in coldroom: door_status = "OPEN" if coldroom["CmdDoorUnlock_Reff"] else "CLOSED" - label = central.findChild(QtWidgets.QLabel, "coldroom_door_state_label") + label = central.findChild( + QtWidgets.QLabel, "coldroom_door_state_label" + ) if label: label.setText(door_status) logger.debug(f"Updated door status: {door_status}") # =========================================================================================== COLDROOM RUN PROCESS =========================================================================================== # Update safe to open LED based on door safety check - safe_to_open_led = central.findChild(QtWidgets.QFrame, "safe_to_open_LED") + safe_to_open_led = central.findChild( + QtWidgets.QFrame, "safe_to_open_LED" + ) if safe_to_open_led: used_caen_channels = self.modules_list_tab.get_used_channels() is_safe, door_msg = check_door_safe_to_open( - self.system.status, self.caen_tab.last_response, used_caen_channels + self.system.status, + self.caen_tab.last_response, + used_caen_channels, ) # Check co2 values if "co2_sensor" in self.system.status: @@ -856,15 +1085,25 @@ def update_ui(self): else: # door_msg += "CO2 levels safe: True" door_msg += "CO2 levels safe: Yes" - safe_to_open_led.setStyleSheet("background-color: green;" if is_safe else "background-color: red;") - logger.debug(f"Updated safe to open LED: {'green' if is_safe else 'red'} (is_safe={is_safe})") + safe_to_open_led.setStyleSheet( + "background-color: green;" + if is_safe + else "background-color: red;" + ) + logger.debug( + f"Updated safe to open LED: {'green' if is_safe else 'red'} (is_safe={is_safe})" + ) self.system._martacoldroom.publish_door_safety_status(is_safe) - self.marta_coldroom_tab.findChild(QtWidgets.QLabel, "door_safety_msg").setText(door_msg) + self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "door_safety_msg" + ).setText(door_msg) # =========================================================================================== COLDROOM RUN PROCESS =========================================================================================== # Run status if "running" in coldroom: - label = central.findChild(QtWidgets.QLabel, "coldroom_run_state_label") + label = central.findChild( + QtWidgets.QLabel, "coldroom_run_state_label" + ) if label: run_text = "Running" if coldroom["running"] else "Stopped" label.setText(run_text) @@ -877,9 +1116,13 @@ def update_ui(self): dry_air_led = central.findChild(QtWidgets.QFrame, "dryair_LED") if dry_air_led: dry_air_led.setStyleSheet( - "background-color: green;" if coldroom["dry_air_status"] else "background-color: red;" + "background-color: green;" + if coldroom["dry_air_status"] + else "background-color: red;" + ) + logger.debug( + f"Updated dry air LED: {'green' if coldroom['dry_air_status'] else 'red'}" ) - logger.debug(f"Updated dry air LED: {'green' if coldroom['dry_air_status'] else 'red'}") # Update CO2 sensor data if "co2_sensor" in self.system.status: @@ -956,7 +1199,9 @@ def update_ui(self): # =========================================================================================== MARTA =========================================================================================== # >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - self.modules_list_tab.marta_safe, self.modules_list_tab.marta_log_msg = check_marta_safe(self.system.status) + self.modules_list_tab.marta_safe, self.modules_list_tab.marta_log_msg = ( + check_marta_safe(self.system.status) + ) # logger.info("Called soft Interlock") # used_caen_channels = self.modules_list_tab.get_used_channels() @@ -974,38 +1219,50 @@ def update_ui(self): # =========================================================================================== MARTA FSM STATE PROCESS =========================================================================================== # Update FSM state if "fsm_state" in marta: - state_label = central.findChild(QtWidgets.QLabel, "marta_state_label") + state_label = central.findChild( + QtWidgets.QLabel, "marta_state_label" + ) if state_label: state_label.setText(marta["fsm_state"]) logger.debug(f"Updated MARTA state: {marta['fsm_state']}") # Update state-specific UI elements if marta["fsm_state"] == "ALARM": # Highlight alarm state - alarm_frame = central.findChild(QtWidgets.QFrame, "marta_alarm_frame") + alarm_frame = central.findChild( + QtWidgets.QFrame, "marta_alarm_frame" + ) if alarm_frame: alarm_frame.setStyleSheet("background-color: red;") # Show alarm message - alarm_msg = central.findChild(QtWidgets.QLabel, "marta_alarm_msg_label") + alarm_msg = central.findChild( + QtWidgets.QLabel, "marta_alarm_msg_label" + ) if alarm_msg and "alarm_message" in marta: alarm_msg.setText(marta["alarm_message"]) else: # Clear alarm highlighting - alarm_frame = central.findChild(QtWidgets.QFrame, "marta_alarm_frame") + alarm_frame = central.findChild( + QtWidgets.QFrame, "marta_alarm_frame" + ) if alarm_frame: alarm_frame.setStyleSheet("") # =========================================================================================== MARTA TEMPERATURE PROCESS =========================================================================================== # Temperature from TT05_CO2 (Supply) if "TT05_CO2" in marta: - label = central.findChild(QtWidgets.QLabel, "marta_temp_supply_value_label") + label = central.findChild( + QtWidgets.QLabel, "marta_temp_supply_value_label" + ) if label: temp_value = marta["TT05_CO2"] label.setText(f"{temp_value:.1f}") logger.debug(f"Updated MARTA supply temperature: {temp_value}") # Temperature from TT06_CO2 (Return) if "TT06_CO2" in marta: - label = central.findChild(QtWidgets.QLabel, "marta_temp_return_value_label") + label = central.findChild( + QtWidgets.QLabel, "marta_temp_return_value_label" + ) if label: temp_value = marta["TT06_CO2"] label.setText(f"{temp_value:.1f}") @@ -1023,7 +1280,9 @@ def update_ui(self): marta_temp_lE_active_flag = True temp_setpoint = lineedit.text() lineedit.setText(str(temp_setpoint)) - logger.debug(f"Updated MARTA temperature setpoint: {temp_setpoint}") + logger.debug( + f"Updated MARTA temperature setpoint: {temp_setpoint}" + ) else: marta_temp_lE_active_flag = False lineedit.clear() @@ -1031,16 +1290,22 @@ def update_ui(self): # Temperature setpoint label if "temperature_setpoint" in marta: - label = central.findChild(QtWidgets.QLabel, "marta_temp_set_point_label") + label = central.findChild( + QtWidgets.QLabel, "marta_temp_set_point_label" + ) if label: temp_setpoint = marta["temperature_setpoint"] label.setText(f"{temp_setpoint:.1f}") - logger.debug(f"Updated MARTA temperature setpoint: {temp_setpoint}") + logger.debug( + f"Updated MARTA temperature setpoint: {temp_setpoint}" + ) # =========================================================================================== MARTA PRESSURE PROCESS =========================================================================================== # Pressure from PT05_CO2 (Supply) if "PT05_CO2" in marta: - label = central.findChild(QtWidgets.QLabel, "marta_pressure_supply_value_label") + label = central.findChild( + QtWidgets.QLabel, "marta_pressure_supply_value_label" + ) if label: pressure_value = marta["PT05_CO2"] label.setText(f"{pressure_value:.3f}") @@ -1048,7 +1313,9 @@ def update_ui(self): # Pressure from PT06_CO2 (Return) if "PT06_CO2" in marta: - label = central.findChild(QtWidgets.QLabel, "marta_pressure_return_value_label") + label = central.findChild( + QtWidgets.QLabel, "marta_pressure_return_value_label" + ) if label: pressure_value = marta["PT06_CO2"] label.setText(f"{pressure_value:.3f}") @@ -1057,7 +1324,9 @@ def update_ui(self): # =========================================================================================== MARTA SPEED PROCESS =========================================================================================== # Speed if "LP_speed" in marta: - label = central.findChild(QtWidgets.QLabel, "marta_speed_value_label") + label = central.findChild( + QtWidgets.QLabel, "marta_speed_value_label" + ) if label: speed_value = marta["LP_speed"] label.setText(f"{speed_value:.1f}") @@ -1084,7 +1353,9 @@ def update_ui(self): # Speed setpoint label if "speed_setpoint" in marta: - label = central.findChild(QtWidgets.QLabel, "marta_speed_set_point_label") + label = central.findChild( + QtWidgets.QLabel, "marta_speed_set_point_label" + ) if label: speed_setpoint = marta["speed_setpoint"] label.setText(f"{speed_setpoint:.1f}") @@ -1095,12 +1366,18 @@ def update_ui(self): # Flow control LED if "set_flow_active" in marta: # Update Flow control LED - ctrl_flow_led = central.findChild(QtWidgets.QFrame, "marta_flow_flag_LED") + ctrl_flow_led = central.findChild( + QtWidgets.QFrame, "marta_flow_flag_LED" + ) if ctrl_flow_led: ctrl_flow_led.setStyleSheet( - "background-color: green;" if marta["set_flow_active"] else "background-color: black;" + "background-color: green;" + if marta["set_flow_active"] + else "background-color: black;" ) # LED is green when ON - logger.debug(f"Updated flow control LED: {'green' if marta['set_flow_active'] else 'black'}") + logger.debug( + f"Updated flow control LED: {'green' if marta['set_flow_active'] else 'black'}" + ) # Flow setpoint lineedit if "set_flow_setpoint" in marta: @@ -1121,7 +1398,9 @@ def update_ui(self): # Flow setpoint (affects speed) if "flow_setpoint" in marta: - label = central.findChild(QtWidgets.QLabel, "marta_flow_set_point_label") + label = central.findChild( + QtWidgets.QLabel, "marta_flow_set_point_label" + ) if label: flow_setpoint = marta["flow_setpoint"] label.setText(f"{flow_setpoint:.1f}") @@ -1143,7 +1422,9 @@ def set_coldroom_temperature(self): central = self.marta_coldroom_tab # Check if temperature control is enabled coldroom = self.system.status.get("coldroom", {}) - if "ch_temperature" not in coldroom or not coldroom.get("ch_temperature", {}).get("status", False): + if "ch_temperature" not in coldroom or not coldroom.get( + "ch_temperature", {} + ).get("status", False): msg = "Temperature control is not enabled. Please enable temperature control first." self.statusBar().showMessage(msg) logger.warning(msg) @@ -1183,8 +1464,12 @@ def set_coldroom_humidity(self): central = self.marta_coldroom_tab # Check if humidity control is enabled coldroom = self.system.status.get("coldroom", {}) - if "ch_humidity" not in coldroom or not coldroom.get("ch_humidity", {}).get("status", False): - msg = "Humidity control is not enabled. Please enable humidity control first." + if "ch_humidity" not in coldroom or not coldroom.get("ch_humidity", {}).get( + "status", False + ): + msg = ( + "Humidity control is not enabled. Please enable humidity control first." + ) self.statusBar().showMessage(msg) logger.warning(msg) return @@ -1321,7 +1606,9 @@ def toggle_coldroom_door(self): # This would normally require safety checks if self.system._martacoldroom: # For demo purposes, we're just toggling the state - self.system._martacoldroom.publish_cmd("door", self.system._martacoldroom._coldroom_client, str(new_state)) + self.system._martacoldroom.publish_cmd( + "door", self.system._martacoldroom._coldroom_client, str(new_state) + ) msg = f"Set door to {'OPEN' if new_state else 'CLOSED'}" self.statusBar().showMessage(msg) logger.info(msg) @@ -1376,8 +1663,12 @@ def update_marta_button_states(self, state): central = self.marta_coldroom_tab # Get all MARTA control buttons - start_chiller_btn = central.findChild(QtWidgets.QPushButton, "marta_chiller_start_PB") - stop_chiller_btn = central.findChild(QtWidgets.QPushButton, "marta_stop_chiller_PB") + start_chiller_btn = central.findChild( + QtWidgets.QPushButton, "marta_chiller_start_PB" + ) + stop_chiller_btn = central.findChild( + QtWidgets.QPushButton, "marta_stop_chiller_PB" + ) start_co2_btn = central.findChild(QtWidgets.QPushButton, "marta_co2_start_PB") stop_co2_btn = central.findChild(QtWidgets.QPushButton, "marta_co2_stop_PB") @@ -1646,13 +1937,25 @@ def toggle_marta_flow_active(self): def save_settings(self): try: # Update settings object - self.system.settings["mqtt"]["broker"] = self.settings_tab.brokerLineEdit.text() + self.system.settings["mqtt"][ + "broker" + ] = self.settings_tab.brokerLineEdit.text() self.system.settings["mqtt"]["port"] = self.settings_tab.portSpinBox.value() - self.system.settings["MARTA"]["mqtt_topic"] = self.settings_tab.martaTopicLineEdit.text() - self.system.settings["Coldroom"]["mqtt_topic"] = self.settings_tab.coldroomTopicLineEdit.text() - self.system.settings["Coldroom"]["co2_sensor_topic"] = self.settings_tab.co2SensorTopicLineEdit.text() - self.system.settings["ThermalCamera"]["mqtt_topic"] = self.settings_tab.thermalCameraTopicLineEdit.text() - self.system.settings["Cleanroom"]["mqtt_topic"] = self.settings_tab.cleanroomTopicLineEdit.text() + self.system.settings["MARTA"][ + "mqtt_topic" + ] = self.settings_tab.martaTopicLineEdit.text() + self.system.settings["Coldroom"][ + "mqtt_topic" + ] = self.settings_tab.coldroomTopicLineEdit.text() + self.system.settings["Coldroom"][ + "co2_sensor_topic" + ] = self.settings_tab.co2SensorTopicLineEdit.text() + self.system.settings["ThermalCamera"][ + "mqtt_topic" + ] = self.settings_tab.thermalCameraTopicLineEdit.text() + self.system.settings["Cleanroom"][ + "mqtt_topic" + ] = self.settings_tab.cleanroomTopicLineEdit.text() # Write to file with open("settings.yaml", "w") as f: @@ -1675,12 +1978,17 @@ def save_settings(self): if __name__ == "__main__": args = argparse.ArgumentParser(description="Cold Room Control Application") args.add_argument( - "--loglevel", "-log", default="WARNING", help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)" + "--loglevel", + "-log", + default="WARNING", + help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", ) parsed_args = args.parse_args() logger = logging.getLogger("integration") log_level = getattr(logging, parsed_args.loglevel.upper(), logging.WARNING) - logging.basicConfig(level=log_level, format="%(asctime)s - %(levelname)s - %(message)s") + logging.basicConfig( + level=log_level, format="%(asctime)s - %(levelname)s - %(message)s" + ) app = QtWidgets.QApplication(sys.argv) window = MainApp() diff --git a/coldroom/safety.py b/coldroom/safety.py index bd1e3f6..a97bcf5 100644 --- a/coldroom/safety.py +++ b/coldroom/safety.py @@ -26,7 +26,9 @@ def check_dew_point(system_status): # Get the three required temperature values marta_supply_temp = system_status.get("marta", {}).get("TT05_CO2") marta_return_temp = system_status.get("marta", {}).get("TT06_CO2") - coldroom_temp = system_status.get("coldroom", {}).get("ch_temperature", {}).get("value") + coldroom_temp = ( + system_status.get("coldroom", {}).get("ch_temperature", {}).get("value") + ) # coldroom = system_status.get("coldroom", {}) # logger.debug(f"Coldroom: {coldroom.get('CmdDoorUnlock_Reff')}") # door_status = system_status.get("coldroom", {}).get("CmdDoorUnlock_Reff") @@ -53,12 +55,19 @@ def check_dew_point(system_status): if "coldroom" not in system_status: logger.debug("Coldroom data not available") - return False # Conservative approach - if we can't check, assume it's unsafe + return ( + False # Conservative approach - if we can't check, assume it's unsafe + ) # Check if environment data exists - if "cleanroom" not in system_status or "dewpoint" not in system_status["cleanroom"]: + if ( + "cleanroom" not in system_status + or "dewpoint" not in system_status["cleanroom"] + ): return False # Conservative approach - reference_dew_point = system_status["cleanroom"]["dewpoint"] # External dewpoint + reference_dew_point = system_status["cleanroom"][ + "dewpoint" + ] # External dewpoint logger.debug(f"Reference dew point: {reference_dew_point}") delta = 1 # Allowable delta between dew point and temperature return min_temperature > reference_dew_point + delta @@ -69,7 +78,10 @@ def check_dew_point(system_status): def check_door_status(system_status): try: - if "coldroom" not in system_status or "door_status" not in system_status["coldroom"]: + if ( + "coldroom" not in system_status + or "door_status" not in system_status["coldroom"] + ): return False # Conservative approach return system_status["coldroom"]["CmdDoorUnlock_Reff"] == 1 # Door is open except Exception as e: @@ -99,7 +111,10 @@ def check_any_hv_on(caen_ch_status, used_channels): return hv_on except Exception as e: logger.debug(f"Error in check_any_hv_on: {str(e)}") - return True, "Error checking high voltage status" # Conservative approach - if we can't check, assume it's unsafe + return ( + True, + "Error checking high voltage status", + ) # Conservative approach - if we can't check, assume it's unsafe def check_cleanroom_expired(elapsed_time, threshold=600): @@ -115,10 +130,14 @@ def check_door_safe_to_open(system_status, caen_ch_status, used_channels): try: # Check if we have all necessary data if "coldroom" not in system_status: - return False # Conservative approach - if we can't check, assume it's unsafe + return ( + False # Conservative approach - if we can't check, assume it's unsafe + ) # 1. Check if dew point conditions are safe - clean_room_expired = check_cleanroom_expired(system_status["cleanroom"]["elapsed_time"]) + clean_room_expired = check_cleanroom_expired( + system_status["cleanroom"]["elapsed_time"] + ) if clean_room_expired: dew_point_safe = False log_msg += f"!!! Warning: Cleanroom data expired, not able to check dew point !!!\n" @@ -169,7 +188,9 @@ def check_light_safe_to_turn_on(system_status, caen_ch_status, used_channels): try: # Check if we have all necessary data if "coldroom" not in system_status: - return False # Conservative approach - if we can't check, assume it's unsafe + return ( + False # Conservative approach - if we can't check, assume it's unsafe + ) # Check if high voltage is off hv_on = check_any_hv_on(caen_ch_status, used_channels) @@ -200,14 +221,15 @@ def check_marta_safe(system_status): # def check_marta_on_for_ot # def check_marta_on_for_it -#def switch_all_lv_off +# def switch_all_lv_off # def soft_interlock_loop: ### describe only highlevel # if condition then : action # if ! check_lv_safe_on and something_on : switch_all_lv_off() -#when performing an active safety action here we send a msg to the alarm topic "/alarm" +# when performing an active safety action here we send a msg to the alarm topic "/alarm" + def Is_any_lv_on(caen_ch_status, used_channels): """ @@ -227,6 +249,7 @@ def Is_any_lv_on(caen_ch_status, used_channels): logger.debug(f"Error in Is_any_lv_on: {str(e)}") return True # Conservative - assume LV is on if we can't check + def Is_it_safe_to_on_lv(system_status, caen_ch_status, used_channels): """ Check if it's safe to turn on LV channels based on multiple safety conditions. @@ -236,7 +259,9 @@ def Is_it_safe_to_on_lv(system_status, caen_ch_status, used_channels): try: # Check if we have all necessary data if "coldroom" not in system_status: - return False # Conservative approach - if we can't check, assume it's unsafe + return ( + False # Conservative approach - if we can't check, assume it's unsafe + ) # Check if MARTA is running marta_safe, marta_msg = check_marta_safe(system_status) @@ -277,17 +302,23 @@ def check_marta_on_for_OT(system_status): try: if "marta" not in system_status or "serviceroom" not in system_status: return False - - logger.info(f"Checking MARTA OT status: {system_status['marta'].get('fsm_state', 'N/A')}, OT valve: {system_status['serviceroom'].get('outer_valve', 'N/A')}") + + logger.info( + f"Checking MARTA OT status: {system_status['marta'].get('fsm_state', 'N/A')}, OT valve: {system_status['serviceroom'].get('outer_valve', 'N/A')}" + ) fsm_state = system_status["marta"].get("fsm_state", "") OT_valve = system_status["serviceroom"].get("outer_valve", 0) - + if fsm_state not in ("DISCONNECTED", "NONE", ""): if OT_valve == 1: # Outer valve is open, so OT is likely running - logger.info("MARTA FSM state is good and OT valve is open, treating as running") + logger.info( + "MARTA FSM state is good and OT valve is open, treating as running" + ) return True else: - logger.debug("MARTA FSM state is good but OT valve is closed, treating as not running") + logger.debug( + "MARTA FSM state is good but OT valve is closed, treating as not running" + ) return False else: return False @@ -304,15 +335,21 @@ def check_marta_on_for_IT(system_status): try: if "marta" not in system_status or "serviceroom" not in system_status: return False - logger.info(f"Checking MARTA IT status: {system_status['marta'].get('fsm_state', 'N/A')}, IT valve: {system_status['serviceroom'].get('inner_valve', 'N/A')}") + logger.info( + f"Checking MARTA IT status: {system_status['marta'].get('fsm_state', 'N/A')}, IT valve: {system_status['serviceroom'].get('inner_valve', 'N/A')}" + ) fsm_state = system_status["marta"].get("fsm_state", "") IT_valve = system_status["serviceroom"].get("inner_valve", 0) if fsm_state not in ("DISCONNECTED", "NONE", ""): if IT_valve == 1: # Inner valve is open, so IT is likely running - logger.info("MARTA FSM state is good and IT valve is open, treating as running") + logger.info( + "MARTA FSM state is good and IT valve is open, treating as running" + ) return True else: - logger.debug("MARTA FSM state is good but IT valve is closed, treating as not running") + logger.debug( + "MARTA FSM state is good but IT valve is closed, treating as not running" + ) return False else: return False @@ -339,7 +376,9 @@ def switch_all_lv_off(caen, used_channels): return False -def soft_interlock_loop(system_status, caen_ch_status, used_channels, caen, publish_alarm=None): +def soft_interlock_loop( + system_status, caen_ch_status, used_channels, caen, publish_alarm=None +): """ Soft interlock loop - monitors safety conditions and takes protective action. @@ -364,16 +403,17 @@ def soft_interlock_loop(system_status, caen_ch_status, used_channels, caen, publ """ try: lv_on = Is_any_lv_on(caen_ch_status, used_channels) - lv_safe_to_on = Is_it_safe_to_on_lv(system_status, caen_ch_status, used_channels) + lv_safe_to_on = Is_it_safe_to_on_lv( + system_status, caen_ch_status, used_channels + ) marta_ot = check_marta_on_for_OT(system_status) marta_it = check_marta_on_for_IT(system_status) - marta_on = marta_ot or marta_it - + if lv_on: lv_status = "ON" else: lv_status = "OFF" - + if marta_ot: marta_ot_status = "RUNNING" else: @@ -383,20 +423,22 @@ def soft_interlock_loop(system_status, caen_ch_status, used_channels, caen, publ marta_it_status = "RUNNING" else: marta_it_status = "NOT RUNNING" - + log_msg = f"\nSoft interlock: LV_on={lv_status}, MARTA_OT={marta_ot_status}, MARTA_IT={marta_it_status}\n" logger.info(log_msg) if lv_safe_to_on == False: - log_msg += "\n!!! Warning: Conditions are not safe to turn on LV channels !!!\n" + log_msg += ( + "\n!!! Warning: Conditions are not safe to turn on LV channels !!!\n" + ) log_msg += f"\nLV safe to on: NO\n" # switch_all_lv_off(caen, used_channels) else: log_msg += f"\nLV safe to on: YES\n" - if lv_on and not marta_on: + if lv_on and not marta_ot: alarm_msg = ( - "SAFETY INTERLOCK: LV channels are on but MARTA is not running. " + "SAFETY INTERLOCK: LV channels are on but MARTA OT is not running. " "Turning off all LV channels to prevent module damage." ) logger.warning(alarm_msg) @@ -413,4 +455,4 @@ def soft_interlock_loop(system_status, caen_ch_status, used_channels, caen, publ except Exception as e: err_msg = f"Error in soft_interlock_loop: {str(e)}" logger.error(err_msg) - return False, err_msg \ No newline at end of file + return False, err_msg From dee915d005b6f8a1cf0deaca9bb6fcd5360b401a Mon Sep 17 00:00:00 2001 From: Sayan Dhani Date: Mon, 13 Jul 2026 19:20:49 +0200 Subject: [PATCH 4/5] Fix coldroom safety bugs, add safety-verdict UI, modernize coldroom GUI safety.py: - BUG-10/-11/-20: return bare False (not truthy tuples) on error in check_dew_point/door/light and check_any_hv_on; guard the key actually read in check_door_status; drop stray tkinter import - BUG-12: derive cleanroom age from last_update timestamp instead of the nonexistent elapsed_time key - Add interpret_door_safety / interpret_soft_interlock helpers cold.py: - Show human-readable verdict beside each safety LED (caption + message) marta_coldroom.ui: - Modern flat stylesheet on centralwidget: rounded card group boxes, outline buttons, rounded inputs, circular status LEDs; toned-down safety-panel fonts and fixed-size status circles Bundles remaining working-tree changes and project docs. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 83 ++ Inner_tracker_GUI/caenGUIall_v2.py | 72 +- Onboarding_integration_tools.md | 109 ++ caen/caenGUI.py | 152 +-- caen/caenGUIall.py | 105 +- caen/caencli.py | 50 +- cold.py | 53 +- coldroom/camera_config.yaml | 12 + coldroom/command_worker.py | 23 +- coldroom/marta_coldroom.py | 64 +- coldroom/marta_coldroom.ui | 117 +- coldroom/module_temperatures_gui.py | 431 +++++-- coldroom/modules_list_gui.py | 144 ++- coldroom/safety.py | 224 ++-- coldroom/system.py | 58 +- coldroom/thermal_camera.py | 17 +- coldroom/thermal_camera_gui.py | 161 ++- db/module_db.py | 326 +++--- db/module_db_gui.py | 34 +- db/utils.py | 98 +- dbbrowser.py | 11 +- dbrunsearch.py | 101 +- drawEvent.py | 316 +++--- edit_ompo_report.py | 130 +-- glue_analyzer.py | 417 ++++--- integration.py | 1617 +++++++++++++++------------ issues.md | 404 +++++++ modulesearch.py | 22 +- plot_tab/influx_3.py | 111 +- plot_tab/sensor_monitor.py | 261 +++-- power_supply/power_supply_ctrl.py | 104 +- power_supply/rigolDP116A.py | 50 +- scripts/mount_modules.py | 97 +- scripts/rigolDP116A.py | 50 +- session-ses_19b8.md | 313 ++++++ settings_coldroom.yaml | 3 +- settings_integration.yaml | 6 +- tests/simple_test.py | 78 +- tests/test_data_generator.py | 96 +- tests/test_integration.py | 229 ++-- tests/test_module_db.py | 238 ++-- ui/integration_gui.py | 207 +++- workflow.md | 442 ++++++++ 43 files changed, 5207 insertions(+), 2429 deletions(-) create mode 100644 CLAUDE.md create mode 100644 Onboarding_integration_tools.md create mode 100644 coldroom/camera_config.yaml create mode 100644 issues.md create mode 100644 session-ses_19b8.md create mode 100644 workflow.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a5438b0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this project is + +A PyQt5 instrument control suite for CMS detector module testing at Pisa (CERN). Operators select a module, connect cables, and run automated electrical tests while the system monitors cooling safety in real time. + +## Running the applications + +```bash +# Main module testing GUI +python integration.py + +# Coldroom / MARTA cooling monitor GUI +python cold.py + +# Run all tests +python -m pytest tests/ +# or +python -m unittest discover tests/ + +# Run a single test file +python -m unittest tests.test_module_db +python -m unittest tests.test_integration +``` + +Tests use `unittest` with mocked GUI components — hardware is not required to run them. + +## Architecture + +Two independent PyQt5 applications share some library code: + +### `integration.py` (main testing app, ~2272 lines) +- Subclasses `ui/integration_gui.py` (auto-generated from Qt Designer — **never edit directly**) +- Creates `CAENControl`, `ModuleDB`, and a raw `paho.mqtt.client` at startup +- One giant `on_mqtt_message()` handles ALL incoming MQTT topics +- Tests run via `CommandWorker(QThread)` wrapping `subprocess.Popen` — **not** `subprocess.run` +- GUI updates from MQTT thread must go through `pyqtSignal` — direct widget writes from non-main threads crash randomly + +### `cold.py` (coldroom monitor, ~2000 lines) +- Subclasses `coldroom/marta_coldroom.ui` +- Owns a `System` object (`coldroom/system.py`) which holds the single shared `_status` dict +- `MartaColdRoomMQTTClient` writes to `_status`; `coldroom/safety.py` reads it +- A `QTimer` fires `soft_interlock_check()` every **5 seconds** → calls `soft_interlock_loop()` → cuts LV if cooling is lost +- `self.system._martacoldroom` can be `None` if the MQTT broker is unreachable at startup — always guard before calling methods on it + +### `coldroom/system.py` +Initializes `_status` with keys: `marta`, `coldroom`, `thermal_camera`, `caen`, `cleanroom`, `coldroomair`. The key `serviceroom` is **never populated** — code that requires it will silently fail. + +### `coldroom/safety.py` +All safety logic is here. Key functions: +- `soft_interlock_loop(system_status, caen_ch_status, used_channels, caen, publish_alarm)` — main 5-second safety loop; returns `(bool, str)` +- `Is_it_safe_to_on_lv(...)` — returns a **tuple** `(bool, str)`; callers must unpack, not compare directly +- `check_marta_on_for_OT/IT(system_status)` — uses `marta.fsm_state` as primary signal; `serviceroom` valve data is optional +- `switch_all_lv_off(caen, used_channels)` — the actual power cutoff action + +### Communication layers +| Protocol | Target | Used by | +|----------|--------|---------| +| HTTP REST | `pccmslab1:5000` (MongoDB) | `db/module_db.py` | +| TCP binary | `192.168.0.45:7000` (CAEN HV/LV) | `caen/caenGUI.py` | +| VISA/TCP | `192.168.0.16` (Rigol DP116A) | `power_supply/` | +| MQTT | `pccmslab1:1883` | everything else | +| subprocess | Ph2_ACF test framework | `CommandWorker` | + +MQTT topics are defined in `settings_coldroom.yaml` and `settings_integration.yaml`. + +## Key invariants + +- **MARTA FSM state** is the primary CO2 cooling indicator. States `"DISCONNECTED"`, `"NONE"`, `""` mean cooling is off. This is what `check_marta_on_for_OT/IT` reads — not valve data. +- `used_channels` dict format: `{"LV": [...channel_ids...], "HV": [...channel_ids...]}` — sourced from `modules_list_tab.get_used_channels()`, which currently only returns OT channels. +- `caen.on(channel)` / `caen.off(channel)` — the CAEN API for LV/HV control. +- Qt Designer `.ui` files are compiled to `*_gui.py` — the `ui/` and `coldroom/*.ui` files are the source of truth for layouts, not the generated Python. + +## Known open bugs (issues.md) + +Critical bugs not yet fixed: +- **BUG-01** `integration.py:792` — thermal safety interlock uses `self.caenGUI` (doesn't exist); should be `self.caen` +- **BUG-02** `integration.py:418` — `new_session()` crashes if API call fails; must check `success` before indexing `result` +- **BUG-03** `coldroom/system.py:213` — `cleanup()` calls `loop_start()` instead of `loop_stop()` on the thermal camera client +- **BUG-04** `coldroom/command_worker.py` — `terminate_process()` calls `.poll()` on `CompletedProcess`; switch to `Popen` +- **BUG-05** `power_supply/power_supply_ctrl.py:204` — `QMainWindow` imported inside `__main__` guard, causing `NameError` when called as a library \ No newline at end of file diff --git a/Inner_tracker_GUI/caenGUIall_v2.py b/Inner_tracker_GUI/caenGUIall_v2.py index 1092dce..25dcbff 100755 --- a/Inner_tracker_GUI/caenGUIall_v2.py +++ b/Inner_tracker_GUI/caenGUIall_v2.py @@ -29,33 +29,29 @@ def __init__(self, ip, port): self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.settimeout(0.5) self.headerBytes = 4 - + self.connectSocket() - pass + pass def __del__(self): - """Destructor, closes socket - """ + """Destructor, closes socket""" try: self.closeSocket() except Exception: pass def connectSocket(self): - """Connects socket - """ + """Connects socket""" self.socket.connect((self.ip, self.port)) pass def closeSocket(self): - """Closes socket connection - """ + """Closes socket connection""" self.socket.close() pass def sendMessage(self, message): - """Encodes message and sends it on socket - """ + """Encodes message and sends it on socket""" encodedMessage = self.encodeMessage(message) self.socket.send(encodedMessage) pass @@ -82,7 +78,7 @@ class CAENQueryThread(QThread): """ dataReady = pyqtSignal(dict) # Emitted when parsed data is ready - error = pyqtSignal(str) # Emitted when an error occurs + error = pyqtSignal(str) # Emitted when an error occurs def __init__(self, ip="192.168.0.45", port=7000): super().__init__() @@ -91,7 +87,7 @@ def __init__(self, ip="192.168.0.45", port=7000): self.message = None self.receive = False self.running = True - self.queue = [] # list of messages to send + self.queue = [] # list of messages to send self.receiveQueue = [] # list of booleans: whether to read a reply def setup_query(self, message, receive=False): @@ -103,11 +99,11 @@ def stop(self): """Stop the thread""" self.running = False self.wait() - + def run(self): """Thread's main method : Process the queued messages.""" while self.queue: - #create lock + # create lock self.message = self.queue.pop(0) self.receive = self.receiveQueue.pop(0) try: @@ -155,7 +151,7 @@ class caenGUI8LV(QWidget): def __init__(self, ip="192.168.0.45", port=7000): super().__init__() - + # Create timer for periodic update self.channels = [] self.led = {} @@ -172,11 +168,11 @@ def __init__(self, ip="192.168.0.45", port=7000): # Timer to periodically request status self.timer = QTimer(self) self.timer.timeout.connect(self.update_status) - + # build the UI self.initUI() - - #start periodic status update + + # start periodic status update self.timer.start(2000) # ms def change_host(self, ip, port): @@ -199,14 +195,14 @@ def initUI(self): self.main_layout = QVBoxLayout() self.setLayout(self.main_layout) - + # Safety checkbox to enable/disable current setting self.enable_current_checkbox = QCheckBox("Enable current setting (safety)") self.enable_current_checkbox.stateChanged.connect(self.toggle_current_setting) self.main_layout.addWidget(self.enable_current_checkbox) # Define 8 LV channels: LV15.1 ... LV15.8 - for i in range(1,9): + for i in range(1, 9): self.channels.append(f"LV15.{i}") for channel in self.channels: @@ -217,7 +213,7 @@ def initUI(self): ch_label = QLabel(channel + ":") ch_label.setFont(QFont("Arial", 10)) hlayout.addWidget(ch_label) - + # Current set text box current_input = QLineEdit() current_input.setFixedWidth(60) @@ -228,27 +224,21 @@ def initUI(self): # 'Set I' button btn_setI = QPushButton("Set I", self) btn_setI.setMinimumWidth(50) - btn_setI.clicked.connect( - lambda checked, ch=channel: self.set_current(ch) - ) - btn_setI.setEnabled(False) # ← DISABLE by default - self.setI_buttons[channel] = btn_setI # ← STORE button + btn_setI.clicked.connect(lambda checked, ch=channel: self.set_current(ch)) + btn_setI.setEnabled(False) # ← DISABLE by default + self.setI_buttons[channel] = btn_setI # ← STORE button hlayout.addWidget(btn_setI) - + # ON button btn_on = QPushButton("ON", self) btn_on.setMinimumWidth(40) - btn_on.clicked.connect( - lambda checked, ch=channel: self.turn_on(ch) - ) + btn_on.clicked.connect(lambda checked, ch=channel: self.turn_on(ch)) hlayout.addWidget(btn_on) # OFF button btn_off = QPushButton("OFF", self) btn_off.setMinimumWidth(40) - btn_off.clicked.connect( - lambda checked, ch=channel: self.turn_off(ch) - ) + btn_off.clicked.connect(lambda checked, ch=channel: self.turn_off(ch)) hlayout.addWidget(btn_off) # Voltage/Current label @@ -264,15 +254,15 @@ def initUI(self): hlayout.addWidget(self.led[channel]) self.show() - + def toggle_current_setting(self, state): """ Enable or disable all 'Set I' buttons based on the safety checkbox. """ - enabled = (state == Qt.Checked) + enabled = state == Qt.Checked for btn in self.setI_buttons.values(): btn.setEnabled(enabled) - + # ---------------- Periodic status update ---------------- @pyqtSlot() @@ -282,7 +272,7 @@ def update_status(self): ask the server for the status of all channels. """ print("Update") - + message = "GetStatus,PowerSupplyId:caen" self.queryThread.setup_query(message, True) @@ -380,9 +370,7 @@ def set_current(self, channel): return # Adjust "SetCurrent" to your actual server protocol if needed. - message = ( - f"SetCurrent,PowerSupplyId:caen,ChannelId:{channel},Current:{value}" - ) + message = f"SetCurrent,PowerSupplyId:caen,ChannelId:{channel},Current:{value}" print(message) self.queryThread.setup_query(message, receive=False) @@ -395,7 +383,9 @@ def set_current(self, channel): if __name__ == "__main__": import argparse - parser = argparse.ArgumentParser(description="INNER TRACKER CAEN GUI - 8 LV Channels") + parser = argparse.ArgumentParser( + description="INNER TRACKER CAEN GUI - 8 LV Channels" + ) parser.add_argument( "--ip", type=str, diff --git a/Onboarding_integration_tools.md b/Onboarding_integration_tools.md new file mode 100644 index 0000000..c943470 --- /dev/null +++ b/Onboarding_integration_tools.md @@ -0,0 +1,109 @@ +Onboarding: integration_tools_new +What this project is +A physics lab instrument control suite for CMS detector module testing at Pisa (CERN collaboration). Operators use it daily to: + +Select a detector module from inventory +Connect fiber/power cables to it +Run 3 automated electrical tests (ID check, noise with HV off, noise with HV on) +Monitor cooling and thermal safety in real time +Think of it as a hardware dashboard — it talks to 4 different systems over 4 different protocols simultaneously. + +The 4 communication layers (this is the architecture) + +Your GUI ─────── HTTP REST ──────────▶ pccmslab1:5000 (MongoDB, module inventory) + ─────── TCP binary ───────────▶ 192.168.0.45:7000 (CAEN HV/LV supply) + ─────── VISA/TCP ─────────────▶ 192.168.0.16 (Rigol DP116A bench supply) + ─────── MQTT pub/sub ─────────▶ pccmslab1:1883 (everything else) + ─────── subprocess ───────────▶ Ph2_ACF (the actual detector test framework) +The MQTT broker is the nervous system: all sensor data (temperatures, cooling state, door lock, CO₂, thermal camera images) flows through it. Commands to the coldroom and MARTA also go over MQTT. + +5 entry points — know which one does what +File Role When to touch it +integration.py Main app — module testing, CAEN control, thermal camera, ring diagram Most development happens here +cold.py Coldroom + MARTA monitoring GUI Cooling system changes +coldroom/safety.py Safety interlock logic Has 4 HIGH bugs — treat carefully +caen/caenGUI.py CAEN power supply widget HV/LV control changes +power_supply/power_supply_ctrl.py Rigol bench supply panel Standalone power supply work +The module test workflow (what operators actually do) + +1. Open integration.py +2. Type ring name (e.g. "L1_47_A#1") → ring diagram draws +3. Click a slot → Module ID fills automatically (from DB) +4. Verify fiber (SfibA/B) and power (R01;M1.x) dropdowns are green (connected) +5. Click [Check ID] → runs Ph2_ACF → confirms module is alive +6. Click [HV OFF Test] → noise measurement, room lights on +7. Click [HV ON Test] → dark noise + calibration +8. Watch thermal plot for overheating (Tmax displayed live from thermal camera) +9. Results auto-populate the noise table +Between steps, the thermal camera and Ph2_ACF chip temperatures arrive continuously via MQTT and update the plot live. + +The codebase mental model +integration.py (2272 lines) is the hub. It: + +Inherits from ui/integration_gui.py (auto-generated from Qt Designer .ui file — don't edit directly) +Creates a ModuleDB widget, a CAENControl object, and a paho MQTT client +Has one giant on_mqtt_message() callback handling ALL incoming MQTT topics +Runs tests via CommandWorker(QThread) which wraps subprocess.run() +coldroom/system.py is the hub for the coldroom side. It holds one shared _status dict that all subsystems read/write. MartaColdRoomMQTTClient fills it; safety.py reads it. + +db/module_db.py is the inventory tab — loads all modules from REST, filters them, shows details. All DB writes go through make_api_request() in integration.py. + +The bugs you need to fix first (priority order) +These are in issues.md. Here's the action order for a new developer: + +1. Fix immediately (safety-critical, will crash in production) +Bug File 1-line fix +BUG-01 integration.py:792 self.caenGUI → self.caen (thermal shutdown is broken) +BUG-03 coldroom/system.py:213 loop_start() → loop_stop() +BUG-04 coldroom/command_worker.py:79 Switch to Popen so Cancel button works +BUG-10 coldroom/safety.py:76,88,98 Remove tuple from return False, "..." → just return False +BUG-12 coldroom/safety.py:139 Fix elapsed_time → compute from last_update +2. Fix soon (safety logic broken but not immediately crashing) +Bug File What's wrong +BUG-11 coldroom/safety.py:82 Checks door_status key, reads CmdDoorUnlock_Reff — KeyError +BUG-13 coldroom/safety.py:435 switch_all_lv_off() is commented out — LV never actually cuts +BUG-09 db/module_db.py:352 Disconnect button crashes — missing argument +3. Fix when you touch those areas +The MEDIUM bugs (BUG-16 through BUG-26) won't crash immediately but cause subtle wrong behaviour — stale Cancel button, GUI freezes on slow network, wrong fuse ID lookup, camera name mismatch. + +Key PyQt5 patterns used in this project +You'll encounter these everywhere: + +QThread for background work: + + +class CommandWorker(QThread): + finished = pyqtSignal(int) # emits when done + def run(self): + result = subprocess.run(...) + self.finished.emit(result.returncode) +Signals/slots for thread-safe GUI updates (the right pattern): + + +# WRONG (BUG-14): updating widget from MQTT thread +self.tMaxLabel.setText("45.2") # called from paho thread → random crashes + +# RIGHT: emit a signal, catch it on main thread +self.temperatureUpdate.emit(45.2) # signal defined as pyqtSignal(float) +QTimer for periodic polling: + + +self.timer = QTimer() +self.timer.timeout.connect(self.update) +self.timer.start(2000) # fires every 2 seconds +Files to read when you want to understand a specific area +Topic Read this file +CAEN protocol caen/caenGUI.py — look at tcp_util and CAENQueryThread +Safety interlocks coldroom/safety.py — all functions are self-contained +MQTT topics map settings_coldroom.yaml + section 11 of workflow.md +Module DB fields db/utils.py + section 5 of workflow.md +Test commands settings_integration.yaml — check_id_command, light_on_command, dark_test_command +Ring diagram drawing integration.py — search for draw_ring() +Thermal panorama coldroom/module_temperatures_gui.py — stitch_multiple_images() +Suggested first week plan +Day 1: Read workflow.md fully. Run grep -n "def " integration.py to map the main class methods. +Day 2: Fix BUG-01 and BUG-03 (5 minutes each, enormous safety impact). Read safety.py fully. +Day 3: Fix BUG-10, BUG-11, BUG-12, BUG-13 in safety.py — these are all in one file and related. +Day 4: Fix BUG-04 (command_worker.py) and BUG-09 (module_db.py). +Day 5+: Work through the MEDIUM bugs in issues.md as you encounter the related features. +Ask me to explain or fix any specific bug, any file, or any concept — I have the full codebase context and can walk you through anything. \ No newline at end of file diff --git a/caen/caenGUI.py b/caen/caenGUI.py index 744b7f6..35edf6f 100755 --- a/caen/caenGUI.py +++ b/caen/caenGUI.py @@ -1,18 +1,30 @@ #!/bin/env python3 -#create QT GUI with one button to send message to TCP server +# create QT GUI with one button to send message to TCP server import sys -from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QTextEdit, QHBoxLayout, QFrame, QLabel +from PyQt5.QtWidgets import ( + QApplication, + QWidget, + QPushButton, + QVBoxLayout, + QTextEdit, + QHBoxLayout, + QFrame, + QLabel, +) from PyQt5.QtCore import pyqtSlot, QTimer, QThread, pyqtSignal, QObject import socket import json + BUFFER_SIZE = 100000 + class CAENQueryThread(QThread): """Thread class for handling CAEN queries""" + dataReady = pyqtSignal(dict) # Signal to emit when data is received error = pyqtSignal(str) # Signal to emit when error occurs - def __init__(self, ip='192.168.0.45', port=7000): + def __init__(self, ip="192.168.0.45", port=7000): super().__init__() self.ip = ip self.port = port @@ -24,7 +36,7 @@ def setup_query(self, message, receive=False): """Setup the query to be executed""" self.message = message self.receive = receive - + def stop(self): """Stop the thread""" self.running = False @@ -38,44 +50,46 @@ def run(self): try: tcpClass = tcp_util(ip=self.ip, port=self.port) tcpClass.sendMessage(self.message) - + if self.receive: data = tcpClass.socket.recv(BUFFER_SIZE) length = data[3] | (data[2] << 8) | (data[1] << 16) | (data[0] << 24) - while len(data) < length : - print("wait for more data",len(data), length) - chunk=tcpClass.socket.recv(BUFFER_SIZE) + while len(data) < length: + print("wait for more data", len(data), length) + chunk = tcpClass.socket.recv(BUFFER_SIZE) if not chunk: break - data+=chunk - data=data[8:].decode("utf-8") + data += chunk + data = data[8:].decode("utf-8") parsedData = {} - for token in data.split(','): - if token.startswith('caen'): + for token in data.split(","): + if token.startswith("caen"): key, value = token.split(":") value = float(value) parsedData[key] = value self.dataReady.emit(parsedData) - + tcpClass.closeSocket() - + except Exception as e: self.error.emit(str(e)) -class tcp_util(): + +class tcp_util: """Utility class for tcp - comunication management. """ + comunication management.""" + def __init__(self, ip, port): - self.ip = ip - self.port = port - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.ip = ip + self.port = port + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.headerBytes = 4 self.connectSocket() pass - def __del__ (self): + def __del__(self): """Desctuctor, closes socket""" try: self.closeSocket() @@ -84,7 +98,7 @@ def __del__ (self): def connectSocket(self): """Connects socket""" - self.socket.connect((self.ip,self.port)) + self.socket.connect((self.ip, self.port)) pass def closeSocket(self): @@ -92,36 +106,37 @@ def closeSocket(self): self.socket.close() pass - def sendMessage(self,message): + def sendMessage(self, message): """Encodes message and sends it on socket""" encodedMessage = self.encodeMessage(message) self.socket.send(encodedMessage) pass - def encodeMessage(self,message): + def encodeMessage(self, message): """Encodes message adding 4 bytes header""" - messageLength = len(message) + self.headerBytes +4 - N=0 - encodedMessage = (messageLength).to_bytes(4, byteorder='big') + N.to_bytes(4, byteorder='big') + message.encode('utf-8') + messageLength = len(message) + self.headerBytes + 4 + N = 0 + encodedMessage = ( + (messageLength).to_bytes(4, byteorder="big") + + N.to_bytes(4, byteorder="big") + + message.encode("utf-8") + ) return encodedMessage + from argparse import Namespace + class CAENControl(QObject): """CAEN control logic without UI elements""" + def __init__(self, ui): super().__init__() self.ui = ui self.channels = {"LV": "LV7.1", "HV": "HV0.11"} - self.led = { - 'LV': self.ui.lvLed, - 'HV': self.ui.hvLed - } - self.label = { - 'LV': self.ui.lvLabel, - 'HV': self.ui.hvLabel - } - + self.led = {"LV": self.ui.lvLed, "HV": self.ui.hvLed} + self.label = {"LV": self.ui.lvLabel, "HV": self.ui.hvLabel} + # Create query thread self.queryThread = CAENQueryThread() self.queryThread.dataReady.connect(self.handle_query_response) @@ -130,27 +145,27 @@ def __init__(self, ui): # Connect buttons self.ui.lvOnButton.clicked.connect(lambda: self.on(self.channels["LV"])) self.ui.lvOffButton.clicked.connect(lambda: self.safe_lv_off()) - self.ui.hvOnButton.clicked.connect(lambda: self.on(self.channels["HV"] )) - self.ui.hvOffButton.clicked.connect(lambda: self.off(self.channels["HV"] )) + self.ui.hvOnButton.clicked.connect(lambda: self.on(self.channels["HV"])) + self.ui.hvOffButton.clicked.connect(lambda: self.off(self.channels["HV"])) # Create timer for periodic update self.timer = QTimer(self) self.timer.timeout.connect(self.update) self.timer.start(2000) - self.lv_off_when_hv_off=False + self.lv_off_when_hv_off = False def safe_lv_off(self): - if self.lv_off_when_hv_off : # if the user insists we switch off + if self.lv_off_when_hv_off: # if the user insists we switch off self.off(self.channels["LV"]) self.off(self.channels["HV"]) - self.lv_off_when_hv_off=True + self.lv_off_when_hv_off = True - def setLV(self,channel_name): - self.channels["LV"]=channel_name + def setLV(self, channel_name): + self.channels["LV"] = channel_name - def setHV(self,channel_name): - self.channels["HV"]=channel_name + def setHV(self, channel_name): + self.channels["HV"] = channel_name def __del__(self): """Cleanup when widget is destroyed""" @@ -159,38 +174,41 @@ def __del__(self): @pyqtSlot() def update(self): """Periodic update method""" - self.queryThread.setup_query('GetStatus,PowerSupplyId:caen', True) + self.queryThread.setup_query("GetStatus,PowerSupplyId:caen", True) self.queryThread.start() def handle_query_response(self, data): """Handle the response from query thread""" try: - if data['caen_'+self.channels["LV"]+'_IsOn'] < 0.5: - self.lv_off_when_hv_off = False - - for hl,channel in self.channels.items(): - if self.lv_off_when_hv_off : - self.led[hl].setStyleSheet("background-color: yellow") + if data["caen_" + self.channels["LV"] + "_IsOn"] < 0.5: + self.lv_off_when_hv_off = False + + for hl, channel in self.channels.items(): + if self.lv_off_when_hv_off: + self.led[hl].setStyleSheet("background-color: yellow") else: - if data['caen_'+channel+'_IsOn'] > 0.5: + if data["caen_" + channel + "_IsOn"] > 0.5: self.led[hl].setStyleSheet("background-color: green") else: self.led[hl].setStyleSheet("background-color: red") - if hl=="HV": + if hl == "HV": self.label[hl].setText( - f'V: {data["caen_"+channel+"_Voltage"]:3.1f}V ' - f'C: {data["caen_"+channel+"_Current"]:2.1f}uA ' + f'V: {data["caen_"+channel+"_Voltage"]:3.1f}V ' + f'C: {data["caen_"+channel+"_Current"]:2.1f}uA ' ) # Check if we need to turn off LV based on HV voltage - if self.lv_off_when_hv_off and data["caen_"+channel+"_Voltage"] <= 10.0: + if ( + self.lv_off_when_hv_off + and data["caen_" + channel + "_Voltage"] <= 10.0 + ): print("Switching OFF LV ") self.off(self.channels["LV"]) - + else: self.label[hl].setText( - f'V: {data["caen_"+channel+"_Voltage"]:2.1f}V ' - f'C: {data["caen_"+channel+"_Current"]:1.1f}A ' - f'P: {data["caen_"+channel+"_Voltage"]*data["caen_"+channel+"_Current"]:1.1f}W' + f'V: {data["caen_"+channel+"_Voltage"]:2.1f}V ' + f'C: {data["caen_"+channel+"_Current"]:1.1f}A ' + f'P: {data["caen_"+channel+"_Voltage"]*data["caen_"+channel+"_Current"]:1.1f}W' ) except Exception as e: print(f"Error handling response: {e}") @@ -201,22 +219,22 @@ def handle_query_error(self, error_msg): @pyqtSlot() def on(self, channel): - print(f'TurnOn,PowerSupplyId:caen,ChannelId:{channel}') - self.queryThread.setup_query(f'TurnOn,PowerSupplyId:caen,ChannelId:{channel}') + print(f"TurnOn,PowerSupplyId:caen,ChannelId:{channel}") + self.queryThread.setup_query(f"TurnOn,PowerSupplyId:caen,ChannelId:{channel}") self.queryThread.start() @pyqtSlot() def off(self, channel): - print(f'TurnOff,PowerSupplyId:caen,ChannelId:{channel}') - self.queryThread.setup_query(f'TurnOff,PowerSupplyId:caen,ChannelId:{channel}') + print(f"TurnOff,PowerSupplyId:caen,ChannelId:{channel}") + self.queryThread.setup_query(f"TurnOff,PowerSupplyId:caen,ChannelId:{channel}") self.queryThread.start() + def main(): app = QApplication(sys.argv) ex = caenGUI() sys.exit(app.exec_()) -if __name__ == '__main__': - main() - +if __name__ == "__main__": + main() diff --git a/caen/caenGUIall.py b/caen/caenGUIall.py index ec6839e..da28095 100755 --- a/caen/caenGUIall.py +++ b/caen/caenGUIall.py @@ -1,7 +1,16 @@ #!/bin/env python3 # create QT GUI with one button to send message to TCP server import sys -from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QTextEdit, QHBoxLayout, QFrame, QLabel +from PyQt5.QtWidgets import ( + QApplication, + QWidget, + QPushButton, + QVBoxLayout, + QTextEdit, + QHBoxLayout, + QFrame, + QLabel, +) from PyQt5.QtCore import pyqtSlot from PyQt5.QtCore import QTimer from PyQt5.QtGui import QFont @@ -10,7 +19,16 @@ BUFFER_SIZE = 100000 import sys -from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QTextEdit, QHBoxLayout, QFrame, QLabel +from PyQt5.QtWidgets import ( + QApplication, + QWidget, + QPushButton, + QVBoxLayout, + QTextEdit, + QHBoxLayout, + QFrame, + QLabel, +) from PyQt5.QtCore import pyqtSlot, QTimer, QThread, pyqtSignal, QObject import socket import json @@ -31,14 +49,13 @@ def __init__(self, ip="192.168.0.45", port=7000): self.message = None self.receive = False self.running = True - self.queue =[] + self.queue = [] self.receiveQueue = [] def setup_query(self, message, receive=False): """Setup the query to be executed""" self.queue.append(message) self.receiveQueue.append(receive) - def stop(self): """Stop the thread""" @@ -50,7 +67,7 @@ def run(self): # print("Running query thread",self.message) while self.queue: - #create lock + # create lock self.message = self.queue.pop(0) self.receive = self.receiveQueue.pop(0) try: @@ -64,9 +81,14 @@ def run(self): if not chunk: break data += chunk - length = data[3] | (data[2] << 8) | (data[1] << 16) | (data[0] << 24) - if len(data) >= length : - break + length = ( + data[3] + | (data[2] << 8) + | (data[1] << 16) + | (data[0] << 24) + ) + if len(data) >= length: + break except: break data = data[8:] @@ -95,7 +117,7 @@ def __init__(self, ip, port): self.ip = ip self.port = port self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -# self.socket.settimeout(0.5) + # self.socket.settimeout(0.5) self.headerBytes = 4 self.connectSocket() @@ -129,7 +151,9 @@ def encodeMessage(self, message): messageLength = len(message) + self.headerBytes + 4 N = 0 encodedMessage = ( - (messageLength).to_bytes(4, byteorder="big") + N.to_bytes(4, byteorder="big") + message.encode("utf-8") + (messageLength).to_bytes(4, byteorder="big") + + N.to_bytes(4, byteorder="big") + + message.encode("utf-8") ) return encodedMessage @@ -191,11 +215,15 @@ def initUI(self): hlayout.addWidget(l) self.button = QPushButton("ON", self) self.button.setMinimumWidth(30) - self.button.clicked.connect(lambda checked, channel=channel: self.on(channel)) + self.button.clicked.connect( + lambda checked, channel=channel: self.on(channel) + ) hlayout.addWidget(self.button) self.button = QPushButton("OFF", self) self.button.setMinimumWidth(30) - self.button.clicked.connect(lambda checked, channel=channel: self.off(channel)) + self.button.clicked.connect( + lambda checked, channel=channel: self.off(channel) + ) hlayout.addWidget(self.button) # use QFrame as a led self.label[channel] = QLabel("n/a") @@ -220,7 +248,7 @@ def initUI(self): def update(self): """Periodic update method""" print("Update") - + self.queryThread.setup_query("GetStatus,PowerSupplyId:caen", True) # if thread not running, start it if not self.queryThread.isRunning(): @@ -262,10 +290,10 @@ def handle_query_error(self, error_msg): def on(self, channel): print(f"TurnOn,PowerSupplyId:caen,ChannelId:{channel}") self.queryThread.setup_query(f"TurnOn,PowerSupplyId:caen,ChannelId:{channel}") - #if thread not running, start it + # if thread not running, start it if not self.queryThread.isRunning(): print("Starting query thread") - self.queryThread.start() + self.queryThread.start() @pyqtSlot() def off(self, channel): @@ -273,36 +301,41 @@ def off(self, channel): self.queryThread.setup_query(f"TurnOff,PowerSupplyId:caen,ChannelId:{channel}") if not self.queryThread.isRunning(): print("Starting query thread") - self.queryThread.start() + self.queryThread.start() + # self.queryThread.start() - # def send(self,message,receive=False): - # print(message) - # tcpClass = tcp_util(ip='192.168.0.45',port=7000) - # tcpClass.sendMessage(message) - # try: - # if receive : - # data = tcpClass.socket.recv(BUFFER_SIZE)[8:].decode("utf-8") - # print(data) - # parsedData={} - # for token in data.split(',') : - # if token.startswith('caen'): - # key,value=token.split(":") - # value=float(value) - # parsedData[key]=value - # print(json.dumps(parsedData,indent=4),flush=True) - # return parsedData - # except: - # print("Cannot parse") +# def send(self,message,receive=False): +# print(message) +# tcpClass = tcp_util(ip='192.168.0.45',port=7000) +# tcpClass.sendMessage(message) +# try: +# if receive : +# data = tcpClass.socket.recv(BUFFER_SIZE)[8:].decode("utf-8") +# print(data) +# parsedData={} +# for token in data.split(',') : +# if token.startswith('caen'): +# key,value=token.split(":") +# value=float(value) +# parsedData[key]=value +# print(json.dumps(parsedData,indent=4),flush=True) +# return parsedData +# except: +# print("Cannot parse") import argparse if __name__ == "__main__": parser = argparse.ArgumentParser(description="CAEN GUI") - parser.add_argument("--ip", type=str, default="192.168.0.45", help="IP address of the CAEN server") - parser.add_argument("--port", type=int, default=7000, help="Port of the CAEN server") + parser.add_argument( + "--ip", type=str, default="192.168.0.45", help="IP address of the CAEN server" + ) + parser.add_argument( + "--port", type=int, default=7000, help="Port of the CAEN server" + ) args = parser.parse_args() app = QApplication(sys.argv) diff --git a/caen/caencli.py b/caen/caencli.py index fcf99f4..f888370 100755 --- a/caen/caencli.py +++ b/caen/caencli.py @@ -9,6 +9,7 @@ class tcp_util: """Utility class for tcp communication management.""" + def __init__(self, ip, port): self.ip = ip self.port = port @@ -40,32 +41,55 @@ def encodeMessage(self, message): """Encodes message adding 4 bytes header""" messageLength = len(message) + self.headerBytes + 4 N = 0 - encodedMessage = (messageLength).to_bytes(4, byteorder='big') + N.to_bytes(4, byteorder='big') + message.encode('utf-8') + encodedMessage = ( + (messageLength).to_bytes(4, byteorder="big") + + N.to_bytes(4, byteorder="big") + + message.encode("utf-8") + ) return encodedMessage + class caen: def on(self, channel, verbose=True): - self.send('TurnOn,PowerSupplyId:caen,ChannelId:' + channel, verbose=verbose) + self.send("TurnOn,PowerSupplyId:caen,ChannelId:" + channel, verbose=verbose) def off(self, channel, verbose=True): - self.send('TurnOff,PowerSupplyId:caen,ChannelId:' + channel, verbose=verbose) + self.send("TurnOff,PowerSupplyId:caen,ChannelId:" + channel, verbose=verbose) def send(self, message, receive=False, verbose=True): - if verbose: print(str(datetime.datetime.now()), message) - tcpClass = tcp_util(ip='192.168.0.45', port=7000) + if verbose: + print(str(datetime.datetime.now()), message) + tcpClass = tcp_util(ip="192.168.0.45", port=7000) tcpClass.sendMessage(message) if receive: data = tcpClass.socket.recv(BUFFER_SIZE)[8:].decode("utf-8") - if verbose: print(data) + if verbose: + print(data) + if __name__ == "__main__": parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) - parser.add_argument("-c", "--channel", dest="channel", required=True, - help="channel number for LV Caen control. e.g. BLV02") - parser.add_argument("--on", dest="action", action="store_const", const="on", - help="turn on the specified channel") - parser.add_argument("--off", dest="action", action="store_const", const="off", - help="turn off the specified channel") + parser.add_argument( + "-c", + "--channel", + dest="channel", + required=True, + help="channel number for LV Caen control. e.g. BLV02", + ) + parser.add_argument( + "--on", + dest="action", + action="store_const", + const="on", + help="turn on the specified channel", + ) + parser.add_argument( + "--off", + dest="action", + action="store_const", + const="off", + help="turn off the specified channel", + ) args = parser.parse_args() if not args.action: @@ -77,5 +101,5 @@ def send(self, message, receive=False, verbose=True): caen_controller.on(args.channel) elif args.action == "off": caen_controller.off(args.channel) - + print("DONE") diff --git a/cold.py b/cold.py index 0f6bc41..bd12221 100755 --- a/cold.py +++ b/cold.py @@ -20,6 +20,8 @@ check_light_safe_to_turn_on, check_marta_safe, soft_interlock_loop, + interpret_door_safety, + interpret_soft_interlock, ) from caen.caenGUIall import caenGUIall from Inner_tracker_GUI.caenGUIall_v2 import caenGUI8LV @@ -739,24 +741,39 @@ def soft_interlock_check(self): ) logger.info(f"Updated soft interlock LED: {status_color} with pulse") + verdict = interpret_soft_interlock(is_safe) + + # Also show the verdict as the LED caption so the light is self-explanatory + soft_interlock_caption = self.marta_coldroom_tab.findChild( + QtWidgets.QLabel, "soft_interlock_label" + ) + if soft_interlock_caption: + caption = "Soft Interlock\n" + ("OK" if is_safe else "TRIPPED") + soft_interlock_caption.setText(caption) + soft_interlock_caption.setStyleSheet( + "color: #1a7f37;" if is_safe else "color: #c0362c;" + ) + soft_interlock_msg_label = self.marta_coldroom_tab.findChild( QtWidgets.QLabel, "soft_interlock_msg" ) if soft_interlock_msg_label: - soft_interlock_msg_label.setText(msg) - logger.info(f"Updated soft interlock message: {msg}") + soft_interlock_msg_label.setText(f"{verdict}\n{msg}") + logger.info(f"Updated soft interlock message: {verdict} | {msg}") logger.info("Completed soft interlock loop") def update_ui(self): """Update UI with current system status""" try: - self.system._martacoldroom._cleanroom_last_update_elapsed_time = ( - time.time() - self.system._martacoldroom._cleanroom_last_update_timer - ) - self.system.status["cleanroom"][ - "elapsed_time" - ] = self.system._martacoldroom._cleanroom_last_update_elapsed_time + if self.system._martacoldroom: + self.system._martacoldroom._cleanroom_last_update_elapsed_time = ( + time.time() + - self.system._martacoldroom._cleanroom_last_update_timer + ) + self.system.status["cleanroom"][ + "elapsed_time" + ] = self.system._martacoldroom._cleanroom_last_update_elapsed_time self.modules_list_tab.light_on = check_light_status(self.system.status) # Get the central widget central = self.marta_coldroom_tab @@ -1093,10 +1110,26 @@ def update_ui(self): logger.debug( f"Updated safe to open LED: {'green' if is_safe else 'red'} (is_safe={is_safe})" ) - self.system._martacoldroom.publish_door_safety_status(is_safe) + if self.system._martacoldroom: + self.system._martacoldroom.publish_door_safety_status(is_safe) + + verdict = interpret_door_safety(is_safe) + + # Show the verdict as the LED caption so the light is self-explanatory + door_caption = central.findChild( + QtWidgets.QLabel, "coldroom_runlabel_2" + ) + if door_caption: + door_caption.setText( + "Safe to open\n" + ("YES" if is_safe else "NO") + ) + door_caption.setStyleSheet( + "color: #1a7f37;" if is_safe else "color: #c0362c;" + ) + self.marta_coldroom_tab.findChild( QtWidgets.QLabel, "door_safety_msg" - ).setText(door_msg) + ).setText(f"{verdict}\n\n{door_msg}") # =========================================================================================== COLDROOM RUN PROCESS =========================================================================================== # Run status diff --git a/coldroom/camera_config.yaml b/coldroom/camera_config.yaml new file mode 100644 index 0000000..2fd1a01 --- /dev/null +++ b/coldroom/camera_config.yaml @@ -0,0 +1,12 @@ +camera1: + position: 0 + side: Front +camera2: + position: 0 + side: Back +camera3: + position: 180 + side: Front +camera4: + position: 180 + side: Back diff --git a/coldroom/command_worker.py b/coldroom/command_worker.py index 161525b..d3fb003 100644 --- a/coldroom/command_worker.py +++ b/coldroom/command_worker.py @@ -3,6 +3,7 @@ import os from PyQt5.QtCore import QThread, pyqtSignal import logging + logger = logging.getLogger(__name__) @@ -44,29 +45,29 @@ def run(self): logger.debug(f"Running command: {expanded_command}") # Use Popen instead of run() to get process control # self.process = subprocess.Popen( - # expanded_command, - # shell=True, - # stdout=subprocess.PIPE, + # expanded_command, + # shell=True, + # stdout=subprocess.PIPE, # stderr=subprocess.PIPE, # text=True, # preexec_fn=os.setsid # Create new process group for proper cleanup # ) self.process = subprocess.run( - expanded_command, - shell=True, - # stdout=subprocess.PIPE, + expanded_command, + shell=True, + # stdout=subprocess.PIPE, # stderr=subprocess.PIPE, capture_output=True, text=True, # preexec_fn=os.setsid # Create new process group for proper cleanup ) - + if self._should_terminate: self.finished.emit(False, "", "Process was terminated by user") else: success = self.process.returncode == 0 self.finished.emit(success, self.process.stdout, self.process.stderr) - + except Exception as e: self.finished.emit(False, "", str(e)) finally: @@ -79,7 +80,7 @@ def terminate_process(self): try: # Try graceful termination first os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) - + # Wait a bit for graceful shutdown try: self.process.wait(timeout=3) @@ -87,7 +88,7 @@ def terminate_process(self): # Force kill if graceful termination didn't work os.killpg(os.getpgid(self.process.pid), signal.SIGKILL) self.process.wait() - + except (ProcessLookupError, OSError): # Process already terminated pass @@ -95,4 +96,4 @@ def terminate_process(self): def terminate(self): """Override Qt terminate to properly handle subprocess""" self.terminate_process() - return super().terminate() \ No newline at end of file + return super().terminate() diff --git a/coldroom/marta_coldroom.py b/coldroom/marta_coldroom.py index 6071678..dab0cf0 100644 --- a/coldroom/marta_coldroom.py +++ b/coldroom/marta_coldroom.py @@ -56,7 +56,9 @@ def __init__(self, system_obj): # Connect to broker try: - logger.debug(f"Connecting to MQTT broker at {self._system.BROKER}:{self._system.PORT}") + logger.debug( + f"Connecting to MQTT broker at {self._system.BROKER}:{self._system.PORT}" + ) self._client.connect(self._system.BROKER, self._system.PORT, keepalive=60) logger.debug("MQTT client connected successfully") except Exception as e: @@ -121,7 +123,9 @@ def on_message(self, client, userdata, msg): elif msg.topic.startswith(self.TOPIC_COLDROOM_AIR): # Add Coldroom Air topic logger.info("Processing Coldroom Air message") self.handle_air_bypass_message(msg.payload) - logger.info(f"Updated Coldroom Air status: {self._coldroom_state.get('air_bypass_status', None)}") + logger.info( + f"Updated Coldroom Air status: {self._coldroom_state.get('air_bypass_status', None)}" + ) # Handle MARTA messages elif msg.topic.startswith(self.TOPIC_BASE_MARTA): # Add MARTA topic @@ -155,7 +159,9 @@ def on_message(self, client, userdata, msg): # Safety checks if self._system.has_valid_status(): - self._system.safety_flags["door_locked"] = not check_dew_point(self._system.status) + self._system.safety_flags["door_locked"] = not check_dew_point( + self._system.status + ) self._system.safety_flags["sleep"] = check_door_status(self._system.status) self._system.safety_flags["door_safe"] = check_door_safe_to_open( self._system.status, self._system.status.get("caen", {}), {"HV": []} @@ -262,19 +268,33 @@ def handle_cleanroom_status_message(self, payload): # Update cleanroom status based on topic if isinstance(data, dict): if "temperature" in data or "temp" in data: - self._cleanroom_status["temperature"] = float(data.get("temperature", data.get("temp"))) - logger.info(f"Updated temperature: {self._cleanroom_status['temperature']}") + self._cleanroom_status["temperature"] = float( + data.get("temperature", data.get("temp")) + ) + logger.info( + f"Updated temperature: {self._cleanroom_status['temperature']}" + ) if "RH" in data or "humidity" in data: - self._cleanroom_status["humidity"] = float(data.get("RH", data.get("humidity"))) - logger.info(f"Updated humidity: {self._cleanroom_status['humidity']}") + self._cleanroom_status["humidity"] = float( + data.get("RH", data.get("humidity")) + ) + logger.info( + f"Updated humidity: {self._cleanroom_status['humidity']}" + ) if "dewpoint" in data: self._cleanroom_status["dewpoint"] = float(data["dewpoint"]) - logger.info(f"Updated dewpoint: {self._cleanroom_status['dewpoint']}") + logger.info( + f"Updated dewpoint: {self._cleanroom_status['dewpoint']}" + ) if "Pressure" in data: self._cleanroom_status["pressure"] = float(data["Pressure"]) - logger.info(f"Updated pressure: {self._cleanroom_status['pressure']}") + logger.info( + f"Updated pressure: {self._cleanroom_status['pressure']}" + ) - self._cleanroom_status["last_update"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self._cleanroom_status["last_update"] = ( + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) self._cleanroom_last_update_timer = time.time() self._cleanroom_last_update_elapsed_time = 0 @@ -294,10 +314,14 @@ def handle_coldroom_state_message(self, payload): # Update control states if "ch_temperature" in self._coldroom_state: if "status" in self._coldroom_state["ch_temperature"]: - self._coldroom_state["temperature_control"] = self._coldroom_state["ch_temperature"]["status"] + self._coldroom_state["temperature_control"] = self._coldroom_state[ + "ch_temperature" + ]["status"] if "ch_humidity" in self._coldroom_state: if "status" in self._coldroom_state["ch_humidity"]: - self._coldroom_state["humidity_control"] = self._coldroom_state["ch_humidity"]["status"] + self._coldroom_state["humidity_control"] = self._coldroom_state[ + "ch_humidity" + ]["status"] self._system.update_status({"coldroom": self._coldroom_state}) except Exception as e: logger.error(f"Error parsing Coldroom state message: {e}") @@ -307,7 +331,9 @@ def handle_air_bypass_message(self, payload): data = json.loads(payload) logger.debug(f"Parsed Coldroom air bypass data: {data}") self._dry_air_bypass_status = data["apower"] > 0.1 - self._system._status["coldroomair"]["air_bypass_status"] = self._dry_air_bypass_status + self._system._status["coldroomair"][ + "air_bypass_status" + ] = self._dry_air_bypass_status except Exception as e: logger.error(f"Error parsing Coldroom air bypass message: {e}") @@ -358,7 +384,9 @@ def dry_air_bypass_on(self): if response.status_code == 200: logger.info("Dry air bypass turned ON successfully") else: - logger.error(f"Failed to turn ON dry air bypass, status code: {response.status_code}") + logger.error( + f"Failed to turn ON dry air bypass, status code: {response.status_code}" + ) except Exception as e: logger.error(f"Error turning ON dry air bypass: {e}") @@ -369,7 +397,9 @@ def dry_air_bypass_off(self): if response.status_code == 200: logger.info("Dry air bypass turned OFF successfully") else: - logger.error(f"Failed to turn OFF dry air bypass, status code: {response.status_code}") + logger.error( + f"Failed to turn OFF dry air bypass, status code: {response.status_code}" + ) except Exception as e: logger.error(f"Error turning OFF dry air bypass: {e}") @@ -388,4 +418,6 @@ def coldroom_state(self): @property def door_locked(self): - return self._system.safety_flags.get("door_locked", True) # Default to True (safe) if not available + return self._system.safety_flags.get( + "door_locked", True + ) # Default to True (safe) if not available diff --git a/coldroom/marta_coldroom.ui b/coldroom/marta_coldroom.ui index 4eacd73..5398e40 100644 --- a/coldroom/marta_coldroom.ui +++ b/coldroom/marta_coldroom.ui @@ -14,7 +14,92 @@ MainWindow + + QWidget { + background-color: #eef1f5; + color: #2b3648; + font-family: "Segoe UI", "Ubuntu", "Helvetica Neue", Arial, sans-serif; + font-size: 13px; +} +QGroupBox { + background-color: #ffffff; + border: 1px solid #e2e7ee; + border-radius: 12px; + margin-top: 16px; + padding: 12px 10px 10px 10px; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 14px; + padding: 2px 8px; + color: #5b6b82; + font-weight: 700; + letter-spacing: 1px; +} +QLabel { + background: transparent; + color: #3a4759; +} +QPushButton { + background-color: #ffffff; + color: #2b3648; + border: 1px solid #cdd6e2; + border-radius: 8px; + padding: 7px 14px; + font-weight: 600; +} +QPushButton:hover { + background-color: #f2f6fc; + border-color: #3b82f6; + color: #1d4ed8; +} +QPushButton:pressed { + background-color: #e6edf7; +} +QPushButton:disabled { + color: #aab4c2; + background-color: #f6f8fb; + border-color: #e2e7ee; +} +QLineEdit { + background-color: #ffffff; + border: 1px solid #cdd6e2; + border-radius: 8px; + padding: 6px 10px; + selection-background-color: #3b82f6; +} +QLineEdit:focus { + border: 1px solid #3b82f6; +} +QCheckBox { + spacing: 6px; +} +#safe_to_open_LED, #soft_interlock_LED { + border-radius: 38px; + border: 4px solid rgba(43, 54, 72, 0.08); +} +#ctrl_temp_LED, #ctrl_humidity_LED, #dryair_LED, #dryair_bypass_LED, #light_LED, #marta_flow_flag_LED { + border-radius: 15px; + border: 2px solid rgba(43, 54, 72, 0.10); +} + + + 16 + + + 16 + + + 16 + + + 16 + + + 14 + @@ -25,18 +110,18 @@ - + - 60 - 60 + 76 + 76 - 300 - 300 + 76 + 76 @@ -60,7 +145,9 @@ - 20 + 15 + 75 + true @@ -84,7 +171,7 @@ - 16 + 12 @@ -98,18 +185,18 @@ - + - 60 - 60 + 76 + 76 - 300 - 300 + 76 + 76 @@ -133,7 +220,9 @@ - 16 + 15 + 75 + true @@ -157,7 +246,7 @@ - 16 + 12 diff --git a/coldroom/module_temperatures_gui.py b/coldroom/module_temperatures_gui.py index b05a15c..f4ddcec 100644 --- a/coldroom/module_temperatures_gui.py +++ b/coldroom/module_temperatures_gui.py @@ -24,8 +24,12 @@ def __init__(self, system_obj): uic.loadUi(ui_file, self) # Initialize scaling modes - default to CO2 scaling - self.use_co2_scaling_stitched = True # Default to CO2 scaling for stitched views - self.use_co2_scaling_temperature_plot = True # Default to CO2 scaling for temperature plot + self.use_co2_scaling_stitched = ( + True # Default to CO2 scaling for stitched views + ) + self.use_co2_scaling_temperature_plot = ( + True # Default to CO2 scaling for temperature plot + ) # Initialize stitching-related attributes # Initialize camera-specific FOV values (default to 20 degrees for all cameras) @@ -51,7 +55,9 @@ def __init__(self, system_obj): # Set default temperature range to CO2 and connect signal if hasattr(self, "t_range_comboBox"): self.t_range_comboBox.setCurrentText("CO2") - self.t_range_comboBox.currentTextChanged.connect(self.on_temperature_range_changed) + self.t_range_comboBox.currentTextChanged.connect( + self.on_temperature_range_changed + ) # Setup update timer self.update_timer = QTimer() @@ -118,7 +124,9 @@ def get_camera_effective_position(self, camera_id): logger.error(f"Invalid camera ID: {camera_id}") return None except Exception as e: - logger.error(f"Error calculating effective position for camera {camera_id}: {e}") + logger.error( + f"Error calculating effective position for camera {camera_id}: {e}" + ) return None def validate_camera_config(self, config): @@ -154,7 +162,12 @@ def setup_stitched_views(self): # Get the tab widget from the UI self.stitched_tab_widget = self.findChild(QtWidgets.QTabWidget, "tabWidget") - for graphics_view in [self.graphics_1, self.graphics_2, self.graphics_3, self.graphics_4]: + for graphics_view in [ + self.graphics_1, + self.graphics_2, + self.graphics_3, + self.graphics_4, + ]: scene = QtWidgets.QGraphicsScene() graphics_view.setScene(scene) @@ -187,7 +200,11 @@ def setup_stitched_views(self): ) # Set up coordinate formatter for stitched views - ax.format_coord = lambda x, y, ax_ref=ax, img_ref=img: self.format_coord_stitched(x, y, ax_ref, img_ref) + ax.format_coord = ( + lambda x, y, ax_ref=ax, img_ref=img: self.format_coord_stitched( + x, y, ax_ref, img_ref + ) + ) # Set up the axes for degrees ax.set_xticks(np.linspace(0, 360, 9)) # Ticks every 45 degrees @@ -248,7 +265,9 @@ def snapshot(self): os.makedirs(save_dir) # Save stitched images - for i, (fig, canvas) in enumerate(zip(self.stitched_figs, self.stitched_canvases)): + for i, (fig, canvas) in enumerate( + zip(self.stitched_figs, self.stitched_canvases) + ): filename = f"{base_filename}_view_camera{i+1}.png" filename = os.path.join(save_dir, filename) fig.savefig(filename, dpi=300) @@ -328,21 +347,33 @@ def setup_temperature_plot(self): color = colors[i] # Create lines for max and min temperatures - (max_line,) = self.temp_ax.plot([], [], "o-", color=color, label=f"{camera_name} Max", markersize=3) + (max_line,) = self.temp_ax.plot( + [], [], "o-", color=color, label=f"{camera_name} Max", markersize=3 + ) (min_line,) = self.temp_ax.plot( - [], [], "s--", color=color, label=f"{camera_name} Min", markersize=3, alpha=0.7 + [], + [], + "s--", + color=color, + label=f"{camera_name} Min", + markersize=3, + alpha=0.7, ) self.temp_lines[f"{camera_name}_max"] = max_line self.temp_lines[f"{camera_name}_min"] = min_line # Add legend with better positioning to account for module annotations - self.temp_ax.legend(bbox_to_anchor=(1.0, 0.5), loc="center left", fontsize=8, frameon=False) + self.temp_ax.legend( + bbox_to_anchor=(1.0, 0.5), loc="center left", fontsize=8, frameon=False + ) # Adjust layout to make room for legend and module annotations self.temp_fig.subplots_adjust(right=0.82, top=0.85) - logger.info("Temperature plot tab with navigation toolbar added successfully") + logger.info( + "Temperature plot tab with navigation toolbar added successfully" + ) except Exception as e: logger.error(f"Error setting up temperature plot: {e}") @@ -369,21 +400,33 @@ def update_temperature_plot_co2_scale(self): y_max = co2_temp + 20.0 # Get temperature data from thermal camera - if hasattr(self.system._thermalcamera, "_stitching_max_temperature") and hasattr( - self.system._thermalcamera, "_stitching_min_temperature" - ): + if hasattr( + self.system._thermalcamera, "_stitching_max_temperature" + ) and hasattr(self.system._thermalcamera, "_stitching_min_temperature"): for i in range(4): - camera_name = f"camera{i}" # This matches the thermal_camera.py naming + camera_name = ( + f"camera{i}" # This matches the thermal_camera.py naming + ) camera_display_name = f"camera{i+1}" # This matches the GUI naming if ( - camera_name in self.system._thermalcamera._stitching_max_temperature - and camera_name in self.system._thermalcamera._stitching_min_temperature + camera_name + in self.system._thermalcamera._stitching_max_temperature + and camera_name + in self.system._thermalcamera._stitching_min_temperature ): - max_temp_data = self.system._thermalcamera._stitching_max_temperature[camera_name] - min_temp_data = self.system._thermalcamera._stitching_min_temperature[camera_name] + max_temp_data = ( + self.system._thermalcamera._stitching_max_temperature[ + camera_name + ] + ) + min_temp_data = ( + self.system._thermalcamera._stitching_min_temperature[ + camera_name + ] + ) if max_temp_data and min_temp_data: # Extract positions and temperatures @@ -392,12 +435,17 @@ def update_temperature_plot_co2_scale(self): min_temps = [] # Get all positions that have both max and min data - common_positions = set(max_temp_data.keys()) & set(min_temp_data.keys()) + common_positions = set(max_temp_data.keys()) & set( + min_temp_data.keys() + ) for pos in sorted(common_positions, key=float): # Apply camera position offset to get effective position effective_position = ( - float(pos) + self.camera_positions[camera_display_name]["position"] + float(pos) + + self.camera_positions[camera_display_name][ + "position" + ] ) % 360 positions.append(effective_position) @@ -409,8 +457,12 @@ def update_temperature_plot_co2_scale(self): filtered_max_temps = self.simple_spike_filter(max_temps) # Update the plot lines - self.temp_lines[f"{camera_display_name}_max"].set_data(positions, filtered_max_temps) - self.temp_lines[f"{camera_display_name}_min"].set_data(positions, min_temps) + self.temp_lines[f"{camera_display_name}_max"].set_data( + positions, filtered_max_temps + ) + self.temp_lines[f"{camera_display_name}_min"].set_data( + positions, min_temps + ) # Set fixed Y-axis range based on CO2 temperature self.temp_ax.set_ylim(y_min, y_max) @@ -437,21 +489,33 @@ def update_temperature_plot_auto_scale(self): # Get temperature data and update plot lines all_temps = [] - if hasattr(self.system._thermalcamera, "_stitching_max_temperature") and hasattr( - self.system._thermalcamera, "_stitching_min_temperature" - ): + if hasattr( + self.system._thermalcamera, "_stitching_max_temperature" + ) and hasattr(self.system._thermalcamera, "_stitching_min_temperature"): for i in range(4): - camera_name = f"camera{i}" # This matches the thermal_camera.py naming + camera_name = ( + f"camera{i}" # This matches the thermal_camera.py naming + ) camera_display_name = f"camera{i+1}" # This matches the GUI naming if ( - camera_name in self.system._thermalcamera._stitching_max_temperature - and camera_name in self.system._thermalcamera._stitching_min_temperature + camera_name + in self.system._thermalcamera._stitching_max_temperature + and camera_name + in self.system._thermalcamera._stitching_min_temperature ): - max_temp_data = self.system._thermalcamera._stitching_max_temperature[camera_name] - min_temp_data = self.system._thermalcamera._stitching_min_temperature[camera_name] + max_temp_data = ( + self.system._thermalcamera._stitching_max_temperature[ + camera_name + ] + ) + min_temp_data = ( + self.system._thermalcamera._stitching_min_temperature[ + camera_name + ] + ) if max_temp_data and min_temp_data: # Extract positions and temperatures @@ -460,12 +524,17 @@ def update_temperature_plot_auto_scale(self): min_temps = [] # Get all positions that have both max and min data - common_positions = set(max_temp_data.keys()) & set(min_temp_data.keys()) + common_positions = set(max_temp_data.keys()) & set( + min_temp_data.keys() + ) for pos in sorted(common_positions, key=float): # Apply camera position offset to get effective position effective_position = ( - float(pos) + self.camera_positions[camera_display_name]["position"] + float(pos) + + self.camera_positions[camera_display_name][ + "position" + ] ) % 360 positions.append(effective_position) @@ -477,8 +546,12 @@ def update_temperature_plot_auto_scale(self): filtered_max_temps = self.simple_spike_filter(max_temps) # Update the plot lines - self.temp_lines[f"{camera_display_name}_max"].set_data(positions, filtered_max_temps) - self.temp_lines[f"{camera_display_name}_min"].set_data(positions, min_temps) + self.temp_lines[f"{camera_display_name}_max"].set_data( + positions, filtered_max_temps + ) + self.temp_lines[f"{camera_display_name}_min"].set_data( + positions, min_temps + ) # Collect all temperature values for auto-scaling all_temps.extend(filtered_max_temps) @@ -540,7 +613,9 @@ def add_module_annotations_to_temperature_plot(self): module_side = module_info["side"] # Get module slot information if available - if "mounted_on" in module_info and ";" in str(module_info["mounted_on"]): + if "mounted_on" in module_info and ";" in str( + module_info["mounted_on"] + ): module_slot = str(module_info["mounted_on"]).split(";")[1] else: module_slot = "-" @@ -548,7 +623,9 @@ def add_module_annotations_to_temperature_plot(self): # Create annotation text with side information for clarity annotation_text = f"{module_slot}:{module_name}" - logger.debug(f"Adding temperature plot annotation '{annotation_text}' at x={module_position}") + logger.debug( + f"Adding temperature plot annotation '{annotation_text}' at x={module_position}" + ) # Add text annotation at the top of the plot with rotation y_top = self.temp_ax.get_ylim()[1] @@ -570,9 +647,17 @@ def add_module_annotations_to_temperature_plot(self): # Add a dashed vertical line to mark the exact position # Use different colors based on side for better distinction - line_color = "black" if module_side == "13" else "gray" if module_side == "24" else "gray" + line_color = ( + "black" + if module_side == "13" + else "gray" if module_side == "24" else "gray" + ) line_obj = self.temp_ax.axvline( - x=module_position, color=line_color, linestyle="--", alpha=0.5, linewidth=1 + x=module_position, + color=line_color, + linestyle="--", + alpha=0.5, + linewidth=1, ) # Mark as module annotation for easy removal @@ -583,7 +668,9 @@ def add_module_annotations_to_temperature_plot(self): f"Added temperature plot annotation {annotation_count}: {annotation_text} at position {module_position}" ) - logger.info(f"Added {annotation_count} module annotations to temperature plot") + logger.info( + f"Added {annotation_count} module annotations to temperature plot" + ) # Adjust the top margin to make room for the angled text current_top = self.temp_fig.subplotpars.top @@ -609,13 +696,18 @@ def simple_spike_filter(self, temps, max_change=5.0): next_temp = temps[i + 1] # Check if current value is very different from both neighbors - if abs(curr_temp - prev_temp) > max_change and abs(curr_temp - next_temp) > max_change: + if ( + abs(curr_temp - prev_temp) > max_change + and abs(curr_temp - next_temp) > max_change + ): # Replace with average of neighbors filtered[i] = (prev_temp + next_temp) / 2 return filtered - def stitch_multiple_images(self, images, positions, temp_min, temp_max, camera_name, full_coverage=360): + def stitch_multiple_images( + self, images, positions, temp_min, temp_max, camera_name, full_coverage=360 + ): """Stitch multiple images from different positions into a panorama""" # Get dimensions of a single image h, w = images[0].shape @@ -673,16 +765,22 @@ def stitch_multiple_images(self, images, positions, temp_min, temp_max, camera_n # Convert to uint8 for display - use actual temperature range if np.isclose(temp_min, temp_max): - temp_max = temp_min + 1.0 # Add a small difference to avoid division by zero + temp_max = ( + temp_min + 1.0 + ) # Add a small difference to avoid division by zero # Create normalized array with proper handling of NaN values panorama_norm = np.zeros_like(panorama) valid_mask = ~np.isnan(panorama) - panorama_norm[valid_mask] = 255 * (panorama[valid_mask] - temp_min) / (temp_max - temp_min) + panorama_norm[valid_mask] = ( + 255 * (panorama[valid_mask] - temp_min) / (temp_max - temp_min) + ) # Areas without data will be black (0) panorama_norm = np.nan_to_num(panorama_norm, nan=0.0) # Convert NaNs to 0 - panorama_norm = np.clip(panorama_norm, 0, 255) # Ensure values are in valid range + panorama_norm = np.clip( + panorama_norm, 0, 255 + ) # Ensure values are in valid range panorama_norm = panorama_norm.astype(np.uint8) return panorama_norm, panorama @@ -720,7 +818,9 @@ def update_displays_co2_scale(self): # Get stitching data from thermal camera if hasattr(self.system._thermalcamera, "_stitching_data"): - for i, (camera_name, camera_data) in enumerate(self.system._thermalcamera._stitching_data.items()): + for i, (camera_name, camera_data) in enumerate( + self.system._thermalcamera._stitching_data.items() + ): # add +1 to the camera name to match the UI naming camera_index = int(camera_name[-1]) + 1 camera_name = f"camera{camera_index}" @@ -735,12 +835,20 @@ def update_displays_co2_scale(self): # Keep only the last image at this position last_img = pos_images[-1] images.append(last_img) - positions.append(float(pos) + self.camera_positions[camera_name]["position"]) + positions.append( + float(pos) + + self.camera_positions[camera_name]["position"] + ) if images: # If we have any images to stitch # Create stitched panorama using CO2 temperature range panorama_norm, panorama = self.stitch_multiple_images( - images, positions, colorbar_min, colorbar_max, camera_name, full_coverage=360 + images, + positions, + colorbar_min, + colorbar_max, + camera_name, + full_coverage=360, ) # Ensure the panorama is properly sized for 360 degrees @@ -753,7 +861,9 @@ def update_displays_co2_scale(self): # Update the stitched view with CO2-based scaling self.stitched_images[camera_index - 1].set_array(panorama) - self.stitched_images[camera_index - 1].set_clim(colorbar_min, colorbar_max) + self.stitched_images[camera_index - 1].set_clim( + colorbar_min, colorbar_max + ) # Clear previous annotations and add module names ax = self.stitched_axes[camera_index - 1] @@ -764,11 +874,15 @@ def update_displays_co2_scale(self): # Remove previous annotation lines (both module and camera position) for line in ax.lines[:]: - if hasattr(line, "_module_annotation") or hasattr(line, "_camera_position"): + if hasattr(line, "_module_annotation") or hasattr( + line, "_camera_position" + ): line.remove() # Add camera position line (red dashed line showing current camera position) - current_camera_pos = self.get_camera_effective_position(camera_index) + current_camera_pos = self.get_camera_effective_position( + camera_index + ) if current_camera_pos is not None: camera_line = ax.axvline( x=current_camera_pos, @@ -783,16 +897,27 @@ def update_displays_co2_scale(self): # Add module annotations if we have mounted modules if self.mounted_modules is not None: - self.add_module_annotations_to_stitched_image(ax, camera_name) + self.add_module_annotations_to_stitched_image( + ax, camera_name + ) else: - logger.debug(f"No mounted modules available for {camera_name}") + logger.debug( + f"No mounted modules available for {camera_name}" + ) # Update colorbar with CO2-based range cbar = self.stitched_images[camera_index - 1].colorbar if cbar is not None: - cbar.set_ticks(np.linspace(colorbar_min, colorbar_max, 5)) + cbar.set_ticks( + np.linspace(colorbar_min, colorbar_max, 5) + ) cbar.set_ticklabels( - [f"{temp:.1f}°C" for temp in np.linspace(colorbar_min, colorbar_max, 5)] + [ + f"{temp:.1f}°C" + for temp in np.linspace( + colorbar_min, colorbar_max, 5 + ) + ] ) # Draw canvas @@ -814,7 +939,9 @@ def update_displays_auto_scale(self): try: # Get stitching data from thermal camera if hasattr(self.system._thermalcamera, "_stitching_data"): - for i, (camera_name, camera_data) in enumerate(self.system._thermalcamera._stitching_data.items()): + for i, (camera_name, camera_data) in enumerate( + self.system._thermalcamera._stitching_data.items() + ): camera_index = int(camera_name[-1]) + 1 camera_ui_name = f"camera{camera_index}" if camera_data: @@ -829,7 +956,10 @@ def update_displays_auto_scale(self): if pos_images: last_img = pos_images[-1] images.append(last_img) - positions.append(float(pos) + self.camera_positions[camera_ui_name]["position"]) + positions.append( + float(pos) + + self.camera_positions[camera_ui_name]["position"] + ) # Update temperature range based on actual data temp_min = min(temp_min, last_img.min()) @@ -838,7 +968,12 @@ def update_displays_auto_scale(self): if images: # Create stitched panorama using actual temperature range panorama_norm, panorama = self.stitch_multiple_images( - images, positions, temp_min, temp_max, camera_ui_name, full_coverage=360 + images, + positions, + temp_min, + temp_max, + camera_ui_name, + full_coverage=360, ) # Ensure the panorama is properly sized for 360 degrees @@ -850,7 +985,9 @@ def update_displays_auto_scale(self): # Update the stitched view with auto-scaling self.stitched_images[camera_index - 1].set_array(panorama) - self.stitched_images[camera_index - 1].set_clim(temp_min, temp_max) + self.stitched_images[camera_index - 1].set_clim( + temp_min, temp_max + ) # Clear previous annotations and add new ones ax = self.stitched_axes[camera_index - 1] @@ -859,11 +996,15 @@ def update_displays_auto_scale(self): txt.remove() for line in ax.lines[:]: - if hasattr(line, "_module_annotation") or hasattr(line, "_camera_position"): + if hasattr(line, "_module_annotation") or hasattr( + line, "_camera_position" + ): line.remove() # Add camera position line - current_camera_pos = self.get_camera_effective_position(camera_index) + current_camera_pos = self.get_camera_effective_position( + camera_index + ) if current_camera_pos is not None: camera_line = ax.axvline( x=current_camera_pos, @@ -877,13 +1018,20 @@ def update_displays_auto_scale(self): # Add module annotations if self.mounted_modules is not None: - self.add_module_annotations_to_stitched_image(ax, camera_ui_name) + self.add_module_annotations_to_stitched_image( + ax, camera_ui_name + ) # Update colorbar with auto-scale range cbar = self.stitched_images[camera_index - 1].colorbar if cbar is not None: cbar.set_ticks(np.linspace(temp_min, temp_max, 5)) - cbar.set_ticklabels([f"{temp:.1f}°C" for temp in np.linspace(temp_min, temp_max, 5)]) + cbar.set_ticklabels( + [ + f"{temp:.1f}°C" + for temp in np.linspace(temp_min, temp_max, 5) + ] + ) # Draw canvas self.stitched_canvases[camera_index - 1].draw() @@ -940,7 +1088,9 @@ def update_stitched_images(self): stitching_data = self.system._thermalcamera._stitching_data - for i, camera_name in enumerate(["camera1", "camera2", "camera3", "camera4"]): + for i, camera_name in enumerate( + ["camera1", "camera2", "camera3", "camera4"] + ): if ( camera_name in stitching_data and len(stitching_data[camera_name]) > 0 @@ -954,15 +1104,25 @@ def update_stitched_images(self): if len(images) > 0: # Get temperature range for this camera - max_temps = self.system._thermalcamera._stitching_max_temperature.get(camera_name, {}) - min_temps = self.system._thermalcamera._stitching_min_temperature.get(camera_name, {}) + max_temps = ( + self.system._thermalcamera._stitching_max_temperature.get( + camera_name, {} + ) + ) + min_temps = ( + self.system._thermalcamera._stitching_min_temperature.get( + camera_name, {} + ) + ) if max_temps and min_temps: temp_max = max(max_temps.values()) temp_min = min(min_temps.values()) # Stitch images - stitched_image, _ = self.stitch_multiple_images(images, positions, temp_min, temp_max, camera_name) + stitched_image, _ = self.stitch_multiple_images( + images, positions, temp_min, temp_max, camera_name + ) # Update display if i < len(self.stitched_images): @@ -991,7 +1151,9 @@ def add_module_annotations_to_stitched_image(self, ax, camera_name): else: target_side = camera_side - logger.debug(f"Camera {camera_name} side: {camera_side}, target_side: {target_side}") + logger.debug( + f"Camera {camera_name} side: {camera_side}, target_side: {target_side}" + ) # Clear previous annotations # Remove previous text annotations and lines @@ -1010,7 +1172,9 @@ def add_module_annotations_to_stitched_image(self, ax, camera_name): module_side = module_info["side"] module_position = module_info["angular_position"] - logger.debug(f"Module {module_name}: side={module_side}, position={module_position}") + logger.debug( + f"Module {module_name}: side={module_side}, position={module_position}" + ) # Only show modules on the same side as the camera if module_side == target_side: @@ -1019,7 +1183,9 @@ def add_module_annotations_to_stitched_image(self, ax, camera_name): module_x_pos = module_position # Get module slot information if available - if "mounted_on" in module_info and ";" in str(module_info["mounted_on"]): + if "mounted_on" in module_info and ";" in str( + module_info["mounted_on"] + ): module_slot = str(module_info["mounted_on"]).split(";")[1] else: module_slot = "-" @@ -1027,7 +1193,9 @@ def add_module_annotations_to_stitched_image(self, ax, camera_name): # Create annotation text annotation_text = f"{module_slot}:{module_name}" - logger.debug(f"Adding annotation '{annotation_text}' at x={module_x_pos}") + logger.debug( + f"Adding annotation '{annotation_text}' at x={module_x_pos}" + ) # Add text annotation on the top x-axis with 45-degree rotation text_obj = ax.text( @@ -1047,15 +1215,25 @@ def add_module_annotations_to_stitched_image(self, ax, camera_name): text_obj._module_annotation = True # Add a dashed vertical line to mark the exact position - line_obj = ax.axvline(x=module_x_pos, color="black", linestyle="--", alpha=0.7, linewidth=1.5) + line_obj = ax.axvline( + x=module_x_pos, + color="black", + linestyle="--", + alpha=0.7, + linewidth=1.5, + ) # Mark as module annotation for easy removal line_obj._module_annotation = True annotation_count += 1 - logger.debug(f"Added annotation {annotation_count}: {annotation_text} at position {module_x_pos}") + logger.debug( + f"Added annotation {annotation_count}: {annotation_text} at position {module_x_pos}" + ) - logger.info(f"Added {annotation_count} module annotations for {camera_name} on side {camera_side}") + logger.info( + f"Added {annotation_count} module annotations for {camera_name} on side {camera_side}" + ) # Adjust the top margin to make room for the angled text # Get current subplot parameters @@ -1084,23 +1262,29 @@ def setup_fov_sliders(self): # Get slider and label widgets slider = self.findChild(QtWidgets.QSlider, slider_name) label = self.findChild(QtWidgets.QLabel, config["label"]) - + if slider and label: camera_name = config["camera"] - + # Set initial slider value and label text initial_fov = self.camera_fovs[camera_name] slider.setValue(initial_fov) label.setText(str(initial_fov)) - + # Connect slider to update function using lambda to capture camera name slider.valueChanged.connect( - lambda value, cam=camera_name, lbl=label: self.on_fov_changed(cam, value, lbl) + lambda value, cam=camera_name, lbl=label: self.on_fov_changed( + cam, value, lbl + ) + ) + + logger.info( + f"Connected FOV slider for {camera_name}, initial value: {initial_fov}" ) - - logger.info(f"Connected FOV slider for {camera_name}, initial value: {initial_fov}") else: - logger.warning(f"Could not find slider '{slider_name}' or label '{config['label']}'") + logger.warning( + f"Could not find slider '{slider_name}' or label '{config['label']}'" + ) except Exception as e: logger.error(f"Error setting up FOV sliders: {e}") @@ -1110,17 +1294,17 @@ def on_fov_changed(self, camera_name, fov_value, label): try: # Update the camera's FOV value self.camera_fovs[camera_name] = fov_value - + # Update the label to show current value label.setText(str(fov_value)) - + logger.info(f"FOV for {camera_name} changed to {fov_value} degrees") - + # Optionally trigger immediate update of displays # Note: This might cause frequent updates while dragging the slider # You can uncomment this if you want real-time updates # self.update_displays() - + except Exception as e: logger.error(f"Error handling FOV change for {camera_name}: {e}") @@ -1160,7 +1344,9 @@ def setup_module_temperature_table(self): table.setHorizontalHeaderLabels(horizontal_headers) # Set vertical headers (temperature keys) - vertical_headers = temp_keys.copy() # Copy temperature keys for vertical headers + vertical_headers = ( + temp_keys.copy() + ) # Copy temperature keys for vertical headers table.setVerticalHeaderLabels(vertical_headers) # Populate the table with empty strings initially for row in range(len(temp_keys)): @@ -1209,7 +1395,9 @@ def update_temperature_table(self): """Update the module temperature table with current data""" try: if not hasattr(self, "mqtt_client"): - logger.warning("MQTT client not initialized, cannot update temperature table") + logger.warning( + "MQTT client not initialized, cannot update temperature table" + ) return # Get the latest status from the MQTT client @@ -1223,22 +1411,38 @@ def update_temperature_table(self): for mounted_module, mounted_module_info in self.mounted_modules.items(): mounted_module_fuse_id = mounted_module_info.get("fuseId", None) if mounted_module_fuse_id is None: - # logger.warning(f"Mounted module {mounted_module} does not have a fuseId, skipping") + # logger.warning(f"Mounted module {mounted_module} does not have a fuseId, skipping") continue if mounted_module_fuse_id == monitored_fuse_id: - column_index = int(mounted_module_info.get("mounted_on", "-").split(";")[1]) - if column_index is not None and column_index < self.module_temp_table.columnCount(): + column_index = int( + mounted_module_info.get("mounted_on", "-").split(";")[1] + ) + if ( + column_index is not None + and column_index < self.module_temp_table.columnCount() + ): for temp_key, temperature in temp_data.items(): if temp_key in self.temp_keys: - if temp_key in mounted_module_info["temperature_offsets"]: - temperature += mounted_module_info["temperature_offsets"][temp_key] - item = QtWidgets.QTableWidgetItem(f"{temperature:.1f}") + if ( + temp_key + in mounted_module_info["temperature_offsets"] + ): + temperature += mounted_module_info[ + "temperature_offsets" + ][temp_key] + item = QtWidgets.QTableWidgetItem( + f"{temperature:.1f}" + ) else: # change the color of the cell to red if no offset is found - item = QtWidgets.QTableWidgetItem(f"{temperature:.1f}") + item = QtWidgets.QTableWidgetItem( + f"{temperature:.1f}" + ) item.setBackground(QColor(255, 0, 0)) row_index = self.temp_keys.index(temp_key) - self.module_temp_table.setItem(row_index, column_index - 1, item) + self.module_temp_table.setItem( + row_index, column_index - 1, item + ) # Note: Removed the publishing logic from here - it's now handled in handle_message @@ -1247,10 +1451,12 @@ def update_temperature_table(self): def publish_calibrated_data(self, fuse_id, temp_data): """Publish calibrated temperature data for a specific module""" - print(fuse_id,temp_data) + print(fuse_id, temp_data) try: if not hasattr(self, "mqtt_client"): - logger.warning("MQTT client not initialized, cannot publish calibrated data") + logger.warning( + "MQTT client not initialized, cannot publish calibrated data" + ) return to_publish = {} @@ -1263,11 +1469,20 @@ def publish_calibrated_data(self, fuse_id, temp_data): for temp_key, temperature in temp_data.items(): if temp_key in self.temp_keys: if temp_key in mounted_module_info["temperature_offsets"]: - calibrated_temp = temperature + mounted_module_info["temperature_offsets"][temp_key] - original_key = self.mqtt_client.key_map.get(temp_key, temp_key) + calibrated_temp = ( + temperature + + mounted_module_info["temperature_offsets"][ + temp_key + ] + ) + original_key = self.mqtt_client.key_map.get( + temp_key, temp_key + ) to_publish[original_key] = calibrated_temp else: - logger.warning(f"No offset found for {temp_key} in module {mounted_module}") + logger.warning( + f"No offset found for {temp_key} in module {mounted_module}" + ) break # Found the module, no need to continue @@ -1276,9 +1491,13 @@ def publish_calibrated_data(self, fuse_id, temp_data): topic = f"{self.mqtt_client.BASE_TOPIC}/calib_data" payload = json.dumps(to_publish) self.mqtt_client.client.publish(topic, payload) - logger.info(f"Published calibrated temperature data for fuse_id {fuse_id}: {to_publish}") + logger.info( + f"Published calibrated temperature data for fuse_id {fuse_id}: {to_publish}" + ) else: - logger.debug(f"No calibrated temperature data to publish for fuse_id {fuse_id}") + logger.debug( + f"No calibrated temperature data to publish for fuse_id {fuse_id}" + ) except Exception as e: logger.error(f"Error publishing calibrated data: {e}") @@ -1365,8 +1584,12 @@ def __init__(self, system): self.mqtt_settings = self.system._settings["mqtt"] self.gui_reference = None try: - self.client.connect(self.mqtt_settings["broker"], self.mqtt_settings["port"], keepalive=60) - logger.info(f"Connected to MQTT broker at {self.system.BROKER}:{self.system.PORT}") + self.client.connect( + self.mqtt_settings["broker"], self.mqtt_settings["port"], keepalive=60 + ) + logger.info( + f"Connected to MQTT broker at {self.system.BROKER}:{self.system.PORT}" + ) except Exception as e: logger.error(f"Failed to connect to MQTT broker: {e}") diff --git a/coldroom/modules_list_gui.py b/coldroom/modules_list_gui.py index ccb95c6..589854f 100644 --- a/coldroom/modules_list_gui.py +++ b/coldroom/modules_list_gui.py @@ -51,9 +51,13 @@ def __init__(self): # Connect existing buttons self.select_all_button_2.clicked.connect(self.select_all_modules) - self.turn_lv_off_all_button.clicked.connect(self.turn_off_lv_for_selected_modules) + self.turn_lv_off_all_button.clicked.connect( + self.turn_off_lv_for_selected_modules + ) self.turn_lv_on_all_button.clicked.connect(self.turn_on_lv_for_selected_modules) - self.turn_hv_off_all_button.clicked.connect(self.turn_off_hv_for_selected_modules) + self.turn_hv_off_all_button.clicked.connect( + self.turn_off_hv_for_selected_modules + ) self.turn_hv_on_all_button.clicked.connect(self.turn_on_hv_for_selected_modules) self.refresh_button.clicked.connect(self.update_module_list) @@ -61,10 +65,10 @@ def __init__(self): self.start_test_all_button.clicked.connect(self.run_test_for_selected_modules) self.cancel_test_all_button.clicked.connect(self.stop_all_tests) self.start_t_monitor_button.clicked.connect( - lambda: self.start_temperature_monitoring() #self.module_temperature_tab) + lambda: self.start_temperature_monitoring() # self.module_temperature_tab) ) self.stop_t_monitor_button.clicked.connect( - lambda: self.stop_temperature_monitoring() #self.module_temperature_tab) + lambda: self.stop_temperature_monitoring() # self.module_temperature_tab) ) # Tests Combobox @@ -97,8 +101,10 @@ def _make_api_request(self, endpoint, method, data=None): url = f"{self.db_url.rstrip('/')}/{endpoint.lstrip('/')}" try: # Simulate API response for demonstration purposes - #result = {"sessionName": "SIMULATED_SESSION_12345"} - response = requests.request(method=method.lower(), url=url, json=data if data else None) + # result = {"sessionName": "SIMULATED_SESSION_12345"} + response = requests.request( + method=method.lower(), url=url, json=data if data else None + ) if response.status_code != 200 and response.status_code != 201: logger.error(f"API Error ({response.status_code}): {response.text}") return False, None @@ -127,7 +133,9 @@ def _new_session(self): def turn_on_lv_for_selected_modules(self): """Turn on LV for all selected modules.""" if not self.marta_safe: - self.message_box.setText(f"MARTA not safe, cannot turn on LV:\n{self.marta_log_msg}") + self.message_box.setText( + f"MARTA not safe, cannot turn on LV:\n{self.marta_log_msg}" + ) self.message_box.exec_() return @@ -191,7 +199,9 @@ def caen_hv_on_wrap(self, channel): def caen_lv_on_wrap(self, channel): """Wrapper for LV on with safety check.""" if not self.marta_safe: - self.message_box.setText(f"MARTA not safe, cannot turn on LV:\n{self.marta_log_msg}") + self.message_box.setText( + f"MARTA not safe, cannot turn on LV:\n{self.marta_log_msg}" + ) self.message_box.exec_() return else: @@ -205,7 +215,9 @@ def caen_hv_off(self, channel): """Queue HV off command.""" self.caen.off(channel) - def populate_from_config(self, caen, modules, number_of_modules, thermal_camera_system=None): + def populate_from_config( + self, caen, modules, number_of_modules, thermal_camera_system=None + ): """Populate the moduleList QTreeWidget with module data from a config list.""" if number_of_modules < 1: return @@ -215,7 +227,10 @@ def populate_from_config(self, caen, modules, number_of_modules, thermal_camera_ self.thermal_camera_system = thermal_camera_system # Create all items first - values = {str(i): [str(i), "", "", "", "", "", "", "", "", ""] for i in range(1, number_of_modules + 1)} + values = { + str(i): [str(i), "", "", "", "", "", "", "", "", ""] + for i in range(1, number_of_modules + 1) + } # Fill the moduleList with the values first for i in values: @@ -241,9 +256,13 @@ def populate_from_config(self, caen, modules, number_of_modules, thermal_camera_ ) * self.MODULE_ANGULAR_WIDTH + (self.MODULE_ANGULAR_WIDTH / 2) * ( 1 if int(module_position) <= (number_of_modules / 2) else 0 ) - module_side = "13" if int(module_position) <= (number_of_modules / 2) else "24" + module_side = ( + "13" if int(module_position) <= (number_of_modules / 2) else "24" + ) - self.mounted_modules[module_name]["angular_position"] = module_angular_position + self.mounted_modules[module_name][ + "angular_position" + ] = module_angular_position self.mounted_modules[module_name]["side"] = module_side item_index = int(module_position) - 1 fc7 = module_info.get("FC7", "") @@ -271,28 +290,42 @@ def populate_from_config(self, caen, modules, number_of_modules, thermal_camera_ # Use queued commands for buttons btn_lv_on = QtWidgets.QPushButton("LV On") - btn_lv_on.clicked.connect(lambda checked, ch=module_info.get("LV"): self.caen_lv_on_wrap(ch)) + btn_lv_on.clicked.connect( + lambda checked, ch=module_info.get("LV"): self.caen_lv_on_wrap(ch) + ) btn_lv_off = QtWidgets.QPushButton("LV Off") - btn_lv_off.clicked.connect(lambda checked, ch=module_info.get("LV"): self.caen_lv_off(ch)) + btn_lv_off.clicked.connect( + lambda checked, ch=module_info.get("LV"): self.caen_lv_off(ch) + ) btn_hv_on = QtWidgets.QPushButton("HV On") - btn_hv_on.clicked.connect(lambda checked, ch=module_info.get("HV"): self.caen_hv_on_wrap(ch)) + btn_hv_on.clicked.connect( + lambda checked, ch=module_info.get("HV"): self.caen_hv_on_wrap(ch) + ) btn_hv_off = QtWidgets.QPushButton("HV Off") - btn_hv_off.clicked.connect(lambda checked, ch=module_info.get("HV"): self.caen_hv_off(ch)) + btn_hv_off.clicked.connect( + lambda checked, ch=module_info.get("HV"): self.caen_hv_off(ch) + ) # Connect test buttons to test functions btn_start_test = QtWidgets.QPushButton("Test On") - btn_start_test.clicked.connect(lambda checked, mod=module_name: self.start_single_module_test(mod)) + btn_start_test.clicked.connect( + lambda checked, mod=module_name: self.start_single_module_test(mod) + ) btn_stop_test = QtWidgets.QPushButton("Test Off") - btn_stop_test.clicked.connect(lambda checked, mod=module_name: self.stop_single_module_test(mod)) + btn_stop_test.clicked.connect( + lambda checked, mod=module_name: self.stop_single_module_test(mod) + ) # Connect camera button to focus function btn_focus_camera = QtWidgets.QPushButton("Camera") btn_focus_camera.clicked.connect( - lambda checked, mod=module_name: self.focus_camera_on_module(mod, self.thermal_camera_system) + lambda checked, mod=module_name: self.focus_camera_on_module( + mod, self.thermal_camera_system + ) ) for btn in [ @@ -324,7 +357,7 @@ def set_test_command(self): def run_test_for_selected_modules(self): """Run the test command for all selected modules.""" - #check if operator field is non empty + # check if operator field is non empty if not self.operator_le.text().strip(): self._show_message("Please enter operator name before starting a test.") return @@ -340,7 +373,9 @@ def run_test_for_selected_modules(self): added_modules.append(module_name) if added_modules: - logger.debug(f"Added {len(added_modules)} modules to test queue: {added_modules}") + logger.debug( + f"Added {len(added_modules)} modules to test queue: {added_modules}" + ) self.process_test_queue() def process_test_queue(self): @@ -355,13 +390,17 @@ def process_test_queue(self): # Get the next module from the queue module_name = self.test_queue.pop(0) - logger.debug(f"Starting test for module: {module_name} ({len(self.test_queue)} remaining in queue)") + logger.debug( + f"Starting test for module: {module_name} ({len(self.test_queue)} remaining in queue)" + ) self._start_test_for_module(module_name) def _start_test_for_module(self, module_name): """Start the actual test execution for a module.""" if self.current_worker is not None: - logger.debug(f"Cannot start test for {module_name}: another test is already running") + logger.debug( + f"Cannot start test for {module_name}: another test is already running" + ) return # Update testing status @@ -441,10 +480,10 @@ def select_all_modules(self): def start_single_module_test(self, module_name): """Start test for a single module via button click.""" - #check if operator field is non empty -# if not self.operator_field.text().strip(): -# self._show_message("Please enter operator name before starting a test.") - + # check if operator field is non empty + # if not self.operator_field.text().strip(): + # self._show_message("Please enter operator name before starting a test.") + if self._add_module_to_queue(module_name): logger.debug(f"Added module {module_name} to test queue via button click") self.process_test_queue() @@ -494,7 +533,10 @@ def _remove_module_from_testing(self, module_name): def _is_module_currently_testing(self, module_name): """Check if a module is currently being tested.""" - return self.current_worker and self.current_worker.placeholders.get("module_id") == module_name + return ( + self.current_worker + and self.current_worker.placeholders.get("module_id") == module_name + ) def _update_module_testing_status(self, module_name, status): """Update the testing status for a module.""" @@ -533,7 +575,9 @@ def update_module_list(self): item.setText(8, str(module_info.get("temperature", ""))) # Update testing status with visual indicators - self._update_testing_status_display(item, module_info.get("testing", "")) + self._update_testing_status_display( + item, module_info.get("testing", "") + ) else: # Clear testing status for empty slots item.setText(9, "") @@ -603,7 +647,11 @@ def get_test_status(self): return { "is_testing": self.current_worker is not None, "queue_length": len(self.test_queue), - "current_module": (self.current_worker.placeholders.get("module_id") if self.current_worker else None), + "current_module": ( + self.current_worker.placeholders.get("module_id") + if self.current_worker + else None + ), "queued_modules": self.test_queue.copy(), } @@ -618,7 +666,9 @@ def focus_camera_on_module(self, module_name, thermal_camera_system): side = module_info.get("side", "Undefined") if angular_position == -1 or side == "Undefined": - logger.debug(f"Invalid position data for module {module_name}: position={angular_position}, side={side}") + logger.debug( + f"Invalid position data for module {module_name}: position={angular_position}, side={side}" + ) return # Choose camera pair based on side @@ -628,7 +678,9 @@ def focus_camera_on_module(self, module_name, thermal_camera_system): return # Choose specific camera based on angular position - selected_camera = self._select_camera_for_position(camera_pair, angular_position) + selected_camera = self._select_camera_for_position( + camera_pair, angular_position + ) logger.debug( f"Focusing camera {selected_camera} on module {module_name} at angular position {angular_position}° (side {side})" @@ -640,7 +692,9 @@ def focus_camera_on_module(self, module_name, thermal_camera_system): # f"Moving camera {selected_camera} to {angular_position}° for module {module_name} (side {side})" # ) # self.camera_status_message_box.exec_() - self._move_camera_to_angular_position(thermal_camera_system, selected_camera, angular_position) + self._move_camera_to_angular_position( + thermal_camera_system, selected_camera, angular_position + ) else: logger.debug(f"No thermal camera system available") @@ -659,10 +713,14 @@ def __fill_message_box(self, camera_pair, angular_position): self.camera_status_message_box = QtWidgets.QMessageBox() self.camera_status_message_box.setWindowTitle("Camera Selection") self.camera_status_message_box.setIcon(QtWidgets.QMessageBox.Information) - self.camera_status_message_box.setText(f"Select camera for angular position {angular_position}°:") + self.camera_status_message_box.setText( + f"Select camera for angular position {angular_position}°:" + ) for camera_name in camera_pair: shown_camera_name = camera_name.replace("camera", "Camera ") - self.camera_status_message_box.addButton(shown_camera_name, QtWidgets.QMessageBox.AcceptRole) + self.camera_status_message_box.addButton( + shown_camera_name, QtWidgets.QMessageBox.AcceptRole + ) self.camera_status_message_box.setStandardButtons(QtWidgets.QMessageBox.Cancel) self.camera_status_message_box.setDefaultButton(QtWidgets.QMessageBox.Cancel) self.camera_status_message_box.setEscapeButton(QtWidgets.QMessageBox.Cancel) @@ -693,15 +751,21 @@ def _select_camera_for_position(self, camera_pair, angular_position): selected_camera = selected_camera.replace("Camera ", "camera") return camera_pair[selected_camera] - def _move_camera_to_angular_position(self, thermal_camera, camera_id, angular_position): + def _move_camera_to_angular_position( + self, thermal_camera, camera_id, angular_position + ): """Move the specified camera to the target angular position.""" try: # Use the thermal camera GUI's move_camera method success = thermal_camera.move_camera(camera_id, angular_position) if success: - logger.debug(f"Camera {camera_id} moved successfully to {angular_position}°") + logger.debug( + f"Camera {camera_id} moved successfully to {angular_position}°" + ) else: - logger.debug(f"Failed to move camera {camera_id} to {angular_position}°") + logger.debug( + f"Failed to move camera {camera_id} to {angular_position}°" + ) except Exception as e: logger.debug(f"Error moving camera {camera_id}: {str(e)}") @@ -722,7 +786,9 @@ def get_camera_status_for_module(self, module_name): if not camera_pair: return None - selected_camera = self._select_camera_for_position(camera_pair, angular_position) + selected_camera = self._select_camera_for_position( + camera_pair, angular_position + ) return { "module_name": module_name, diff --git a/coldroom/safety.py b/coldroom/safety.py index a97bcf5..9d9747d 100644 --- a/coldroom/safety.py +++ b/coldroom/safety.py @@ -1,5 +1,5 @@ import os -from tkinter import NO, YES, YES +import datetime import yaml import logging @@ -73,20 +73,20 @@ def check_dew_point(system_status): return min_temperature > reference_dew_point + delta except Exception as e: logger.debug(f"Error in check_dew_point: {str(e)}") - return False, "Error checking dew point safety" # Conservative approach + return False # Conservative approach - a bare False, not a truthy tuple def check_door_status(system_status): try: if ( "coldroom" not in system_status - or "door_status" not in system_status["coldroom"] + or "CmdDoorUnlock_Reff" not in system_status["coldroom"] ): return False # Conservative approach return system_status["coldroom"]["CmdDoorUnlock_Reff"] == 1 # Door is open except Exception as e: logger.debug(f"Error in check_door_status: {str(e)}") - return False, "Error checking door status" # Conservative approach + return False # Conservative approach - a bare False, not a truthy tuple def check_light_status(system_status): @@ -96,7 +96,7 @@ def check_light_status(system_status): return system_status["coldroom"]["light"] == 1 # Light is on except Exception as e: logger.debug(f"Error in check_light_status: {str(e)}") - return False, "Error checking light status" # Conservative approach + return False # Conservative approach - a bare False, not a truthy tuple def check_any_hv_on(caen_ch_status, used_channels): @@ -111,16 +111,37 @@ def check_any_hv_on(caen_ch_status, used_channels): return hv_on except Exception as e: logger.debug(f"Error in check_any_hv_on: {str(e)}") - return ( - True, - "Error checking high voltage status", - ) # Conservative approach - if we can't check, assume it's unsafe + # Conservative approach - if we can't check, assume HV is on (unsafe). + # Return a bare True, not a truthy tuple, so callers' `not hv_on` works. + return True def check_cleanroom_expired(elapsed_time, threshold=600): return elapsed_time > threshold +def interpret_door_safety(is_safe): + """ + Human-readable verdict for the 'Safe to open' LED, so the operator is told + what the light means instead of having to infer it from the colour. + """ + if is_safe: + return "SAFE TO OPEN — you may open the door." + return "DO NOT OPEN — conditions are unsafe, keep the door closed." + + +def interpret_soft_interlock(is_safe): + """ + Human-readable verdict for the 'Soft Interlock' LED. + + Note: green means "no protective action needed" (e.g. LV is off), not + necessarily "safe to energize LV" — the detailed message says which. + """ + if is_safe: + return "OK — no protective action needed (LV is protected)." + return "INTERLOCK TRIPPED — unsafe condition, LV has been switched off." + + def check_door_safe_to_open(system_status, caen_ch_status, used_channels): """ Check if it's safe to open the door based on multiple safety conditions. @@ -134,10 +155,22 @@ def check_door_safe_to_open(system_status, caen_ch_status, used_channels): False # Conservative approach - if we can't check, assume it's unsafe ) - # 1. Check if dew point conditions are safe - clean_room_expired = check_cleanroom_expired( - system_status["cleanroom"]["elapsed_time"] - ) + # 1. Check if dew point conditions are safe. + # Cleanroom status carries a "last_update" timestamp string, not an + # "elapsed_time" value — derive the age from it here. + last_update = system_status.get("cleanroom", {}).get("last_update") + if last_update is None: + clean_room_expired = True # No cleanroom data → treat as expired + else: + try: + last_dt = datetime.datetime.strptime( + last_update, "%Y-%m-%d %H:%M:%S" + ) + elapsed_time = (datetime.datetime.now() - last_dt).total_seconds() + clean_room_expired = check_cleanroom_expired(elapsed_time) + except (ValueError, TypeError) as e: + logger.debug(f"Could not parse cleanroom last_update: {str(e)}") + clean_room_expired = True # Unparseable → treat as expired if clean_room_expired: dew_point_safe = False log_msg += f"!!! Warning: Cleanroom data expired, not able to check dew point !!!\n" @@ -296,32 +329,37 @@ def Is_it_safe_to_on_lv(system_status, caen_ch_status, used_channels): def check_marta_on_for_OT(system_status): """ - Check if MARTA is running for OT (Outer Tracker). - Returns True if MARTA is connected and operational. + Check if MARTA CO2 supply is active for OT (Outer Tracker). + + Primary signal: MARTA FSM state is not disconnected/idle. + Secondary signal: outer_valve from serviceroom data (used when available). + Returns True only when CO2 is confirmed to be flowing to OT modules. """ try: - if "marta" not in system_status or "serviceroom" not in system_status: + if "marta" not in system_status: + logger.debug("MARTA data not available, treating OT CO2 as OFF") return False - logger.info( - f"Checking MARTA OT status: {system_status['marta'].get('fsm_state', 'N/A')}, OT valve: {system_status['serviceroom'].get('outer_valve', 'N/A')}" - ) fsm_state = system_status["marta"].get("fsm_state", "") - OT_valve = system_status["serviceroom"].get("outer_valve", 0) + logger.info(f"Checking MARTA OT status: fsm_state={fsm_state!r}") - if fsm_state not in ("DISCONNECTED", "NONE", ""): - if OT_valve == 1: # Outer valve is open, so OT is likely running - logger.info( - "MARTA FSM state is good and OT valve is open, treating as running" - ) - return True - else: - logger.debug( - "MARTA FSM state is good but OT valve is closed, treating as not running" - ) + if fsm_state in ("DISCONNECTED", "NONE", ""): + logger.info("MARTA is disconnected/idle, OT CO2 is OFF") + return False + + # If serviceroom valve data is available, use it for a precise check. + # Without it, fall back to FSM state as the best available indicator. + if "serviceroom" in system_status: + OT_valve = system_status["serviceroom"].get("outer_valve", 0) + logger.info(f"MARTA OT valve status: outer_valve={OT_valve}") + if OT_valve != 1: + logger.info("OT valve is closed, OT CO2 is OFF") return False else: - return False + logger.debug("Serviceroom data unavailable, relying on MARTA FSM state for OT check") + + return True + except Exception as e: logger.debug(f"Error in check_marta_on_for_OT: {str(e)}") return False @@ -329,30 +367,36 @@ def check_marta_on_for_OT(system_status): def check_marta_on_for_IT(system_status): """ - Check if MARTA is running for IT (Inner Tracker). - Returns True if MARTA is connected and operational. + Check if MARTA CO2 supply is active for IT (Inner Tracker). + + Primary signal: MARTA FSM state is not disconnected/idle. + Secondary signal: inner_valve from serviceroom data (used when available). + Returns True only when CO2 is confirmed to be flowing to IT modules. """ try: - if "marta" not in system_status or "serviceroom" not in system_status: + if "marta" not in system_status: + logger.debug("MARTA data not available, treating IT CO2 as OFF") return False - logger.info( - f"Checking MARTA IT status: {system_status['marta'].get('fsm_state', 'N/A')}, IT valve: {system_status['serviceroom'].get('inner_valve', 'N/A')}" - ) + fsm_state = system_status["marta"].get("fsm_state", "") - IT_valve = system_status["serviceroom"].get("inner_valve", 0) - if fsm_state not in ("DISCONNECTED", "NONE", ""): - if IT_valve == 1: # Inner valve is open, so IT is likely running - logger.info( - "MARTA FSM state is good and IT valve is open, treating as running" - ) - return True - else: - logger.debug( - "MARTA FSM state is good but IT valve is closed, treating as not running" - ) + logger.info(f"Checking MARTA IT status: fsm_state={fsm_state!r}") + + if fsm_state in ("DISCONNECTED", "NONE", ""): + logger.info("MARTA is disconnected/idle, IT CO2 is OFF") + return False + + # If serviceroom valve data is available, use it for a precise check. + if "serviceroom" in system_status: + IT_valve = system_status["serviceroom"].get("inner_valve", 0) + logger.info(f"MARTA IT valve status: inner_valve={IT_valve}") + if IT_valve != 1: + logger.info("IT valve is closed, IT CO2 is OFF") return False else: - return False + logger.debug("Serviceroom data unavailable, relying on MARTA FSM state for IT check") + + return True + except Exception as e: logger.debug(f"Error in check_marta_on_for_IT: {str(e)}") return False @@ -382,74 +426,78 @@ def soft_interlock_loop( """ Soft interlock loop - monitors safety conditions and takes protective action. - High-level logic: - if LV is on AND MARTA is not running: - → switch all LV off - → publish alarm message to /alarm topic + Decision tree (evaluated every ~5 s): + 1. If MARTA is not in a safe/connected state AND LV is on + → switch all LV off immediately + 2. Else if LV is on AND MARTA CO2 is not flowing to OT modules + → switch all LV off immediately + (IT modules share the same MARTA CO2 system; a full MARTA shutdown + is caught by condition 1. Per-valve IT protection requires + serviceroom data to be subscribed — see check_marta_on_for_IT.) - When performing an active safety action, a message is sent to the alarm topic "/alarm" - via the publish_alarm callback. + A message is published to /alarm whenever a protective action fires. Args: system_status (dict): Full system status including MARTA, coldroom, etc. caen_ch_status (dict): CAEN channel status with caen_{channel}_IsOn keys. used_channels (dict): Active channel list {"LV": [...], "HV": [...]}. caen: CAEN control object with on()/off() methods. - publish_alarm (callable, optional): Function to publish alarm messages. - Signature: publish_alarm(message_string) + publish_alarm (callable, optional): publish_alarm(message_string) Returns: tuple: (is_safe: bool, message: str) """ try: lv_on = Is_any_lv_on(caen_ch_status, used_channels) - lv_safe_to_on = Is_it_safe_to_on_lv( + # Is_it_safe_to_on_lv returns (bool, str) — unpack properly + lv_safe_to_on, lv_safe_msg = Is_it_safe_to_on_lv( system_status, caen_ch_status, used_channels ) marta_ot = check_marta_on_for_OT(system_status) marta_it = check_marta_on_for_IT(system_status) - if lv_on: - lv_status = "ON" - else: - lv_status = "OFF" + lv_status = "ON" if lv_on else "OFF" + marta_ot_status = "RUNNING" if marta_ot else "NOT RUNNING" + marta_it_status = "RUNNING" if marta_it else "NOT RUNNING" - if marta_ot: - marta_ot_status = "RUNNING" - else: - marta_ot_status = "NOT RUNNING" - - if marta_it: - marta_it_status = "RUNNING" - else: - marta_it_status = "NOT RUNNING" - - log_msg = f"\nSoft interlock: LV_on={lv_status}, MARTA_OT={marta_ot_status}, MARTA_IT={marta_it_status}\n" + log_msg = ( + f"\nSoft interlock: LV={lv_status}, " + f"MARTA_OT={marta_ot_status}, MARTA_IT={marta_it_status}\n" + ) logger.info(log_msg) - if lv_safe_to_on == False: - log_msg += ( - "\n!!! Warning: Conditions are not safe to turn on LV channels !!!\n" - ) - log_msg += f"\nLV safe to on: NO\n" - # switch_all_lv_off(caen, used_channels) - else: - log_msg += f"\nLV safe to on: YES\n" - - if lv_on and not marta_ot: + # --- Condition 1: MARTA itself is not safe (e.g. disconnected) --- + if not lv_safe_to_on: + log_msg += "\n!!! Warning: MARTA not safe — LV safe to turn on: NO\n" + log_msg += lv_safe_msg + if lv_on: alarm_msg = ( - "SAFETY INTERLOCK: LV channels are on but MARTA OT is not running. " + "SAFETY INTERLOCK: MARTA is not in a safe state and LV channels are ON. " "Turning off all LV channels to prevent module damage." ) logger.warning(alarm_msg) - # switch_all_lv_off(caen, used_channels) + switch_all_lv_off(caen, used_channels) if publish_alarm: publish_alarm(alarm_msg) return False, alarm_msg - else: - log_msg += "\nAll safety conditions met.\n" - logger.info(log_msg) + return True, log_msg + + log_msg += "\nLV safe to turn on: YES\n" + # --- Condition 2: MARTA connected but CO2 not flowing to OT --- + if lv_on and not marta_ot: + alarm_msg = ( + "SAFETY INTERLOCK: LV channels are ON but MARTA OT CO2 is not flowing. " + "Turning off all LV channels to prevent module damage." + ) + logger.warning(alarm_msg) + switch_all_lv_off(caen, used_channels) + if publish_alarm: + publish_alarm(alarm_msg) + return False, alarm_msg + + log_msg += "\nAll safety conditions met.\n" + logger.info(log_msg) return True, log_msg except Exception as e: diff --git a/coldroom/system.py b/coldroom/system.py index 9002f45..c4337d7 100644 --- a/coldroom/system.py +++ b/coldroom/system.py @@ -16,7 +16,12 @@ def __init__(self): # Load settings self._settings = {} # Initialize private settings variable try: - with open(os.path.join(os.path.dirname(__file__), os.pardir, "settings_coldroom.yaml"), "r") as f: + with open( + os.path.join( + os.path.dirname(__file__), os.pardir, "settings_coldroom.yaml" + ), + "r", + ) as f: self._settings = yaml.safe_load(f) logger.debug("Settings loaded successfully") logger.debug(f"MQTT Broker: {self._settings['mqtt']['broker']}") @@ -28,14 +33,25 @@ def __init__(self): "Cleanroom": {"mqtt_topic": "/environment/HumAndTemp001/#"}, "MARTA": {"mqtt_topic": "/MARTA/#"}, "Coldroom": {"mqtt_topic": "/coldroom/#"}, - "ThermalCamera": {"mqtt_topic": "/thermalcamera/#"} + "ThermalCamera": {"mqtt_topic": "/thermalcamera/#"}, } # Global variables self.BROKER = self._settings["mqtt"]["broker"] self.PORT = self._settings["mqtt"]["port"] - self._status = {"marta": {}, "coldroom": {}, "thermal_camera": {}, "caen": {}, "cleanroom": {}, "coldroomair": {}} - self.safety_flags = {"door_locked": True, "sleep": True, "hv_safe": False} # Default value to safest state + self._status = { + "marta": {}, + "coldroom": {}, + "thermal_camera": {}, + "caen": {}, + "cleanroom": {}, + "coldroomair": {}, + } + self.safety_flags = { + "door_locked": True, + "sleep": True, + "hv_safe": False, + } # Default value to safest state # Thread control self._mqtt_thread = None @@ -58,9 +74,9 @@ def __init__(self): except Exception as e: logger.error(f"Error initializing Thermal Camera client: {e}") self._thermalcamera = None - + try: - self._caen = None + self._caen = None except Exception as e: logger.error(f"Error initializing CAEN client: {e}") self._caen = None @@ -73,7 +89,7 @@ def __init__(self): def settings(self): """Get the current settings""" return self._settings - + @settings.setter def settings(self, value): """Update settings and save to file""" @@ -81,7 +97,9 @@ def settings(self, value): raise ValueError("Settings must be a dictionary") self._settings = value try: - with open(os.path.join(os.path.dirname(__file__), "settings.yaml"), "w") as f: + with open( + os.path.join(os.path.dirname(__file__), "settings.yaml"), "w" + ) as f: yaml.dump(self._settings, f, default_flow_style=False) except Exception as e: logger.error(f"Error saving settings: {e}") @@ -115,7 +133,7 @@ def start_mqtt_thread(self): self._mqtt_thread.daemon = True self._mqtt_thread.start() logger.info("MQTT thread started") - + # Start CAEN status update thread if self._caen and not self._caen_thread: self._caen_thread_stop = False @@ -133,7 +151,7 @@ def stop_mqtt_thread(self): self._thread_stop = True self._mqtt_thread.join(timeout=2) logger.info("MQTT thread stopped") - + # Stop CAEN status thread if self._caen_thread and self._caen_thread.is_alive(): self._caen_thread_stop = True @@ -154,13 +172,13 @@ def _mqtt_loop(self): if self._thermalcamera: logger.debug("Starting Thermal Camera client loop") self._thermalcamera.loop_start() - + logger.info("All MQTT client loops started") - + # Keep thread running while not self._thread_stop: time.sleep(1) - + except Exception as e: logger.error(f"Error in MQTT loop: {e}") finally: @@ -184,21 +202,21 @@ def cleanup(self): try: # Stop MQTT thread first self.stop_mqtt_thread() - + # Stop individual client loops - if hasattr(self, '_martacoldroom') and self._martacoldroom: + if hasattr(self, "_martacoldroom") and self._martacoldroom: logger.debug("Stopping MARTA/Coldroom/Cleanroom client loops") self._martacoldroom.stop_client_loops() - if hasattr(self, '_thermalcamera') and self._thermalcamera: + if hasattr(self, "_thermalcamera") and self._thermalcamera: logger.debug("Stopping Thermal Camera client loop") self._thermalcamera.loop_start() - - if hasattr(self, '_caen') and self._caen: + + if hasattr(self, "_caen") and self._caen: logger.debug("Disconnecting CAEN TCP client") self._caen.disconnect() - + logger.info("All resources cleaned up") - + except Exception as e: logger.error(f"Error during cleanup: {e}") diff --git a/coldroom/thermal_camera.py b/coldroom/thermal_camera.py index da53dfb..3368939 100644 --- a/coldroom/thermal_camera.py +++ b/coldroom/thermal_camera.py @@ -36,7 +36,9 @@ def __init__(self, system_obj): try: self._client.connect(self._system.BROKER, self._system.PORT, keepalive=60) - logger.info(f"Connected to MQTT broker at {self._system.BROKER}:{self._system.PORT}") + logger.info( + f"Connected to MQTT broker at {self._system.BROKER}:{self._system.PORT}" + ) except Exception as e: logger.error(f"Failed to connect to MQTT broker: {e}") raise @@ -88,10 +90,15 @@ def handle_camera_message(self, topic, payload): position = float(data["position"]) # Convert to float array - flo_arr = [struct.unpack("f", image_data[i : i + 4])[0] for i in range(0, len(image_data), 4)] + flo_arr = [ + struct.unpack("f", image_data[i : i + 4])[0] + for i in range(0, len(image_data), 4) + ] # Process image - processed_image = np.flip(np.rot90(np.array(flo_arr).reshape(24, 32)), axis=0) + processed_image = np.flip( + np.rot90(np.array(flo_arr).reshape(24, 32)), axis=0 + ) # Store for stitching - accumulate images for each position if camera_name not in self._stitching_data: @@ -104,7 +111,9 @@ def handle_camera_message(self, topic, payload): # Keep only the last 5 images for each position to prevent memory issues if len(self._stitching_data[camera_name][position]) > 5: - self._stitching_data[camera_name][position] = self._stitching_data[camera_name][position][-5:] + self._stitching_data[camera_name][position] = self._stitching_data[ + camera_name + ][position][-5:] # Update current image self._images[camera_name] = processed_image diff --git a/coldroom/thermal_camera_gui.py b/coldroom/thermal_camera_gui.py index b4be869..ba83681 100644 --- a/coldroom/thermal_camera_gui.py +++ b/coldroom/thermal_camera_gui.py @@ -20,7 +20,9 @@ def __init__(self, system): # Load the UI file into a QMainWindow self.widget = QtWidgets.QMainWindow() - uic.loadUi(os.path.join(os.path.dirname(__file__), "thermal_camera.ui"), self.widget) + uic.loadUi( + os.path.join(os.path.dirname(__file__), "thermal_camera.ui"), self.widget + ) self.ui = self.widget # Assign the loaded UI to self.ui # Initialize camera positions from config file @@ -40,9 +42,11 @@ def __init__(self, system): self.update_camera_displays() # Set default temperature range to CO2 and connect signal - if hasattr(self.ui, 't_range_comboBox'): + if hasattr(self.ui, "t_range_comboBox"): self.ui.t_range_comboBox.setCurrentText("CO2") - self.ui.t_range_comboBox.currentTextChanged.connect(self.on_temperature_range_changed) + self.ui.t_range_comboBox.currentTextChanged.connect( + self.on_temperature_range_changed + ) # Setup update timer self.update_timer = QTimer() @@ -60,7 +64,9 @@ def __init__(self, system): def get_camera_modules_map(self): if self.mounted_modules is None: - logger.debug("Mounted modules not set, returning default camera modules map") + logger.debug( + "Mounted modules not set, returning default camera modules map" + ) return { "camera1": "Unknown", "camera2": "Unknown", @@ -81,7 +87,10 @@ def get_camera_modules_map(self): for camera_name, camera_offset in self.camera_positions.items(): camera_position = (absolute_position + camera_offset["position"]) % 360 module_slot = str(module_info.get("mounted_on", "-").split(";")[1]) - if abs(module_position - camera_position) <= 20 and module_side == camera_offset["side"]: + if ( + abs(module_position - camera_position) <= 20 + and module_side == camera_offset["side"] + ): camera_modules[camera_name] = f"{module_slot};{module_name}" close_camera = True break @@ -116,7 +125,9 @@ def load_camera_config(self): self.save_camera_config(default_config) return default_config else: - logger.info(f"Config file not found at {self.config_file}, creating with defaults") + logger.info( + f"Config file not found at {self.config_file}, creating with defaults" + ) # Save default config to file self.save_camera_config(default_config) return default_config @@ -180,22 +191,24 @@ def setup_camera_views(self): # Create matplotlib figure for individual cameras with adjusted size self.cameras_fig = Figure(figsize=(8, 5), dpi=100) # Increased width self.cameras_canvas = FigureCanvas(self.cameras_fig) - + # Create navigation toolbar for camera views self.cameras_toolbar = NavigationToolbar(self.cameras_canvas, self.widget) - + # Create a widget to hold both toolbar and canvas camera_widget = QtWidgets.QWidget() camera_layout = QtWidgets.QVBoxLayout(camera_widget) camera_layout.addWidget(self.cameras_toolbar) camera_layout.addWidget(self.cameras_canvas) camera_layout.setContentsMargins(0, 0, 0, 0) - + # Add the combined widget to the scene scene1.addWidget(camera_widget) # Create 2x2 grid for cameras with minimal spacing - self.cameras_axes = self.cameras_fig.subplots(2, 2, gridspec_kw={"hspace": 0.15, "wspace": 0.1}) + self.cameras_axes = self.cameras_fig.subplots( + 2, 2, gridspec_kw={"hspace": 0.15, "wspace": 0.1} + ) self.camera_images = [] # Initialize camera images with proper sizing and coordinate formatting @@ -203,7 +216,12 @@ def setup_camera_views(self): for i in range(2): for j in range(2): ax = self.cameras_axes[i, j] - img = ax.imshow(np.zeros((24, 32)), cmap="plasma", aspect="equal", interpolation="nearest") + img = ax.imshow( + np.zeros((24, 32)), + cmap="plasma", + aspect="equal", + interpolation="nearest", + ) self.camera_images.append(img) camera_index += 1 # Increment for each camera ax.set_title(f"Camera {camera_index}") @@ -211,14 +229,20 @@ def setup_camera_views(self): ax.set_yticks([]) # Set up coordinate formatter to show temperature values - ax.format_coord = lambda x, y, ax_ref=ax, img_ref=img: self.format_coord_temperature(x, y, ax_ref, img_ref) + ax.format_coord = lambda x, y, ax_ref=ax, img_ref=img: self.format_coord_temperature( + x, y, ax_ref, img_ref + ) # Add colorbar with proper sizing - cbar = self.cameras_fig.colorbar(img, ax=ax, fraction=0.046, pad=0.04) + cbar = self.cameras_fig.colorbar( + img, ax=ax, fraction=0.046, pad=0.04 + ) cbar.ax.tick_params(labelsize=8) # Adjust subplot parameters to remove excess whitespace - self.cameras_fig.subplots_adjust(left=0.05, right=0.95, top=0.95, bottom=0.05) + self.cameras_fig.subplots_adjust( + left=0.05, right=0.95, top=0.95, bottom=0.05 + ) logger.info("Camera views with navigation toolbar initialized") @@ -230,22 +254,22 @@ def format_coord_temperature(self, x, y, ax, img): try: # Get the image data data = img.get_array() - + # Convert mouse coordinates to array indices numrows, numcols = data.shape col = int(x + 0.5) row = int(y + 0.5) - + # Check if coordinates are within bounds if 0 <= col < numcols and 0 <= row < numrows: temp_value = data[row, col] - return f'x={col}, y={row}, T={temp_value:.1f}°C' + return f"x={col}, y={row}, T={temp_value:.1f}°C" else: - return f'x={x:.1f}, y={y:.1f}' - + return f"x={x:.1f}, y={y:.1f}" + except Exception as e: logger.error(f"Error formatting coordinates: {e}") - return f'x={x:.1f}, y={y:.1f}' + return f"x={x:.1f}, y={y:.1f}" def snapshot(self): """Save current camera views""" @@ -274,7 +298,7 @@ def enable_controls(self, enabled=True): self.ui.calibrate_PB, self.ui.set_abs_pos_PB, self.ui.export_abs_pos_PB, -# self.ui.get_frms_PB, + # self.ui.get_frms_PB, self.ui.relse_mtr_PB, self.ui.run_PB, self.ui.stop_PB, @@ -297,17 +321,25 @@ def connect_signals(self): self.ui.calibrate_PB.clicked.connect(self.calibrate) self.ui.set_abs_pos_PB.clicked.connect(self.set_absolute_position) self.ui.export_abs_pos_PB.clicked.connect(self.export_absolute_position) - # self.ui.get_frms_PB.clicked.connect(self.get_frames) + # self.ui.get_frms_PB.clicked.connect(self.get_frames) self.ui.relse_mtr_PB.clicked.connect(self.release_motor) self.ui.run_PB.clicked.connect(self.run) self.ui.stop_PB.clicked.connect(self.stop) self.ui.snapshot_PB.clicked.connect(self.snapshot) # Connect camera coordinate setting buttons - self.ui.camera_set_pos_button_1.clicked.connect(lambda: self.set_camera_position(1)) - self.ui.camera_set_pos_button_2.clicked.connect(lambda: self.set_camera_position(2)) - self.ui.camera_set_pos_button_3.clicked.connect(lambda: self.set_camera_position(3)) - self.ui.camera_set_pos_button_4.clicked.connect(lambda: self.set_camera_position(4)) + self.ui.camera_set_pos_button_1.clicked.connect( + lambda: self.set_camera_position(1) + ) + self.ui.camera_set_pos_button_2.clicked.connect( + lambda: self.set_camera_position(2) + ) + self.ui.camera_set_pos_button_3.clicked.connect( + lambda: self.set_camera_position(3) + ) + self.ui.camera_set_pos_button_4.clicked.connect( + lambda: self.set_camera_position(4) + ) logger.info("Thermal camera signals connected") except Exception as e: @@ -323,12 +355,16 @@ def set_camera_position(self, camera_id): try: new_position = float(pos_le.text()) except ValueError: - logger.error(f"Invalid position value for camera {camera_id}: {pos_le.text()}") + logger.error( + f"Invalid position value for camera {camera_id}: {pos_le.text()}" + ) return # Validate position range (0-360 degrees) if not (0 <= new_position <= 360): - logger.error(f"Position out of range for camera {camera_id}: {new_position}. Must be 0-360°") + logger.error( + f"Position out of range for camera {camera_id}: {new_position}. Must be 0-360°" + ) return # Update camera position offset in our data structure @@ -358,7 +394,9 @@ def update_camera_displays(self): camera_modules_map = self.get_camera_modules_map() logger.debug(f"Camera modules map: {camera_modules_map}") - for i, (camera_name, camera_info) in enumerate(self.camera_positions.items(), 1): + for i, (camera_name, camera_info) in enumerate( + self.camera_positions.items(), 1 + ): # Calculate effective position based on current system position and camera offset camera_offset = camera_info["position"] effective_position = (current_system_pos + camera_offset) % 360 @@ -404,7 +442,9 @@ def move_camera(self, camera_id, target_angle): absolute_position = absolute_position % 360 logger.info(f"Moving camera {camera_id} to target {target_angle}°") - logger.info(f"Camera offset: {camera_offset}°, calculated absolute position: {absolute_position}°") + logger.info( + f"Camera offset: {camera_offset}°, calculated absolute position: {absolute_position}°" + ) # Use the go_to function to move to the calculated absolute position if self.system._thermalcamera: @@ -414,7 +454,9 @@ def move_camera(self, camera_id, target_angle): # Wait a moment for the system to update (you might want to make this more robust) self._update_all_camera_positions_after_move(absolute_position) - logger.info(f"Camera {camera_id} moved to target position {target_angle}°") + logger.info( + f"Camera {camera_id} moved to target position {target_angle}°" + ) return True else: logger.error("Thermal camera system not available") @@ -473,7 +515,9 @@ def get_camera_effective_position(self, camera_id): logger.error(f"Invalid camera ID: {camera_id}") return None except Exception as e: - logger.error(f"Error calculating effective position for camera {camera_id}: {e}") + logger.error( + f"Error calculating effective position for camera {camera_id}: {e}" + ) return None def update_status(self): @@ -495,7 +539,9 @@ def update_status(self): "background-color: green;" if streaming else "background-color: red;" ) running = status.get("running", False) - self.ui.run_stat_flg.setStyleSheet("background-color: green;" if running else "background-color: red;") + self.ui.run_stat_flg.setStyleSheet( + "background-color: green;" if running else "background-color: red;" + ) switch_state = status.get("switch_state", False) self.ui.switch_state_flag.setStyleSheet( @@ -524,7 +570,9 @@ def update_camera_images_co2_scale(self): marta_status = self.system.status.get("marta", {}) co2_temp = float(marta_status.get("TT06_CO2", 0.0)) except (ValueError, TypeError): - logger.warning("Could not get CO2 temperature from MARTA status, using default range") + logger.warning( + "Could not get CO2 temperature from MARTA status, using default range" + ) co2_temp = 0.0 # Calculate colorbar range: CO2 temp to CO2 temp + 20 @@ -532,7 +580,9 @@ def update_camera_images_co2_scale(self): colorbar_max = co2_temp + 15.0 # Update individual camera views - for i, (camera_name, image_data) in enumerate(self.system._thermalcamera._images.items()): + for i, (camera_name, image_data) in enumerate( + self.system._thermalcamera._images.items() + ): if isinstance(image_data, np.ndarray): # Convert to float if needed if image_data.dtype != np.float64: @@ -552,7 +602,12 @@ def update_camera_images_co2_scale(self): cbar = self.camera_images[i].colorbar if cbar is not None: cbar.set_ticks(np.linspace(colorbar_min, colorbar_max, 5)) - cbar.set_ticklabels([f"{temp:.1f}°C" for temp in np.linspace(colorbar_min, colorbar_max, 5)]) + cbar.set_ticklabels( + [ + f"{temp:.1f}°C" + for temp in np.linspace(colorbar_min, colorbar_max, 5) + ] + ) # Draw camera canvas self.cameras_canvas.draw() @@ -568,7 +623,9 @@ def update_camera_images_auto_scale(self): """Update camera images using automatic scaling based on image data""" try: # Update individual camera views with auto-scaling - for i, (camera_name, image_data) in enumerate(self.system._thermalcamera._images.items()): + for i, (camera_name, image_data) in enumerate( + self.system._thermalcamera._images.items() + ): if isinstance(image_data, np.ndarray): # Convert to float if needed if image_data.dtype != np.float64: @@ -590,7 +647,9 @@ def update_camera_images_auto_scale(self): cbar = self.camera_images[i].colorbar if cbar is not None: cbar.set_ticks(np.linspace(vmin, vmax, 5)) - cbar.set_ticklabels([f"{temp:.1f}°C" for temp in np.linspace(vmin, vmax, 5)]) + cbar.set_ticklabels( + [f"{temp:.1f}°C" for temp in np.linspace(vmin, vmax, 5)] + ) # Draw camera canvas self.cameras_canvas.draw() @@ -608,10 +667,12 @@ def set_scaling_mode(self, use_co2_scale=True): """ try: self.use_co2_scaling = use_co2_scale - logger.info(f"Camera scaling mode set to: {'CO2 temperature range' if use_co2_scale else 'Auto-scale'}") + logger.info( + f"Camera scaling mode set to: {'CO2 temperature range' if use_co2_scale else 'Auto-scale'}" + ) # Update the ComboBox to reflect the change - if hasattr(self.ui, 't_range_comboBox'): + if hasattr(self.ui, "t_range_comboBox"): combo_text = "CO2" if use_co2_scale else "Auto" self.ui.t_range_comboBox.setCurrentText(combo_text) @@ -653,8 +714,14 @@ def rotate(self): try: angle = float(self.ui.ip_DAngle_LE.text()) if self.system._thermalcamera: - direction = "bw" if self.ui.direction_combo.currentText() == "Clockwise" else "fw" - self.system._thermalcamera.rotate({"angle": angle, "direction": direction}) + direction = ( + "bw" + if self.ui.direction_combo.currentText() == "Clockwise" + else "fw" + ) + self.system._thermalcamera.rotate( + {"angle": angle, "direction": direction} + ) logger.info(f"Rotating camera by {angle} degrees") except ValueError: logger.error("Invalid angle value") @@ -680,8 +747,14 @@ def calibrate(self): # Use ip_angle_LE_2 which contains the 90-degree angle input limit = float(self.ui.ip_angle_LE_2.text()) if self.system._thermalcamera: - direction = "bw" if self.ui.direction_combo.currentText() == "Clockwise" else "fw" - self.system._thermalcamera.calibrate({"prudence": limit, "direction": direction}) + direction = ( + "bw" + if self.ui.direction_combo.currentText() == "Clockwise" + else "fw" + ) + self.system._thermalcamera.calibrate( + {"prudence": limit, "direction": direction} + ) logger.info(f"Calibrating camera with limit {limit} degrees") self.update_camera_displays() except ValueError: diff --git a/db/module_db.py b/db/module_db.py index 41ca4f3..201a686 100644 --- a/db/module_db.py +++ b/db/module_db.py @@ -1,6 +1,14 @@ from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QLineEdit, QLabel, QTreeWidgetItem, - QMessageBox, QPushButton, QInputDialog, QHBoxLayout, QTreeWidget + QWidget, + QVBoxLayout, + QLineEdit, + QLabel, + QTreeWidgetItem, + QMessageBox, + QPushButton, + QInputDialog, + QHBoxLayout, + QTreeWidget, ) from PyQt5.QtCore import Qt, pyqtSignal import requests @@ -9,24 +17,25 @@ import re from db.module_db_gui import Ui_ModuleDBWidget + class ModuleDB(QWidget): """Widget for managing module inventory and details""" - + # Signals module_selected = pyqtSignal(str) # Emitted when module is selected - + def __init__(self, parent=None): super().__init__(parent) - + # Set up UI self.ui = Ui_ModuleDBWidget() self.ui.setupUi(self) - + # Initialize variables self.all_modules = [] self.mounted_modules = {} self.current_module_id = None - + # Add layer type mapping self.layers_to_filters = { "L1_47": { @@ -58,19 +67,19 @@ def __init__(self, parent=None): "speed": "5G", }, } - + # Setup UI components self.setup_filters() self.setup_tree_widget() self.setup_search() self.setup_module_details_tab() - + # Connect signals self.connect_signals() - + # Load settings self.load_settings() - + # Initial module list load self.update_module_list() @@ -80,10 +89,10 @@ def setup_filters(self): self.ui.layertypeCB.clear() self.ui.layertypeCB.addItem("any") self.ui.layertypeCB.addItems(sorted(self.layers_to_filters.keys())) - + # Add "any" to status filter - #self.ui.spacerCB_3.insertItem(0, "any") - + # self.ui.spacerCB_3.insertItem(0, "any") + # Add grade options self.ui.spacerCB_2.addItems(["A++", "A+", "A", "B", "C"]) @@ -91,15 +100,15 @@ def setup_tree_widget(self): """Setup tree widget properties""" # Enable sorting self.ui.treeWidget.setSortingEnabled(True) - + # Set column widths self.ui.treeWidget.setColumnWidth(0, 150) # Name self.ui.treeWidget.setColumnWidth(1, 70) # Inventory Slot - self.ui.treeWidget.setColumnWidth(2, 70) # Speed - self.ui.treeWidget.setColumnWidth(3, 70) # Spacer + self.ui.treeWidget.setColumnWidth(2, 70) # Speed + self.ui.treeWidget.setColumnWidth(3, 70) # Spacer self.ui.treeWidget.setColumnWidth(4, 100) # Status self.ui.treeWidget.setColumnWidth(5, 150) # Description - + # Enable selection mode self.ui.treeWidget.setSelectionMode(QTreeWidget.SingleSelection) @@ -111,7 +120,7 @@ def connect_signals(self): self.ui.editDetailsButton.clicked.connect(self.edit_selected_detail) self.ui.saveDetailsButton.clicked.connect(self.save_module_details) self.ui.disconnectButton.clicked.connect(self.disconnect_module) - + # Connect filter signals self.ui.speedCB.currentTextChanged.connect(self.update_module_list) self.ui.spacerCB.currentTextChanged.connect(self.update_module_list) @@ -121,8 +130,12 @@ def connect_signals(self): def get_settings_file(self): """Get the settings file path""" - config_file = os.path.join(os.path.expanduser("~/.config/module_db"), 'settings.yaml') - bundled_file = os.path.join(os.path.dirname(__file__), os.pardir, 'settings_integration.yaml') + config_file = os.path.join( + os.path.expanduser("~/.config/module_db"), "settings.yaml" + ) + bundled_file = os.path.join( + os.path.dirname(__file__), os.pardir, "settings_integration.yaml" + ) if os.path.exists(config_file): return config_file elif os.path.exists(bundled_file): @@ -133,147 +146,161 @@ def get_settings_file(self): def load_settings(self): """Load settings from YAML file""" try: - with open(self.get_settings_file(), 'r') as f: + with open(self.get_settings_file(), "r") as f: settings = yaml.safe_load(f) if settings: - self.db_url = settings.get('db_url', 'http://localhost:5000') + self.db_url = settings.get("db_url", "http://localhost:5000") except FileNotFoundError: - self.db_url = 'http://localhost:5000' + self.db_url = "http://localhost:5000" self.save_settings() def save_settings(self): """Save settings to YAML file""" - settings = { - 'db_url': self.db_url - } - with open(self.get_settings_file(), 'w') as f: + settings = {"db_url": self.db_url} + with open(self.get_settings_file(), "w") as f: yaml.dump(settings, f) - def get_api_url(self, endpoint=''): + def get_api_url(self, endpoint=""): """Get full API URL with endpoint""" - base_url = self.db_url.rstrip('/') + base_url = self.db_url.rstrip("/") return f"{base_url}/{endpoint.lstrip('/')}" if endpoint else base_url def setup_module_details_tab(self): """Setup the module details tab""" # Set up tree widget - self.ui.detailsTree.setHeaderLabels(['Field', 'Value']) + self.ui.detailsTree.setHeaderLabels(["Field", "Value"]) self.ui.detailsTree.setColumnWidth(0, 200) - def make_api_request(self, endpoint, method='GET', data=None): + def make_api_request(self, endpoint, method="GET", data=None): """Make API request with error handling""" try: url = self.get_api_url(endpoint) - if method == 'GET': + if method == "GET": response = requests.get(url) - elif method == 'POST': + elif method == "POST": response = requests.post(url, json=data) - elif method == 'PUT': + elif method == "PUT": response = requests.put(url, json=data) else: return False, f"Unsupported method: {method}" - + if response.status_code == 200: return True, response.json() else: return False, response.text - + except Exception as e: return False, str(e) def update_module_list(self): """Update the module list from database""" - success, modules = self.make_api_request('modules') + success, modules = self.make_api_request("modules") if not success: self.show_error_dialog(f"Failed to fetch modules: {modules}") return - + self.all_modules = modules self.filter_modules() def filter_modules(self, search_text=None): """Filter modules based on search text and all filters""" if search_text is None: - search_text = self.searchBox.text() - search_text=search_text - + search_text = self.searchBox.text() + search_text = search_text + self.ui.treeWidget.clear() - + for module in self.all_modules: print(module["moduleName"]) # Get module speed module_speed = "" - if "_5_" in module.get("moduleName", "") or "_05_" in module.get("moduleName", "") or "_5-" in module.get("moduleName", "") or "_05-" in module.get("moduleName", ""): + if ( + "_5_" in module.get("moduleName", "") + or "_05_" in module.get("moduleName", "") + or "_5-" in module.get("moduleName", "") + or "_05-" in module.get("moduleName", "") + ): module_speed = "5G" - if "_10_" in module.get("moduleName", "") or "_10-" in module.get("moduleName", ""): + if "_10_" in module.get("moduleName", "") or "_10-" in module.get( + "moduleName", "" + ): module_speed = "10G" - + # Check hybrid details for speed - if isinstance(module.get("children"),dict): - if isinstance(module.get("children").get("PS Read-out Hybrid"),dict): - if isinstance(module.get("children").get("PS Read-out Hybrid").get("details"),dict): - if module.get("children").get("PS Read-out Hybrid").get("details").get("ALPGBT_BANDWIDTH") is not None: - module_speed = module.get("children").get("PS Read-out Hybrid").get("details").get("ALPGBT_BANDWIDTH") + if isinstance(module.get("children"), dict): + if isinstance(module.get("children").get("PS Read-out Hybrid"), dict): + if isinstance( + module.get("children").get("PS Read-out Hybrid").get("details"), + dict, + ): + if ( + module.get("children") + .get("PS Read-out Hybrid") + .get("details") + .get("ALPGBT_BANDWIDTH") + is not None + ): + module_speed = ( + module.get("children") + .get("PS Read-out Hybrid") + .get("details") + .get("ALPGBT_BANDWIDTH") + ) if module_speed == "10Gbps": module_speed = "10G" if module_speed == "5Gbps": module_speed = "5G" - + module["speed"] = module_speed - + # Get spacer fields = module.get("moduleName", "").split("_") spacer = fields[1] if len(fields) > 2 else "" module["spacer"] = spacer - if not "Pisa" in module.get("details",{}).get("LOCATION",""): - continue + if not "Pisa" in module.get("details", {}).get("LOCATION", ""): + continue print("In Pisa") # Apply all filters - if search_text and search_text not in module.get('moduleName', ''): + if search_text and search_text not in module.get("moduleName", ""): continue print("search ok") - + speed = self.ui.speedCB.currentText() - if speed != 'any' and module_speed != speed: + if speed != "any" and module_speed != speed: continue print("Speed ok") spacer_filter = self.ui.spacerCB.currentText() - spacer_dict = { - "4.0mm": "40", - "2.6mm": "26", - "1.8mm": "18", - "any": "any" - } + spacer_dict = {"4.0mm": "40", "2.6mm": "26", "1.8mm": "18", "any": "any"} spacer_filter = spacer_dict[spacer_filter] - if spacer_filter != 'any' and spacer != spacer_filter: + if spacer_filter != "any" and spacer != spacer_filter: continue print("Spacer ok") - + grade = self.ui.spacerCB_2.currentText() - if grade != 'any' and module.get('grade') != grade: + if grade != "any" and module.get("grade") != grade: continue - + status = self.ui.spacerCB_3.currentText() - if status != 'any' and module.get('status') != status: + if status != "any" and module.get("status") != status: continue - + # Add matching module to tree item = QTreeWidgetItem(self.ui.treeWidget) item.setText(0, module.get("moduleName", "")) -# item.setText(1, module.get("inventorySlot", "")) - item.setText(1, module.get("position","")) + # item.setText(1, module.get("inventorySlot", "")) + item.setText(1, module.get("position", "")) item.setText(2, module.get("speed", "")) item.setText(3, str(module.get("spacer", ""))) - item.setText(4, module.get("status","")) + item.setText(4, module.get("status", "")) item.setText(6, module.get("details", {}).get("DESCRIPTION", "")) - + # Handle connections column connections = module.get("crateSide", {}) print(connections) - connections = [y[0] for x,y in connections.items() if len(y)>0] + connections = [y[0] for x, y in connections.items() if len(y) > 0] connections = list(set(connections)) # Make connections entries unique item.setText(5, "/".join(connections)) - + # Add disconnect button if there are connections if len(connections) > 0: # Create a widget to hold both connection info and button @@ -281,7 +308,7 @@ def filter_modules(self, search_text=None): layout = QVBoxLayout(container) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - + # Create button with connection info disconnect_button = QPushButton() # Make button smaller @@ -289,16 +316,20 @@ def filter_modules(self, search_text=None): font = disconnect_button.font() font.setPointSize(8) disconnect_button.setFont(font) - print("conn",connections) - button_text = "Disconnect "+ ("/".join(connections)) + print("conn", connections) + button_text = "Disconnect " + ("/".join(connections)) disconnect_button.setText(button_text) - disconnect_button.clicked.connect(lambda checked, m=module.get("moduleName", ""): self.disconnect_module(m)) - + disconnect_button.clicked.connect( + lambda checked, m=module.get( + "moduleName", "" + ): self.disconnect_module(m) + ) + layout.addWidget(disconnect_button) self.ui.treeWidget.setItemWidget(item, 5, container) - + item.setText(8, module.get("mounted_on", "")) - + # Resize columns to content for i in range(self.ui.treeWidget.columnCount()): self.ui.treeWidget.resizeColumnToContents(i) @@ -311,19 +342,19 @@ def populate_details_tree(self, data, parent=None, path=""): # Store original data structure for reference self.original_data_types = {} self.store_data_types(data, "") - + # Add disconnect button if module has connections or is mounted - if data.get('connections') or data.get('mounted_on'): + if data.get("connections") or data.get("mounted_on"): disconnect_item = QTreeWidgetItem(parent) disconnect_item.setText(0, "Actions") disconnect_button = QPushButton("Disconnect") disconnect_button.clicked.connect(self.disconnect_module) self.ui.detailsTree.setItemWidget(disconnect_item, 1, disconnect_button) - + if isinstance(data, dict): for key, value in sorted(data.items()): current_path = f"{path}.{key}" if path else key - item = QTreeWidgetItem([str(key), '']) + item = QTreeWidgetItem([str(key), ""]) # Store the path in the item for later reference item.setData(0, Qt.UserRole, current_path) if parent == self.ui.detailsTree: @@ -334,11 +365,13 @@ def populate_details_tree(self, data, parent=None, path=""): elif isinstance(data, list): for i, value in enumerate(data): current_path = f"{path}[{i}]" - if isinstance(value, dict) and 'childName' in value: + if isinstance(value, dict) and "childName" in value: # Special handling for child components - item = QTreeWidgetItem([value['childName'], value.get('childType', '')]) + item = QTreeWidgetItem( + [value["childName"], value.get("childType", "")] + ) else: - item = QTreeWidgetItem([f"[{i}]", '']) + item = QTreeWidgetItem([f"[{i}]", ""]) item.setData(0, Qt.UserRole, current_path) parent.addChild(item) self.populate_details_tree(value, item, current_path) @@ -349,12 +382,12 @@ def populate_details_tree(self, data, parent=None, path=""): def store_data_types(self, data, path): """Store the original data types for later reconstruction""" if isinstance(data, dict): - self.original_data_types[path] = 'dict' + self.original_data_types[path] = "dict" for key, value in data.items(): current_path = f"{path}.{key}" if path else key self.store_data_types(value, current_path) elif isinstance(data, list): - self.original_data_types[path] = 'list' + self.original_data_types[path] = "list" for i, value in enumerate(data): current_path = f"{path}[{i}]" self.store_data_types(value, current_path) @@ -382,16 +415,16 @@ def get_item_value(self, item, path): if item.childCount() == 0: # Leaf node - convert to appropriate type text_value = item.text(1) - original_type = self.original_data_types.get(path, 'str') - - if original_type == 'bool': - return text_value.lower() == 'true' - elif original_type == 'int': + original_type = self.original_data_types.get(path, "str") + + if original_type == "bool": + return text_value.lower() == "true" + elif original_type == "int": try: return int(text_value) except ValueError: return text_value - elif original_type == 'float': + elif original_type == "float": try: return float(text_value) except ValueError: @@ -400,9 +433,9 @@ def get_item_value(self, item, path): return text_value else: # Non-leaf node - check if it should be a list or dict - original_type = self.original_data_types.get(path, 'dict') - - if original_type == 'list': + original_type = self.original_data_types.get(path, "dict") + + if original_type == "list": # Reconstruct as list result = [] children_data = [] @@ -411,7 +444,7 @@ def get_item_value(self, item, path): child_path = child.data(0, Qt.UserRole) child_value = self.get_item_value(child, child_path) children_data.append((child_path, child_value)) - + # Sort by list index and add to result children_data.sort(key=lambda x: self.extract_list_index(x[0])) for _, value in children_data: @@ -431,17 +464,18 @@ def get_item_value(self, item, path): def extract_list_index(self, path): """Extract list index from path like 'children[0]' -> 0""" import re - match = re.search(r'\[(\d+)\]$', path) + + match = re.search(r"\[(\d+)\]$", path) return int(match.group(1)) if match else 0 def tree_to_dict(self, item): """Convert tree widget items back to dictionary recursively""" result = {} - + for i in range(item.childCount()): child = item.child(i) key = child.text(0) - + if child.childCount() > 0: # This is a non-leaf node, recurse value = self.tree_to_dict(child) @@ -450,33 +484,37 @@ def tree_to_dict(self, item): value = child.text(1) # Try to convert to appropriate type try: - if value.lower() == 'true': + if value.lower() == "true": value = True - elif value.lower() == 'false': + elif value.lower() == "false": value = False elif value.isdigit(): value = int(value) - elif value.replace('.','',1).isdigit(): + elif value.replace(".", "", 1).isdigit(): value = float(value) except: pass - + # Handle nested paths using dot notation - key_parts = key.split('.') + key_parts = key.split(".") current_dict = result for part in key_parts[:-1]: if part not in current_dict: current_dict[part] = {} current_dict = current_dict[part] current_dict[key_parts[-1]] = value - + return result def merge_dicts(self, dict1, dict2): """Recursively merge two dictionaries""" result = dict1.copy() for key, value in dict2.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): result[key] = self.merge_dicts(result[key], value) else: result[key] = value @@ -488,7 +526,7 @@ def select_module(self): if not selected_items: self.show_error_dialog("No module selected") return - + module_id = selected_items[0].text(0) self.module_selected.emit(module_id) @@ -498,7 +536,7 @@ def view_module_details(self): if not selected_items: self.show_error_dialog("No module selected") return - + module_id = selected_items[0].text(0) # Get module name self.current_module_id = module_id self.ui.moduleNameLabel.setText(module_id) @@ -510,10 +548,12 @@ def load_module_details(self): if not self.current_module_id: self.ui.moduleNameLabel.setText("") return - + try: - success, module_data = self.make_api_request(f'modules/{self.current_module_id}') - self.current_module_data=module_data + success, module_data = self.make_api_request( + f"modules/{self.current_module_id}" + ) + self.current_module_data = module_data if success: self.populate_details_tree(module_data) else: @@ -533,7 +573,7 @@ def edit_selected_detail(self): dialog.setWindowTitle("Edit Value") dialog.setLabelText(f"Edit value for {item.text(0)}:") dialog.setTextValue(item.text(1)) - + if dialog.exec_(): item.setText(1, dialog.textValue()) @@ -541,34 +581,38 @@ def save_module_details(self): """Save the modified module details back to the database""" try: # First get current module data to preserve all fields - success, current_data = self.make_api_request(f'modules/{self.current_module_id}') + success, current_data = self.make_api_request( + f"modules/{self.current_module_id}" + ) if not success: self.show_error_dialog(f"Error fetching module: {current_data}") return - + # Convert tree widget back to dictionary preserving types - new_data = self.tree_to_dict_preserving_types(self.ui.detailsTree.invisibleRootItem()) - + new_data = self.tree_to_dict_preserving_types( + self.ui.detailsTree.invisibleRootItem() + ) + # Recursively merge the data merged_data = self.merge_dicts(current_data, new_data) - + # Remove _id from merged data if "_id" in merged_data: del merged_data["_id"] - + # Make API request to update module success, result = self.make_api_request( - endpoint=f'modules/{self.current_module_id}', - method='PUT', - data=merged_data + endpoint=f"modules/{self.current_module_id}", + method="PUT", + data=merged_data, ) - + if success: self.show_info_dialog("Module details updated successfully") self.update_module_list() # Refresh the module list to show updated data else: self.show_error_dialog(f"Error updating module: {result}") - + except Exception as e: self.show_error_dialog(f"Error saving module details: {str(e)}") @@ -582,7 +626,7 @@ def update_filters_from_layer(self, layer_type): self.ui.speedCB.setCurrentText("any") self.ui.spacerCB.setCurrentText("any") return - + if layer_type in self.layers_to_filters: filters = self.layers_to_filters[layer_type] self.ui.speedCB.setCurrentText(filters["speed"]) @@ -597,7 +641,7 @@ def setup_search(self): searchLayout.addWidget(searchLabel) searchLayout.addWidget(self.searchBox) searchLayout.addStretch() - + # Insert search layout before the tree widget layout = self.ui.tab_2.layout() if layout: @@ -608,28 +652,26 @@ def setup_search(self): layout = QVBoxLayout(self.ui.tab_2) layout.addLayout(searchLayout) - def disconnect_module(self,m): + def disconnect_module(self, m): """Disconnect the current module""" - + try: data = { - "cable": m, - } + "cable": m, + } success, result = self.make_api_request( - endpoint="disconnect_all_crateSide", - method="POST", - data=data - ) - + endpoint="disconnect_all_crateSide", method="POST", data=data + ) + if success: self.show_info_dialog("Module disconnected successfully") self.update_module_list() # Refresh the module list self.load_module_details() # Refresh the details view else: self.show_error_dialog(f"Error disconnecting module: {result}") - + except Exception as e: self.show_error_dialog(f"Error disconnecting module: {str(e)}") - # ... Copy other methods from main.py ... + # ... Copy other methods from main.py ... diff --git a/db/module_db_gui.py b/db/module_db_gui.py index 39a4026..3cf9368 100644 --- a/db/module_db_gui.py +++ b/db/module_db_gui.py @@ -79,7 +79,9 @@ def setupUi(self, ModuleDBWidget): self.selectModulePB = QtWidgets.QPushButton(self.tab_2) self.selectModulePB.setObjectName("selectModulePB") self.horizontalLayout_10.addWidget(self.selectModulePB) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem = QtWidgets.QSpacerItem( + 40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum + ) self.horizontalLayout_10.addItem(spacerItem) self.viewDetailsPB = QtWidgets.QPushButton(self.tab_2) self.viewDetailsPB.setObjectName("viewDetailsPB") @@ -141,23 +143,39 @@ def retranslateUi(self, ModuleDBWidget): self.spacerCB_2.setItemText(0, _translate("ModuleDBWidget", "any")) self.label_11.setText(_translate("ModuleDBWidget", "Status:")) self.spacerCB_3.setItemText(0, _translate("ModuleDBWidget", "any")) - self.spacerCB_3.setItemText(1, _translate("ModuleDBWidget", "Ready For Mounting")) + self.spacerCB_3.setItemText( + 1, _translate("ModuleDBWidget", "Ready For Mounting") + ) self.spacerCB_3.setItemText(2, _translate("ModuleDBWidget", "Mounted")) self.spacerCB_3.setItemText(3, _translate("ModuleDBWidget", "To Be Tested")) self.treeWidget.headerItem().setText(0, _translate("ModuleDBWidget", "Name")) - self.treeWidget.headerItem().setText(1, _translate("ModuleDBWidget", "Inventory Slot")) + self.treeWidget.headerItem().setText( + 1, _translate("ModuleDBWidget", "Inventory Slot") + ) self.treeWidget.headerItem().setText(2, _translate("ModuleDBWidget", "Speed")) self.treeWidget.headerItem().setText(3, _translate("ModuleDBWidget", "Spacer")) self.treeWidget.headerItem().setText(4, _translate("ModuleDBWidget", "Status")) - self.treeWidget.headerItem().setText(5, _translate("ModuleDBWidget", "Connections")) - self.treeWidget.headerItem().setText(6, _translate("ModuleDBWidget", "Description")) - self.treeWidget.headerItem().setText(7, _translate("ModuleDBWidget", "Mounted_on")) + self.treeWidget.headerItem().setText( + 5, _translate("ModuleDBWidget", "Connections") + ) + self.treeWidget.headerItem().setText( + 6, _translate("ModuleDBWidget", "Description") + ) + self.treeWidget.headerItem().setText( + 7, _translate("ModuleDBWidget", "Mounted_on") + ) self.selectModulePB.setText(_translate("ModuleDBWidget", "<-- Select Module")) self.viewDetailsPB.setText(_translate("ModuleDBWidget", "View Details -->")) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_2), _translate("ModuleDBWidget", "Module Inventory")) + self.tabWidget.setTabText( + self.tabWidget.indexOf(self.tab_2), + _translate("ModuleDBWidget", "Module Inventory"), + ) self.detailsTree.headerItem().setText(0, _translate("ModuleDBWidget", "Field")) self.detailsTree.headerItem().setText(1, _translate("ModuleDBWidget", "Value")) self.editDetailsButton.setText(_translate("ModuleDBWidget", "Edit Selected")) self.saveDetailsButton.setText(_translate("ModuleDBWidget", "Save Changes")) self.disconnectButton.setText(_translate("ModuleDBWidget", "Disconnect")) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.moduleDetailsTab), _translate("ModuleDBWidget", "Module Details")) + self.tabWidget.setTabText( + self.tabWidget.indexOf(self.moduleDetailsTab), + _translate("ModuleDBWidget", "Module Details"), + ) diff --git a/db/utils.py b/db/utils.py index d1cd1f7..f5d7d5c 100644 --- a/db/utils.py +++ b/db/utils.py @@ -4,15 +4,17 @@ def get_module_name_from_fc7(fc7, optical_group, db_url="http://cmslabserver:5000"): """Get the module name from the FC7 and optical group.""" url = f"{db_url}/snapshot" - response = requests.post(url, json={"cable": fc7, "port": optical_group, "side": "detSide"}) + response = requests.post( + url, json={"cable": fc7, "port": optical_group, "side": "detSide"} + ) if response.status_code == 200: snapshot = response.json() for line in snapshot: if snapshot[line]["connections"]: last_conn = snapshot[line]["connections"][-1] - #{'cable': 'PS_40_IBA-10003', 'line': 1, 'det_port': [], 'crate_port': ['fiber']} - #{'cable': 'PS_40_IBA-10003', 'line': 2, 'det_port': [], 'crate_port': ['fiber']} - if 'fiber' in last_conn["crate_port"]: + # {'cable': 'PS_40_IBA-10003', 'line': 1, 'det_port': [], 'crate_port': ['fiber']} + # {'cable': 'PS_40_IBA-10003', 'line': 2, 'det_port': [], 'crate_port': ['fiber']} + if "fiber" in last_conn["crate_port"]: return last_conn["cable"] return None @@ -20,32 +22,36 @@ def get_module_name_from_fc7(fc7, optical_group, db_url="http://cmslabserver:500 def get_ring_from_cable(cable_id, db_url="http://cmslabserver:5000"): """Navigate from cable to modules, then check the mounted_on attribute removing the ;position trailing part""" url = f"{db_url}/snapshot" - modules=[] + modules = [] response = requests.post(url, json={"cable": cable_id, "side": "detSide"}) - + if response.status_code == 200: snapshot = response.json() - # print(snapshot) + # print(snapshot) for line in snapshot: if snapshot[line]["connections"]: last_conn = snapshot[line]["connections"][-1] - #{'cable': 'PS_40_IBA-10003', 'line': 1, 'det_port': [], 'crate_port': ['fiber']} - #{'cable': 'PS_40_IBA-10003', 'line': 2, 'det_port': [], 'crate_port': ['fiber']} - if 'power' in last_conn["crate_port"] or 'fiber' in last_conn["crate_port"]: + # {'cable': 'PS_40_IBA-10003', 'line': 1, 'det_port': [], 'crate_port': ['fiber']} + # {'cable': 'PS_40_IBA-10003', 'line': 2, 'det_port': [], 'crate_port': ['fiber']} + if ( + "power" in last_conn["crate_port"] + or "fiber" in last_conn["crate_port"] + ): modules.append(last_conn["cable"]) - - rings=[] + + rings = [] for m in modules: m_info = get_module(m, db_url) if m_info: mounted_on = m_info.get("mounted_on", "") - if mounted_on == "" : + if mounted_on == "": continue if ";" in mounted_on: mounted_on = mounted_on.split(";")[0] rings.append(mounted_on) return rings[0] if len(rings) else None + def get_modules_on_ring(ring_id, db_url="http://cmslabserver:5000"): """Get the modules on a specific ring from the database snapshot.""" url = f"{db_url}/generic_module_query" @@ -66,27 +72,28 @@ def get_modules_on_ring(ring_id, db_url="http://cmslabserver:5000"): return None - -def get_module_from_lpGBT_hwId(serial_number, db_url ="http://cmslabserver:5000"): +def get_module_from_lpGBT_hwId(serial_number, db_url="http://cmslabserver:5000"): """Get the module from the lpGBT serial number using generic_query""" url = f"{db_url}/generic_module_query" try: - response = requests.post(url, json= {"children.lpGBT.CHILD_SERIAL_NUMBER": str(serial_number)}) + response = requests.post( + url, json={"children.lpGBT.CHILD_SERIAL_NUMBER": str(serial_number)} + ) if response.status_code == 200: - snapshot = response.json() + snapshot = response.json() for module in snapshot: - if str(serial_number) in module.get("children", {}).get("lpGBT", {}).get("CHILD_SERIAL_NUMBER", ""): + if str(serial_number) in module.get("children", {}).get( + "lpGBT", {} + ).get("CHILD_SERIAL_NUMBER", ""): return module["moduleName"] - + else: print(f"Error: Received status code {response.status_code}") return None except requests.RequestException as e: print(f"Error making request to {url}: {str(e)}") return None - - def get_module(module_id, db_url="http://cmslabserver:5000"): @@ -104,6 +111,7 @@ def get_module(module_id, db_url="http://cmslabserver:5000"): print(f"Error making request to {url}: {str(e)}") return None + def get_module_lpgbtVersion(module, db_url="http://cmslabserver:5000"): # the version is under childer, PS Read-out Hybrid, details, ALPGBT_VERSION if isinstance(module, str): @@ -114,11 +122,25 @@ def get_module_lpgbtVersion(module, db_url="http://cmslabserver:5000"): lpgbtVersion = "N/A" if isinstance(module.get("children"), dict): if isinstance(module.get("children").get("PS Read-out Hybrid"), dict): - if isinstance(module.get("children").get("PS Read-out Hybrid").get("details"), dict): - if module.get("children").get("PS Read-out Hybrid").get("details").get("ALPGBT_VERSION") is not None: - lpgbtVersion = module.get("children").get("PS Read-out Hybrid").get("details").get("ALPGBT_VERSION") + if isinstance( + module.get("children").get("PS Read-out Hybrid").get("details"), dict + ): + if ( + module.get("children") + .get("PS Read-out Hybrid") + .get("details") + .get("ALPGBT_VERSION") + is not None + ): + lpgbtVersion = ( + module.get("children") + .get("PS Read-out Hybrid") + .get("details") + .get("ALPGBT_VERSION") + ) return lpgbtVersion + def get_module_speed(module, db_url="http://cmslabserver:5000"): # check if the name or the object is given if isinstance(module, str): @@ -139,10 +161,21 @@ def get_module_speed(module, db_url="http://cmslabserver:5000"): # Check hybrid details for speed if isinstance(module.get("children"), dict): if isinstance(module.get("children").get("PS Read-out Hybrid"), dict): - if isinstance(module.get("children").get("PS Read-out Hybrid").get("details"), dict): - if module.get("children").get("PS Read-out Hybrid").get("details").get("ALPGBT_BANDWIDTH") is not None: + if isinstance( + module.get("children").get("PS Read-out Hybrid").get("details"), dict + ): + if ( + module.get("children") + .get("PS Read-out Hybrid") + .get("details") + .get("ALPGBT_BANDWIDTH") + is not None + ): module_speed = ( - module.get("children").get("PS Read-out Hybrid").get("details").get("ALPGBT_BANDWIDTH") + module.get("children") + .get("PS Read-out Hybrid") + .get("details") + .get("ALPGBT_BANDWIDTH") ) if module_speed == "10Gbps": module_speed = "10G" @@ -169,7 +202,9 @@ def get_module_endpoints(module_id, db_url="http://cmslabserver:5000"): elif "XSLOT" in last_conn["cable"]: ret["LV"] = f"LV{last_conn['cable'][5:]}.{last_conn['line']}" elif "ASLOT" in last_conn["cable"]: - ret["HV"] = f"HV{last_conn['cable'][5:]}.{last_conn['det_port'][0]}" + ret["HV"] = ( + f"HV{last_conn['cable'][5:]}.{last_conn['det_port'][0]}" + ) return ret else: print(f"Error: Received status code {response.status_code}") @@ -207,9 +242,12 @@ def get_module_fuse_id(module, db_url="http://cmslabserver:5000"): if modules: # iterate on modules and print the module name and speed and mounted on, sorted by mounted_on for module_name, module in sorted( - modules.items(), key=lambda x: int(x[1].get("mounted_on", ";0").split(";")[1]) + modules.items(), + key=lambda x: int(x[1].get("mounted_on", ";0").split(";")[1]), ): speed = get_module_speed(module) - print(f"Speed: {speed} \t {module.get('mounted_on', 'N/A')} \t {module_name}") + print( + f"Speed: {speed} \t {module.get('mounted_on', 'N/A')} \t {module_name}" + ) else: print(f"Failed to retrieve modules for ring {ring_id}.") diff --git a/dbbrowser.py b/dbbrowser.py index 079b174..b036a37 100755 --- a/dbbrowser.py +++ b/dbbrowser.py @@ -3,23 +3,26 @@ from PyQt5.QtWidgets import QApplication, QMainWindow from db.module_db import ModuleDB + class DBMainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Module Database") - + # Create and set the module DB widget as central widget self.module_db = ModuleDB() self.setCentralWidget(self.module_db) - + # Set initial size self.resize(1000, 800) + def main(): app = QApplication(sys.argv) window = DBMainWindow() window.show() sys.exit(app.exec_()) -if __name__ == '__main__': - main() + +if __name__ == "__main__": + main() diff --git a/dbrunsearch.py b/dbrunsearch.py index 536cf04..cde6db2 100755 --- a/dbrunsearch.py +++ b/dbrunsearch.py @@ -4,6 +4,7 @@ import json from datetime import datetime + def get_run_details(run_number, base_url="http://cmslabserver:5000"): """Get details for a specific run number.""" try: @@ -12,13 +13,14 @@ def get_run_details(run_number, base_url="http://cmslabserver:5000"): if response.status_code != 200: print(f"Error fetching run details: {response.status_code}") return None - + return response.json() - + except requests.RequestException as e: print(f"Error connecting to database: {e}") return None + def get_session_details(session_name, base_url="http://cmslabserver:5000"): """Get detailed information for a specific session.""" try: @@ -26,13 +28,14 @@ def get_session_details(session_name, base_url="http://cmslabserver:5000"): if response.status_code != 200: print(f"Error fetching session details: {response.status_code}") return None - + return response.json() - + except requests.RequestException as e: print(f"Error fetching session details: {e}") return None + def format_run_details(run_data): """Format run details for display.""" output = [] @@ -46,20 +49,21 @@ def format_run_details(run_data): output.append(f"Type: {run_data.get('runType', 'N/A')}") output.append(f"Module Tests: {', '.join(run_data.get('moduleTestName', []))}") output.append(f"Run File: {run_data.get('runFile', 'N/A')}") - + # Format run configuration if present - if 'runConfiguration' in run_data: + if "runConfiguration" in run_data: output.append("\nRun Configuration:") - config = run_data['runConfiguration'] + config = run_data["runConfiguration"] output.append(f" Events: {config.get('Nevents', 'N/A')}") - if 'boards' in config: - for board_id, board_data in config['boards'].items(): + if "boards" in config: + for board_id, board_data in config["boards"].items(): output.append(f" Board {board_id}:") output.append(f" IP: {board_data.get('ip', 'N/A')}") - + output.append("=" * 80) return "\n".join(output) + def format_session_details(session): """Format session details for display.""" output = [] @@ -70,72 +74,97 @@ def format_session_details(session): output.append(f"Timestamp: {session.get('timestamp', 'N/A')}") output.append(f"Description: {session.get('description', 'N/A')}") output.append(f"Modules List: {', '.join(session.get('modulesList', []))}") - + # Add any additional details that might be present for key, value in session.items(): - if key not in ['sessionName', 'operator', 'timestamp', 'description', 'modulesList']: + if key not in [ + "sessionName", + "operator", + "timestamp", + "description", + "modulesList", + ]: output.append(f"{key}: {value}") - + output.append("=" * 80) return "\n".join(output) -def update_session_comment(session_name, new_comment, base_url="http://cmslabserver:5000"): + +def update_session_comment( + session_name, new_comment, base_url="http://cmslabserver:5000" +): """Update the description/comment for a specific session.""" try: # Prepare the update data update_data = {"description": new_comment} - + # Send PUT request to update the session response = requests.put(f"{base_url}/sessions/{session_name}", json=update_data) - + if response.status_code != 200: print(f"Error updating session comment: {response.status_code}") return False - + return True - + except requests.RequestException as e: print(f"Error updating session comment: {e}") return False + def main(): - parser = argparse.ArgumentParser(description='Search for run and session details by run number') - parser.add_argument('run_number', type=str, help='Run number to search for') - parser.add_argument('--url', default='http://cmslabserver:5000', - help='Base URL for the database API') - parser.add_argument('--edit-comment', nargs='?', const=True, metavar='COMMENT', - help='Edit the session comment. If COMMENT is provided, updates directly. If no COMMENT, enters interactive mode') - + parser = argparse.ArgumentParser( + description="Search for run and session details by run number" + ) + parser.add_argument("run_number", type=str, help="Run number to search for") + parser.add_argument( + "--url", + default="http://cmslabserver:5000", + help="Base URL for the database API", + ) + parser.add_argument( + "--edit-comment", + nargs="?", + const=True, + metavar="COMMENT", + help="Edit the session comment. If COMMENT is provided, updates directly. If no COMMENT, enters interactive mode", + ) + args = parser.parse_args() - + # Get run details run_data = get_run_details(args.run_number, args.url) - + if not run_data: print(f"No run found with number {args.run_number}") return - + # Print run details print(format_run_details(run_data)) - + # Get and display session details if available - session_name = run_data.get('runSession') + session_name = run_data.get("runSession") if session_name: session_details = get_session_details(session_name, args.url) if session_details: print(format_session_details(session_details)) - + # Handle comment editing if requested if args.edit_comment is not None: - print("\nCurrent comment:", session_details.get('description', 'No comment')) - + print( + "\nCurrent comment:", + session_details.get("description", "No comment"), + ) + # If comment was provided via command line, use it directly if isinstance(args.edit_comment, str): new_comment = args.edit_comment else: # Otherwise enter interactive mode - new_comment = input("Enter new comment (press Enter to keep current): ").strip() - + new_comment = input( + "Enter new comment (press Enter to keep current): " + ).strip() + if new_comment: if update_session_comment(session_name, new_comment, args.url): print("Comment updated successfully!") @@ -151,6 +180,6 @@ def main(): else: print("\nNo session information available for this run") + if __name__ == "__main__": main() - diff --git a/drawEvent.py b/drawEvent.py index b3185d6..b198a9e 100644 --- a/drawEvent.py +++ b/drawEvent.py @@ -6,6 +6,7 @@ layer_spacers = {0: 26, 1: 40, 2: 40, 3: 40, 4: 26} + def get_xy_strip(hybrid_id, address, fe_id): # Logic from drawEvent.C: # X: -int(SCluster_HybridId/2)*500+spacer @@ -20,6 +21,7 @@ def get_xy_strip(hybrid_id, address, fe_id): y = 960 - y_val return x, y + def get_xy_pixel(hybrid_id, address, fe_id): # Logic from drawEvent.C: # X: -int(PCluster_HybridId/2)*500 @@ -32,6 +34,7 @@ def get_xy_pixel(hybrid_id, address, fe_id): y = 960 - y_val return x, y + def get_xy_stub(hybrid_id, column, fe_id): # Logic from drawEvent.C: # X: -int(Stub_HybridId/2)*500+spacer/2 @@ -46,6 +49,7 @@ def get_xy_stub(hybrid_id, column, fe_id): y = 960 - y_val return x, y + def get_z_pixel(hybrid_id, zpos): # Logic from drawEvent.C: # (PCluster_HybridId%2==0)?PCluster_Zpos:32-PCluster_Zpos @@ -56,6 +60,7 @@ def get_z_pixel(hybrid_id, zpos): z = 32 - zpos return z * 10.0 + def get_z_stub(hybrid_id, row): # Logic from drawEvent.C: # (Stub_HybridId%2==0)?Stub_Row:32-Stub_Row @@ -66,37 +71,44 @@ def get_z_stub(hybrid_id, row): z = 32 - row return z * 10.0 + def draw_event(tree, entry, canvas): tree.GetEntry(entry) - + canvas.Clear() canvas.Divide(2, 1) - + # --- Pad 1: 3D View (Left) --- pad_3d = canvas.cd(1) - #resize it to leave top 10% free + # resize it to leave top 10% free pad_3d.SetPad(0.0, 0.0, 0.5, 0.9) # Create TView3D view = ROOT.TView.CreateView(1) - + # Center of the 5 modules # Modules at 0, -500, -1000, -1500, -2000 center_real_y = -1000.0 - center_real_x = 480.0 # Middle of 0-960 - center_real_z = 160.0 # Middle of 0-320 - + center_real_x = 480.0 # Middle of 0-960 + center_real_z = 160.0 # Middle of 0-320 + # Mapping: (Real X, Real Z, Real Y) -> (3D X, 3D Y, 3D Z) center_x = center_real_x center_y = center_real_z center_z = center_real_y - + # Define a view range centered on this point - range_dx = 500 # +/- 500 (Real X width is 960) + range_dx = 500 # +/- 500 (Real X width is 960) range_dy = 300 # +/- 250 (Real Z height is 320) - range_dz = 1100 # +/- 1500 (Real Y height is ~2200) - - view.SetRange(center_x - range_dx, center_y - range_dy, center_z - range_dz, - center_x + range_dx, center_y + range_dy, center_z + range_dz) + range_dz = 1100 # +/- 1500 (Real Y height is ~2200) + + view.SetRange( + center_x - range_dx, + center_y - range_dy, + center_z - range_dz, + center_x + range_dx, + center_y + range_dy, + center_z + range_dz, + ) view.ShowAxis() # Store primitives to prevent garbage collection @@ -108,7 +120,7 @@ def draw_box_real(x1, z1, y1, x2, z2, y2, color): # 3D X = Real X # 3D Y = Real Z # 3D Z = Real Y - + # Corners p0 = (x1, z1, y1) p1 = (x2, z1, y1) @@ -118,7 +130,7 @@ def draw_box_real(x1, z1, y1, x2, z2, y2, color): p5 = (x2, z1, y2) p6 = (x2, z2, y2) p7 = (x1, z2, y2) - + # Draw edges # Bottom face l = ROOT.TPolyLine3D(5) @@ -130,7 +142,7 @@ def draw_box_real(x1, z1, y1, x2, z2, y2, color): l.SetLineColor(color) l.Draw() canvas.primitives.append(l) - + # Top face l2 = ROOT.TPolyLine3D(5) l2.SetPoint(0, p4[0], p4[1], p4[2]) @@ -141,7 +153,7 @@ def draw_box_real(x1, z1, y1, x2, z2, y2, color): l2.SetLineColor(color) l2.Draw() canvas.primitives.append(l2) - + # Verticals for pa, pb in [(p0, p4), (p1, p5), (p2, p6), (p3, p7)]: l3 = ROOT.TPolyLine3D(2) @@ -156,14 +168,16 @@ def draw_box_real(x1, z1, y1, x2, z2, y2, color): # Base Real Y for this module real_y = float(-i * 500) spacer = layer_spacers.get(i, 40) - + # Draw module frame (Real X: 0-960, Real Z: 0-320, Thickness: 3?) # User said "draw also the modules without hits". # Let's draw them as thin frames or boxes. draw_box_real(0.0, 0.0, real_y, 960.0, 320.0, real_y + 3.0, ROOT.kGray) - + # Strips part (offset spacer in Real Y) - draw_box_real(0.0, 0.0, real_y + spacer, 960.0, 320.0, real_y + spacer + 3.0, ROOT.kGray) + draw_box_real( + 0.0, 0.0, real_y + spacer, 960.0, 320.0, real_y + spacer + 3.0, ROOT.kGray + ) # Helper to safely get list/array def get_collection(obj, name): @@ -179,16 +193,16 @@ def get_collection(obj, name): s_addr = get_collection(tree, "SCluster_Address") s_fe = get_collection(tree, "SCluster_FrontEndId") s_width = get_collection(tree, "SCluster_Width") - + n_strips = len(s_hids) for i in range(n_strips): hid = int(s_hids[i]) addr = int(s_addr[i]) fe = int(s_fe[i]) width = float(s_width[i]) if s_width else 1.0 - + real_y, real_x = get_xy_strip(hid, addr, fe) - + # Determine Real Z range based on parity if hid % 2 == 0: real_z_start = 0.0 @@ -210,17 +224,25 @@ def get_collection(obj, name): # `real_x - width` to `real_x`. real_x_start = real_x - width real_x_end = real_x - + # Draw Box # Thickness 3 - draw_box_real(real_x_start, real_z_start, real_y, real_x_end, real_z_end, real_y + 3.0, ROOT.kRed) - + draw_box_real( + real_x_start, + real_z_start, + real_y, + real_x_end, + real_z_end, + real_y + 3.0, + ROOT.kRed, + ) + # Draw Pixels as Boxes p_addr = get_collection(tree, "PCluster_Address") p_fe = get_collection(tree, "PCluster_FrontEndId") p_zpos = get_collection(tree, "PCluster_Zpos") p_width = get_collection(tree, "PCluster_Width") - + n_pixels = len(p_hids) for i in range(n_pixels): hid = int(p_hids[i]) @@ -228,18 +250,18 @@ def get_collection(obj, name): fe = int(p_fe[i]) z_raw = float(p_zpos[i]) width = float(p_width[i]) if p_width else 1.0 - + real_y, real_x = get_xy_pixel(hid, addr, fe) real_z = get_z_pixel(hid, z_raw) - + # Pixel Z Length = 10 units (1 pitch) # Parity handling for X and Z directions - + if hid % 2 == 0: # Even real_x_start = real_x real_x_end = real_x + width - + # Z: Zpos -> Zpos*10. # Covers Zpos to Zpos+1 -> Z*10 to (Z+1)*10. real_z_start = real_z @@ -248,7 +270,7 @@ def get_collection(obj, name): # Odd real_x_start = real_x - width real_x_end = real_x - + # Z: 32 - Zpos -> (32-Zpos)*10. # If Zpos increases, Real Z decreases. # Covers Zpos to Zpos+1. @@ -256,30 +278,38 @@ def get_collection(obj, name): # (32-Zpos)*10 - 10 to (32-Zpos)*10. real_z_start = real_z - 10.0 real_z_end = real_z - - draw_box_real(real_x_start, real_z_start, real_y, real_x_end, real_z_end, real_y + 3.0, ROOT.kBlue) - + + draw_box_real( + real_x_start, + real_z_start, + real_y, + real_x_end, + real_z_end, + real_y + 3.0, + ROOT.kBlue, + ) + # Draw Stubs as Points (Removed in favor of lines) # st_col = get_collection(tree, "Stub_Column") # st_fe = get_collection(tree, "Stub_FrontEndId") # st_row = get_collection(tree, "Stub_Row") - # + # # n_stubs = len(st_hids) # if n_stubs > 0: # pm_stub = ROOT.TPolyMarker3D(n_stubs) # pm_stub.SetMarkerStyle(22) # pm_stub.SetMarkerColor(ROOT.kGreen + 2) # pm_stub.SetMarkerSize(1.2) - # + # # for i in range(n_stubs): # hid = int(st_hids[i]) # col = float(st_col[i]) # fe = int(st_fe[i]) # row = float(st_row[i]) - # + # # real_y, real_x = get_xy_stub(hid, col, fe) # real_z = get_z_stub(hid, row) - # + # # # Mapping: (Real X, Real Z, Real Y) # pm_stub.SetPoint(i, real_x, real_z, real_y) # pm_stub.Draw() @@ -289,21 +319,21 @@ def get_collection(obj, name): st_fe = get_collection(tree, "Stub_FrontEndId") st_row = get_collection(tree, "Stub_Row") st_bend = get_collection(tree, "Stub_Bend") - + # Lookup table for bending # Indices 5-13 map to offsets bend_lut = { - 5: -4.0, # DM8 - 6: -3.5, # M76 - 7: -2.5, # M54 - 8: -1.5, # M32 - 9: -0.5, # M10 - 10: 0.5, # P12 - 11: 1.5, # P34 - 12: 2.5, # P56 - 13: 3.5 # P78 + 5: -4.0, # DM8 + 6: -3.5, # M76 + 7: -2.5, # M54 + 8: -1.5, # M32 + 9: -0.5, # M10 + 10: 0.5, # P12 + 11: 1.5, # P34 + 12: 2.5, # P56 + 13: 3.5, # P78 } - + n_stubs = len(st_hids) print(f"Drawing {n_stubs} stubs") for i in range(n_stubs): @@ -312,32 +342,32 @@ def get_collection(obj, name): fe = int(st_fe[i]) row = float(st_row[i]) bend_code = int(st_bend[i]) if st_bend else 0 - #take only first 3 bits + # take only first 3 bits bend_code = bend_code & 0x7 offset = bend_lut.get(bend_code, 0.0) print(f"Bend code: {bend_code}, offset: {offset}") - #flip sign based on hybrid parity + # flip sign based on hybrid parity if hid % 2 == 1: offset = -offset real_y, real_x = get_xy_stub(hid, col, fe) real_z = get_z_stub(hid, row) - + # Stub connects Pixel Layer (Real Y) to Strip Layer (Real Y + 40? or similar distance) # We assume the stub is anchored at the Pixel position (real_x, real_z, real_y) # And connects to (real_x + offset, real_z, real_y + 40) - + # Point 1 (Pixel Layer) p1_real_x = real_x p1_real_z = real_z - p1_real_y = real_y - + p1_real_y = real_y + # Point 2 (Strip Layer) - spacer = layer_spacers.get(int(hid/2), 40) + spacer = layer_spacers.get(int(hid / 2), 40) p2_real_x = real_x + offset p2_real_z = real_z p2_real_y = real_y + spacer - + # Draw Line # Mapping: (Real X, Real Z, Real Y) -> (3D X, 3D Y, 3D Z) line = ROOT.TPolyLine3D(2) @@ -349,7 +379,7 @@ def get_collection(obj, name): canvas.primitives.append(line) # --- Custom Track Drawing --- - + # Group stubs by layer stubs_by_layer = {} for i in range(n_stubs): @@ -358,52 +388,54 @@ def get_collection(obj, name): if layer not in stubs_by_layer: stubs_by_layer[layer] = [] stubs_by_layer[layer].append(i) - + sorted_layers = sorted(stubs_by_layer.keys()) if len(sorted_layers) >= 2: min_layer = sorted_layers[0] max_layer = sorted_layers[-1] - + # Use first stub in min/max layer idx1 = stubs_by_layer[min_layer][0] idx2 = stubs_by_layer[max_layer][0] - + hid1 = int(st_hids[idx1]) col1 = float(st_col[idx1]) fe1 = int(st_fe[idx1]) row1 = float(st_row[idx1]) - + hid2 = int(st_hids[idx2]) col2 = float(st_col[idx2]) fe2 = int(st_fe[idx2]) row2 = float(st_row[idx2]) - - y1, x1 = get_xy_stub(hid1, col1, fe1) # Note: get_xy_stub returns (real_y, real_x) + + y1, x1 = get_xy_stub( + hid1, col1, fe1 + ) # Note: get_xy_stub returns (real_y, real_x) z1 = get_z_stub(hid1, row1) - + y2, x2 = get_xy_stub(hid2, col2, fe2) z2 = get_z_stub(hid2, row2) - + # Draw Track Line tline = ROOT.TPolyLine3D(2) - tline.SetPoint(0, x1, z1, y1) # 3D: x=real_x, y=real_z, z=real_y + tline.SetPoint(0, x1, z1, y1) # 3D: x=real_x, y=real_z, z=real_y tline.SetPoint(1, x2, z2, y2) tline.SetLineColor(ROOT.kGray) tline.SetLineWidth(2) tline.Draw() canvas.primitives.append(tline) - + # Draw dots in intermediate layers if abs(y2 - y1) > 1e-5: for layer in range(min_layer + 1, max_layer): spacer = layer_spacers.get(layer, 40) - + # Sensor 1: y = -layer * 500 y_s1 = -layer * 500.0 t_s1 = (y_s1 - y1) / (y2 - y1) x_s1 = x1 + t_s1 * (x2 - x1) z_s1 = z1 + t_s1 * (z2 - z1) - + pm1 = ROOT.TPolyMarker3D(1) pm1.SetPoint(0, x_s1, z_s1, y_s1) pm1.SetMarkerStyle(20) @@ -411,13 +443,13 @@ def get_collection(obj, name): pm1.SetMarkerSize(1.0) pm1.Draw() canvas.primitives.append(pm1) - + # Sensor 2: y = -layer * 500 + spacer y_s2 = -layer * 500.0 + spacer t_s2 = (y_s2 - y1) / (y2 - y1) x_s2 = x1 + t_s2 * (x2 - x1) z_s2 = z1 + t_s2 * (z2 - z1) - + pm2 = ROOT.TPolyMarker3D(1) pm2.SetPoint(0, x_s2, z_s2, y_s2) pm2.SetMarkerStyle(20) @@ -429,27 +461,35 @@ def get_collection(obj, name): # --- Pad 2: 2D Views (Right) --- pad_right = canvas.cd(2) pad_right.Divide(1, 2) - + # --- Pad 2_1: XY View (Top Right) --- pad_xy = pad_right.cd(1) pad_xy.SetGridx() pad_xy.SetGridy() - + # Background histogram for XY h_xy = ROOT.TH2F("h_xy", "XY View", 1000, -100, 1000, 1000, -2200, 100) h_xy.SetStats(0) h_xy.Draw() - + # SCluster (Strips) - Red tree.SetMarkerStyle(20) tree.SetMarkerColor(ROOT.kRed) - tree.Draw("-int(SCluster_HybridId/2)*500+( (int(SCluster_HybridId/2)==0 || int(SCluster_HybridId/2)==4) ? 26 : 40 ):(SCluster_HybridId%2==0)?(SCluster_Address+SCluster_FrontEndId*120):(960-(SCluster_Address+SCluster_FrontEndId*120))", f"Entry$=={entry}", "same") - + tree.Draw( + "-int(SCluster_HybridId/2)*500+( (int(SCluster_HybridId/2)==0 || int(SCluster_HybridId/2)==4) ? 26 : 40 ):(SCluster_HybridId%2==0)?(SCluster_Address+SCluster_FrontEndId*120):(960-(SCluster_Address+SCluster_FrontEndId*120))", + f"Entry$=={entry}", + "same", + ) + # PCluster (Pixels) - Blue tree.SetMarkerStyle(21) tree.SetMarkerColor(ROOT.kBlue) - tree.Draw("-int(PCluster_HybridId/2)*500:(PCluster_HybridId%2==0)?(PCluster_Address+PCluster_FrontEndId*120):(960-(PCluster_Address+PCluster_FrontEndId*120))", f"Entry$=={entry}", "same") - + tree.Draw( + "-int(PCluster_HybridId/2)*500:(PCluster_HybridId%2==0)?(PCluster_Address+PCluster_FrontEndId*120):(960-(PCluster_Address+PCluster_FrontEndId*120))", + f"Entry$=={entry}", + "same", + ) + # Stub - Green (lines showing bending) # Draw lines from pixel position to strip position with offset for i in range(n_stubs): @@ -460,113 +500,130 @@ def get_collection(obj, name): bend_code = bend_code & 0x7 offset = bend_lut.get(bend_code, 0.0) print(f"Bend code: {bend_code}, offset: {offset}") - #flip sign based on hybrid parity + # flip sign based on hybrid parity if hid % 2 == 1: offset = -offset # Pixel position (X coordinate) - spacer = layer_spacers.get(int(hid/2), 40) - real_y_pixel, real_x_pixel = get_xy_pixel(hid, int(col/2), fe) + spacer = layer_spacers.get(int(hid / 2), 40) + real_y_pixel, real_x_pixel = get_xy_pixel(hid, int(col / 2), fe) real_y_pixel -= spacer real_x_pixel -= offset # Strip position (X coordinate with offset) - real_y_strip = real_y_pixel + spacer*3 - real_x_strip = real_x_pixel + offset*3 - + real_y_strip = real_y_pixel + spacer * 3 + real_x_strip = real_x_pixel + offset * 3 + # Draw line in XY view line_xy = ROOT.TLine(real_x_pixel, real_y_pixel, real_x_strip, real_y_strip) line_xy.SetLineColor(ROOT.kGreen + 2) line_xy.SetLineWidth(2) line_xy.Draw() canvas.primitives.append(line_xy) - + # Legend leg = ROOT.TLegend(0.1, 0.5, 0.25, 0.65) - gS = ROOT.TGraph(); gS.SetMarkerStyle(20); gS.SetMarkerColor(ROOT.kRed); gS.SetTitle("SCluster") - gP = ROOT.TGraph(); gP.SetMarkerStyle(21); gP.SetMarkerColor(ROOT.kBlue); gP.SetTitle("PCluster") - gStub = ROOT.TGraph(); gStub.SetMarkerStyle(22); gStub.SetMarkerColor(ROOT.kGreen + 2); gStub.SetTitle("Stub") + gS = ROOT.TGraph() + gS.SetMarkerStyle(20) + gS.SetMarkerColor(ROOT.kRed) + gS.SetTitle("SCluster") + gP = ROOT.TGraph() + gP.SetMarkerStyle(21) + gP.SetMarkerColor(ROOT.kBlue) + gP.SetTitle("PCluster") + gStub = ROOT.TGraph() + gStub.SetMarkerStyle(22) + gStub.SetMarkerColor(ROOT.kGreen + 2) + gStub.SetTitle("Stub") leg.AddEntry(gS, "SCluster", "p") leg.AddEntry(gP, "PCluster", "p") leg.AddEntry(gStub, "Stub", "p") leg.Draw() - + # --- Pad 2_2: Z View (Bottom Right) --- pad_z = pad_right.cd(2) pad_z.SetGridx() pad_z.SetGridy() - + h_z = ROOT.TH2F("h_z", "Z View", 1000, -5, 50, 1000, -2200, 100) h_z.SetStats(0) h_z.Draw() - + # PCluster (Pixels) - ZPos tree.SetMarkerStyle(21) tree.SetMarkerColor(ROOT.kBlue) - tree.Draw("-int(PCluster_HybridId/2)*500:(PCluster_HybridId%2==0)?PCluster_Zpos:32-PCluster_Zpos", f"Entry$=={entry}", "same") - + tree.Draw( + "-int(PCluster_HybridId/2)*500:(PCluster_HybridId%2==0)?PCluster_Zpos:32-PCluster_Zpos", + f"Entry$=={entry}", + "same", + ) + # Stub - Row (triangles) tree.SetMarkerStyle(22) tree.SetMarkerColor(ROOT.kGreen + 2) - tree.Draw("-int(Stub_HybridId/2)*500+( (int(Stub_HybridId/2)==0 || int(Stub_HybridId/2)==4) ? 13 : 20 ):(Stub_HybridId%2==0)?Stub_Row:32-Stub_Row", f"Entry$=={entry}", "same") - + tree.Draw( + "-int(Stub_HybridId/2)*500+( (int(Stub_HybridId/2)==0 || int(Stub_HybridId/2)==4) ? 13 : 20 ):(Stub_HybridId%2==0)?Stub_Row:32-Stub_Row", + f"Entry$=={entry}", + "same", + ) canvas.Update() + def main(): # Initialize TApplication to handle GUI events properly app = ROOT.TApplication("app", 0, 0) - + parser = argparse.ArgumentParser(description="Draw events from ROOT file") parser.add_argument("--input", "-i", required=True, help="Input ROOT file") parser.add_argument("--start", type=int, default=0, help="Start entry") args = parser.parse_args() - + f = ROOT.TFile.Open(args.input) if not f or f.IsZombie(): print(f"Error opening file {args.input}") sys.exit(1) - + tree = f.Get("Events") if not tree: print("Error: Events tree not found") sys.exit(1) - + canvas = ROOT.TCanvas("c1", "Event Display", 1200, 800) - + n_entries = tree.GetEntries() print(f"Found {n_entries} entries.") - + import select import time - + # State variable to track button clicks button_clicked = [None] # Use list so it can be modified in nested function - + # Define callback functions that will be called when buttons are clicked def on_prev_click(): - button_clicked[0] = 'prev' - + button_clicked[0] = "prev" + def on_next_click(): - button_clicked[0] = 'next' - + button_clicked[0] = "next" + current_entry = args.start while current_entry < n_entries: print(f"Drawing entry {current_entry}...") draw_event(tree, current_entry, canvas) - + # Add buttons after drawing event (they need to be recreated after canvas.Clear()) # Draw on the main canvas, not in a sub-pad canvas.cd(0) # Select the main canvas - + prev_button = ROOT.TButton("Previous", "", 0.02, 0.92, 0.10, 0.97) prev_button.SetFillColor(ROOT.kGray + 1) prev_button.SetTextSize(0.5) prev_button.Draw() - + next_button = ROOT.TButton("Next", "", 0.12, 0.92, 0.20, 0.97) next_button.SetFillColor(ROOT.kGray + 1) next_button.SetTextSize(0.5) next_button.Draw() - + # Event counter display event_label = ROOT.TPaveText(0.22, 0.92, 0.38, 0.97, "NDC") event_label.SetFillColor(ROOT.kWhite) @@ -576,19 +633,21 @@ def on_next_click(): event_label.SetBorderSize(1) event_label.AddText(f"Event: {current_entry} / {n_entries - 1}") event_label.Draw() - + canvas.Update() - - print(f"Entry {current_entry}. Click buttons or press Enter for next, 'p' for previous, 'q' to quit.") - + + print( + f"Entry {current_entry}. Click buttons or press Enter for next, 'p' for previous, 'q' to quit." + ) + # Reset button click state button_clicked[0] = None - + # Non-blocking wait loop while True: # Process ROOT events (GUI, Zooming, etc.) ROOT.gSystem.ProcessEvents() - + # Check if a button was clicked by checking if the selected object is one of our buttons # Only respond to actual button release events (kButton1Up = 1) event = ROOT.gPad.GetEvent() @@ -605,17 +664,17 @@ def on_next_click(): current_entry = min(n_entries - 1, current_entry + 1) ROOT.gPad.SetSelected(ROOT.nullptr) # Clear selection break # Redraw - + # Check for input on stdin # Wait up to 20ms for input r, w, e = select.select([sys.stdin], [], [], 0.02) if r: user_input = sys.stdin.readline().strip() - if user_input.lower() == 'q': + if user_input.lower() == "q": sys.exit(0) - elif user_input.lower() == 'p': + elif user_input.lower() == "p": current_entry = max(0, current_entry - 1) - break # Redraw + break # Redraw elif user_input.isdigit(): # Go to specific event goto_entry = int(user_input) @@ -623,13 +682,16 @@ def on_next_click(): current_entry = goto_entry print(f"Going to event {current_entry}") else: - print(f"Invalid entry number. Must be between 0 and {n_entries - 1}") - break # Redraw + print( + f"Invalid entry number. Must be between 0 and {n_entries - 1}" + ) + break # Redraw else: current_entry += 1 - break # Redraw - + break # Redraw + # No explicit sleep needed as select waits up to timeout + if __name__ == "__main__": main() diff --git a/edit_ompo_report.py b/edit_ompo_report.py index c86736c..6f55847 100755 --- a/edit_ompo_report.py +++ b/edit_ompo_report.py @@ -22,45 +22,45 @@ def find_and_replace_text_in_shape(shape, old_text, new_text): """ Find and replace text in a shape, preserving formatting. - + Args: shape: The shape object to search old_text: Text to find new_text: Text to replace with - + Returns: bool: True if text was replaced, False otherwise """ if not shape.has_text_frame: return False - + replaced = False text_frame = shape.text_frame - + # Check all paragraphs and runs for paragraph in text_frame.paragraphs: for run in paragraph.runs: if old_text in run.text: run.text = run.text.replace(old_text, new_text) replaced = True - + # If no runs, try the whole text if not replaced and old_text in text_frame.text: text_frame.text = text_frame.text.replace(old_text, new_text) replaced = True - + return replaced def find_and_replace_in_slide(slide, old_text, new_text): """ Find and replace text in all shapes in a slide. - + Args: slide: The slide object old_text: Text to find new_text: Text to replace with - + Returns: int: Number of replacements made """ @@ -74,135 +74,122 @@ def find_and_replace_in_slide(slide, old_text, new_text): def edit_ompo_report(input_file, output_file, name=None, date=None, status_text=None): """ Edit the OMPO Report PowerPoint presentation. - + Args: input_file: Path to input PowerPoint file output_file: Path to output PowerPoint file name: Name of person filling/submitting the report date: Date in YYYY-MM-DD format status_text: Text to replace in the Status of Burnin Center section - + Returns: dict: Summary of replacements made """ # Load presentation prs = Presentation(input_file) - - summary = { - 'name': 0, - 'date': 0, - 'status': 0 - } - + + summary = {"name": 0, "date": 0, "status": 0} + # Process each slide for slide_idx, slide in enumerate(prs.slides): print(f"Processing slide {slide_idx + 1}...") - + # Replace Name field if name: count = find_and_replace_in_slide(slide, "Name", name) - summary['name'] += count + summary["name"] += count if count > 0: print(f" - Replaced 'Name' with '{name}' ({count} occurrences)") - + # Replace Date field if date: count = find_and_replace_in_slide(slide, "YYYY-MM-DD", date) - summary['date'] += count + summary["date"] += count if count > 0: print(f" - Replaced 'YYYY-MM-DD' with '{date}' ({count} occurrences)") - + # Replace Status text (the "Text" placeholder in yellow box) if status_text: count = find_and_replace_in_slide(slide, "Text", status_text) - summary['status'] += count + summary["status"] += count if count > 0: - print(f" - Replaced 'Text' with status information ({count} occurrences)") - + print( + f" - Replaced 'Text' with status information ({count} occurrences)" + ) + # Save modified presentation prs.save(output_file) print(f"\nSaved modified presentation to: {output_file}") - + return summary def main(): """Main function for command-line usage.""" parser = argparse.ArgumentParser( - description='Edit OMPO Report PowerPoint presentation fields', + description="Edit OMPO Report PowerPoint presentation fields", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s input.pptx output.pptx --name "John Doe" --date "2025-11-18" %(prog)s template.pptx filled.pptx --name "Jane Smith" --date "2025-11-20" --status "All systems operational. No downtime expected." %(prog)s input.pptx output.pptx --date $(date +%%Y-%%m-%%d) # Use current date - """ - ) - - parser.add_argument( - 'input_file', - type=str, - help='Input PowerPoint file path' + """, ) - - parser.add_argument( - 'output_file', - type=str, - help='Output PowerPoint file path' - ) - + + parser.add_argument("input_file", type=str, help="Input PowerPoint file path") + + parser.add_argument("output_file", type=str, help="Output PowerPoint file path") + parser.add_argument( - '--name', - type=str, - help='Name of person filling/submitting the report' + "--name", type=str, help="Name of person filling/submitting the report" ) - + parser.add_argument( - '--date', - type=str, - help='Date in YYYY-MM-DD format (default: today)' + "--date", type=str, help="Date in YYYY-MM-DD format (default: today)" ) - + parser.add_argument( - '--status', - type=str, - help='Status text for the Burnin Center section' + "--status", type=str, help="Status text for the Burnin Center section" ) - + parser.add_argument( - '--use-today', - action='store_true', - help='Use today\'s date automatically' + "--use-today", action="store_true", help="Use today's date automatically" ) - + args = parser.parse_args() - + # Validate input file exists if not Path(args.input_file).exists(): print(f"Error: Input file '{args.input_file}' not found.", file=sys.stderr) return 1 - + # Use today's date if requested date = args.date if args.use_today or (date is None and args.name is None and args.status is None): # If no arguments provided, at least use today's date - date = datetime.now().strftime('%Y-%m-%d') + date = datetime.now().strftime("%Y-%m-%d") print(f"Using today's date: {date}") - + # Validate date format if provided if date: try: - datetime.strptime(date, '%Y-%m-%d') + datetime.strptime(date, "%Y-%m-%d") except ValueError: - print(f"Error: Date must be in YYYY-MM-DD format, got '{date}'", file=sys.stderr) + print( + f"Error: Date must be in YYYY-MM-DD format, got '{date}'", + file=sys.stderr, + ) return 1 - + # Check if at least one field is being updated if not any([args.name, date, args.status]): - print("Warning: No fields specified to update. Use --name, --date, or --status options.") + print( + "Warning: No fields specified to update. Use --name, --date, or --status options." + ) parser.print_help() return 1 - + try: # Edit the presentation summary = edit_ompo_report( @@ -210,22 +197,23 @@ def main(): args.output_file, name=args.name, date=date, - status_text=args.status + status_text=args.status, ) - + print("\nSummary:") print(f" - Name field: {summary['name']} replacements") print(f" - Date field: {summary['date']} replacements") print(f" - Status field: {summary['status']} replacements") - + return 0 - + except Exception as e: print(f"Error: {e}", file=sys.stderr) import traceback + traceback.print_exc() return 1 -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/glue_analyzer.py b/glue_analyzer.py index 08d7f28..62a23eb 100644 --- a/glue_analyzer.py +++ b/glue_analyzer.py @@ -6,10 +6,11 @@ # --- Configuration --- # You can adjust these values if needed RESIZE_WIDTH = 1200 # Resize images wider than this for better display -MANUAL_BRUSH_SIZE = 10 # Brush size for manual correction +MANUAL_BRUSH_SIZE = 10 # Brush size for manual correction INSTRUCTION_FONT_SCALE = 0.6 INSTRUCTION_FONT_THICKNESS = 1 + class GlueContactAnalyzer: """ An interactive tool to analyze glue contact area in images. @@ -43,37 +44,41 @@ def __init__(self, image_path): self.roi_mask = None self.manual_good_mask = np.zeros(self.image.shape[:2], dtype=np.uint8) self.manual_bad_mask = np.zeros(self.image.shape[:2], dtype=np.uint8) - + # For perimeter drawing mode self.perimeter_points = [] self.drawing_perimeter = False - + # We will work on the blue channel, as the glue is blueish/gray # and has good contrast with the black carbon fiber. - self.gray_image = self.image[:, :, 0] # Using Blue channel - - self.threshold_value = 120 # Initial threshold value - self.use_advanced_segmentation = False # Toggle between simple threshold and gradient-corrected threshold - self.invert_logic = False # Whether to invert the segmentation logic - + self.gray_image = self.image[:, :, 0] # Using Blue channel + + self.threshold_value = 120 # Initial threshold value + self.use_advanced_segmentation = ( + False # Toggle between simple threshold and gradient-corrected threshold + ) + self.invert_logic = False # Whether to invert the segmentation logic + # Gradient correction parameters self.gradient_kernel_size = 50 # Size of kernel for estimating local background - self.gradient_correction_strength = 1.0 # How much to apply the correction (0.0 to 1.0) - + self.gradient_correction_strength = ( + 1.0 # How much to apply the correction (0.0 to 1.0) + ) + # Display options self.show_overlay = True # Whether to show green/red overlay self.show_corrected_image = False # Whether to show gradient-corrected image self.show_help_text = True # Whether to show instruction text overlay - - self.mode = 'ROI_SELECT' # Modes: 'ROI_SELECT', 'THRESHOLD', 'MANUAL_GOOD', 'MANUAL_BAD', 'PERIMETER_GOOD', 'PERIMETER_BAD' - + + self.mode = "ROI_SELECT" # Modes: 'ROI_SELECT', 'THRESHOLD', 'MANUAL_GOOD', 'MANUAL_BAD', 'PERIMETER_GOOD', 'PERIMETER_BAD' + self.window_name = "Glue Contact Analyzer" cv2.namedWindow(self.window_name) cv2.setMouseCallback(self.window_name, self.mouse_callback) def mouse_callback(self, event, x, y, flags, param): """Handles all mouse interactions.""" - if self.mode == 'ROI_SELECT': + if self.mode == "ROI_SELECT": if event == cv2.EVENT_LBUTTONDOWN: self.roi_points.append((x, y)) print(f"Added ROI point: ({x}, {y})") @@ -82,7 +87,7 @@ def mouse_callback(self, event, x, y, flags, param): self.roi_points.pop() print("Removed last ROI point.") - elif self.mode in ['PERIMETER_GOOD', 'PERIMETER_BAD']: + elif self.mode in ["PERIMETER_GOOD", "PERIMETER_BAD"]: if event == cv2.EVENT_LBUTTONDOWN: if not self.drawing_perimeter: # Start new perimeter @@ -96,28 +101,34 @@ def mouse_callback(self, event, x, y, flags, param): elif event == cv2.EVENT_MBUTTONDOWN: # Middle click to complete if self.drawing_perimeter and len(self.perimeter_points) > 2: # Complete the perimeter and fill it - target_mask = self.manual_good_mask if self.mode == 'PERIMETER_GOOD' else self.manual_bad_mask + target_mask = ( + self.manual_good_mask + if self.mode == "PERIMETER_GOOD" + else self.manual_bad_mask + ) cv2.fillPoly(target_mask, [np.array(self.perimeter_points)], 255) - print(f"Completed perimeter with {len(self.perimeter_points)} points") + print( + f"Completed perimeter with {len(self.perimeter_points)} points" + ) self.perimeter_points = [] self.drawing_perimeter = False elif self.drawing_perimeter: print("Need at least 3 points to complete perimeter") - elif flags == cv2.EVENT_FLAG_LBUTTON: # Mouse is being dragged - if self.mode == 'MANUAL_GOOD': + elif flags == cv2.EVENT_FLAG_LBUTTON: # Mouse is being dragged + if self.mode == "MANUAL_GOOD": cv2.circle(self.manual_good_mask, (x, y), MANUAL_BRUSH_SIZE, 255, -1) - elif self.mode == 'MANUAL_BAD': + elif self.mode == "MANUAL_BAD": cv2.circle(self.manual_bad_mask, (x, y), MANUAL_BRUSH_SIZE, 255, -1) - + def set_threshold(self, val): """Callback for the threshold trackbar.""" self.threshold_value = val - + def set_gradient_kernel_size(self, val): """Callback for gradient kernel size trackbar.""" self.gradient_kernel_size = max(10, val) # Minimum size of 10 - + def set_gradient_correction_strength(self, val): """Callback for gradient correction strength trackbar.""" self.gradient_correction_strength = val / 100.0 # Convert to 0.0-1.0 range @@ -125,90 +136,132 @@ def set_gradient_correction_strength(self, val): def create_trackbars(self): """Create all trackbars for parameter adjustment.""" # Basic threshold - cv2.createTrackbar('Threshold', self.window_name, self.threshold_value, 255, self.set_threshold) - + cv2.createTrackbar( + "Threshold", self.window_name, self.threshold_value, 255, self.set_threshold + ) + # Gradient correction parameters - cv2.createTrackbar('Gradient Kernel', self.window_name, self.gradient_kernel_size, 200, self.set_gradient_kernel_size) - cv2.createTrackbar('Correction Strength', self.window_name, int(self.gradient_correction_strength * 100), 100, self.set_gradient_correction_strength) + cv2.createTrackbar( + "Gradient Kernel", + self.window_name, + self.gradient_kernel_size, + 200, + self.set_gradient_kernel_size, + ) + cv2.createTrackbar( + "Correction Strength", + self.window_name, + int(self.gradient_correction_strength * 100), + 100, + self.set_gradient_correction_strength, + ) def process_and_display(self): """The main display loop, updates the image based on the current state.""" display_image = self.image.copy() - + # --- Draw ROI points as they are being selected --- if self.roi_points: for i in range(len(self.roi_points)): - cv2.circle(display_image, self.roi_points[i], 4, (0, 255, 255), -1) # Yellow dots + cv2.circle( + display_image, self.roi_points[i], 4, (0, 255, 255), -1 + ) # Yellow dots if i > 0: - cv2.line(display_image, self.roi_points[i-1], self.roi_points[i], (0, 255, 255), 2) - if self.mode != 'ROI_SELECT' and len(self.roi_points) > 2: + cv2.line( + display_image, + self.roi_points[i - 1], + self.roi_points[i], + (0, 255, 255), + 2, + ) + if self.mode != "ROI_SELECT" and len(self.roi_points) > 2: # Draw the closed polygon after confirmation - cv2.polylines(display_image, [np.array(self.roi_points)], isClosed=True, color=(0, 255, 255), thickness=2) - + cv2.polylines( + display_image, + [np.array(self.roi_points)], + isClosed=True, + color=(0, 255, 255), + thickness=2, + ) + # --- Draw perimeter points during perimeter drawing --- if self.drawing_perimeter and self.perimeter_points: - color = (0, 255, 0) if self.mode == 'PERIMETER_GOOD' else (0, 0, 255) + color = (0, 255, 0) if self.mode == "PERIMETER_GOOD" else (0, 0, 255) for i in range(len(self.perimeter_points)): cv2.circle(display_image, self.perimeter_points[i], 3, color, -1) if i > 0: - cv2.line(display_image, self.perimeter_points[i-1], self.perimeter_points[i], color, 2) + cv2.line( + display_image, + self.perimeter_points[i - 1], + self.perimeter_points[i], + color, + 2, + ) # --- Process and overlay segmentation if ROI is defined --- if self.roi_mask is not None: # 1. Choose segmentation method if self.use_advanced_segmentation: - auto_good_mask, corrected_image = self.gradient_corrected_threshold(self.roi_mask) + auto_good_mask, corrected_image = self.gradient_corrected_threshold( + self.roi_mask + ) else: # Simple thresholding - thresh_type = cv2.THRESH_BINARY if self.invert_logic else cv2.THRESH_BINARY_INV - _, auto_good_mask = cv2.threshold(self.gray_image, self.threshold_value, 255, thresh_type) + thresh_type = ( + cv2.THRESH_BINARY if self.invert_logic else cv2.THRESH_BINARY_INV + ) + _, auto_good_mask = cv2.threshold( + self.gray_image, self.threshold_value, 255, thresh_type + ) corrected_image = None - + # Show gradient-corrected image if requested if self.show_corrected_image and corrected_image is not None: # Convert single channel to 3-channel for display corrected_display = cv2.cvtColor(corrected_image, cv2.COLOR_GRAY2BGR) display_image = corrected_display - + # 2. Apply manual corrections # Start with the automatic mask final_good_mask = auto_good_mask # Force manually painted good areas to be white final_good_mask = cv2.bitwise_or(final_good_mask, self.manual_good_mask) # Force manually painted bad areas to be black - final_good_mask = cv2.bitwise_and(final_good_mask, cv2.bitwise_not(self.manual_bad_mask)) + final_good_mask = cv2.bitwise_and( + final_good_mask, cv2.bitwise_not(self.manual_bad_mask) + ) # 3. Constrain everything to the ROI final_good_mask = cv2.bitwise_and(final_good_mask, self.roi_mask) - + # Apply overlays only if show_overlay is True if self.show_overlay: # Create overlays for visualization good_overlay = np.zeros_like(display_image) - good_overlay[:] = (0, 255, 0) # Green for good contact - + good_overlay[:] = (0, 255, 0) # Green for good contact + bad_overlay = np.zeros_like(display_image) - bad_overlay[:] = (0, 0, 255) # Red for bad contact + bad_overlay[:] = (0, 0, 255) # Red for bad contact # Find the bad contact area within the ROI - roi_area_only = cv2.bitwise_not(final_good_mask) # Invert to get bad areas + roi_area_only = cv2.bitwise_not( + final_good_mask + ) # Invert to get bad areas bad_mask_in_roi = cv2.bitwise_and(roi_area_only, self.roi_mask) - + # Apply overlays # Create masked overlays and blend them good_indices = final_good_mask > 0 bad_indices = bad_mask_in_roi > 0 - + # Apply green overlay for good contact areas display_image[good_indices] = cv2.addWeighted( - display_image[good_indices], 0.6, - good_overlay[good_indices], 0.4, 0 + display_image[good_indices], 0.6, good_overlay[good_indices], 0.4, 0 ) - + # Apply red overlay for bad contact areas display_image[bad_indices] = cv2.addWeighted( - display_image[bad_indices], 0.6, - bad_overlay[bad_indices], 0.4, 0 + display_image[bad_indices], 0.6, bad_overlay[bad_indices], 0.4, 0 ) # --- Add instructions to the screen --- @@ -219,30 +272,60 @@ def process_and_display(self): def draw_instructions(self, image): """Draws text instructions on the image.""" y_pos = 20 + def put_text(text, color=(255, 255, 255)): nonlocal y_pos - cv2.putText(image, text, (10, y_pos), cv2.FONT_HERSHEY_SIMPLEX, - INSTRUCTION_FONT_SCALE, (0,0,0), INSTRUCTION_FONT_THICKNESS + 1, cv2.LINE_AA) # Shadow - cv2.putText(image, text, (10, y_pos), cv2.FONT_HERSHEY_SIMPLEX, - INSTRUCTION_FONT_SCALE, color, INSTRUCTION_FONT_THICKNESS, cv2.LINE_AA) + cv2.putText( + image, + text, + (10, y_pos), + cv2.FONT_HERSHEY_SIMPLEX, + INSTRUCTION_FONT_SCALE, + (0, 0, 0), + INSTRUCTION_FONT_THICKNESS + 1, + cv2.LINE_AA, + ) # Shadow + cv2.putText( + image, + text, + (10, y_pos), + cv2.FONT_HERSHEY_SIMPLEX, + INSTRUCTION_FONT_SCALE, + color, + INSTRUCTION_FONT_THICKNESS, + cv2.LINE_AA, + ) y_pos += 20 - - if self.mode == 'ROI_SELECT': + + if self.mode == "ROI_SELECT": put_text(f"MODE: Select ROI", (0, 255, 255)) put_text("Left-click: Add point. Right-click: Remove last point.") put_text("Press 'c' to confirm ROI when done.") else: - mode_color = (255, 255, 0) if self.mode == 'THRESHOLD' else (0, 255, 0) if self.mode in ['MANUAL_GOOD', 'PERIMETER_GOOD'] else (0, 0, 255) + mode_color = ( + (255, 255, 0) + if self.mode == "THRESHOLD" + else ( + (0, 255, 0) + if self.mode in ["MANUAL_GOOD", "PERIMETER_GOOD"] + else (0, 0, 255) + ) + ) put_text(f"MODE: {self.mode}", mode_color) - + # Show current segmentation method - seg_method = "Gradient-Corrected" if self.use_advanced_segmentation else "Simple" + seg_method = ( + "Gradient-Corrected" if self.use_advanced_segmentation else "Simple" + ) put_text(f"Segmentation: {seg_method}", (255, 255, 0)) put_text(f"Threshold: {self.threshold_value}", (255, 255, 0)) if self.use_advanced_segmentation: put_text(f"Gradient Kernel: {self.gradient_kernel_size}", (255, 255, 0)) - put_text(f"Correction: {self.gradient_correction_strength:.2f}", (255, 255, 0)) - + put_text( + f"Correction: {self.gradient_correction_strength:.2f}", + (255, 255, 0), + ) + put_text("--- Controls ---") put_text("'t': Adjust Threshold with slider") put_text("'a': Toggle Gradient-corrected/Simple threshold") @@ -252,114 +335,133 @@ def put_text(text, color=(255, 255, 255)): put_text("'h': Toggle help text visibility") put_text("'g': Paint GOOD contact (hold left mouse)") put_text("'b': Paint BAD contact (hold left mouse)") - put_text("'p': Draw GOOD perimeter (left-click points, middle-click to fill)") - put_text("'n': Draw BAD perimeter (left-click points, middle-click to fill)") + put_text( + "'p': Draw GOOD perimeter (left-click points, middle-click to fill)" + ) + put_text( + "'n': Draw BAD perimeter (left-click points, middle-click to fill)" + ) if self.drawing_perimeter: put_text("ESC to cancel perimeter", (255, 0, 255)) put_text("'s': Calculate and show results") put_text("'r': Reset everything") put_text("'q': Quit") - def run(self): """Main application loop.""" print("--- Glue Contact Analyzer ---") print("Step 1: Define the ROI by clicking points on the image.") print(" Press 'c' to confirm the ROI and proceed.") - + while True: self.process_and_display() key = cv2.waitKey(20) & 0xFF - if key == ord('q'): + if key == ord("q"): break - - elif key == ord('c') and self.mode == 'ROI_SELECT': + + elif key == ord("c") and self.mode == "ROI_SELECT": if len(self.roi_points) > 2: - print("\nStep 2: ROI Confirmed. Adjust threshold or perform manual corrections.") - self.mode = 'THRESHOLD' + print( + "\nStep 2: ROI Confirmed. Adjust threshold or perform manual corrections." + ) + self.mode = "THRESHOLD" self.roi_mask = np.zeros(self.image.shape[:2], dtype=np.uint8) cv2.fillPoly(self.roi_mask, [np.array(self.roi_points)], 255) self.create_trackbars() else: print("Please select at least 3 points to form a polygon.") - elif key == ord('t') and self.mode != 'ROI_SELECT': - self.mode = 'THRESHOLD' + elif key == ord("t") and self.mode != "ROI_SELECT": + self.mode = "THRESHOLD" print("Mode changed to THRESHOLD adjustment.") - elif key == ord('g') and self.mode != 'ROI_SELECT': - self.mode = 'MANUAL_GOOD' + elif key == ord("g") and self.mode != "ROI_SELECT": + self.mode = "MANUAL_GOOD" print("Mode changed to MANUAL GOOD. Hold and drag left mouse to paint.") - elif key == ord('b') and self.mode != 'ROI_SELECT': - self.mode = 'MANUAL_BAD' + elif key == ord("b") and self.mode != "ROI_SELECT": + self.mode = "MANUAL_BAD" print("Mode changed to MANUAL BAD. Hold and drag left mouse to paint.") - - elif key == ord('p') and self.mode != 'ROI_SELECT': - self.mode = 'PERIMETER_GOOD' + + elif key == ord("p") and self.mode != "ROI_SELECT": + self.mode = "PERIMETER_GOOD" self.perimeter_points = [] self.drawing_perimeter = False - print("Mode changed to PERIMETER GOOD. Left-click to add points, middle-click to fill perimeter.") - - elif key == ord('n') and self.mode != 'ROI_SELECT': - self.mode = 'PERIMETER_BAD' + print( + "Mode changed to PERIMETER GOOD. Left-click to add points, middle-click to fill perimeter." + ) + + elif key == ord("n") and self.mode != "ROI_SELECT": + self.mode = "PERIMETER_BAD" self.perimeter_points = [] self.drawing_perimeter = False - print("Mode changed to PERIMETER BAD. Left-click to add points, middle-click to fill perimeter.") - + print( + "Mode changed to PERIMETER BAD. Left-click to add points, middle-click to fill perimeter." + ) + elif key == 27: # ESC key if self.drawing_perimeter: print("Cancelled perimeter drawing") self.perimeter_points = [] self.drawing_perimeter = False - - elif key == ord('a') and self.mode != 'ROI_SELECT': + + elif key == ord("a") and self.mode != "ROI_SELECT": self.use_advanced_segmentation = not self.use_advanced_segmentation - method = "Gradient-corrected threshold" if self.use_advanced_segmentation else "Simple threshold" + method = ( + "Gradient-corrected threshold" + if self.use_advanced_segmentation + else "Simple threshold" + ) print(f"Switched to {method} segmentation.") - - elif key == ord('i') and self.mode != 'ROI_SELECT': + + elif key == ord("i") and self.mode != "ROI_SELECT": self.invert_good_bad_masks() - - elif key == ord('o') and self.mode != 'ROI_SELECT': + + elif key == ord("o") and self.mode != "ROI_SELECT": self.show_overlay = not self.show_overlay status = "ON" if self.show_overlay else "OFF" print(f"Overlay visibility: {status}") - - elif key == ord('v') and self.mode != 'ROI_SELECT': + + elif key == ord("v") and self.mode != "ROI_SELECT": self.show_corrected_image = not self.show_corrected_image status = "ON" if self.show_corrected_image else "OFF" print(f"Gradient-corrected image view: {status}") - - elif key == ord('h'): + + elif key == ord("h"): self.show_help_text = not self.show_help_text status = "ON" if self.show_help_text else "OFF" print(f"Help text visibility: {status}") - - elif key == ord('s') and self.roi_mask is not None: + + elif key == ord("s") and self.roi_mask is not None: self.calculate_and_show_results() - elif key == ord('r'): + elif key == ord("r"): print("Resetting application state.") - self.__init__(self.image_path) # Re-initialize the object + self.__init__(self.image_path) # Re-initialize the object cv2.destroyWindow(self.window_name) - self.run() # Restart the run loop - return # Exit the current loop + self.run() # Restart the run loop + return # Exit the current loop cv2.destroyAllWindows() - + def calculate_and_show_results(self): """Final calculation and printing of results.""" # Recalculate the final mask to be sure it's up to date if self.use_advanced_segmentation: auto_good_mask, _ = self.gradient_corrected_threshold(self.roi_mask) else: - thresh_type = cv2.THRESH_BINARY if self.invert_logic else cv2.THRESH_BINARY_INV - _, auto_good_mask = cv2.threshold(self.gray_image, self.threshold_value, 255, thresh_type) - + thresh_type = ( + cv2.THRESH_BINARY if self.invert_logic else cv2.THRESH_BINARY_INV + ) + _, auto_good_mask = cv2.threshold( + self.gray_image, self.threshold_value, 255, thresh_type + ) + final_good_mask = cv2.bitwise_or(auto_good_mask, self.manual_good_mask) - final_good_mask = cv2.bitwise_and(final_good_mask, cv2.bitwise_not(self.manual_bad_mask)) + final_good_mask = cv2.bitwise_and( + final_good_mask, cv2.bitwise_not(self.manual_bad_mask) + ) final_good_mask = cv2.bitwise_and(final_good_mask, self.roi_mask) total_roi_pixels = cv2.countNonZero(self.roi_mask) @@ -372,7 +474,7 @@ def calculate_and_show_results(self): good_percentage = (good_contact_pixels / total_roi_pixels) * 100 bad_percentage = (bad_contact_pixels / total_roi_pixels) * 100 - + print("\n--- Analysis Results ---") print(f"Total Pixels in ROI: {total_roi_pixels}") print(f"Good Contact Pixels: {good_contact_pixels}") @@ -381,13 +483,45 @@ def calculate_and_show_results(self): print(f"Good Contact: {good_percentage:.2f}%") print(f"Bad Contact: {bad_percentage:.2f}%") print("--------------------------\n") - + # Display results on a new image result_img = np.zeros((200, 500, 3), dtype=np.uint8) - cv2.putText(result_img, "Analysis Results", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 0), 2) - cv2.putText(result_img, f"Good Contact: {good_percentage:.2f}%", (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) - cv2.putText(result_img, f"Bad Contact: {bad_percentage:.2f}%", (10, 120), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) - cv2.putText(result_img, "Press any key to close.", (10, 180), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) + cv2.putText( + result_img, + "Analysis Results", + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 1, + (255, 255, 0), + 2, + ) + cv2.putText( + result_img, + f"Good Contact: {good_percentage:.2f}%", + (10, 80), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (0, 255, 0), + 2, + ) + cv2.putText( + result_img, + f"Bad Contact: {bad_percentage:.2f}%", + (10, 120), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (0, 0, 255), + 2, + ) + cv2.putText( + result_img, + "Press any key to close.", + (10, 180), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (255, 255, 255), + 1, + ) cv2.imshow("Results", result_img) cv2.waitKey(0) cv2.destroyWindow("Results") @@ -399,49 +533,56 @@ def gradient_corrected_threshold(self, roi_mask): """ # Apply gradient correction to the ENTIRE image first, not just ROI # This avoids boundary artifacts and gets better lighting estimation - + # Estimate local background using morphological opening with a large kernel kernel_size = self.gradient_kernel_size - kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) - + kernel = cv2.getStructuringElement( + cv2.MORPH_ELLIPSE, (kernel_size, kernel_size) + ) + # Estimate background (local lighting) on the full image background = cv2.morphologyEx(self.gray_image, cv2.MORPH_OPEN, kernel) - + # Alternative: use Gaussian blur for smoother background estimation - blur_size = kernel_size//2*2+1 # Ensure odd number + blur_size = kernel_size // 2 * 2 + 1 # Ensure odd number background_smooth = cv2.GaussianBlur(self.gray_image, (blur_size, blur_size), 0) - + # Blend the two background estimates background_final = cv2.addWeighted(background, 0.5, background_smooth, 0.5, 0) - + # Apply gradient correction to the full image if self.gradient_correction_strength > 0: # Subtract background to remove lighting gradients corrected = cv2.subtract(self.gray_image, background_final) - + # Add back a uniform level to maintain good contrast # Use mean of entire image for better global balance mean_background = cv2.mean(background_final)[0] corrected = cv2.add(corrected, int(mean_background)) - + # Blend original and corrected based on correction strength corrected_final = cv2.addWeighted( - self.gray_image, 1.0 - self.gradient_correction_strength, - corrected, self.gradient_correction_strength, 0 + self.gray_image, + 1.0 - self.gradient_correction_strength, + corrected, + self.gradient_correction_strength, + 0, ) else: corrected_final = self.gray_image - + # Now apply ROI mask to the corrected image roi_corrected = cv2.bitwise_and(corrected_final, roi_mask) - + # Apply threshold to the corrected image thresh_type = cv2.THRESH_BINARY if self.invert_logic else cv2.THRESH_BINARY_INV - _, thresholded = cv2.threshold(corrected_final, self.threshold_value, 255, thresh_type) - + _, thresholded = cv2.threshold( + corrected_final, self.threshold_value, 255, thresh_type + ) + # Apply ROI constraint to the final mask final_mask = cv2.bitwise_and(thresholded, roi_mask) - + return final_mask, corrected_final def invert_good_bad_masks(self): @@ -450,18 +591,20 @@ def invert_good_bad_masks(self): temp_mask = self.manual_good_mask.copy() self.manual_good_mask = self.manual_bad_mask.copy() self.manual_bad_mask = temp_mask - + # Invert the automatic segmentation logic self.invert_logic = not self.invert_logic - + logic_state = "INVERTED" if self.invert_logic else "NORMAL" print(f"Inverted good and bad regions. Segmentation logic: {logic_state}") if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Interactively analyze glue contact area in an image.") + parser = argparse.ArgumentParser( + description="Interactively analyze glue contact area in an image." + ) parser.add_argument("image_path", help="Path to the input image file.") args = parser.parse_args() analyzer = GlueContactAnalyzer(args.image_path) - analyzer.run() \ No newline at end of file + analyzer.run() diff --git a/integration.py b/integration.py index 2551070..bc3b630 100755 --- a/integration.py +++ b/integration.py @@ -2,9 +2,22 @@ import threading import ui.integration_gui as integration_gui from PyQt5.QtWidgets import ( - QApplication, QMainWindow, QVBoxLayout, QWidget, - QLineEdit, QLabel, QFormLayout, QTreeWidgetItem, - QTreeWidget, QMessageBox, QPushButton, QInputDialog, QHBoxLayout, QSpacerItem, QSizePolicy, QComboBox + QApplication, + QMainWindow, + QVBoxLayout, + QWidget, + QLineEdit, + QLabel, + QFormLayout, + QTreeWidgetItem, + QTreeWidget, + QMessageBox, + QPushButton, + QInputDialog, + QHBoxLayout, + QSpacerItem, + QSizePolicy, + QComboBox, ) from PyQt5.QtGui import QPen from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject, QThread, QSize, QUrl @@ -20,9 +33,11 @@ import numpy as np from caen.caenGUI import CAENControl import json + # Set matplotlib backend before importing pyplot import matplotlib -matplotlib.use('Qt5Agg') + +matplotlib.use("Qt5Agg") import matplotlib.pyplot as plt import matplotlib.dates as mdates @@ -37,31 +52,34 @@ from datetime import datetime from db.module_db import ModuleDB from db.utils import get_module_fuse_id + + # Add this class near the top of the file class LogEmitter(QObject): """Helper class to emit log messages from any thread""" + log_message = pyqtSignal(str) + # Add new CommandWorker class class CommandWorker(QThread): finished = pyqtSignal(bool, str, str) # success, stdout, stderr - + def __init__(self, command): super().__init__() self.command = command - + def run(self): try: expanded_command = self.command - #open spinning wheel dialog - result = subprocess.run(expanded_command, shell=True, - capture_output=True, text=True) + # open spinning wheel dialog + result = subprocess.run( + expanded_command, shell=True, capture_output=True, text=True + ) self.finished.emit(result.returncode == 0, result.stdout, result.stderr) - #create a waiting dialog + # create a waiting dialog # Create a waiting dialog - - - + except Exception as e: self.finished.emit(False, "", str(e)) @@ -70,42 +88,44 @@ class MainApp(integration_gui.Ui_MainWindow): def __init__(self, window): # First call setupUi to create all UI elements from the .ui file self.setupUi(window) - + # Add Ph2ACF topic to API settings layout programmatically # Need to do this before load_settings self.ph2acfTopicLabel = QLabel("Ph2ACF Topic:") self.ph2acfTopicLE = QLineEdit() self.ph2acfTopicLE.setObjectName("ph2acfTopicLE") self.formLayout_2.addRow(self.ph2acfTopicLabel, self.ph2acfTopicLE) - + # Load settings before setting up connections self.load_settings() - + # Connect settings change self.ph2acfTopicLE.textChanged.connect(self.save_settings) - + # Create ModuleDB instance self.module_db = ModuleDB() - + # Replace inventory and details tabs with ModuleDB tabs - self.tabWidget.insertTab(1,self.module_db.ui.tab_2, "Module Inventory") - self.tabWidget.insertTab(2,self.module_db.ui.moduleDetailsTab, "Module Details") - + self.tabWidget.insertTab(1, self.module_db.ui.tab_2, "Module Inventory") + self.tabWidget.insertTab( + 2, self.module_db.ui.moduleDetailsTab, "Module Details" + ) + # Connect module selection signal self.module_db.module_selected.connect(self.on_module_selected) self.module_db.ui.viewDetailsPB.clicked.connect(self.view_module_details) # Set stretch factors for the main grid layout to make left side expand more -# grid_layout = self.tab.layout() -# grid_layout.setColumnStretch(0, 2) # Left column gets 2 parts -# grid_layout.setColumnStretch(1, 1) # Right column gets 1 part - + # grid_layout = self.tab.layout() + # grid_layout.setColumnStretch(0, 2) # Left column gets 2 parts + # grid_layout.setColumnStretch(1, 1) # Right column gets 1 part + # Set up square aspect ratio for graphics view self.graphicsView.setMinimumSize(300, 300) self.graphicsView.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.graphicsView.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - + # Create a custom resize event for the graphics view # def resizeEvent(event): # # Get the smaller of width or height @@ -116,27 +136,26 @@ def __init__(self, window): # self.graphicsView.resize(square_size) # # Call parent resize event # QGraphicsView.resizeEvent(self.graphicsView, event) - + # # Attach the custom resize event # self.graphicsView.resizeEvent = resizeEvent - + # Initialize log emitter self.log_emitter = LogEmitter() self.log_emitter.log_message.connect(self.append_log) - + # Fix text format setting self.placeholdersHelpLabel.setTextFormat(Qt.PlainText) - + # Initialize MQTT client to None self.client = None - + # Initialize air state self.air_state = False - - + # Store the full module list for filtering - initialize before using self.all_modules = [] - + # # Add search box - move this before connecting signals # searchLayout = QHBoxLayout() # searchLabel = QLabel("Search:") @@ -145,17 +164,17 @@ def __init__(self, window): # searchLayout.addWidget(searchLabel) # searchLayout.addWidget(self.searchBox) # searchLayout.addStretch() - + # # Insert search layout before the tree widget # layout = self.tab_2.layout() # layout.insertLayout(1, searchLayout) - + # Enable sorting # self.treeWidget.setSortingEnabled(True) - + # Enable sorting # self.treeWidget.setSortingEnabled(True) - + # Initialize lists for Ph2ACF temperatures self.ph2acf_time = [] self.ssa_mean_values = [] @@ -163,26 +182,26 @@ def __init__(self, window): self.mpa_mean_values = [] self.mpa_max_values = [] self.current_fuse_id = None - + # Setup module details tab - # self.setup_module_details_tab() - + # self.setup_module_details_tab() + # Now connect signals after UI elements exist # self.searchBox.textChanged.connect(self.filter_modules) self.ringLE.returnPressed.connect(self.split_ring_and_position) self.positionLE.returnPressed.connect(self.draw_ring) self.mountPB.clicked.connect(self.mount_module) self.unmountPB.clicked.connect(self.unmount_module) - + # Initialize mounted modules dict self.mounted_modules = {} - self.analysisURL="" - fibers=["SfibA","SfibB"] - fibers+=[f"E3;{x}" for x in range(1,6)] - powers=["BINT1"] - powers+=[f"R01;M1.{x}" for x in range(1,13)] - powers+=[f"R01;M2.{x}" for x in range(1,13)] - powers+=[f"R01;M3.{x}" for x in range(1,13)] + self.analysisURL = "" + fibers = ["SfibA", "SfibB"] + fibers += [f"E3;{x}" for x in range(1, 6)] + powers = ["BINT1"] + powers += [f"R01;M1.{x}" for x in range(1, 13)] + powers += [f"R01;M2.{x}" for x in range(1, 13)] + powers += [f"R01;M3.{x}" for x in range(1, 13)] self.layers_to_filters = { "L1_47": { "spacer": "2.6mm", @@ -196,7 +215,7 @@ def __init__(self, window): "spacer": "4.0mm", "speed": "10G", }, - #40,55,68 + # 40,55,68 "L2_40": { "spacer": "2.6mm", "speed": "10G", @@ -216,25 +235,24 @@ def __init__(self, window): } self.fiberCB.addItems(fibers) self.powerCB.addItems(powers) - self.number_of_modules=18 + self.number_of_modules = 18 -# config_dir = os.path.expanduser("~/.config/integration_ui") - #load last session from ~/.config/integration_ui/lastsession.txt + # config_dir = os.path.expanduser("~/.config/integration_ui") + # load last session from ~/.config/integration_ui/lastsession.txt - # Setup thermal camera plotting self.setup_thermal_plot() - + # Setup CAEN control self.caen = CAENControl(self) - + # Connect button signals self.checkIDPB.clicked.connect(self.run_check_id) self.hvOFFTestPB.clicked.connect(self.run_light_on_test) self.hvONTestPB.clicked.connect(self.run_dark_test) self.connectPowerPB.clicked.connect(self.connect_power) self.connectFiberPB.clicked.connect(self.connect_fiber) - + # Connect settings-related signals self.checkIDCommandLE.textChanged.connect(self.save_settings) self.lightOnCommandLE.textChanged.connect(self.save_settings) @@ -244,7 +262,7 @@ def __init__(self, window): self.mqttTopicLE.textChanged.connect(self.save_settings) self.airCommandLE.textChanged.connect(self.save_settings) self.resultsUrlLE.textChanged.connect(self.save_settings) - + # Connect Apply button self.applySettingsPB.clicked.connect(self.apply_settings) @@ -256,13 +274,13 @@ def __init__(self, window): # self.spacerCB.currentTextChanged.connect(self.update_module_list) # self.spacerCB_2.currentTextChanged.connect(self.update_module_list) # self.spacerCB_3.currentTextChanged.connect(self.update_module_list) - + # # Initial module list load self.update_module_list() # # Connect select module button # self.selectModulePB.clicked.connect(self.select_module) - + # # Enable selection mode for tree widget # self.treeWidget.setSelectionMode(QTreeWidget.SingleSelection) @@ -270,18 +288,20 @@ def __init__(self, window): self.moduleLE.textChanged.connect(self.module_changed) self.powerCB.currentTextChanged.connect(self.update_connection_status) self.fiberCB.currentTextChanged.connect(self.update_connection_status) - self.fiber_endpoint="" - + self.fiber_endpoint = "" + # Initial connection status check - QTimer.singleShot(100, self.update_connection_status) # Small delay to ensure UI is ready + QTimer.singleShot( + 100, self.update_connection_status + ) # Small delay to ensure UI is ready # Connect module details buttons # self.editDetailsButton.clicked.connect(self.edit_selected_detail) # self.saveDetailsButton.clicked.connect(self.save_module_details) - + # Setup inventory buttons # self.setup_inventory_buttons() - + # Initialize current module tracking self.current_module_id = None @@ -289,38 +309,40 @@ def __init__(self, window): self.checkIDLED.setStyleSheet("background-color: rgb(255, 255, 0);") # Yellow self.hvOFFTestLED.setStyleSheet("background-color: rgb(255, 255, 0);") self.hvONTestLED.setStyleSheet("background-color: rgb(255, 255, 0);") - + # Disable tests 2 and 3 initially -# self.hvOFFTestPB.setEnabled(False) -# self.hvONTestPB.setEnabled(False) - + # self.hvOFFTestPB.setEnabled(False) + # self.hvONTestPB.setEnabled(False) + # Track command workers self.current_worker = None # Connect module ID changes to reset test states self.moduleLE.textChanged.connect(self.reset_test_states) - + # Connect module selection to reset test states self.module_db.ui.selectModulePB.clicked.connect(self.reset_test_states) - + self.logsPB.clicked.connect(self.open_ph2acf_log) # Connect module ID changes to load details -# self.moduleLE.textChanged.connect(self.load_module_details) + # self.moduleLE.textChanged.connect(self.load_module_details) # Populate layer type combo box # self.ui.layertypeCB.clear() # self.ui.layertypeCB.addItem("any") # self.ui.layertypeCB.addItems(sorted(self.layers_to_filters.keys())) - + # Connect signals - #self.ui.layertypeCB.currentTextChanged.connect(self.update_filters_from_layer) + # self.ui.layertypeCB.currentTextChanged.connect(self.update_filters_from_layer) self.ringLE.textChanged.connect(self.update_layer_from_ring) # Connect module ID changes to check mounting status self.moduleLE.textChanged.connect(self.check_module_mounting_status) - session_file = os.path.join(os.path.expanduser("~/.config/integration_ui"), "lastsession.txt") + session_file = os.path.join( + os.path.expanduser("~/.config/integration_ui"), "lastsession.txt" + ) if os.path.exists(session_file): with open(session_file, "r") as f: session = f.read().strip() @@ -330,16 +352,15 @@ def __init__(self, window): self.ringLE.setText("L1") self.split_ring_and_position() - self.draw_ring() + self.draw_ring() # Connect air control buttons self.airONPB.clicked.connect(lambda: self.control_air(True)) self.airOFFPB.clicked.connect(lambda: self.control_air(False)) - + # Connect test results button self.showPB.clicked.connect(self.show_test_results) - - + # Store max temperature self.max_temperature = 0.0 @@ -351,24 +372,25 @@ def __init__(self, window): self.current_session_operator = None self.current_session_comments = None self.current_module_id = None - self.pbstatus={} + self.pbstatus = {} self.airLed.setStyleSheet("background-color: yellow;") - #define test session for DB -# session = { -# "operator": self.BI_Operator_line.text(), -# "timestamp": datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), -# "description": self.SeshDescription_db.text(), -# "temperatures": { -# "low": self.BI_LowTemp_dsb.value(), -# "high": self.BI_HighTemp_dsb.value(), -# }, -# "nCycles": self.BI_NCycles_sb.value(), -# # "status": "Open" #to be implemented -# "modulesList": [], -# } + # define test session for DB + + # session = { + # "operator": self.BI_Operator_line.text(), + # "timestamp": datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), + # "description": self.SeshDescription_db.text(), + # "temperatures": { + # "low": self.BI_LowTemp_dsb.value(), + # "high": self.BI_HighTemp_dsb.value(), + # }, + # "nCycles": self.BI_NCycles_sb.value(), + # # "status": "Open" #to be implemented + # "modulesList": [], + # } def disable_test_pbs_enable_cancel(self): - self.pbstatus ={ + self.pbstatus = { self.checkIDPB: self.checkIDPB.isEnabled(), self.hvOFFTestPB: self.hvOFFTestPB.isEnabled(), self.hvONTestPB: self.hvONTestPB.isEnabled(), @@ -377,7 +399,6 @@ def disable_test_pbs_enable_cancel(self): self.hvOFFTestPB.setEnabled(False) self.hvONTestPB.setEnabled(False) self.cancelPB.setEnabled(True) - def reset_test_pbs(self): print("Resetting test PBs") @@ -386,35 +407,37 @@ def reset_test_pbs(self): pb.setEnabled(status) self.cancelPB.setEnabled(False) - def new_session(self): - session={ + session = { "operator": self.operatorLE.text(), - "description": "INTEGRATION: "+self.commentsLE.text(), - "timestamp": datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), - "modulesList": [ self.moduleLE.text() ], + "description": "INTEGRATION: " + self.commentsLE.text(), + "timestamp": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + "modulesList": [self.moduleLE.text()], } - #create new session in DB - success, result = self.make_api_request("sessions", "POST", session) + # create new session in DB + success, result = self.make_api_request("sessions", "POST", session) self.current_session = result["sessionName"] self.current_module_id = self.moduleLE.text() self.current_session_operator = self.operatorLE.text() self.current_session_comments = self.commentsLE.text() print(self.current_session) return self.current_session - + def get_session(self): if self.current_session is None: return self.new_session() - if self.current_session_operator != self.operatorLE.text() or self.current_session_comments != self.commentsLE.text(): + if ( + self.current_session_operator != self.operatorLE.text() + or self.current_session_comments != self.commentsLE.text() + ): return self.new_session() if self.moduleLE.text() != self.current_module_id: return self.new_session() return self.current_session - + def setup_thermal_plot(self): self.fig, (self.ax1, self.ax2) = plt.subplots(2, 1, figsize=(8, 6)) - + # First plot: Image self.im = self.ax1.imshow(np.random.rand(24, 32) * 30 + 10, cmap="plasma") divider1 = make_axes_locatable(self.ax1) @@ -427,18 +450,26 @@ def setup_thermal_plot(self): self.max_values = [] self.avg_values = [] - self.line_min, = self.ax2.plot(self.time, self.min_values, label='Min') - self.line_max, = self.ax2.plot(self.time, self.max_values, label='Max') - self.line_avg, = self.ax2.plot(self.time, self.avg_values, label='Avg') - + (self.line_min,) = self.ax2.plot(self.time, self.min_values, label="Min") + (self.line_max,) = self.ax2.plot(self.time, self.max_values, label="Max") + (self.line_avg,) = self.ax2.plot(self.time, self.avg_values, label="Avg") + # SSA/MPA trend lines - self.line_ssa_mean, = self.ax2.plot([], [], label='SSA Mean', linestyle='-', marker='s', markersize=3) - self.line_ssa_max, = self.ax2.plot([], [], label='SSA Max', linestyle='-', marker='^', markersize=3) - self.line_mpa_mean, = self.ax2.plot([], [], label='MPA Mean', linestyle='-', marker='o', markersize=3) - self.line_mpa_max, = self.ax2.plot([], [], label='MPA Max', linestyle='-', marker='v', markersize=3) - - self.ax2.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S')) - self.ax2.legend(fontsize='xx-small', loc='upper left', ncol=2) + (self.line_ssa_mean,) = self.ax2.plot( + [], [], label="SSA Mean", linestyle="-", marker="s", markersize=3 + ) + (self.line_ssa_max,) = self.ax2.plot( + [], [], label="SSA Max", linestyle="-", marker="^", markersize=3 + ) + (self.line_mpa_mean,) = self.ax2.plot( + [], [], label="MPA Mean", linestyle="-", marker="o", markersize=3 + ) + (self.line_mpa_max,) = self.ax2.plot( + [], [], label="MPA Max", linestyle="-", marker="v", markersize=3 + ) + + self.ax2.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) + self.ax2.legend(fontsize="xx-small", loc="upper left", ncol=2) # Create canvas and add to layout self.canvas = FigureCanvas(self.fig) @@ -447,8 +478,12 @@ def setup_thermal_plot(self): def get_settings_file(self): """Get the settings file path""" - config_file = os.path.join(os.path.expanduser("~/.config/integration_ui"), 'settings.yaml') - bundled_file = os.path.join(os.path.dirname(__file__), 'settings_integration.yaml') + config_file = os.path.join( + os.path.expanduser("~/.config/integration_ui"), "settings.yaml" + ) + bundled_file = os.path.join( + os.path.dirname(__file__), "settings_integration.yaml" + ) if os.path.exists(config_file): return config_file elif os.path.exists(bundled_file): @@ -459,57 +494,65 @@ def get_settings_file(self): def load_settings(self): """Load settings from YAML file""" try: - with open(self.get_settings_file(), 'r') as f: + with open(self.get_settings_file(), "r") as f: settings = yaml.safe_load(f) - + if settings: print(settings) print(self.get_settings_file()) # Load database URL - self.dbEndpointLE.setText(settings.get('db_url', 'http://localhost:5000')) - + self.dbEndpointLE.setText( + settings.get("db_url", "http://localhost:5000") + ) + # Load MQTT settings - self.mqttServerLE.setText(settings.get('mqtt_server', 'test.mosquitto.org')) - self.mqttTopicLE.setText(settings.get('mqtt_topic', '/ar/thermal/image')) - + self.mqttServerLE.setText( + settings.get("mqtt_server", "test.mosquitto.org") + ) + self.mqttTopicLE.setText( + settings.get("mqtt_topic", "/ar/thermal/image") + ) + # Load command settings - self.checkIDCommandLE.setText(settings.get('check_id_command', '')) - self.lightOnCommandLE.setText(settings.get('light_on_command', '')) - self.darkTestCommandLE.setText(settings.get('dark_test_command', '')) - self.airCommandLE.setText(settings.get('air_command', 'air.sh {airOn}')) - self.ph2acfTopicLE.setText(settings.get('ph2acf_topic', '/ph2acf/data')) - self.resultsUrlLE.setText(settings.get('results_url', 'file:Results/html/latest/index.html')) - + self.checkIDCommandLE.setText(settings.get("check_id_command", "")) + self.lightOnCommandLE.setText(settings.get("light_on_command", "")) + self.darkTestCommandLE.setText(settings.get("dark_test_command", "")) + self.airCommandLE.setText(settings.get("air_command", "air.sh {airOn}")) + self.ph2acfTopicLE.setText(settings.get("ph2acf_topic", "/ph2acf/data")) + self.resultsUrlLE.setText( + settings.get("results_url", "file:Results/html/latest/index.html") + ) + except FileNotFoundError: # Use defaults if no settings file exists self.save_settings() - def get_api_url(self, endpoint=''): + def get_api_url(self, endpoint=""): """Get full API URL with endpoint""" - base_url = self.dbEndpointLE.text().rstrip('/') + base_url = self.dbEndpointLE.text().rstrip("/") return f"{base_url}/{endpoint.lstrip('/')}" if endpoint else base_url def save_settings(self): """Save settings to YAML file""" settings = { - 'db_url': self.dbEndpointLE.text(), - 'mqtt_server': self.mqttServerLE.text(), - 'mqtt_topic': self.mqttTopicLE.text(), + "db_url": self.dbEndpointLE.text(), + "mqtt_server": self.mqttServerLE.text(), + "mqtt_topic": self.mqttTopicLE.text(), # Add command settings - 'check_id_command': self.checkIDCommandLE.text(), - 'light_on_command': self.lightOnCommandLE.text(), - 'dark_test_command': self.darkTestCommandLE.text(), - 'air_command': self.airCommandLE.text(), - 'ph2acf_topic': self.ph2acfTopicLE.text(), - 'results_url': self.resultsUrlLE.text(), + "check_id_command": self.checkIDCommandLE.text(), + "light_on_command": self.lightOnCommandLE.text(), + "dark_test_command": self.darkTestCommandLE.text(), + "air_command": self.airCommandLE.text(), + "ph2acf_topic": self.ph2acfTopicLE.text(), + "results_url": self.resultsUrlLE.text(), } - + try: # Ensure config directory exists config_dir = os.path.dirname(self.get_settings_file()) os.makedirs(config_dir, exist_ok=True) - - with open(self.get_settings_file(), 'w') as f: + + with open(self.get_settings_file(), "w") as f: yaml.dump(settings, f, default_flow_style=False) except Exception as e: self.log_output(f"Error saving settings: {e}") @@ -524,20 +567,20 @@ def setup_mqtt(self): self.client.loop_stop() except: pass - + self.client = mqtt.Client() self.client.on_connect = self.on_mqtt_connect self.client.on_message = self.on_mqtt_message self.client.on_disconnect = self.on_mqtt_disconnect - + # Use settings from UI mqtt_server = self.mqttServerLE.text() - + # Connect with timeout self.log_output(f"Connecting to MQTT server: {mqtt_server}") self.client.connect_async(mqtt_server, 1883, 60) self.client.loop_start() - + except Exception as e: self.log_output(f"MQTT Setup Error: {str(e)}") # Don't let MQTT errors crash the UI @@ -552,12 +595,14 @@ def on_mqtt_connect(self, client, userdata, flags, rc): mqtt_topic = self.mqttTopicLE.text() ph2acf_topic = self.ph2acfTopicLE.text() self.log_output(f"Subscribing to topics: {mqtt_topic}, {ph2acf_topic}") - client.subscribe([ - ("/air/status", 0), - (mqtt_topic, 0), - (ph2acf_topic, 0), - ("shellies/ventola/status/switch:0", 0) - ]) + client.subscribe( + [ + ("/air/status", 0), + (mqtt_topic, 0), + (ph2acf_topic, 0), + ("shellies/ventola/status/switch:0", 0), + ] + ) else: self.log_output(f"MQTT Connection failed with code {rc}") except Exception as e: @@ -568,184 +613,203 @@ def on_mqtt_disconnect(self, client, userdata, rc): try: self.log_output("Disconnected from MQTT server") if rc != 0: - self.log_output("Unexpected disconnection. Will attempt to reconnect...") + self.log_output( + "Unexpected disconnection. Will attempt to reconnect..." + ) except Exception as e: self.log_output(f"MQTT Disconnect Error: {str(e)}") def on_mqtt_message(self, client, userdata, msg): """Handle MQTT messages with error protection""" -# print(msg.topic) - if msg.topic == "shellies/ventola/status/switch:0" : - j=json.loads(msg.payload) + # print(msg.topic) + if msg.topic == "shellies/ventola/status/switch:0": + j = json.loads(msg.payload) print(j) - if j.get("apower",0) > 0.1 : + if j.get("apower", 0) > 0.1: self.airLed.setStyleSheet("background-color: green;") else: self.airLed.setStyleSheet("background-color: red;") - + elif msg.topic == "/air/status": - # print(msg.payload) - if int(msg.payload)==1: + # print(msg.payload) + if int(msg.payload) == 1: self.airLed.setStyleSheet("background-color: green;") else: self.airLed.setStyleSheet("background-color: red;") elif msg.topic == self.ph2acfTopicLE.text(): - try: - data = json.loads(msg.payload) - # Only process if fuseId matches (and is not None) - fuse_id_msg = data.get("LpGBT_OG0_fuseId") - if self.current_fuse_id is not None and fuse_id_msg is not None and int(fuse_id_msg) == int(self.current_fuse_id): - # We have matching data, extract temperatures - ssa_temps = [] - mpa_temps = [] - missing_any_offset = False - - offsets = {} - if self.current_module_data: - if "temperature_offset" in self.current_module_data: - offsets = self.current_module_data["temperature_offset"] - else: - # Look for alternative keys starting with 'temperature_offset' - variants = sorted([k for k in self.current_module_data.keys() if k.startswith("temperature_offset")]) - if variants: - offsets = self.current_module_data[variants[0]] - - if not isinstance(offsets, dict): + try: + data = json.loads(msg.payload) + # Only process if fuseId matches (and is not None) + fuse_id_msg = data.get("LpGBT_OG0_fuseId") + if ( + self.current_fuse_id is not None + and fuse_id_msg is not None + and int(fuse_id_msg) == int(self.current_fuse_id) + ): + # We have matching data, extract temperatures + ssa_temps = [] + mpa_temps = [] + missing_any_offset = False + offsets = {} - print(offsets) - for key, value in data.items(): - if key.endswith("_temp"): - # Extract hybrid and chip from key like SSA_H0_C0_temp - print(key,value) - parts = key.split('_') - if len(parts) >= 3: - ctype = parts[0] # SSA or MPA (though MPA often encoded as SSA in some Ph2_ACF versions) - hybrid = parts[1] # H0 or H1 - chip = parts[2] # C0, C1, ... - - # Clean up indices - h_idx = hybrid[1:] if hybrid.startswith('H') else hybrid - c_idx = chip[1:] if chip.startswith('C') else chip - - # Construct offset key like SSA_H0_0 - # Note: The mapping provided in prompt says SSA_H0_0, MPA_H0_8 etc. - # We need to determine if it's SSA or MPA based on the chip index or key name. - # User said "SSA/MPA mean and max". - # Let's use the provided mapping logic: keys like SSA_H0_0 - offset_key = f"{ctype}_{hybrid}_{c_idx}" - - temp = float(value) - if offset_key in offsets: - temp += float(offsets[offset_key]) - else: - missing_any_offset = True - - if ctype == "SSA": - ssa_temps.append(temp) - elif ctype == "MPA": - mpa_temps.append(temp) - else: - # Fallback/Mixed - ssa_temps.append(temp) - + if self.current_module_data: + if "temperature_offset" in self.current_module_data: + offsets = self.current_module_data["temperature_offset"] + else: + # Look for alternative keys starting with 'temperature_offset' + variants = sorted( + [ + k + for k in self.current_module_data.keys() + if k.startswith("temperature_offset") + ] + ) + if variants: + offsets = self.current_module_data[variants[0]] + + if not isinstance(offsets, dict): + offsets = {} + print(offsets) + for key, value in data.items(): + if key.endswith("_temp"): + # Extract hybrid and chip from key like SSA_H0_C0_temp + print(key, value) + parts = key.split("_") + if len(parts) >= 3: + ctype = parts[ + 0 + ] # SSA or MPA (though MPA often encoded as SSA in some Ph2_ACF versions) + hybrid = parts[1] # H0 or H1 + chip = parts[2] # C0, C1, ... + + # Clean up indices + h_idx = hybrid[1:] if hybrid.startswith("H") else hybrid + c_idx = chip[1:] if chip.startswith("C") else chip + + # Construct offset key like SSA_H0_0 + # Note: The mapping provided in prompt says SSA_H0_0, MPA_H0_8 etc. + # We need to determine if it's SSA or MPA based on the chip index or key name. + # User said "SSA/MPA mean and max". + # Let's use the provided mapping logic: keys like SSA_H0_0 + offset_key = f"{ctype}_{hybrid}_{c_idx}" + + temp = float(value) + if offset_key in offsets: + temp += float(offsets[offset_key]) + else: + missing_any_offset = True + + if ctype == "SSA": + ssa_temps.append(temp) + elif ctype == "MPA": + mpa_temps.append(temp) + else: + # Fallback/Mixed + ssa_temps.append(temp) + + now = datetime.now() + # Update trend + self.ph2acf_time.append(now) + + # Keep maximum 600 values for Ph2ACF data + if len(self.ph2acf_time) > 600: + self.ph2acf_time.pop(0) + self.ssa_mean_values.pop(0) + self.ssa_max_values.pop(0) + self.mpa_mean_values.pop(0) + self.mpa_max_values.pop(0) + + # SSA Stats + if ssa_temps: + self.ssa_mean_values.append(np.mean(ssa_temps)) + self.ssa_max_values.append(np.max(ssa_temps)) + else: + self.ssa_mean_values.append(np.nan) + self.ssa_max_values.append(np.nan) + + # MPA Stats + if mpa_temps: + self.mpa_mean_values.append(np.mean(mpa_temps)) + self.mpa_max_values.append(np.max(mpa_temps)) + else: + self.mpa_mean_values.append(np.nan) + self.mpa_max_values.append(np.nan) + + print( + f"DEBUG: SSA Mean: {self.ssa_mean_values[-1] if ssa_temps else 'N/A'}" + ) + + # Update plot lines + # Set line style based on offset presence + ls = "--" if missing_any_offset else "-" + + self.line_ssa_mean.set_data(self.ph2acf_time, self.ssa_mean_values) + self.line_ssa_mean.set_linestyle(ls) + self.line_ssa_max.set_data(self.ph2acf_time, self.ssa_max_values) + self.line_ssa_max.set_linestyle(ls) + + self.line_mpa_mean.set_data(self.ph2acf_time, self.mpa_mean_values) + self.line_mpa_mean.set_linestyle(ls) + self.line_mpa_max.set_data(self.ph2acf_time, self.mpa_max_values) + self.line_mpa_max.set_linestyle(ls) + + self.ax2.relim() + self.ax2.autoscale_view() + self.canvas.draw() + + except Exception as e: + self.log_output(f"Ph2ACF Message Error: {str(e)}") + + elif msg.topic == self.mqttTopicLE.text(): + try: + flo_arr = [ + struct.unpack("f", msg.payload[i : i + 4])[0] + for i in range(0, len(msg.payload), 4) + ] + + # Update image plot + self.im.set_data(np.array(flo_arr).reshape(24, 32)) + self.im.set_clim(18, 35) + + # Update trend plot now = datetime.now() - # Update trend - self.ph2acf_time.append(now) - - # Keep maximum 600 values for Ph2ACF data - if len(self.ph2acf_time) > 600: - self.ph2acf_time.pop(0) - self.ssa_mean_values.pop(0) - self.ssa_max_values.pop(0) - self.mpa_mean_values.pop(0) - self.mpa_max_values.pop(0) - - # SSA Stats - if ssa_temps: - self.ssa_mean_values.append(np.mean(ssa_temps)) - self.ssa_max_values.append(np.max(ssa_temps)) - else: - self.ssa_mean_values.append(np.nan) - self.ssa_max_values.append(np.nan) - - # MPA Stats - if mpa_temps: - self.mpa_mean_values.append(np.mean(mpa_temps)) - self.mpa_max_values.append(np.max(mpa_temps)) - else: - self.mpa_mean_values.append(np.nan) - self.mpa_max_values.append(np.nan) - - print(f"DEBUG: SSA Mean: {self.ssa_mean_values[-1] if ssa_temps else 'N/A'}") - - # Update plot lines - # Set line style based on offset presence - ls = '--' if missing_any_offset else '-' - - self.line_ssa_mean.set_data(self.ph2acf_time, self.ssa_mean_values) - self.line_ssa_mean.set_linestyle(ls) - self.line_ssa_max.set_data(self.ph2acf_time, self.ssa_max_values) - self.line_ssa_max.set_linestyle(ls) - - self.line_mpa_mean.set_data(self.ph2acf_time, self.mpa_mean_values) - self.line_mpa_mean.set_linestyle(ls) - self.line_mpa_max.set_data(self.ph2acf_time, self.mpa_max_values) - self.line_mpa_max.set_linestyle(ls) - + self.time.append(now) + + self.min_values.append(min(flo_arr)) + self.max_values.append(max(flo_arr)) + self.avg_values.append(np.mean(flo_arr)) + data = { + "min": min(flo_arr), + "max": max(flo_arr), + "avg": np.mean(flo_arr), + } + ret = client.publish("/integration/thermalcamera", json.dumps(data)) + + # Update max temperature display + self.max_temperature = max(flo_arr) + self.tMaxLabel.setText(f"Tmax: {self.max_temperature:.1f}") + if self.max_temperature > 45: + self.caenGUI.safe_lv_off() + if self.max_temperature > 50: + self.caenGUI.off(self.caenGUI.channels["HV"]) + self.caenGUI.off(self.caenGUI.channels["LV"]) + # Keep maximum 600 values + if len(self.time) > 600: + self.time.pop(0) + self.min_values.pop(0) + self.max_values.pop(0) + self.avg_values.pop(0) + + self.line_min.set_data(self.time, self.min_values) + self.line_max.set_data(self.time, self.max_values) + self.line_avg.set_data(self.time, self.avg_values) self.ax2.relim() self.ax2.autoscale_view() + self.canvas.draw() - - except Exception as e: - self.log_output(f"Ph2ACF Message Error: {str(e)}") - elif msg.topic == self.mqttTopicLE.text(): - try: - flo_arr = [ - struct.unpack("f", msg.payload[i : i + 4])[0] - for i in range(0, len(msg.payload), 4) - ] - - # Update image plot - self.im.set_data(np.array(flo_arr).reshape(24, 32)) - self.im.set_clim(18, 35) - - # Update trend plot - now = datetime.now() - self.time.append(now) - - self.min_values.append(min(flo_arr)) - self.max_values.append(max(flo_arr)) - self.avg_values.append(np.mean(flo_arr)) - data={ "min": min(flo_arr), "max": max(flo_arr), "avg":np.mean(flo_arr) } - ret=client.publish("/integration/thermalcamera",json.dumps(data)) - - - # Update max temperature display - self.max_temperature = max(flo_arr) - self.tMaxLabel.setText(f"Tmax: {self.max_temperature:.1f}") - if self.max_temperature > 45 : - self.caenGUI.safe_lv_off() - if self.max_temperature > 50 : - self.caenGUI.off(self.caenGUI.channels["HV"]) - self.caenGUI.off(self.caenGUI.channels["LV"]) - # Keep maximum 600 values - if len(self.time) > 600: - self.time.pop(0) - self.min_values.pop(0) - self.max_values.pop(0) - self.avg_values.pop(0) - - self.line_min.set_data(self.time, self.min_values) - self.line_max.set_data(self.time, self.max_values) - self.line_avg.set_data(self.time, self.avg_values) - self.ax2.relim() - self.ax2.autoscale_view() - - self.canvas.draw() - - except Exception as e: - self.log_output(f"MQTT Message Error: {str(e)}") + except Exception as e: + self.log_output(f"MQTT Message Error: {str(e)}") else: # Handle other topics if any (already handled for air and fan) pass @@ -757,116 +821,119 @@ def split_ring_and_position(self): session_file = os.path.join(config_dir, "lastsession.txt") with open(session_file, "w") as f: f.write(self.ringLE.text()) - ring=ring_position - position="0" - if ';' in ring_position: - ring, position = ring_position.split(';') + ring = ring_position + position = "0" + if ";" in ring_position: + ring, position = ring_position.split(";") self.ringLE.setText(ring) self.positionLE.setText(position) if ring[:2] == "L1": - self.number_of_modules=18 + self.number_of_modules = 18 if ring[:2] == "L2": - self.number_of_modules=26 + self.number_of_modules = 26 if ring[:2] == "L3": - self.number_of_modules=36 + self.number_of_modules = 36 self.draw_ring() - + def draw_ring(self): - #draw a ring with number_of_modules modules, each is draw as a rectangle + # draw a ring with number_of_modules modules, each is draw as a rectangle # even and odd modules are drawn at different radii # draw in graphics view self.update_module_list() scene = QGraphicsScene() self.graphicsView.setScene(scene) - + # Store module coordinates for click detection self.module_coordinates = [] - + pen = QPen() pen.setWidth(2) pen.setColor(Qt.black) - #dashed style + # dashed style pen.setStyle(Qt.DashLine) - radius=min(self.graphicsView.width(),self.graphicsView.height())/2.5 - scene.addEllipse(0, 0, radius*2, radius*2, pen) - deltaphi=360/self.number_of_modules*2 - #solid + radius = min(self.graphicsView.width(), self.graphicsView.height()) / 2.5 + scene.addEllipse(0, 0, radius * 2, radius * 2, pen) + deltaphi = 360 / self.number_of_modules * 2 + # solid pen.setStyle(Qt.SolidLine) - #draw two circles in the middle-top - scene.addEllipse(radius-7, -20, 6, 6, pen) - scene.addEllipse(radius+7, -20, 6, 6, pen) - scene.addEllipse(radius-4, -17, 1, 1, pen) - scene.addEllipse(radius+10, -17, 1, 1, pen) + # draw two circles in the middle-top + scene.addEllipse(radius - 7, -20, 6, 6, pen) + scene.addEllipse(radius + 7, -20, 6, 6, pen) + scene.addEllipse(radius - 4, -17, 1, 1, pen) + scene.addEllipse(radius + 10, -17, 1, 1, pen) ring_id = self.ringLE.text() - - for i in range(1,self.number_of_modules+1): - phi=-(i)*deltaphi+deltaphi/2 - phi-=90 - if i> self.number_of_modules/2: - phi+=deltaphi/2 - + + for i in range(1, self.number_of_modules + 1): + phi = -(i) * deltaphi + deltaphi / 2 + phi -= 90 + if i > self.number_of_modules / 2: + phi += deltaphi / 2 + # Check if this position has a mounted module position_key = f"{ring_id};{i}" is_mounted = position_key in self.mounted_modules - - if self.positionLE.text()==str(i): + + if self.positionLE.text() == str(i): pen.setColor(Qt.red) elif is_mounted: pen.setColor(Qt.blue) # Use blue for mounted positions else: pen.setColor(Qt.black) - + # Calculate module coordinates - if i<= self.number_of_modules/2: - x1=(radius*0.95)*cos((phi+deltaphi/4)/180*pi)+radius - y1=(radius*0.95)*sin((phi+deltaphi/4)/180*pi)+radius - x2=(radius*0.95)*cos((phi-deltaphi/4)/180*pi)+radius - y2=(radius*0.95)*sin((phi-deltaphi/4)/180*pi)+radius - x3=(radius*1.1)*cos((phi+deltaphi/4)/180*pi)+radius - y3=(radius*1.1)*sin((phi+deltaphi/4)/180*pi)+radius - x4=(radius*1.1)*cos((phi-deltaphi/4)/180*pi)+radius - y4=(radius*1.1)*sin((phi-deltaphi/4)/180*pi)+radius + if i <= self.number_of_modules / 2: + x1 = (radius * 0.95) * cos((phi + deltaphi / 4) / 180 * pi) + radius + y1 = (radius * 0.95) * sin((phi + deltaphi / 4) / 180 * pi) + radius + x2 = (radius * 0.95) * cos((phi - deltaphi / 4) / 180 * pi) + radius + y2 = (radius * 0.95) * sin((phi - deltaphi / 4) / 180 * pi) + radius + x3 = (radius * 1.1) * cos((phi + deltaphi / 4) / 180 * pi) + radius + y3 = (radius * 1.1) * sin((phi + deltaphi / 4) / 180 * pi) + radius + x4 = (radius * 1.1) * cos((phi - deltaphi / 4) / 180 * pi) + radius + y4 = (radius * 1.1) * sin((phi - deltaphi / 4) / 180 * pi) + radius else: - x1=(radius*0.9)*cos((phi+deltaphi/4)/180*pi)+radius - y1=(radius*0.9)*sin((phi+deltaphi/4)/180*pi)+radius - x2=(radius*0.9)*cos((phi-deltaphi/4)/180*pi)+radius - y2=(radius*0.9)*sin((phi-deltaphi/4)/180*pi)+radius - x3=(radius*1.05)*cos((phi+deltaphi/4)/180*pi)+radius - y3=(radius*1.05)*sin((phi+deltaphi/4)/180*pi)+radius - x4=(radius*1.05)*cos((phi-deltaphi/4)/180*pi)+radius - y4=(radius*1.05)*sin((phi-deltaphi/4)/180*pi)+radius - + x1 = (radius * 0.9) * cos((phi + deltaphi / 4) / 180 * pi) + radius + y1 = (radius * 0.9) * sin((phi + deltaphi / 4) / 180 * pi) + radius + x2 = (radius * 0.9) * cos((phi - deltaphi / 4) / 180 * pi) + radius + y2 = (radius * 0.9) * sin((phi - deltaphi / 4) / 180 * pi) + radius + x3 = (radius * 1.05) * cos((phi + deltaphi / 4) / 180 * pi) + radius + y3 = (radius * 1.05) * sin((phi + deltaphi / 4) / 180 * pi) + radius + x4 = (radius * 1.05) * cos((phi - deltaphi / 4) / 180 * pi) + radius + y4 = (radius * 1.05) * sin((phi - deltaphi / 4) / 180 * pi) + radius + # Store module coordinates and position number - self.module_coordinates.append({ - 'position': i, - 'coords': [(x1,y1), (x2,y2), (x3,y3), (x4,y4)] - }) - + self.module_coordinates.append( + {"position": i, "coords": [(x1, y1), (x2, y2), (x3, y3), (x4, y4)]} + ) + scene.addLine(x1, y1, x2, y2, pen) scene.addLine(x2, y2, x4, y4, pen) scene.addLine(x4, y4, x3, y3, pen) scene.addLine(x3, y3, x1, y1, pen) - + # Add module ID text for mounted modules if is_mounted: text = self.mounted_modules[position_key] else: - text= str(i) + text = str(i) # Calculate center point of the module center_x = (x1 + x3) / 2 center_y = (y1 + y3) / 2 - + # Calculate angle for radial text (in degrees) angle = atan2(center_y - radius, center_x - radius) * 180 / pi # Add 90 degrees to make text perpendicular to radius - angle += 180-deltaphi/4 - + angle += 180 - deltaphi / 4 + text_item = scene.addText(text) # Center the text around its position text_bounds = text_item.boundingRect() - text_item.setPos((radius*0.5)*cos(phi/180*pi)+radius - text_bounds.width()/2, - (radius*0.5)*sin(phi/180*pi)+radius - text_bounds.height()/2) + text_item.setPos( + (radius * 0.5) * cos(phi / 180 * pi) + radius - text_bounds.width() / 2, + (radius * 0.5) * sin(phi / 180 * pi) + + radius + - text_bounds.height() / 2, + ) # Set the rotation around the center of the text text_item.setTransformOriginPoint(text_bounds.center()) text_item.setRotation(angle) @@ -881,78 +948,80 @@ def handle_ring_click(self, event): # Convert click coordinates to scene coordinates scene_pos = self.graphicsView.mapToScene(event.pos()) x, y = scene_pos.x(), scene_pos.y() - + # Check if click is inside any module for module in self.module_coordinates: - coords = module['coords'] + coords = module["coords"] # Create polygon from module coordinates polygon = [(x_, y_) for x_, y_ in coords] - + # Calculate bounding box for quick check min_x = min(x for x, _ in polygon) max_x = max(x for x, _ in polygon) min_y = min(y for _, y in polygon) max_y = max(y for _, y in polygon) - + # First do a bounding box check - if (min_x <= x <= max_x and min_y <= y <= max_y): + if min_x <= x <= max_x and min_y <= y <= max_y: # If in bounding box, do detailed polygon check if self.point_in_polygon(x, y, polygon): - new_position = str(module['position']) - + new_position = str(module["position"]) + # Only proceed if position actually changed if new_position != self.positionLE.text(): self.log_output(f"Found match in module {new_position}") - + # Update position LE self.positionLE.setText(new_position) - + # Check if a module is mounted at this position ring_id = self.ringLE.text() position_key = f"{ring_id};{new_position}" - + if position_key in self.mounted_modules: # Update module ID with the mounted module's ID mounted_module_id = self.mounted_modules[position_key] self.moduleLE.setText(mounted_module_id) - + # Reset test states since we have a new module self.reset_test_states() - + # Redraw ring to update highlighting self.draw_ring() break def point_in_polygon(self, x, y, polygon): """Check if point (x,y) is inside polygon using cross product method""" + def sign(p1, p2, p3): return (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]) - + def is_point_in_triangle(pt, v1, v2, v3): d1 = sign(pt, v1, v2) d2 = sign(pt, v2, v3) d3 = sign(pt, v3, v1) - + has_neg = (d1 < 0) or (d2 < 0) or (d3 < 0) has_pos = (d1 > 0) or (d2 > 0) or (d3 > 0) - + return not (has_neg and has_pos) - + # For quadrilateral, split into two triangles and check both point = (x, y) - return (is_point_in_triangle(point, polygon[0], polygon[1], polygon[2]) or - is_point_in_triangle(point, polygon[2], polygon[3], polygon[0])) + return is_point_in_triangle( + point, polygon[0], polygon[1], polygon[2] + ) or is_point_in_triangle(point, polygon[2], polygon[3], polygon[0]) def get_placeholder_values(self): """Get current values for all placeholders""" return { - 'ring_id': self.ringLE.text(), - 'position': self.positionLE.text(), - 'module_id': self.moduleLE.text(), - 'fiber': self.fiberCB.currentText(), - 'power': self.powerCB.currentText(), - 'fiber_endpoint': self.fiber_endpoint, - 'session': self.get_session(), + "ring_id": self.ringLE.text(), + "position": self.positionLE.text(), + "module_id": self.moduleLE.text(), + "fiber": self.fiberCB.currentText(), + "power": self.powerCB.currentText(), + "fiber_endpoint": self.fiber_endpoint, + "session": self.get_session(), } def expand_placeholders(self, text): @@ -973,11 +1042,11 @@ def run_command(self, command): self.current_worker.finished.disconnect() self.current_worker.terminate() self.current_worker.wait() - + expanded_command = self.expand_placeholders(command) self.log_output(f"Running command: {expanded_command}") self.log_output(f"Using placeholders: {self.get_placeholder_values()}") - + self.current_worker = CommandWorker(expanded_command) self.current_worker.finished.connect(self.handle_command_finished) self.current_worker.start() @@ -988,13 +1057,12 @@ def handle_command_finished(self, success, stdout, stderr): self.log_output(f"Output:\n{stdout}") if stderr: self.log_output(f"Error:\n{stderr}") - + # Clean up worker if self.current_worker: self.current_worker.finished.disconnect() self.current_worker = None - - + def show_error_dialog(self, message): """Show error dialog with the given message""" msg = QMessageBox() @@ -1007,19 +1075,17 @@ def show_error_dialog(self, message): def make_api_request(self, endpoint, method, data=None): """Make API request and return result""" url = self.get_api_url(endpoint) - + try: self.log_output(f"Making {method} request to: {url}") - + if data: self.log_output(f"With data: {data}") - + response = requests.request( - method=method.lower(), - url=url, - json=data if data else None + method=method.lower(), url=url, json=data if data else None ) - + if response.status_code != 200 and response.status_code != 201: error_msg = f"API Error ({response.status_code}): {response.text}" self.log_output(error_msg) @@ -1053,44 +1119,55 @@ def append_log(self, text): def run_check_id(self): """Run Check ID test""" self.log_output("=== Running Check ID ===") - #check that operator, comment and module are not empty - if self.operatorLE.text()=="" or self.commentsLE.text()=="" or self.moduleLE.text()=="": + # check that operator, comment and module are not empty + if ( + self.operatorLE.text() == "" + or self.commentsLE.text() == "" + or self.moduleLE.text() == "" + ): self.log_output("Operator, Comments and Module ID must be filled") - #open dialog + # open dialog self.show_error_dialog("Operator, Comments and Module ID must be filled") return + def handle_check_id(success, stdout, stderr): if success: - self.checkIDLED.setStyleSheet("background-color: rgb(85, 170, 0);") # Green + self.checkIDLED.setStyleSheet( + "background-color: rgb(85, 170, 0);" + ) # Green self.checkIDPB.setEnabled(True) self.hvOFFTestPB.setEnabled(True) # Enable test 2 self.hvONTestPB.setEnabled(True) # Enable test 3 - + # Try to parse module ID from output try: # Assuming output format contains "Module ID: XXXX" print(stdout) - # module_id = stdout.split("Module ID:")[1].strip().split()[0] - # self.checkIDlabel.setText(f"ID: {module_id}") - module_id = re.search(r"Board.*Module (.*?)\s\(", stdout).group(1).strip() + # module_id = stdout.split("Module ID:")[1].strip().split()[0] + # self.checkIDlabel.setText(f"ID: {module_id}") + module_id = ( + re.search(r"Board.*Module (.*?)\s\(", stdout).group(1).strip() + ) print(re.search(r"Module (.*?)\s\(", stdout)) self.checkIDlabel.setText(f"ID: {module_id}") except: self.log_output("Could not parse module ID from output") else: self.checkIDLED.setStyleSheet("background-color: red;") - self.hvOFFTestPB.setEnabled(False) + self.hvOFFTestPB.setEnabled(False) self.hvONTestPB.setEnabled(False) self.reset_test_pbs() - self.handle_command_finished(success,stdout,stderr) + self.handle_command_finished(success, stdout, stderr) if self.current_worker: self.current_worker.finished.disconnect() - self.current_worker = CommandWorker(self.expand_placeholders(self.checkIDCommandLE.text())) + self.current_worker = CommandWorker( + self.expand_placeholders(self.checkIDCommandLE.text()) + ) self.current_worker.finished.connect(handle_check_id) - #self.current_worker.finished.connect(self.reset_test_pbs) - #self.current_worker.finished.connect(self.handle_command_finished) + # self.current_worker.finished.connect(self.reset_test_pbs) + # self.current_worker.finished.connect(self.handle_command_finished) self.disable_test_pbs_enable_cancel() self.cancelPB.clicked.connect(self.current_worker.terminate) self.cancelPB.clicked.connect(self.reset_test_pbs) @@ -1102,17 +1179,19 @@ def handle_check_id(success, stdout, stderr): # self.waiting_dialog.setStandardButtons(QMessageBox.Cancel) # self.waiting_dialog.buttonClicked.connect(lambda button: self.current_worker.terminate()) # self.waiting_dialog.show() - #self.current_worker.finished.connect(self.waiting_dialog.accept) + # self.current_worker.finished.connect(self.waiting_dialog.accept) self.current_worker.start() def run_light_on_test(self): """Run Light On Test""" self.log_output("=== Running Light On Test ===") - + def handle_light_on(success, stdout, stderr): if success: - self.hvOFFTestLED.setStyleSheet("background-color: rgb(85, 170, 0);") # Green + self.hvOFFTestLED.setStyleSheet( + "background-color: rgb(85, 170, 0);" + ) # Green self.hvOFFTestCB.setChecked(True) self.checkIDPB.setEnabled(True) self.hvOFFTestPB.setEnabled(True) # Enable test 2 @@ -1122,21 +1201,21 @@ def handle_light_on(success, stdout, stderr): # try: # # Update results label with the output # noise_text = "

Noise:

" - + # # Look for MPA and SSA noise values in the output # mpa_noise = "0.0" # ssa_noise = "0.0" - + # for line in stdout.split('\n'): # if "MPA noise:" in line: # mpa_noise = line.split(":")[-1].strip() # elif "SSA noise:" in line: # ssa_noise = line.split(":")[-1].strip() - + # noise_text += f"

MPA: {mpa_noise}

" # noise_text += f"

SSA: {ssa_noise}

" # noise_text += "" - + # self.resultsLabel.setText(noise_text) # except Exception as e: # self.log_output(f"Error parsing test results: {e}") @@ -1145,21 +1224,20 @@ def handle_light_on(success, stdout, stderr): self.hvOFFTestCB.setChecked(False) self.log_output(f"Light on test error: {stderr}") self.reset_test_pbs() - self.handle_command_finished(success,stdout,stderr) - - - + self.handle_command_finished(success, stdout, stderr) if self.current_worker: self.current_worker.finished.disconnect() - self.current_worker = CommandWorker(self.expand_placeholders(self.lightOnCommandLE.text())) -# self.log_worker = CommandWorker("konsole -e tail -f /home/thermal/BurnIn_moduleTest/logs/Ph2_ACF.log") + self.current_worker = CommandWorker( + self.expand_placeholders(self.lightOnCommandLE.text()) + ) + # self.log_worker = CommandWorker("konsole -e tail -f /home/thermal/BurnIn_moduleTest/logs/Ph2_ACF.log") self.current_worker.finished.connect(handle_light_on) - # self.current_worker.finished.connect(self.reset_test_pbs) -# self.current_worker.finished.connect(self.handle_command_finished) + # self.current_worker.finished.connect(self.reset_test_pbs) + # self.current_worker.finished.connect(self.handle_command_finished) self.disable_test_pbs_enable_cancel() self.cancelPB.clicked.connect(self.current_worker.terminate) - self.cancelPB.clicked.connect(self.reset_test_pbs) + self.cancelPB.clicked.connect(self.reset_test_pbs) # self.waiting_dialog = QMessageBox() # self.waiting_dialog.setWindowTitle("Running Ph2_ACF") # self.waiting_dialog.setText("test with lights on") @@ -1169,74 +1247,90 @@ def handle_light_on(success, stdout, stderr): # self.waiting_dialog.show() # self.current_worker.finished.connect(self.waiting_dialog.accept) - self.current_worker.start() -# self.log_worker.start() + + # self.log_worker.start() def run_dark_test(self): """Run Dark Test""" self.log_output("=== Running Dark Test ===") - + def handle_dark_test(success, stdout, stderr): if success: try: - self.hvONTestLED.setStyleSheet("background-color: rgb(85, 170, 0);") # Green + self.hvONTestLED.setStyleSheet( + "background-color: rgb(85, 170, 0);" + ) # Green self.hvONTestCB.setChecked(True) self.checkIDPB.setEnabled(True) self.hvOFFTestPB.setEnabled(True) # Enable test 2 self.hvONTestPB.setEnabled(True) # Enable test 3 - #{"message": "Entry created", "moduleTestAnalysisName": "Module_PS_26_IPG-10005_Run_run590_Result_Test7"} - moduleTestAnalysisName=re.search(r".*moduleTestAnalysisName\": \"(.*?)\"}", stdout).group(1) - #query DB from http://....:5000/module_test_analysis endpoint - #get the result from the DB - analysisData=self.make_api_request("module_test_analysis/"+moduleTestAnalysisName, "GET") + # {"message": "Entry created", "moduleTestAnalysisName": "Module_PS_26_IPG-10005_Run_run590_Result_Test7"} + moduleTestAnalysisName = re.search( + r".*moduleTestAnalysisName\": \"(.*?)\"}", stdout + ).group(1) + # query DB from http://....:5000/module_test_analysis endpoint + # get the result from the DB + analysisData = self.make_api_request( + "module_test_analysis/" + moduleTestAnalysisName, "GET" + ) if analysisData[0]: print(analysisData[1]) - self.analysisURL=analysisData[1]["analysisFile"] - res={} + self.analysisURL = analysisData[1]["analysisFile"] + res = {} for k in analysisData[1]["analysisSummary"].keys(): print(k) if "Average" in k: - chip=k.split("_")[-1] - hybrid=int(k.split("_")[-2][1:]) % 2 - res[(hybrid,chip)]=analysisData[1]["analysisSummary"][k] - + chip = k.split("_")[-1] + hybrid = int(k.split("_")[-2][1:]) % 2 + res[(hybrid, chip)] = analysisData[1][ + "analysisSummary" + ][k] + noise_text = "

Noise:

" noise_text += "" - noise_text += "" - noise_text += ""%(res[(0,"SSA")],res[(1,"SSA")]) - noise_text += ""%(res[(0,"MPA")],res[(1,"MPA")]) + noise_text += ( + "" + ) + noise_text += ( + "" + % (res[(0, "SSA")], res[(1, "SSA")]) + ) + noise_text += ( + "" + % (res[(0, "MPA")], res[(1, "MPA")]) + ) noise_text += "" print(noise_text) self.resultsLabel.setText(noise_text) - #ensure label is redrawn + # ensure label is redrawn self.resultsLabel.repaint() - self.resultsLabel.setToolTip(str(analysisData[1]["analysisSummary"])) + self.resultsLabel.setToolTip( + str(analysisData[1]["analysisSummary"]) + ) except Exception as e: self.log_output(f"Error parsing test results: {e}") -# self.log_worker.terminate() - + # self.log_worker.terminate() + else: self.hvONTestLED.setStyleSheet("background-color: red;") self.hvONTestCB.setChecked(False) self.log_output(f"Dark test error: {stderr}") self.reset_test_pbs() - self.handle_command_finished(success,stdout,stderr) + self.handle_command_finished(success, stdout, stderr) if self.current_worker: self.current_worker.finished.disconnect() - self.current_worker = CommandWorker(self.expand_placeholders(self.darkTestCommandLE.text())) + self.current_worker = CommandWorker( + self.expand_placeholders(self.darkTestCommandLE.text()) + ) self.current_worker.finished.connect(handle_dark_test) - # self.current_worker.finished.connect(self.reset_test_pbs) - #self.current_worker.finished.connect(self.handle_command_finished) + # self.current_worker.finished.connect(self.reset_test_pbs) + # self.current_worker.finished.connect(self.handle_command_finished) self.disable_test_pbs_enable_cancel() self.cancelPB.clicked.connect(self.current_worker.terminate) self.cancelPB.clicked.connect(self.reset_test_pbs) - - - - # self.waiting_dialog = QMessageBox() # self.waiting_dialog.setWindowTitle("Running Ph2_ACF") # self.waiting_dialog.setText("test with modules in dark") @@ -1245,24 +1339,25 @@ def handle_dark_test(success, stdout, stderr): # self.waiting_dialog.buttonClicked.connect(lambda button: self.current_worker.terminate()) # self.waiting_dialog.show() # self.current_worker.finished.connect(self.waiting_dialog.accept) - #self.current_worker.finished.connect(self.log_worker.quit) + # self.current_worker.finished.connect(self.log_worker.quit) self.current_worker.start() + def open_ph2acf_log(self): - self.log_worker = CommandWorker("konsole -e tail -f /home/thermal/BurnIn_moduleTest/logs/Ph2_ACF.log") + self.log_worker = CommandWorker( + "konsole -e tail -f /home/thermal/BurnIn_moduleTest/logs/Ph2_ACF.log" + ) self.log_worker.start() def disconnect_connection(self, cable_id, port): """Disconnect a cable's detSide connections""" try: - data = { - "cable": cable_id, - } - self.make_api_request( - endpoint="disconnect_all_detSide", - method="POST", - data=data - ) + data = { + "cable": cable_id, + } + self.make_api_request( + endpoint="disconnect_all_detSide", method="POST", data=data + ) except Exception as e: self.log_output(f"Error disconnecting: {str(e)}") @@ -1270,20 +1365,26 @@ def connect_power(self): self.log_output("=== Connecting Power ===") # First disconnect any existing connections self.disconnect_connection(self.powerCB.currentText(), "power") - + # Then make the new connection data = { "cable1": self.moduleLE.text(), - "cable2": self.powerCB.currentText().split(";")[0] if ";" in self.powerCB.currentText() else self.powerCB.currentText(), + "cable2": ( + self.powerCB.currentText().split(";")[0] + if ";" in self.powerCB.currentText() + else self.powerCB.currentText() + ), "port1": "power", - "port2": (""+self.powerCB.currentText().split(";")[1]) if ";" in self.powerCB.currentText() else 'power' + "port2": ( + ("" + self.powerCB.currentText().split(";")[1]) + if ";" in self.powerCB.currentText() + else "power" + ), } success, result = self.make_api_request( - endpoint='connect', # Simplified endpoint - method='POST', - data=data + endpoint="connect", method="POST", data=data # Simplified endpoint ) - + # Update connection status and LEDs self.update_connection_status() self.update_connection_leds() @@ -1292,21 +1393,19 @@ def connect_fiber(self): self.log_output("=== Connecting Fiber ===") # First disconnect any existing connections self.disconnect_connection(self.fiberCB.currentText(), "A") - + # Then make the new connection - fib=self.fiberCB.currentText() + fib = self.fiberCB.currentText() data = { "cable1": self.moduleLE.text(), "cable2": fib.split(";")[0] if ";" in fib else fib, "port1": "fiber", - "port2": fib.split(";")[1] if ";" in fib else "A" + "port2": fib.split(";")[1] if ";" in fib else "A", } success, result = self.make_api_request( - endpoint='connect', # Simplified endpoint - method='POST', - data=data + endpoint="connect", method="POST", data=data # Simplified endpoint ) - + # Update connection status and LEDs self.update_connection_status() self.update_connection_leds() @@ -1314,10 +1413,10 @@ def connect_fiber(self): def apply_settings(self): """Apply current settings""" self.log_output("=== Applying Settings ===") - + # Save settings to file self.save_settings() - + # Reconnect MQTT if self.setup_mqtt(): self.log_output("MQTT settings applied successfully") @@ -1329,73 +1428,69 @@ def update_module_list(self): self.module_db.update_module_list() # Store full module list self.all_modules = self.module_db.all_modules - - # Update mounted modules dict + + # Update mounted modules dict self.mounted_modules = { - m.get("mounted_on", ""): m.get("moduleName", "") - for m in self.all_modules - if m.get("mounted_on") + m.get("mounted_on", ""): m.get("moduleName", "") + for m in self.all_modules + if m.get("mounted_on") } - - def disconnect_module(self, module_id): """Disconnect all connections for a module""" try: self.log_output(f"Disconnecting module: {module_id}") data = { - "cable": module_id, - } - + "cable": module_id, + } + success, result = self.make_api_request( - endpoint="disconnect_all_crateSide", - method="POST", - data=data - ) - + endpoint="disconnect_all_crateSide", method="POST", data=data + ) + # Update the display self.update_module_list() self.update_connection_status() - + except Exception as e: self.log_output(f"Error disconnecting module: {str(e)}") self.show_error_dialog(f"Failed to disconnect module: {str(e)}") - def module_changed(self): self.load_module_details() - module_id=self.moduleLE.text() - query={"cable": module_id, "side": "crateSide"} - response = requests.post( - self.get_api_url('snapshot'), - json=query - ) + module_id = self.moduleLE.text() + query = {"cable": module_id, "side": "crateSide"} + response = requests.post(self.get_api_url("snapshot"), json=query) if response.status_code == 200: snapshot = response.json() - print("LOADED:",snapshot.get("1",None)) - print("LOADED:",snapshot.get("3",None)) - fiber=snapshot.get("1",None) - power=snapshot.get("3",None) - if fiber and len(fiber["connections"])>0: - fib=fiber["connections"][0]["cable"] - fports=fiber["connections"][0]["det_port"] - fports=[x for x in fports if x != "A"] - self.fiberCB.setCurrentText(fib+(f";{fports[0]}" if len(fports) >0 else '' )) - print(fib+(f";{fports[0]}" if len(fports) >0 else '' )) - if power and len(power["connections"])>0: - pcable=power["connections"][0]["cable"] - pports=power["connections"][0]["det_port"] - pports=[x for x in pports if x != "power" and x!= "A"] - self.powerCB.setCurrentText(pcable+(f";{pports[0]}" if len(pports) >0 else '' )) - print(pcable+(f";{pports[0]}" if len(pports) >0 else '' )) - -#LOADED: {'crate_port': 'fiber', 'connections': [{'cable': 'SfibA', 'line': 1, 'det_port': ['A'], 'crate_port': ['67']}, {'cable': 'FC7OT8', 'line': 3, 'det_port': ['OG1'], 'crate_port': []}]} -#LOADED: {'crate_port': 'power', 'connections': [{'cable': 'BINT1', 'line': 3, 'det_port': ['power'], 'crate_port': ['HV', 'HVLV']}, {'cable': 'P001', 'line': 12, 'det_port': ['A', 'B12'], 'crate_port': ['HV12']}, {'cable': 'H48', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT0', 'line': 12, 'det_port': ['12'], 'crate_port': []}]} -# print("LOADED:",self.current_module_data["crateSide"]) - # fiber=self.current_module_data["crateSide"].get("2","") - # power=self.current_module_data["crateSide"].get("3","") - # print(str(fiber[0])+";"+str(fiber[1]//2),power[0]+";"+str(power[1])) + print("LOADED:", snapshot.get("1", None)) + print("LOADED:", snapshot.get("3", None)) + fiber = snapshot.get("1", None) + power = snapshot.get("3", None) + if fiber and len(fiber["connections"]) > 0: + fib = fiber["connections"][0]["cable"] + fports = fiber["connections"][0]["det_port"] + fports = [x for x in fports if x != "A"] + self.fiberCB.setCurrentText( + fib + (f";{fports[0]}" if len(fports) > 0 else "") + ) + print(fib + (f";{fports[0]}" if len(fports) > 0 else "")) + if power and len(power["connections"]) > 0: + pcable = power["connections"][0]["cable"] + pports = power["connections"][0]["det_port"] + pports = [x for x in pports if x != "power" and x != "A"] + self.powerCB.setCurrentText( + pcable + (f";{pports[0]}" if len(pports) > 0 else "") + ) + print(pcable + (f";{pports[0]}" if len(pports) > 0 else "")) + + # LOADED: {'crate_port': 'fiber', 'connections': [{'cable': 'SfibA', 'line': 1, 'det_port': ['A'], 'crate_port': ['67']}, {'cable': 'FC7OT8', 'line': 3, 'det_port': ['OG1'], 'crate_port': []}]} + # LOADED: {'crate_port': 'power', 'connections': [{'cable': 'BINT1', 'line': 3, 'det_port': ['power'], 'crate_port': ['HV', 'HVLV']}, {'cable': 'P001', 'line': 12, 'det_port': ['A', 'B12'], 'crate_port': ['HV12']}, {'cable': 'H48', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT0', 'line': 12, 'det_port': ['12'], 'crate_port': []}]} + # print("LOADED:",self.current_module_data["crateSide"]) + # fiber=self.current_module_data["crateSide"].get("2","") + # power=self.current_module_data["crateSide"].get("3","") + # print(str(fiber[0])+";"+str(fiber[1]//2),power[0]+";"+str(power[1])) self.update_connection_status() def update_connection_status(self): @@ -1403,19 +1498,21 @@ def update_connection_status(self): try: power_id = self.powerCB.currentText() fiber_id = self.fiberCB.currentText() - power_id_slot="" + power_id_slot = "" if ";" in power_id: - power_id_slot= power_id.split(";")[1] - power_id= power_id.split(";")[0] - fiber_id_slot="" + power_id_slot = power_id.split(";")[1] + power_id = power_id.split(";")[0] + fiber_id_slot = "" if ";" in fiber_id: - fiber_id_slot= fiber_id.split(";")[1] - fiber_id= fiber_id.split(";")[0] + fiber_id_slot = fiber_id.split(";")[1] + fiber_id = fiber_id.split(";")[0] # Get power connections power_status = set() module_id = self.moduleLE.text() if module_id: - det_endpoint, crate_endpoints = self.get_power_endpoints_from_module(module_id) + det_endpoint, crate_endpoints = self.get_power_endpoints_from_module( + module_id + ) if crate_endpoints: for crate_endpoint in crate_endpoints: power_status.add(f"{module_id} <--> {crate_endpoint}") @@ -1423,14 +1520,20 @@ def update_connection_status(self): # Get fiber connections fiber_status = set() if fiber_id: - det_endpoint, crate_endpoint = self.get_fiber_endpoints(fiber_id,fiber_id_slot) + det_endpoint, crate_endpoint = self.get_fiber_endpoints( + fiber_id, fiber_id_slot + ) if det_endpoint and crate_endpoint: fiber_status.add(f"{det_endpoint} <--> {crate_endpoint}") self.fiber_endpoint = crate_endpoint # Update labels - self.powerConnectionLabel.setText("\n".join(power_status) if power_status else "Not connected") - self.fiberConnectionLabel.setText("\n".join(fiber_status) if fiber_status else "Not connected") + self.powerConnectionLabel.setText( + "\n".join(power_status) if power_status else "Not connected" + ) + self.fiberConnectionLabel.setText( + "\n".join(fiber_status) if fiber_status else "Not connected" + ) # Update LED colors self.update_connection_leds() @@ -1451,36 +1554,44 @@ def update_connection_leds(self): try: power_id = self.powerCB.currentText() fiber_id = self.fiberCB.currentText() - power_id_slot="" + power_id_slot = "" if ";" in power_id: - power_id_slot= power_id.split(";")[1] - power_id= power_id.split(";")[0] - fiber_id_slot="" + power_id_slot = power_id.split(";")[1] + power_id = power_id.split(";")[0] + fiber_id_slot = "" if ";" in fiber_id: - fiber_id_slot= fiber_id.split(";")[1] - fiber_id= fiber_id.split(";")[0] + fiber_id_slot = fiber_id.split(";")[1] + fiber_id = fiber_id.split(";")[0] # Get power connection status if power_id: - det_endpoint, _ = self.get_power_endpoints(power_id,power_id_slot) + det_endpoint, _ = self.get_power_endpoints(power_id, power_id_slot) if det_endpoint: if self.check_connection_match(det_endpoint): - self.connectPowerLED.setStyleSheet("background-color: rgb(85, 170, 0);") # Green + self.connectPowerLED.setStyleSheet( + "background-color: rgb(85, 170, 0);" + ) # Green else: - self.connectPowerLED.setStyleSheet("background-color: rgb(255, 255, 0);") # Yellow + self.connectPowerLED.setStyleSheet( + "background-color: rgb(255, 255, 0);" + ) # Yellow else: self.connectPowerLED.setStyleSheet("background-color: red;") - + # Get fiber connection status if fiber_id: - det_endpoint, _ = self.get_fiber_endpoints(fiber_id,fiber_id_slot) + det_endpoint, _ = self.get_fiber_endpoints(fiber_id, fiber_id_slot) if det_endpoint: if self.check_connection_match(det_endpoint): - self.connectFiberLED.setStyleSheet("background-color: rgb(85, 170, 0);") # Green + self.connectFiberLED.setStyleSheet( + "background-color: rgb(85, 170, 0);" + ) # Green else: - self.connectFiberLED.setStyleSheet("background-color: rgb(255, 255, 0);") # Yellow + self.connectFiberLED.setStyleSheet( + "background-color: rgb(255, 255, 0);" + ) # Yellow else: self.connectFiberLED.setStyleSheet("background-color: red;") - + except Exception as e: self.log_output(f"Error updating LEDs: {str(e)}") @@ -1488,20 +1599,23 @@ def get_fiber_endpoints(self, fiber_id, fiber_id_slot): """Get both detSide and crateSide endpoints for a fiber connection""" try: # Get detSide path - data={"cable": fiber_id, "side": "detSide"} + data = {"cable": fiber_id, "side": "detSide"} det_response = requests.post( - f"{self.dbEndpointLE.text()}/snapshot", - json=data + f"{self.dbEndpointLE.text()}/snapshot", json=data ) if det_response.status_code != 200: - return None, None + return None, None det_snapshot = det_response.json() det_endpoint = None - if fiber_id_slot != "" : - #filter to get only the lines with det_port=fiber_id_slot - det_snapshot = {k: v for k, v in det_snapshot.items() if v.get("det_port") == fiber_id_slot} - + if fiber_id_slot != "": + # filter to get only the lines with det_port=fiber_id_slot + det_snapshot = { + k: v + for k, v in det_snapshot.items() + if v.get("det_port") == fiber_id_slot + } + for line in det_snapshot: if det_snapshot[line]["connections"]: # Get the last connection in the detSide path @@ -1509,28 +1623,33 @@ def get_fiber_endpoints(self, fiber_id, fiber_id_slot): break # Get crateSide path from the module - #f det_endpoint: + # f det_endpoint: if True: - data={"cable": fiber_id, "side": "crateSide"} - + data = {"cable": fiber_id, "side": "crateSide"} crate_response = requests.post( - f"{self.dbEndpointLE.text()}/snapshot", - json=data + f"{self.dbEndpointLE.text()}/snapshot", json=data ) if crate_response.status_code == 200: crate_snapshot = crate_response.json() - print("Crate",crate_snapshot) + print("Crate", crate_snapshot) # Crate {'1': {'crate_port': 'A', 'det_port': '1', 'connections': [{'cable': 'D3', 'line': 1, 'det_port': ['A'], 'crate_port': ['P12']}, {'cable': 'FC7OT5', 'line': 1, 'det_port': ['OG0'], 'crate_port': []}]}, '2': {'crate_port': 'A', 'det_port': '1', 'connections': [{'cable': 'D3', 'line': 2, 'det_port': ['A'], 'crate_port': ['P12']}, {'cable': 'FC7OT5', 'line': 2, 'det_port': ['OG0'], 'crate_port': []}]}, '3': {'crate_port': 'A', 'det_port': '2', 'connections': [{'cable': 'D3', 'line': 3, 'det_port': ['A'], 'crate_port': ['P34']}, {'cable': 'FC7OT5', 'line': 3, 'det_port': ['OG1'], 'crate_port': []}]}, '4': {'crate_port': 'A', 'det_port': '2', 'connections': [{'cable': 'D3', 'line': 4, 'det_port': ['A'], 'crate_port': ['P34']}, {'cable': 'FC7OT5', 'line': 4, 'det_port': ['OG1'], 'crate_port': []}]}, '5': {'crate_port': 'A', 'det_port': '3', 'connections': [{'cable': 'D3', 'line': 5, 'det_port': ['A'], 'crate_port': ['P56']}, {'cable': 'FC7OT5', 'line': 5, 'det_port': ['OG2'], 'crate_port': []}]}, '6': {'crate_port': 'A', 'det_port': '3', 'connections': [{'cable': 'D3', 'line': 6, 'det_port': ['A'], 'crate_port': ['P56']}, {'cable': 'FC7OT5', 'line': 6, 'det_port': ['OG2'], 'crate_port': []}]}, '7': {'crate_port': 'A', 'det_port': '4', 'connections': [{'cable': 'D3', 'line': 7, 'det_port': ['A'], 'crate_port': ['P78']}, {'cable': 'FC7OT5', 'line': 7, 'det_port': ['OG3'], 'crate_port': []}]}, '8': {'crate_port': 'A', 'det_port': '4', 'connections': [{'cable': 'D3', 'line': 8, 'det_port': ['A'], 'crate_port': ['P78']}, {'cable': 'FC7OT5', 'line': 8, 'det_port': ['OG3'], 'crate_port': []}]}, '9': {'crate_port': 'A', 'det_port': '5', 'connections': [{'cable': 'D3', 'line': 9, 'det_port': ['A'], 'crate_port': ['P910']}, {'cable': 'FC7OT5', 'line': 9, 'det_port': ['OG4'], 'crate_port': []}]}, '10': {'crate_port': 'A', 'det_port': '5', 'connections': [{'cable': 'D3', 'line': 10, 'det_port': ['A'], 'crate_port': ['P910']}, {'cable': 'FC7OT5', 'line': 10, 'det_port': ['OG4'], 'crate_port': []}]}, '11': {'crate_port': 'A', 'det_port': '6', 'connections': [{'cable': 'D3', 'line': 11, 'det_port': ['A'], 'crate_port': ['P1112']}, {'cable': 'FC7OT5', 'line': 11, 'det_port': ['OG5'], 'crate_port': []}]}, '12': {'crate_port': 'A', 'det_port': '6', 'connections': [{'cable': 'D3', 'line': 12, 'det_port': ['A'], 'crate_port': ['P1112']}, {'cable': 'FC7OT5', 'line': 12, 'det_port': ['OG5'], 'crate_port': []}]}} - if fiber_id_slot != "" : - #filter to get only the lines with det_port=fiber_id_slot - crate_snapshot = {k: v for k, v in crate_snapshot.items() if v["det_port"]==fiber_id_slot} - + if fiber_id_slot != "": + # filter to get only the lines with det_port=fiber_id_slot + crate_snapshot = { + k: v + for k, v in crate_snapshot.items() + if v["det_port"] == fiber_id_slot + } + for line in crate_snapshot.keys(): - if line in crate_snapshot and crate_snapshot[line]["connections"]: + if ( + line in crate_snapshot + and crate_snapshot[line]["connections"] + ): last_conn = crate_snapshot[line]["connections"][-1] - ports = last_conn['crate_port'] + last_conn['det_port'] + ports = last_conn["crate_port"] + last_conn["det_port"] port = ports[0] if ports else "?" return det_endpoint, f"{last_conn['cable']}_{port}" @@ -1538,6 +1657,7 @@ def get_fiber_endpoints(self, fiber_id, fiber_id_slot): except Exception as e: self.log_output(f"Error getting fiber endpoints: {str(e)}") return None, None + def get_power_endpoints_from_module(self, module_id): """Get the power endpoint from the module's crateSide connections""" ret = (module_id, []) @@ -1546,12 +1666,12 @@ def get_power_endpoints_from_module(self, module_id): return ret try: response = requests.post( - self.get_api_url('snapshot'), - json={"cable": module_id, "side": "crateSide"} + self.get_api_url("snapshot"), + json={"cable": module_id, "side": "crateSide"}, ) if response.status_code == 200: snapshot = response.json() - print("json:",snapshot) + print("json:", snapshot) for line in snapshot: if snapshot[line]["connections"]: last_conn = snapshot[line]["connections"][-1] @@ -1559,89 +1679,112 @@ def get_power_endpoints_from_module(self, module_id): # Get the last connection in the crateSide path if "FC" in last_conn["cable"]: continue - #set LV/HV channels - if "XSLOT" in last_conn['cable'] : - self.caen.setLV("LV"+last_conn['cable'][5:]+f".{last_conn['line']}") - if "ASLOT" in last_conn['cable'] : - self.caen.setHV("HV"+last_conn['cable'][5:]+f".{last_conn['line']}") - + # set LV/HV channels + if "XSLOT" in last_conn["cable"]: + self.caen.setLV( + "LV" + last_conn["cable"][5:] + f".{last_conn['line']}" + ) + if "ASLOT" in last_conn["cable"]: + self.caen.setHV( + "HV" + last_conn["cable"][5:] + f".{last_conn['line']}" + ) + ret[1].append(f"{last_conn['cable']}.{last_conn['line']}") - print("ret",ret) + print("ret", ret) return ret except Exception as e: self.log_output(f"Error getting power endpoint: {str(e)}") return ret - def get_power_endpoints(self, power_id,power_id_slot=""): + def get_power_endpoints(self, power_id, power_id_slot=""): """Get both detSide and crateSide endpoints for power connections""" try: # Get detSide path - query={"cable": power_id, "side": "detSide"} + query = {"cable": power_id, "side": "detSide"} - det_response = requests.post( - self.get_api_url('snapshot'), - json=query - ) + det_response = requests.post(self.get_api_url("snapshot"), json=query) if det_response.status_code != 200: return None, [] det_snapshot = det_response.json() det_endpoint = None - if power_id_slot != "" : - #filter to get only the lines with det_port=power_id_slot - if "B" == power_id_slot[0] : - power_id_slot=power_id_slot[1:] - det_snapshot = {k: v for k, v in det_snapshot.items() if v.get("crate_port") == "HV%s"%power_id_slot} - + if power_id_slot != "": + # filter to get only the lines with det_port=power_id_slot + if "B" == power_id_slot[0]: + power_id_slot = power_id_slot[1:] + det_snapshot = { + k: v + for k, v in det_snapshot.items() + if v.get("crate_port") == "HV%s" % power_id_slot + } + for line in det_snapshot: if det_snapshot[line]["connections"]: # Get the last connection in the detSide path det_endpoint = det_snapshot[line]["connections"][-1]["cable"] break - print("Det endpoint", det_endpoint) + print("Det endpoint", det_endpoint) # Get crateSide path from the module -# if det_endpoint: + # if det_endpoint: if True: crate_response = requests.post( - self.get_api_url('snapshot'), - json={"cable": power_id, "side": "crateSide"} + self.get_api_url("snapshot"), + json={"cable": power_id, "side": "crateSide"}, ) if crate_response.status_code == 200: crate_snapshot = crate_response.json() crate_endpoints = [] - #print("PCrate_pre",crate_snapshot) -#PCrate_pre {'1': {'crate_port': 'HV1', 'det_port': 'A', 'connections': [{'cable': 'H36', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 1, 'det_port': ['1'], 'crate_port': []}]}, -# '2': {'crate_port': 'HV2', 'det_port': 'A', 'connections': [{'cable': 'H37', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 2, 'det_port': ['2'], 'crate_port': []}]}, -# '3': {'crate_port': 'HV3', 'det_port': 'A', 'connections': [{'cable': 'H38', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 3, 'det_port': ['3'], 'crate_port': []}]}, -# '4': {'crate_port': 'HV4', 'det_port': 'A', 'connections': [{'cable': 'H39', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 4, 'det_port': ['4'], 'crate_port': []}]}, -# '5': {'crate_port': 'HV5', 'det_port': 'A', 'connections': [{'cable': 'H40', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 5, 'det_port': ['5'], 'crate_port': []}]}, -# '6': {'crate_port': 'HV6', 'det_port': 'A', 'connections': [{'cable': 'H41', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 6, 'det_port': ['6'], 'crate_port': []}]}, -# '7': {'crate_port': 'HV7', 'det_port': 'A', 'connections': [{'cable': 'H42', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 7, 'det_port': ['7'], 'crate_port': []}]}, -# '8': {'crate_port': 'HV8', 'det_port': 'A', 'connections': [{'cable': 'H43', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 8, 'det_port': ['8'], 'crate_port': []}]}, -# '9': {'crate_port': 'HV9', 'det_port': 'A', 'connections': [{'cable': 'H44', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 9, 'det_port': ['9'], 'crate_port': []}]}, -# '10': {'crate_port': 'HV10', 'det_port': 'A', 'connections': [{'cable': 'H45', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 10, 'det_port': ['10'], 'crate_port': []}]}, -# '11': {'crate_port': 'HV11', 'det_port': 'A', 'connections': [{'cable': 'H46', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 11, 'det_port': ['11'], 'crate_port': []}]}, '12': {'crate_port': 'HV12', 'det_port': 'A', 'connections': [{'cable': 'H47', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 12, 'det_port': ['12'], 'crate_port': []}]}, -# '13': {'crate_port': 'LV1', 'det_port': 'A', 'connections': [{'cable': 'L5', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT7', 'line': 5, 'det_port': ['down'], 'crate_port': []}]}, '14': {'crate_port': 'LV1', 'det_port': 'A', 'connections': [{'cable': 'L5', 'line': 2, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT7', 'line': 6, 'det_port': ['down'], 'crate_port': []}]}, '15': {'crate_port': 'LV1', 'det_port': 'A', 'connections': [{'cable': 'L5', 'line': 3, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT7', 'line': 7, 'det_port': ['down'], 'crate_port': []}]}, -# '16': {'crate_port': 'LV1', 'det_port': 'A', 'connections': [{'cable': 'L5', 'line': 4, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT7', 'line': 8, 'det_port': ['down'], 'crate_port': []}]}, '17': {'crate_port': 'LV2', 'det_port': 'A', 'connections': [{'cable': 'L13', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 1, 'det_port': ['up'], 'crate_port': []}]}, '18': {'crate_port': 'LV2', 'det_port': 'A', 'connections': [{'cable': 'L13', 'line': 2, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 2, 'det_port': ['up'], 'crate_port': []}]}, '19': {'crate_port': 'LV2', 'det_port': 'A', 'connections': [{'cable': 'L13', 'line': 3, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 3, 'det_port': ['up'], 'crate_port': []}]}, '20': {'crate_port': 'LV2', 'det_port': 'A', 'connections': [{'cable': 'L13', 'line': 4, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 4, 'det_port': ['up'], 'crate_port': []}]}, '21': {'crate_port': 'LV3', 'det_port': 'A', 'connections': [{'cable': 'L9', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 5, 'det_port': ['down'], 'crate_port': []}]}, '22': {'crate_port': 'LV3', 'det_port': 'A', 'connections': [{'cable': 'L9', 'line': 2, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 6, 'det_port': ['down'], 'crate_port': []}]}, '23': {'crate_port': 'LV3', 'det_port': 'A', 'connections': [{'cable': 'L9', 'line': 3, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 7, 'det_port': ['down'], 'crate_port': []}]}, -# '24': {'crate_port': 'LV3', 'det_port': 'A', 'connections': [{'cable': 'L9', 'line': 4, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 8, 'det_port': ['down'], 'crate_port': []}]}} - - if power_id_slot != "" : - crate_snapshot = {k: v for k, v in crate_snapshot.items() if v["crate_port"]=="HV%s"%power_id_slot or int(k) == int(power_id_slot)+12} - # print("PCrate_post",crate_snapshot) + # print("PCrate_pre",crate_snapshot) + # PCrate_pre {'1': {'crate_port': 'HV1', 'det_port': 'A', 'connections': [{'cable': 'H36', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 1, 'det_port': ['1'], 'crate_port': []}]}, + # '2': {'crate_port': 'HV2', 'det_port': 'A', 'connections': [{'cable': 'H37', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 2, 'det_port': ['2'], 'crate_port': []}]}, + # '3': {'crate_port': 'HV3', 'det_port': 'A', 'connections': [{'cable': 'H38', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 3, 'det_port': ['3'], 'crate_port': []}]}, + # '4': {'crate_port': 'HV4', 'det_port': 'A', 'connections': [{'cable': 'H39', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 4, 'det_port': ['4'], 'crate_port': []}]}, + # '5': {'crate_port': 'HV5', 'det_port': 'A', 'connections': [{'cable': 'H40', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 5, 'det_port': ['5'], 'crate_port': []}]}, + # '6': {'crate_port': 'HV6', 'det_port': 'A', 'connections': [{'cable': 'H41', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 6, 'det_port': ['6'], 'crate_port': []}]}, + # '7': {'crate_port': 'HV7', 'det_port': 'A', 'connections': [{'cable': 'H42', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 7, 'det_port': ['7'], 'crate_port': []}]}, + # '8': {'crate_port': 'HV8', 'det_port': 'A', 'connections': [{'cable': 'H43', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 8, 'det_port': ['8'], 'crate_port': []}]}, + # '9': {'crate_port': 'HV9', 'det_port': 'A', 'connections': [{'cable': 'H44', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 9, 'det_port': ['9'], 'crate_port': []}]}, + # '10': {'crate_port': 'HV10', 'det_port': 'A', 'connections': [{'cable': 'H45', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 10, 'det_port': ['10'], 'crate_port': []}]}, + # '11': {'crate_port': 'HV11', 'det_port': 'A', 'connections': [{'cable': 'H46', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 11, 'det_port': ['11'], 'crate_port': []}]}, '12': {'crate_port': 'HV12', 'det_port': 'A', 'connections': [{'cable': 'H47', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'ASLOT2', 'line': 12, 'det_port': ['12'], 'crate_port': []}]}, + # '13': {'crate_port': 'LV1', 'det_port': 'A', 'connections': [{'cable': 'L5', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT7', 'line': 5, 'det_port': ['down'], 'crate_port': []}]}, '14': {'crate_port': 'LV1', 'det_port': 'A', 'connections': [{'cable': 'L5', 'line': 2, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT7', 'line': 6, 'det_port': ['down'], 'crate_port': []}]}, '15': {'crate_port': 'LV1', 'det_port': 'A', 'connections': [{'cable': 'L5', 'line': 3, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT7', 'line': 7, 'det_port': ['down'], 'crate_port': []}]}, + # '16': {'crate_port': 'LV1', 'det_port': 'A', 'connections': [{'cable': 'L5', 'line': 4, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT7', 'line': 8, 'det_port': ['down'], 'crate_port': []}]}, '17': {'crate_port': 'LV2', 'det_port': 'A', 'connections': [{'cable': 'L13', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 1, 'det_port': ['up'], 'crate_port': []}]}, '18': {'crate_port': 'LV2', 'det_port': 'A', 'connections': [{'cable': 'L13', 'line': 2, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 2, 'det_port': ['up'], 'crate_port': []}]}, '19': {'crate_port': 'LV2', 'det_port': 'A', 'connections': [{'cable': 'L13', 'line': 3, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 3, 'det_port': ['up'], 'crate_port': []}]}, '20': {'crate_port': 'LV2', 'det_port': 'A', 'connections': [{'cable': 'L13', 'line': 4, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 4, 'det_port': ['up'], 'crate_port': []}]}, '21': {'crate_port': 'LV3', 'det_port': 'A', 'connections': [{'cable': 'L9', 'line': 1, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 5, 'det_port': ['down'], 'crate_port': []}]}, '22': {'crate_port': 'LV3', 'det_port': 'A', 'connections': [{'cable': 'L9', 'line': 2, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 6, 'det_port': ['down'], 'crate_port': []}]}, '23': {'crate_port': 'LV3', 'det_port': 'A', 'connections': [{'cable': 'L9', 'line': 3, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 7, 'det_port': ['down'], 'crate_port': []}]}, + # '24': {'crate_port': 'LV3', 'det_port': 'A', 'connections': [{'cable': 'L9', 'line': 4, 'det_port': ['A'], 'crate_port': ['A']}, {'cable': 'XSLOT8', 'line': 8, 'det_port': ['down'], 'crate_port': []}]}} + + if power_id_slot != "": + crate_snapshot = { + k: v + for k, v in crate_snapshot.items() + if v["crate_port"] == "HV%s" % power_id_slot + or int(k) == int(power_id_slot) + 12 + } + # print("PCrate_post",crate_snapshot) # Look at power lines (3,4) for line in crate_snapshot.keys(): print(crate_snapshot[line]["connections"]) - if line in crate_snapshot and crate_snapshot[line]["connections"]: + if ( + line in crate_snapshot + and crate_snapshot[line]["connections"] + ): last_conn = crate_snapshot[line]["connections"][-1] - ports = last_conn['crate_port'] + last_conn['det_port'] + ports = last_conn["crate_port"] + last_conn["det_port"] port = ports[0] if ports else "?" - #crate_endpoints.append(f"{last_conn['cable']}_{port}_{last_conn['line']}") - crate_endpoints.append(f"{last_conn['cable']}.{last_conn['line']}") - if "XSLOT" in last_conn['cable'] : - self.caen.setLV("LV"+last_conn['cable'][5:]+f".{last_conn['line']}") - if "ASLOT" in last_conn['cable'] : - self.caen.setHV("HV"+last_conn['cable'][5:]+f".{last_conn['line']}") + # crate_endpoints.append(f"{last_conn['cable']}_{port}_{last_conn['line']}") + crate_endpoints.append( + f"{last_conn['cable']}.{last_conn['line']}" + ) + if "XSLOT" in last_conn["cable"]: + self.caen.setLV( + "LV" + + last_conn["cable"][5:] + + f".{last_conn['line']}" + ) + if "ASLOT" in last_conn["cable"]: + self.caen.setHV( + "HV" + + last_conn["cable"][5:] + + f".{last_conn['line']}" + ) return det_endpoint, crate_endpoints @@ -1655,7 +1798,7 @@ def get_power_endpoints(self, power_id,power_id_slot=""): # # Set up tree widget # self.detailsTree.setHeaderLabels(['Field', 'Value']) # self.detailsTree.setColumnWidth(0, 200) - + # # Connect buttons # self.editDetailsButton.clicked.connect(self.edit_selected_detail) # self.saveDetailsButton.clicked.connect(self.save_module_details) @@ -1665,7 +1808,7 @@ def get_power_endpoints(self, power_id,power_id_slot=""): # if parent is None: # self.detailsTree.clear() # parent = self.detailsTree - + # if isinstance(data, dict): # for key, value in sorted(data.items()): # item = QTreeWidgetItem([str(key), '']) @@ -1694,18 +1837,18 @@ def get_power_endpoints(self, power_id,power_id_slot=""): # dialog.setWindowTitle("Edit Value") # dialog.setLabelText(f"Edit value for {item.text(0)}:") # dialog.setTextValue(item.text(1)) - + # if dialog.exec_(): # item.setText(1, dialog.textValue()) def tree_to_dict(self, item): """Convert tree widget items back to dictionary recursively""" result = {} - + for i in range(item.childCount()): child = item.child(i) key = child.text(0) - + if child.childCount() > 0: # This is a non-leaf node, recurse value = self.tree_to_dict(child) @@ -1714,33 +1857,37 @@ def tree_to_dict(self, item): value = child.text(1) # Try to convert to appropriate type try: - if value.lower() == 'true': + if value.lower() == "true": value = True - elif value.lower() == 'false': + elif value.lower() == "false": value = False elif value.isdigit(): value = int(value) - elif value.replace('.','',1).isdigit(): + elif value.replace(".", "", 1).isdigit(): value = float(value) except: pass - + # Handle nested paths using dot notation - key_parts = key.split('.') + key_parts = key.split(".") current_dict = result for part in key_parts[:-1]: if part not in current_dict: current_dict[part] = {} current_dict = current_dict[part] current_dict[key_parts[-1]] = value - + return result def merge_dicts(self, dict1, dict2): """Recursively merge two dictionaries""" result = dict1.copy() for key, value in dict2.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): result[key] = self.merge_dicts(result[key], value) else: result[key] = value @@ -1754,20 +1901,20 @@ def merge_dicts(self, dict1, dict2): # if response.status_code != 200: # self.log_output(f"Error fetching module: {response.text}") # return - + # current_data = response.json() - + # # Convert tree widget back to dictionary # new_data = self.tree_to_dict(self.detailsTree.invisibleRootItem()) # print(new_data) # print(current_data) # # Recursively merge the data # merged_data = self.merge_dicts(current_data, new_data) - + # # Remove _id from merged data # if "_id" in merged_data: # del merged_data["_id"] - + # # Make API request to update module # print(merged_data) # #return #debug check what it would do @@ -1775,13 +1922,13 @@ def merge_dicts(self, dict1, dict2): # self.get_api_url(f'modules/{self.current_module_id}'), # json=merged_data # ) - + # if response.status_code == 200: # self.log_output("Module details updated successfully") # self.update_module_list() # Refresh the module list to show updated data # else: # self.log_output(f"Error updating module: {response.text}") - + # except Exception as e: # self.log_output(f"Error saving module details: {str(e)}") @@ -1791,7 +1938,7 @@ def view_module_details(self): # if not selected_items: # self.log_output("No module selected") # return - + # module_id = selected_items[0].text(0) # Get module name # self.moduleLE.setText(module_id) # This will trigger load_module_details # print("here") @@ -1800,12 +1947,12 @@ def view_module_details(self): def setup_inventory_buttons(self): """Setup buttons for inventory tab""" buttonLayout = QHBoxLayout() - + spacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) buttonLayout.addItem(spacer) - + # Add button layout to tab - + # Connect the "View Details" button signal self.viewDetailsPB.clicked.connect(self.view_module_details) @@ -1815,20 +1962,22 @@ def reset_test_states(self): self.checkIDLED.setStyleSheet("background-color: rgb(255, 255, 0);") # Yellow self.hvOFFTestLED.setStyleSheet("background-color: rgb(255, 255, 0);") self.hvONTestLED.setStyleSheet("background-color: rgb(255, 255, 0);") - + # Reset checkboxes self.hvOFFTestCB.setChecked(False) self.hvONTestCB.setChecked(False) - + # Enable tests 2 and 3 self.hvOFFTestPB.setEnabled(True) self.hvONTestPB.setEnabled(True) - + # Clear ID label self.checkIDlabel.setText("ID:") - + # Reset results label - self.resultsLabel.setText("

Noise:

MPA: 0.0

SSA: 0.0

") + self.resultsLabel.setText( + "

Noise:

MPA: 0.0

SSA: 0.0

" + ) def update_filters_from_layer(self, layer_type): """Update speed and spacer filters based on selected layer type""" @@ -1836,7 +1985,7 @@ def update_filters_from_layer(self, layer_type): self.speedCB.setCurrentText("any") self.spacerCB.setCurrentText("any") return - + if layer_type in self.layers_to_filters: filters = self.layers_to_filters[layer_type] self.speedCB.setCurrentText(filters["speed"]) @@ -1846,7 +1995,7 @@ def update_layer_from_ring(self, ring_id): """Update layer type selection based on ring ID""" if not ring_id: return - + # Find matching layer type matching_layer = "any" for layer in self.layers_to_filters: @@ -1862,7 +2011,7 @@ def update_layer_from_ring(self, ring_id): # Otherwise keep looking for a better match elif matching_layer == "any": matching_layer = layer - + # Update layer type combo box self.module_db.ui.layertypeCB.setCurrentText(matching_layer) @@ -1871,7 +2020,7 @@ def mount_module(self): module_id = self.moduleLE.text() ring_id = self.ringLE.text() position = self.positionLE.text() - + if not all([module_id, ring_id, position]): self.show_error_dialog("Please specify module ID, ring ID and position") return @@ -1884,19 +2033,17 @@ def mount_module(self): return # Check if position is valid for the layer - max_positions = { - "L1": 18, - "L2": 26, - "L3": 36 - } - layer = next((key for key in max_positions.keys() if ring_id.startswith(key)), None) + max_positions = {"L1": 18, "L2": 26, "L3": 36} + layer = next( + (key for key in max_positions.keys() if ring_id.startswith(key)), None + ) if not layer: self.show_error_dialog( f"Invalid ring ID format: {ring_id}\n\n" f"Ring ID must start with one of: L1, L2, L3" ) return - + if position_num < 1 or position_num > max_positions[layer]: self.show_error_dialog( f"Invalid position for {layer}\n\n" @@ -1906,12 +2053,11 @@ def mount_module(self): return # Check if module is already mounted somewhere - success, modules = self.make_api_request( - endpoint='modules', - method='GET' - ) + success, modules = self.make_api_request(endpoint="modules", method="GET") if not success: - self.show_error_dialog("Failed to check module mounting status.\nPlease check your database connection.") + self.show_error_dialog( + "Failed to check module mounting status.\nPlease check your database connection." + ) return for module in modules: @@ -1923,7 +2069,7 @@ def mount_module(self): f"Please unmount it first if you want to move it." ) return - + # Check if there's already a module in the target position if module.get("mounted_on") == f"{ring_id};{position}": self.show_error_dialog( @@ -1935,28 +2081,26 @@ def mount_module(self): # If we get here, all validations passed # First get current module data - response = requests.get(self.get_api_url(f'modules/{module_id}')) + response = requests.get(self.get_api_url(f"modules/{module_id}")) if response.status_code != 200: self.show_error_dialog(f"Error fetching module data: {response.text}") return - + module_data = response.json() mounted_on = f"{ring_id};{position}" - + # Update only the relevant fields while preserving others module_data["mounted_on"] = mounted_on module_data["status"] = "MOUNTED" module_data["position"] = "OnDet@ " + mounted_on - # Remove _id from mounted_on + # Remove _id from mounted_on if "_id" in module_data: del module_data["_id"] - + success, result = self.make_api_request( - endpoint=f'modules/{module_id}', - method='PUT', - data=module_data + endpoint=f"modules/{module_id}", method="PUT", data=module_data ) - + if success: self.log_output(f"Module {module_id} mounted at {mounted_on}") self.unmountPB.setEnabled(True) @@ -1969,31 +2113,29 @@ def unmount_module(self): """Unmount the currently selected module""" module_id = self.moduleLE.text() print("UNMOUNTING", module_id) - + if not module_id: self.log_output("No module selected") return - + # First get current module data - response = requests.get(self.get_api_url(f'modules/{module_id}')) + response = requests.get(self.get_api_url(f"modules/{module_id}")) if response.status_code != 200: self.log_output(f"Error fetching module: {response.text}") return - + module_data = response.json() - + # Update only the relevant fields while preserving others module_data["mounted_on"] = "" module_data["status"] = "un-mounted" - if "_id" in module_data : + if "_id" in module_data: del module_data["_id"] - + success, result = self.make_api_request( - endpoint=f'modules/{module_id}', - method='PUT', - data=module_data + endpoint=f"modules/{module_id}", method="PUT", data=module_data ) - + if success: self.log_output(f"Module {module_id} unmounted") self.unmountPB.setEnabled(False) @@ -2005,13 +2147,15 @@ def unmount_module(self): def check_module_mounting_status(self): """Check if the current module ID is mounted and update unmount button state""" module_id = self.moduleLE.text() - + if not module_id: self.unmountPB.setEnabled(False) return - + # Check if this module ID exists in mounted_modules values - is_mounted = any(module_id == mounted_id for mounted_id in self.mounted_modules.values()) + is_mounted = any( + module_id == mounted_id for mounted_id in self.mounted_modules.values() + ) self.unmountPB.setEnabled(is_mounted) def load_module_details(self): @@ -2020,25 +2164,27 @@ def load_module_details(self): if not module_id: self.module_db.ui.moduleNameLabel.setText("") return - + try: - response = requests.get(self.get_api_url(f'modules/{module_id}')) + response = requests.get(self.get_api_url(f"modules/{module_id}")) if response.status_code == 200: module_data = response.json() - self.current_module_data=module_data + self.current_module_data = module_data self.current_module_id = module_id - + # Fetch Fuse ID self.current_fuse_id = get_module_fuse_id(module_data) - self.log_output(f"Module {module_id} loaded. Fuse ID: {self.current_fuse_id}") - + self.log_output( + f"Module {module_id} loaded. Fuse ID: {self.current_fuse_id}" + ) + # Reset Ph2ACF plots for new module self.ph2acf_time = [] self.ssa_mean_values = [] self.ssa_max_values = [] self.mpa_mean_values = [] self.mpa_max_values = [] - + self.module_db.ui.moduleNameLabel.setText(module_id) self.module_db.populate_details_tree(module_data) else: @@ -2049,11 +2195,12 @@ def load_module_details(self): def control_air(self, turn_on): """Control air system""" self.air_state = turn_on -# command = self.airCommandLE.text().format(airOn="1" if turn_on else "0") + # command = self.airCommandLE.text().format(airOn="1" if turn_on else "0") command = self.airCommandLE.text().format(airOn="on" if turn_on else "off") - + def handle_air_result(success, stdout, stderr): pass + if self.current_worker: self.current_worker.finished.disconnect() self.current_worker = CommandWorker(command) @@ -2064,19 +2211,19 @@ def show_test_results(self): """Show test results in plots tab""" # Switch to plots tab self.tabWidget.setCurrentWidget(self.tab_5) - + # Load URL from settings and normalize path url = self.resultsUrlLE.text() - if url.startswith('file:'): + if url.startswith("file:"): # Remove file: prefix if present and make absolute path - url = url.replace('file:', '', 1) + url = url.replace("file:", "", 1) url = os.path.abspath(url) - url = f'file://{url}' - elif not url.startswith('http://') and not url.startswith('https://'): + url = f"file://{url}" + elif not url.startswith("http://") and not url.startswith("https://"): # If no protocol specified, assume local file url = os.path.abspath(url) - url = f'file://{url}' - + url = f"file://{url}" + # Try to load the content into text browser try: self.resultsBrowser.setSource(QUrl(url)) @@ -2093,15 +2240,15 @@ def show_test_results(self): def open_results_in_browser(self): """Open test results in system default browser""" -# self.analysisUrl -# url = self.resultsUrlLE.text() -# if url.startswith('file:'): -# url = url.replace('file:', '', 1) -# url = os.path.abspath(url) -# url = f'file://{url}' -# elif not url.startswith('http://') and not url.startswith('https://'): -# url = os.path.abspath(url) -# url = f'file://{url}' + # self.analysisUrl + # url = self.resultsUrlLE.text() + # if url.startswith('file:'): + # url = url.replace('file:', '', 1) + # url = os.path.abspath(url) + # url = f'file://{url}' + # elif not url.startswith('http://') and not url.startswith('https://'): + # url = os.path.abspath(url) + # url = f'file://{url}' webbrowser.open(self.analysisURL) def on_module_selected(self, module_id): @@ -2109,6 +2256,7 @@ def on_module_selected(self, module_id): self.moduleLE.setText(module_id) self.tabWidget.setCurrentIndex(0) # Switch to first tab + def main(): app = QApplication(sys.argv) window = QMainWindow() @@ -2116,7 +2264,8 @@ def main(): window.show() sys.exit(app.exec_()) -if __name__ == '__main__': + +if __name__ == "__main__": import sys main() diff --git a/issues.md b/issues.md new file mode 100644 index 0000000..660da30 --- /dev/null +++ b/issues.md @@ -0,0 +1,404 @@ +# Bug & Issue Report +> Auto-generated by code review — branch `fix-safely-and-coldroom-power-supply` + +--- + +## CRITICAL (will crash or silently do nothing at runtime) + +### BUG-01 · `self.caenGUI` does not exist — safety interlock is broken +**File:** [integration.py:792-795](integration.py#L792-L795) + +```python +if self.max_temperature > 45: + self.caenGUI.safe_lv_off() # ← AttributeError +if self.max_temperature > 50: + self.caenGUI.off(...) # ← AttributeError +``` +The CAEN object is stored as `self.caen` (set at line 247), not `self.caenGUI`. When the thermal camera temperature exceeds 45 °C the safety interlock fires and immediately crashes. **The safety shutdown never executes.** + +**Fix:** replace `self.caenGUI` with `self.caen` on both lines. + +--- + +### BUG-02 · `new_session()` crashes if the API call fails +**File:** [integration.py:418-420](integration.py#L418-L420) + +```python +success, result = self.make_api_request("sessions", "POST", session) +self.current_session = result["sessionName"] # crashes when result is None or a str +``` +`make_api_request` returns `(False, None)` or `(False, str(e))` on failure, but the next line assumes `result` is always a dict. + +**Fix:** check `success` before accessing `result["sessionName"]`. + +--- + +### BUG-03 · `cleanup()` starts the thermal camera loop instead of stopping it +**File:** [coldroom/system.py:213](coldroom/system.py#L213) + +```python +self._thermalcamera.loop_start() # BUG: should be loop_stop() +``` +During application shutdown, calling `loop_start()` re-starts the background MQTT thread instead of stopping it, leaking threads and connections. + +**Fix:** change to `self._thermalcamera.loop_stop()`. + +--- + +### BUG-04 · `terminate_process()` calls `.poll()` on a `CompletedProcess` object +**File:** [coldroom/command_worker.py:79](coldroom/command_worker.py#L79) + +```python +self.process = subprocess.run(...) # returns CompletedProcess +... +if self.process and self.process.poll() is None: # AttributeError: poll() doesn't exist +``` +`subprocess.run()` returns a `CompletedProcess`; only `Popen` has `.poll()`. The Cancel button will always raise `AttributeError`. The commented-out `Popen` code was the original intent. + +**Fix:** switch to `subprocess.Popen(...)` so the process can be killed while running, or remove the `terminate_process` body since `subprocess.run` is already blocking. + +--- + +### BUG-05 · `power_supply/power_supply_ctrl.py` `main()` uses `QMainWindow` before it is imported +**File:** [power_supply/power_supply_ctrl.py:204](power_supply/power_supply_ctrl.py#L204) + +```python +def main(): + ... + window = QMainWindow() # NameError — QMainWindow imported only inside __main__ guard +``` +`from PyQt5.QtWidgets import QMainWindow` is at line 214, inside `if __name__ == "__main__":`. Calling `main()` from another module raises `NameError`. + +**Fix:** move the `QMainWindow` import to the top of the file. + +--- + +### BUG-06 · `power_supply/rigolDP116A.py` connects to hardware at import time +**File:** [power_supply/rigolDP116A.py:8-10](power_supply/rigolDP116A.py#L8-L10) + +```python +rm = visa.ResourceManager() +power_supply = rm.open_resource(f"TCPIP0::{ip_address}::INSTR") +``` +These lines run the moment the file is imported. If the power supply is unreachable, importing this module crashes the calling application. + +**Fix:** wrap connection logic in a `connect()` function or class and call it explicitly. + +--- + +### BUG-07 · `update_stitched_images` accesses `camera_data[pos]["image"]` but the value is a list +**File:** [coldroom/module_temperatures_gui.py:1103](coldroom/module_temperatures_gui.py#L1103) + +```python +images = [camera_data[pos]["image"] for pos in positions] # TypeError: list has no key "image" +``` +`thermal_camera.py` stores raw numpy arrays in a list per position (`.append(processed_image)`), not dicts. This method is dead but would crash if called. + +--- + +### BUG-08 · `caenGUI.py` `main()` references undefined class `caenGUI` +**File:** [caen/caenGUI.py:235](caen/caenGUI.py#L235) + +```python +def main(): + ex = caenGUI() # NameError — class was renamed to CAENControl +``` +The standalone test entry-point is broken; calling it raises `NameError`. + +--- + +### BUG-09 · `disconnect_module` in ModuleDB wired without argument +**File:** [db/module_db.py:352](db/module_db.py#L352) + +```python +disconnect_button.clicked.connect(self.disconnect_module) +# disconnect_module(self, m) requires argument m +``` +The `clicked` signal passes a `bool` (checked state), not a module ID. Clicking the Disconnect button in the Details tree will raise `TypeError`. + +**Fix:** use `lambda: self.disconnect_module(self.current_module_id)`. + +--- + +## HIGH (safety-logic errors, silent wrong results) + +### BUG-10 · Safety functions return a tuple on error — tuples are truthy +**File:** [coldroom/safety.py:76, 88, 98](coldroom/safety.py#L76) + +```python +def check_dew_point(...): + ... + except Exception as e: + return False, "Error checking dew point safety" # returns (False, str) — truthy! +``` +`check_door_status` and `check_light_status` have the same pattern. Code that does `if not check_dew_point(status):` evaluates a non-empty tuple as `True`, i.e., "safe", which is the **opposite** of the intended conservative behaviour. + +**Fix:** return only `False` (or raise) on exceptions; never a tuple. + +--- + +### BUG-11 · `check_door_status` checks wrong key then reads a different key +**File:** [coldroom/safety.py:82-86](coldroom/safety.py#L82-L86) + +```python +if "coldroom" not in system_status or "door_status" not in system_status["coldroom"]: + return False +return system_status["coldroom"]["CmdDoorUnlock_Reff"] == 1 # KeyError if not present +``` +Guards check `"door_status"` but then reads `"CmdDoorUnlock_Reff"`. If `"door_status"` exists but `"CmdDoorUnlock_Reff"` does not, raises `KeyError`. + +--- + +### BUG-12 · `check_door_safe_to_open` accesses `cleanroom["elapsed_time"]` key that does not exist +**File:** [coldroom/safety.py:139](coldroom/safety.py#L139) + +```python +clean_room_expired = check_cleanroom_expired(system_status["cleanroom"]["elapsed_time"]) +``` +`MartaColdRoomMQTTClient` stores `last_update` (a datetime string) in cleanroom status, not `elapsed_time`. This always raises `KeyError`. + +--- + +### BUG-13 · Soft interlock **never actually shuts off LV** — actions are commented out +**File:** [coldroom/safety.py:435, 445](coldroom/safety.py#L435-L445) + +```python +# switch_all_lv_off(caen, used_channels) ← commented out in both branches +``` +The safety interlock loop only publishes alarm messages. The actual protective action (turning off LV) is disabled. + +--- + +### BUG-14 · MQTT callbacks update GUI widgets from a background thread +**File:** [integration.py:629, 637, 756-758, 790, 808-809](integration.py#L629) + +`on_mqtt_message` is called from paho-mqtt's network thread and directly calls `self.airLed.setStyleSheet(...)`, `self.canvas.draw()`, `self.tMaxLabel.setText(...)`, etc. PyQt5 is **not thread-safe**: GUI updates from non-main threads cause random crashes and visual corruption. + +**Fix:** emit signals from `on_mqtt_message` and process them in slots on the main thread (the `LogEmitter` pattern already used in this file is the right model). + +--- + +### BUG-15 · `MartaColdRoomMQTTClient` starts its loop twice +**File:** [coldroom/marta_coldroom.py:69](coldroom/marta_coldroom.py#L69) and [coldroom/system.py:170](coldroom/system.py#L170) + +`__init__` calls `self.start_client_loops()` at line 69, then `System._mqtt_loop()` calls `self._martacoldroom.start_client_loops()` again at line 170. Calling `loop_start()` twice on a paho client creates a second network thread. + +--- + +## MEDIUM (functional bugs, bad fallbacks) + +### BUG-16 · `cancelPB` accumulates multiple signal connections +**File:** [integration.py:1172-1173, 1239-1240, 1331-1332](integration.py#L1172) + +```python +self.cancelPB.clicked.connect(self.current_worker.terminate) +self.cancelPB.clicked.connect(self.reset_test_pbs) +``` +Each call to `run_check_id()`, `run_light_on_test()`, or `run_dark_test()` adds new connections without disconnecting old ones. Clicking Cancel after 3 tests fires 6 slots, trying to terminate already-finished workers. + +**Fix:** call `self.cancelPB.clicked.disconnect()` before re-connecting, or use a single persistent slot. + +--- + +### BUG-17 · Blocking HTTP requests on the main (GUI) thread +**Files:** [integration.py:1464, 1603, 1668, 1705, 2056, 2084, 2122, 2169](integration.py#L1464); [db/module_db.py:197](db/module_db.py#L197) + +Multiple methods (`module_changed`, `update_connection_status`, `mount_module`, `load_module_details`, `update_module_list`) call `requests.get/post` directly on the main thread. Any network delay freezes the entire GUI. + +**Fix:** run all API calls in a `QThread` (use the existing `CommandWorker` pattern) and update the GUI via signals. + +--- + +### BUG-18 · `struct` and `numpy` imported twice +**File:** [integration.py:31-33 and 46-47](integration.py#L31) + +```python +import struct +import numpy as np +... +import struct # duplicate +import numpy as np # duplicate +``` +Not a crash, but dead code. + +--- + +### BUG-19 · `logger.debug("Stopping", payload)` passes extra positional arg +**File:** [coldroom/marta_coldroom.py:367](coldroom/marta_coldroom.py#L367) + +```python +logger.debug("Stopping", payload) +``` +Python's `logging` API doesn't accept a second positional non-format argument here; it silently ignores `payload` in older Python but may warn. Should be `logger.debug(f"Stopping {payload}")`. + +--- + +### BUG-20 · `from tkinter import NO, YES, YES` in safety.py — wrong library, unused import +**File:** [coldroom/safety.py:2](coldroom/safety.py#L2) + +tkinter is not used anywhere in `safety.py`. This imports the tk event loop in a PyQt5 app and `YES` is imported twice. Dead import. + +--- + +### BUG-21 · `CAENQueryThread.start()` called every 2 s without checking if previous query finished +**File:** [caen/caenGUI.py:177-178](caen/caenGUI.py#L177) + +```python +def update(self): + self.queryThread.setup_query("GetStatus,...", True) + self.queryThread.start() # ← called even if thread is already running +``` +`QThread.start()` on a running thread does nothing but logs a Qt warning. If a CAEN query takes longer than 2 s the thread queue grows and responses pile up, causing stale or duplicate updates. + +--- + +### BUG-22 · `filter_modules` writes to columns 6 and 8 — likely out-of-range +**File:** [db/module_db.py:295, 331](db/module_db.py#L295) + +```python +item.setText(6, module.get("details", {}).get("DESCRIPTION", "")) +item.setText(8, module.get("mounted_on", "")) +``` +`QTreeWidget.setText` silently does nothing if the column index exceeds the column count. Unless the widget has ≥9 columns these values are discarded and never shown. + +--- + +### BUG-23 · `get_module_fuse_id` always overwrites `hwId` with `children.lpGBT` value +**File:** [db/utils.py:226-228](db/utils.py#L226) + +```python +if "hwId" in module: + fuseId = int(module["hwId"]) +if "children" in module and "lpGBT" in module["children"]: # second if, not elif + fuseId = int(module["children"]["lpGBT"]["CHILD_SERIAL_NUMBER"]) +``` +Both are plain `if`, so the second silently overwrites the first. If `CHILD_SERIAL_NUMBER` is missing or non-numeric, raises `KeyError`/`ValueError`. + +--- + +### BUG-24 · `if True:` dead conditional masking commented-out logic +**File:** [integration.py:1627, 1730](integration.py#L1627) + +```python +# f det_endpoint: +if True: +``` +This is a commented-out guard replaced by `if True:`, bypassing intended conditional behaviour and making control flow hard to follow. + +--- + +### BUG-25 · Camera naming mismatch: `camera0-3` vs `camera1-4` +**File:** [coldroom/thermal_camera.py:23](coldroom/thermal_camera.py#L23) vs [coldroom/module_temperatures_gui.py:407-410](coldroom/module_temperatures_gui.py#L407) + +`thermal_camera.py` initialises images as `camera0`–`camera3`; `module_temperatures_gui.py` expects `camera1`–`camera4`. Conversion (`int(camera_name[-1]) + 1`) is scattered through the code and fragile (breaks if camera names ever use two-digit suffixes). + +--- + +### BUG-26 · `update_temperature_table` crashes if `mounted_on` has no `;` +**File:** [coldroom/module_temperatures_gui.py:1418](coldroom/module_temperatures_gui.py#L1418) + +```python +column_index = int(mounted_module_info.get("mounted_on", "-").split(";")[1]) +``` +If `mounted_on` is empty or `"-"`, `split(";")` returns a one-element list and `[1]` raises `IndexError`. + +--- + +## LOW (code quality, minor functional issues) + +### BUG-27 · `import sys` missing at module level in `integration.py` +**File:** [integration.py:2264](integration.py#L2264) + +`main()` calls `sys.exit(app.exec_())`. `sys` is only imported inside the `if __name__ == "__main__":` guard (line 2269). Works only when run directly, fails when `main()` is imported and called. + +**Fix:** move `import sys` to the top-level imports. + +--- + +### BUG-28 · `open_ph2acf_log` hardcodes `konsole` terminal and an absolute path +**File:** [integration.py:1347-1349](integration.py#L1347) + +```python +CommandWorker("konsole -e tail -f /home/thermal/BurnIn_moduleTest/logs/Ph2_ACF.log") +``` +Assumes the KDE `konsole` terminal is installed and the log path is fixed. Silently does nothing on GNOME systems or if the path doesn't exist. + +--- + +### BUG-29 · IP address hardcoded in `power_supply_ctrl.py` +**File:** [power_supply/power_supply_ctrl.py:66](power_supply/power_supply_ctrl.py#L66) + +```python +ip_address = "192.168.0.16" +``` +Not read from settings; requires source-code edit to change the instrument address. + +--- + +### BUG-30 · `handle_ring_click` is re-assigned as a monkey-patch every redraw +**File:** [integration.py:943](integration.py#L943) + +```python +self.graphicsView.mousePressEvent = self.handle_ring_click +``` +Called inside `draw_ring()` which runs on every UI change. Reassigning instance methods is fragile; use `QGraphicsView.mousePressEvent` subclass override or a proper event filter instead. + +--- + +### BUG-31 · No automatic MQTT reconnection in main integration window +**File:** [integration.py:611-619](integration.py#L611) + +`on_mqtt_disconnect` logs the event but the paho client's built-in reconnect (`reconnect_delay_set` + `loop_start`) is never configured. If the broker drops, data stops silently. + +--- + +### BUG-32 · `tcp_util.__del__` swallows all socket exceptions silently +**File:** [caen/caenGUI.py:92-95](caen/caenGUI.py#L92) + +```python +def __del__(self): + try: + self.closeSocket() + except: # bare except hides errors + pass +``` +Bare `except` masks all exceptions including `NameError`, `AttributeError`, and socket errors that might be useful for debugging. + +--- + +## Summary Table + +| ID | Severity | File | Description | +|----|----------|------|-------------| +| BUG-01 | CRITICAL | integration.py:792 | `self.caenGUI` → should be `self.caen`; safety shutdown broken | +| BUG-02 | CRITICAL | integration.py:418 | `new_session()` crashes on API failure | +| BUG-03 | CRITICAL | coldroom/system.py:213 | `loop_start()` instead of `loop_stop()` in cleanup | +| BUG-04 | CRITICAL | coldroom/command_worker.py:79 | `poll()` on `CompletedProcess` → Cancel button crashes | +| BUG-05 | CRITICAL | power_supply/power_supply_ctrl.py:204 | `QMainWindow` imported after use | +| BUG-06 | CRITICAL | power_supply/rigolDP116A.py:8 | Hardware connection at import time | +| BUG-07 | CRITICAL | coldroom/module_temperatures_gui.py:1103 | Wrong data structure access (`list` vs `dict`) | +| BUG-08 | CRITICAL | caen/caenGUI.py:235 | `main()` references undefined class `caenGUI` | +| BUG-09 | CRITICAL | db/module_db.py:352 | `disconnect_module` wired without required argument | +| BUG-10 | HIGH | coldroom/safety.py:76 | Safety functions return truthy tuple on error | +| BUG-11 | HIGH | coldroom/safety.py:82 | Wrong key checked vs key read in door status | +| BUG-12 | HIGH | coldroom/safety.py:139 | `elapsed_time` key doesn't exist → KeyError | +| BUG-13 | HIGH | coldroom/safety.py:435 | Actual LV-off action is commented out | +| BUG-14 | HIGH | integration.py:629 | GUI updates from MQTT background thread | +| BUG-15 | HIGH | coldroom/marta_coldroom.py:69 | MQTT loop started twice | +| BUG-16 | MEDIUM | integration.py:1172 | Cancel button accumulates signal connections | +| BUG-17 | MEDIUM | integration.py:1464 | Blocking HTTP on GUI thread freezes UI | +| BUG-18 | MEDIUM | integration.py:31,46 | `struct`/`numpy` imported twice | +| BUG-19 | MEDIUM | coldroom/marta_coldroom.py:367 | `logger.debug` extra positional arg | +| BUG-20 | MEDIUM | coldroom/safety.py:2 | `tkinter` imported in PyQt5 app, unused | +| BUG-21 | MEDIUM | caen/caenGUI.py:177 | CAEN query thread started while possibly running | +| BUG-22 | MEDIUM | db/module_db.py:295 | Column indices 6 and 8 likely out of range | +| BUG-23 | MEDIUM | db/utils.py:226 | `hwId` always overwritten by second `if` branch | +| BUG-24 | MEDIUM | integration.py:1627 | `if True:` obscures commented-out conditional | +| BUG-25 | MEDIUM | thermal_camera.py vs module_temperatures_gui.py | Camera name offset mismatch (0-index vs 1-index) | +| BUG-26 | MEDIUM | coldroom/module_temperatures_gui.py:1418 | `mounted_on` split without `;` guard → IndexError | +| BUG-27 | LOW | integration.py:2264 | `sys` not imported at module level | +| BUG-28 | LOW | integration.py:1347 | Hardcoded `konsole` terminal and log path | +| BUG-29 | LOW | power_supply/power_supply_ctrl.py:66 | Hardcoded power supply IP | +| BUG-30 | LOW | integration.py:943 | Monkey-patching `mousePressEvent` every redraw | +| BUG-31 | LOW | integration.py:611 | No MQTT auto-reconnect configured | +| BUG-32 | LOW | caen/caenGUI.py:92 | Bare `except` in destructor hides errors | diff --git a/modulesearch.py b/modulesearch.py index 06bd4d0..8e72bc2 100755 --- a/modulesearch.py +++ b/modulesearch.py @@ -3,6 +3,7 @@ import argparse from db.utils import * + def print_module_info(module_name): print(f"********** module: {module_name} ***********") # get module connections @@ -16,7 +17,7 @@ def print_module_info(module_name): print(f"{conn_type}: {conn_value}") else: print(f"{conn_type}: Not connected") - + module = get_module(module_name) if module: print(f"Mounted_on: {module.get('mounted_on', 'N/A')}") @@ -26,11 +27,19 @@ def print_module_info(module_name): print(f"Module details for '{module_name}' not found.") return True + def main(): parser = argparse.ArgumentParser(description="Search for module information.") - parser.add_argument("search_terms", nargs="*", help="Module names or FC7 identifiers to search for.") - parser.add_argument("-r", "--reverse", action="store_true", help="Reverse search from FC7 identifier (format: FC7_NAME_PORT).") - + parser.add_argument( + "search_terms", nargs="*", help="Module names or FC7 identifiers to search for." + ) + parser.add_argument( + "-r", + "--reverse", + action="store_true", + help="Reverse search from FC7 identifier (format: FC7_NAME_PORT).", + ) + args = parser.parse_args() if not args.search_terms: @@ -45,7 +54,7 @@ def main(): else: print(f"Invalid FC7 identifier format: {term}. Expected FC7NAME_PORT.") continue - + module_name = get_module_name_from_fc7(fc7, port) if module_name: print_module_info(module_name) @@ -56,5 +65,6 @@ def main(): # Optionally handle module not found pass + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/plot_tab/influx_3.py b/plot_tab/influx_3.py index edae922..6f9af04 100644 --- a/plot_tab/influx_3.py +++ b/plot_tab/influx_3.py @@ -5,16 +5,18 @@ verbose = 0 # Set to 1 for basic output, 2 for more detailed, 3 for debug-level output + def getInfluxQueryAPI(): token_location = os.path.join(os.path.dirname(__file__), "influx.sct") token = open(os.path.expanduser(token_location)).read().strip() client = InfluxDBClient(url="http://pccmslab1:8086/", token=token) return client.query_api() + def get_sensor_hierarchy(): """Get the complete sensor hierarchy organized by topic""" query_api = getInfluxQueryAPI() - + try: # Query to get all unique topics topics_query = """ @@ -25,7 +27,7 @@ def get_sensor_hierarchy(): |> distinct(column: "topic") |> sort() """ - + # Query to get fields for a specific topic fields_query_template = """ from(bucket: "sensor_data") @@ -36,58 +38,59 @@ def get_sensor_hierarchy(): |> distinct(column: "_field") |> sort() """ - + # Get all topics topics = [] topics_result = query_api.query(topics_query, org="pisaoutertracker") for table in topics_result: for record in table.records: topics.append(record.get_value()) - + if not topics: print("No topics found in mqtt_consumer measurement") return {} - + # For each topic, get its fields topic_sensors = {} for topic in sorted(topics): fields_result = query_api.query( - fields_query_template.format(topic=topic), - org="pisaoutertracker" + fields_query_template.format(topic=topic), org="pisaoutertracker" ) fields = [] for table in fields_result: for record in table.records: fields.append(record.get_value()) - + if fields: topic_sensors[topic] = sorted(fields) - + return topic_sensors - + except Exception as e: print(f"Error fetching sensor list: {e}") return {} + def print_sensor_hierarchy(topic_sensors): """Print the sensor hierarchy in a tree-like structure""" if not topic_sensors: print("No sensor data available") return - + print("\nSensor Hierarchy:") print("sensor_data (bucket)") print("└── mqtt_consumer (measurement)") - + for topic, sensors in topic_sensors.items(): print(f" └── {topic}") for sensor in sensors: print(f" └── {sensor}") - + print(f"\nTotal topics: {len(topic_sensors)}") total_sensors = sum(len(sensors) for sensors in topic_sensors.values()) print(f"Total sensors: {total_sensors}") + def getSensorValueAt(timestamp, sensorName, org="pisaoutertracker"): """Get a single sensor value at a specific timestamp""" window = timedelta(seconds=30) @@ -106,6 +109,7 @@ def getSensorValueAt(timestamp, sensorName, org="pisaoutertracker"): values = [record.get_value() for table in tables for record in table.records] return sum(values) / len(values) if values else None + def getSensorValueSeries(start_time, end_time, sensorName, org="pisaoutertracker"): """Get time series data for a sensor""" query = f""" @@ -116,7 +120,7 @@ def getSensorValueSeries(start_time, end_time, sensorName, org="pisaoutertracker |> aggregateWindow(every: 1m, fn: mean, createEmpty: false) |> yield(name: "mean") """ - + tables = getInfluxQueryAPI().query(query, org=org) timestamps = [] values = [] @@ -126,104 +130,115 @@ def getSensorValueSeries(start_time, end_time, sensorName, org="pisaoutertracker values.append(record.get_value()) return timestamps, values + def plot_sensor_data(start_time, end_time, sensorNames): """Plot multiple sensors in subplots""" if not sensorNames: print("No sensors specified for plotting") return - + num_sensors = len(sensorNames) - fig, axes = plt.subplots(num_sensors, 1, figsize=(12, 6*num_sensors), sharex=True) - + fig, axes = plt.subplots(num_sensors, 1, figsize=(12, 6 * num_sensors), sharex=True) + if num_sensors == 1: axes = [axes] - + for i, sensor in enumerate(sensorNames): timestamps, values = getSensorValueSeries(start_time, end_time, sensor) - + if not timestamps: print(f"No data found for {sensor}") continue - - axes[i].plot(timestamps, values, marker='o', linestyle='-', label=sensor) - axes[i].set_title(f'Sensor: {sensor}') - axes[i].set_ylabel('Value') + + axes[i].plot(timestamps, values, marker="o", linestyle="-", label=sensor) + axes[i].set_title(f"Sensor: {sensor}") + axes[i].set_ylabel("Value") axes[i].grid(True) axes[i].legend() - - axes[-1].set_xlabel('Time') + + axes[-1].set_xlabel("Time") plt.xticks(rotation=45) plt.tight_layout() plt.show() + def interactive_menu(): """Interactive menu for exploring and plotting sensors""" topic_sensors = get_sensor_hierarchy() if not topic_sensors: return - + while True: print("\n===== Sensor Data Explorer =====") print("1. View sensor hierarchy") print("2. Plot sensor data") print("3. Exit") - + choice = input("Select an option (1-3): ").strip() - + if choice == "1": print_sensor_hierarchy(topic_sensors) - + elif choice == "2": # Select topic print("\nAvailable Topics:") topics = sorted(topic_sensors.keys()) for i, topic in enumerate(topics, 1): print(f"{i}. {topic}") - - topic_choice = input("\nSelect a topic (number) or 'b' to go back: ").strip() - if topic_choice.lower() == 'b': + + topic_choice = input( + "\nSelect a topic (number) or 'b' to go back: " + ).strip() + if topic_choice.lower() == "b": continue - + try: - selected_topic = topics[int(topic_choice)-1] + selected_topic = topics[int(topic_choice) - 1] except (ValueError, IndexError): print("Invalid selection") continue - + # Select sensors sensors = topic_sensors[selected_topic] print(f"\nAvailable Sensors in {selected_topic}:") for i, sensor in enumerate(sensors, 1): print(f"{i}. {sensor}") - - sensor_choices = input("\nSelect sensors (comma-separated numbers) or 'b' to go back: ").strip() - if sensor_choices.lower() == 'b': + + sensor_choices = input( + "\nSelect sensors (comma-separated numbers) or 'b' to go back: " + ).strip() + if sensor_choices.lower() == "b": continue - + try: - selected_indices = [int(x.strip())-1 for x in sensor_choices.split(",")] - selected_sensors = [sensors[i] for i in selected_indices if 0 <= i < len(sensors)] - + selected_indices = [ + int(x.strip()) - 1 for x in sensor_choices.split(",") + ] + selected_sensors = [ + sensors[i] for i in selected_indices if 0 <= i < len(sensors) + ] + if not selected_sensors: print("No valid sensors selected") continue - + # Get time range print("\nEnter time range (UTC):") start_time = input("Start time (YYYY-MM-DDTHH:MM:SSZ): ").strip() end_time = input("End time (YYYY-MM-DDTHH:MM:SSZ): ").strip() - + # Plot the data plot_sensor_data(start_time, end_time, selected_sensors) - + except ValueError: print("Invalid sensor selection") - + elif choice == "3": break - + else: print("Invalid choice") + if __name__ == "__main__": - interactive_menu() \ No newline at end of file + interactive_menu() diff --git a/plot_tab/sensor_monitor.py b/plot_tab/sensor_monitor.py index ca19e4f..7c08bcc 100644 --- a/plot_tab/sensor_monitor.py +++ b/plot_tab/sensor_monitor.py @@ -1,8 +1,16 @@ import sys import os from datetime import datetime, timedelta -from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QWidget, - QTreeWidgetItem, QMessageBox, QComboBox, QListWidget) +from PyQt5.QtWidgets import ( + QApplication, + QMainWindow, + QVBoxLayout, + QWidget, + QTreeWidgetItem, + QMessageBox, + QComboBox, + QListWidget, +) from PyQt5.QtCore import QTimer, pyqtSignal from PyQt5.uic import loadUi from influxdb_client import InfluxDBClient @@ -13,156 +21,175 @@ verbose = 0 + class PlotWidget(QWidget): def __init__(self, plot_title="Plot"): super().__init__() self.plot_title = plot_title self.setup_ui() - + def setup_ui(self): layout = QVBoxLayout() self.figure = Figure(figsize=(8, 4), dpi=100) self.canvas = FigureCanvas(self.figure) layout.addWidget(self.canvas) self.setLayout(layout) - + # Initialize with empty plot self.ax = self.figure.add_subplot(111) self.ax.set_title(self.plot_title, fontsize=10, pad=10) - self.ax.grid(True, linestyle=':', alpha=0.3) + self.ax.grid(True, linestyle=":", alpha=0.3) self.figure.tight_layout() - - def update_plot(self, timestamps, values, sensor_names, topic, start_time, end_time): + + def update_plot( + self, timestamps, values, sensor_names, topic, start_time, end_time + ): """Update the plot with new data""" self.ax.clear() - + # Set style - plt.style.use('seaborn-v0_8') - self.figure.set_facecolor('#f5f5f5') - self.ax.set_facecolor('#ffffff') - + plt.style.use("seaborn-v0_8") + self.figure.set_facecolor("#f5f5f5") + self.ax.set_facecolor("#ffffff") + if not timestamps or not values: self.ax.set_title(f"{self.plot_title}\nNo Data", fontsize=10) - self.ax.text(0.5, 0.5, 'No data available', - horizontalalignment='center', - verticalalignment='center', - transform=self.ax.transAxes, - fontsize=9, - alpha=0.7) + self.ax.text( + 0.5, + 0.5, + "No data available", + horizontalalignment="center", + verticalalignment="center", + transform=self.ax.transAxes, + fontsize=9, + alpha=0.7, + ) else: - colors = ['#4C72B0', '#55A868', '#C44E52', '#8172B2', '#CCB974'] + colors = ["#4C72B0", "#55A868", "#C44E52", "#8172B2", "#CCB974"] has_artists = False - - for i, (sensor_name, sensor_timestamps, sensor_values) in enumerate(zip(sensor_names, timestamps, values)): + + for i, (sensor_name, sensor_timestamps, sensor_values) in enumerate( + zip(sensor_names, timestamps, values) + ): if sensor_timestamps and sensor_values: color = colors[i % len(colors)] - self.ax.plot(sensor_timestamps, sensor_values, - marker='o', markersize=4, - linewidth=1.5, alpha=0.8, - label=sensor_name, color=color, - markerfacecolor='white', - markeredgewidth=1) + self.ax.plot( + sensor_timestamps, + sensor_values, + marker="o", + markersize=4, + linewidth=1.5, + alpha=0.8, + label=sensor_name, + color=color, + markerfacecolor="white", + markeredgewidth=1, + ) has_artists = True - + # Title and labels - self.ax.set_title(f"{self.plot_title}\n{topic}", - fontsize=10, pad=15, fontweight='bold') - self.ax.set_xlabel('Time', fontsize=9) - self.ax.set_ylabel('Value', fontsize=9) - + self.ax.set_title( + f"{self.plot_title}\n{topic}", fontsize=10, pad=15, fontweight="bold" + ) + self.ax.set_xlabel("Time", fontsize=9) + self.ax.set_ylabel("Value", fontsize=9) + # Legend if has_artists: legend = self.ax.legend( - loc='upper right', + loc="upper right", fontsize=8, frameon=True, fancybox=True, framealpha=0.8, - edgecolor='#cccccc' + edgecolor="#cccccc", ) legend.get_frame().set_linewidth(0.5) - + # Grid and borders - self.ax.grid(True, linestyle='--', alpha=0.5) - self.ax.spines['top'].set_visible(False) - self.ax.spines['right'].set_visible(False) - + self.ax.grid(True, linestyle="--", alpha=0.5) + self.ax.spines["top"].set_visible(False) + self.ax.spines["right"].set_visible(False) + # Date formatting hours = (end_time.toSecsSinceEpoch() - start_time.toSecsSinceEpoch()) / 3600 - + if hours <= 6: - self.ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) + self.ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) self.ax.xaxis.set_major_locator(mdates.MinuteLocator(interval=30)) elif hours <= 24: - self.ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) + self.ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) self.ax.xaxis.set_major_locator(mdates.HourLocator(interval=2)) else: - self.ax.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d')) + self.ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d")) self.ax.xaxis.set_major_locator(mdates.DayLocator()) - - self.figure.autofmt_xdate(rotation=30, ha='right') - + + self.figure.autofmt_xdate(rotation=30, ha="right") + self.figure.tight_layout() self.canvas.draw() + class SensorMonitor(QMainWindow): def __init__(self): super().__init__() - + # Load UI from file ui_file = os.path.join(os.path.dirname(__file__), "sensor_monitor.ui") loadUi(ui_file, self) - + self.topic_sensors = {} self.plot_configs = [ {"topic": "", "sensors": []}, {"topic": "", "sensors": []}, {"topic": "", "sensors": []}, - {"topic": "", "sensors": []} + {"topic": "", "sensors": []}, ] - + self.setup_ui() self.setup_plots() self.setup_timers() self.load_sensor_hierarchy() self.setup_default_plots() - + def setup_ui(self): """Setup UI connections and initial state""" # Connect buttons self.refreshButton.clicked.connect(self.refresh_data) self.autoRefreshButton.clicked.connect(self.toggle_auto_refresh) self.updatePlotButton.clicked.connect(self.update_selected_plot) - + # Time range buttons self.lastHourButton.clicked.connect(lambda: self.set_time_range(1)) self.last6HoursButton.clicked.connect(lambda: self.set_time_range(6)) self.last24HoursButton.clicked.connect(lambda: self.set_time_range(24)) - + # Topic selection self.topicCombo.currentTextChanged.connect(self.on_topic_changed) - + # Set initial time range (last hour) self.set_time_range(1) - + # Status self.statusLabel.setText("Status: Ready") - + def setup_plots(self): """Setup the four plot widgets""" self.plot_widgets = [] - + # Create plot widgets for i in range(4): plot_widget = PlotWidget(f"Plot {i+1}") self.plot_widgets.append(plot_widget) - + # Replace placeholder widgets with actual plot widgets # placeholder = getattr(self, f'plotWidget{i+1}') - placeholder = getattr(self, f'plotWidget{i+3}') # Note: plotWidget3-6 correspond to plots 1-4 + placeholder = getattr( + self, f"plotWidget{i+3}" + ) # Note: plotWidget3-6 correspond to plots 1-4 parent = placeholder.parent() layout = parent.layout() - + # Find the placeholder and replace it for j in range(layout.count()): item = layout.itemAt(j) @@ -173,17 +200,17 @@ def setup_plots(self): # layout.addWidget(plot_widget, j // 2, j % 2) layout.addWidget(plot_widget) break - + def setup_timers(self): """Setup refresh timer""" self.refresh_timer = QTimer() self.refresh_timer.timeout.connect(self.refresh_plots) - + def setup_default_plots(self): """Setup default plots with some common sensors""" # This will be called after sensor hierarchy is loaded pass - + def load_sensor_hierarchy(self): """Load sensor hierarchy from InfluxDB""" try: @@ -192,28 +219,28 @@ def load_sensor_hierarchy(self): self.populate_sensor_tree() self.populate_topic_combo() self.statusLabel.setText(f"Status: Loaded {len(self.topic_sensors)} topics") - + # Load default plots after hierarchy is loaded self.load_default_plots() - + except Exception as e: self.statusLabel.setText(f"Status: Error loading sensors - {str(e)}") QMessageBox.critical(self, "Error", f"Failed to load sensors: {str(e)}") - + def load_default_plots(self): """Load default plots for the first two plot windows""" if not self.topic_sensors: return - + topics = list(self.topic_sensors.keys()) - + # Default plot 1 - first topic, first few sensors if len(topics) > 0: topic = topics[0] sensors = self.topic_sensors[topic][:2] # First 2 sensors self.plot_configs[0] = {"topic": topic, "sensors": sensors} self.update_plot(0, topic, sensors) - + # Default plot 2 - second topic if available, or first topic with different sensors if len(topics) > 1: topic = topics[1] @@ -225,12 +252,12 @@ def load_default_plots(self): sensors = self.topic_sensors[topic][2:4] # Next 2 sensors self.plot_configs[1] = {"topic": topic, "sensors": sensors} self.update_plot(1, topic, sensors) - + def get_sensor_hierarchy(self): """Get sensor hierarchy from InfluxDB""" try: query_api = self.getInfluxQueryAPI() - + # Query to get all unique topics topics_query = """ from(bucket: "sensor_data") @@ -240,17 +267,17 @@ def get_sensor_hierarchy(self): |> distinct(column: "topic") |> sort() """ - + # Get all topics topics = [] topics_result = query_api.query(topics_query, org="pisaoutertracker") for table in topics_result: for record in table.records: topics.append(record.get_value()) - + if not topics: return {} - + # For each topic, get its fields topic_sensors = {} for topic in sorted(topics): @@ -263,149 +290,151 @@ def get_sensor_hierarchy(self): |> distinct(column: "_field") |> sort() """ - + fields_result = query_api.query(fields_query, org="pisaoutertracker") fields = [] for table in fields_result: for record in table.records: fields.append(record.get_value()) - + if fields: topic_sensors[topic] = sorted(fields) - + return topic_sensors - + except Exception as e: print(f"Error fetching sensor hierarchy: {e}") return {} - + def getInfluxQueryAPI(self): """Get InfluxDB query API""" token_location = os.path.join(os.path.dirname(__file__), "influx.sct") token = open(os.path.expanduser(token_location)).read().strip() client = InfluxDBClient(url="http://pccmslab1:8086/", token=token) return client.query_api() - + def populate_sensor_tree(self): """Populate the sensor tree widget""" self.sensorTree.clear() - + if not self.topic_sensors: return - + root_item = QTreeWidgetItem(self.sensorTree) root_item.setText(0, "sensor_data") - + mqtt_item = QTreeWidgetItem(root_item) mqtt_item.setText(0, "mqtt_consumer") - + for topic, sensors in self.topic_sensors.items(): topic_item = QTreeWidgetItem(mqtt_item) topic_item.setText(0, f"{topic} ({len(sensors)} sensors)") - + for sensor in sensors: sensor_item = QTreeWidgetItem(topic_item) sensor_item.setText(0, sensor) - + self.sensorTree.expandAll() - + def populate_topic_combo(self): """Populate the topic combo box""" self.topicCombo.clear() self.topicCombo.addItems(sorted(self.topic_sensors.keys())) - + def on_topic_changed(self, topic): """Handle topic selection change""" self.sensorList.clear() if topic and topic in self.topic_sensors: self.sensorList.addItems(self.topic_sensors[topic]) - + def set_time_range(self, hours): """Set time range for the plots""" end_time = datetime.now() start_time = end_time - timedelta(hours=hours) - + self.startTime.setDateTime(start_time) self.endTime.setDateTime(end_time) - + def toggle_auto_refresh(self): """Toggle auto refresh on/off""" if self.autoRefreshButton.isChecked(): interval = self.intervalSpinBox.value() * 1000 # Convert to milliseconds self.refresh_timer.start(interval) self.autoRefreshButton.setText("Auto: ON") - self.statusLabel.setText(f"Status: Auto-refresh ON ({self.intervalSpinBox.value()}s)") + self.statusLabel.setText( + f"Status: Auto-refresh ON ({self.intervalSpinBox.value()}s)" + ) else: self.refresh_timer.stop() self.autoRefreshButton.setText("Auto: OFF") self.statusLabel.setText("Status: Auto-refresh OFF") - + def refresh_data(self): """Refresh sensor data and update plots""" self.statusLabel.setText("Status: Refreshing data...") self.load_sensor_hierarchy() self.refresh_plots() - + def refresh_plots(self): """Refresh all plots with current data""" for i, config in enumerate(self.plot_configs): if config["topic"] and config["sensors"]: self.update_plot(i, config["topic"], config["sensors"]) - + def update_selected_plot(self): """Update the selected plot with chosen sensors""" plot_index = self.plotCombo.currentIndex() topic = self.topicCombo.currentText() - + # Get selected sensors selected_sensors = [] for i in range(self.sensorList.count()): item = self.sensorList.item(i) if item.isSelected(): selected_sensors.append(item.text()) - + if not selected_sensors: QMessageBox.warning(self, "Warning", "Please select at least one sensor") return - + # Update plot configuration self.plot_configs[plot_index] = {"topic": topic, "sensors": selected_sensors} self.update_plot(plot_index, topic, selected_sensors) - + def update_plot(self, plot_index, topic, sensor_names): """Update a specific plot with sensor data""" if plot_index >= len(self.plot_widgets): return - + try: start_time = self.startTime.dateTime() end_time = self.endTime.dateTime() - + timestamps = [] values = [] - + for sensor_name in sensor_names: sensor_timestamps, sensor_values = self.get_sensor_data( start_time.toString("yyyy-MM-ddThh:mm:ss") + "Z", end_time.toString("yyyy-MM-ddThh:mm:ss") + "Z", - sensor_name, topic + sensor_name, + topic, ) timestamps.append(sensor_timestamps) values.append(sensor_values) - + self.plot_widgets[plot_index].update_plot( - timestamps, values, sensor_names, topic, - start_time, end_time + timestamps, values, sensor_names, topic, start_time, end_time ) - + except Exception as e: print(f"Error updating plot {plot_index}: {e}") - + def get_sensor_data(self, start_time, end_time, sensor_name, topic): """Get sensor data from InfluxDB""" try: query_api = self.getInfluxQueryAPI() - + query = f""" from(bucket: "sensor_data") |> range(start: {start_time}, stop: {end_time}) @@ -415,27 +444,29 @@ def get_sensor_data(self, start_time, end_time, sensor_name, topic): |> aggregateWindow(every: 1m, fn: mean, createEmpty: false) |> yield(name: "mean") """ - + tables = query_api.query(query, org="pisaoutertracker") timestamps = [] values = [] - + for table in tables: for record in table.records: timestamps.append(record.get_time()) values.append(record.get_value()) - + return timestamps, values - + except Exception as e: print(f"Error fetching sensor data: {e}") return [], [] + def main(): app = QApplication(sys.argv) window = SensorMonitor() window.show() sys.exit(app.exec_()) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/power_supply/power_supply_ctrl.py b/power_supply/power_supply_ctrl.py index 162b571..00fba34 100644 --- a/power_supply/power_supply_ctrl.py +++ b/power_supply/power_supply_ctrl.py @@ -9,34 +9,35 @@ # Configure logging logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', + format="%(asctime)s - %(levelname)s - %(message)s", handlers=[ logging.StreamHandler(sys.stdout), # logging.FileHandler('power_supply.log') - ] + ], ) logger = logging.getLogger(__name__) + class PowerSupplyController(QWidget): def __init__(self, parent=None): super(PowerSupplyController, self).__init__(parent) # Load the UI file - ui_path = os.path.join(os.path.dirname(__file__), 'power_supply.ui') + ui_path = os.path.join(os.path.dirname(__file__), "power_supply.ui") loadUi(ui_path, self) - + # Initialize status dictionary self.status = { - 'voltage': 0.000, - 'current': 0.000, - 'power': 'OFF', - 'set_voltage': 0.0, - 'set_current': 0.0 + "voltage": 0.000, + "current": 0.000, + "power": "OFF", + "set_voltage": 0.0, + "set_current": 0.0, } self.connected = False # Set up timer early so closeEvent doesn't crash on connection failure self.timer = QTimer(self) - + # Connect to the power supply self.connect_to_power_supply() if not self.connected: @@ -45,11 +46,11 @@ def __init__(self, parent=None): # Connect UI signals to slots self.setup_ui_connections() - + # Set up a timer to periodically update measurements self.timer.timeout.connect(self.update_measurements) self.timer.start(1000) # Update every 1000ms (1 second) - + # Update UI with initial status self.update_ui() self.log_status("Initial Power Supply Status") @@ -58,18 +59,20 @@ def __init__(self, parent=None): def log_status(self, message): """Log the current status with a descriptive message""" logger.info(f"{message}: {self.status}") - + def connect_to_power_supply(self): """Connect to the power supply device""" try: - ip_address = '192.168.0.16' + ip_address = "192.168.0.16" self.rm = visa.ResourceManager() - self.power_supply = self.rm.open_resource(f'TCPIP0::{ip_address}::INSTR') + self.power_supply = self.rm.open_resource(f"TCPIP0::{ip_address}::INSTR") self.connected = True print("Successfully connected to power supply") except Exception as e: logger.error(f"Failed connection: {e}") - QMessageBox.critical(self, "Connection Error", f"Failed to connect to power supply: {e}") + QMessageBox.critical( + self, "Connection Error", f"Failed to connect to power supply: {e}" + ) def setup_ui_connections(self): """Connect UI signals to slots""" @@ -80,76 +83,80 @@ def setup_ui_connections(self): def update_ui(self): """Update the UI elements with current status""" - self.powsup_current_value_label.setText(str(self.status['current'])) - self.powsup_voltage_value_label.setText(str(self.status['voltage'])) - self.powsup_voltage_set_value_label.setText(str(self.status['set_voltage'])) - self.powsup_current_set_value_label.setText(str(self.status['set_current'])) + self.powsup_current_value_label.setText(str(self.status["current"])) + self.powsup_voltage_value_label.setText(str(self.status["voltage"])) + self.powsup_voltage_set_value_label.setText(str(self.status["set_voltage"])) + self.powsup_current_set_value_label.setText(str(self.status["set_current"])) # Update power LED color - if self.status['power'] == 'ON': + if self.status["power"] == "ON": self.power_LED.setStyleSheet("background-color: green;") else: self.power_LED.setStyleSheet("background-color: red;") - + def update_measurements(self): """Periodically update the voltage and current measurements""" if not self.connected: return try: # Read actual voltage and current - voltage = self.power_supply.query('MEAS:VOLT?\x00').strip() - current = self.power_supply.query('MEAS:CURR?\x00').strip() - + voltage = self.power_supply.query("MEAS:VOLT?\x00").strip() + current = self.power_supply.query("MEAS:CURR?\x00").strip() + # Update status - self.status['voltage'] = voltage - self.status['current'] = current - + self.status["voltage"] = voltage + self.status["current"] = current + # Update UI self.update_ui() self.log_status("Updated measurements") except Exception as e: logger.error(f"Error reading measurements: {e}") - + def set_voltage(self): """Set the voltage value from the line edit""" if not self.connected: return try: voltage = float(self.powsup_voltage_LE.text()) - self.power_supply.write(f'APPLY {voltage}\x00') - self.status['set_voltage'] = voltage + self.power_supply.write(f"APPLY {voltage}\x00") + self.status["set_voltage"] = voltage logger.info(f"Voltage set to {voltage}V") print(f"Voltage set to {voltage}V") except ValueError: logger.error("Invalid voltage input") - QMessageBox.warning(self, "Input Error", "Please enter a valid number for voltage") + QMessageBox.warning( + self, "Input Error", "Please enter a valid number for voltage" + ) except Exception as e: logger.error(f"Failed to set voltage: {e}") QMessageBox.critical(self, "Error", f"Failed to set voltage: {e}") - + def set_current(self): """Set the current value from the line edit""" if not self.connected: return try: current = float(self.powsup_current_LE.text()) - self.power_supply.write(f'SOUR:CURR {current}\x00') - self.status['set_current'] = current + self.power_supply.write(f"SOUR:CURR {current}\x00") + self.status["set_current"] = current logger.info(f"Current set to {current}A") print(f"Current set to {current}A") except ValueError: logger.error("Invalid current input") - QMessageBox.warning(self, "Input Error", "Please enter a valid number for current") + QMessageBox.warning( + self, "Input Error", "Please enter a valid number for current" + ) except Exception as e: logger.error(f"Failed to set current: {e}") QMessageBox.critical(self, "Error", f"Failed to set current: {e}") - + def power_on(self): """Turn the power supply ON""" if not self.connected: return try: - self.power_supply.write('OUTP:STAT ON\x00') - self.status['power'] = 'ON' + self.power_supply.write("OUTP:STAT ON\x00") + self.status["power"] = "ON" self.update_ui() self.log_status("Power Supply turned ON") print("Power Supply is ON") @@ -157,14 +164,14 @@ def power_on(self): print(f"Error turning power ON: {e}") logger.error(f"Failed to turn power ON: {e}") QMessageBox.critical(self, "Error", f"Failed to turn power ON: {e}") - + def power_off(self): """Turn the power supply OFF""" if not self.connected: return try: - self.power_supply.write('OUTP:STAT OFF\x00') - self.status['power'] = 'OFF' + self.power_supply.write("OUTP:STAT OFF\x00") + self.status["power"] = "OFF" self.update_ui() # Force a measurement update to show actual off values self.update_measurements() @@ -174,16 +181,16 @@ def power_off(self): logger.error(f"Failed to turn power OFF: {e}") QMessageBox.critical(self, "Error", f"Failed to turn power OFF: {e}") print(f"Error turning power OFF: {e}") - + def closeEvent(self, event): """Ensure proper cleanup when closing the application""" try: - if hasattr(self, 'timer'): + if hasattr(self, "timer"): self.timer.stop() - if hasattr(self, 'power_supply'): + if hasattr(self, "power_supply"): self.power_off() # Turn off power supply when closing self.power_supply.close() - if hasattr(self, 'rm'): + if hasattr(self, "rm"): self.rm.close() logger.info("Power Supply Controller closed successfully") except Exception as e: @@ -191,6 +198,7 @@ def closeEvent(self, event): logger.error(f"Error during cleanup: {e}") event.accept() + def main(): app = QApplication(sys.argv) window = QMainWindow() @@ -201,6 +209,8 @@ def main(): window.show() sys.exit(app.exec_()) -if __name__ == '__main__': + +if __name__ == "__main__": from PyQt5.QtWidgets import QMainWindow + main() diff --git a/power_supply/rigolDP116A.py b/power_supply/rigolDP116A.py index b25646a..1ac00b2 100644 --- a/power_supply/rigolDP116A.py +++ b/power_supply/rigolDP116A.py @@ -3,84 +3,95 @@ import argparse # Define the IP address of the Rigol DP1116A power supply -ip_address = '192.168.0.16' +ip_address = "192.168.0.16" # Connect to the device via Ethernet using VISA rm = visa.ResourceManager() -power_supply = rm.open_resource(f'TCPIP0::{ip_address}::INSTR') +power_supply = rm.open_resource(f"TCPIP0::{ip_address}::INSTR") + # Function to turn the power supply ON def power_on(): try: - power_supply.write('OUTP:STAT ON\x00') # Turn on output + power_supply.write("OUTP:STAT ON\x00") # Turn on output print("Power Supply is ON.") except Exception as e: print(f"Error turning on power supply: {e}") + # Function to turn the power supply OFF def power_off(): try: - power_supply.write('OUTP:STAT OFF\x00') # Turn off output + power_supply.write("OUTP:STAT OFF\x00") # Turn off output print("Power Supply is OFF.") except Exception as e: print(f"Error turning off power supply: {e}") + # Function to set the voltage def set_voltage(voltage): try: - power_supply.write(f'APPLY {voltage}\x00') # Set voltage + power_supply.write(f"APPLY {voltage}\x00") # Set voltage print(f"Voltage set to {voltage}V.") except Exception as e: print(f"Error setting voltage: {e}") + # Function to set the current def set_current(current): try: - power_supply.write(f'SOUR:CURR {current}\x00') # Set current + power_supply.write(f"SOUR:CURR {current}\x00") # Set current print(f"Current set to {current}A.") except Exception as e: print(f"Error setting current: {e}") + # Function to read voltage def read_voltage(): try: - voltage = power_supply.query('MEAS:VOLT?\x00') # Query voltage + voltage = power_supply.query("MEAS:VOLT?\x00") # Query voltage print(f"Voltage: {voltage} V") except Exception as e: print(f"Error reading voltage: {e}") + # Function to read current def read_current(): try: - current = power_supply.query('MEAS:CURR?\x00') # Query current + current = power_supply.query("MEAS:CURR?\x00") # Query current print(f"Current: {current} A") except Exception as e: print(f"Error reading current: {e}") + # Main function to handle command-line arguments def main(): - parser = argparse.ArgumentParser(description='Control Rigol DP1116A Power Supply via VISA.') - + parser = argparse.ArgumentParser( + description="Control Rigol DP1116A Power Supply via VISA." + ) + # Command-line arguments - parser.add_argument('-v', '--voltage', type=float, help='Set the voltage (in volts)') - parser.add_argument('-i', '--current', type=float, help='Set the current (in amps)') - parser.add_argument('--on', action='store_true', help='Turn the power supply ON') - parser.add_argument('--off', action='store_true', help='Turn the power supply OFF') + parser.add_argument( + "-v", "--voltage", type=float, help="Set the voltage (in volts)" + ) + parser.add_argument("-i", "--current", type=float, help="Set the current (in amps)") + parser.add_argument("--on", action="store_true", help="Turn the power supply ON") + parser.add_argument("--off", action="store_true", help="Turn the power supply OFF") args = parser.parse_args() # Turn on power supply if --on is specified if args.on: power_on() - + # Turn off power supply if --off is specified if args.off: power_off() - + # Set voltage if specified with -v argument if args.voltage is not None: set_voltage(args.voltage) - + # Set current if specified with -i argument if args.current is not None: set_current(args.current) @@ -91,10 +102,11 @@ def main(): read_current() # Optional: Add delay before turning off power supply after all operations + + # if not args.on and not args.off: # time.sleep(2) # power_off() -if __name__ == '__main__': +if __name__ == "__main__": main() - diff --git a/scripts/mount_modules.py b/scripts/mount_modules.py index 4a70418..4626edc 100644 --- a/scripts/mount_modules.py +++ b/scripts/mount_modules.py @@ -9,6 +9,7 @@ PISA_LOCATION = "Pisa" + def check_components(db, module_label, ring_label): # Check module location module_loc = db.component_location(module_label) @@ -23,38 +24,45 @@ def check_components(db, module_label, ring_label): # Verify same location if module_loc != ring_loc: - raise ValueError(f"Module and Ring are in different locations: {module_loc} vs {ring_loc}") + raise ValueError( + f"Module and Ring are in different locations: {module_loc} vs {ring_loc}" + ) - # Need to add a check for Tracker Detector ROOT + # Need to add a check for Tracker Detector ROOT # Get location name location_id = db.get_location_id(PISA_LOCATION) if module_loc != location_id: raise ValueError(f"Components not in PISA location") - - print(f"Components verified: {module_label} and {ring_label} are in {PISA_LOCATION}") + + print( + f"Components verified: {module_label} and {ring_label} are in {PISA_LOCATION}" + ) return True -def build_connect_xml(ring_barcode, module_label, position_index, integration_status="Attached"): + +def build_connect_xml( + ring_barcode, module_label, position_index, integration_status="Attached" +): root = etree.Element("ROOT") parts = etree.SubElement(root, "PARTS") - + # Ring part ring = etree.SubElement(parts, "PART") ring.set("mode", "auto") etree.SubElement(ring, "KIND_OF_PART").text = "TBPS Ring" etree.SubElement(ring, "NAME_LABEL").text = ring_barcode - + # Children section children = etree.SubElement(ring, "CHILDREN") - + # Module part module = etree.SubElement(children, "PART") module.set("mode", "auto") etree.SubElement(module, "KIND_OF_PART").text = "PS Module" etree.SubElement(module, "NAME_LABEL").text = module_label - + # Add Status attribute attrs = etree.SubElement(module, "PREDEFINED_ATTRIBUTES") attr = etree.SubElement(attrs, "ATTRIBUTE") @@ -64,28 +72,29 @@ def build_connect_xml(ring_barcode, module_label, position_index, integration_st attr2 = etree.SubElement(attrs, "ATTRIBUTE") etree.SubElement(attr2, "NAME").text = "Module Integration Status" etree.SubElement(attr2, "VALUE").text = integration_status - + return root + def build_disconnect_xml(module_label): root = etree.Element("ROOT") parts = etree.SubElement(root, "PARTS") - + # Tracker ROOT part tracker = etree.SubElement(parts, "PART") tracker.set("mode", "auto") etree.SubElement(tracker, "KIND_OF_PART").text = "Tracker Detector ROOT" etree.SubElement(tracker, "NAME_LABEL").text = "ROOT" - + # Children section children = etree.SubElement(tracker, "CHILDREN") - + # Module part module = etree.SubElement(children, "PART") module.set("mode", "auto") etree.SubElement(module, "KIND_OF_PART").text = "PS Module" etree.SubElement(module, "NAME_LABEL").text = module_label - + # Remove Status attribute attrs = etree.SubElement(module, "PREDEFINED_ATTRIBUTES") attr = etree.SubElement(attrs, "ATTRIBUTE") @@ -96,21 +105,35 @@ def build_disconnect_xml(module_label): # print(etree.tostring(root, pretty_print=True, xml_declaration=True, encoding='ASCII', standalone='yes')) return root + def main(): - parser = argparse.ArgumentParser(description='Connect/Disconnect modules from rings') - parser.add_argument('module_label', help='Module name label') - parser.add_argument('ring_barcode', help='Ring barcode') - parser.add_argument('--disconnect', action='store_true', help='Disconnect instead of connect') - parser.add_argument('--position-index', type=int, default=0, help='Position index for module') - parser.add_argument('--integration-status', type=str, default='Attached', help='Module Integration Status') + parser = argparse.ArgumentParser( + description="Connect/Disconnect modules from rings" + ) + parser.add_argument("module_label", help="Module name label") + parser.add_argument("ring_barcode", help="Ring barcode") + parser.add_argument( + "--disconnect", action="store_true", help="Disconnect instead of connect" + ) + parser.add_argument( + "--position-index", type=int, default=0, help="Position index for module" + ) + parser.add_argument( + "--integration-status", + type=str, + default="Attached", + help="Module Integration Status", + ) args = parser.parse_args() # Initialize DB access - path = os.path.dirname(os.environ.get('DBLOADER')) + path = os.path.dirname(os.environ.get("DBLOADER")) # cmsr is production database, int2r is test database - db = DBaccess(database="trker_cmsr", verbose=True, login_type='login') - uploader = DBupload(database='cmsr', verbose=True, path_to_dbloader_api=path, login_type='login') + db = DBaccess(database="trker_cmsr", verbose=True, login_type="login") + uploader = DBupload( + database="cmsr", verbose=True, path_to_dbloader_api=path, login_type="login" + ) try: # Verify components @@ -122,17 +145,33 @@ def main(): xml_root = build_disconnect_xml(args.module_label) else: action_prefix = "connect" - xml_root = build_connect_xml(args.ring_barcode, args.module_label, args.position_index, args.integration_status) + xml_root = build_connect_xml( + args.ring_barcode, + args.module_label, + args.position_index, + args.integration_status, + ) # Create output directory and unique filename - output_dir = 'xml_submissions' + output_dir = "xml_submissions" os.makedirs(output_dir, exist_ok=True) timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - filename = os.path.join(output_dir, f'{action_prefix}_{args.ring_barcode}_{args.module_label}_{timestamp}.xml') + filename = os.path.join( + output_dir, + f"{action_prefix}_{args.ring_barcode}_{args.module_label}_{timestamp}.xml", + ) # Write XML file - with open(filename, 'wb') as f: - f.write(etree.tostring(xml_root, pretty_print=True, xml_declaration=True, encoding='ASCII', standalone='yes')) + with open(filename, "wb") as f: + f.write( + etree.tostring( + xml_root, + pretty_print=True, + xml_declaration=True, + encoding="ASCII", + standalone="yes", + ) + ) # Upload to database uploader.upload_data(filename) @@ -143,6 +182,6 @@ def main(): return 0 + if __name__ == "__main__": exit(main()) - diff --git a/scripts/rigolDP116A.py b/scripts/rigolDP116A.py index b25646a..1ac00b2 100644 --- a/scripts/rigolDP116A.py +++ b/scripts/rigolDP116A.py @@ -3,84 +3,95 @@ import argparse # Define the IP address of the Rigol DP1116A power supply -ip_address = '192.168.0.16' +ip_address = "192.168.0.16" # Connect to the device via Ethernet using VISA rm = visa.ResourceManager() -power_supply = rm.open_resource(f'TCPIP0::{ip_address}::INSTR') +power_supply = rm.open_resource(f"TCPIP0::{ip_address}::INSTR") + # Function to turn the power supply ON def power_on(): try: - power_supply.write('OUTP:STAT ON\x00') # Turn on output + power_supply.write("OUTP:STAT ON\x00") # Turn on output print("Power Supply is ON.") except Exception as e: print(f"Error turning on power supply: {e}") + # Function to turn the power supply OFF def power_off(): try: - power_supply.write('OUTP:STAT OFF\x00') # Turn off output + power_supply.write("OUTP:STAT OFF\x00") # Turn off output print("Power Supply is OFF.") except Exception as e: print(f"Error turning off power supply: {e}") + # Function to set the voltage def set_voltage(voltage): try: - power_supply.write(f'APPLY {voltage}\x00') # Set voltage + power_supply.write(f"APPLY {voltage}\x00") # Set voltage print(f"Voltage set to {voltage}V.") except Exception as e: print(f"Error setting voltage: {e}") + # Function to set the current def set_current(current): try: - power_supply.write(f'SOUR:CURR {current}\x00') # Set current + power_supply.write(f"SOUR:CURR {current}\x00") # Set current print(f"Current set to {current}A.") except Exception as e: print(f"Error setting current: {e}") + # Function to read voltage def read_voltage(): try: - voltage = power_supply.query('MEAS:VOLT?\x00') # Query voltage + voltage = power_supply.query("MEAS:VOLT?\x00") # Query voltage print(f"Voltage: {voltage} V") except Exception as e: print(f"Error reading voltage: {e}") + # Function to read current def read_current(): try: - current = power_supply.query('MEAS:CURR?\x00') # Query current + current = power_supply.query("MEAS:CURR?\x00") # Query current print(f"Current: {current} A") except Exception as e: print(f"Error reading current: {e}") + # Main function to handle command-line arguments def main(): - parser = argparse.ArgumentParser(description='Control Rigol DP1116A Power Supply via VISA.') - + parser = argparse.ArgumentParser( + description="Control Rigol DP1116A Power Supply via VISA." + ) + # Command-line arguments - parser.add_argument('-v', '--voltage', type=float, help='Set the voltage (in volts)') - parser.add_argument('-i', '--current', type=float, help='Set the current (in amps)') - parser.add_argument('--on', action='store_true', help='Turn the power supply ON') - parser.add_argument('--off', action='store_true', help='Turn the power supply OFF') + parser.add_argument( + "-v", "--voltage", type=float, help="Set the voltage (in volts)" + ) + parser.add_argument("-i", "--current", type=float, help="Set the current (in amps)") + parser.add_argument("--on", action="store_true", help="Turn the power supply ON") + parser.add_argument("--off", action="store_true", help="Turn the power supply OFF") args = parser.parse_args() # Turn on power supply if --on is specified if args.on: power_on() - + # Turn off power supply if --off is specified if args.off: power_off() - + # Set voltage if specified with -v argument if args.voltage is not None: set_voltage(args.voltage) - + # Set current if specified with -i argument if args.current is not None: set_current(args.current) @@ -91,10 +102,11 @@ def main(): read_current() # Optional: Add delay before turning off power supply after all operations + + # if not args.on and not args.off: # time.sleep(2) # power_off() -if __name__ == '__main__': +if __name__ == "__main__": main() - diff --git a/session-ses_19b8.md b/session-ses_19b8.md new file mode 100644 index 0000000..430632d --- /dev/null +++ b/session-ses_19b8.md @@ -0,0 +1,313 @@ +# Codebase overview and connections + +**Session ID:** ses_19b84853cffeZtVdgrIeqyIyIP +**Created:** 5/26/2026, 3:31:14 PM +**Updated:** 5/26/2026, 3:45:22 PM + +--- + +## User + +ubderstand the whole codebase, and give me a detail overview of the project, how things are connected and working. + +--- + +## Assistant + +**Tool: read** + +--- + +## Assistant + +**Tool: task** + +--- + +## Assistant + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +--- + +## Assistant + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +--- + +## Assistant + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +--- + +## Assistant + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +**Tool: read** + +--- + +## Assistant + +Here is a comprehensive overview of the project. + +--- + +## Project: CMS Pisa Integration Tools + +This is a **CMS (Compact Muon Solenoid) detector integration and testing system** for the Phase-2 Upgrade of the Inner Tracker (IT) at **CERN's Pisa site**. It provides a suite of tools to control, monitor, and test detector modules (silicon sensors) during their assembly and burn-in process. + +--- + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ TWO MAIN GUI APPLICATIONS │ +│ │ +│ integration.py (Burn-In Test Station) cold.py (Cold Room Ctrl) │ +│ ┌──────────────────────────────────┐ ┌────────────────────────┐ │ +│ │ Module Testing & Verification │ │ Environmental Control │ │ +│ │ HV/LV via CAEN power supplies │ │ MARTA CO2 Plant │ │ +│ │ Ph2_ACF readout system │ │ Temperature/Humidity │ │ +│ │ Thermal Camera MQTT monitoring │ │ Door/Light/Dry Air │ │ +│ │ Module mounting ring diagram │ │ Safety interlocks │ │ +│ │ Test session management │ │ Modules List/Layout │ │ +│ └──────────────────────────────────┘ └────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ SHARED LAYERS │ +│ │ +│ db/ - Database REST API client (ModuleDB, utils) │ +│ caen/ - CAEN HV/LV power supply control (TCP/IP) │ +│ coldroom/ - System state, MQTT, safety, thermal camera │ +│ ui/ - PyQt5 .ui compiled GUIs │ +│ power_supply/ - Rigol bench power supply control (USB) │ +│ plot_tab/ - InfluxDB sensor monitoring │ +│ scripts/ - Mount modules, DB upload │ +│ tests/ - Unit/integration tests │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ EXTERNAL HARDWARE / NETWORK │ +│ │ +│ MQTT Broker (192.168.0.45) ← Thermal Camera, Sensors, Actuators │ +│ REST API (cmslabserver:5000) ← Module database backend │ +│ CAEN Power Supply (TCP:7000) ← HV/LV bias for modules │ +│ Ph2_ACF Test System ← Readout board testing framework │ +│ Rigol DP116A (USB/Serial) ← Bench power supply │ +│ Shellies Smart Switches ← Air/dry air control │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### Major Components + +#### 1. `integration.py` — Main Burn-In Test Station GUI +- **Purpose**: The primary interface for operators testing detector modules on the integration bench +- **Key features**: + - **Ring Diagram**: Visual representation of the detector ring with clickable module positions (`integration.py:774-877`) + - **Test Execution Pipeline**: Three sequential tests: Check ID → Light ON Test (eyeOpening) → Dark Test (calibration+pedestal+noise) (`integration.py:1053-1201`) + - **Command Expansion**: Placeholder system (`{module_id}`, `{fiber_endpoint}`, `{session}`) for flexible command construction (`integration.py:958-968`) + - **MQTT Monitoring**: Subscribes to thermal camera data (`/ar/thermal/image`), Ph2_ACF temperatures (`/ph2acf/data`), and air status (`integration.py:517-751`) + - **CAEN Power Control**: Integrated CAEN HV/LV GUI tab for biasing modules + - **Session Management**: Creates test sessions on the database with operator info, comments, timestamp (`integration.py:390-413`) + - **Thermal Safety**: Auto-shutdown HV/LV if temperature exceeds 45°C/50°C (`integration.py:727-731`) + - **Module DB Integration**: Full module inventory browse, search, filter, and detail view + +#### 2. `cold.py` — Cold Room & MARTA Control GUI +- **Purpose**: Full environmental control and monitoring for the cold room where modules are tested at low temperatures +- **Key features**: + - **MARTA CO₂ Plant Control**: Temperature setpoint, chiller speed, CO₂ flow control, start/stop (`cold.py:294-492`) + - **Cold Room Control**: Temperature/Humidity regulation, lights, dry air, door lock, run state (`cold.py:554-903`) + - **Safety Interlocks**: Dew point checking, door safe-to-open logic, light safe-to-turn-on, HV interlock, CO₂ level monitoring (`coldroom/safety.py`) + - **Modules List Tab**: Interactive table showing each module's position, power status (HV/LV), fiber connection, temperature, and grade (`coldroom/modules_list_gui.py`) + - **Thermal Camera Tab**: Real-time 24×32 thermal image display, trend plots (`coldroom/thermal_camera_gui.py`) + - **Module Temperatures Tab**: Ph2_ACF temperature monitoring with SSA/MPA mean/max trends (`coldroom/module_temperatures_gui.py`) + - **CAEN Control**: Two CAEN tabs — one for the Outer Tracker (OT) and one for the Inner Tracker (IT) + +#### 3. `db/` — Database Layer +- **`module_db.py`**: Central DB widget with module inventory tree, search/filter by layer/speed/spacer/grade, module detail view (FB, EB, connections, temperature offsets, grade, comments) (`db/module_db.py`) +- **`utils.py`**: REST API helper functions for all DB operations: + - `get_module()` — Fetch single module by ID + - `get_modules_on_ring()` — Query all modules mounted on a given ring + - `get_module_endpoints()` — Get fiber/power connections for a module + - `get_module_name_from_fc7()` — Reverse lookup from FC7 readout board + port + - `get_ring_from_cable()` — Determine which ring a Harting cable connects to + - `get_module_speed()`, `get_module_fuse_id()`, `get_module_lpgbtVersion()` — Various metadata lookups +- **`module_db_gui.py`**: Compiled Qt UI (`module_db.ui`) with the tree widget + +#### 4. `caen/` — CAEN Power Supply Control +- **`caenGUIall.py`**: OT CAEN GUI and TCP/IP protocol client — controls HV (up to ~200V) and LV channels; queries status (current, voltage, temperature); threaded non-blocking I/O +- **`caenGUI.py`**: Simplified CAEN control used by `integration.py` +- **`caencli.py`**: CLI tool to send raw commands to CAEN power supplies + +#### 5. `coldroom/` — Cold Room Subsystem +- **`system.py`**: Central `System` class holding shared state (status dict, MQTT clients, settings) +- **`marta_coldroom.py`**: `MartaColdRoomMQTTClient` — handles MQTT pub/sub for MARTA, cold room, cleanroom, CO₂ sensor, dry air bypass (`http://192.168.0.204/relay/0`) +- **`thermal_camera.py`**: `ThermalCameraMQTTClient` — receives thermal image data from MQTT +- **`safety.py`**: Safety functions — `check_dew_point()`, `check_door_safe_to_open()`, `check_light_safe_to_turn_on()`, `check_marta_safe()`, `check_any_hv_on()` +- **`modules_list_gui.py`**: Module configuration table per position +- **`module_temperatures_gui.py`**: Real-time temperature plots per module + +#### 6. Standalone Tools +- **`glue_analyzer.py`** — Interactive OpenCV tool for analyzing glue contact area in module assembly images. Supports ROI selection, thresholding, gradient correction, manual painting (`glue_analyzer.py`) +- **`drawEvent.py`** — ROOT-based 3D event display for visualizing tracker hits (strips, pixels, stubs with bending) from ROOT data files (`drawEvent.py`) +- **`dbbrowser.py`** — Standalone GUI for browsing the module database +- **`dbrunsearch.py`** — CLI tool to search runs, view session details, edit session comments against the DB API +- **`modulesearch.py`** — CLI tool to query module info (connections, speed, lpGBT version) by module ID or FC7 port +- **`edit_ompo_report.py`** — PowerPoint template editor for OMPO shift reports (replaces Name/Date/Status fields) +- **`monitor_module.sh`** & **`balistic_calibration.sh`** — Shell scripts for monitoring and calibration + +#### 7. `power_supply/` — Bench Power Supply +- **`rigolDP116A.py`** & **`power_supply_ctrl.py`** — Control for Rigol DP116A programmable bench power supply via USB/serial + +#### 8. `plot_tab/` — Sensor Monitoring +- **`influx_3.py`** & **`sensor_monitor.py`** — InfluxDB-based sensor monitoring with historical data plotting + +#### 9. `scripts/` — Utilities +- **`mount_modules.py`** — Script for mounting modules on rings +- **`py4dbupload/`** — Database upload scripts + +--- + +### Communication & Data Flow + +| Protocol / Interface | Usage | +|---|---| +| **MQTT** (paho-mqtt) | Thermal camera images, environmental sensors, actuator commands, status broadcasts | +| **REST API** (requests) | Module database CRUD operations to `cmslabserver:5000` | +| **TCP/IP** (socket) | CAEN power supply control on port 7000 | +| **USB/Serial** | Rigol DP116A bench supply | +| **Shell Commands** (subprocess) | Ph2_ACF test execution, shell scripts | +| **HTTP** (requests) | Shellies smart switch control (`192.168.0.204`) | + +--- + +### Configuration + +- **`settings_integration.yaml`**: Database URL, MQTT broker/topics, test commands (check_id, light_on, dark_test), air command, results URL +- **`settings_coldroom.yaml`**: MQTT topics for Cleanroom, Coldroom, MARTA, ThermalCamera, CO₂ sensor, Shellies air switch +- User settings saved to `~/.config/integration_ui/settings.yaml` +- Session persistence via `~/.config/integration_ui/lastsession.txt` + +--- + +### Module Types & Geometry + +The project supports three ring layers with different configurations: +- **L1** (18 modules): Three variants (47mm, 60mm, 72mm) with 2.6mm or 4.0mm spacers +- **L2** (26 modules): Three variants (40mm, 55mm, 68mm) +- **L3** (36 modules): Single variant, 5G speed, 2.6mm spacer + +Each module has fiber connections (SfibA, SfibB, E3;1-5) and power connections (BINT1, R01;M1.1-12, R01;M2.1-12, R01;M3.1-12). + +--- + +### Entry Points + +| Command | Purpose | +|---|---| +| `python integration.py` | Launch Burn-In Test Station GUI | +| `python cold.py` | Launch Cold Room / MARTA Control GUI | +| `python dbbrowser.py` | Standalone Module DB browser | +| `python modulesearch.py ` | CLI module info lookup | +| `python dbrunsearch.py ` | CLI run/session search | +| `python glue_analyzer.py ` | Glue contact analysis tool | +| `python drawEvent.py -i .root` | ROOT event display | +| `python edit_ompo_report.py ` | OMPO report editor | + +--- + +### Test Dependencies + +- Python 3 with PyQt5, matplotlib, numpy, requests, paho-mqtt, pyyaml, opencv-python, python-pptx, ROOT +- Hardware: CAEN power supplies, thermal camera (FLIR/Lepton), Rigol DP116A, Shellies smart relays, FC7/Ph2_ACF readout boards + +--- + +## User + +How to save this chat in a markdown ? + +--- + +## Assistant + +**Tool: websearch** + +--- + +## Assistant + +There are a couple ways: + +**Within the TUI**: Type `/export` during a conversation to export the current session. + +**From the CLI** (if the TUI doesn't work): +```bash +opencode session list # find the session ID +opencode export --format md -o conversation.md +``` + +Alternatively, install a dedicated tool for this: +```bash +pip install opencode-log +opencode-log --format both # generates both HTML and Markdown +``` + +Or install the autosave plugin: +```bash +npm install -g opencode-autosave-conversation +# then add to your opencode.json +``` + +--- + diff --git a/settings_coldroom.yaml b/settings_coldroom.yaml index 988172f..0c9e868 100644 --- a/settings_coldroom.yaml +++ b/settings_coldroom.yaml @@ -10,5 +10,6 @@ ThermalCamera: mqtt_topic: /thermalcamera/# stitch_camera: camera0 mqtt: - broker: 192.168.0.45 + # broker: 192.168.0.45 + broker: pccmslab1 port: 1883 diff --git a/settings_integration.yaml b/settings_integration.yaml index 81a90d2..a5c7137 100644 --- a/settings_integration.yaml +++ b/settings_integration.yaml @@ -5,10 +5,12 @@ check_id_command: cd /home/thermal/BurnIn_moduleTest && python3 moduleTest.py -- dark_test_command: cd /home/thermal/BurnIn_moduleTest && python3 moduleTest.py --module {module_id} --slot `echo {fiber_endpoint} | perl -pe 's/.*OG//'` --board `echo {fiber_endpoint} | perl -pe 's/_.*//'` -c calibrationandpedenoise --session {session} | tee /tmp/analysis.log -db_url: http://192.168.0.45:5000 +# db_url: http://192.168.0.45:5000 +db_url: http://pccmslab1:5000 light_on_command: cd /home/thermal/BurnIn_moduleTest && python3 moduleTest.py --module {module_id} --slot `echo {fiber_endpoint} | perl -pe 's/.*OG//'` --board `echo {fiber_endpoint} | perl -pe 's/_.*//'` -c eyeOpening --session {session} -mqtt_server: 192.168.0.45 +mqtt_server: pccmslab1 #192.168.0.45 +# mqtt_server: 192.168.0.45 mqtt_topic: /ar/thermal/image results_url: file:///tmp/latest_ana/index.html diff --git a/tests/simple_test.py b/tests/simple_test.py index 176186e..b43b9cc 100644 --- a/tests/simple_test.py +++ b/tests/simple_test.py @@ -9,52 +9,54 @@ # Add the parent directory to sys.path to import the modules sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + def test_data_type_preservation(): """Test the core data type preservation functionality without GUI""" - + # Create a simple mock class with just the data type preservation methods class MockModuleDB: def __init__(self): self.original_data_types = {} - + def store_data_types(self, data, path): """Store the original data types for later reconstruction""" if isinstance(data, dict): - self.original_data_types[path] = 'dict' + self.original_data_types[path] = "dict" for key, value in data.items(): current_path = f"{path}.{key}" if path else key self.store_data_types(value, current_path) elif isinstance(data, list): - self.original_data_types[path] = 'list' + self.original_data_types[path] = "list" for i, value in enumerate(data): current_path = f"{path}[{i}]" self.store_data_types(value, current_path) else: self.original_data_types[path] = type(data).__name__ - + def extract_list_index(self, path): """Extract list index from path like 'children[0]' -> 0""" import re - match = re.search(r'\[(\d+)\]$', path) + + match = re.search(r"\[(\d+)\]$", path) return int(match.group(1)) if match else 0 - + def simulate_type_conversion(self, value, original_type): """Simulate the type conversion that would happen""" - if original_type == 'bool': - return str(value).lower() == 'true' - elif original_type == 'int': + if original_type == "bool": + return str(value).lower() == "true" + elif original_type == "int": try: return int(value) except ValueError: return value - elif original_type == 'float': + elif original_type == "float": try: return float(value) except ValueError: return value else: return str(value) - + # Test data test_data = { "moduleName": "TEST_MODULE_001", @@ -64,33 +66,30 @@ def simulate_type_conversion(self, value, original_type): "readings": [1.1, 2.2, 3.3], "metadata": { "created": "2024-01-01", - "flags": { - "enabled": False, - "priority": 1 - } + "flags": {"enabled": False, "priority": 1}, }, "children": [ {"name": "child1", "type": "sensor"}, - {"name": "child2", "type": "actuator"} - ] + {"name": "child2", "type": "actuator"}, + ], } - + # Create mock instance mock_db = MockModuleDB() - + # Store data types mock_db.store_data_types(test_data, "") - + # Verify types were stored correctly expected_types = { "": "dict", "moduleName": "str", - "isActive": "bool", + "isActive": "bool", "temperature": "float", "count": "int", "readings": "list", "readings[0]": "float", - "readings[1]": "float", + "readings[1]": "float", "readings[2]": "float", "metadata": "dict", "metadata.created": "str", @@ -103,20 +102,22 @@ def simulate_type_conversion(self, value, original_type): "children[0].type": "str", "children[1]": "dict", "children[1].name": "str", - "children[1].type": "str" + "children[1].type": "str", } - + print("Testing data type storage...") all_passed = True - + for path, expected_type in expected_types.items(): stored_type = mock_db.original_data_types.get(path) if stored_type != expected_type: - print(f"❌ FAIL: Path '{path}' expected '{expected_type}' but got '{stored_type}'") + print( + f"❌ FAIL: Path '{path}' expected '{expected_type}' but got '{stored_type}'" + ) all_passed = False else: print(f"✅ PASS: Path '{path}' -> '{expected_type}'") - + # Test type conversion print("\nTesting type conversion...") test_conversions = [ @@ -125,26 +126,30 @@ def simulate_type_conversion(self, value, original_type): ("42", "int", 42), ("3.14", "float", 3.14), ("hello", "str", "hello"), - ("invalid", "int", "invalid") # Should fall back to string + ("invalid", "int", "invalid"), # Should fall back to string ] - + for input_val, input_type, expected_output in test_conversions: result = mock_db.simulate_type_conversion(input_val, input_type) if result == expected_output and type(result) == type(expected_output): - print(f"✅ PASS: '{input_val}' ({input_type}) -> {result} ({type(result).__name__})") + print( + f"✅ PASS: '{input_val}' ({input_type}) -> {result} ({type(result).__name__})" + ) else: - print(f"❌ FAIL: '{input_val}' ({input_type}) -> {result} ({type(result).__name__}), expected {expected_output} ({type(expected_output).__name__})") + print( + f"❌ FAIL: '{input_val}' ({input_type}) -> {result} ({type(result).__name__}), expected {expected_output} ({type(expected_output).__name__})" + ) all_passed = False - + # Test list index extraction print("\nTesting list index extraction...") index_tests = [ ("children[0]", 0), ("readings[5]", 5), ("data.items[12]", 12), - ("simple_path", 0) + ("simple_path", 0), ] - + for path, expected_index in index_tests: result = mock_db.extract_list_index(path) if result == expected_index: @@ -152,7 +157,7 @@ def simulate_type_conversion(self, value, original_type): else: print(f"❌ FAIL: '{path}' -> {result}, expected {expected_index}") all_passed = False - + print(f"\n{'='*50}") if all_passed: print("🎉 ALL TESTS PASSED! Data type preservation is working correctly.") @@ -161,6 +166,7 @@ def simulate_type_conversion(self, value, original_type): print("❌ SOME TESTS FAILED. Check the implementation.") return False + if __name__ == "__main__": success = test_data_type_preservation() sys.exit(0 if success else 1) diff --git a/tests/test_data_generator.py b/tests/test_data_generator.py index 45e1c8d..90dc30d 100644 --- a/tests/test_data_generator.py +++ b/tests/test_data_generator.py @@ -8,14 +8,14 @@ class ModuleTestDataGenerator: """Generate realistic test data for module database testing""" - + LAYER_TYPES = ["L1_47", "L1_60", "L1_72", "L2_40", "L2_55", "L2_68", "L3"] SPACER_SIZES = ["18", "26", "40"] SPEEDS = ["5G", "10G"] GRADES = ["A++", "A+", "A", "B", "C"] STATUSES = ["active", "inactive", "testing", "failed", "retired"] CENTERS = ["Pisa", "CERN", "Other"] - + @staticmethod def generate_module_name(layer_type=None, spacer=None, speed=None): """Generate a realistic module name""" @@ -25,89 +25,91 @@ def generate_module_name(layer_type=None, spacer=None, speed=None): spacer = random.choice(ModuleTestDataGenerator.SPACER_SIZES) if not speed: speed = random.choice(ModuleTestDataGenerator.SPEEDS) - + speed_suffix = "5" if speed == "5G" else "10" serial = random.randint(1000, 9999) - + return f"{layer_type}_{spacer}_{speed_suffix}_{serial:04d}" - + @staticmethod def generate_sensor_readings(count=10): """Generate realistic sensor readings""" base_temp = 25.0 return [round(base_temp + random.uniform(-2.0, 2.0), 2) for _ in range(count)] - + @staticmethod def generate_child_component(): """Generate a child component structure""" component_types = ["PS Read-out Hybrid", "Sensor", "Connector", "Cable"] component_type = random.choice(component_types) - + base_component = { "childName": f"{component_type}_{random.randint(1, 999):03d}", "childType": component_type, "serialNumber": f"SN{random.randint(100000, 999999)}", - "status": random.choice(ModuleTestDataGenerator.STATUSES) + "status": random.choice(ModuleTestDataGenerator.STATUSES), } - + # Add specific details based on component type if component_type == "PS Read-out Hybrid": base_component["details"] = { "ALPGBT_BANDWIDTH": random.choice(["5Gbps", "10Gbps"]), "FIRMWARE_VERSION": f"v{random.randint(1, 5)}.{random.randint(0, 9)}", "TEMPERATURE_LIMIT": random.randint(60, 80), - "POWER_CONSUMPTION": round(random.uniform(2.0, 8.0), 2) + "POWER_CONSUMPTION": round(random.uniform(2.0, 8.0), 2), } elif component_type == "Sensor": base_component["details"] = { "SENSOR_TYPE": random.choice(["temperature", "humidity", "pressure"]), - "CALIBRATION_DATE": (datetime.now() - timedelta(days=random.randint(0, 365))).isoformat(), - "READINGS": ModuleTestDataGenerator.generate_sensor_readings() + "CALIBRATION_DATE": ( + datetime.now() - timedelta(days=random.randint(0, 365)) + ).isoformat(), + "READINGS": ModuleTestDataGenerator.generate_sensor_readings(), } - + return base_component - + @staticmethod def generate_crate_side_connections(): """Generate crate side connections""" connections = {} - + # Randomly generate some connections for i in range(random.randint(0, 4)): crate_name = f"Crate_{chr(65 + i)}" # Crate_A, Crate_B, etc. slot_count = random.randint(1, 3) slots = [f"Slot_{j+1}" for j in range(slot_count)] connections[crate_name] = slots - + return connections - + @staticmethod def generate_full_module_data(module_name=None): """Generate a complete module data structure""" if not module_name: module_name = ModuleTestDataGenerator.generate_module_name() - + # Extract info from module name name_parts = module_name.split("_") layer_type = "_".join(name_parts[:2]) if len(name_parts) >= 2 else "L1_47" spacer = name_parts[1] if len(name_parts) > 1 else "26" speed_suffix = name_parts[2] if len(name_parts) > 2 else "10" speed = "10G" if speed_suffix == "10" else "5G" - + # Generate children num_children = random.randint(1, 5) children = {} children_list = [] - + for i in range(num_children): child = ModuleTestDataGenerator.generate_child_component() child_name = child["childName"] children[child_name] = child children_list.append(child) - + # Generate connections crate_connections = ModuleTestDataGenerator.generate_crate_side_connections() - + module_data = { "moduleName": module_name, "inventorySlot": f"INV_{random.randint(1000, 9999)}", @@ -117,47 +119,61 @@ def generate_full_module_data(module_name=None): "spacer": spacer, "Current Center": random.choice(ModuleTestDataGenerator.CENTERS), "crateSide": crate_connections, - "mounted_on": f"TestBench_{random.randint(1, 10)}" if random.random() > 0.5 else "", + "mounted_on": ( + f"TestBench_{random.randint(1, 10)}" if random.random() > 0.5 else "" + ), "children": children, # Dictionary format "childrenList": children_list, # List format - for testing type preservation "details": { "DESCRIPTION": f"Test module {module_name}", - "MANUFACTURE_DATE": (datetime.now() - timedelta(days=random.randint(30, 730))).isoformat(), - "LAST_TESTED": (datetime.now() - timedelta(days=random.randint(0, 30))).isoformat(), + "MANUFACTURE_DATE": ( + datetime.now() - timedelta(days=random.randint(30, 730)) + ).isoformat(), + "LAST_TESTED": ( + datetime.now() - timedelta(days=random.randint(0, 30)) + ).isoformat(), "TEST_CYCLES": random.randint(0, 1000), "TEMPERATURE_RANGE": { "min": round(random.uniform(-40, -20), 1), "max": round(random.uniform(60, 85), 1), - "operating": round(random.uniform(20, 30), 1) + "operating": round(random.uniform(20, 30), 1), }, "ELECTRICAL_SPECS": { "voltage": round(random.uniform(3.0, 5.0), 2), "current": round(random.uniform(0.5, 2.0), 3), - "resistance": round(random.uniform(10, 100), 1) + "resistance": round(random.uniform(10, 100), 1), }, "QA_CHECKS": [ {"test": "visual_inspection", "passed": True, "date": "2024-01-15"}, - {"test": "electrical_test", "passed": random.choice([True, False]), "date": "2024-01-16"}, - {"test": "thermal_cycling", "passed": True, "date": "2024-01-17"} - ] + { + "test": "electrical_test", + "passed": random.choice([True, False]), + "date": "2024-01-16", + }, + {"test": "thermal_cycling", "passed": True, "date": "2024-01-17"}, + ], }, "metadata": { "created_by": f"user_{random.randint(1, 100)}", - "created_at": (datetime.now() - timedelta(days=random.randint(1, 365))).isoformat(), + "created_at": ( + datetime.now() - timedelta(days=random.randint(1, 365)) + ).isoformat(), "updated_at": datetime.now().isoformat(), "version": random.randint(1, 10), - "tags": random.sample(["prototype", "production", "test", "experimental", "certified"], - random.randint(1, 3)), + "tags": random.sample( + ["prototype", "production", "test", "experimental", "certified"], + random.randint(1, 3), + ), "flags": { "is_prototype": random.choice([True, False]), "requires_special_handling": random.choice([True, False]), - "has_known_issues": random.choice([True, False]) - } - } + "has_known_issues": random.choice([True, False]), + }, + }, } - + return module_data - + @staticmethod def generate_multiple_modules(count=5): """Generate multiple module data structures""" @@ -170,14 +186,14 @@ def generate_multiple_modules(count=5): # Example usage and test data if __name__ == "__main__": generator = ModuleTestDataGenerator() - + # Generate a single module single_module = generator.generate_full_module_data() print("Single module example:") print(f"Name: {single_module['moduleName']}") print(f"Children count: {len(single_module['children'])}") print(f"Has connections: {bool(single_module['crateSide'])}") - + # Generate multiple modules multiple_modules = generator.generate_multiple_modules(3) print(f"\nGenerated {len(multiple_modules)} modules") diff --git a/tests/test_integration.py b/tests/test_integration.py index 8fbba2b..992446c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -14,7 +14,7 @@ class TestModuleDBWithRealData(unittest.TestCase): """Integration tests using realistic generated data""" - + @classmethod def setUpClass(cls): """Set up QApplication for GUI tests""" @@ -22,47 +22,58 @@ def setUpClass(cls): cls.app = QApplication([]) else: cls.app = QApplication.instance() - + def setUp(self): """Set up test fixtures""" - with patch('db.module_db.Ui_ModuleDBWidget'): + with patch("db.module_db.Ui_ModuleDBWidget"): self.module_db = ModuleDB() - + # Mock UI components self.module_db.ui = Mock() self.module_db.ui.detailsTree = Mock() self.module_db.ui.moduleNameLabel = Mock() self.module_db.ui.tabWidget = Mock() self.module_db.ui.moduleDetailsTab = Mock() - + # Generate test data self.generator = ModuleTestDataGenerator() self.test_modules = self.generator.generate_multiple_modules(5) - + def test_complex_data_type_preservation(self): """Test data type preservation with complex, realistic module data""" for module_data in self.test_modules: - with self.subTest(module=module_data['moduleName']): + with self.subTest(module=module_data["moduleName"]): # Store original data types self.module_db.original_data_types = {} self.module_db.store_data_types(module_data, "") - + # Verify key data types are preserved self.assertEqual(self.module_db.original_data_types[""], "dict") - self.assertEqual(self.module_db.original_data_types["moduleName"], "str") - + self.assertEqual( + self.module_db.original_data_types["moduleName"], "str" + ) + # Check children structure (both dict and list formats) if "children" in module_data: - self.assertEqual(self.module_db.original_data_types["children"], "dict") + self.assertEqual( + self.module_db.original_data_types["children"], "dict" + ) if "childrenList" in module_data: - self.assertEqual(self.module_db.original_data_types["childrenList"], "list") - + self.assertEqual( + self.module_db.original_data_types["childrenList"], "list" + ) + # Check nested structures if "details" in module_data: - self.assertEqual(self.module_db.original_data_types["details"], "dict") + self.assertEqual( + self.module_db.original_data_types["details"], "dict" + ) if "QA_CHECKS" in module_data["details"]: - self.assertEqual(self.module_db.original_data_types["details.QA_CHECKS"], "list") - + self.assertEqual( + self.module_db.original_data_types["details.QA_CHECKS"], + "list", + ) + # Check metadata flags if "metadata" in module_data and "flags" in module_data["metadata"]: flags = module_data["metadata"]["flags"] @@ -70,10 +81,9 @@ def test_complex_data_type_preservation(self): flag_path = f"metadata.flags.{flag_name}" if isinstance(flag_value, bool): self.assertEqual( - self.module_db.original_data_types[flag_path], - "bool" + self.module_db.original_data_types[flag_path], "bool" ) - + def test_list_order_preservation(self): """Test that list order is preserved during roundtrip""" # Create a module with ordered lists @@ -81,19 +91,19 @@ def test_list_order_preservation(self): "ordered_list": ["first", "second", "third", "fourth"], "qa_checks": [ {"step": 1, "name": "visual"}, - {"step": 2, "name": "electrical"}, - {"step": 3, "name": "thermal"} - ] + {"step": 2, "name": "electrical"}, + {"step": 3, "name": "thermal"}, + ], } - + # Store data types self.module_db.original_data_types = {} self.module_db.store_data_types(test_data, "") - + # Simulate reconstruction for ordered_list mock_list_item = Mock() mock_list_item.childCount.return_value = 4 - + # Create mock children in specific order children = [] for i, value in enumerate(test_data["ordered_list"]): @@ -102,14 +112,14 @@ def test_list_order_preservation(self): child.childCount.return_value = 0 child.text.return_value = value children.append(child) - + mock_list_item.child.side_effect = children - + result = self.module_db.get_item_value(mock_list_item, "ordered_list") - + self.assertEqual(result, test_data["ordered_list"]) self.assertIsInstance(result, list) - + def test_nested_dict_list_combinations(self): """Test complex nested combinations of dicts and lists""" complex_data = { @@ -119,44 +129,41 @@ def test_nested_dict_list_combinations(self): "readings": [25.1, 25.2, 25.0], "metadata": { "calibrated": True, - "calibration_points": [0.0, 25.0, 50.0, 75.0, 100.0] - } + "calibration_points": [0.0, 25.0, 50.0, 75.0, 100.0], + }, }, { - "name": "pressure_sensor_1", + "name": "pressure_sensor_1", "readings": [101325, 101330, 101320], - "metadata": { - "calibrated": False, - "calibration_points": [] - } - } + "metadata": {"calibrated": False, "calibration_points": []}, + }, ] } - + # Store data types self.module_db.original_data_types = {} self.module_db.store_data_types(complex_data, "") - + # Verify complex nested paths expected_types = { "": "dict", - "sensors": "list", + "sensors": "list", "sensors[0]": "dict", "sensors[0].readings": "list", "sensors[0].readings[0]": "float", "sensors[0].metadata": "dict", "sensors[0].metadata.calibrated": "bool", "sensors[0].metadata.calibration_points": "list", - "sensors[1].metadata.calibrated": "bool" + "sensors[1].metadata.calibrated": "bool", } - + for path, expected_type in expected_types.items(): self.assertEqual( - self.module_db.original_data_types[path], + self.module_db.original_data_types[path], expected_type, - f"Wrong type for path {path}" + f"Wrong type for path {path}", ) - + def test_edge_case_data_types(self): """Test edge cases with various data types""" edge_case_data = { @@ -170,13 +177,13 @@ def test_edge_case_data_types(self): "negative_int": -42, "scientific_notation": 1.23e-4, "unicode_string": "测试数据", - "special_chars": "!@#$%^&*()" + "special_chars": "!@#$%^&*()", } - + # Store data types self.module_db.original_data_types = {} self.module_db.store_data_types(edge_case_data, "") - + # Test type preservation for edge cases test_cases = [ ("null_value", None, "NoneType"), @@ -186,126 +193,144 @@ def test_edge_case_data_types(self): ("empty_list", [], "list"), ("empty_dict", {}, "dict"), ("negative_int", -42, "int"), - ("scientific_notation", 1.23e-4, "float") + ("scientific_notation", 1.23e-4, "float"), ] - + for field, value, expected_type in test_cases: self.assertEqual( self.module_db.original_data_types[field], expected_type, - f"Wrong type stored for {field}" + f"Wrong type stored for {field}", ) - + # Test value reconstruction mock_item = Mock() mock_item.childCount.return_value = 0 mock_item.text.return_value = str(value) - + if value is not None: # Skip None values for text conversion result = self.module_db.get_item_value(mock_item, field) if expected_type in ["int", "float", "bool"]: self.assertEqual(result, value) self.assertIsInstance(result, type(value)) - - @patch('db.module_db.ModuleDB.make_api_request') + + @patch("db.module_db.ModuleDB.make_api_request") def test_full_roundtrip_with_realistic_data(self, mock_api): """Test complete save/load cycle with realistic module data""" # Use a realistic module original_module = self.test_modules[0] - + # Set up the module DB state - self.module_db.current_module_id = original_module['moduleName'] - + self.module_db.current_module_id = original_module["moduleName"] + # Store the original data types that would be created self.module_db.original_data_types = {} self.module_db.store_data_types(original_module, "") - + # Simulate tree reconstruction - with patch.object(self.module_db, 'tree_to_dict_preserving_types') as mock_tree_to_dict: + with patch.object( + self.module_db, "tree_to_dict_preserving_types" + ) as mock_tree_to_dict: # Return a modified version of the data modified_data = original_module.copy() - modified_data['status'] = 'updated_status' + modified_data["status"] = "updated_status" mock_tree_to_dict.return_value = modified_data - + # Mock API calls for save (only the ones needed for save_module_details) mock_api.side_effect = [ (True, original_module), # GET for current data - (True, {"success": True}) # PUT for update + (True, {"success": True}), # PUT for update ] - - with patch.object(self.module_db, 'show_info_dialog'), \ - patch.object(self.module_db, 'update_module_list'): - + + with patch.object(self.module_db, "show_info_dialog"), patch.object( + self.module_db, "update_module_list" + ): + # Call save self.module_db.save_module_details() - + # Verify the type-preserving method was called mock_tree_to_dict.assert_called_once() - + # Verify the correct number of API calls were made self.assertEqual(mock_api.call_count, 2) class TestDataIntegrity(unittest.TestCase): """Test data integrity and consistency""" - + def setUp(self): """Set up test fixtures""" self.generator = ModuleTestDataGenerator() - + def test_generated_data_consistency(self): """Test that generated data is internally consistent""" modules = self.generator.generate_multiple_modules(10) - + for module in modules: - with self.subTest(module=module['moduleName']): + with self.subTest(module=module["moduleName"]): # Check module name format - name_parts = module['moduleName'].split('_') - self.assertGreaterEqual(len(name_parts), 3, "Module name should have at least 3 parts") - + name_parts = module["moduleName"].split("_") + self.assertGreaterEqual( + len(name_parts), 3, "Module name should have at least 3 parts" + ) + # Check speed consistency - if 'speed' in module: - speed = module['speed'] - self.assertIn(speed, ['5G', '10G'], f"Invalid speed: {speed}") - + if "speed" in module: + speed = module["speed"] + self.assertIn(speed, ["5G", "10G"], f"Invalid speed: {speed}") + # Check children structure - if 'children' in module: - self.assertIsInstance(module['children'], dict, "Children should be a dict") - - if 'childrenList' in module: - self.assertIsInstance(module['childrenList'], list, "ChildrenList should be a list") - + if "children" in module: + self.assertIsInstance( + module["children"], dict, "Children should be a dict" + ) + + if "childrenList" in module: + self.assertIsInstance( + module["childrenList"], list, "ChildrenList should be a list" + ) + # Check nested data structures - if 'details' in module and 'QA_CHECKS' in module['details']: - qa_checks = module['details']['QA_CHECKS'] + if "details" in module and "QA_CHECKS" in module["details"]: + qa_checks = module["details"]["QA_CHECKS"] self.assertIsInstance(qa_checks, list, "QA_CHECKS should be a list") for check in qa_checks: - self.assertIsInstance(check, dict, "Each QA check should be a dict") - self.assertIn('test', check, "Each QA check should have a 'test' field") - self.assertIn('passed', check, "Each QA check should have a 'passed' field") - + self.assertIsInstance( + check, dict, "Each QA check should be a dict" + ) + self.assertIn( + "test", check, "Each QA check should have a 'test' field" + ) + self.assertIn( + "passed", + check, + "Each QA check should have a 'passed' field", + ) + def test_data_serialization(self): """Test that generated data can be JSON serialized/deserialized""" modules = self.generator.generate_multiple_modules(3) - + for module in modules: - with self.subTest(module=module['moduleName']): + with self.subTest(module=module["moduleName"]): try: # Test JSON serialization json_str = json.dumps(module, default=str) - - # Test JSON deserialization + + # Test JSON deserialization restored_module = json.loads(json_str) - + # Basic structure should be preserved self.assertEqual( - restored_module['moduleName'], - module['moduleName'] + restored_module["moduleName"], module["moduleName"] ) - + except (TypeError, ValueError) as e: - self.fail(f"Failed to serialize/deserialize module {module['moduleName']}: {e}") + self.fail( + f"Failed to serialize/deserialize module {module['moduleName']}: {e}" + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_module_db.py b/tests/test_module_db.py index 9efacaf..008e3db 100644 --- a/tests/test_module_db.py +++ b/tests/test_module_db.py @@ -14,7 +14,7 @@ class TestModuleDBDataTypePreservation(unittest.TestCase): """Test suite for ModuleDB data type preservation functionality""" - + @classmethod def setUpClass(cls): """Set up QApplication for GUI tests""" @@ -22,19 +22,19 @@ def setUpClass(cls): cls.app = QApplication([]) else: cls.app = QApplication.instance() - + def setUp(self): """Set up test fixtures""" # Mock the UI setup - with patch('db.module_db.Ui_ModuleDBWidget'): + with patch("db.module_db.Ui_ModuleDBWidget"): self.module_db = ModuleDB() - + # Mock the UI components self.module_db.ui = Mock() self.module_db.ui.detailsTree = Mock() self.module_db.ui.detailsTree.invisibleRootItem = Mock() self.module_db.ui.detailsTree.clear = Mock() - + # Sample test data with mixed types self.test_data = { "moduleName": "Test_Module_001", @@ -47,110 +47,107 @@ def setUp(self): { "childName": "Sensor_1", "childType": "temperature", - "readings": [25.5, 26.0, 24.8] + "readings": [25.5, 26.0, 24.8], }, { - "childName": "Sensor_2", + "childName": "Sensor_2", "childType": "pressure", - "readings": [101325, 101330, 101320] - } + "readings": [101325, 101330, 101320], + }, ], "metadata": { "created": "2024-01-01", "tags": ["test", "prototype"], - "config": { - "timeout": 30, - "retries": 3, - "enabled": False - } - } + "config": {"timeout": 30, "retries": 3, "enabled": False}, + }, } - + def test_store_data_types(self): """Test that data types are correctly stored""" self.module_db.original_data_types = {} self.module_db.store_data_types(self.test_data, "") - + # Check root level types self.assertEqual(self.module_db.original_data_types[""], "dict") self.assertEqual(self.module_db.original_data_types["moduleName"], "str") self.assertEqual(self.module_db.original_data_types["version"], "float") self.assertEqual(self.module_db.original_data_types["isEnabled"], "bool") self.assertEqual(self.module_db.original_data_types["testCount"], "int") - + # Check nested types self.assertEqual(self.module_db.original_data_types["children"], "list") self.assertEqual(self.module_db.original_data_types["children[0]"], "dict") - self.assertEqual(self.module_db.original_data_types["children[0].readings"], "list") - self.assertEqual(self.module_db.original_data_types["children[0].readings[0]"], "float") - + self.assertEqual( + self.module_db.original_data_types["children[0].readings"], "list" + ) + self.assertEqual( + self.module_db.original_data_types["children[0].readings[0]"], "float" + ) + # Check deeply nested types self.assertEqual(self.module_db.original_data_types["metadata"], "dict") self.assertEqual(self.module_db.original_data_types["metadata.config"], "dict") - self.assertEqual(self.module_db.original_data_types["metadata.config.timeout"], "int") - self.assertEqual(self.module_db.original_data_types["metadata.config.enabled"], "bool") - + self.assertEqual( + self.module_db.original_data_types["metadata.config.timeout"], "int" + ) + self.assertEqual( + self.module_db.original_data_types["metadata.config.enabled"], "bool" + ) + def test_extract_list_index(self): """Test extraction of list indices from paths""" self.assertEqual(self.module_db.extract_list_index("children[0]"), 0) self.assertEqual(self.module_db.extract_list_index("children[5]"), 5) self.assertEqual(self.module_db.extract_list_index("data.items[12]"), 12) self.assertEqual(self.module_db.extract_list_index("simple_path"), 0) - + def test_get_item_value_leaf_nodes(self): """Test value extraction for leaf nodes with type preservation""" # Set up original data types self.module_db.original_data_types = { "string_path": "str", - "int_path": "int", + "int_path": "int", "float_path": "float", "bool_true_path": "bool", - "bool_false_path": "bool" + "bool_false_path": "bool", } - + # Create mock tree items for leaf nodes string_item = Mock() string_item.childCount.return_value = 0 string_item.text.return_value = "test_string" - + int_item = Mock() int_item.childCount.return_value = 0 int_item.text.return_value = "42" - + float_item = Mock() float_item.childCount.return_value = 0 float_item.text.return_value = "3.14" - + bool_true_item = Mock() bool_true_item.childCount.return_value = 0 bool_true_item.text.return_value = "true" - + bool_false_item = Mock() bool_false_item.childCount.return_value = 0 bool_false_item.text.return_value = "false" - + # Test type conversions self.assertEqual( - self.module_db.get_item_value(string_item, "string_path"), - "test_string" - ) - self.assertEqual( - self.module_db.get_item_value(int_item, "int_path"), - 42 + self.module_db.get_item_value(string_item, "string_path"), "test_string" ) + self.assertEqual(self.module_db.get_item_value(int_item, "int_path"), 42) self.assertAlmostEqual( - self.module_db.get_item_value(float_item, "float_path"), - 3.14 + self.module_db.get_item_value(float_item, "float_path"), 3.14 ) self.assertEqual( - self.module_db.get_item_value(bool_true_item, "bool_true_path"), - True + self.module_db.get_item_value(bool_true_item, "bool_true_path"), True ) self.assertEqual( - self.module_db.get_item_value(bool_false_item, "bool_false_path"), - False + self.module_db.get_item_value(bool_false_item, "bool_false_path"), False ) - + def test_get_item_value_list_reconstruction(self): """Test list reconstruction from tree items""" # Set up original data types for a list @@ -158,36 +155,36 @@ def test_get_item_value_list_reconstruction(self): "list_path": "list", "list_path[0]": "str", "list_path[1]": "int", - "list_path[2]": "float" + "list_path[2]": "float", } - + # Create mock tree item for the list list_item = Mock() list_item.childCount.return_value = 3 - + # Create mock child items child0 = Mock() child0.data.return_value = "list_path[0]" child0.childCount.return_value = 0 child0.text.return_value = "first" - + child1 = Mock() child1.data.return_value = "list_path[1]" child1.childCount.return_value = 0 child1.text.return_value = "42" - + child2 = Mock() child2.data.return_value = "list_path[2]" child2.childCount.return_value = 0 child2.text.return_value = "3.14" - + list_item.child.side_effect = [child0, child1, child2] - + result = self.module_db.get_item_value(list_item, "list_path") - + self.assertEqual(result, ["first", 42, 3.14]) self.assertIsInstance(result, list) - + def test_get_item_value_dict_reconstruction(self): """Test dictionary reconstruction from tree items""" # Set up original data types for a dict @@ -195,46 +192,49 @@ def test_get_item_value_dict_reconstruction(self): "dict_path": "dict", "dict_path.name": "str", "dict_path.count": "int", - "dict_path.enabled": "bool" + "dict_path.enabled": "bool", } - + # Create mock tree item for the dict dict_item = Mock() dict_item.childCount.return_value = 3 - + # Create mock child items child0 = Mock() child0.data.return_value = "dict_path.name" - child0.text.side_effect = ["name", "test"] # text(0) returns key, text(1) returns value + child0.text.side_effect = [ + "name", + "test", + ] # text(0) returns key, text(1) returns value child0.childCount.return_value = 0 - + child1 = Mock() child1.data.return_value = "dict_path.count" child1.text.side_effect = ["count", "5"] child1.childCount.return_value = 0 - + child2 = Mock() child2.data.return_value = "dict_path.enabled" child2.text.side_effect = ["enabled", "true"] child2.childCount.return_value = 0 - + dict_item.child.side_effect = [child0, child1, child2] - + result = self.module_db.get_item_value(dict_item, "dict_path") - + expected = {"name": "test", "count": 5, "enabled": True} self.assertEqual(result, expected) self.assertIsInstance(result, dict) - + def test_full_data_roundtrip(self): """Test complete data roundtrip: data -> tree -> data""" # Store original data types self.module_db.original_data_types = {} self.module_db.store_data_types(self.test_data, "") - + # Simulate the tree population and reconstruction process # This is a simplified version since we can't easily mock the full tree widget - + # Test specific paths and values test_cases = [ ("moduleName", "Test_Module_001", str), @@ -242,42 +242,42 @@ def test_full_data_roundtrip(self): ("isEnabled", True, bool), ("testCount", 42, int), ] - + for path, expected_value, expected_type in test_cases: # Simulate a leaf item mock_item = Mock() mock_item.childCount.return_value = 0 mock_item.text.return_value = str(expected_value) - + result = self.module_db.get_item_value(mock_item, path) - + self.assertEqual(result, expected_value) self.assertIsInstance(result, expected_type) - + def test_edge_cases(self): """Test edge cases and error handling""" self.module_db.original_data_types = {} - + # Test with None values test_data_with_none = {"field": None} self.module_db.store_data_types(test_data_with_none, "") self.assertEqual(self.module_db.original_data_types["field"], "NoneType") - + # Test with empty structures empty_data = {"empty_list": [], "empty_dict": {}} self.module_db.store_data_types(empty_data, "") self.assertEqual(self.module_db.original_data_types["empty_list"], "list") self.assertEqual(self.module_db.original_data_types["empty_dict"], "dict") - + # Test invalid numeric conversion mock_item = Mock() mock_item.childCount.return_value = 0 mock_item.text.return_value = "not_a_number" - + self.module_db.original_data_types["invalid_int"] = "int" result = self.module_db.get_item_value(mock_item, "invalid_int") self.assertEqual(result, "not_a_number") # Should fall back to string - + def test_deeply_nested_structures(self): """Test handling of deeply nested data structures""" nested_data = { @@ -285,33 +285,31 @@ def test_deeply_nested_structures(self): "level2": { "level3": [ {"name": "item1", "values": [1, 2, 3]}, - {"name": "item2", "values": [4, 5, 6]} + {"name": "item2", "values": [4, 5, 6]}, ] } } } - + self.module_db.original_data_types = {} self.module_db.store_data_types(nested_data, "") - + # Verify deep nesting is correctly tracked self.assertEqual( - self.module_db.original_data_types["level1.level2.level3"], - "list" + self.module_db.original_data_types["level1.level2.level3"], "list" ) self.assertEqual( - self.module_db.original_data_types["level1.level2.level3[0].values"], - "list" + self.module_db.original_data_types["level1.level2.level3[0].values"], "list" ) self.assertEqual( - self.module_db.original_data_types["level1.level2.level3[0].values[0]"], - "int" + self.module_db.original_data_types["level1.level2.level3[0].values[0]"], + "int", ) class TestModuleDBIntegration(unittest.TestCase): """Integration tests for ModuleDB with mocked API calls""" - + @classmethod def setUpClass(cls): """Set up QApplication for GUI tests""" @@ -319,20 +317,20 @@ def setUpClass(cls): cls.app = QApplication([]) else: cls.app = QApplication.instance() - + def setUp(self): """Set up test fixtures with mocked components""" - with patch('db.module_db.Ui_ModuleDBWidget'): + with patch("db.module_db.Ui_ModuleDBWidget"): self.module_db = ModuleDB() - + # Mock UI components self.module_db.ui = Mock() self.module_db.ui.detailsTree = Mock() self.module_db.ui.moduleNameLabel = Mock() self.module_db.ui.tabWidget = Mock() self.module_db.ui.moduleDetailsTab = Mock() - - @patch('db.module_db.ModuleDB.make_api_request') + + @patch("db.module_db.ModuleDB.make_api_request") def test_load_module_details_preserves_types(self, mock_api): """Test that loading module details preserves data types""" # Mock API response @@ -341,79 +339,83 @@ def test_load_module_details_preserves_types(self, mock_api): "isActive": True, "temperature": 25.5, "readings": [1, 2, 3], - "metadata": {"created": "2024-01-01"} + "metadata": {"created": "2024-01-01"}, } - + mock_api.return_value = (True, test_module_data) - + # Set current module ID self.module_db.current_module_id = "TEST_MODULE_001" - + # Call load_module_details without mocking populate_details_tree # so that store_data_types() gets called - with patch.object(self.module_db.ui, 'detailsTree') as mock_tree: + with patch.object(self.module_db.ui, "detailsTree") as mock_tree: # Mock the tree widget methods mock_tree.clear = Mock() mock_tree.addTopLevelItem = Mock() - + self.module_db.load_module_details() - + # Verify data types were stored - self.assertIsNotNone(getattr(self.module_db, 'original_data_types', None)) + self.assertIsNotNone(getattr(self.module_db, "original_data_types", None)) expected_types = { "": "dict", - "moduleName": "str", + "moduleName": "str", "isActive": "bool", "temperature": "float", "readings": "list", "readings[0]": "int", - "readings[1]": "int", + "readings[1]": "int", "readings[2]": "int", "metadata": "dict", - "metadata.created": "str" + "metadata.created": "str", } self.assertEqual(self.module_db.original_data_types, expected_types) - - @patch('db.module_db.ModuleDB.make_api_request') - @patch('db.module_db.ModuleDB.show_info_dialog') - @patch('db.module_db.ModuleDB.update_module_list') - def test_save_module_details_preserves_types(self, mock_update_list, mock_show_info, mock_api): + + @patch("db.module_db.ModuleDB.make_api_request") + @patch("db.module_db.ModuleDB.show_info_dialog") + @patch("db.module_db.ModuleDB.update_module_list") + def test_save_module_details_preserves_types( + self, mock_update_list, mock_show_info, mock_api + ): """Test that saving module details preserves data types""" # Mock current module data current_data = { "moduleName": "TEST_MODULE_001", "isActive": True, "temperature": 25.5, - "readings": [1, 2, 3] + "readings": [1, 2, 3], } - + # Mock API responses mock_api.side_effect = [ (True, current_data), # GET request - (True, {"success": True}) # PUT request + (True, {"success": True}), # PUT request ] - + # Set up module state self.module_db.current_module_id = "TEST_MODULE_001" self.module_db.original_data_types = {} - + # Mock the tree_to_dict_preserving_types method - with patch.object(self.module_db, 'tree_to_dict_preserving_types') as mock_tree_to_dict: + with patch.object( + self.module_db, "tree_to_dict_preserving_types" + ) as mock_tree_to_dict: mock_tree_to_dict.return_value = {"moduleName": "TEST_MODULE_001_UPDATED"} - + # Call save_module_details self.module_db.save_module_details() - + # Verify the preserving method was called mock_tree_to_dict.assert_called_once() - + # Verify API was called correctly self.assertEqual(mock_api.call_count, 2) - + # Verify success message was shown mock_show_info.assert_called_once() -if __name__ == '__main__': +if __name__ == "__main__": # Run the tests unittest.main(verbosity=2) diff --git a/ui/integration_gui.py b/ui/integration_gui.py index f400260..245ba1d 100644 --- a/ui/integration_gui.py +++ b/ui/integration_gui.py @@ -56,7 +56,9 @@ def setupUi(self, MainWindow): self.label.setObjectName("label") self.horizontalLayout.addWidget(self.label) self.ringLE = QtWidgets.QLineEdit(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.ringLE.sizePolicy().hasHeightForWidth()) @@ -68,7 +70,9 @@ def setupUi(self, MainWindow): self.label_2.setObjectName("label_2") self.horizontalLayout.addWidget(self.label_2) self.positionLE = QtWidgets.QLineEdit(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.positionLE.sizePolicy().hasHeightForWidth()) @@ -81,7 +85,9 @@ def setupUi(self, MainWindow): self.graphicsView.setMinimumSize(QtCore.QSize(400, 400)) self.graphicsView.setObjectName("graphicsView") self.verticalLayout.addWidget(self.graphicsView) - spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) + spacerItem = QtWidgets.QSpacerItem( + 20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum + ) self.verticalLayout.addItem(spacerItem) self.label_7 = QtWidgets.QLabel(self.tab) self.label_7.setObjectName("label_7") @@ -121,10 +127,14 @@ def setupUi(self, MainWindow): self.connectPowerPB.setObjectName("connectPowerPB") self.gridLayout.addWidget(self.connectPowerPB, 2, 0, 1, 1) self.connectPowerLED = QtWidgets.QFrame(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.connectPowerLED.sizePolicy().hasHeightForWidth()) + sizePolicy.setHeightForWidth( + self.connectPowerLED.sizePolicy().hasHeightForWidth() + ) self.connectPowerLED.setSizePolicy(sizePolicy) self.connectPowerLED.setMinimumSize(QtCore.QSize(30, 34)) self.connectPowerLED.setStyleSheet("background-color: rgb(85, 170, 0);") @@ -136,17 +146,23 @@ def setupUi(self, MainWindow): self.fiberCB.setObjectName("fiberCB") self.gridLayout.addWidget(self.fiberCB, 0, 1, 1, 1) self.connectFiberLED = QtWidgets.QFrame(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.connectFiberLED.sizePolicy().hasHeightForWidth()) + sizePolicy.setHeightForWidth( + self.connectFiberLED.sizePolicy().hasHeightForWidth() + ) self.connectFiberLED.setSizePolicy(sizePolicy) self.connectFiberLED.setMinimumSize(QtCore.QSize(30, 34)) self.connectFiberLED.setStyleSheet("background-color: rgb(85, 170, 0);") self.connectFiberLED.setFrameShape(QtWidgets.QFrame.NoFrame) self.connectFiberLED.setObjectName("connectFiberLED") self.gridLayout.addWidget(self.connectFiberLED, 0, 3, 1, 1) - spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem1 = QtWidgets.QSpacerItem( + 40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum + ) self.gridLayout.addItem(spacerItem1, 0, 2, 1, 1) self.verticalLayout.addLayout(self.gridLayout) self.horizontalLayout_8.addLayout(self.verticalLayout) @@ -161,10 +177,14 @@ def setupUi(self, MainWindow): self.checkIDlabel = QtWidgets.QLabel(self.tab) self.checkIDlabel.setObjectName("checkIDlabel") self.horizontalLayout_3.addWidget(self.checkIDlabel) - spacerItem2 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem2 = QtWidgets.QSpacerItem( + 40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum + ) self.horizontalLayout_3.addItem(spacerItem2) self.checkIDLED = QtWidgets.QFrame(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.checkIDLED.sizePolicy().hasHeightForWidth()) @@ -177,12 +197,18 @@ def setupUi(self, MainWindow): self.verticalLayout_5.addLayout(self.horizontalLayout_3) self.gridLayout_2 = QtWidgets.QGridLayout() self.gridLayout_2.setObjectName("gridLayout_2") - spacerItem3 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem3 = QtWidgets.QSpacerItem( + 40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum + ) self.gridLayout_2.addItem(spacerItem3, 2, 2, 1, 1) - spacerItem4 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem4 = QtWidgets.QSpacerItem( + 40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum + ) self.gridLayout_2.addItem(spacerItem4, 0, 2, 1, 1) self.hvOFFTestLED = QtWidgets.QFrame(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.hvOFFTestLED.sizePolicy().hasHeightForWidth()) @@ -202,7 +228,9 @@ def setupUi(self, MainWindow): self.hvONTestCB.setObjectName("hvONTestCB") self.gridLayout_2.addWidget(self.hvONTestCB, 2, 1, 1, 1) self.hvONTestLED = QtWidgets.QFrame(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.hvONTestLED.sizePolicy().hasHeightForWidth()) @@ -253,7 +281,9 @@ def setupUi(self, MainWindow): self.lvOffButton.setObjectName("lvOffButton") self.horizontalLayout_4.addWidget(self.lvOffButton) self.lvLabel = QtWidgets.QLabel(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.lvLabel.sizePolicy().hasHeightForWidth()) @@ -278,7 +308,9 @@ def setupUi(self, MainWindow): self.hvOffButton.setObjectName("hvOffButton") self.horizontalLayout_5.addWidget(self.hvOffButton) self.hvLabel = QtWidgets.QLabel(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.hvLabel.sizePolicy().hasHeightForWidth()) @@ -302,7 +334,9 @@ def setupUi(self, MainWindow): self.airOFFPB.setObjectName("airOFFPB") self.horizontalLayout_6.addWidget(self.airOFFPB) self.tMaxLabel = QtWidgets.QLabel(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.tMaxLabel.sizePolicy().hasHeightForWidth()) @@ -310,7 +344,9 @@ def setupUi(self, MainWindow): self.tMaxLabel.setObjectName("tMaxLabel") self.horizontalLayout_6.addWidget(self.tMaxLabel) self.airLed = QtWidgets.QFrame(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.airLed.sizePolicy().hasHeightForWidth()) @@ -321,7 +357,9 @@ def setupUi(self, MainWindow): self.horizontalLayout_6.addWidget(self.airLed) self.verticalLayout_5.addLayout(self.horizontalLayout_6) self.plotWidget = QtWidgets.QWidget(self.tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.plotWidget.sizePolicy().hasHeightForWidth()) @@ -351,19 +389,27 @@ def setupUi(self, MainWindow): self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.checkIDLabel) self.checkIDCommandLE = QtWidgets.QLineEdit(self.commandsGroupBox) self.checkIDCommandLE.setObjectName("checkIDCommandLE") - self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.checkIDCommandLE) + self.formLayout.setWidget( + 0, QtWidgets.QFormLayout.FieldRole, self.checkIDCommandLE + ) self.lightOnLabel = QtWidgets.QLabel(self.commandsGroupBox) self.lightOnLabel.setObjectName("lightOnLabel") self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.lightOnLabel) self.lightOnCommandLE = QtWidgets.QLineEdit(self.commandsGroupBox) self.lightOnCommandLE.setObjectName("lightOnCommandLE") - self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.lightOnCommandLE) + self.formLayout.setWidget( + 1, QtWidgets.QFormLayout.FieldRole, self.lightOnCommandLE + ) self.darkTestLabel = QtWidgets.QLabel(self.commandsGroupBox) self.darkTestLabel.setObjectName("darkTestLabel") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.darkTestLabel) + self.formLayout.setWidget( + 2, QtWidgets.QFormLayout.LabelRole, self.darkTestLabel + ) self.darkTestCommandLE = QtWidgets.QLineEdit(self.commandsGroupBox) self.darkTestCommandLE.setObjectName("darkTestCommandLE") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.darkTestCommandLE) + self.formLayout.setWidget( + 2, QtWidgets.QFormLayout.FieldRole, self.darkTestCommandLE + ) self.label_13 = QtWidgets.QLabel(self.commandsGroupBox) self.label_13.setObjectName("label_13") self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.label_13) @@ -383,32 +429,48 @@ def setupUi(self, MainWindow): self.formLayout_2.setObjectName("formLayout_2") self.dbEndpointLabel = QtWidgets.QLabel(self.apiGroupBox) self.dbEndpointLabel.setObjectName("dbEndpointLabel") - self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.dbEndpointLabel) + self.formLayout_2.setWidget( + 0, QtWidgets.QFormLayout.LabelRole, self.dbEndpointLabel + ) self.dbEndpointLE = QtWidgets.QLineEdit(self.apiGroupBox) self.dbEndpointLE.setObjectName("dbEndpointLE") - self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.dbEndpointLE) + self.formLayout_2.setWidget( + 0, QtWidgets.QFormLayout.FieldRole, self.dbEndpointLE + ) self.mqttServerLabel = QtWidgets.QLabel(self.apiGroupBox) self.mqttServerLabel.setObjectName("mqttServerLabel") - self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.mqttServerLabel) + self.formLayout_2.setWidget( + 1, QtWidgets.QFormLayout.LabelRole, self.mqttServerLabel + ) self.mqttServerLE = QtWidgets.QLineEdit(self.apiGroupBox) self.mqttServerLE.setObjectName("mqttServerLE") - self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.mqttServerLE) + self.formLayout_2.setWidget( + 1, QtWidgets.QFormLayout.FieldRole, self.mqttServerLE + ) self.mqttTopicLabel = QtWidgets.QLabel(self.apiGroupBox) self.mqttTopicLabel.setObjectName("mqttTopicLabel") - self.formLayout_2.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.mqttTopicLabel) + self.formLayout_2.setWidget( + 2, QtWidgets.QFormLayout.LabelRole, self.mqttTopicLabel + ) self.mqttTopicLE = QtWidgets.QLineEdit(self.apiGroupBox) self.mqttTopicLE.setObjectName("mqttTopicLE") - self.formLayout_2.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.mqttTopicLE) + self.formLayout_2.setWidget( + 2, QtWidgets.QFormLayout.FieldRole, self.mqttTopicLE + ) self.verticalLayout_6.addWidget(self.apiGroupBox) self.horizontalLayout_9 = QtWidgets.QHBoxLayout() self.horizontalLayout_9.setObjectName("horizontalLayout_9") self.applySettingsPB = QtWidgets.QPushButton(self.tab_3) self.applySettingsPB.setObjectName("applySettingsPB") self.horizontalLayout_9.addWidget(self.applySettingsPB) - spacerItem5 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + spacerItem5 = QtWidgets.QSpacerItem( + 40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum + ) self.horizontalLayout_9.addItem(spacerItem5) self.verticalLayout_6.addLayout(self.horizontalLayout_9) - spacerItem6 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + spacerItem6 = QtWidgets.QSpacerItem( + 20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding + ) self.verticalLayout_6.addItem(spacerItem6) self.tabWidget.addTab(self.tab_3, "") self.tab_4 = QtWidgets.QWidget() @@ -448,10 +510,20 @@ def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) self.label_12.setText(_translate("MainWindow", "Operator")) self.label_15.setText(_translate("MainWindow", "Comments")) - self.label_5.setText(_translate("MainWindow", "

Ring:

")) + self.label_5.setText( + _translate( + "MainWindow", + '

Ring:

', + ) + ) self.label.setText(_translate("MainWindow", "Ring ID")) self.label_2.setText(_translate("MainWindow", "Position")) - self.label_7.setText(_translate("MainWindow", "

Module:

")) + self.label_7.setText( + _translate( + "MainWindow", + '

Module:

', + ) + ) self.label_3.setText(_translate("MainWindow", "Module ID")) self.mountPB.setText(_translate("MainWindow", "Mount")) self.unmountPB.setText(_translate("MainWindow", "Un-mount")) @@ -463,7 +535,11 @@ def retranslateUi(self, MainWindow): self.checkIDlabel.setText(_translate("MainWindow", "TextLabel")) self.hvONTestPB.setText(_translate("MainWindow", "Dark Tests (HV on)")) self.hvOFFTestPB.setText(_translate("MainWindow", "Light On Tests (HV off)")) - self.resultsLabel.setText(_translate("MainWindow", "

Noise:

")) + self.resultsLabel.setText( + _translate( + "MainWindow", "

Noise:

" + ) + ) self.cancelPB.setText(_translate("MainWindow", "Cancel Test")) self.logsPB.setText(_translate("MainWindow", "Ph2ACF logs")) self.showPB.setText(_translate("MainWindow", "Show Results")) @@ -480,27 +556,49 @@ def retranslateUi(self, MainWindow): self.airOFFPB.setText(_translate("MainWindow", "Air OFF")) self.tMaxLabel.setText(_translate("MainWindow", "Tmax: 0.0")) self.airLed.setStyleSheet(_translate("MainWindow", "background-color: red;")) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab), _translate("MainWindow", "Mount and Test")) - self.placeholdersHelpLabel.setText(_translate("MainWindow", "Available placeholders:\n" -"{ring_id} - Ring ID\n" -"{position} - Position number\n" -"{module_id} - Module ID\n" -"{fiber} - Selected fiber\n" -"{power} - Selected power source\n" -"{fiber_endpoint} - Fiber endpoint (i.e. FC7)\n" -"\n" -"Placeholders can be used in commands.")) + self.tabWidget.setTabText( + self.tabWidget.indexOf(self.tab), _translate("MainWindow", "Mount and Test") + ) + self.placeholdersHelpLabel.setText( + _translate( + "MainWindow", + "Available placeholders:\n" + "{ring_id} - Ring ID\n" + "{position} - Position number\n" + "{module_id} - Module ID\n" + "{fiber} - Selected fiber\n" + "{power} - Selected power source\n" + "{fiber_endpoint} - Fiber endpoint (i.e. FC7)\n" + "\n" + "Placeholders can be used in commands.", + ) + ) self.commandsGroupBox.setTitle(_translate("MainWindow", "Test Commands")) self.checkIDLabel.setText(_translate("MainWindow", "Check ID Command:")) - self.checkIDCommandLE.setText(_translate("MainWindow", "check_id.sh --module {module_id} --position {position}")) + self.checkIDCommandLE.setText( + _translate( + "MainWindow", "check_id.sh --module {module_id} --position {position}" + ) + ) self.lightOnLabel.setText(_translate("MainWindow", "Light-On Test Command:")) - self.lightOnCommandLE.setText(_translate("MainWindow", "light_on_test.sh --ring {ring_id} --pos {position} --fiber {fiber}")) + self.lightOnCommandLE.setText( + _translate( + "MainWindow", + "light_on_test.sh --ring {ring_id} --pos {position} --fiber {fiber}", + ) + ) self.darkTestLabel.setText(_translate("MainWindow", "Dark Test Command:")) - self.darkTestCommandLE.setText(_translate("MainWindow", "dark_test.sh --module {module_id} --power {power}")) + self.darkTestCommandLE.setText( + _translate( + "MainWindow", "dark_test.sh --module {module_id} --power {power}" + ) + ) self.label_13.setText(_translate("MainWindow", "Air control command:")) self.airCommandLE.setText(_translate("MainWindow", "air.sh {airOn}")) self.label_14.setText(_translate("MainWindow", "Results URL:")) - self.resultsUrlLE.setText(_translate("MainWindow", "Results/html/latest/index.html")) + self.resultsUrlLE.setText( + _translate("MainWindow", "Results/html/latest/index.html") + ) self.apiGroupBox.setTitle(_translate("MainWindow", "API Settings")) self.dbEndpointLabel.setText(_translate("MainWindow", "Database URL:")) self.dbEndpointLE.setText(_translate("MainWindow", "http://localhost:5000")) @@ -509,7 +607,14 @@ def retranslateUi(self, MainWindow): self.mqttTopicLabel.setText(_translate("MainWindow", "MQTT Topic:")) self.mqttTopicLE.setText(_translate("MainWindow", "/ar/thermal/image")) self.applySettingsPB.setText(_translate("MainWindow", "Apply settings")) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_3), _translate("MainWindow", "Settings")) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_4), _translate("MainWindow", "Commands Output")) + self.tabWidget.setTabText( + self.tabWidget.indexOf(self.tab_3), _translate("MainWindow", "Settings") + ) + self.tabWidget.setTabText( + self.tabWidget.indexOf(self.tab_4), + _translate("MainWindow", "Commands Output"), + ) self.openInBrowserPB.setText(_translate("MainWindow", "Open in System Browser")) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_5), _translate("MainWindow", "Plots")) + self.tabWidget.setTabText( + self.tabWidget.indexOf(self.tab_5), _translate("MainWindow", "Plots") + ) diff --git a/workflow.md b/workflow.md new file mode 100644 index 0000000..75efd8b --- /dev/null +++ b/workflow.md @@ -0,0 +1,442 @@ +# Integration Tools — Simplified Workflow & Architecture +> Covers the `integration_tools_new` repository as of branch `fix-safely-and-coldroom-power-supply` + +--- + +## 1. What this software does + +This is a **PyQt5 desktop GUI suite** used at CERN/Pisa for testing CMS Outer Tracker and Inner Tracker modules. It lets operators: + +- Track detector modules in a database (inventory, mounting status, connections) +- Run automated electrical tests on individual modules (check ID, noise tests) +- Control and monitor the CAEN high-voltage / low-voltage power supply +- Monitor and control the cold room and MARTA CO₂ cooling system +- Watch a rotating thermal camera and spot overheating in real time +- Enforce safety interlocks (dew point, HV status, door status) + +--- + +## 2. High-level architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ integration.py (MainApp) │ +│ Main GUI window — ties everything together │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ +│ │ Module │ │ CAEN │ │ MQTT │ │ Shell │ │ +│ │ DB Tab │ │ Control │ │ listener │ │ Commands │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ │ +└───────┼──────────────┼──────────────┼───────────────┼───────┘ + │ │ │ │ + HTTP REST TCP socket MQTT broker subprocess + (module DB) (CAEN server) (pccmslab1) (Ph2_ACF tests) + +┌─────────────────────────────────┐ +│ cold.py / marta_coldroom.py │ +│ Coldroom / MARTA GUI │ +│ │ +│ ┌──────────┐ ┌─────────────┐ │ +│ │ System │ │ Safety │ │ +│ │ (MQTT) │ │ Interlocks │ │ +│ └──────────┘ └─────────────┘ │ +└─────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ power_supply/power_supply_ctrl │ +│ Rigol DP116A GUI (standalone) │ +│ Connects via VISA/TCP │ +└──────────────────────────────────┘ +``` + +--- + +## 3. Entry points + +| Script | Purpose | +|--------|---------| +| `python integration.py` | Main integration GUI (most used) | +| `python cold.py` | Coldroom / MARTA monitoring GUI | +| `python power_supply/power_supply_ctrl.py` | Standalone Rigol power supply panel | +| `python caen/caenGUIall.py` | Standalone CAEN multi-channel panel | +| `python Inner_tracker_GUI/caenGUIall_v2.py` | Inner Tracker CAEN panel | + +--- + +## 4. Main Integration GUI — step-by-step workflow + +### 4.1 Application startup (`integration.py`) + +``` +app starts + │ + ├─ load UI from ui/integration.ui + ├─ load settings from ~/.config/integration_ui/settings.yaml + │ (falls back to settings_integration.yaml in repo) + ├─ create ModuleDB widget → queries REST API at db_url/modules + ├─ create CAENControl object → starts 2-second timer to poll CAEN + ├─ connect to MQTT broker → subscribe to: + │ /ar/thermal/image (thermal camera frames) + │ /ph2acf/data (chip temperatures from Ph2_ACF) + │ /air/status (dry-air system) + │ shellies/ventola/... (fan power monitor via Shelly plug) + └─ restore last session ring from ~/.config/integration_ui/lastsession.txt +``` + +### 4.2 Selecting a module for testing + +``` +Operator types ring name in Ring field (e.g. "L1_47_A#1") + │ + ├─ split_ring_and_position() extracts ring ID and slot number + ├─ draw_ring() redraws the circular ring diagram + │ blue rectangles = slots with mounted modules + │ red rectangle = currently selected slot + │ + └─ Operator clicks a rectangle OR types in Position field + │ + ├─ If a module is mounted there, the Module ID field auto-fills + └─ Operator can also manually type a Module ID + │ + └─ module_changed() fires: + ├─ load_module_details() → GET /modules/{id} + │ stores module data, extracts lpGBT fuse ID + │ (used to match incoming Ph2_ACF temperature data) + └─ queries /snapshot to restore fiber & power dropdowns +``` + +### 4.3 Connecting fiber and power + +``` +Fiber dropdown (SfibA, SfibB, E3;1 … E3;5) +Power dropdown (BINT1, R01;M1.1 … R01;M3.12) + + ├─ Connect Power button → POST /disconnect_all_detSide, then POST /connect + ├─ Connect Fiber button → same pattern + │ + └─ Status LEDs update automatically: + green = connected and matches selected module + yellow = connected but to a different module + red = not connected +``` + +Connections are stored in the REST database and reflected back in the Module Inventory tab. + +### 4.4 Running tests + +Three test buttons trigger shell commands defined in Settings: + +``` +[Check ID] → runs check_id_command + │ expands {module_id}, {fiber_endpoint}, {session}, etc. + │ + └─ CommandWorker (QThread) runs the command + ├─ success: LED turns green, enables next tests, parses module ID from stdout + └─ fail: LED turns red + +[HV OFF Test] → runs light_on_command + │ (noise test with room lights ON, HV OFF) + └─ same pattern; parses noise results from DB on success + +[HV ON Test] → runs dark_test_command + │ (dark noise + calibration, HV ON) + └─ on success: queries /module_test_analysis/{name} + extracts per-chip Average SSA/MPA noise + displays result in the Noise table label +``` + +The `{fiber_endpoint}` placeholder is resolved at test time from the live connection snapshot — it encodes which FC7 board and optical group to use. + +All commands are cancellable via the Cancel button (kills the worker thread). + +### 4.5 Thermal camera feedback + +``` +MQTT /ar/thermal/image → on_mqtt_message() + │ raw bytes: 24×32 float array (768 floats × 4 bytes) + │ + ├─ reshapes to 24×32, updates thermal image plot + ├─ tracks min/max/avg trend over last 600 samples + ├─ publishes summary to /integration/thermalcamera + │ + └─ Safety thresholds: + Tmax > 45 °C → CAEN safe_lv_off() (ramp HV down first, then kill LV) + Tmax > 50 °C → immediate HV + LV off +``` + +### 4.6 Ph2_ACF chip temperature overlay + +``` +MQTT /ph2acf/data → on_mqtt_message() + │ JSON with lpGBT fuse ID and per-chip temperatures + │ + ├─ filters to only process data matching current module's fuse ID + ├─ looks up temperature offsets from module DB record + ├─ computes SSA mean/max and MPA mean/max + └─ overlays on the same thermal trend plot (dashed lines if offsets missing) +``` + +### 4.7 Mounting / unmounting modules + +``` +Mount button: + 1. Validate: module ID, ring ID, position all filled + 2. Check no module already at target position + 3. Check module not already mounted elsewhere + 4. PUT /modules/{id} with mounted_on = "L1;5", status = "MOUNTED" + 5. Redraw ring (slot turns blue) + +Unmount button: + 1. PUT /modules/{id} with mounted_on = "", status = "un-mounted" + 2. Redraw ring (slot turns black) +``` + +--- + +## 5. Database layer (`db/module_db.py` + REST API) + +The module database is a **MongoDB backend** exposed through a REST API at `http://pccmslab1:5000`. + +Key endpoints used: + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/modules` | GET | List all modules | +| `/modules/{id}` | GET/PUT | Fetch or update one module | +| `/sessions` | POST | Create a test session | +| `/connect` | POST | Record a cable connection | +| `/disconnect_all_detSide` | POST | Remove all crate-side connections | +| `/disconnect_all_crateSide` | POST | Remove all detector-side connections | +| `/snapshot` | POST | Walk connection graph from a cable | +| `/module_test_analysis/{name}` | GET | Fetch analysis results | +| `/generic_module_query` | POST | MongoDB-style query | + +**Module record structure** (simplified): + +```json +{ + "moduleName": "PS_26_IPG-10005", + "status": "MOUNTED", + "mounted_on": "L1_47_A#1;5", + "position": "OnDet@ L1_47_A#1;5", + "grade": "A+", + "hwId": 12345, + "children": { + "lpGBT": { "CHILD_SERIAL_NUMBER": "12345" }, + "PS Read-out Hybrid": { "details": { "ALPGBT_BANDWIDTH": "10Gbps" } } + }, + "details": { "LOCATION": "Pisa", "DESCRIPTION": "..." }, + "temperature_offset": { "SSA_H0_0": -2.1, "MPA_H0_8": 1.3, ... } +} +``` + +The `ModuleDB` widget (tab "Module Inventory") filters by location (Pisa), speed (5G/10G), spacer type, grade, and status, and lets operators view/edit any field and disconnect cable connections. + +--- + +## 6. CAEN control (`caen/caenGUI.py`) + +``` +CAENControl + │ + ├─ CAENQueryThread (QThread) + │ connects TCP socket to 192.168.0.45:7000 per query + │ sends custom text protocol: "GetStatus,PowerSupplyId:caen" + │ parses response: "caen_HV0.11_IsOn:1,caen_HV0.11_Voltage:100.0,..." + │ + ├─ 2-second QTimer → fires update() → sends GetStatus query + │ → updates LV/HV LEDs and voltage/current labels + │ + ├─ LV On/Off buttons → send TurnOn/TurnOff commands + ├─ HV On/Off buttons → same + │ + └─ safe_lv_off(): + first call: ramp HV down (TurnOff HV), set flag lv_off_when_hv_off=True + second call (or when HV voltage ≤ 10 V): also turn off LV + (ensures LV is never cut before HV ramps safely) +``` + +The channels are updated dynamically when a module is selected: +- `setLV("LV7.5")` — called when the module's LV cable path is resolved +- `setHV("HV2.3")` — called when the module's HV cable path is resolved + +--- + +## 7. Coldroom system (`coldroom/`) + +The coldroom GUI (`cold.py`) and its supporting classes form an independent subsystem: + +``` +System (coldroom/system.py) + │ + ├─ loads settings_coldroom.yaml + ├─ holds shared _status dict: + │ { "marta": {}, "coldroom": {}, "thermal_camera": {}, "cleanroom": {}, + │ "caen": {}, "coldroomair": {} } + │ + ├─ MartaColdRoomMQTTClient (subscribes to /MARTA/#, /coldroom/#, /environment/#, etc.) + │ ├─ parses temperatures, humidity, dew point, door status + │ ├─ handles CO₂ sensor data + │ └─ sends commands: start_chiller, stop_co2, set_temperature_setpoint, etc. + │ + └─ ThermalCameraMQTTClient (subscribes to /thermalcamera/#) + ├─ receives 24×32 float images + position from rotating camera + ├─ accumulates last 5 images per angular position + └─ used by ModuleTemperaturesTAB to stitch 360° panoramas +``` + +### Coldroom safety flow + +Every MQTT message triggers safety checks in `marta_coldroom.py → on_message()`: + +``` +on_message received + │ + ├─ update relevant subsystem status + │ + └─ if has_valid_status(): ← all subsystems have reported at least once + │ + ├─ check_dew_point(): + │ min(MARTA_supply_T, MARTA_return_T, coldroom_T) > dewpoint + 1°C ? + │ → sets safety_flags["door_locked"] + │ + ├─ check_door_status(): + │ is CmdDoorUnlock_Reff == 1? + │ → sets safety_flags["sleep"] + │ + └─ check_door_safe_to_open(): + dew point safe AND HV off? + → sets safety_flags["door_safe"] +``` + +The `soft_interlock_loop` (called externally) checks: +- If LV is ON but MARTA OT is not running → publish alarm to `/alarm` +- (The actual `switch_all_lv_off()` call is currently commented out — see BUG-13) + +--- + +## 8. Thermal camera panorama stitching (`coldroom/module_temperatures_gui.py`) + +``` +Camera rotates around the detector ring (0°–360°) + │ + ├─ At each angular position, ThermalCameraMQTTClient receives a 24×32 image + ├─ Stores last 5 images per position per camera in _stitching_data + │ + └─ ModuleTemperaturesTAB.update_displays() (every 1 second) + │ + ├─ For each camera (0–3): + │ ├─ Collect all (position, image) pairs + │ ├─ stitch_multiple_images(): + │ │ - places each image on a (24 × total_width) canvas + │ │ at the pixel column corresponding to its angle + │ │ - averages overlapping regions + │ │ - normalises to 8-bit using either CO₂ temp range or auto range + │ └─ displays as a 360°-wide false-colour strip + │ + ├─ Overlays mounted module names at their angular positions + └─ Temperature plot tab: per-camera max/min vs angle (with spike filter) +``` + +Colour scale can be fixed (CO₂ return temperature as baseline, +15/+20 °C range) or auto-scaled. + +--- + +## 9. Power supply — Rigol DP116A (`power_supply/`) + +A **simple standalone panel** (not embedded in the main GUI): + +``` +PowerSupplyController + │ + ├─ connects via PyVISA over TCP: TCPIP0::192.168.0.16::INSTR + ├─ 1-second QTimer → queries MEAS:VOLT? and MEAS:CURR? + ├─ Set Voltage button → APPLY {voltage} + ├─ Set Current button → SOUR:CURR {current} + ├─ Power ON/OFF → OUTP:STAT ON/OFF + └─ closeEvent: turns off supply, closes VISA resource +``` + +`scripts/rigolDP116A.py` is a CLI version of the same instrument control. + +--- + +## 10. Settings files + +| File | Loaded by | Key settings | +|------|-----------|--------------| +| `settings_integration.yaml` | `integration.py`, `db/module_db.py` | `db_url`, `mqtt_server`, test commands, topic names | +| `settings_coldroom.yaml` | `coldroom/system.py` | MQTT broker, per-subsystem topic names | +| `~/.config/integration_ui/settings.yaml` | `integration.py` (overrides bundled) | user-local overrides | +| `~/.config/integration_ui/lastsession.txt` | `integration.py` | remembers last selected ring | +| `coldroom/camera_config.yaml` | `module_temperatures_gui.py` | camera angular offsets and Front/Back side assignment | + +--- + +## 11. Communication summary + +``` +┌────────────────────────────────────────────────────────────────┐ +│ MQTT broker (pccmslab1:1883) │ +│ │ +│ /ar/thermal/image ← thermal camera raw frames │ +│ /thermalcamera/# ← camera state + per-camera imgs │ +│ /ph2acf/data ← chip temperatures from Ph2_ACF │ +│ /MARTA/# ← MARTA CO₂ chiller status/cmd │ +│ /coldroom/# ← coldroom state/cmd │ +│ /environment/HumAndTemp001/#← cleanroom T/RH/dewpoint │ +│ /ble/CO2-1 ← CO₂ sensor │ +│ /air/status ← dry-air on/off │ +│ /air/control ← dry-air command │ +│ /alarm ← safety alarm messages │ +│ shellies/ventola/... ← fan power via Shelly plug │ +│ shellies/coldroomair/... ← coldroom air bypass via Shelly │ +└────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────┐ +│ REST API (pccmslab1:5000) │ +│ Module database (MongoDB backend) │ +└─────────────────────────────────────┘ + +┌─────────────────────────────────────┐ +│ TCP 192.168.0.45:7000 │ +│ CAEN power supply daemon │ +└─────────────────────────────────────┘ + +┌─────────────────────────────────────┐ +│ TCP 192.168.0.16 (VISA/INSTR) │ +│ Rigol DP116A power supply │ +└─────────────────────────────────────┘ + +┌─────────────────────────────────────┐ +│ HTTP 192.168.0.204 (Shelly relay) │ +│ Dry-air bypass valve │ +└─────────────────────────────────────┘ +``` + +--- + +## 12. Typical operator session flow + +``` +1. Start integration.py +2. Last ring auto-loaded from lastsession.txt +3. Click slot on ring diagram → Module ID auto-fills (if mounted) +4. Verify fiber (SfibX) and power (R01;Mx.y) dropdowns show correct connections + (green LEDs = confirmed connected) +5. Fill in Operator and Comments fields +6. Click [Check ID] + → runs moduleTest.py --board FC7OT8 --slot 2 -c readOnlyID + → LED turns green, module ID confirmed +7. Click [HV OFF Test] + → runs eyeOpening test + → LED turns green +8. Click [HV ON Test] + → runs calibrationandpedenoise + → noise table updates with SSA/MPA values per hybrid +9. Monitor thermal plot during test (Tmax displayed live) +10. Click [Show Results] or [Open in Browser] to view full HTML analysis +11. If moving to next module: type new ring position or click ring diagram + → test LEDs reset automatically +``` From 3420fdf2bde62cee09da540c2d82e13f07840969 Mon Sep 17 00:00:00 2001 From: Sayan Dhani Date: Thu, 23 Jul 2026 14:17:28 +0200 Subject: [PATCH 5/5] added HV safety and test for safety --- cold.py | 14 +- coldroom/safety.py | 103 ++++++-- tests/conftest.py | 12 + tests/test_safety.py | 567 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 664 insertions(+), 32 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_safety.py diff --git a/cold.py b/cold.py index bd12221..889c4e4 100755 --- a/cold.py +++ b/cold.py @@ -1094,14 +1094,16 @@ def update_ui(self): if "co2_sensor" in self.system.status: co2_data = self.system.status["co2_sensor"] if "CO2" in co2_data: - if co2_data["CO2"] > 800: + ppm = co2_data["CO2"] + if ppm > 800: is_safe = False - # door_msg += "CO2 levels safe: False" - door_msg += "CO2 levels safe: NO" - door_msg += f" (Current CO2: {co2_data['CO2']:.1f} ppm > 800 ppm)" + door_msg += f"CO2 levels safe: NO (current {ppm:.0f} ppm > 800 ppm threshold — risk of oxygen depletion)\n" else: - # door_msg += "CO2 levels safe: True" - door_msg += "CO2 levels safe: Yes" + door_msg += f"CO2 levels safe: YES (current {ppm:.0f} ppm < 800 ppm threshold)\n" + else: + door_msg += "CO2 levels safe: UNKNOWN (sensor present but no CO2 reading)\n" + else: + door_msg += "CO2 levels safe: UNKNOWN (CO2 sensor data not available)\n" safe_to_open_led.setStyleSheet( "background-color: green;" if is_safe diff --git a/coldroom/safety.py b/coldroom/safety.py index 9d9747d..d8af14a 100644 --- a/coldroom/safety.py +++ b/coldroom/safety.py @@ -151,9 +151,7 @@ def check_door_safe_to_open(system_status, caen_ch_status, used_channels): try: # Check if we have all necessary data if "coldroom" not in system_status: - return ( - False # Conservative approach - if we can't check, assume it's unsafe - ) + return False, "coldroom data not available — assuming unsafe" # 1. Check if dew point conditions are safe. # Cleanroom status carries a "last_update" timestamp string, not an @@ -171,25 +169,52 @@ def check_door_safe_to_open(system_status, caen_ch_status, used_channels): except (ValueError, TypeError) as e: logger.debug(f"Could not parse cleanroom last_update: {str(e)}") clean_room_expired = True # Unparseable → treat as expired + # Pull raw values used by check_dew_point so we can show the actual numbers. + marta_supply = system_status.get("marta", {}).get("TT05_CO2") + marta_return = system_status.get("marta", {}).get("TT06_CO2") + coldroom_t = system_status.get("coldroom", {}).get("ch_temperature", {}).get("value") + temps = [t for t in [marta_supply, marta_return, coldroom_t] if t is not None] + ext_dew = system_status.get("cleanroom", {}).get("dewpoint") + if clean_room_expired: dew_point_safe = False - log_msg += f"!!! Warning: Cleanroom data expired, not able to check dew point !!!\n" + dew_reason = "cleanroom sensor data expired (no update in >10 min)" + log_msg += "!!! Warning: Cleanroom data expired, not able to check dew point !!!\n" + elif not temps: + dew_point_safe = False + dew_reason = "no internal temperature readings available" + elif ext_dew is None: + dew_point_safe = False + dew_reason = "no dew point reading from cleanroom sensor" else: dew_point_safe = check_dew_point(system_status) - if not dew_point_safe: - log_msg += f"!!! Warning: Dew point conditions are not safe for opening door !!!\n" - log_msg += f"Dew point safe: NO\n" + min_t = min(temps) + if dew_point_safe: + dew_reason = ( + f"min internal temp {min_t:.1f}°C > " + f"dew point {ext_dew:.1f}°C + 1°C safety margin" + ) else: - log_msg += f"Dew point safe: YES\n" + dew_reason = ( + f"min internal temp {min_t:.1f}°C ≤ " + f"dew point {ext_dew:.1f}°C + 1°C safety margin" + ) + log_msg += "!!! Warning: Dew point conditions are not safe for opening door !!!\n" + log_msg += f"Dew point safe: {'YES' if dew_point_safe else 'NO'} ({dew_reason})\n" # 2. Check if high voltage is off - hv_on = check_any_hv_on(caen_ch_status, used_channels) + hv_on = check_any_hv_on(caen_ch_status, used_channels) hv_safe = not hv_on - if hv_safe == False: - log_msg += f"!!! Warning: High voltage is ON, not safe to open door !!!\n" - log_msg += f"High voltage safe: NO\n" + if not hv_safe: + on_hv = [ + ch for ch in used_channels.get("HV", []) + if ch and bool(caen_ch_status.get(f"caen_{ch}_IsOn", False)) + ] + hv_reason = f"channel(s) still ON: {on_hv}" if on_hv else "HV status uncertain" + log_msg += "!!! Warning: High voltage is ON, not safe to open door !!!\n" else: - log_msg += f"High voltage safe: YES\n" + hv_reason = "all HV channels are off" + log_msg += f"High voltage safe: {'YES' if hv_safe else 'NO'} ({hv_reason})\n" # 3. Check if light is off (light should be off when opening door) # light_off = not check_light_status(system_status) @@ -292,9 +317,7 @@ def Is_it_safe_to_on_lv(system_status, caen_ch_status, used_channels): try: # Check if we have all necessary data if "coldroom" not in system_status: - return ( - False # Conservative approach - if we can't check, assume it's unsafe - ) + return False, "coldroom data not available — assuming unsafe" # Check if MARTA is running marta_safe, marta_msg = check_marta_safe(system_status) @@ -402,9 +425,29 @@ def check_marta_on_for_IT(system_status): return False +def switch_all_hv_off(caen, used_channels): + """ + Turn off all HV channels. + Must be called BEFORE switch_all_lv_off — cutting LV while HV is still + ramped risks a sudden uncontrolled discharge through the silicon sensors. + Returns True if all commands were sent successfully. + """ + try: + for channel in used_channels["HV"]: + if channel is None: + continue + logger.warning(f"Safety interlock: turning off HV channel {channel}") + caen.off(channel) + return True + except Exception as e: + logger.error(f"Error in switch_all_hv_off: {str(e)}") + return False + + def switch_all_lv_off(caen, used_channels): """ Turn off all LV channels. + Always call switch_all_hv_off first so HV has been cut before LV is removed. Returns True if all commands were sent successfully. """ try: @@ -427,10 +470,12 @@ def soft_interlock_loop( Soft interlock loop - monitors safety conditions and takes protective action. Decision tree (evaluated every ~5 s): - 1. If MARTA is not in a safe/connected state AND LV is on - → switch all LV off immediately - 2. Else if LV is on AND MARTA CO2 is not flowing to OT modules - → switch all LV off immediately + 1. If MARTA is not in a safe/connected state AND any power (HV or LV) is on + → switch all HV off first, then all LV off + 2. Else if any power is on AND MARTA CO2 is not flowing to OT modules + → switch all HV off first, then all LV off + HV is always cut before LV to avoid an uncontrolled discharge + through the silicon sensors. (IT modules share the same MARTA CO2 system; a full MARTA shutdown is caught by condition 1. Per-valve IT protection requires serviceroom data to be subscribed — see check_marta_on_for_IT.) @@ -466,16 +511,21 @@ def soft_interlock_loop( ) logger.info(log_msg) + hv_on = check_any_hv_on(caen_ch_status, used_channels) + hv_status = "ON" if hv_on else "OFF" + log_msg += f"HV={hv_status}\n" + # --- Condition 1: MARTA itself is not safe (e.g. disconnected) --- if not lv_safe_to_on: log_msg += "\n!!! Warning: MARTA not safe — LV safe to turn on: NO\n" log_msg += lv_safe_msg - if lv_on: + if lv_on or hv_on: alarm_msg = ( - "SAFETY INTERLOCK: MARTA is not in a safe state and LV channels are ON. " - "Turning off all LV channels to prevent module damage." + "SAFETY INTERLOCK: MARTA is not in a safe state and power is ON. " + "Turning off all HV then LV channels to prevent module damage." ) logger.warning(alarm_msg) + switch_all_hv_off(caen, used_channels) switch_all_lv_off(caen, used_channels) if publish_alarm: publish_alarm(alarm_msg) @@ -485,12 +535,13 @@ def soft_interlock_loop( log_msg += "\nLV safe to turn on: YES\n" # --- Condition 2: MARTA connected but CO2 not flowing to OT --- - if lv_on and not marta_ot: + if (lv_on or hv_on) and not marta_ot: alarm_msg = ( - "SAFETY INTERLOCK: LV channels are ON but MARTA OT CO2 is not flowing. " - "Turning off all LV channels to prevent module damage." + "SAFETY INTERLOCK: Power is ON but MARTA OT CO2 is not flowing. " + "Turning off all HV then LV channels to prevent module damage." ) logger.warning(alarm_msg) + switch_all_hv_off(caen, used_channels) switch_all_lv_off(caen, used_channels) if publish_alarm: publish_alarm(alarm_msg) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bc2f955 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +""" +pytest configuration for the integration_tools test suite. +Shows the test docstring alongside the test name in verbose output. +""" + + +def pytest_collection_modifyitems(items): + for item in items: + lines = (item.obj.__doc__ or "").strip().splitlines() + doc = lines[0].strip() if lines else "" + if doc: + item._nodeid = f"{item.nodeid} [{doc}]" diff --git a/tests/test_safety.py b/tests/test_safety.py new file mode 100644 index 0000000..ae5f8ae --- /dev/null +++ b/tests/test_safety.py @@ -0,0 +1,567 @@ +""" +Unit tests for coldroom/safety.py. + +All tests use MagicMock CAEN and plain dicts for system_status / caen_ch_status +so no real hardware or MQTT broker is required. + +Minimal "fully safe" system_status: + { + "marta": {"fsm_state": "RUNNING"}, + "coldroom": {}, # required guard key in Is_it_safe_to_on_lv + } +Extend per-test to trigger the specific condition under test. +""" + + +""" +# All tests, grouped by class with progress +python -m pytest tests/test_safety.py -v + +# Just a quick pass/fail summary (no names) +python -m pytest tests/test_safety.py +""" + +import datetime +import logging +import unittest +from unittest.mock import MagicMock, call + +# Silence safety.py's logger during tests — the interlock messages are expected +# output of intentional test conditions, not real alarms. +logging.getLogger("coldroom.safety").setLevel(logging.CRITICAL) + +from coldroom.safety import ( + check_door_safe_to_open, + check_marta_on_for_IT, + check_marta_on_for_OT, + soft_interlock_loop, + switch_all_hv_off, + switch_all_lv_off, +) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _safe_status(): + """Minimal system_status for a fully connected, safe system.""" + return { + "marta": {"fsm_state": "RUNNING"}, + "coldroom": {}, + } + + +def _make_caen(): + """MagicMock CAEN that records every .off() call without touching hardware.""" + caen = MagicMock() + caen.off = MagicMock() + return caen + + +def _all_off(lv=("LV0.1",), hv=("HV0.1",)): + """caen_ch_status dict with every listed channel reported as OFF.""" + status = {} + for ch in lv: + status[f"caen_{ch}_IsOn"] = False + for ch in hv: + status[f"caen_{ch}_IsOn"] = False + return status + + +def _recent_ts(): + """A last_update timestamp that is less than 10 minutes old.""" + return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _stale_ts(): + """A last_update timestamp that is more than 10 minutes old.""" + return (datetime.datetime.now() - datetime.timedelta(minutes=15)).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + +USED = {"LV": ["LV0.1"], "HV": ["HV0.1"]} +USED_MULTI = {"LV": ["LV0.1", "LV0.2"], "HV": ["HV0.1", "HV0.2"]} + + +# --------------------------------------------------------------------------- +# soft_interlock_loop +# --------------------------------------------------------------------------- + +class TestSoftInterlock(unittest.TestCase): + """5-second safety loop: monitors MARTA state and cuts power if cooling is lost.""" + + # ---- Condition 1: MARTA is not safe (FSM state bad / no coldroom data) -- + + def test_marta_disconnected_lv_on_triggers_shutdown(self): + """Classic danger: cooling lost, LV still energised.""" + caen = _make_caen() + status = {"marta": {"fsm_state": "DISCONNECTED"}} + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + published = [] + + is_safe, msg = soft_interlock_loop( + status, ch_status, USED, caen, published.append + ) + + self.assertFalse(is_safe) + caen.off.assert_called() + self.assertEqual(len(published), 1) + self.assertIn("SAFETY INTERLOCK", published[0]) + + def test_marta_disconnected_hv_only_on_triggers_shutdown(self): + """HV alone being on is sufficient to trigger the interlock.""" + caen = _make_caen() + status = {"marta": {"fsm_state": "DISCONNECTED"}} + ch_status = {"caen_LV0.1_IsOn": False, "caen_HV0.1_IsOn": True} + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen) + + self.assertFalse(is_safe) + caen.off.assert_called() + + def test_marta_disconnected_all_power_off_no_action(self): + """MARTA gone but everything already off — nothing to protect, no action.""" + caen = _make_caen() + status = {"marta": {"fsm_state": "DISCONNECTED"}} + ch_status = _all_off() + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen) + + caen.off.assert_not_called() + self.assertTrue(is_safe) + + def test_marta_fsm_none_treated_as_disconnected(self): + """FSM state 'NONE' is an offline indicator.""" + caen = _make_caen() + status = {"marta": {"fsm_state": "NONE"}} + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen) + + self.assertFalse(is_safe) + caen.off.assert_called() + + def test_marta_fsm_empty_string_treated_as_disconnected(self): + """Empty FSM state means MARTA has not reported yet — assume offline.""" + caen = _make_caen() + status = {"marta": {"fsm_state": ""}} + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen) + + self.assertFalse(is_safe) + caen.off.assert_called() + + def test_missing_coldroom_key_with_power_on_triggers_shutdown(self): + """Is_it_safe_to_on_lv requires 'coldroom' to be present; without it → unsafe.""" + caen = _make_caen() + status = {"marta": {"fsm_state": "RUNNING"}} # no 'coldroom' + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen) + + self.assertFalse(is_safe) + caen.off.assert_called() + + # ---- Condition 2: MARTA connected but OT CO2 not flowing ---------------- + + def test_marta_running_ot_valve_closed_triggers_shutdown(self): + """MARTA connected but serviceroom reports OT valve closed → cut power.""" + caen = _make_caen() + status = { + "marta": {"fsm_state": "RUNNING"}, + "coldroom": {}, + "serviceroom": {"outer_valve": 0}, + } + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + + is_safe, msg = soft_interlock_loop(status, ch_status, USED, caen) + + self.assertFalse(is_safe) + caen.off.assert_called() + self.assertIn("OT CO2", msg) + + def test_marta_running_ot_valve_open_is_safe(self): + """Valve confirmed open → no protective action.""" + caen = _make_caen() + status = { + "marta": {"fsm_state": "RUNNING"}, + "coldroom": {}, + "serviceroom": {"outer_valve": 1}, + } + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen) + + self.assertTrue(is_safe) + caen.off.assert_not_called() + + def test_marta_running_no_serviceroom_falls_back_to_fsm(self): + """Without serviceroom data, RUNNING FSM state is trusted for OT.""" + caen = _make_caen() + status = _safe_status() # no 'serviceroom' key + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen) + + self.assertTrue(is_safe) + caen.off.assert_not_called() + + def test_ot_valve_closed_but_power_off_no_action(self): + """OT CO2 not flowing, but all channels already off — nothing to cut.""" + caen = _make_caen() + status = { + "marta": {"fsm_state": "RUNNING"}, + "coldroom": {}, + "serviceroom": {"outer_valve": 0}, + } + ch_status = _all_off() + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen) + + self.assertTrue(is_safe) + caen.off.assert_not_called() + + # ---- Fully safe baseline ------------------------------------------------ + + def test_fully_safe_baseline_no_action(self): + """MARTA running, OT confirmed via FSM, all power off → safe.""" + caen = _make_caen() + is_safe, _ = soft_interlock_loop(_safe_status(), _all_off(), USED, caen) + self.assertTrue(is_safe) + caen.off.assert_not_called() + + # ---- HV/LV shutdown ordering — critical safety invariant ---------------- + + def test_hv_cut_before_lv(self): + """ + HV must be switched off BEFORE LV. + Cutting LV first while HV is still ramped risks an uncontrolled + discharge through the silicon sensors. + """ + caen = _make_caen() + status = {"marta": {"fsm_state": "DISCONNECTED"}} + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": True} + + soft_interlock_loop(status, ch_status, USED, caen) + + calls = caen.off.call_args_list + self.assertEqual(len(calls), 2) + self.assertEqual(calls[0], call("HV0.1")) + self.assertEqual(calls[1], call("LV0.1")) + + def test_all_channels_shut_down_and_hv_first(self): + """With multiple channels, every channel is cut and all HV calls precede all LV calls.""" + caen = _make_caen() + status = {"marta": {"fsm_state": "DISCONNECTED"}} + ch_status = { + "caen_LV0.1_IsOn": True, "caen_LV0.2_IsOn": True, + "caen_HV0.1_IsOn": True, "caen_HV0.2_IsOn": True, + } + + soft_interlock_loop(status, ch_status, USED_MULTI, caen) + + turned_off = [c.args[0] for c in caen.off.call_args_list] + for ch in ("HV0.1", "HV0.2", "LV0.1", "LV0.2"): + self.assertIn(ch, turned_off) + + hv_last = max(i for i, ch in enumerate(turned_off) if ch.startswith("HV")) + lv_first = min(i for i, ch in enumerate(turned_off) if ch.startswith("LV")) + self.assertLess(hv_last, lv_first, "All HV calls must precede all LV calls") + + # ---- Alarm publishing --------------------------------------------------- + + def test_alarm_published_exactly_once_per_trip(self): + caen = _make_caen() + status = {"marta": {"fsm_state": "DISCONNECTED"}} + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + published = [] + + soft_interlock_loop(status, ch_status, USED, caen, published.append) + + self.assertEqual(len(published), 1) + + def test_publish_alarm_none_does_not_crash(self): + """publish_alarm=None is the test/dry-run mode — must not raise.""" + caen = _make_caen() + status = {"marta": {"fsm_state": "DISCONNECTED"}} + ch_status = {"caen_LV0.1_IsOn": True, "caen_HV0.1_IsOn": False} + + is_safe, _ = soft_interlock_loop(status, ch_status, USED, caen, publish_alarm=None) + + self.assertFalse(is_safe) + + def test_no_alarm_when_everything_safe(self): + caen = _make_caen() + published = [] + + soft_interlock_loop(_safe_status(), _all_off(), USED, caen, published.append) + + self.assertEqual(len(published), 0) + + +# --------------------------------------------------------------------------- +# check_door_safe_to_open +# --------------------------------------------------------------------------- + +class TestDoorSafety(unittest.TestCase): + """Door can be opened only when dew point is safe AND HV is off.""" + + def _door_safe_status(self, min_temp=-5.0, dew_point=-10.0): + """ + Fully safe status for door-open check: + min internal temp (-5 °C) comfortably above dew point (-10 °C) + 1 °C margin. + """ + return { + "coldroom": {"ch_temperature": {"value": min_temp}}, + "marta": {"TT05_CO2": min_temp, "TT06_CO2": min_temp}, + "cleanroom": {"dewpoint": dew_point, "last_update": _recent_ts()}, + } + + # ---- Missing data guards ------------------------------------------------ + + def test_no_coldroom_key_returns_false(self): + is_safe, msg = check_door_safe_to_open({}, {}, USED) + self.assertFalse(is_safe) + self.assertIn("coldroom", msg.lower()) + + def test_no_cleanroom_last_update_treats_as_expired(self): + status = {"coldroom": {}, "cleanroom": {"dewpoint": -10.0}} # no last_update + is_safe, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertFalse(is_safe) + self.assertIn("expired", msg) + + def test_stale_cleanroom_data_blocks_door(self): + status = { + "coldroom": {}, + "cleanroom": {"dewpoint": -10.0, "last_update": _stale_ts()}, + } + is_safe, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertFalse(is_safe) + self.assertIn("expired", msg) + + def test_unparseable_timestamp_treated_as_expired(self): + status = { + "coldroom": {}, + "cleanroom": {"dewpoint": -10.0, "last_update": "not-a-date"}, + } + is_safe, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertFalse(is_safe) + self.assertIn("expired", msg) + + def test_no_internal_temps_blocks_door(self): + """No MARTA or coldroom temperatures → cannot verify dew point.""" + status = { + "coldroom": {}, # no ch_temperature + "cleanroom": {"dewpoint": -10.0, "last_update": _recent_ts()}, + } + is_safe, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertFalse(is_safe) + self.assertIn("temperature", msg.lower()) + + def test_no_dewpoint_reading_blocks_door(self): + status = { + "coldroom": {"ch_temperature": {"value": -5.0}}, + "marta": {"TT05_CO2": -5.0}, + "cleanroom": {"last_update": _recent_ts()}, # no "dewpoint" key + } + is_safe, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertFalse(is_safe) + self.assertIn("dew point", msg.lower()) + + # ---- Dew point logic ---------------------------------------------------- + + def test_safe_when_temp_well_above_dew_point(self): + """-5 °C internal > -10 °C dew + 1 °C margin (-9 °C) → safe.""" + status = self._door_safe_status(min_temp=-5.0, dew_point=-10.0) + is_safe, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertTrue(is_safe) + self.assertIn("YES", msg) + + def test_unsafe_when_temp_below_dew_point_plus_margin(self): + """-10 °C internal ≤ -9 °C margin → condensation risk.""" + status = self._door_safe_status(min_temp=-10.0, dew_point=-10.0) + is_safe, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertFalse(is_safe) + self.assertIn("NO", msg) + + def test_exact_margin_boundary_is_unsafe(self): + """Condition is strictly >; equal to margin means not safe.""" + # min_temp == dew_point + 1 → -9 == -10 + 1 → NOT strictly greater + status = self._door_safe_status(min_temp=-9.0, dew_point=-10.0) + is_safe, _ = check_door_safe_to_open(status, _all_off(), USED) + self.assertFalse(is_safe) + + def test_dew_point_reason_includes_actual_temperatures(self): + """Message must show the numeric values so operators know the actual margin.""" + status = self._door_safe_status(min_temp=-5.0, dew_point=-10.0) + _, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertIn("-5.0", msg) + self.assertIn("-10.0", msg) + + # ---- HV checks ---------------------------------------------------------- + + def test_hv_on_blocks_door_and_lists_channel(self): + status = self._door_safe_status() + ch_status = {"caen_HV0.1_IsOn": True} + is_safe, msg = check_door_safe_to_open(status, ch_status, USED) + self.assertFalse(is_safe) + self.assertIn("NO", msg) + self.assertIn("HV0.1", msg) + + def test_hv_off_passes_hv_check(self): + status = self._door_safe_status() + is_safe, msg = check_door_safe_to_open(status, _all_off(), USED) + self.assertTrue(is_safe) + self.assertIn("all HV channels are off", msg) + + def test_multiple_hv_channels_one_on_blocks_and_names_it(self): + status = self._door_safe_status() + ch_status = {"caen_HV0.1_IsOn": False, "caen_HV0.2_IsOn": True} + used = {"LV": ["LV0.1"], "HV": ["HV0.1", "HV0.2"]} + is_safe, msg = check_door_safe_to_open(status, ch_status, used) + self.assertFalse(is_safe) + self.assertIn("HV0.2", msg) + self.assertNotIn("HV0.1", msg) # off channels should not appear + + # ---- Combined verdict --------------------------------------------------- + + def test_both_conditions_met_allows_door(self): + status = self._door_safe_status() + is_safe, _ = check_door_safe_to_open(status, _all_off(), USED) + self.assertTrue(is_safe) + + def test_dew_point_ok_but_hv_on_blocks_door(self): + status = self._door_safe_status() + is_safe, _ = check_door_safe_to_open(status, {"caen_HV0.1_IsOn": True}, USED) + self.assertFalse(is_safe) + + def test_hv_off_but_dew_point_unsafe_blocks_door(self): + # min_temp at margin → dew point not safe + status = self._door_safe_status(min_temp=-9.0, dew_point=-10.0) + is_safe, _ = check_door_safe_to_open(status, _all_off(), USED) + self.assertFalse(is_safe) + + +# --------------------------------------------------------------------------- +# check_marta_on_for_OT / IT +# --------------------------------------------------------------------------- + +class TestMartaCO2Flow(unittest.TestCase): + """OT and IT valve checks: FSM state is primary; valve data refines when available.""" + + # ---- OT ----------------------------------------------------------------- + + def test_ot_no_marta_data_returns_false(self): + self.assertFalse(check_marta_on_for_OT({})) + + def test_ot_fsm_disconnected_returns_false(self): + self.assertFalse(check_marta_on_for_OT({"marta": {"fsm_state": "DISCONNECTED"}})) + + def test_ot_fsm_none_returns_false(self): + self.assertFalse(check_marta_on_for_OT({"marta": {"fsm_state": "NONE"}})) + + def test_ot_fsm_empty_returns_false(self): + self.assertFalse(check_marta_on_for_OT({"marta": {"fsm_state": ""}})) + + def test_ot_running_no_serviceroom_trusts_fsm(self): + """Without valve data, RUNNING FSM state is sufficient.""" + self.assertTrue(check_marta_on_for_OT({"marta": {"fsm_state": "RUNNING"}})) + + def test_ot_running_outer_valve_open_returns_true(self): + status = {"marta": {"fsm_state": "RUNNING"}, "serviceroom": {"outer_valve": 1}} + self.assertTrue(check_marta_on_for_OT(status)) + + def test_ot_running_outer_valve_closed_returns_false(self): + """Valve data overrides FSM state — closed valve means no CO2 to OT.""" + status = {"marta": {"fsm_state": "RUNNING"}, "serviceroom": {"outer_valve": 0}} + self.assertFalse(check_marta_on_for_OT(status)) + + # ---- IT ----------------------------------------------------------------- + + def test_it_no_marta_data_returns_false(self): + self.assertFalse(check_marta_on_for_IT({})) + + def test_it_fsm_disconnected_returns_false(self): + self.assertFalse(check_marta_on_for_IT({"marta": {"fsm_state": "DISCONNECTED"}})) + + def test_it_running_no_serviceroom_trusts_fsm(self): + self.assertTrue(check_marta_on_for_IT({"marta": {"fsm_state": "RUNNING"}})) + + def test_it_running_inner_valve_open_returns_true(self): + status = {"marta": {"fsm_state": "RUNNING"}, "serviceroom": {"inner_valve": 1}} + self.assertTrue(check_marta_on_for_IT(status)) + + def test_it_running_inner_valve_closed_returns_false(self): + status = {"marta": {"fsm_state": "RUNNING"}, "serviceroom": {"inner_valve": 0}} + self.assertFalse(check_marta_on_for_IT(status)) + + def test_ot_and_it_valves_are_independent(self): + """OT valve closed should not affect IT result and vice versa.""" + status = { + "marta": {"fsm_state": "RUNNING"}, + "serviceroom": {"outer_valve": 0, "inner_valve": 1}, + } + self.assertFalse(check_marta_on_for_OT(status)) + self.assertTrue(check_marta_on_for_IT(status)) + + +# --------------------------------------------------------------------------- +# switch_all_hv_off / switch_all_lv_off +# --------------------------------------------------------------------------- + +class TestSwitchHelpers(unittest.TestCase): + """Direct tests for the channel-switching primitives.""" + + def test_switch_hv_off_calls_caen_off_for_each_channel(self): + caen = _make_caen() + used = {"LV": [], "HV": ["HV0.1", "HV0.2"]} + result = switch_all_hv_off(caen, used) + self.assertTrue(result) + caen.off.assert_any_call("HV0.1") + caen.off.assert_any_call("HV0.2") + + def test_switch_lv_off_calls_caen_off_for_each_channel(self): + caen = _make_caen() + used = {"LV": ["LV0.1", "LV0.2"], "HV": []} + result = switch_all_lv_off(caen, used) + self.assertTrue(result) + caen.off.assert_any_call("LV0.1") + caen.off.assert_any_call("LV0.2") + + def test_switch_hv_off_skips_none_entries(self): + caen = _make_caen() + used = {"LV": [], "HV": [None, "HV0.1"]} + switch_all_hv_off(caen, used) + caen.off.assert_called_once_with("HV0.1") + + def test_switch_lv_off_skips_none_entries(self): + caen = _make_caen() + used = {"LV": [None, "LV0.1"], "HV": []} + switch_all_lv_off(caen, used) + caen.off.assert_called_once_with("LV0.1") + + def test_switch_hv_returns_false_on_caen_error(self): + caen = _make_caen() + caen.off.side_effect = RuntimeError("TCP connection lost") + result = switch_all_hv_off(caen, {"LV": [], "HV": ["HV0.1"]}) + self.assertFalse(result) + + def test_switch_lv_returns_false_on_caen_error(self): + caen = _make_caen() + caen.off.side_effect = RuntimeError("TCP connection lost") + result = switch_all_lv_off(caen, {"LV": ["LV0.1"], "HV": []}) + self.assertFalse(result) + + def test_empty_channel_lists_return_true_without_calling_off(self): + """No channels configured — nothing to do, still success.""" + caen = _make_caen() + self.assertTrue(switch_all_hv_off(caen, {"LV": [], "HV": []})) + self.assertTrue(switch_all_lv_off(caen, {"LV": [], "HV": []})) + caen.off.assert_not_called() + + +if __name__ == "__main__": + unittest.main()
Hybrid 0Hybrid 1
SSA%2.2f%2.2f
MPA%2.2f%2.2f
Hybrid 0Hybrid 1
SSA%2.2f%2.2f
MPA%2.2f%2.2f