import os
import re
import numpy as np
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QMessageBox, QProgressBar, QTableWidget, QTableWidgetItem,
QHeaderView, QComboBox, QDoubleSpinBox, QSpinBox, QGroupBox,
QFormLayout, QWidget, QTabWidget, QApplication, QInputDialog,
QCheckBox, QLineEdit, QListView,QFileDialog,QScrollArea
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QColor
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
import matplotlib.pyplot as plt
from scipy.optimize import least_squares
import fit
from matplotlib.widgets import Cursor
from matplotlib.ticker import FuncFormatter
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QMessageBox, QLineEdit, QCheckBox, QListWidget,
QAbstractItemView, QListWidgetItem, QColorDialog, QFormLayout
)
from matplotlib.figure import Figure
from matplotlib.backends.backend_agg import FigureCanvasAgg
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QColor
from PyQt5.QtCore import Qt
# ---------------------------------------------------------------------
# GUI Styling Constants
# ---------------------------------------------------------------------
BUTTON_STYLE = """
QPushButton {
background-color: #6CB66C;
color: white;
border: 1px solid #549A54;
border-radius: 4px;
padding: 6px 12px;
font-weight: bold;
font-family: "Segoe UI";
font-size: 9pt;
}
QPushButton:hover {
background-color: #5CA55C;
color: white;
border: 1px solid #468446;
}
QPushButton:pressed {
background-color: #4A8C4A;
border: 1px solid #4A8C4A;
color: white;
padding-top: 7px;
padding-left: 13px;
}
QPushButton:disabled {
background-color: #A0C8A0;
border: 1px solid #A0C8A0;
color: #F0F0F0;
}
"""
DARK_THEME_STYLE = """
QDialog, QWidget {
color: #222222;
font-family: "Segoe UI";
font-size: 8pt;
}
QGroupBox {
border: 1px solid #C0C0C0;
border-radius: 5px;
margin-top: 8px;
padding-top: 10px;
font-weight: bold;
color: #000000;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 3px;
}
QSpinBox, QDoubleSpinBox, QComboBox, QLineEdit {
background-color: #FFFFFF;
border: 1px solid #C0C0C0;
border-radius: 4px;
color: #000000;
padding: 2px;
min-height: 18px;
}
QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus, QLineEdit:focus {
border: 1px solid #0078D7;
}
QComboBox QAbstractItemView {
background-color: #FFFFFF;
color: #000000;
selection-background-color: #E5F1FB;
selection-color: #000000;
border: 1px solid #C0C0C0;
}
QLabel { color: #222222; }
QCheckBox { color: #222222; }
QProgressBar {
border: 1px solid #C0C0C0;
border-radius: 5px;
text-align: center;
background-color: #FFFFFF;
color: #222222;
max-height: 15px;
}
QProgressBar::chunk {
background-color: #6CB66C;
border-radius: 3px;
width: 10px;
margin: 0.5px;
}
"""
[docs]
class Surface3DWindow(QDialog):
"""
Independent window to visualize the 3D plot without blocking the main application.
"""
[docs]
def __init__(self, xs, ys, zs, scale='linear', parent=None):
"""
Initializes the 3D surface plotting window.
Args:
xs (numpy.ndarray): X-axis array (e.g., Wavelengths).
ys (numpy.ndarray): Y-axis array (e.g., Time Delays).
zs (numpy.ndarray): 2D Z-axis matrix (e.g., Transient Absorption data).
scale (str, optional): The scale of the Y-axis ('linear' or 'symlog'). Defaults to 'linear'.
parent (QWidget, optional): Parent widget. Defaults to None.
"""
super().__init__(parent)
self.setWindowTitle("3D Surface Preview")
self.resize(800, 600)
self.setStyleSheet(DARK_THEME_STYLE)
self.setWindowModality(Qt.NonModal)
layout = QVBoxLayout()
self.fig = plt.Figure()
self.canvas = FigureCanvas(self.fig)
self.toolbar = NavigationToolbar(self.canvas, self)
self.toolbar.setStyleSheet("QToolBar { background-color: transparent; border: none; }")
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas)
self.setLayout(layout)
self.plot_data(xs, ys, zs, scale)
[docs]
def plot_data(self, xs, ys, zs, scale):
"""
Renders the 3D surface plot onto the canvas.
Args:
xs (numpy.ndarray): X-axis array.
ys (numpy.ndarray): Y-axis array.
zs (numpy.ndarray): 2D Z-axis matrix.
scale (str): The scale of the Y-axis ('linear' or 'symlog').
"""
ax = self.fig.add_subplot(111, projection='3d')
X, Y = np.meshgrid(xs, ys)
Z = zs.T
z_min = np.min(Z)
Y_plot = Y
y_axis_1d = ys
if scale == 'symlog':
linthresh = 1.0
Y_plot = np.where(np.abs(Y) <= linthresh,
Y,
np.sign(Y) * (linthresh + np.log10(np.abs(Y) / linthresh)))
y_axis_1d = Y_plot[:, 0]
ax.plot_surface(X, Y_plot, Z, cmap='jet', edgecolor='none', antialiased=True)
ax.view_init(elev=30, azim=135)
ax.contourf(X, Y_plot, Z, zdir='z', offset=z_min, cmap='jet', alpha=0.5)
def symlog_ticks(val, pos):
orig_val = val if np.abs(val) <= linthresh else np.sign(val) * linthresh * (10**(np.abs(val) - linthresh))
if orig_val == 0: return "0"
elif np.abs(orig_val) >= 10:
exponent = int(np.round(np.log10(np.abs(orig_val))))
sign = "-" if orig_val < 0 else ""
return f"{sign}$10^{{{exponent}}}$"
else: return f"{orig_val:.0g}"
ax.yaxis.set_major_formatter(FuncFormatter(symlog_ticks))
else:
ax.plot_surface(X, Y, Z, cmap='jet', edgecolor='none', antialiased=True)
ax.contourf(X, Y, Z, zdir='z', offset=z_min, cmap='jet', alpha=0.5)
ax.view_init(elev=30, azim=-50, roll=-60)
x_min = np.min(xs)
y_max = np.max(Y_plot)
x_min_pared = x_min - 20
y_max_pared = y_max + 0.5
# 1. Spectra
indices_tiempo = [len(ys)//10, len(ys)//4, len(ys)//2]
colores_espectros = ['red', 'orange', 'yellow']
for i, idx_t in enumerate(indices_tiempo):
espectro = Z[idx_t, :]
ax.plot(xs, espectro, zs=y_max_pared, zdir='y', color=colores_espectros[i%len(colores_espectros)], linewidth=1.5, alpha=0.8)
# 2. Kinetics
indices_onda = [len(xs)//4, len(xs)//2, 3*len(xs)//4]
colores_cineticas = ['cyan', 'blue', 'magenta']
for i, idx_w in enumerate(indices_onda):
cinetica = Z[:, idx_w]
ax.plot(y_axis_1d, cinetica, zs=x_min_pared, zdir='x', color=colores_cineticas[i%len(colores_cineticas)], linewidth=1.5, alpha=0.8)
ax.set_xlabel("Wavelength/Energy")
ax.set_ylabel("Delay (ps)")
ax.set_zlabel("Transient absorption")
ax.set_zlim(bottom=z_min)
# Clear panels (hide grid/panes for a cleaner look)
ax.grid(False)
ax.xaxis.pane.fill = False
ax.yaxis.pane.fill = False
ax.zaxis.pane.fill = False
ax.view_init(elev=25, azim=75)
self.canvas.draw()
[docs]
class PlotViewerWindow(QDialog):
"""Ventana independiente para visualizar gráficos SAS/DAS sin bloquear la app."""
[docs]
def __init__(self, fig, title="Plot", parent=None):
super().__init__(parent)
self.setWindowTitle(title)
self.resize(900, 600)
self.setWindowModality(Qt.NonModal) # Esto hace que no bloquee la app principal
self.setStyleSheet(DARK_THEME_STYLE)
layout = QVBoxLayout(self)
self.canvas = FigureCanvas(fig)
self.toolbar = NavigationToolbar(self.canvas, self)
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas)
[docs]
class TraceExplorerWindow(QDialog):
"""Explorador interactivo de cinéticas sin bloquear la interfaz."""
[docs]
def __init__(self, parent_panel, outdir):
super().__init__(parent_panel)
self.p = parent_panel
self.outdir = outdir
self.setWindowTitle("Interactive Trace Viewer")
self.resize(1000, 550)
self.setWindowModality(Qt.NonModal) # Clave para que flote libremente
self.setStyleSheet(DARK_THEME_STYLE + BUTTON_STYLE)
layout = QVBoxLayout(self)
# Controles superiores
ctrl_layout = QHBoxLayout()
ctrl_layout.addWidget(QLabel("Wavelength (nm):"))
self.wl_array = getattr(self.p, '_wl_proc', self.p.WL)
self.td_array = getattr(self.p, '_td_proc', self.p.TD)
# Usamos un ComboBox para moverse exactamente por los índices medidos
self.combo_wl = QComboBox()
self.combo_wl.addItems([f"{w:.1f}" for w in self.wl_array])
self.combo_wl.setCurrentIndex(len(self.wl_array)//2)
self.combo_wl.currentIndexChanged.connect(self.update_plot)
ctrl_layout.addWidget(self.combo_wl)
self.btn_save = QPushButton("Save Trace Data")
self.btn_save.clicked.connect(self.save_trace)
ctrl_layout.addWidget(self.btn_save)
layout.addLayout(ctrl_layout)
# Lienzo (Canvas) de Matplotlib
self.fig = plt.Figure(figsize=(12, 5))
self.canvas = FigureCanvas(self.fig)
self.toolbar = NavigationToolbar(self.canvas, self)
layout.addWidget(self.toolbar)
layout.addWidget(self.canvas)
self.update_plot()
[docs]
def update_plot(self):
self.fig.clear()
idx = self.combo_wl.currentIndex()
real_wl = self.wl_array[idx]
y_exp = self.p.data_c[idx, :]
td = self.td_array
# Generar eje de tiempo suave para el Fit
td_lin = np.linspace(td.min(), 1.0, 1000)
td_log = np.geomspace(1.0, max(1.1, td.max()), 1000)
td_smooth = np.unique(np.concatenate((td_lin, td_log)))
# Evaluar el modelo
if self.p.t0_choice == 'No' and hasattr(self.p, 'S_T_full'):
# NUEVO: Reconstrucción fiel usando VarPro y el Artefacto
use_art = getattr(self.p, 'chk_artifact', None) and self.p.chk_artifact.isChecked()
if self.p.model_type == "Sequential":
C_smooth = fit.get_concentration_matrix_sequential(self.p.fit_x, td_smooth, self.p.numExp, use_art)
elif self.p.model_type == 'Damped Oscillation':
C_smooth = fit.get_concentration_matrix_oscillation(self.p.fit_x, td_smooth, self.p.numExp, use_art)
else:
C_smooth = fit.get_concentration_matrix_global(self.p.fit_x, td_smooth, self.p.numExp, use_art)
# Multiplicamos la cinética suave por TODAS las amplitudes de esta longitud de onda
y_fit_smooth = C_smooth @ self.p.S_T_full[:, idx]
else:
# MODO CLÁSICO (Chirp o compatibilidad)
if self.p.model_type == "Sequential":
F_mat_smooth = fit.eval_sequential_model(self.p.fit_x, td_smooth, self.p.numExp, len(self.wl_array), self.p.t0_choice)
elif self.p.model_type == 'Damped Oscillation':
F_mat_smooth = fit.eval_oscillation_model(self.p.fit_x, td_smooth, self.p.numExp, len(self.wl_array), self.p.t0_choice)
else:
F_mat_smooth = fit.eval_global_model(self.p.fit_x, td_smooth, self.p.numExp, len(self.wl_array), self.p.t0_choice)
y_fit_smooth = F_mat_smooth.T[idx, :]
ax1 = self.fig.add_subplot(121)
ax2 = self.fig.add_subplot(122, sharey=ax1)
self.fig.suptitle(f"Fit at {real_wl:.1f} nm", fontsize=14)
# Plot Lineal
ax1.plot(td, y_exp, 'bo', markersize=4, alpha=0.6, label='Data')
ax1.plot(td_smooth, y_fit_smooth, 'r-', linewidth=2, label='Fit')
ax1.set_xlabel("Time / ps")
ax1.set_ylabel("ΔA")
ax1.legend(frameon=True)
ax1.grid(True, alpha=0.3)
# Plot Semi-Log
mask_pos_exp = td > 0
mask_pos_smooth = td_smooth > 0
if np.any(mask_pos_exp):
ax2.plot(td[mask_pos_exp], y_exp[mask_pos_exp], 'bo', markersize=4, alpha=0.6)
ax2.plot(td_smooth[mask_pos_smooth], y_fit_smooth[mask_pos_smooth], 'r-', linewidth=2)
ax2.set_xscale('log')
ax2.set_xlabel("Time / ps (log scale)")
ax2.grid(True, which="both", ls="-", alpha=0.3)
self.fig.tight_layout()
self.canvas.draw()
[docs]
def save_trace(self):
idx = self.combo_wl.currentIndex()
real_wl = self.wl_array[idx]
img_name = f"Trace_{real_wl:.1f}nm.png"
self.fig.savefig(os.path.join(self.outdir, img_name), dpi=300)
QMessageBox.information(self, "Saved", f"Trace saved successfully in:\n{self.outdir}")
[docs]
class CompareSetupDialog(QDialog):
"""Cuadro de diálogo interactivo con Drag & Drop y selector de color."""
[docs]
def __init__(self, wl_min, wl_max, default_wl, filenames, parent=None):
super().__init__(parent)
self.setWindowTitle("Kinetics Comparison Setup")
self.resize(450, 420)
# Intentamos usar el estilo oscuro si está en el padre, si no, uno básico
style = getattr(parent, 'styleSheet', lambda: "")()
if style: self.setStyleSheet(style)
layout = QVBoxLayout(self)
form = QFormLayout()
# 1. Parámetros básicos
self.wl_input = QLineEdit(str(default_wl))
form.addRow(f"Wavelength (nm) [{wl_min:.1f} - {wl_max:.1f}]:", self.wl_input)
self.chk_norm = QCheckBox("Normalize to Max = 1")
self.chk_norm.setChecked(True)
form.addRow("", self.chk_norm)
self.title_input = QLineEdit("Kinetics Comparison")
form.addRow("Plot Title:", self.title_input)
layout.addLayout(form)
# Colores por defecto (Paleta "tab10" de Matplotlib)
self.default_colors = [
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'
]
# 2. Lista interactiva
layout.addWidget(QLabel("<b>Legend Labels & Order:</b><br/><i>(Drag to reorder, Double-click to edit)</i>"))
self.list_widget = QListWidget()
self.list_widget.setDragDropMode(QAbstractItemView.InternalMove)
self.list_widget.setSelectionMode(QAbstractItemView.SingleSelection)
self.list_widget.setAlternatingRowColors(True)
for original_idx, name in enumerate(filenames):
item = QListWidgetItem(name)
item.setFlags(item.flags() | Qt.ItemIsEditable | Qt.ItemIsDragEnabled | Qt.ItemIsSelectable)
# Guardamos el índice original
item.setData(Qt.UserRole, original_idx)
# Asignamos y guardamos el color
color_hex = self.default_colors[original_idx % len(self.default_colors)]
item.setData(Qt.UserRole + 1, color_hex)
item.setIcon(self._create_color_icon(color_hex))
self.list_widget.addItem(item)
layout.addWidget(self.list_widget)
# 3. Botón para cambiar el color
btn_color = QPushButton("🎨 Change Selected Color")
btn_color.clicked.connect(self.change_item_color)
layout.addWidget(btn_color)
# 4. Botones de acción principales
btns = QHBoxLayout()
btn_cancel = QPushButton("Cancel")
btn_cancel.clicked.connect(self.reject)
btn_ok = QPushButton("Plot")
btn_ok.clicked.connect(self.accept)
btns.addWidget(btn_cancel)
btns.addWidget(btn_ok)
layout.addLayout(btns)
def _create_color_icon(self, color_hex):
"""Dibuja un pequeño cuadrado de color para ponerlo junto al nombre."""
pixmap = QPixmap(16, 16)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setBrush(QColor(color_hex))
painter.setPen(QColor(0, 0, 0)) # Borde negro
painter.drawRect(0, 0, 15, 15)
painter.end()
return QIcon(pixmap)
[docs]
def change_item_color(self):
"""Abre la paleta de colores y actualiza el item seleccionado."""
selected_items = self.list_widget.selectedItems()
if not selected_items:
return
item = selected_items[0]
current_color = item.data(Qt.UserRole + 1)
# Abrir el selector de color nativo
color = QColorDialog.getColor(QColor(current_color), self, "Select Curve Color")
if color.isValid():
new_hex = color.name() # Ej: '#ff0000'
item.setData(Qt.UserRole + 1, new_hex)
item.setIcon(self._create_color_icon(new_hex))
[docs]
def get_data(self):
"""Devuelve todos los datos, incluyendo la lista de colores final."""
try:
wl = float(self.wl_input.text())
except ValueError:
wl = None
custom_labels = []
ordered_indices = []
custom_colors = []
for row in range(self.list_widget.count()):
item = self.list_widget.item(row)
custom_labels.append(item.text())
ordered_indices.append(item.data(Qt.UserRole))
custom_colors.append(item.data(Qt.UserRole + 1)) # Extraemos el color de cada uno
return wl, self.chk_norm.isChecked(), self.title_input.text(), custom_labels, ordered_indices, custom_colors
[docs]
class GlobalFitPanel(QDialog):
"""
Global Fit Analysis Panel.
Provides a comprehensive UI for loading kinetic data, applying pre-processing steps,
setting up global fitting models (Parallel, Sequential, Oscillation), running SVD,
executing the fit pipeline, and exploring the results and residuals.
"""
[docs]
def __init__(self, parent=None):
"""Initializes the Global Fit Panel UI, variables, and layouts."""
super().__init__(parent)
self.setWindowTitle("Global Fit Analysis")
self.setWindowFlags(self.windowFlags() | Qt.WindowMinMaxButtonsHint)
# --- AUTO-AJUSTE Y CENTRADO INTELIGENTE ---
screen = QApplication.primaryScreen()
screen_geom = screen.availableGeometry()
# Calculamos un tamaño objetivo, asegurándonos de no exceder los márgenes de la pantalla
w_target = min(1200, int(screen_geom.width() * 0.85))
h_target = min(850, int(screen_geom.height() * 0.85))
self.resize(w_target, h_target)
self.setStyleSheet(DARK_THEME_STYLE + BUTTON_STYLE) # Apply Dark Theme
# Centrar la ventana en la pantalla actual de forma nativa
qr = self.frameGeometry()
cp = screen_geom.center()
qr.moveCenter(cp)
self.move(qr.topLeft())
# --- 1. Data Variables ---
self.parent_app = parent
self.data_c_list = [] # Lista para guardar los datos procesados
self.data_raw_list = [] # Lista para guardar las matrices crudas
self.TD_list = [] # Lista para los ejes de tiempo
self.WL_list = [] # Lista para los ejes de longitud de onda
self.filenames = [] # Nombres de los archivos para la leyenda
self.base_dir = None
if hasattr(parent, "save_dir") and parent.save_dir:
self.base_dir = parent.save_dir
elif hasattr(parent, "file_path") and parent.file_path:
base_name = os.path.splitext(os.path.basename(parent.file_path))[0]
self.base_dir = os.path.join(os.path.dirname(parent.file_path), f"{base_name}_Results")
os.makedirs(self.base_dir, exist_ok=True)
else:
self.base_dir = os.getcwd()
# --- 2. Fit Variables ---
self.numExp = 2
self.model_type = 'Parallel'
self.t0_choice = 'No'
self.tech = 'TAS'
self.yscale = 'linear'
# Placeholders for results
self.fit_result = None
self.fit_x = None
self.As = None
# Rest of the fit variables
self.fit_resid = None
self.fit_fitres = None
self.ci = None
self.errAs = None
self.t0s = None
self.errt0s = None
self.errtaus = None
self.ini = None
self.limi = None
self.lims = None
# --- 3. MAIN LAYOUT DESIGN ---
main_layout = QHBoxLayout()
# --- A. Left Panel (Sidebar) CON SCROLL ---
# 1. Creamos el área de Scroll
self.sidebar_scroll = QScrollArea()
self.sidebar_scroll.setFixedWidth(360) # Un poco más ancho para dejar espacio a la barra
self.sidebar_scroll.setWidgetResizable(True) # Esto hace que se adapte al tamaño
self.sidebar_scroll.setFrameShape(QScrollArea.NoFrame) # Sin borde feo
# 2. Creamos el Widget contenedor que irá DENTRO del scroll
self.sidebar_widget = QWidget()
self.sidebar_layout = QVBoxLayout(self.sidebar_widget)
self.sidebar_layout.setContentsMargins(5, 5, 5, 5)
# 3. Metemos el widget en el scroll y el scroll en el layout principal
self.sidebar_scroll.setWidget(self.sidebar_widget)
self._init_sidebar_ui() # Esto llenará el self.sidebar_layout como siempre
main_layout.addWidget(self.sidebar_scroll)
# --- B. Right Panel (Plots) ---
self.right_area = QWidget()
self.right_layout = QVBoxLayout(self.right_area)
self._init_plots_ui()
main_layout.addWidget(self.right_area)
self.setLayout(main_layout)
# --- IMPORTANT: INITIALIZE PLOTTING VARIABLES ---
self.pcm_exp = None
self.cbar_exp = None
self.pcm_fit = None
self.cbar_fit = None
self.pcm_resid = None
self.cbar_resid = None
def _init_sidebar_ui(self):
"""Sets up all the widgets of the left panel (controls and settings)."""
l = self.sidebar_layout
# --- Group 1: Data Loading ---
gb_load = QGroupBox("1. Data Source")
v_load = QVBoxLayout()
self.label_status = QLabel("No data loaded")
self.label_status.setStyleSheet("color: gray; font-style: italic; font-weight: bold;")
v_load.addWidget(self.label_status)
h_btns = QHBoxLayout()
self.btn_load = QPushButton("Load .npy")
self.btn_load.clicked.connect(self.load_data)
h_btns.addWidget(self.btn_load)
self.btn_parent = QPushButton("Use Parent Data")
self.btn_parent.clicked.connect(self.use_parent_data)
h_btns.addWidget(self.btn_parent)
v_load.addLayout(h_btns)
self.btn_compare = QPushButton("Compare Kinetics")
self.btn_compare.clicked.connect(self.compare_kinetics)
v_load.addWidget(self.btn_compare)
v_load.addWidget(QLabel("<b>Visualizing Dataset:</b>"))
self.combo_active_dataset = QComboBox()
self.combo_active_dataset.setView(QListView())
self.combo_active_dataset.currentIndexChanged.connect(self._on_active_dataset_changed)
v_load.addWidget(self.combo_active_dataset)
gb_load.setLayout(v_load)
l.addWidget(gb_load)
# --- Group 2: Pre-processing ---
gb_prep = QGroupBox("2. Pre-processing")
form_prep = QFormLayout()
# Baseline
self.spin_bl = QSpinBox()
self.spin_bl.setRange(0, 500)
self.spin_bl.setValue(0)
self.spin_bl.valueChanged.connect(self.apply_baseline_correction)
form_prep.addRow("Baseline Pts:", self.spin_bl)
# WL Ranges
self.spin_wl_min = QDoubleSpinBox(); self.spin_wl_min.setRange(0, 10000);
self.spin_wl_max = QDoubleSpinBox(); self.spin_wl_max.setRange(0, 10000);
self.spin_wl_max.setDecimals(6)
self.spin_wl_max.setSingleStep(0.5)
self.spin_wl_min.setDecimals(6)
self.spin_wl_min.setSingleStep(0.1)
form_prep.addRow("Min WL (nm):", self.spin_wl_min)
form_prep.addRow("Max WL (nm):", self.spin_wl_max)
self.line_exclude = QLineEdit()
self.line_exclude.setPlaceholderText("e.g. 490-540, 600-615")
self.line_exclude.editingFinished.connect(self._preview_data_processing)
form_prep.addRow("Exclude WLs:", self.line_exclude)
# Time Ranges
self.spin_t_min = QDoubleSpinBox(); self.spin_t_min.setRange(-100, 1e6); self.spin_t_min.setDecimals(3)
self.spin_t_max = QDoubleSpinBox(); self.spin_t_max.setRange(-100, 1e6); self.spin_t_max.setDecimals(3)
form_prep.addRow("Min Time (ps):", self.spin_t_min)
form_prep.addRow("Max Time (ps):", self.spin_t_max)
# Binning
self.spin_bin = QSpinBox()
self.spin_bin.setRange(1, 50)
self.spin_bin.setValue(1)
form_prep.addRow("Binning:", self.spin_bin)
self.chk_zero_neg = QCheckBox("Set t < 0 to zero (background)")
self.chk_zero_neg.setChecked(False)
form_prep.addRow(self.chk_zero_neg)
# Preview Button
self.btn_preview = QPushButton("Apply and Preview")
self.btn_preview.clicked.connect(self._preview_data_processing)
form_prep.addRow(self.btn_preview)
gb_prep.setLayout(form_prep)
l.addWidget(gb_prep)
# --- Group 3: Model Settings ---
gb_model = QGroupBox("3. Model Settings")
form_model = QFormLayout()
self.btn_svd = QPushButton("Run SVD Analysis")
self.btn_svd.clicked.connect(self.run_svd)
form_model.addRow(self.btn_svd)
gb_vis = QGroupBox("4. Visualization")
form_vis = QFormLayout()
self.btn_plot_3d = QPushButton("3D Map")
self.btn_plot_3d.clicked.connect(self.plot_3d_surface)
form_vis.addRow(self.btn_plot_3d)
self.combo_scale = QComboBox()
self.combo_scale.setView(QListView())
self.combo_scale.setMaxVisibleItems(10)
self.combo_scale.addItems(["Linear", "SymLog"])
self.combo_scale.currentTextChanged.connect(self._on_scale_changed) # Connect function
form_vis.addRow("Time Axis Scale:", self.combo_scale)
gb_vis.setLayout(form_vis)
l.addWidget(gb_vis)
# Num Exponentials
self.spin_numExp = QSpinBox()
self.spin_numExp.setRange(1, 6)
self.spin_numExp.setValue(2)
form_model.addRow("Components:", self.spin_numExp)
# Model type
self.combo_model = QComboBox()
self.combo_model.setView(QListView())
self.combo_model.setMaxVisibleItems(10)
self.combo_model.addItems(["Parallel (DAS)", "Sequential (SAS)", "Damped Oscillation"])
form_model.addRow("Model Type:", self.combo_model)
# Technique
self.combo_tech = QComboBox()
self.combo_tech.setView(QListView())
self.combo_tech.setMaxVisibleItems(10)
self.combo_tech.addItems(["FLUPS", "TAS", "TCSPC"])
form_model.addRow("Technique:", self.combo_tech)
# Chirp
self.chk_chirp = QCheckBox("Fit Independent t0 (Chirp)")
form_model.addRow(self.chk_chirp)
self.chk_artifact = QCheckBox("Model Coherent Artifact (XPM/Raman)")
form_model.addRow(self.chk_artifact)
# ---Checkbox para Forzar Positivos ---
self.chk_nnls = QCheckBox("Force Positive Spectra (NNLS)")
form_model.addRow(self.chk_nnls)
# Initial Guesses
self.btn_edit_guess = QPushButton("Edit Initial Guesses")
self.btn_edit_guess.clicked.connect(self._open_guess_editor_and_update)
form_model.addRow(self.btn_edit_guess)
gb_model.setLayout(form_model)
l.addWidget(gb_model)
self.btn_run = QPushButton("RUN FIT")
self.btn_preview = QPushButton("Apply and Preview")
self.btn_run.setFixedHeight(40)
self.btn_run.setEnabled(False)
self.btn_run.clicked.connect(self.run_fit_pipeline)
l.addWidget(self.btn_run)
self.btn_batch = QPushButton("RUN BATCH FIT (All Files)")
self.btn_batch.setFixedHeight(40)
self.btn_batch.setEnabled(False)
self.btn_batch.setStyleSheet("background-color: #0078D7; color: white; font-weight: bold;") # Azul para diferenciarlo
self.btn_batch.clicked.connect(self.run_batch_pipeline)
l.addWidget(self.btn_batch)
self.btn_show_das = QPushButton("Show Plots / Results")
self.btn_show_das.setEnabled(False)
self.btn_show_das.clicked.connect(self.plot_das_and_more)
l.addWidget(self.btn_show_das)
l.addStretch()
[docs]
def run_svd(self):
"""Executes Singular Value Decomposition (SVD) on the active dataset to identify components."""
if self.data_c is None:
QMessageBox.warning(self, "Error", "Load the data before trying to run SVD analysis.")
return
# 1. Run SVD
# data_c must be [WL x TD]
try:
U, s, Vh = np.linalg.svd(self.data_c, full_matrices=False)
self.svd_U = U # Spectral vectors (species)
self.svd_s = s # Weight of the species
self.svd_V = Vh.T # Temporal vectors (kinetics)
self._plot_svd_results()
self.tabs.setCurrentWidget(self.tab_svd)
except Exception as e:
print(f"SVD Error: {e}")
def _create_svd_canvas(self, tab_widget):
"""
Creates and embeds the matplotlib canvas for the SVD tab.
Args:
tab_widget (QWidget): The tab container.
Returns:
tuple: (canvas, (ax1, ax2))
"""
fig = plt.Figure(figsize=(5, 8))
# ax1: Scree Plot, ax2: First spectral components
ax1 = fig.add_subplot(211)
ax2 = fig.add_subplot(212)
canvas = FigureCanvas(fig)
layout = QVBoxLayout()
layout.addWidget(canvas)
tab_widget.setLayout(layout)
return canvas, (ax1, ax2)
def _plot_svd_results(self):
"""Plots the singular values (Scree Plot) and principal spectral components."""
ax1, ax2 = self.ax_svd
ax1.clear()
ax2.clear()
# --- Plot 1: Scree Plot (Log scale) ---
n_comp = min(len(self.svd_s), 10) # See top 10
ax1.semilogy(range(1, n_comp + 1), self.svd_s[:n_comp], 'o-', color='red')
ax1.set_title("Singular Values (Scree Plot)")
ax1.set_ylabel("Eigenvalue (log)")
ax1.set_xlabel("Component Number")
ax1.grid(True, which="both", ls="-", alpha=0.2)
# 2. Spectral components
wl = getattr(self, '_wl_proc', self.WL)
n_mostrar = self.spin_numExp.value()
for i in range(min(n_mostrar, len(self.svd_s))):
ax2.plot(wl, self.svd_U[:, i], label=f"Comp {i+1}")
ax2.set_title(f"First {n_mostrar} Spectral Components")
ax2.set_xlabel("Energy / Wavelength")
ax2.axhline(0, color='black', lw=1, alpha=0.5)
ax2.legend(frameon=True)
self.canvas_svd.draw()
def _on_scale_changed(self, text):
"""
Updates the scale parameter and replots the data canvases.
Args:
text (str): The selected scale ('Linear' or 'SymLog').
"""
self.yscale = text.lower() # 'linear' or 'symlog'
self._update_exp_canvas()
self._update_fit_canvas()
self._update_resid_canvas()
def _init_plots_ui(self):
"""Builds the right side widgets comprising the tabbed plotting areas."""
l = self.right_layout
self.lbl_cursor = QLabel("Cursor: Out of the 2D map")
self.lbl_cursor.setStyleSheet("font-weight: bold; color: #0078D7; font-size: 10pt;")
l.addWidget(self.lbl_cursor)
# Tabs
self.tabs = QTabWidget()
self.tabs.setStyleSheet("""
QTabWidget::pane {
border: 1px solid #999;
background: white;
}
QTabBar::tab {
background: #e0e0e0;
color: black;
padding: 8px 20px;
border: 1px solid #bbb;
border-bottom: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
margin-right: 2px;
}
QTabBar::tab:selected {
background: #ffffff;
border-bottom: 1px solid #ffffff;
}
QTabBar::tab:hover {
background: #d0d0d0;
}
""")
self.tab_exp = QWidget()
self.tab_fit = QWidget()
self.tab_resid = QWidget()
self.tab_svd = QWidget()
self.tabs.addTab(self.tab_exp, "Experimental")
self.tabs.addTab(self.tab_fit, "Fit Reconstructed")
self.tabs.addTab(self.tab_resid, "Residuals")
self.tabs.addTab(self.tab_svd, "SVD Diagnosis")
# Create Canvas
self.canvas_exp, self.ax_exp = self._create_canvas_for_tab(self.tab_exp)
self.canvas_fit, self.ax_fit = self._create_canvas_for_tab(self.tab_fit)
self.canvas_resid, self.ax_resid = self._create_canvas_for_tab(self.tab_resid)
self.canvas_svd, self.ax_svd = self._create_svd_canvas(self.tab_svd)
l.addWidget(self.tabs)
# Progress bar
self.progress_bar = QProgressBar()
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(True)
l.addWidget(self.progress_bar)
[docs]
def plot_3d_surface(self):
"""Plots the 3D surface representation of the current data matrix."""
if self.data_c is None:
QMessageBox.warning(self, "Sin datos", "Aplica 'Preview' antes de ver el 3D.")
return
# Take the actual data
xs = getattr(self, '_wl_proc', self.WL)
ys = getattr(self, '_td_proc', self.TD)
zs = self.data_c
scale = getattr(self, 'yscale', 'linear')
# Create the window associated with the 3D plot
self.pop_3d = Surface3DWindow(xs, ys, zs, scale, parent=self)
self.pop_3d.show()
def _generate_defaults(self):
"""
Generates the initial parameter guesses based on the chosen experimental technique and model.
Returns:
bool: True if defaults were generated successfully, False otherwise.
"""
numExp = self.spin_numExp.value()
t0_choice = 'Yes' if self.chk_chirp.isChecked() else 'No'
tech = self.combo_tech.currentText()
model_str = self.combo_model.currentText()
is_oscillation = "Oscillation" in model_str
if self.data_c is not None:
numWL = self.data_c.shape[0]
elif self.WL is not None:
numWL = len(self.WL)
else:
QMessageBox.warning(self, "Warning", "Load data first to generate guesses.")
return False
if is_oscillation:
L = (2 + numExp + 3) + numWL * (numExp + 1)
elif t0_choice == 'Yes':
L = 1 + numExp + numWL*(numExp+1)
else:
L = 2 + numExp + numWL*numExp
self.ini = np.zeros(L)
self.limi = -np.inf * np.ones(L)
self.lims = np.inf * np.ones(L)
taus_defaults = [0.5, 5.0, 50.0, 500.0, 2000.0, 5000.0]
w_guess = 0.15 if tech == 'TAS' else (0.3 if tech == 'FLUPS' else 0.1)
if is_oscillation:
self.ini[0] = w_guess; self.limi[0] = 0.05; self.lims[0] = 2.0
self.ini[1] = 0.0; self.limi[1] = -5.0; self.lims[1] = 5.0
base_tau = 2
for n in range(numExp):
val_t = taus_defaults[n] if n < len(taus_defaults) else 1000.0*(n+1)
self.ini[base_tau + n] = val_t
self.limi[base_tau + n] = 0.001
self.lims[base_tau + n] = 1e8
idx_osc = base_tau + numExp
self.ini[idx_osc] = 0.1; self.limi[idx_osc] = 0.0; self.lims[idx_osc] = 100.0
self.ini[idx_osc+1] = 1.0; self.limi[idx_osc+1] = 0.0; self.lims[idx_osc+1] = 500.0
self.ini[idx_osc+2] = 0.0; self.limi[idx_osc+2] = -np.pi; self.lims[idx_osc+2] = np.pi
start_local = idx_osc + 3
val_A = 1000.0 if tech == 'TCSPC' else (5.0 if tech == 'FLUPS' else 0.01)
self.ini[start_local:] = val_A
elif t0_choice == 'No':
self.ini[0] = w_guess; self.limi[0] = 0.05; self.lims[0] = 2.0
self.ini[1] = 0.0; self.limi[1] = -5.0; self.lims[1] = 5.0
base_tau = 2
for n in range(numExp):
self.ini[base_tau + n] = taus_defaults[n] if n < len(taus_defaults) else 1000.0*(n+1)
self.limi[base_tau + n] = 0.001; self.lims[base_tau + n] = 1e8
start_A = base_tau + numExp
val_A = 1000.0 if tech == 'TCSPC' else (5.0 if tech == 'FLUPS' else 0.01)
self.ini[start_A:] = val_A
else:
self.ini[0] = w_guess; self.limi[0] = 0.05; self.lims[0] = 2.0
for n in range(numExp):
self.ini[1+n] = taus_defaults[n] if n < len(taus_defaults) else 100.0
self.limi[1+n] = 0.001; self.lims[1+n] = 1e8
base_idx = 1 + numExp
params_per_wl = 1 + numExp
val_A = 1000.0 if tech == 'TCSPC' else 0.1
self.ini[base_idx:] = val_A
self.ini[base_idx::params_per_wl] = 0.0
self.limi[base_idx::params_per_wl] = -5.0
self.lims[base_idx::params_per_wl] = 5.0
return True
def _create_canvas_for_tab(self, tab_widget):
"""
Helper method to initialize a matplotlib canvas inside a specific tab.
Args:
tab_widget (QWidget): The tab container.
Returns:
tuple: (canvas, ax)
"""
fig = plt.Figure(figsize=(5,4))
ax = fig.add_subplot(111)
canvas = FigureCanvas(fig)
layout = QVBoxLayout()
layout.addWidget(canvas)
tab_widget.setLayout(layout)
return canvas, ax
# --- Auxiliary methods to improve the user experience ---
[docs]
def update_from_parent(self):
"""Updates internal data from the parent application if it exists."""
p = self.parent_app
if p is None: return
if getattr(p, "is_TAS_mode", False):
if hasattr(p, "data_corrected") and p.data_corrected is not None:
incoming_data = np.array(p.data_corrected, copy=True)
self.data_raw = incoming_data
self.WL = getattr(p, "WL", None)
self.TD = getattr(p, "TD", None)
self.apply_baseline_correction()
[docs]
def apply_baseline_correction(self):
"""Performs a baseline correction based on the spinbox value and replots the data."""
if self.data_raw is None:
return
n_pts = self.spin_bl.value()
temp_data = self.data_raw.copy()
if n_pts > 0:
if temp_data.shape[1] >= n_pts:
# Calculate the baseline (average of the first n columns of time)
# assuming a shape [NumWL, NumTD] or [NumTD,NumWL]
baseline = np.mean(temp_data[:, :n_pts], axis=1, keepdims=True)
temp_data = temp_data - baseline
else:
print("Warning: Not enough points for baseline.")
self.data_c = temp_data
self._update_exp_canvas()
def _update_ui_limits_from_data(self):
"""Updates the internal SpinBox ranges based on the currently loaded data limits."""
# Update wavelength limits if data exists
if self.WL is not None and len(self.WL) > 0:
self.spin_wl_min.setValue(np.min(self.WL))
self.spin_wl_max.setValue(np.max(self.WL))
# Update time/delay limits if data exists
if self.TD is not None and len(self.TD) > 0:
self.spin_t_min.setValue(np.min(self.TD))
self.spin_t_max.setValue(np.max(self.TD))
# Reset data_c to raw data upon loading and trigger plot
self.data_c = self.data_raw.copy()
# Immediately plot the raw data
self._update_exp_canvas(use_processed=False)
[docs]
def use_parent_data(self):
"""Loads data from the main application window (if it exists)."""
if self.parent_app is None: return
# Check if parent has corrected data available
if hasattr(self.parent_app, "data_corrected") and self.parent_app.data_corrected is not None:
self.data_raw = np.array(self.parent_app.data_corrected, copy=True)
self.WL = getattr(self.parent_app, "WL", None)
self.TD = getattr(self.parent_app, "TD", None)
# Detect experimental technique
if getattr(self.parent_app, "is_TAS_mode", False):
self.combo_tech.setCurrentText("TAS")
else:
self.combo_tech.setCurrentText("FLUPS")
# Refresh UI components and enable execution
self._update_ui_limits_from_data()
self.btn_run.setEnabled(True)
self.btn_batch.setEnabled(True)
self.label_status.setText(f"Loaded from Parent: {len(self.WL)} WL, {len(self.TD)} TD")
[docs]
def load_data(self):
"""Carga múltiples archivos .npy para compararlos."""
file_paths, _ = QFileDialog.getOpenFileNames(
self, "Select .npy files", "", "Numpy Files (*.npy)"
)
self.base_dir = os.path.dirname(file_paths[0])
if not file_paths:
return
for path in file_paths:
try:
# Cargamos el archivo .npy directamente usando numpy
# allow_pickle=True y .item() extraen el diccionario directamente
loaded_dict = np.load(path, allow_pickle=True).item()
# Extraemos las matrices usando las claves exactas de tu script original
raw_data = loaded_dict['data_c']
WL = loaded_dict['WL']
TD = loaded_dict['TD']
self.data_raw_list.append(raw_data.copy())
self.data_c_list.append(raw_data.copy()) # Por defecto igual a crudo
self.TD_list.append(TD)
self.WL_list.append(WL)
self.filenames.append(os.path.basename(path))
except Exception as e:
QMessageBox.critical(self, f"Error loading {os.path.basename(path)}", str(e))
# Sincronizamos la UI usando el primer archivo cargado como base
if self.WL_list:
self.WL = self.WL_list[0]
self.TD = self.TD_list[0]
self.data_raw = self.data_raw_list[0]
self.data_c = self.data_c_list[0]
self._update_ui_limits_from_data()
self.btn_run.setEnabled(True)
self.btn_batch.setEnabled(True)
# --- NUEVO: Poblar el combo box con los archivos cargados ---
self.combo_active_dataset.blockSignals(True) # Bloqueamos eventos temporales
self.combo_active_dataset.clear()
self.combo_active_dataset.addItems(self.filenames)
self.combo_active_dataset.setCurrentIndex(0)
self.combo_active_dataset.blockSignals(False)
self.label_status.setText(f"Loaded {len(self.filenames)} Files")
def _on_active_dataset_changed(self, index):
"""Cambia dinámicamente el dataset visible y carga sus ajustes si existen."""
if index < 0 or index >= len(self.data_c_list):
return
# 1. Actualizar los punteros a las matrices del archivo seleccionado
self.data_raw = self.data_raw_list[index]
self.data_c = self.data_c_list[index]
self.WL = self.WL_list[index]
self.TD = self.TD_list[index]
# Comprobar si ya pasaron por el pre-procesamiento (recortes/binning)
use_proc = False
if hasattr(self, 'wl_proc_list') and len(self.wl_proc_list) > index:
self._wl_proc = self.wl_proc_list[index]
self._td_proc = self.td_proc_list[index]
use_proc = True
# 2. Re-dibujar el mapa experimental correspondiente
self._update_exp_canvas(use_processed=use_proc)
# 3. Lógica inteligente: Intentar buscar si este archivo ya tiene un ajuste guardado
filename = self.filenames[index]
base_name = os.path.splitext(filename)[0]
# Ruta donde el Batch guarda el ajuste de esta molécula específica
batch_fit_file = os.path.join(self.base_dir, "Batch_Results", base_name, "GFitResults.npy")
# Ruta del ajuste único estándar
standard_fit_file = os.path.join(self.base_dir, "fit", "GFitResults.npy")
fit_path = None
if os.path.exists(batch_fit_file):
fit_path = batch_fit_file
elif index == 0 and os.path.exists(standard_fit_file):
fit_path = standard_fit_file
if fit_path and os.path.exists(fit_path):
try:
# Cargamos el Fit histórico de esa molécula
fit_data = np.load(fit_path, allow_pickle=True).item()
self.fit_fitres = fit_data["fitres"]
self.fit_resid = fit_data["resid"]
self.extracted_taus = fit_data["taus"]
self.extracted_errtaus = fit_data["err_taus"]
self.As = fit_data["As"]
self.errAs = fit_data["errAs"]
self.numExp = len(self.extracted_taus)
# Sincronizamos el spinbox de componentes por si acaso varió
self.spin_numExp.setValue(self.numExp)
# Re-dibujamos las pestañas de ajuste y residuales con los datos correctos
self._update_fit_canvas()
self._update_resid_canvas()
self.btn_show_das.setEnabled(True)
except Exception as e:
print(f"Error cargando el Fit de {base_name}: {e}")
self._clear_fit_plots()
else:
# Si la molécula seleccionada aún no se ha ajustado, limpiamos las pantallas del Fit
self._clear_fit_plots()
def _clear_fit_plots(self):
"""Limpia los lienzos de Fit y Residuales si el archivo actual no está ajustado."""
self.fit_fitres = None
self.fit_resid = None
self.As = None
self.ax_fit.clear()
self.ax_resid.clear()
self._clear_colorbar_if_exists(self.cbar_fit)
self._clear_colorbar_if_exists(self.cbar_resid)
self.canvas_fit.draw_idle()
self.canvas_resid.draw_idle()
self.btn_show_das.setEnabled(False)
def _clear_colorbar_if_exists(self, cbar):
"""
Removes the specified colorbar from the plot if it exists.
Args:
cbar: The colorbar object to remove.
"""
try:
if cbar is not None:
cbar.remove()
except Exception:
# Silently fail if the colorbar cannot be removed (e.g., already deleted)
pass
[docs]
def compare_kinetics(self):
"""Compara cinéticas de múltiples archivos para una lambda específica con personalización total."""
if not getattr(self, 'data_c_list', None):
QMessageBox.warning(self, "No data", "Load multiple .npy files first.")
return
use_proc = hasattr(self, 'wl_proc_list') and len(self.wl_proc_list) == len(self.data_c_list)
wl_base = self.wl_proc_list[0] if use_proc else self.WL_list[0]
text_default = f"{wl_base[len(wl_base)//2]:.1f}"
dlg = CompareSetupDialog(wl_base.min(), wl_base.max(), text_default, self.filenames, self)
if dlg.exec_() != QDialog.Accepted:
return
# ===> Extraemos el nuevo array custom_colors <===
target_wl, normalize, custom_title, custom_labels, ordered_indices, custom_colors = dlg.get_data()
if target_wl is None:
QMessageBox.critical(self, "Input Error", "Please enter a valid numeric wavelength.")
return
try:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(9, 6))
title_str = custom_title
if normalize and "Norm" not in title_str:
title_str += f" (Norm) @ ~{target_wl:.1f} nm"
elif "nm" not in title_str:
title_str += f" @ ~{target_wl:.1f} nm"
ax.set_title(title_str, fontsize=14)
# ===> Aplicamos los colores personalizados <===
for ui_idx, original_idx in enumerate(ordered_indices):
wl_array = self.wl_proc_list[original_idx] if use_proc else self.WL_list[original_idx]
td_array = self.td_proc_list[original_idx] if use_proc else self.TD_list[original_idx]
data_matrix = self.data_c_list[original_idx]
name = custom_labels[ui_idx]
plot_color = custom_colors[ui_idx]
idx = np.argmin(np.abs(wl_array - target_wl))
y_exp = data_matrix[idx, :].copy()
if normalize:
max_val = np.nanmax(np.abs(y_exp))
if max_val != 0:
y_exp = y_exp / max_val
# Le pasamos el parámetro color a matplotlib
ax.plot(td_array, y_exp, 'o-', markersize=4, alpha=0.8, color=plot_color, label=name)
ax.set_xscale('symlog', linthresh=1.0)
ax.set_xlabel("Time / ps (symlog scale)")
ax.set_ylabel("Norm. ΔA" if normalize else "ΔA")
ax.grid(True, which="both", ls="-", alpha=0.3)
ax.legend(frameon=True)
plt.tight_layout()
plt.show()
except Exception as e:
QMessageBox.critical(self, "Plot Error", f"Failed to plot comparison: {e}")
def _preview_data_processing(self):
"""
Procesa los datos crudos para TODOS los archivos cargados aplicando:
Baseline -> Wavelength Crop -> Time Crop -> Binning.
"""
if getattr(self, 'data_raw_list', None) is None or not self.data_raw_list:
if self.data_raw is None: return
raw_list = [self.data_raw]
wl_list = [self.WL]
td_list = [self.TD]
else:
raw_list = self.data_raw_list
wl_list = self.WL_list
td_list = self.TD_list
self.data_c_list = []
self.wl_proc_list = []
self.td_proc_list = []
# Aplicar el procesamiento a cada archivo cargado
for idx in range(len(raw_list)):
temp_data = raw_list[idx].copy()
temp_WL = wl_list[idx].copy()
temp_TD = td_list[idx].copy()
# 1. Baseline Correction
n_pts = self.spin_bl.value()
if n_pts > 0 and temp_data.shape[1] >= n_pts:
baseline = np.mean(temp_data[:, :n_pts], axis=1, keepdims=True)
temp_data = temp_data - baseline
# 2. Wavelength Cropping
w_min = self.spin_wl_min.value()
w_max = self.spin_wl_max.value()
mask_w = (temp_WL >= min(w_min, w_max)) & (temp_WL <= max(w_min, w_max))
# Exclusiones específicas
if hasattr(self, 'line_exclude'):
exclude_str = self.line_exclude.text().strip()
if exclude_str:
mask_exclude = np.zeros_like(temp_WL, dtype=bool)
ranges = exclude_str.split(',')
for r in ranges:
try:
parts = r.split('-')
if len(parts) == 2:
c_min = float(parts[0].strip())
c_max = float(parts[1].strip())
mask_exclude |= (temp_WL >= min(c_min, c_max)) & (temp_WL <= max(c_min, c_max))
except ValueError:
pass
mask_w &= (~mask_exclude)
if np.any(mask_w):
temp_data = temp_data[mask_w, :]
temp_WL = temp_WL[mask_w]
# 3. Time Cropping
t_min = self.spin_t_min.value()
t_max = self.spin_t_max.value()
mask_t = (temp_TD >= min(t_min, t_max)) & (temp_TD <= max(t_min, t_max))
if np.any(mask_t):
temp_data = temp_data[:, mask_t]
temp_TD = temp_TD[mask_t]
if hasattr(self, 'chk_zero_neg') and self.chk_zero_neg.isChecked():
mask_neg = temp_TD < 0
if np.any(mask_neg):
temp_data[:, mask_neg] = 0.0
# 4. Binning
b_size = self.spin_bin.value()
if b_size > 1:
n_wl = temp_data.shape[0]
new_len = n_wl // b_size
if new_len > 0:
temp_data = temp_data[:new_len*b_size, :]
temp_data = temp_data.reshape(new_len, b_size, temp_data.shape[1]).mean(axis=1)
temp_WL = temp_WL[:new_len*b_size]
temp_WL = temp_WL.reshape(new_len, b_size).mean(axis=1)
# Guardar el resultado procesado
self.data_c_list.append(temp_data)
self.wl_proc_list.append(temp_WL)
self.td_proc_list.append(temp_TD)
# Actualizar variables del modelo global usando el primer archivo
if self.data_c_list:
self.data_c = self.data_c_list[0]
self._wl_proc = self.wl_proc_list[0]
self._td_proc = self.td_proc_list[0]
self._update_exp_canvas(use_processed=True)
self.label_status.setText(f"Processed {len(self.data_c_list)} files")
try:
outdir = os.path.join(self.base_dir, "Plots")
os.makedirs(outdir, exist_ok=True)
# Iteramos sobre todos los datasets que se acaban de procesar
for i in range(len(self.data_c_list)):
z_data = self.data_c_list[i]
x_data = self.wl_proc_list[i]
y_data = self.td_proc_list[i]
# Extraer el nombre original sin la extensión .npy
if hasattr(self, 'filenames') and i < len(self.filenames):
base_name = os.path.splitext(self.filenames[i])[0]
else:
base_name = f"Dataset_{i+1}"
# 1. Crear figura "desconectada" de la GUI para evitar pantallazos
fig_temp = Figure(figsize=(6, 4))
canvas_temp = FigureCanvasAgg(fig_temp) # Motor de renderizado en segundo plano
ax_temp = fig_temp.add_subplot(111)
# 2. Calcular límites reales para no cortar la señal
vmin_val = np.nanmin(z_data)
vmax_val = np.nanmax(z_data)
# 3. Dibujar el mapa
pcm = ax_temp.pcolormesh(x_data, y_data, z_data.T,
shading='auto', cmap='jet',
vmin=vmin_val, vmax=vmax_val)
ax_temp.set_title(f"Processed: {base_name}", fontsize=10)
ax_temp.set_xlabel("Energy / Wavelength")
ax_temp.set_ylabel("Delay (ps)")
# Aplicar escala (SymLog o Lineal) según la interfaz
if hasattr(self, 'yscale') and self.yscale == 'symlog':
ax_temp.set_yscale('symlog', linthresh=2)
else:
ax_temp.set_yscale('linear')
fig_temp.colorbar(pcm, ax=ax_temp, label='$\Delta A$ / -')
fig_temp.tight_layout()
# 4. Guardar imagen (Ya no hace falta plt.close() porque no usa pyplot)
filepath = os.path.join(outdir, f"Map_Processed_{base_name}.png")
fig_temp.savefig(filepath, dpi=300)
print(f"Éxito: Se han exportado {len(self.data_c_list)} mapas procesados a la carpeta /Plots/")
except Exception as e:
print(f"Error durante el guardado masivo de los mapas procesados: {e}")
def _update_exp_canvas(self, use_processed=False):
"""
Plots the experimental map on the first tab canvas,
with support for dynamic scaling and Linear/SymLog axes.
Args:
use_processed (bool, optional): If True, plots processed data. Defaults to False.
"""
if self.data_c is None: return
self.ax_exp.clear()
self._clear_colorbar_if_exists(self.cbar_exp)
# Select which axes to use based on data processing state
if use_processed and hasattr(self, '_wl_proc'):
Xs = self._wl_proc
Ys = self._td_proc
Title = "Experimental (Processed)"
else:
Xs = self.WL
Ys = self.TD
Title = "Experimental (Raw)"
# Axis protection: Fallback to index-based axes if shapes mismatch
if Xs.shape[0] != self.data_c.shape[0] or Ys.shape[0] != self.data_c.shape[1]:
Xs = np.arange(self.data_c.shape[0])
Ys = np.arange(self.data_c.shape[1])
try:
# Reemplaza la línea antigua de np.percentile por esto:
# Usamos el mínimo y máximo real para que no se corte la escala al normalizar
self.exp_vmin = np.nanmin(self.data_c)
self.exp_vmax = np.nanmax(self.data_c)
# Render the 2D map
self.pcm_exp = self.ax_exp.pcolormesh(Xs, Ys, self.data_c.T,
shading="auto", cmap='jet',
vmin=self.exp_vmin, vmax=self.exp_vmax)
# Set axis labels
self.ax_exp.set_xlabel("Energy / Wavelength")
self.ax_exp.set_ylabel("Delay (ps)")
# --- APPLY Y-AXIS SCALE (CONDITIONAL) ---
if hasattr(self, 'yscale') and self.yscale == 'symlog':
self.ax_exp.set_yscale('symlog', linthresh=2)
else:
self.ax_exp.set_yscale('linear')
# Colorbar management using Axes Divider for proper alignment
divider = make_axes_locatable(self.ax_exp)
cax = divider.append_axes("right", size="5%", pad=0.05)
self.cbar_exp = self.canvas_exp.figure.colorbar(self.pcm_exp, cax=cax, label='$\Delta A$ / -')
self.canvas_exp.draw_idle()
# Initialize interactive cursor (white dashed lines)
self.cursor_exp = Cursor(self.ax_exp, useblit=True, color='white', linewidth=1, linestyle='--')
# Connect mouse motion event to the handler if not already connected
if not hasattr(self, 'cid_mouse_move'):
self.cid_mouse_move = self.canvas_exp.mpl_connect('motion_notify_event', self.on_mouse_move)
except Exception as e:
print(f"Plotting error: {e}")
[docs]
def on_mouse_move(self, event):
"""Updates the status label with real-time mouse coordinates and data values on the map."""
# Ensure the mouse is within the main plot area
if event.inaxes == self.ax_exp:
x = event.xdata
y = event.ydata
if x is None or y is None:
return
# Attempt to retrieve the specific ΔA value at the cursor position
if self.data_c is not None and hasattr(self, '_wl_proc') and hasattr(self, '_td_proc'):
try:
# Find the closest indices using absolute difference (nearest neighbor)
idx_wl = (np.abs(self._wl_proc - x)).argmin()
idx_td = (np.abs(self._td_proc - y)).argmin()
z_val = self.data_c[idx_wl, idx_td]
self.lbl_cursor.setText(f"Cursor: λ = {x:.1f} nm | Delay = {y:.3f} ps | ΔA = {z_val:.3e}")
except Exception:
# Fallback if there is a temporary mismatch in array indexing
self.lbl_cursor.setText(f"Cursor: λ = {x:.1f} nm | Delay = {y:.3f} ps")
else:
self.lbl_cursor.setText(f"Cursor: λ = {x:.1f} nm | Delay = {y:.3f} ps")
else:
# Clear or notify when the cursor leaves the plot boundaries
self.lbl_cursor.setText("Cursor: Out of the 2D MAP")
def _update_fit_canvas(self):
"""Plots the reconstructed fit map onto the appropriate tab canvas."""
if self.fit_fitres is None: return
self.ax_fit.clear()
self._clear_colorbar_if_exists(self.cbar_fit)
# Use processed axes if available, fallback to raw WL/TD
Xs = getattr(self, '_wl_proc', self.WL)
Ys = getattr(self, '_td_proc', self.TD)
Z = self.fit_fitres.T
# Dimension safety check: Fallback to index-based ranges if shapes don't match data
if Xs is None or Xs.shape[0] != Z.shape[1]: Xs = np.arange(Z.shape[1])
if Ys is None or Ys.shape[0] != Z.shape[0]: Ys = np.arange(Z.shape[0])
try:
if Z.shape[0] < 2 or Z.shape[1] < 2: return
# Forzamos los mismos límites de color (Z) que el Experimental
vmin = getattr(self, 'exp_vmin', np.nanmin(Z))
vmax = getattr(self, 'exp_vmax', np.nanmax(Z))
self.pcm_fit = self.ax_fit.pcolormesh(Xs, Ys, Z, shading='auto', cmap='jet',
vmin=vmin, vmax=vmax)
self.ax_fit.set_title("Fit Reconstructed")
self.ax_fit.set_xlabel("Energy / Wavelength")
self.ax_fit.set_ylabel("Delay (ps)")
# --- APPLY Y-AXIS SCALE (CONDITIONAL) ---
if hasattr(self, 'yscale') and self.yscale == 'symlog':
self.ax_fit.set_yscale('symlog', linthresh=2)
else:
self.ax_fit.set_yscale('linear')
# Sincronizar límites del eje Y exactamente con el Experimental
if hasattr(self, 'ax_exp'):
self.ax_fit.set_ylim(self.ax_exp.get_ylim())
self.ax_fit.set_xlim(self.ax_exp.get_xlim())
# Align colorbar using Axes Divider to match the plot height
divider = make_axes_locatable(self.ax_fit)
cax = divider.append_axes("right", size="5%", pad=0.05)
self.cbar_fit = self.canvas_fit.figure.colorbar(self.pcm_fit, cax=cax, label='Transient absorption / -')
self.canvas_fit.draw()
except Exception as e:
print(f"Error painting Fit: {e}")
def _update_resid_canvas(self):
"""Plots the residuals map (Difference between Experimental and Fit) onto the canvas."""
if self.fit_resid is None: return
self.ax_resid.clear()
self._clear_colorbar_if_exists(self.cbar_resid)
# Select processed axes if available; otherwise, use raw data axes
Xs = getattr(self, '_wl_proc', self.WL)
Ys = getattr(self, '_td_proc', self.TD)
Z = self.fit_resid.T
# Axis consistency check: Revert to indices if coordinates mismatch data shape
if Xs is None or Xs.shape[0] != Z.shape[1]: Xs = np.arange(Z.shape[1])
if Ys is None or Ys.shape[0] != Z.shape[0]: Ys = np.arange(Z.shape[0])
try:
# Ensure the data array is valid for 2D plotting
if Z.shape[0] < 2 or Z.shape[1] < 2: return
# Dynamic contrast adjustment using the 1st and 99th percentiles
vals = Z.flatten()
vmin = np.percentile(vals, 1)
vmax = np.percentile(vals, 99)
self.pcm_resid = self.ax_resid.pcolormesh(Xs, Ys, Z, shading='auto', cmap='jet',
vmin=vmin, vmax=vmax)
self.ax_resid.set_title("Residuals")
self.ax_resid.set_xlabel("Energy / Wavelength")
self.ax_resid.set_ylabel("Delay (ps)")
# --- APPLY Y-AXIS SCALE (CONDITIONAL) ---
if hasattr(self, 'yscale') and self.yscale == 'symlog':
self.ax_resid.set_yscale('symlog', linthresh=2)
else:
self.ax_resid.set_yscale('linear')
if hasattr(self, 'ax_exp'):
self.ax_resid.set_ylim(self.ax_exp.get_ylim())
self.ax_resid.set_xlim(self.ax_exp.get_xlim())
# Create and align colorbar for the residuals plot
divider = make_axes_locatable(self.ax_resid)
cax = divider.append_axes("right", size="5%", pad=0.05)
self.cbar_resid = self.canvas_resid.figure.colorbar(self.pcm_resid, cax=cax, label='Residual')
self.canvas_resid.draw()
except Exception as e:
print(f"Error painting Resid: {e}")
# =============================================================================
# FIT PIPELINE
# =============================================================================
[docs]
def run_fit_pipeline(self):
"""Main execution pipeline: Preprocess, set model parameters, and run the optimization."""
try:
# 1. Validation: Ensure data is loaded
if self.data_raw is None:
QMessageBox.warning(self, "No data", "Load data first.")
return
# 2. Pre-processing: Apply crops, baseline, and binning
self._preview_data_processing()
if self.data_c is None or self.data_c.size == 0: return
# 3. Parameter setup from UI
self.numExp = self.spin_numExp.value()
self.tech = self.combo_tech.currentText()
self.t0_choice = 'Yes' if self.chk_chirp.isChecked() else 'No'
# Identify Kinetic Model type
model_str = self.combo_model.currentText()
if "Sequential" in model_str:
self.model_type = "Sequential"
elif "Oscillation" in model_str:
self.model_type = "Damped Oscillation"
else:
self.model_type = "Parallel"
if self.data_c is not None:
numWL = self.data_c.shape[0]
else:
numWL = 0
# 4. Determine parameter vector length (L_needed) based on model selection
# This logic ensures the initial guess vector matches the mathematical model
if self.model_type == "Damped Oscillation":
# Formula for Oscillation model parameters
if self.t0_choice == 'Yes':
L_needed = (2 + self.numExp + 3) + numWL * (self.numExp + 1)
else:
L_needed = (2 + self.numExp + 3) + numWL * (self.numExp + 1)
elif self.t0_choice == 'Yes':
# Formula with chirp/t0 correction
L_needed = 1 + self.numExp + numWL*(self.numExp+1)
else:
# Standard parallel/sequential formula
L_needed = 2 + self.numExp + numWL*self.numExp
# 5. Initialization check: Reset guesses if dimensions have changed
if self.ini is None or len(self.ini) != L_needed:
print(f"Size mismatch (Vector: {len(self.ini) if self.ini is not None else 0}, Needed: {L_needed}). Regenerating defaults...")
self._generate_defaults()
else:
print("Using existing guesses.")
# Store current axes for the fitting session
self._temp_fit_TD = getattr(self, '_td_proc', self.TD)
self._temp_fit_WL = getattr(self, '_wl_proc', self.WL)
# 6. Execute Optimization and Post-processing
self._run_least_squares_with_progress()
self._postprocess_fit_and_save()
except Exception as e:
# Error handling with full traceback for debugging
QMessageBox.critical(self, "Fit Error", str(e))
import traceback
traceback.print_exc()
[docs]
def run_batch_pipeline(self):
"""Ejecuta el ajuste para todos los archivos cargados de forma secuencial."""
if not getattr(self, 'data_c_list', None):
QMessageBox.warning(self, "No data", "Load multiple files first to run a batch fit.")
return
# 1. Aplicar pre-procesamiento si no se ha hecho
self._preview_data_processing()
# 2. Configuración inicial del modelo
self.numExp = self.spin_numExp.value()
self.tech = self.combo_tech.currentText()
self.t0_choice = 'Yes' if self.chk_chirp.isChecked() else 'No'
model_str = self.combo_model.currentText()
if "Sequential" in model_str: self.model_type = "Sequential"
elif "Oscillation" in model_str: self.model_type = "Damped Oscillation"
else: self.model_type = "Parallel"
# Asegurar que tenemos guesses iniciales
if self.ini is None:
self._generate_defaults()
# Guardar los guesses originales para reiniciar en cada iteración
original_ini = self.ini.copy()
# Carpeta maestra para el Batch
batch_outdir = os.path.join(self.base_dir, "Batch_Results")
os.makedirs(batch_outdir, exist_ok=True)
# Bandera para evitar que los popups bloqueen el bucle
self.is_batch_running = True
try:
for idx in range(len(self.data_c_list)):
filename = self.filenames[idx] if idx < len(self.filenames) else f"Dataset_{idx+1}"
base_name = os.path.splitext(filename)[0]
self.label_status.setText(f"Batch Fitting: {idx+1}/{len(self.data_c_list)} ({base_name})")
self.combo_active_dataset.blockSignals(True)
self.combo_active_dataset.setCurrentIndex(idx)
self.combo_active_dataset.blockSignals(False)
QApplication.processEvents()
# Cargar datos específicos de esta iteración
self.data_c = self.data_c_list[idx]
self._temp_fit_WL = self.wl_proc_list[idx] if hasattr(self, 'wl_proc_list') else self.WL_list[idx]
self._temp_fit_TD = self.td_proc_list[idx] if hasattr(self, 'td_proc_list') else self.TD_list[idx]
# Restaurar guesses y definir carpeta de salida única
self.ini = original_ini.copy()
self.current_batch_outdir = os.path.join(batch_outdir, base_name)
# Ejecutar el núcleo matemático
self._run_least_squares_with_progress()
self._postprocess_fit_and_save()
self.label_status.setText(f"Batch Completed: {len(self.data_c_list)} files.")
QMessageBox.information(self, "Batch Complete", f"Successfully fitted {len(self.data_c_list)} datasets.\nResults saved in: {batch_outdir}")
except Exception as e:
QMessageBox.critical(self, "Batch Error", f"Error during batch fit: {str(e)}")
finally:
self.is_batch_running = False
def _open_guess_editor_and_update(self):
"""Opens a dialog to manually edit initial guesses, bounds, and fixed parameters."""
numExp = self.spin_numExp.value()
is_chirp = self.chk_chirp.isChecked()
model_str = self.combo_model.currentText()
is_oscillation = "Oscillation" in model_str
# Determine number of wavelengths for parameter indexing
if self.data_c is not None: numWL = self.data_c.shape[0]
elif self.WL is not None: numWL = len(self.WL)
else: numWL = 1
# Calculate expected vector length based on the selected model
if is_oscillation:
L_needed = 2 + numExp + 3 + numWL * (numExp + 1)
elif is_chirp:
L_needed = 1 + numExp + numWL * (numExp + 1)
else:
L_needed = 2 + numExp + numWL * numExp
# Regenerate defaults if the vector size is inconsistent
if self.ini is None or len(self.ini) != L_needed:
self._generate_defaults()
L = len(self.ini)
dlg = QDialog(self)
dlg.setWindowTitle(f"Edit Initial Guesses - {model_str}")
dlg.resize(800, 600)
v = QVBoxLayout()
# Initialize Table Widget
table = QTableWidget(L, 5)
table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
table.setHorizontalHeaderLabels(["Parameter", "Value", "Lower Bound", "Upper Bound", "Fix?"])
if not hasattr(self, 'is_fixed') or len(self.is_fixed) != L:
self.is_fixed = np.zeros(L, dtype=bool)
for i in range(L):
label = f"{i}: "
# Labeling logic for Damped Oscillation model
if is_oscillation:
if i == 0: label += "w (IRF Width)"
elif i == 1: label += "t0 (Time Zero)"
elif i < 2 + numExp: label += f"τ{i-1} (Lifetime)"
elif i == 2 + numExp: label += "α (Damping/Decay)"
elif i == 2 + numExp + 1: label += "ω (Ang. Frequency)"
elif i == 2 + numExp + 2: label += "φ (Phase)"
else:
local_idx = i - (2 + numExp + 3)
wl_idx = local_idx // (numExp + 1)
p_idx = local_idx % (numExp + 1)
curr_wl = self._wl_proc[wl_idx] if hasattr(self, '_wl_proc') else wl_idx
if p_idx < numExp: label += f"A{p_idx+1} (Amp) @ {curr_wl:.1f}nm"
else: label += f"B (Osc. Amp) @ {curr_wl:.1f}nm"
# Labeling logic for standard (Parallel/Sequential) and Chirp models
else:
if not is_chirp:
if i == 0: label += "w (FWHM (ps))"
elif i == 1: label += "t0 (Time Zero)"
elif i < 2 + numExp: label += f"τ{i-1} (Lifetime)"
else:
local_idx = i - (2 + numExp)
wl_idx = local_idx // numExp
p_idx = local_idx % numExp
label += f"A{p_idx+1} @ WL {wl_idx}"
else:
if i == 0: label += "w (FWHM (ps) /2.355)"
elif i < 1 + numExp: label += f"τ{i} (Lifetime)"
else: label += "Local (t0 or Amp)"
# Populate table row
item_lbl = QTableWidgetItem(label)
item_lbl.setFlags(item_lbl.flags() ^ Qt.ItemIsEditable)
table.setItem(i, 0, item_lbl)
table.setItem(i, 1, QTableWidgetItem(str(self.ini[i])))
table.setItem(i, 2, QTableWidgetItem(str(self.limi[i])))
table.setItem(i, 3, QTableWidgetItem(str(self.lims[i])))
# Checkbox for fixing parameters during optimization
chk_item = QTableWidgetItem()
chk_item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
chk_item.setCheckState(Qt.Checked if self.is_fixed[i] else Qt.Unchecked)
table.setItem(i, 4, chk_item)
v.addWidget(table)
# Dialog Buttons
btns = QHBoxLayout()
btn_reset = QPushButton("Reset to Defaults")
# Recursive call to reopen with fresh defaults
btn_reset.clicked.connect(lambda: [self._generate_defaults(), dlg.accept(), self._open_guess_editor_and_update()])
btn_ok = QPushButton("Save & Close")
btn_ok.clicked.connect(dlg.accept)
btns.addWidget(btn_reset); btns.addWidget(btn_ok)
v.addLayout(btns)
dlg.setLayout(v)
# If user clicks "Save & Close", update internal values from table
if dlg.exec_() == QDialog.Accepted:
for i in range(L):
self.ini[i] = float(table.item(i, 1).text())
self.limi[i] = float(table.item(i, 2).text())
self.lims[i] = float(table.item(i, 3).text())
self.is_fixed[i] = (table.item(i, 4).checkState() == Qt.Checked)
def _run_least_squares_with_progress(self):
"""Executes the least squares optimization while updating the UI progress bar."""
TD = self._temp_fit_TD
WL = self._temp_fit_WL
numWL = len(WL)
data_flat = self.data_c.T.flatten()
# Ensure 'is_fixed' mask exists and matches parameter size
if not hasattr(self, 'is_fixed') or len(self.is_fixed) != len(self.ini):
self.is_fixed = np.zeros(len(self.ini), dtype=bool)
# Filter parameters: separate free (optimizable) parameters from fixed ones
free_indices = np.where(~self.is_fixed)[0]
x0_free = self.ini[free_indices]
low_free = self.limi[free_indices]
upp_free = self.lims[free_indices]
# --- PROGRESS BAR LOGIC ---
self.progress_bar.setValue(0)
self.progress_bar.setFormat("Iterating: %v")
self.iter_count = 0
def residuals(p_free):
"""Objective function for least_squares: returns the difference between model and data."""
self.iter_count += 1
if self.iter_count % 10 == 0:
val = (self.iter_count // 10) % 101
self.progress_bar.setValue(val)
QApplication.processEvents()
x_full = self.ini.copy()
x_full[free_indices] = p_free
# ==========================================
# 1. MOTOR VARPRO (Alta Velocidad y Precisión)
# ==========================================
if self.t0_choice == 'No':
use_art = self.chk_artifact.isChecked() # <--- LEEMOS LA UI
if self.model_type == "Sequential":
C = fit.get_concentration_matrix_sequential(x_full, TD, self.numExp, use_art)
elif self.model_type == 'Damped Oscillation':
C = fit.get_concentration_matrix_oscillation(x_full, TD, self.numExp, use_art)
else: # Parallel
C = fit.get_concentration_matrix_global(x_full, TD, self.numExp, use_art)
use_nnls = getattr(self, 'chk_nnls', None) and self.chk_nnls.isChecked()
F, _ = fit.eval_varpro_model(C, self.data_c.T, enforce_nonneg=use_nnls, numExp=self.numExp)
# ==========================================
# 2. MOTOR CLÁSICO (Para Chirp / t0 variable)
# ==========================================
else:
if self.model_type == "Sequential":
F = fit.eval_sequential_model(x_full, TD, self.numExp, numWL, self.t0_choice)
elif self.model_type == 'Damped Oscillation':
F = fit.eval_oscillation_model(x_full, TD, self.numExp, numWL, self.t0_choice)
else:
F = fit.eval_global_model(x_full, TD, self.numExp, numWL, self.t0_choice)
return F.flatten() - data_flat
try:
# Ejecutar el optimizador
res = least_squares(
fun=residuals,
x0=x0_free,
bounds=(low_free, upp_free),
method='trf',
x_scale='jac',
loss='soft_l1',
ftol=1e-8,
xtol=1e-8,
verbose=0
)
# Guardar resultados
self.fit_result = res
self.fit_x = self.ini.copy()
self.fit_x[free_indices] = res.x
# =========================================================
# EL "PUENTE MÁGICO": Reconstruir Amplitudes para la GUI
# =========================================================
if self.t0_choice == 'No':
# Recalculamos la matriz C con los parámetros finales óptimos
if self.model_type == "Sequential":
C = fit.get_concentration_matrix_sequential(self.fit_x, TD, self.numExp)
elif self.model_type == 'Damped Oscillation':
C = fit.get_concentration_matrix_oscillation(self.fit_x, TD, self.numExp)
else:
C = fit.get_concentration_matrix_global(self.fit_x, TD, self.numExp)
# Extraemos las amplitudes óptimas definitivas (S_T)
_, S_T = fit.eval_varpro_model(C, self.data_c.T)
# Calculamos dónde empiezan las amplitudes en el vector fit_x
A_base = 2 + self.numExp
if self.model_type == 'Damped Oscillation':
A_base += 3
# Inyectamos las amplitudes en formato plano.
# Tu código original extraía: all_Amps = x[A_base:].reshape(numWL, numExp).T
# La operación inversa exacta es S_T.T.flatten()
self.fit_x[A_base:] = S_T.T.flatten()
# Finalizar la barra de progreso
self.progress_bar.setValue(100)
self.progress_bar.setFormat("Fit Completed")
except Exception as e:
self.progress_bar.setValue(0)
raise e
def _postprocess_fit_and_save(self):
"""Calculates statistics, extracts spectra with errors, and saves files to the /fit/ directory."""
if self.fit_result is None:
return
x = self.fit_x
TD = getattr(self, '_temp_fit_TD', self.TD)
WL = getattr(self, '_temp_fit_WL', self.WL)
if TD is None or WL is None:
print("Error: No se encontraron los ejes (TD/WL) del ajuste.")
return
numWL = len(WL)
numExp = self.numExp
# --- 1. Reconstruct Fit Matrix and Residuals ---
use_art = getattr(self, 'chk_artifact', None) and self.chk_artifact.isChecked()
if self.t0_choice == 'No':
# Reconstrucción 100% exacta usando VarPro
if self.model_type == "Sequential":
C = fit.get_concentration_matrix_sequential(x, TD, numExp, use_art)
elif self.model_type == 'Damped Oscillation':
C = fit.get_concentration_matrix_oscillation(x, TD, numExp, use_art)
else:
C = fit.get_concentration_matrix_global(x, TD, numExp, use_art)
# Extraemos las amplitudes limpias directamente desde aquí
use_nnls = getattr(self, 'chk_nnls', None) and self.chk_nnls.isChecked()
F_mat, S_T_full = fit.eval_varpro_model(C, self.data_c.T, enforce_nonneg=use_nnls, numExp=numExp)
self.S_T_full = S_T_full
# Guardamos los DAS reales e ignoramos las amplitudes del artefacto para el plot
if "Oscillation" in self.model_type:
self.As = S_T_full[:numExp, :]
self.Bs = S_T_full[numExp, :]
else:
self.As = S_T_full[:numExp, :]
else:
# Reconstrucción Clásica (Chirp)
if self.model_type == "Sequential":
F_mat = fit.eval_sequential_model(x, TD, numExp, numWL, self.t0_choice)
elif self.model_type == 'Damped Oscillation':
F_mat = fit.eval_oscillation_model(x, TD, numExp, numWL, self.t0_choice)
else:
F_mat = fit.eval_global_model(x, TD, numExp, numWL, self.t0_choice)
fitres = F_mat.T if self.t0_choice == 'Yes' else F_mat.T
resid = self.data_c - fitres
self.fit_fitres = fitres
self.fit_resid = resid
# --- 2. Robust Error Calculation (Confidence Intervals) ---
L_total = len(x)
self.ci = np.zeros(L_total) # Default to 0
try:
# Ensure is_fixed mask exists and matches parameter count
if not hasattr(self, 'is_fixed') or len(self.is_fixed) != L_total:
self.is_fixed = np.zeros(L_total, dtype=bool)
free_indices = np.where(~self.is_fixed)[0]
J = self.fit_result.jac
# Proceed only if there are free parameters and a valid Jacobian
if J is not None and J.size > 0 and len(free_indices) > 0:
# -------------------------------------------------------------
# NUEVO: Cálculo de covarianza robusto mediante SVD directo
# -------------------------------------------------------------
U, s, Vh = np.linalg.svd(J, full_matrices=False)
# Tolerancia dinámica: filtra valores singulares demasiado pequeños
# que causarían divisiones por cero o inestabilidad numérica.
tol = np.finfo(float).eps * max(J.shape) * s[0]
s_inv = np.zeros_like(s)
s_inv[s > tol] = 1.0 / s[s > tol]
# Matriz de Covarianza: V * (1/S^2) * V^T
cov_free = (Vh.T * (s_inv**2)) @ Vh
# -------------------------------------------------------------
# Degrees of Freedom (DoF)
dof = resid.size - len(free_indices)
if dof > 0:
mse = np.sum(resid**2) / dof
# Parameter Variance = Diagonal of Covariance * MSE
var_free = np.diagonal(cov_free) * mse
# Prevent negative roots due to small numerical precision errors
err_free = np.sqrt(np.maximum(var_free, 0))
# Map calculated errors back to their respective positions
self.ci[free_indices] = err_free
else:
print("Warning: Degrees of freedom <= 0. Cannot compute errors.")
except Exception as e:
print(f"CRITICAL ERROR calculating covariance: {e}")
# --- 3. Extract Lifetimes (Taus) and their Errors ---
idx_tau = 1 if self.t0_choice == 'Yes' else 2
# Index protection in case of model changes
end_tau = idx_tau + numExp
if end_tau <= len(x):
self.extracted_taus = x[idx_tau : end_tau]
self.extracted_errtaus = self.ci[idx_tau : end_tau]
else:
self.extracted_taus = np.zeros(numExp)
self.extracted_errtaus = np.zeros(numExp)
# --- 4. Extract Amplitudes and their Errors ---
self.As = np.zeros((numExp, numWL))
self.errAs = np.zeros((numExp, numWL))
self.Bs = None # To store Oscillation Amplitude Spectrum
self.errBs = None
try:
if self.t0_choice == 'No':
if "Oscillation" in self.model_type:
# Structure: [w, t0, taus..., alpha, omega, phi, ...]
base_A = 2 + numExp + 3
params_per_wl = numExp + 1
# Extract all local params (A's + B)
all_local = x[base_A:]
all_local_err = self.ci[base_A:]
# Reshape to (numWL, params_per_wl)
mat_local = all_local.reshape(numWL, params_per_wl)
mat_err = all_local_err.reshape(numWL, params_per_wl)
# Separate A's (Decays) and B (Oscillation)
self.As = mat_local[:, :numExp].T
self.errAs = mat_err[:, :numExp].T
# The last column is B (Oscillation Amplitude)
self.Bs = mat_local[:, numExp]
self.errBs = mat_err[:, numExp]
self.t0s = np.full(numWL, x[1])
else:
# Standard Model: Extract amplitudes for each lifetime
base_A = 2 + numExp
self.As = x[base_A:].reshape(numWL, numExp).T
self.errAs = self.ci[base_A:].reshape(numWL, numExp).T
self.t0s = np.full(numWL, x[1])
else:
pass # Chirp-specific logic could be added here
except Exception as e:
print(f"Error extrayendo amplitudes: {e}")
# --- 5. Export Results ---
if getattr(self, 'is_batch_running', False) and hasattr(self, 'current_batch_outdir'):
outdir = self.current_batch_outdir
else:
outdir = os.path.join(self.base_dir, "fit")
os.makedirs(outdir, exist_ok=True)
try:
# Save full results as a binary NumPy file
np.save(os.path.join(outdir, "GFitResults.npy"), {
"taus": self.extracted_taus,
"err_taus": self.extracted_errtaus,
"As": self.As,
"errAs": self.errAs,
"WL": WL,
"TD": TD,
"fitres": fitres,
"resid": resid
})
# Export plain text axes for accessibility
np.savetxt(os.path.join(outdir, "WL.txt"), WL, fmt='%.6f', header="Wavelength (nm)")
np.savetxt(os.path.join(outdir, "TD.txt"), TD, fmt='%.6f', header="Time Delay (ps)")
# Write amplitudes and errors to a tab-separated text file
with open(os.path.join(outdir, "Amplitudes.txt"), 'w') as f:
header_list = [f"A{i+1}\tErrA{i+1}" for i in range(numExp)]
f.write("WL(nm)\t" + "\t".join(header_list) + "\n")
for i in range(numWL):
line_data = [f"{WL[i]:.2f}"]
for j in range(numExp):
val = self.As[j, i] if j < self.As.shape[0] else 0
err = self.errAs[j, i] if j < self.errAs.shape[0] else 0
line_data.append(f"{val:.6e}")
line_data.append(f"{err:.6e}")
f.write("\t".join(line_data) + "\n")
print(f"Results successfully exported to: {outdir}")
except Exception as e:
print(f"Error saving output files: {e}")
# Update visualization and summary
self._update_fit_canvas()
self._update_resid_canvas()
self.btn_show_das.setEnabled(True)
if not getattr(self, 'is_batch_running', False):
self.show_results_summary()
rmsd = np.sqrt(np.mean(resid**2))
QMessageBox.information(self, "Fit Complete",
f"Optimization finished successfully.\nRMSD: {rmsd:.2e}\nData saved in /fit/")
self.show_results_summary()
# Final completion notice with RMSD
rmsd = np.sqrt(np.mean(resid**2))
QMessageBox.information(self, "Fit Complete",
f"Optimization finished successfully.\nRMSD: {rmsd:.2e}\nData saved in /fit/")
[docs]
def show_results_summary(self):
"""Displays a popup window detailing the final global parameters derived from the fit."""
if self.fit_x is None: return
# Initialize the Summary Dialog
dlg = QDialog(self)
dlg.setWindowTitle("Fit Results Summary")
dlg.resize(400, 300)
layout = QVBoxLayout(dlg)
# Initialize the table to display parameters
table = QTableWidget()
layout.addWidget(table)
# Prepare data based on the kinetic model results
results = []
# Index 0: Instrument Response Function (IRF) width
results.append(["w (IRF)", f"{self.fit_x[0]:.4f}"])
# Index 1: Time zero offset
results.append(["t0", f"{self.fit_x[1]:.4f}"])
# Append extracted lifetimes (Taus) with their respective errors
for i in range(self.numExp):
val = self.extracted_taus[i]
error = self.extracted_errtaus[i] if self.extracted_errtaus is not None else 0.0
results.append([f"τ{i+1}", f"{val:.2f} ± {error:.2f} ps"])
# Configure table dimensions and headers
table.setRowCount(len(results))
table.setColumnCount(2)
table.setHorizontalHeaderLabels(["Parameter", "Final Value"])
table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
# Populate the table with results
for i, (name, val) in enumerate(results):
table.setItem(i, 0, QTableWidgetItem(name))
table.setItem(i, 1, QTableWidgetItem(val))
# Add a close button to dismiss the dialog
btn_close = QPushButton("Close")
btn_close.clicked.connect(dlg.accept)
layout.addWidget(btn_close)
dlg.exec_()
[docs]
def plot_das_and_more(self):
"""
Opens an external window to display DAS/SAS (Decay/Species Associated Spectra).
If an oscillation model is used, it separates the plots into two distinct panels.
"""
if self.As is None: return
# Define output directory for plots
outdir = os.path.join(self.base_dir, "Plots")
os.makedirs(outdir, exist_ok=True)
wl = getattr(self, '_wl_proc', self.WL)
td = getattr(self, '_td_proc', self.TD)
# --- DETECT OSCILLATION COMPONENT ---
has_oscillation = hasattr(self, 'Bs') and self.Bs is not None
# Figure configuration: 2 panels if oscillation exists, 1 otherwise
fig_das = Figure(figsize=(14, 6) if has_oscillation else (8, 6))
if has_oscillation:
ax_das = fig_das.add_subplot(121)
ax_osc = fig_das.add_subplot(122)
else:
ax_das = fig_das.add_subplot(111)
ax_osc = None
# --- 1. PLOT DAS/SAS (Exponential Components) ---
colors = ['b', 'r', 'g', 'orange', 'm', 'c']
markers = ['o', 's', '^', 'D', 'v', 'p']
for n in range(self.numExp):
tau_val = self.extracted_taus[n]
# Validate error existence and check for NaNs
if self.extracted_errtaus is not None and n < len(self.extracted_errtaus):
err_tau = self.extracted_errtaus[n]
if np.isnan(err_tau): err_tau = 0.0
else:
err_tau = 0.0
lbl = f"$\\tau_{n+1}$ = {tau_val:.2f} ± {err_tau:.2f} ps"
color = colors[n % len(colors)]
marker = markers[n % len(markers)] # Assign marker
if self.errAs is not None:
# Clean NaNs in Y-error to prevent Matplotlib issues
err_y = np.nan_to_num(self.errAs[n])
# Plot line with markers and error bars (caps included)
ax_das.errorbar(wl, self.As[n], yerr=err_y, label=lbl,
color=color, fmt=f'-{marker}', markersize=5,
capsize=4, capthick=1.5, linewidth=1.5, elinewidth=1.5)
else:
# Fallback plot without error bars
ax_das.plot(wl, self.As[n], f'-{marker}', label=lbl, color=color,
markersize=5, linewidth=1.5)
ax_das.set_xlabel("Wavelength (nm)")
if self.model_type == "Sequential":
ax_das.set_ylabel("SAS (Concentration)")
ax_das.set_title("Species Associated Spectra (SAS)")
else:
ax_das.set_ylabel("DAS Amplitude (ΔA)")
ax_das.set_title("Decay Associated Spectra (DAS)")
ax_das.legend(frameon=True)
ax_das.axhline(0, color='k', linestyle='--', alpha=0.5)
ax_das.grid(True, linestyle=':', alpha=0.4)
# --- 2. PLOT OSCILLATION COMPONENT (If applicable) ---
if has_oscillation and ax_osc is not None:
# Retrieve physical parameters from fit_x vector
# Indices based on: [w, t0, tau1..n, alpha, omega, phi, ...]
alpha = self.fit_x[2 + self.numExp]
omega = self.fit_x[2 + self.numExp + 1]
phi = self.fit_x[2 + self.numExp + 2]
# Descriptive title for the oscillation panel
title_osc = (f"Oscillation Spectrum\n"
f"Damping α={alpha:.4f} | Freq ω={omega:.4f} | Phase φ={phi:.2f}")
# Plot B-Spectrum (Oscillation Amplitude)
ax_osc.plot(wl, self.Bs, color='black', linewidth=2, label='Oscillation Amplitude (B)')
# Plot B-Spectrum error as a shaded area (fill_between)
if self.errBs is not None:
ax_osc.fill_between(wl, self.Bs - self.errBs, self.Bs + self.errBs, color='black', alpha=0.1)
ax_osc.set_xlabel("Wavelength (nm)")
ax_osc.set_ylabel("Oscillation Amplitude")
ax_osc.set_title(title_osc, color='darkblue')
ax_osc.axhline(0, color='k', linestyle='--', alpha=0.5)
ax_osc.grid(True, linestyle=':', alpha=0.4)
ax_osc.legend(frameon=True)
fig_das.tight_layout()
# Save the figure silently
savename = "DAS_and_Oscillation.png" if has_oscillation else "DAS.png"
try:
fig_das.savefig(os.path.join(outdir, savename), dpi=300)
print(f"Plot saved to {outdir}")
except Exception as e:
print(f"Error saving DAS plot: {e}")
# --- LA MAGIA: Usar tu ventana personalizada para que no desaparezca ---
self.das_viewer = PlotViewerWindow(fig_das, title="DAS / SAS Spectra", parent=self)
self.das_viewer.show()
# --- 3. RESIDUALS MAP EXPORT (También en modo silencioso) ---
fig_res = Figure()
canvas_res = FigureCanvasAgg(fig_res)
ax_res = fig_res.add_subplot(111)
pcm = ax_res.pcolormesh(wl, td, self.fit_resid.T, cmap='jet', shading='auto')
fig_res.colorbar(pcm, ax=ax_res, label='Residuals')
ax_res.set_title("Residuals Map")
ax_res.set_xlabel("Energy / Wavelength")
ax_res.set_ylabel("Delay (ps)")
if hasattr(self, 'yscale') and self.yscale == 'symlog':
ax_res.set_yscale('symlog', linthresh=2) # Consistente con el resto
fig_res.tight_layout()
fig_res.savefig(os.path.join(outdir, "Residuals_Map.png"), dpi=300)
# Mostramos el Trace Explorer
self.trace_viewer = TraceExplorerWindow(self, outdir)
self.trace_viewer.show()