Source code for UltrafastSpectroscopyAnalyzer

# -*- coding: utf-8 -*-
"""
Created on Sun Oct 12 16:25:52 2025

@author: Alejandro
"""

# =============================================================================
# IMPORTS
# =============================================================================

import os
import sys
import time

import numpy as np
import pandas as pd
from scipy.optimize import least_squares
from scipy.interpolate import RegularGridInterpolator, interp1d


import matplotlib.pyplot as plt 
from matplotlib import cm, gridspec
from matplotlib.figure import Figure
from matplotlib.colors import BoundaryNorm
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
from mpl_toolkits.axes_grid1 import make_axes_locatable

from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QDialog, QTabWidget,
    QVBoxLayout, QHBoxLayout, QGridLayout, QFormLayout,
    QPushButton, QLabel, QLineEdit, QFileDialog, QMessageBox,
    QProgressBar, QTableWidget, QTableWidgetItem, QHeaderView,
    QComboBox, QDoubleSpinBox, QSpinBox, QSlider, QDial,
    QFrame, QGroupBox, QRadioButton, QCheckBox, QSpacerItem, QSizePolicy,QInputDialog
)
from PyQt5.QtGui import QFont, QPalette, QColor, QDesktopServices, QIcon
from PyQt5.QtCore import Qt, QTimer, QUrl, QSize,QEvent

import fit
from core_analysis import fit_t0, load_data, eV_a_nm
from GlobalFitClassGui import GlobalFitPanel
from maps_from_timescans import AppWindow as XFELWindow

STYLESHEET = """
    QMainWindow, QWidget {
        background-color: #e6e8ed; 
        color: #222222;
        font-family: "Segoe UI", Arial, sans-serif;
        font-size: 13px;
    }

   
    QLineEdit, QComboBox, QListWidget, QTextEdit, QTableWidget {
        background-color: #FFFFFF;
        border: 1px solid #C0C0C0;
        border-radius: 3px;
        padding: 4px;
        color: #000000;
    }
    
    QComboBox::down-arrow {
            image: none; 
            border-left: 4px solid transparent;
            border-right: 4px solid transparent;
            border-top: 5px solid #666666; 
            width: 0px;
            height: 0px;
            margin-top: 2px;
        }
    
    QComboBox:hover {
            border: 1px solid #0078D7; 
        }
    
        
        QComboBox::drop-down {
            subcontrol-origin: padding;
            subcontrol-position: top right;
            width: 25px;
            border-left: 1px solid #E5E5E5; 
            background-color: #FAFAFA;
            border-top-right-radius: 3px;
            border-bottom-right-radius: 3px;
        }
    
        
        QComboBox QAbstractItemView {
            border: 1px solid #C0C0C0;
            background-color: #FFFFFF;
            selection-background-color: #E5F1FB;
            selection-color: #000000;
            outline: none; 
        }
    
        
        QComboBox QAbstractItemView::item {
            padding: 8px 10px; 
            min-height: 25px;
        }
        
    
    QPushButton {
        background-color: #E1E1E1;
        border: 1px solid #ADADAD;
        border-radius: 3px;
        padding: 6px 12px;
        color: #222222;
    }
    QPushButton:hover {
        background-color: #D4D4D4;
        border: 1px solid #0078D7; 
    }
    QPushButton:pressed {
        background-color: #C8C8C8;
    }

    
    QPushButton#MenuCard {
        background-color: #FFFFFF; 
        color: #2B2B2B;
        border: 1px solid #D2D2D2; 
        border-radius: 6px; 
        font-size: 15px;
        font-weight: bold;
    }
    QPushButton#MenuCard:hover {
        background-color: #F8FBFF; 
        border: 1px solid #0078D7; 
        color: #005A9E; 
    }
    QPushButton#MenuCard:pressed {
        background-color: #E5F1FB;
        border: 1px solid #005499;
    }

    
    QPushButton#BtnGreen {
        background-color: #6CB66C; 
        color: white;
        border: 1px solid #549A54;
        border-radius: 3px;
        font-weight: bold;
        padding: 8px;
    }
    QPushButton#BtnGreen:hover {
        background-color: #5CA55C;
        border: 1px solid #468446;
    }
    QPushButton#BtnGreen:pressed {
        background-color: #4A8C4A;
    }

    
    QTabWidget::pane {
        border: 1px solid #C0C0C0;
        background: #F0F2F5;
        top: -1px; 
    }
    QTabBar::tab {
        background: #E1E1E1;
        border: 1px solid #C0C0C0;
        padding: 6px 15px;
        margin-right: 2px;
        border-top-left-radius: 2px;
        border-top-right-radius: 2px;
    }
    QTabBar::tab:selected {
        background: #F0F2F5;
        border-bottom-color: #F0F2F5; 
        font-weight: bold;
    }
    QTabBar::tab:hover:!selected {
        background: #ECECEC;
    }

    
    QLabel#MainTitle {
        font-size: 24px;
        font-weight: bold;
        color: #333333;
    }
    
    
    QCheckBox {
        spacing: 5px;
    }
    QCheckBox::indicator {
        width: 14px;
        height: 14px;
        border: 1px solid #ADADAD;
        background: #FFFFFF;
        border-radius: 2px;
    }
    QCheckBox::indicator:checked {
        background: #6CB66C;
        border: 1px solid #549A54;
    }
"""

[docs] class MainApp(QMainWindow): """ Main Window (DASHBOARD) Serves as the central hub for the Ultrafast Spectroscopy Analyzer suite. Provides a graphical menu to launch different specialized analysis tools (FLUPS, TAS, Global Fit, and 2D Mapper). """
[docs] def __init__(self): """Initializes the main dashboard window, sets dimensions, and applies styling.""" super().__init__() self.setWindowTitle("Ultrafast Spectroscopy Analyzer") self.setMinimumSize(800, 400) # Note: Ensure STYLESHEET is defined in your broader scope or imported # self.setStyleSheet(STYLESHEET) self.github_url = "https://github.com/AlejandroSerranoCapote/Ultrafast-Spectroscopy-Analyzer" self.initUI()
[docs] def initUI(self): """Sets up the UI elements, layouts, headers, buttons, and footer.""" central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) main_layout.setContentsMargins(50, 40, 50, 30) main_layout.setSpacing(1) # --- 1. Header --- title = QLabel("SELECT ANALYSIS MODE") title.setObjectName("MainTitle") title.setAlignment(Qt.AlignCenter) subtitle = QLabel("Ultrafast Spectroscopy Processing Tools") subtitle.setAlignment(Qt.AlignCenter) main_layout.addWidget(title) main_layout.addWidget(subtitle) # --- 2. Buttons Grid --- grid = QGridLayout() grid.setSpacing(20) txt_flups = "FLUPS ANALYZER" txt_tas = "TAS ANALYZER" txt_fit = "GLOBAL FIT" txt_xfel = "2D MAPPER" # Create buttons self.btn_flups = self.create_card(txt_flups) self.btn_tas = self.create_card(txt_tas) self.btn_fit = self.create_card(txt_fit) self.btn_xfel = self.create_card(txt_xfel) # Connect buttons to their respective launcher methods self.btn_flups.clicked.connect(self.launch_flups) self.btn_tas.clicked.connect(self.launch_tas) self.btn_fit.clicked.connect(self.launch_global) self.btn_xfel.clicked.connect(self.launch_xfel) # Add buttons to grid grid.addWidget(self.btn_flups, 0, 0) grid.addWidget(self.btn_tas, 0, 1) grid.addWidget(self.btn_fit, 1, 0) grid.addWidget(self.btn_xfel, 1, 1) main_layout.addLayout(grid) main_layout.addSpacing(20) # --- 3. Footer --- footer_layout = QVBoxLayout() footer_layout.setSpacing(10) self.btn_github = QPushButton("View Source Code on GitHub") self.btn_github.setCursor(Qt.PointingHandCursor) self.btn_github.clicked.connect(self.open_github) h_center = QHBoxLayout() h_center.addStretch() h_center.addWidget(self.btn_github) h_center.addStretch() footer_layout.addLayout(h_center) description = QLabel( "Welcome! This free and open-source software allows you to analyze " "ultrafast spectroscopy data directly from experiments such as " "<b>FLUPS</b> (Fluorescence Upconversion Spectroscopy) " ", <b>TAS</b> (Transient Absorption Spectroscopy) " "and <b>XTAS</b> (X-Ray Transient Absorption Spectroscopy).<br><br>" "For any questions or feedback, please contact:<br>" "<b>alejandro.serrano1610@gmail.com</b>" ) description.setWordWrap(True) description.setAlignment(Qt.AlignCenter) footer_layout.addWidget(description) main_layout.addLayout(footer_layout)
[docs] def create_card(self, text): """ Creates a stylized, large push button to act as a dashboard card. Args: text (str): The label text to display on the button. Returns: QPushButton: The configured button widget. """ btn = QPushButton(text) btn.setCursor(Qt.PointingHandCursor) btn.setMinimumHeight(80) # Used for targeting in CSS/QSS styling btn.setObjectName("MenuCard") return btn
[docs] def open_github(self): """Opens the repository URL in the user's default web browser.""" if hasattr(self, 'github_url'): QDesktopServices.openUrl(QUrl(self.github_url))
[docs] def open_tool(self, tool_window): """ Hides the main menu, opens the selected tool, and ensures the menu reappears when the child tool window is closed. Args: tool_window (QMainWindow or QWidget): The specific tool instance to open. """ self.current_tool = tool_window # Store the original close event of the tool window original_close = tool_window.closeEvent def on_close_tool(event): # Show the main menu again when the tool closes self.show() # Execute the tool's standard close event original_close(event) # Override the tool's close event with our custom wrapper tool_window.closeEvent = on_close_tool tool_window.show() self.hide()
[docs] def launch_xfel(self): """Instantiates and launches the XFEL 2D Mapper tool.""" window = XFELWindow() self.open_tool(window)
[docs] def launch_flups(self): """Instantiates and launches the FLUPS Analyzer tool.""" window = FLUPSAnalyzer() self.open_tool(window)
[docs] def launch_tas(self): """Instantiates and launches the TAS Analyzer tool.""" window = TASAnalyzer() self.open_tool(window)
[docs] def launch_global(self): """Instantiates and launches the Global Fit tool.""" window = GlobalFitPanel() self.open_tool(window)
[docs] class FLUPSAnalyzer(QMainWindow): """ Main application window for FLUPS (Fluorescence Upconversion Spectroscopy) analysis. Provides an interactive GUI to load data, visualize 2D maps, fit time-zero (t0) dispersion curves, and explore kinetics/spectra dynamically. """
[docs] def __init__(self): """Initializes the FLUPS Analyzer UI, layouts, and state variables.""" super().__init__() self.setWindowTitle("FLUPS Analyzer") screen = QApplication.primaryScreen() screen_geom = screen.availableGeometry() # Usable size (excluding taskbar) w_target = int(screen_geom.width() * 0.85) h_target = int(screen_geom.height() * 0.90) x_pos = (screen_geom.width() - w_target) // 2 + screen_geom.left() y_pos = screen_geom.top() + 35 self.setGeometry(x_pos, y_pos, w_target, h_target) self.setMinimumSize(1000, 700) # --- State variables --- self.WL = None self.TD = None self.data = None self.file_path = None self.data_corrected = None self.result_fit = None self.use_discrete_levels = True # Change to False for a continuous map self.bg_cache = None self.cid_draw = None self._is_drawing = False # --- UI Widgets --- self.btn_load = QPushButton("Load CSV") self.btn_load.setObjectName("BtnGreen") self.btn_load.clicked.connect(self.load_file) self.btn_plot = QPushButton("Show Map") self.btn_plot.clicked.connect(self.plot_map) self.btn_plot.setEnabled(False) self.btn_remove_fringe = QPushButton("Remove Pump Fringe") self.btn_remove_fringe.clicked.connect(self.remove_pump_fringe) self.btn_remove_fringe.setEnabled(True) self.label_status = QLabel("No file loaded") self.btn_select = QPushButton("Select t₀ points") self.btn_select.clicked.connect(self.enable_point_selection) self.btn_select.setEnabled(False) self.btn_fit = QPushButton("Fit t₀") self.btn_fit.clicked.connect(self.fit_t0_points) self.btn_fit.setEnabled(False) self.btn_auto_chirp = QPushButton("Auto-Chirp (Experimental)") self.btn_auto_chirp.clicked.connect(self.auto_fit_chirp) self.btn_auto_chirp.setObjectName("BtnGreen") # Para que destaque self.btn_auto_chirp.setEnabled(False) self.btn_auto_chirp.setToolTip("Automatically detect and correct t0 dispersion") self.btn_show_corr = QPushButton("Show Corrected Map") self.btn_show_corr.clicked.connect(self.toggle_corrected_map) self.btn_show_corr.setEnabled(False) self.showing_corrected = False self.btn_global_fit = QPushButton("Global Fit") self.btn_global_fit.clicked.connect(self.open_global_fit) self.btn_global_fit.setObjectName("BtnGreen") self._last_move_time = 0.0 self._move_min_interval = 1.0 / 25.0 self.figure = Figure(figsize=(12, 8)) self.gs = gridspec.GridSpec(2, 2, height_ratios=[3, 1], width_ratios=[1, 1], hspace=0.25, wspace=0.35) self.ax_map = self.figure.add_subplot(self.gs[0, :]) self.ax_time_small = self.figure.add_subplot(self.gs[1, 0]) self.ax_spec_small = self.figure.add_subplot(self.gs[1, 1]) self.canvas = FigureCanvas(self.figure) self.cid_draw = self.canvas.mpl_connect('draw_event', self.on_draw) self.cid_move = self.canvas.mpl_connect("motion_notify_event", self.on_move_map) self.clicked_points = [] self.cid_click = None self.pcm = None self.cbar = None self.marker_map = None self.vline_map = None self.hline_map = None self.fit_line_artist = None self._init_small_plots() # --- Main window layout --- layout = QVBoxLayout() # Top Layout (Buttons) top_layout = QHBoxLayout() top_layout.addWidget(self.btn_load) top_layout.addWidget(self.label_status) top_layout.addWidget(self.btn_plot) top_layout.addWidget(self.btn_select) top_layout.addWidget(self.btn_fit) top_layout.addWidget(self.btn_auto_chirp) top_layout.addWidget(self.btn_show_corr) top_layout.addWidget(self.btn_remove_fringe) top_layout.addWidget(self.btn_global_fit) layout.addLayout(top_layout) # Add Canvas layout.addWidget(self.canvas) # =================================================================== # BOTTOM BLOCK: CENTERED CONTROLS # =================================================================== # Layout holding all controls (TAS will inject here) self.bottom_controls_layout = QHBoxLayout() self.bottom_controls_layout.setSpacing(25) # 1. --- Delay --- delay_layout = QVBoxLayout() delay_layout.setSpacing(5) delay_layout.addWidget(QLabel("Delay min (ps):")) self.xmin_edit = QLineEdit("-1") self.xmin_edit.setFixedWidth(50) delay_layout.addWidget(self.xmin_edit) delay_layout.addWidget(QLabel("Delay max (ps):")) self.xmax_edit = QLineEdit("3") self.xmax_edit.setFixedWidth(50) delay_layout.addWidget(self.xmax_edit) self.btn_apply_xlim = QPushButton("Apply X limits") self.btn_apply_xlim.setFixedWidth(120) self.btn_apply_xlim.clicked.connect(self.apply_x_limits) delay_layout.addWidget(self.btn_apply_xlim) delay_layout.addStretch() # Push upwards # 2. --- Wavelength --- wl_layout = QVBoxLayout() wl_layout.setSpacing(5) # wl min wl_min_layout = QHBoxLayout() wl_min_label = QLabel("λ min:") self.lbl_min_value = QLabel("400") self.lbl_min_value.setCursor(Qt.PointingHandCursor) self.lbl_min_value.setToolTip("Click to enter exact value") self.lbl_min_value.installEventFilter(self) # Make the window listen to this label's events self.slider_min = QSlider(Qt.Horizontal) self.slider_min.setMinimumWidth(200) self.slider_min.setMinimum(400) self.slider_min.setMaximum(800) self.slider_min.setValue(500) self.slider_min.valueChanged.connect(self.update_wl_range) wl_min_layout.addWidget(wl_min_label) wl_min_layout.addWidget(self.slider_min) wl_min_layout.addWidget(self.lbl_min_value) wl_layout.addLayout(wl_min_layout) # wlmax wl_max_layout = QHBoxLayout() wl_max_label = QLabel("λ max:") self.lbl_max_value = QLabel("800") self.lbl_max_value.setCursor(Qt.PointingHandCursor) self.lbl_max_value.setToolTip("Click to enter exact value") self.lbl_max_value.installEventFilter(self) # Make the window listen to this label's events self.slider_max = QSlider(Qt.Horizontal) self.slider_max.setMinimumWidth(200) self.slider_max.setMinimum(400) self.slider_max.setMaximum(800) self.slider_max.setValue(700) self.slider_max.valueChanged.connect(self.update_wl_range) wl_max_layout.addWidget(wl_max_label) wl_max_layout.addWidget(self.slider_max) wl_max_layout.addWidget(self.lbl_max_value) wl_layout.addLayout(wl_max_layout) wl_layout.addStretch() # Push upwards # 3. --- Dial Levels --- dial_layout = QVBoxLayout() self.n_levels = 30 self.dial_levels = QDial() self.dial_levels.setRange(2, 100) self.dial_levels.setValue(self.n_levels) self.dial_levels.setNotchesVisible(True) self.dial_levels.setWrapping(False) self.dial_levels.setFixedSize(80, 80) self.dial_levels.valueChanged.connect(self.update_n_levels) self.lbl_dial = QLabel(f"{self.n_levels}") self.lbl_dial.setAlignment(Qt.AlignCenter) dial_layout.addWidget(self.dial_levels, alignment=Qt.AlignCenter) dial_layout.addWidget(self.lbl_dial, alignment=Qt.AlignCenter) dial_layout.addStretch() # 4. --- Combo Box (t0 Model) --- combo_layout = QVBoxLayout() lbl_model = QLabel("Chirp model fit ( t<sub>0</sub> ):") self.combo_model = QComboBox() self.combo_model.addItems(["Polynomial", "Non linear"]) self.combo_model.setCurrentIndex(1) combo_layout.addWidget(lbl_model) combo_layout.addWidget(self.combo_model) combo_layout.addStretch() # 5. --- Y-Axis Scale --- scale_layout = QVBoxLayout() scale_layout.setSpacing(5) scale_layout.addWidget(QLabel("Y-Axis Scale:")) self.combo_scale = QComboBox() self.combo_scale.addItems(["SymLog", "Linear"]) self.combo_scale.setCurrentIndex(0) # SymLog by default scale_layout.addWidget(self.combo_scale) self.lbl_linthresh = QLabel("Linthresh (ps):") self.spin_linthresh = QDoubleSpinBox() self.spin_linthresh.setDecimals(2) self.spin_linthresh.setRange(0.01, 1000.0) # Wide range to play with self.spin_linthresh.setValue(1.0) # Default value self.spin_linthresh.setSingleStep(0.5) scale_layout.addWidget(self.lbl_linthresh) scale_layout.addWidget(self.spin_linthresh) scale_layout.addStretch() # Connect to the function that updates the plot instantly self.combo_scale.currentIndexChanged.connect(self.apply_y_scale) self.spin_linthresh.valueChanged.connect(self.apply_y_scale) # --- Pack everything into the controls layout --- self.bottom_controls_layout.addLayout(delay_layout) self.bottom_controls_layout.addLayout(wl_layout) self.bottom_controls_layout.addLayout(dial_layout) # Insert the new control here: self.bottom_controls_layout.addLayout(scale_layout) self.bottom_controls_layout.addLayout(combo_layout) center_bottom_layout = QHBoxLayout() center_bottom_layout.addStretch() # Left stretch center_bottom_layout.addLayout(self.bottom_controls_layout) center_bottom_layout.addStretch() # Right stretch layout.addLayout(center_bottom_layout) self.bottom_controls_layout.setSpacing(60) self.bottom_controls_layout.setContentsMargins(60, 10, 60, 0) layout.addLayout(self.bottom_controls_layout) # Set central widget container = QWidget() container.setLayout(layout) self.setCentralWidget(container) # --- Color styling for main axes and colorbars --- self.ax_map.tick_params(colors="black") self.ax_map.xaxis.label.set_color("black") self.ax_map.yaxis.label.set_color("black") self.ax_map.title.set_color("black") for spine in self.ax_map.spines.values(): spine.set_color("black") if self.cbar is not None: self.cbar.ax.yaxis.set_tick_params(color="black", labelcolor="black") self.cbar.ax.yaxis.label.set_color("black") for spine in self.cbar.ax.spines.values(): spine.set_color("black") for ax in [self.ax_time_small, self.ax_spec_small]: ax.tick_params(colors="black") ax.xaxis.label.set_color("black") ax.yaxis.label.set_color("black") ax.title.set_color("black")
[docs] def on_draw(self, event): """ Captures the background for Blitting with anti-recursion protection. Args: event: The matplotlib draw event. """ if event is not None and event.canvas != self.canvas: return if self._is_drawing: return self._is_drawing = True try: self.bg_cache = self.canvas.copy_from_bbox(self.figure.bbox) self.draw_animated_artists() finally: self._is_drawing = False
[docs] def apply_y_scale(self): """Applies the selected Y-axis scale and updates the plot instantly.""" is_symlog = self.combo_scale.currentText() == "SymLog" self.lbl_linthresh.setVisible(is_symlog) self.spin_linthresh.setVisible(is_symlog) # If the map doesn't exist yet, do nothing if not hasattr(self, 'ax_map') or self.data is None: return # Apply scale if is_symlog: # Reads the exact value from the spinbox to set where the logarithmic part begins self.ax_map.set_yscale("symlog", linthresh=self.spin_linthresh.value()) self.ax_map.set_ylabel("Delay (ps) - SymLog") else: self.ax_map.set_yscale("linear") self.ax_map.set_ylabel("Delay (ps) - Linear") # Redraw only what is necessary self.canvas.draw_idle()
[docs] def draw_animated_artists(self): """Draws only the animated (moving) elements over the cached background.""" # Map Lines if self.vline_map: self.ax_map.draw_artist(self.vline_map) if self.hline_map: self.ax_map.draw_artist(self.hline_map) if self.marker_map: self.ax_map.draw_artist(self.marker_map) # Small Plot Lines if self.cut_time_small: self.ax_time_small.draw_artist(self.cut_time_small) if self.vline_time_small: self.ax_time_small.draw_artist(self.vline_time_small) if self.cut_spec_small: self.ax_spec_small.draw_artist(self.cut_spec_small)
[docs] def eventFilter(self, obj, event): """ Intercepts specific events from monitored widgets. Args: obj: The QObject receiving the event. event: The QEvent object. Returns: bool: True if event was handled, False otherwise. """ if event.type() == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: if obj == self.lbl_min_value: self.prompt_exact_wl_min() return True # Indicate that the event has already been processed elif obj == self.lbl_max_value: self.prompt_exact_wl_max() return True # Let the rest of the events process normally return super().eventFilter(obj, event)
[docs] def prompt_exact_wl_min(self): """Opens a dialog to precisely set the minimum λ value.""" if getattr(self, "WL", None) is None: return # Prevents errors if no data is loaded try: current_val = float(self.lbl_min_value.text().replace(" nm", "")) except ValueError: current_val = float(np.min(self.WL)) val, ok = QInputDialog.getDouble( self, "Exact Min Wavelength", "Enter wavelength (nm):", value=current_val, decimals=2, min=np.min(self.WL), max=np.max(self.WL) ) if ok: idx = int(np.argmin(np.abs(self.WL - val))) self.slider_min.setValue(idx)
[docs] def prompt_exact_wl_max(self): """Opens a dialog to precisely set the maximum λ value.""" if getattr(self, "WL", None) is None: return try: current_val = float(self.lbl_max_value.text().replace(" nm", "")) except ValueError: current_val = float(np.max(self.WL)) val, ok = QInputDialog.getDouble( self, "Exact Max Wavelength", "Enter wavelength (nm):", value=current_val, decimals=2, min=np.min(self.WL), max=np.max(self.WL) ) if ok: idx = int(np.argmin(np.abs(self.WL - val))) self.slider_max.setValue(idx)
[docs] def open_global_fit(self): """Opens the Global Fit panel dialog.""" dlg = GlobalFitPanel(self) dlg.exec_()
def _init_small_plots(self): """Initializes the empty state and labels of the subplots.""" self.ax_time_small.set_xlabel("Delay (ps)") self.ax_time_small.set_ylabel("ΔA") self.ax_time_small.set_title("Kinetics (cursor)") self.ax_time_small.set_xlim(-1, 3) self.cut_time_small, = self.ax_time_small.plot([], [], '-', lw=1.5) self.vline_time_small = self.ax_time_small.axvline( x=0, color='k', ls='--', lw=1, visible=False, zorder=5 ) self.ax_spec_small.set_xlabel("Wavelength (nm)") self.ax_spec_small.set_ylabel("ΔA") self.ax_spec_small.set_title("Spectra (cursor)") self.cut_spec_small, = self.ax_spec_small.plot([], [], '-', lw=1.5)
[docs] def apply_x_limits(self): """Applies the X-axis (Delay) limits entered by the user.""" try: x_min = float(self.xmin_edit.text()) x_max = float(self.xmax_edit.text()) if x_min >= x_max: raise ValueError("x_min debe ser menor que x_max") self.ax_time_small.set_xlim(x_min, x_max) self.canvas.draw_idle() except ValueError: QMessageBox.warning(self, "Error", "Introduce valores numéricos válidos para los límites de Delay.")
[docs] def remove_pump_fringe(self): """Removes the pump fringe directly from the current data.""" if self.data is None: QMessageBox.warning(self, "No data", "Load data first.") return sWl, ok1 = QInputDialog.getDouble( self, "Pump wavelength", "Pump wavelength (nm):", min=0.0 ) if not ok1: return wisWL, ok2 = QInputDialog.getDouble( self, "Width of scattering", "Width of pump scattering (nm):", min=0.0 ) if not ok2: return if getattr(self, "showing_corrected", False) and self.data_corrected is not None: data_target = self.data_corrected else: data_target = self.data # Fringe indices posl1 = np.argmin(np.abs(self.WL - (sWl - wisWL / 2))) posl2 = np.argmin(np.abs(self.WL - (sWl + wisWL / 2))) # Modify data directly data_target[posl1:posl2, :] = 1e-10 # Refresh map to see the effect if getattr(self, "showing_corrected", False): self.toggle_corrected_map() # re-show corrected map else: self.plot_map() # re-show original map QMessageBox.information( self, "Pump fringe removed", f"Fringe at {sWl} ± {wisWL/2} nm has been set to near-zero." )
[docs] def load_file(self): """Loads the data file, cleans it, and automatically normalizes ΔA.""" # Select CSV or data.txt file_path, _ = QFileDialog.getOpenFileName( self, "Select CSV or Data File", "", "CSV Files (*.csv);;Data Files (*.txt *.dat)" ) if not file_path: return try: # Load data if file_path.endswith(".csv"): data, wl, td = load_data(auto_path=file_path) else: wl_path, _ = QFileDialog.getOpenFileName(self, "Select Wavelength File", "", "Text Files (*.txt)") td_path, _ = QFileDialog.getOpenFileName(self, "Select Delay File", "", "Text Files (*.txt)") if not wl_path or not td_path: QMessageBox.warning(self, "Files missing", "You must select both WL and TD files.") return data, wl, td = load_data(data_path=file_path, wl_path=wl_path, td_path=td_path) # --- REMOVE DUPLICATES AND SORT (FLUPS) --- wl, idx_wl = np.unique(wl, return_index=True) td, idx_td = np.unique(td, return_index=True) # Crop the data matrix to match the clean indices data = data[idx_wl, :][:, idx_td] # --- Normalization --- # ============================================================================= # NORMALIZATION DATA IN FLUPS # ============================================================================= max_val = np.nanmax(np.abs(data)) if max_val != 0: data = data / max_val self.WL, self.TD, self.data = wl, td, data self.file_path = file_path # Save the CSV path and base directory self.csv_path = file_path self.base_dir = os.path.dirname(file_path) self.label_status.setText(f"Loaded : {os.path.basename(file_path)}") self.btn_plot.setEnabled(True) self.btn_select.setEnabled(True) self.btn_fit.setEnabled(True) self.btn_auto_chirp.setEnabled(True) # Update sliders nwl = len(wl) self.slider_min.blockSignals(True) self.slider_max.blockSignals(True) self.slider_min.setMinimum(0) self.slider_min.setMaximum(nwl - 1) self.slider_max.setMinimum(0) self.slider_max.setMaximum(nwl - 1) self.slider_min.setValue(0) self.slider_max.setValue(nwl - 1) self.slider_min.blockSignals(False) self.slider_max.blockSignals(False) self.update_wl_range() except Exception as e: QMessageBox.critical(self, "Error loading file", str(e))
[docs] def apply_wl_range(self): """Applies the current slider values to the console printout.""" min_val = self.slider_min.value() max_val = self.slider_max.value() print(f"Aplicando λ min={min_val}, λ max={max_val}")
def _plot_discrete_map(self, ax, WL, TD, data, n_levels=5, cmap='jet', shading='auto', vmin=None, vmax=None): """ Draws a contourf-style map using a discrete pcolormesh. Args: ax: The matplotlib axis object. WL: Wavelength array. TD: Time Delay array. data: The 2D data matrix. n_levels: Number of discrete color levels. cmap: The colormap string. shading: Shading style for pcolormesh. vmin: Minimum value for the color scale. vmax: Maximum value for the color scale. Returns: QuadMesh: The created pcolormesh object. """ if vmin is None: vmin = np.nanmin(data) if vmax is None: vmax = np.nanmax(data) levels = np.linspace(vmin, vmax, n_levels) norm = BoundaryNorm(levels, ncolors=plt.get_cmap(cmap).N, clip=True) pcm = ax.pcolormesh(WL, TD, data.T, shading=shading, cmap=cmap, norm=norm) return pcm
[docs] def update_n_levels(self, value): """Updates the number of levels for the discrete map and redraws it, respecting the visible range.""" self.n_levels = value self.lbl_dial.setText(f"{value} levels") # update text if self.data is None: return # Determine which data and WL to use (respecting current visible range) if hasattr(self, "WL_visible") and self.WL_visible is not None: WL_used = self.WL_visible if getattr(self, "showing_corrected", False): # if we are showing the corrected map wl_min = self.WL_visible[0] wl_max = self.WL_visible[-1] wl_min_idx = np.argmin(np.abs(self.WL - wl_min)) wl_max_idx = np.argmin(np.abs(self.WL - wl_max)) + 1 data_used = self.data_corrected[wl_min_idx:wl_max_idx, :] else: data_used = self.data_visible else: WL_used = self.WL data_used = self.data_corrected if getattr(self, "showing_corrected", False) else self.data # Redraw map directly (without resetting) self.ax_map.clear() if self.cbar: try: self.cbar.remove() except: pass self.cbar = None self.pcm = self._plot_discrete_map( self.ax_map, WL_used, self.TD, data_used, n_levels=self.n_levels, shading="auto", vmin=-1, vmax=1 ) self.ax_map.set_xlabel("Wavelength (nm)") self.ax_map.set_ylabel("Delay (ps)") self.ax_map.set_title("ΔA Map") self.apply_y_scale() # Colorbar divider = make_axes_locatable(self.ax_map) cax = divider.append_axes("right", size="3%", pad=0.02) self.cbar = self.figure.colorbar(self.pcm, cax=cax, label="ΔA") # Coherent visual style self.ax_map.set_facecolor("white") for spine in self.ax_map.spines.values(): spine.set_color("black") self.ax_map.tick_params(colors="black") self.ax_map.xaxis.label.set_color("black") self.ax_map.yaxis.label.set_color("black") self.ax_map.title.set_color("black") self.canvas.draw_idle()
[docs] def plot_map(self): """Draws the main map configured for Blitting (high speed).""" if self.data is None: return # Standard cleanup self.ax_map.clear() if self.cbar: try: self.cbar.remove() except: pass self.cbar = None # --- Determine data to plot (respecting filters) --- WL_plot = self.WL_visible if hasattr(self, "WL_visible") and self.WL_visible is not None else self.WL data_plot = self.data_visible if hasattr(self, "data_visible") and self.data_visible is not None else self.data # 1. Draw Map (Static) if self.use_discrete_levels: self.pcm = self._plot_discrete_map(self.ax_map, WL_plot, self.TD, data_plot, n_levels=self.n_levels) else: self.pcm = self.ax_map.pcolormesh(WL_plot, self.TD, data_plot.T, shading="auto", cmap="jet") self.apply_y_scale() self.ax_map.set_title("ΔA Map") self.ax_map.set_xlabel("Wavelength (nm)") self.ax_map.set_ylabel("Delay (ps)") divider = make_axes_locatable(self.ax_map) cax = divider.append_axes("right", size="5%", pad=0.05) self.cbar = self.figure.colorbar(self.pcm, cax=cax, label="ΔA") # 2. Initialize Dynamic Elements (animated=True) x0, y0 = WL_plot[0], self.TD[0] self.vline_map = self.ax_map.axvline(x0, color='k', ls='--', lw=1, animated=True, zorder=6) self.hline_map = self.ax_map.axhline(y0, color='k', ls='--', lw=1, animated=True, zorder=6) self.marker_map, = self.ax_map.plot([x0], [y0], 'wx', markersize=8, markeredgewidth=2, animated=True, zorder=7) # 3. Prepare small subplots (IMPORTANT: Set limits here) self.ax_time_small.clear() self.ax_spec_small.clear() # Initialize animated lines (empty or with first value) self.cut_time_small, = self.ax_time_small.plot(self.TD, data_plot[0, :], 'b-', lw=1.5, animated=True) self.vline_time_small = self.ax_time_small.axvline(y0, color='k', ls='--', lw=1, animated=True) self.cut_spec_small, = self.ax_spec_small.plot(WL_plot, data_plot[:, 0], 'r-', lw=1.5, animated=True) # --- SET STATIC LIMITS --- vmin_g, vmax_g = np.nanmin(data_plot), np.nanmax(data_plot) margin = (vmax_g - vmin_g) * 0.05 self.ax_time_small.set_xlim(self.TD.min(), self.TD.max()) self.ax_time_small.set_ylim(vmin_g - margin, vmax_g + margin) self.ax_time_small.set_xlabel("Delay (ps)") self.ax_time_small.set_title("Kinetics (Preview)") # Static title self.ax_spec_small.set_xlim(WL_plot.min(), WL_plot.max()) self.ax_spec_small.set_ylim(vmin_g - margin, vmax_g + margin) self.ax_spec_small.set_xlabel("Wavelength (nm)") self.ax_spec_small.set_title("Spectra (Preview)") # Static title # Connect events if self.cid_click is None: self.cid_click = self.canvas.mpl_connect("button_press_event", self.on_click_map) # 4. Trigger the first full draw (Generates bg_cache) self.canvas.draw()
[docs] def update_wl_range(self): """ Updates the visible data variables based on sliders and calls plot_map to redraw everything correctly. """ if getattr(self, "WL", None) is None or getattr(self, "data", None) is None: # Update text to dashes if no data is present if hasattr(self, "lbl_min_value"): self.lbl_min_value.setText("- nm") if hasattr(self, "lbl_max_value"): self.lbl_max_value.setText("- nm") return # 1. Get slider indices wl_min_idx = int(self.slider_min.value()) wl_max_idx = int(self.slider_max.value()) # 2. Correct index crossings if wl_min_idx >= wl_max_idx: wl_max_idx = wl_min_idx + 1 # Ensure array boundaries wl_min_idx = max(0, min(wl_min_idx, len(self.WL) - 1)) wl_max_idx = max(0, min(wl_max_idx, len(self.WL) - 1)) # 3. Update Text Labels (nm) try: self.lbl_min_value.setText(f"{self.WL[wl_min_idx]:.1f} nm") self.lbl_max_value.setText(f"{self.WL[wl_max_idx]:.1f} nm") except Exception: pass # 4. DEFINE VISIBLE DATA (Global Visualization State) source_data = self.data_corrected if getattr(self, "showing_corrected", False) else self.data # Slice the data self.WL_visible = self.WL[wl_min_idx : wl_max_idx + 1] self.data_visible = source_data[wl_min_idx : wl_max_idx + 1, :] # 5. CENTRALIZED CALL self.plot_map()
[docs] def enable_point_selection(self): """Activates the mode allowing the user to select t0 points on the plot.""" self.clicked_points = [] if self.cid_click is None: self.cid_click = self.canvas.mpl_connect("button_press_event", self.on_click_map) QMessageBox.information(self, "Mode: Select points", "Left click: add point\nRight click: delete last point.\nThen press 'Fit t₀'.")
[docs] def update_small_cuts(self, x, y, WL_sel=None, data_sel=None): """ Full update after a click event. Args: x (float): The clicked X coordinate. y (float): The clicked Y coordinate. WL_sel (numpy.ndarray, optional): Selected Wavelength slice. data_sel (numpy.ndarray, optional): Selected data slice. """ # Reuse movement logic by simulating an event # This ensures visual consistency class MockEvent: pass evt = MockEvent() evt.xdata = x evt.ydata = y evt.inaxes = self.ax_map # Call on_move_map for fast rendering self.on_move_map(evt)
# If it was a click, ensure it stays fixed (optional) # self.canvas.draw_idle()
[docs] def on_click_map(self, event): """Registers points on the map (left adds, right deletes last) and updates cuts.""" if event.inaxes != self.ax_map: return x, y = event.xdata, event.ydata if x is None or y is None: return if event.button == 1: # left click -> add point artist, = self.ax_map.plot(x, y, 'wo', markeredgecolor='k', markersize=6, zorder=6) self.clicked_points.append({'x': x, 'y': y, 'artist': artist}) elif event.button == 3 and self.clicked_points: # right click -> delete last last = self.clicked_points.pop() try: last['artist'].remove() except Exception: pass # update marker tracking the cursor (optional: move main marker) if self.marker_map is None: self.marker_map, = self.ax_map.plot([x], [y], 'wx', markersize=8, markeredgewidth=2) else: self.marker_map.set_data([x], [y]) # update visual references for the vertical line if self.vline_map is None: # if it doesn't exist, create it self.vline_map = self.ax_map.axvline(x, color='k', ls='--', lw=1) else: # if it already exists, just update its position and ensure visibility self.vline_map.set_xdata([x, x]) self.vline_map.set_visible(True) # update visual references for the horizontal line if self.hline_map is None: self.hline_map = self.ax_map.axhline(y, color='k', ls='--', lw=1) else: self.hline_map.set_ydata([y, y]) self.hline_map.set_visible(True) # --- Here is the difference: update the small subplots --- self.update_small_cuts(x, y) self.update_small_cuts( x, y, WL_sel=getattr(self, "WL_visible", None), data_sel=getattr(self, "data_visible", None) ) self.canvas.draw_idle()
[docs] def on_move_map(self, event): """ Handles mouse movement over the plot to update cursors and slices dynamically. Args: event: The matplotlib mouse motion event. """ # If no cache or not on the axis, exit if self.bg_cache is None or self.data is None: return if event.inaxes != self.ax_map: return # 1. Restore clean background (erases previous cursors instantly) self.canvas.restore_region(self.bg_cache) # 2. Update mathematical positions (without drawing yet) x, y = event.xdata, event.ydata if x is None or y is None: return self._last_cursor_x = x self._last_cursor_y = y # Map lines self.vline_map.set_xdata([x, x]) self.hline_map.set_ydata([y, y]) self.marker_map.set_data([x], [y]) # Calculate indices for subplots # Use WL_visible if it exists, otherwise full WL cur_WL = self.WL_visible if hasattr(self, 'WL_visible') and self.WL_visible is not None else self.WL cur_data = self.data_visible if hasattr(self, 'data_visible') and self.data_visible is not None else self.data if cur_WL is not None and len(cur_WL) > 0: idx_wl = int(np.abs(cur_WL - x).argmin()) idx_td = int(np.abs(self.TD - y).argmin()) # Update small curves self.cut_time_small.set_data(self.TD, cur_data[idx_wl, :]) self.vline_time_small.set_xdata([y, y]) self.cut_spec_small.set_data(cur_WL, cur_data[:, idx_td]) # Info in status bar val = cur_data[idx_wl, idx_td] self.label_status.setText(f"Cursor: {x:.1f} nm, {y:.2f} ps | Val: {val:.4e}") # 3. Draw ONLY the animated elements and blit to screen self.draw_animated_artists() self.canvas.blit(self.figure.bbox)
[docs] def fit_t0_points(self): """Fits the selected points to a t0 curve and saves the extracted/corrected data.""" if not getattr(self, "clicked_points", None) or len(self.clicked_points) < 2: QMessageBox.warning(self, "Not enough points", "Select at least 2 points on the map.") return w_points = np.array([p['x'] for p in self.clicked_points]) t0_points = np.array([p['y'] for p in self.clicked_points]) texto_modelo = self.combo_model.currentText() if texto_modelo == "Polynomial": mode = 'poly' elif texto_modelo == "Non linear": mode = 'nonlinear' else: mode = 'auto' # Attempt the fit try: result = fit_t0(w_points, t0_points, self.WL, self.TD, self.data, mode=mode) except Exception as e: QMessageBox.critical(self, "Error de ajuste t₀", str(e)) return self.result_fit = result self.data_corrected = result['corrected'] # draw fit curve on main map if self.fit_line_artist is not None: try: self.fit_line_artist.remove() except Exception: pass self.fit_line_artist, = self.ax_map.plot(result['fit_x'], result['fit_y'], 'r-', lw=2, label="t₀ fit") self.ax_map.legend() self.canvas.draw_idle() # automatic saving (identical to current behavior) self.btn_show_corr.setEnabled(True) base_dir = os.path.dirname(self.file_path) base_name = os.path.splitext(os.path.basename(self.file_path))[0] self.save_dir = os.path.join(base_dir, f"{base_name}_Results") os.makedirs(self.save_dir, exist_ok=True) data_corr = result['corrected'] WL = self.WL TD = self.TD np.save(os.path.join(self.save_dir, f"{base_name}_treated_data.npy"), {'data_c': data_corr, 'WL': WL, 'TD': TD}) np.savetxt(os.path.join(self.save_dir, f"{base_name}_WL.txt"), WL, fmt='%.6f', header='Wavelength (nm)', comments='') np.savetxt(os.path.join(self.save_dir, f"{base_name}_TD.txt"), TD, fmt='%.6f', header='Delay (ps)', comments='') with open(os.path.join(self.save_dir, f"{base_name}_kin.txt"), 'w') as f: f.write("\t".join([f"{base_name}_kin_{round(wl,1)}nm" for wl in WL]) + "\n") np.savetxt(f, data_corr.T, fmt='%.6e', delimiter='\t') with open(os.path.join(self.save_dir, f"{base_name}_spec.txt"), 'w') as f: f.write("\t".join([f"{base_name}_spec_{td:.2f}ps" for td in TD]) + "\n") np.savetxt(f, data_corr, fmt='%.6e', delimiter='\t') t0_lambda = result['t0_lambda'] popt = result['popt'] method = result['method'] t0_file = os.path.join(self.save_dir, f"{base_name}_t0_fit.txt") np.savetxt(t0_file, np.column_stack((WL, t0_lambda)), fmt='%.6f', header='Wavelength (nm)\t t0 (ps)', comments='') params_file = os.path.join(self.save_dir, f"{base_name}_fit_params.txt") with open(params_file, 'w') as f: f.write(f"Fit method: {method}\n") f.write("Fit parameters:\n") if method.startswith('poly'): names = ['c4', 'c3', 'c2', 'c1', 'c0'] else: names = ['a', 'b', 'c', 'd'] for name, val in zip(names, popt): f.write(f" {name} = {val:.6g}\n") QMessageBox.information(self, "Files saved", f"Results saved in:\n{self.save_dir}") QMessageBox.information(self, "t₀ Fit Result", f"Fit completed using {method} model.\nParameters: {np.round(popt,4)}")
[docs] def auto_fit_chirp(self): """ Automatically detects t0 using Gaussian smoothing and a strict Global Intensity Threshold to reject dead spectral zones. """ if self.data is None: QMessageBox.warning(self, "No data", "Load data first.") return from scipy.ndimage import gaussian_filter1d # 1. Usar región visible if hasattr(self, 'WL_visible') and self.WL_visible is not None: wl_array = self.WL_visible data_array = self.data_visible else: wl_array = self.WL data_array = self.data # 2. Definir ventana de búsqueda global_max_idx = np.unravel_index(np.nanargmax(data_array), data_array.shape) global_t_max = self.TD[global_max_idx[1]] t_search_max = global_t_max + 3.0 valid_td_mask = self.TD <= t_search_max # EL FILTRO MAESTRO: 15% del máximo absoluto de todo el mapa global_max_val = np.nanmax(data_array) global_threshold = global_max_val * 0.15 w_points = [] t0_points = [] for i, wl in enumerate(wl_array): raw_kinetics = data_array[i, :] # Suavizar para no pillar ruido de alta frecuencia kinetics = gaussian_filter1d(raw_kinetics, sigma=2) kinetics_valid = kinetics[valid_td_mask] td_valid = self.TD[valid_td_mask] if len(kinetics_valid) < 5: continue max_idx = np.argmax(kinetics_valid) max_val = kinetics_valid[max_idx] # LA CRIBADORA: Si esta lambda no tiene fuerza real comparada con el pico, fuera. if max_val < global_threshold: continue target_val = max_val * 0.5 cross_idx = None for j in range(max_idx, 0, -1): if kinetics_valid[j] >= target_val and kinetics_valid[j-1] < target_val: cross_idx = j break if cross_idx is not None: y1, y2 = kinetics_valid[cross_idx - 1], kinetics_valid[cross_idx] t1, t2 = td_valid[cross_idx - 1], td_valid[cross_idx] if y2 != y1: t0_exact = t1 + (target_val - y1) * (t2 - t1) / (y2 - y1) w_points.append(wl) t0_points.append(t0_exact) if len(w_points) < 3: QMessageBox.warning(self, "Auto-Chirp failed", "No clear signals found. Adjust sliders or lower the global threshold.") return # Limpiar y dibujar self.clicked_points = [{'x': w, 'y': t} for w, t in zip(w_points, t0_points)] for p in self.clicked_points: p['artist'], = self.ax_map.plot(p['x'], p['y'], 'wo', markeredgecolor='g', markersize=4, zorder=6) self.canvas.draw_idle() # Llamar al fit try: reply = QMessageBox.question(self, 'Auto-Chirp Detection', f"Found {len(w_points)} clean t0 points.\nProceed to fit and correct?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) if reply == QMessageBox.Yes: self.fit_t0_points() except Exception as e: QMessageBox.critical(self, "Auto-Chirp Error", str(e))
[docs] def toggle_corrected_map(self): """Toggles between the original and corrected map using optimized rendering.""" # 1. Safety validation if self.data_corrected is None: QMessageBox.warning(self, "No corrected data", "Run 'Fit t₀' first.") return # 2. Toggle state (boolean flag) self.showing_corrected = not getattr(self, "showing_corrected", False) # 3. Decide the data source # If showing_corrected is True, use corrected data. If False, use self.data (base/original). source_data = self.data_corrected if self.showing_corrected else self.data # 4. Update Texts if self.showing_corrected: self.btn_show_corr.setText("Show Base/Original Map") suffix = "(t₀ Corrected)" else: self.btn_show_corr.setText("Show Corrected Map") suffix = "(Base/Original)" # 5. Recalculate visible slice (Respecting Sliders) if hasattr(self, 'slider_min') and hasattr(self, 'slider_max'): wl_min_idx = int(self.slider_min.value()) wl_max_idx = int(self.slider_max.value()) # Index protections if wl_min_idx >= wl_max_idx: wl_max_idx = wl_min_idx + 1 wl_min_idx = max(0, min(wl_min_idx, len(self.WL) - 1)) wl_max_idx = max(0, min(wl_max_idx, len(self.WL) - 1)) # Update variables used by plot_map self.WL_visible = self.WL[wl_min_idx:wl_max_idx+1] self.data_visible = source_data[wl_min_idx:wl_max_idx+1, :] else: # Fallback in case there are no sliders self.WL_visible = self.WL self.data_visible = source_data self.plot_map() # Explicitly update title to reflect state tech_name = "TAS" if getattr(self, "is_TAS_mode", False) else "FLUPS" self.ax_map.set_title(f"ΔA Map ({tech_name}) {suffix}") # A final redraw to ensure title updates self.canvas.draw()
[docs] class TASAnalyzer(FLUPSAnalyzer): """ Transient Absorption Spectroscopy (TAS) Analyzer. Inherits from FLUPSAnalyzer but specializes in handling TAS data, which includes simultaneous loading and dynamic subtraction of solvent data, pump fringe removal, and real-time amplitude/shift adjustments. """
[docs] def __init__(self): """Initializes the TAS Analyzer UI, extending and modifying the base FLUPS UI.""" super().__init__() self.setWindowTitle("TAS Analyzer") self.label_status.setText("No file loaded") # --- Data State --- self.medida = None self.solvente = None self.pump_mask = None # New variable to remove the pump artifact self.TDSol = None self.WLSol = None self.is_TAS_mode = True self.use_discrete_levels = False # Hide the discrete level dial (not used in TAS continuous map mode) self.dial_levels.hide() self.lbl_dial.hide() # --- Initialize variables for Blitting (optimization) --- self.bg_cache = None self.cid_draw = None self.cid_click = None self.cid_move = None # =================================================================== # SLIDERS AND CLICKABLE LABELS (AM and SF) # =================================================================== # 1. Slider and Label for Amplitude (AM) self.slider_am = QSlider(Qt.Horizontal) self.slider_am.setMinimumWidth(200) self.slider_am.setMinimum(0) self.slider_am.setMaximum(200) self.slider_am.setValue(100) # 100% by default self.lbl_am_value = QLabel("100 %") self.lbl_am_value.setCursor(Qt.PointingHandCursor) self.lbl_am_value.setToolTip("Click to enter exact value") self.lbl_am_value.installEventFilter(self) self.slider_am.valueChanged.connect(self.on_am_changed) # 2. Slider and Label for Temporal Shift (SF) self.slider_sf = QSlider(Qt.Horizontal) self.slider_sf.setMinimumWidth(200) self.slider_sf.setMinimum(-20000) self.slider_sf.setMaximum(20000) self.slider_sf.setValue(0) self.lbl_sf_value = QLabel("0.000 ps") self.lbl_sf_value.setCursor(Qt.PointingHandCursor) self.lbl_sf_value.setToolTip("Click to enter exact value") self.lbl_sf_value.installEventFilter(self) self.slider_sf.valueChanged.connect(self.on_sf_changed) # =================================================================== # TAS LAYOUT INJECTION IN THE CENTER # =================================================================== tas_extra_layout = QVBoxLayout() tas_extra_layout.setSpacing(5) # Amplitude Row amp_row = QHBoxLayout() amp_row.addWidget(QLabel("Amplitude (%):")) amp_row.addWidget(self.slider_am) amp_row.addWidget(self.lbl_am_value) # <--- Add the label here tas_extra_layout.addLayout(amp_row) # Shift Row shift_row = QHBoxLayout() shift_row.addWidget(QLabel("Shift (ps):")) shift_row.addWidget(self.slider_sf) shift_row.addWidget(self.lbl_sf_value) # <--- Add the label here tas_extra_layout.addLayout(shift_row) tas_extra_layout.addStretch() # Pushes the block up to align with the rest # Inject this block into the main bottom layout (index 2 is between WL and the Combo box) if hasattr(self, 'bottom_controls_layout'): self.bottom_controls_layout.insertLayout(2, tas_extra_layout) # === Checkbox for automatic .dat --> .csv conversion === self.chk_convert_dat = QCheckBox("Convert .dat → .csv (IMDEA DATA)") self.chk_convert_dat.setChecked(True) # Activated by default # Center it and place it at the very bottom chk_layout = QHBoxLayout() chk_layout.addStretch() chk_layout.addWidget(self.chk_convert_dat) chk_layout.addStretch() self.centralWidget().layout().addLayout(chk_layout)
[docs] def on_am_changed(self, value): """Updates the amplitude text label and recalculates the map.""" self.lbl_am_value.setText(f"{value} %") self.update_am_sf()
[docs] def on_sf_changed(self, value): """Updates the shift text label and recalculates the map.""" self.lbl_sf_value.setText(f"{value / 100.0:.3f} ps") self.update_am_sf()
[docs] def prompt_exact_am(self): """Opens a dialog to allow the user to enter an exact amplitude value.""" current_val = self.slider_am.value() val, ok = QInputDialog.getDouble( self, "Exact Amplitude", "Enter amplitude (%):", value=current_val, decimals=0, min=0, max=200 ) if ok: self.slider_am.setValue(int(val))
[docs] def prompt_exact_sf(self): """Opens a dialog to allow the user to enter an exact shift value in ps.""" current_val = self.slider_sf.value() / 100.0 val, ok = QInputDialog.getDouble( self, "Exact Shift", "Enter shift (ps):", value=current_val, decimals=3, min=-200.0, max=200.0 ) if ok: self.slider_sf.setValue(int(val * 100))
[docs] def eventFilter(self, obj, event): """ Intercepts click events on the Amplitude and Shift labels. Args: obj: The QObject receiving the event. event: The QEvent object. Returns: bool: True if event was handled, False otherwise. """ if event.type() == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: if obj == getattr(self, "lbl_am_value", None): self.prompt_exact_am() return True elif obj == getattr(self, "lbl_sf_value", None): self.prompt_exact_sf() return True # Pass the rest of the events (like the WL ones) to the parent class return super().eventFilter(obj, event)
[docs] def switch_analyzer(self): """Switches between FLUPSAnalyzer and TASAnalyzer without closing the main application process.""" try: target_cls_name = "FLUPSAnalyzer" if isinstance(self, TASAnalyzer) else "TASAnalyzer" if target_cls_name in globals() and callable(globals()[target_cls_name]): TargetCls = globals()[target_cls_name] else: raise NameError(f"{target_cls_name} not found") # Save the reference in self (not a local variable) self.new_window = TargetCls() self.new_window.show() # Close the current window self.close() except Exception as e: QMessageBox.critical(self, "Switch error", f"Cannot switch analyzer:\n{e}")
[docs] def convert_dat_to_csv(self, file_path): """ Converts a .dat file into a .csv file structured for TAS analysis. Args: file_path (str): The path to the original .dat file. Returns: str or None: The path to the newly created .csv file, or None if conversion fails. """ try: data = np.loadtxt(file_path) # wl = first column wl = data[:, 0] # t = first row (convert to ps) t = data[0] * 1e-3 # Replace in the matrix data[:, 0] = wl data[0, :] = t # Create .csv path csv_path = os.path.splitext(file_path)[0] + ".csv" # Save np.savetxt(csv_path, data, delimiter=",") return csv_path except Exception as e: QMessageBox.critical(self, "Conversion error", f"Cannot convert .dat:\n{e}") return None
[docs] def get_base_dir(self): """ Returns the directory containing the measurement CSV. Automatically creates 'fit' and 'plots' subfolders if they do not exist. Returns: tuple: (base_dir, fit_dir, plots_dir) paths. """ if hasattr(self, 'file_path') and self.file_path: base_dir = os.path.dirname(self.file_path) else: base_dir = os.getcwd() fit_dir = os.path.join(base_dir, "fit") plots_dir = os.path.join(base_dir, "plots") os.makedirs(fit_dir, exist_ok=True) os.makedirs(plots_dir, exist_ok=True) return base_dir, fit_dir, plots_dir
[docs] def remove_pump_fringe(self): """ Prompts the user for a central wavelength and width to mask out the pump scatter artifact. The masked region is set to near-zero (1e-10) to avoid division by zero errors. """ if self.data is None: QMessageBox.warning(self, "No data", "Load TAS data first.") return sWl, ok1 = QInputDialog.getDouble(self, "Pump wavelength", "Pump wavelength (nm):", min=0.0) if not ok1: return wisWL, ok2 = QInputDialog.getDouble(self, "Width of scattering", "Width of pump scattering (nm):", min=0.0) if not ok2: return posl1 = np.argmin(np.abs(self.WL - (sWl - wisWL / 2))) posl2 = np.argmin(np.abs(self.WL - (sWl + wisWL / 2))) # Create or update mask if self.pump_mask is None: self.pump_mask = np.zeros_like(self.medida, dtype=bool) self.pump_mask[posl1:posl2, :] = True # Apply mask over self.data self.update_am_sf() QMessageBox.information(self, "Pump fringe removed", f"Fringe at {sWl} ± {wisWL/2} nm will be zeroed.")
# ------------------------------------------------------------------ # FILE LOADING # ------------------------------------------------------------------
[docs] def load_file(self): """ Loads both the measurement data and the corresponding solvent data. Handles automatic deduplication, sorting, and initial UI setup for TAS data. """ # --- Select measurement file --- file_path_medida, _ = QFileDialog.getOpenFileName( self, "Select Measurement CSV", "", "CSV Files (*.csv);;Data Files (*.txt *.dat)" ) if not file_path_medida or not os.path.exists(file_path_medida): self.label_status.setText(" No measurement file selected.") return # --- Automatic .dat → .csv conversion if the option is checked --- if self.chk_convert_dat.isChecked() and file_path_medida.lower().endswith(".dat"): new_path = self.convert_dat_to_csv(file_path_medida) if new_path: file_path_medida = new_path # Save the path of the first CSV read self.file_path = file_path_medida # Create a specific folder for this measurement base_dir = os.path.dirname(self.file_path) base_name = os.path.splitext(os.path.basename(self.file_path))[0] self.results_dir = os.path.join(base_dir, f"{base_name}_results") os.makedirs(self.results_dir, exist_ok=True) raw = pd.read_csv(file_path_medida, header=None) raw = raw.apply(pd.to_numeric, errors="coerce").dropna(how="any") raw = raw.values.astype(float) temp_TD = raw[0, 1:] # Temporary delay (ps) temp_WL = raw[1:, 0] # Temporary wavelength (nm) temp_medida = raw[1:, 1:] # 2D Intensity Matrix temp_medida[np.isnan(temp_medida)] = 0 # --- REMOVE DUPLICATES AND SORT (MEASUREMENT) --- self.WL, idx_wl = np.unique(temp_WL, return_index=True) self.TD, idx_td = np.unique(temp_TD, return_index=True) # Crop the data matrix to match the clean indices self.medida = temp_medida[idx_wl, :][:, idx_td] # --- Select solvent file --- file_path_solvente, _ = QFileDialog.getOpenFileName( self, "Select Solvent CSV", os.path.dirname(self.file_path), # ✅ Opens dialog in the same folder "CSV Files (*.csv);;Data Files (*.txt *.dat)" ) if not file_path_solvente or not os.path.exists(file_path_solvente): self.label_status.setText(" No solvent file selected.") return if self.chk_convert_dat.isChecked() and file_path_solvente.lower().endswith(".dat"): new_path = self.convert_dat_to_csv(file_path_solvente) if new_path: file_path_solvente = new_path rawSol = pd.read_csv(file_path_solvente, header=None) rawSol = rawSol.apply(pd.to_numeric, errors="coerce").dropna(how="any") rawSol = rawSol.values.astype(float) temp_TDSol = rawSol[0, 1:] temp_WLSol = rawSol[1:, 0] temp_solvente = rawSol[1:, 1:] temp_solvente[np.isnan(temp_solvente)] = 0 # --- REMOVE DUPLICATES AND SORT (SOLVENT) --- self.WLSol, idx_wl_sol = np.unique(temp_WLSol, return_index=True) self.TDSol, idx_td_sol = np.unique(temp_TDSol, return_index=True) self.solvente = temp_solvente[idx_wl_sol, :][:, idx_td_sol] nwl = len(self.WL) self.slider_min.setMinimum(0) self.slider_min.setMaximum(nwl - 1) self.slider_max.setMinimum(0) self.slider_max.setMaximum(nwl - 1) self.slider_min.setValue(0) self.slider_max.setValue(nwl - 1) self.idx_min = 0 self.idx_max = nwl - 1 try: self.slider_min.valueChanged.disconnect() except: pass try: self.slider_max.valueChanged.disconnect() except: pass self.slider_min.valueChanged.connect(self.update_wl_range) self.slider_max.valueChanged.connect(self.update_wl_range) # --- Calculate initial map --- self.label_status.setText(" TAS data loaded") self.update_am_sf() self.plot_map() # --- Define base path for compatibility with FLUPSAnalyzer --- self.file_path = file_path_medida if hasattr(self, "btn_plot"): self.btn_plot.setEnabled(True) if hasattr(self, "btn_select"): self.btn_select.setEnabled(True) if hasattr(self, "btn_fit"): self.btn_fit.setEnabled(True) # Display only the loaded file name file_name = os.path.basename(file_path_medida) self.label_status.setText(f"TAS data loaded from: {file_name}")
# In TASAnalyzer (replacing the current version)
[docs] def fit_t0_points(self): """ Fits selected time-zero points and saves the corrected matrix. Overrides the base FLUPS method to ensure the solvent-subtracted base data is used. """ if not getattr(self, "clicked_points", None) or len(self.clicked_points) < 2: QMessageBox.warning(self, "Not enough points", "Select at least 2 points on the map.") return w_points = np.array([p['x'] for p in self.clicked_points]) t0_points = np.array([p['y'] for p in self.clicked_points]) try: # Re-calculate the base (self.data) with the most recent solvent/shift self.update_am_sf() # Use self.data (Base Data: solvent-corrected) for the fit result = fit_t0(w_points, t0_points, self.WL, self.TD, self.data) except Exception as e: QMessageBox.critical(self, "Fit error", str(e)) return # --- Save corrected data globally --- self.result_fit = result self.data_corrected = result['corrected'] self.plot_map(show_fit=True) self.btn_show_corr.setEnabled(True) # --- Create results folder next to the CSV and save --- base_dir = os.path.dirname(self.file_path) base_name = os.path.splitext(os.path.basename(self.file_path))[0] save_dir = os.path.join(base_dir, f"{base_name}_results") os.makedirs(save_dir, exist_ok=True) data_corr = np.copy(self.data_corrected) WL = self.WL TD = self.TD t0_lambda = result['t0_lambda'] popt = result['popt'] method = result['method'] np.save(os.path.join(save_dir, f"{base_name}_treated_data.npy"), {'data_c': data_corr, 'WL': WL, 'TD': TD}) np.savetxt(os.path.join(save_dir, f"{base_name}_WL.txt"), WL, fmt='%.6f', header='Wavelength (nm)', comments='') np.savetxt(os.path.join(save_dir, f"{base_name}_TD.txt"), TD, fmt='%.6f', header='Delay (ps)', comments='') np.savetxt(os.path.join(save_dir, f"{base_name}_kin.txt"), data_corr.T, fmt='%.6e', delimiter='\t') np.savetxt(os.path.join(save_dir, f"{base_name}_spec.txt"), data_corr, fmt='%.6e', delimiter='\t') np.savetxt(os.path.join(save_dir, f"{base_name}_t0_fit.txt"), np.column_stack((WL, t0_lambda)), fmt='%.6f', header='Wavelength (nm)\t t0 (ps)', comments='') with open(os.path.join(save_dir, f"{base_name}_fit_params.txt"), 'w') as f: f.write(f"Fit method: {method}\n") f.write("Fit parameters:\n") if method.startswith('poly'): names = ['c4', 'c3', 'c2', 'c1', 'c0'] else: names = ['a', 'b', 'c', 'd'] for name, val in zip(names, popt): f.write(f" {name} = {val:.6g}\n") QMessageBox.information(self, "Files saved", f" Results saved in:\n{save_dir}") QMessageBox.information(self, "t₀ Fit Result", f"Fit completed using {method} model.\nParameters: {np.round(popt,4)}")
[docs] def update_wl_range(self): """Updates the crop indices based on the UI sliders and refreshes the map.""" if self.medida is None: return # 1. Read slider values # Ensure they are integers (array indices) s_min = int(self.slider_min.value()) s_max = int(self.slider_max.value()) # 2. Validate crossing (Min cannot be >= Max) if s_min >= s_max: s_min = s_max - 1 if s_min < 0: s_min = 0 self.slider_min.blockSignals(True) # Prevent infinite loop self.slider_min.setValue(s_min) self.slider_min.blockSignals(False) # 3. Save to class variables self.idx_min = s_min self.idx_max = s_max # 4. Update text labels try: self.lbl_min_value.setText(f"{self.WL[s_min]:.1f} nm") self.lbl_max_value.setText(f"{self.WL[s_max]:.1f} nm") except Exception: pass # 5. Redraw self.plot_map()
# ------------------------------------------------------------------ # MAP UPDATE AFTER SLIDERS # ------------------------------------------------------------------ # In TASAnalyzer (replacing the current version)
[docs] def update_am_sf(self): """ Recalculates the base TAS data by subtracting the interpolated solvent matrix scaled by Amplitude (AM) and shifted in time (SF). """ if self.medida is None or self.solvente is None: return if hasattr(self, "_updating_am_sf") and self._updating_am_sf: return self._updating_am_sf = True am = self.slider_am.value() / 100.0 sf = self.slider_sf.value() / 100.0 interpSol = RegularGridInterpolator( (self.WLSol, self.TDSol), self.solvente, bounds_error=False, fill_value=0 ) WL_grid, TD_grid = np.meshgrid(self.WL, self.TD, indexing="ij") points = np.column_stack([WL_grid.ravel(), (TD_grid - sf).ravel()]) solvente_interp = interpSol(points).reshape(len(self.WL), len(self.TD)) * am # Base: measurement - solvent base_data = self.medida - solvente_interp # Apply mask if it exists if self.pump_mask is not None: base_data[self.pump_mask] = 1e-10 # The entire 'if hasattr(self, "data_corrected") ...' block that caused double calculation is removed. self.data = base_data self.update_wl_range() if hasattr(self, "global_fit_panel") and self.global_fit_panel is not None: self.global_fit_panel.update_from_parent() self._updating_am_sf = False
# ------------------------------------------------------------------ # DRAW ΔA MAP # ------------------------------------------------------------------
[docs] def plot_map(self, show_fit=False): """ Draws the main interactive 2D map (SymLog in Y) with support for toggling between Corrected and Original modes. Args: show_fit (bool, optional): Unused flag kept for backward compatibility. """ # 1. Determine which data to use (Base vs Corrected) # Check the flag that activates the toggle button showing_corrected = getattr(self, "showing_corrected", False) if showing_corrected and hasattr(self, "data_corrected") and self.data_corrected is not None: source_data = self.data_corrected mode_suffix = "(t₀ Corrected)" else: # If no flag or False, we use self.data (which already has the solvent subtracted) source_data = self.data mode_suffix = "(Base Data)" if source_data is None: return saved_x, saved_y = None, None if getattr(self, 'marker_map', None) is not None: try: saved_x = self.marker_map.get_xdata()[0] saved_y = self.marker_map.get_ydata()[0] except Exception: pass # --- Cleanup --- self.ax_map.clear() self.ax_time_small.clear() self.ax_spec_small.clear() # Reset variables to prevent errors self.vline_map = None self.hline_map = None self.marker_map = None self.cut_time_small = None self.cut_spec_small = None if self.cbar: try: self.cbar.remove() except: pass self.cbar = None # --- 2. Slicing --- if not hasattr(self, 'idx_min'): self.idx_min = 0 if not hasattr(self, 'idx_max'): self.idx_max = len(self.WL) - 1 idx_start = self.idx_min idx_end = self.idx_max + 1 wl_plot = self.WL[idx_start:idx_end] # Use source_data instead of self.data data_plot = source_data[idx_start:idx_end, :] if len(wl_plot) < 2: return # --- 3. Calculate Global Limits --- g_min = np.nanmin(data_plot) g_max = np.nanmax(data_plot) data_range = g_max - g_min if data_range == 0: data_range = 1.0 y_lim_min = g_min - (0.1 * data_range) y_lim_max = g_max + (0.1 * data_range) # --- 4. Draw Main Map --- self.pcm = self.ax_map.pcolormesh( wl_plot, self.TD, data_plot.T, shading="auto", cmap="jet", ) self.apply_y_scale() self.ax_map.set_xlabel("Wavelength (nm)") self.ax_map.set_ylabel("Delay (ps) - SymLog") # Update the title dynamically depending on the mode self.ax_map.set_title(f"ΔA Map (TAS) {mode_suffix}") self.ax_map.set_xlim(wl_plot.min(), wl_plot.max()) # Colorbar divider = make_axes_locatable(self.ax_map) cax = divider.append_axes("right", size="5%", pad=0.05) self.cbar = self.figure.colorbar(self.pcm, cax=cax, label="ΔA") self.apply_y_scale() if saved_x is not None and saved_y is not None: # np.clip prevents the cursor from staying off-screen if you crop too much with the slider mid_x = np.clip(saved_x, wl_plot.min(), wl_plot.max()) mid_y = saved_y else: mid_x = np.median(wl_plot) mid_y = np.median(self.TD) self.vline_map = self.ax_map.axvline(mid_x, color="k", ls="--", lw=1, animated=True) self.hline_map = self.ax_map.axhline(mid_y, color="k", ls="--", lw=1, animated=True) self.marker_map, = self.ax_map.plot([mid_x], [mid_y], "wx", markersize=8, markeredgewidth=2, animated=True) # --- 6. Configure Small Subplots --- # A) KINETICS (Bottom-Left) y_cut_time = data_plot[np.abs(wl_plot - mid_x).argmin(), :] self.cut_time_small, = self.ax_time_small.plot(self.TD, y_cut_time, 'b-', animated=True) self.vline_time_small = self.ax_time_small.axvline(mid_y, color='k', ls='--', lw=1.2, animated=True) self.ax_time_small.set_xscale('linear') try: user_xmin = float(self.xmin_edit.text()) user_xmax = float(self.xmax_edit.text()) self.ax_time_small.set_xlim(user_xmin, user_xmax) except ValueError: self.ax_time_small.set_xlim(self.TD.min(), self.TD.max()) self.ax_time_small.set_ylim(y_lim_min, y_lim_max) self.ax_time_small.set_title("Kinetics") self.ax_time_small.set_xlabel("Delay (ps)") # B) SPECTRUM (Bottom-Right) y_cut_spec = data_plot[:, np.abs(self.TD - mid_y).argmin()] self.cut_spec_small, = self.ax_spec_small.plot(wl_plot, y_cut_spec, 'r-', animated=True) self.ax_spec_small.set_xlim(wl_plot.min(), wl_plot.max()) self.ax_spec_small.set_ylim(y_lim_min, y_lim_max) self.ax_spec_small.set_title("Spectrum") self.ax_spec_small.set_xlabel("Wavelength (nm)") # --- 7. Events --- self.bg_cache = None if self.cid_draw is not None: self.canvas.mpl_disconnect(self.cid_draw) self.cid_draw = self.canvas.mpl_connect('draw_event', self.on_draw) if self.cid_click is None: self.cid_click = self.canvas.mpl_connect("button_press_event", self.on_click_map) if self.cid_move is None: self.cid_move = self.canvas.mpl_connect("motion_notify_event", self.on_move_map) self.canvas.draw()
[docs] def on_draw(self, event): """ Captures the background for blitting when the entire figure is redrawn. Args: event: The matplotlib draw event. """ if event is not None and event.canvas != self.canvas: return # Copy the canvas region (without animated lines) self.bg_cache = self.canvas.copy_from_bbox(self.figure.bbox) # Take the opportunity to redraw the animated lines once self.draw_animated_artists()
[docs] def draw_animated_artists(self): """Helper function to draw only the dynamic elements over the cached background.""" # 1. Safety check: # If vline_map doesn't exist or is None, we do nothing. # This prevents a crash when the window opens before data is loaded. vline = getattr(self, 'vline_map', None) if vline is None: return # 2. Draw Map elements # (Since we already checked vline, we assume the rest were created with it) try: self.ax_map.draw_artist(self.vline_map) self.ax_map.draw_artist(self.hline_map) self.ax_map.draw_artist(self.marker_map) # 3. Draw subplot elements # Verify these as well for safety if getattr(self, 'cut_time_small', None) is not None: self.ax_time_small.draw_artist(self.cut_time_small) self.ax_time_small.draw_artist(self.vline_time_small) if getattr(self, 'cut_spec_small', None) is not None: self.ax_spec_small.draw_artist(self.cut_spec_small) except AttributeError: # If something fails internally in matplotlib (e.g. window closed), ignore pass
[docs] def update_small_cuts(self, x, y, WL_sel=None, data_sel=None): """ Performs a full (slow) update for clicks or slider changes. Args: x (float): The X coordinate. y (float): The Y coordinate. WL_sel: Unused parameter kept for signature compatibility. data_sel: Unused parameter kept for signature compatibility. """ self.on_move_map(type('Event', (object,), {'xdata': x, 'ydata': y, 'inaxes': self.ax_map})()) self.canvas.draw_idle() # Ensures everything stays fixed
# ------------------------------------------------------------------ # MOUSE MOVEMENT EVENT # ------------------------------------------------------------------
[docs] def on_move_map(self, event): """ Ultra-fast update of subplots and cursors during mouse movement using Blitting. Args: event: The matplotlib mouse motion event. """ # 1. Basic validation of axes and data if self.data is None or event.inaxes != self.ax_map: return # 2. --- BUG FIX --- # Verify if the lines exist. If vline_map is None, # it means the graph is being cleared or hasn't been created yet. # We use getattr for extra safety. if getattr(self, 'vline_map', None) is None: return # 3. Get coordinates x, y = event.xdata, event.ydata if x is None or y is None: return # 4. Restore clean background (erases previous lines) if self.bg_cache is not None: self.canvas.restore_region(self.bg_cache) # 5. Update line data (without redrawing axes) self.vline_map.set_xdata([x, x]) self.hline_map.set_ydata([y, y]) self.marker_map.set_data([x], [y]) # --- Data for slices --- idx_wl = np.abs(self.WL - x).argmin() idx_td = np.abs(self.TD - y).argmin() # Validate indices (in case the mouse is outside the valid data range) if idx_wl >= self.data.shape[0] or idx_td >= self.data.shape[1]: return # Update Kinetics curve y_time = self.data[idx_wl, :] self.cut_time_small.set_data(self.TD, y_time) self.vline_time_small.set_xdata([y, y]) # Update Spectrum curve y_spec = self.data[:, idx_td] self.cut_spec_small.set_data(self.WL, y_spec) # 6. Draw the animated elements self.draw_animated_artists() # 7. Blit self.canvas.blit(self.figure.bbox) # Status bar val = self.data[idx_wl, idx_td] self.label_status.setText(f"Cursor: {x:.1f} nm, {y:.2f} ps | ΔA: {val:.4e}")
if __name__ == "__main__": # ===================================================================== # Application Entry Point # ===================================================================== # 1. Create the application instance app = QApplication(sys.argv) # Apply the "Fusion" style for a modern, consistent look across different OS app.setStyle("Fusion") # 2. Apply the global stylesheet to the entire application app.setStyleSheet(STYLESHEET) # 3. Force Windows to properly recognize the application icon on the taskbar. # This workaround prevents Windows from grouping the app under the default Python executable icon. import ctypes myappid = 'spectroscopy.analyzer.v1' ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) # 4. Configure the Global Application Icon icon_path = os.path.join(os.path.dirname(__file__), "icon.ico") if os.path.exists(icon_path): app.setWindowIcon(QIcon(icon_path)) # 5. Instantiate and launch the main dashboard window window = MainApp() window.show() # Start the main event loop and exit safely when closed sys.exit(app.exec_())