Source code for hyloa.gui.worksheet

# This file is part of HYLOA - HYsteresis LOop Analyzer.
# Copyright (C) 2024 Francesco Zeno Costanzo

# HYLOA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# HYLOA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with HYLOA. If not, see <https://www.gnu.org/licenses/>.

"""
Code for the worksheet window, allowing data table manipulation and plotting.
"""

import numpy as np
import pandas as pd
from scipy.special import *
from scipy.optimize import curve_fit
        
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtGui import QKeySequence
from PyQt5.QtCore import QItemSelectionModel


from PyQt5.QtWidgets import (
    QFileDialog, QMessageBox, QMdiSubWindow, QLineEdit,
    QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem,
    QPushButton, QHBoxLayout, QDialog, QLabel, QComboBox,
    QDialogButtonBox, QInputDialog, QAction, QApplication,
    QFormLayout, QTextEdit, QSizePolicy, QCheckBox,
    QListWidget, QAbstractItemView, QGridLayout,
    QGroupBox, QStackedWidget, QButtonGroup
)



from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar

from matplotlib.figure import Figure
from matplotlib.container import ErrorbarContainer
from matplotlib.legend_handler import HandlerErrorbar
from matplotlib import colors as mcolors, markers, lines as mlines


from hyloa.data.io import detect_header_length
from hyloa.utils.err_format import format_value_error
from hyloa.gui.worksheet_utils import ColumnSelectionDialog, ColumnMathDialog

[docs] class WorksheetWindow(QMdiSubWindow): ''' A worksheet subwindow for managing tabular data and plotting. ''' def __init__(self, mdi_area, parent=None, name="worksheet", logger=None, app_instance=None): ''' Initialize the worksheet window. Parameters ---------- mdi_area : QMdiArea The main MDI area where this window will be added. parent : QWidget, optional The parent widget (default is None). name : str, optional Name of the worksheet (default is "worksheet"). logger : logging.Logger, optional Logger for debug messages (default is None). ''' super().__init__(parent) self.mdi_area = mdi_area self.name = name self.setWindowTitle(f"Worksheet - {self.name}") self.setMinimumSize(500, 500) self.adjustSize() # Logger self.logger = logger # App instance self.app_instance = app_instance # Create an initial table with 20 rows and 4 columns self.table = QTableWidget(20, 4) self.table.setHorizontalHeaderLabels([f"Col {i+1}" for i in range(4)]) self.table.cellChanged.connect(self.auto_expand_rows) # Enable copy/paste functionality with ctrl+c / ctrl+v self.table.setContextMenuPolicy(Qt.ActionsContextMenu) self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.table.setSelectionBehavior(QAbstractItemView.SelectItems) copy_action = QAction("Copy", self.table) copy_action.setShortcut(QKeySequence.Copy) copy_action.triggered.connect(self.copy_selection) self.table.addAction(copy_action) paste_action = QAction("Paste", self.table) paste_action.setShortcut(QKeySequence.Paste) paste_action.triggered.connect(self.paste_selection) self.table.addAction(paste_action) # Allow to rename columns by double-clicking header self.table.horizontalHeader().setSectionsClickable(True) self.table.horizontalHeader().setSectionsMovable(True) self.table.horizontalHeader().setDefaultAlignment(Qt.AlignCenter) # Makes title editable self.table.horizontalHeader().setSectionsMovable(True) self.table.horizontalHeader().sectionDoubleClicked.connect(self.edit_column_name) # Selection of a single or several columns self._column_selection_anchor = None self.table.horizontalHeader().sectionClicked.connect( self.on_column_header_clicked ) self.btn_add_col = QPushButton("Add column") self.btn_rmv_col = QPushButton("Remove column") self.btn_load = QPushButton("Load from file") self.btn_plot = QPushButton("Create Plot") self.btn_math = QPushButton("Column Math") self.btn_custom = QPushButton("Customization") self.btn_fit = QPushButton("Fit Data") self.btn_appearance = QPushButton("Appearance") self.btn_import_col = QPushButton("Import Column") self.btn_export = QPushButton("Save Data") # ---------- DATA GROUP ---------- data_group = QGroupBox("Data manipulation") data_layout = QHBoxLayout() data_group.setLayout(data_layout) data_layout.addWidget(self.btn_load) data_layout.addWidget(self.btn_import_col) data_layout.addWidget(self.btn_add_col) data_layout.addWidget(self.btn_rmv_col) data_layout.addWidget(self.btn_export) data_layout.addStretch() # ---------- PLOT GROUP ---------- plot_group = QGroupBox("Create and customize plots") plot_layout = QHBoxLayout() plot_group.setLayout(plot_layout) plot_layout.addWidget(self.btn_plot) plot_layout.addWidget(self.btn_custom) plot_layout.addWidget(self.btn_appearance) plot_layout.addStretch() # ---------- ANALYSIS GROUP ---------- analysis_group = QGroupBox("Analysis tools") analysis_layout = QHBoxLayout() analysis_group.setLayout(analysis_layout) analysis_layout.addWidget(self.btn_math) analysis_layout.addWidget(self.btn_fit) analysis_layout.addStretch() self.tools_stack = QStackedWidget() self.tools_stack.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Fixed ) self.tools_stack.addWidget(data_group) self.tools_stack.addWidget(plot_group) self.tools_stack.addWidget(analysis_group) self.btn_data_tab = QPushButton("Data") self.btn_plot_tab = QPushButton("Plot") self.btn_analysis_tab = QPushButton("Analysis") self.btn_help = QPushButton("Help") tab_layout = QHBoxLayout() tab_layout.addWidget(self.btn_data_tab) tab_layout.addWidget(self.btn_plot_tab) tab_layout.addWidget(self.btn_analysis_tab) tab_layout.addWidget(self.btn_help) tab_layout.addStretch() for btn in [self.btn_data_tab, self.btn_plot_tab, self.btn_analysis_tab]: btn.setCheckable(True) layout = QVBoxLayout() layout.addLayout(tab_layout) # Buttons to switch between sections layout.addWidget(self.tools_stack) # Stacked widget with different sections layout.addWidget(self.table, stretch=1) # Table takes remaining space container = QWidget() container.setLayout(layout) self.setWidget(container) def show_help_dialog(): help_text = ( "Worksheet Guide:\n\n" "- Use the 'Data' tab to load data from files, add/remove columns, or import columns from loaded data.\n" "- Use the 'Plot' tab to create and customize plots.\n" "- Use the 'Analysis' tab to perform mathematical operations and curve fitting." ) QMessageBox.information(self, "Worksheet Guide", help_text) # Connect button actions self.btn_plot.clicked.connect(self.create_plot) self.btn_export.clicked.connect(self.export_data) self.btn_add_col.clicked.connect(self.add_column) self.btn_rmv_col.clicked.connect(self.remove_column) self.btn_math.clicked.connect(self.open_math_dialog) self.btn_custom.clicked.connect(self.customize_plot) self.btn_load.clicked.connect(self.load_file_into_table) self.btn_fit.clicked.connect(self.open_curve_fitting_window) self.btn_import_col.clicked.connect(self.import_column_from_main) self.btn_appearance.clicked.connect(self.customize_plot_appearance) self.section_group = QButtonGroup(self) self.section_group.setExclusive(True) self.section_group.addButton(self.btn_data_tab, 0) self.section_group.addButton(self.btn_plot_tab, 1) self.section_group.addButton(self.btn_analysis_tab, 2) self.section_group.buttonClicked[int].connect(self.tools_stack.setCurrentIndex) self.btn_data_tab.setChecked(True) self.btn_help.clicked.connect(show_help_dialog) # Attributes to memorize plot windows self.plots = {} # {int: {"x":..., "y":..., "x_err":..., "y_err":..., "geom":...}} self.plot_count = 0 # to assign unique plot IDs self.plot_subwindows = {} # {int: QMdiSubWindow} self._plot_widgets = {} # {plot_id: {"sub": sub, "container": widget, "canvas": canvas, "toolbar": toolbar}} self.figure = {} # {plot_id: {"figure": Figure, "ax": Axes,} self.plot_customization = {} # {"figure":..., "ax":..., "canvas":..., "customizations": {...}}
[docs] def sync_to_data(self): ''' Function to update dataframe storage ''' if self.app_instance and hasattr(self.app_instance, "worksheet_dfs"): df = self.to_dataframe() self.app_instance.worksheet_dfs.add(self.name, df)
[docs] def closeEvent(self, event): ''' Cleanup when the worksheet window is closed. Parameters ---------- event : QCloseEvent The close event. ''' # Close all plot subwindows linked to this worksheet try: for pid, sub in list(self.plot_subwindows.items()): try: sub.close() except Exception: pass self.plot_subwindows.clear() self._plot_widgets.clear() self.figure.clear() self.plot_customization.clear() self.plots.clear() # Remove self from parent's worksheet tracking if hasattr(self, "mdi_area") and self.mdi_area is not None: parent = self.mdi_area.parent() if parent is not None: for idx, ws in list(parent.worksheet_windows.items()): if ws is self: parent.worksheet_windows.pop(idx, None) parent.worksheet_names.pop(idx, None) parent.worksheet_subwindows.pop(idx, None) break # Remove associated dataframe if self.app_instance and hasattr(self.app_instance, "worksheet_dfs"): self.app_instance.worksheet_dfs.remove(self.name) except Exception as e: print(f"[DEBUG] Error during worksheet cleanup: {e}") super().closeEvent(event)
[docs] def on_column_header_clicked(self, col): ''' Handle column header click for selection with modifiers. Parameters ---------- col : int The index of the clicked column. ''' modifiers = QApplication.keyboardModifiers() sel_model = self.table.selectionModel() col_index = self.table.model().index(0, col) if modifiers & Qt.ControlModifier: sel_model.select( col_index, QItemSelectionModel.Select | QItemSelectionModel.Columns ) elif modifiers & Qt.ShiftModifier: sel_model.select( col_index, QItemSelectionModel.SelectCurrent | QItemSelectionModel.Columns ) else: sel_model.clearSelection() sel_model.select( col_index, QItemSelectionModel.Select | QItemSelectionModel.Columns )
[docs] def copy_selection(self): ''' Copy selected columns or selected cells to clipboard in tab-delimited format. ''' clipboard = QApplication.clipboard() selection_model = self.table.selectionModel() selected_columns = selection_model.selectedColumns() # Case 1: Selection of the columns if selected_columns: cols = sorted(c.column() for c in selected_columns) copied = [] for row in range(self.table.rowCount()): row_data = [] for col in cols: item = self.table.item(row, col) row_data.append(item.text() if item else "") copied.append("\t".join(row_data)) clipboard.setText("\n".join(copied)) return # Case 2: Selection of the cells selection = self.table.selectedRanges() if not selection: return r = selection[0] copied = [] for row in range(r.topRow(), r.bottomRow() + 1): row_data = [] for col in range(r.leftColumn(), r.rightColumn() + 1): item = self.table.item(row, col) row_data.append(item.text() if item else "") copied.append("\t".join(row_data)) clipboard.setText("\n".join(copied))
[docs] def paste_selection(self): ''' Paste tab-delimited data from clipboard into the table starting at current cell. ''' clipboard = QApplication.clipboard() text = clipboard.text() if not text: return # Remove empty line (clipboard behaviour) rows = [r for r in text.split("\n") if r.strip("\t ") != ""] data = [row.split("\t") for row in rows] start_col = self.table.currentColumn() start_row = self.table.currentRow() num_cols = max(len(r) for r in data) # Ensure enough columns while start_col + num_cols > self.table.columnCount(): c = self.table.columnCount() self.table.insertColumn(c) self.table.setHorizontalHeaderItem( c, QTableWidgetItem(f"Col {c+1}") ) # Ensure enough rows while start_row + len(data) > self.table.rowCount(): self.table.insertRow(self.table.rowCount()) for i, row in enumerate(data): for j, cell in enumerate(row): r = start_row + i c = start_col + j self.table.setItem(r, c, QTableWidgetItem(cell)) self.sync_to_data()
[docs] def load_file_into_table(self): ''' Load data from a text file into the table. The function automatically detects header rows and populates the QTableWidget with the parsed data. ''' file_path, _ = QFileDialog.getOpenFileName( self, "Load file", "", "Text Files (*.txt);;All Files (*)" ) if not file_path: return try: header_length = detect_header_length(file_path) # Handle different header scenarios if header_length > 0: df = pd.read_csv(file_path, sep="\t").drop(list(range(header_length))) elif header_length == -1: data = np.loadtxt(file_path) _, n_col = data.shape col = [f"col_{i}" for i in range(n_col)] df = pd.DataFrame(data, columns=col) else: df = pd.read_csv(file_path, sep="\t") # Update table size self.table.setRowCount(len(df)) self.table.setColumnCount(len(df.columns)) self.table.setHorizontalHeaderLabels([str(c) for c in df.columns]) #Log loaded data info if self.logger is not None: self.logger.info(f"Loaded file '{file_path}' in worksheet with {len(df)} rows and {len(df.columns)} columns.") # Populate table with data for r in range(len(df)): for c in range(len(df.columns)): val = df.iat[r, c] if pd.isna(val): item = QTableWidgetItem("") else: item = QTableWidgetItem(str(val)) self.table.setItem(r, c, item) self.sync_to_data() except Exception as e: QMessageBox.critical(self, "Error", f"Error in loading file:\n{e}")
[docs] def export_data(self): ''' Export current worksheet data to file. ''' df = self.to_dataframe() if df.empty: QMessageBox.warning(self, "Error", "Worksheet is empty.") return options = QFileDialog.Options() file_path, selected_filter = QFileDialog.getSaveFileName( self, "Export Worksheet Data", f"{self.name}.txt", "CSV files (*.csv);;Text files (*.txt)", options=options ) if not file_path: return try: if selected_filter.startswith("CSV"): df.to_csv(file_path, index=False) elif selected_filter.startswith("Text"): df.to_csv(file_path, sep="\t", index=False) QMessageBox.information(self, "Success", f"Data exported to:\n{file_path}") if self.logger: self.logger.info(f"Worksheet '{self.name}' exported to {file_path}") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to export data:\n{e}")
[docs] def add_column(self): ''' Add a new empty column to the table. ''' col_count = self.table.columnCount() self.table.insertColumn(col_count) self.table.setHorizontalHeaderItem(col_count, QTableWidgetItem(f"Col {col_count+1}")) self.sync_to_data()
[docs] def remove_column(self): ''' Remove the currently selected column(s) from the table. ''' selected = self.table.selectionModel().selectedColumns() if not selected: return # Nothing selected to remove so return if len(selected) > 1: reply = QMessageBox.question( self, "Remove columns", f"Remove {len(selected)} columns?", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return # Remove columns in reverse order to avoid index shifting for col in sorted([c.column() for c in selected], reverse=True): self.table.removeColumn(col) self.sync_to_data()
[docs] def auto_expand_rows(self, row, col): ''' Automatically add a new row if the user edits the last row. Parameters ---------- row : int The row index that was edited. col : int The column index that was edited. ''' if row == self.table.rowCount() - 1: self.table.insertRow(self.table.rowCount())
[docs] def edit_column_name(self, index): ''' Open a dialog to edit the name of the column at the given index. Parameters ---------- index : int The index of the column to rename. ''' current_name = self.table.horizontalHeaderItem(index).text() new_name, ok = QInputDialog.getText( self, "Rename column", "New column name:", text=current_name ) if ok and new_name.strip(): self.table.setHorizontalHeaderItem(index, QTableWidgetItem(new_name.strip()))
[docs] def import_column_from_main(self): ''' Import a column from one of the DataFrames loaded in MainApp. The worksheet will know the dataframes only if they are loaded before creating the worksheet. ''' data = self.app_instance if len(data.dataframes) == 0: msg = "No DataFrames in this woorksheet. " msg += "The worksheet will know the dataframes only if " msg += "they are loaded before creating the worksheet. " msg += "So you need to create a new one." QMessageBox.warning(self, "Error", msg) return dialog = QDialog(self) dialog.setWindowTitle("Import column from loaded data") dialog.setMinimumSize(500, 200) dialog.adjustSize() layout = QHBoxLayout(dialog) # Left list = dataframe names df_list = QListWidget() df_list.addItems([f"File {i+1}" for i in range(len(data.dataframes))]) layout.addWidget(df_list) # Right list = columns of selected dataframe col_list = QListWidget() col_list.setSelectionMode(QAbstractItemView.MultiSelection) layout.addWidget(col_list) # Update columns when a dataframe is selected def update_columns(): col_list.clear() idx = df_list.currentRow() if idx >= 0: df = data.dataframes[idx] col_list.addItems(df.columns) df_list.currentRowChanged.connect(update_columns) df_list.setCurrentRow(0) # show first dataframe by default # Buttons btn_box = QVBoxLayout() layout.addLayout(btn_box) import_btn = QPushButton("Import") cancel_btn = QPushButton("Cancel") btn_box.addWidget(import_btn) btn_box.addWidget(cancel_btn) btn_box.addStretch() # === When Import is clicked === def do_import(): df_idx = df_list.currentRow() selections = col_list.selectedItems() if df_idx < 0 or not selections: QMessageBox.warning(dialog, "Error", "Please select a dataframe and a column.") return df = data.dataframes[df_idx] # Determine max length first max_len = max(len(df[item.text()]) for item in selections) # Ensure table has enough rows while self.table.rowCount() < max_len: self.table.insertRow(self.table.rowCount()) # Import each selected column for item in selections: col_name = item.text() values = df[col_name].values new_col_index = self.table.columnCount() self.table.insertColumn(new_col_index) self.table.setHorizontalHeaderItem( new_col_index, QTableWidgetItem(col_name) ) for r, val in enumerate(values): self.table.setItem( r, new_col_index, QTableWidgetItem(str(val)) ) self.sync_to_data() dialog.accept() import_btn.clicked.connect(do_import) cancel_btn.clicked.connect(dialog.reject) dialog.exec_()
[docs] def to_dataframe(self): ''' Convert the table contents to a pandas DataFrame. Returns ------- pd.DataFrame DataFrame containing the numeric values from the table. Non-numeric values are replaced with an empty string. ''' rows = self.table.rowCount() cols = self.table.columnCount() data = {} for vc in range(cols): lc = self.table.horizontalHeader().logicalIndex(vc) col_name = self.table.horizontalHeaderItem(lc).text() values = [] for r in range(rows): item = self.table.item(r, lc) if item is None or item.text().strip() == "": values.append(np.nan) else: try: values.append(float(item.text())) except ValueError: values.append(np.nan) data[col_name] = values return pd.DataFrame(data)
[docs] def open_math_dialog(self): ''' Open a dialog to perform arithmetic operations between columns. ''' columns = [self.table.horizontalHeaderItem(c).text() for c in range(self.table.columnCount())] if not columns: return dlg = ColumnMathDialog(columns, self) if dlg.exec_() != QDialog.Accepted: return sel = dlg.get_selection() mode = sel["mode"] new_name = sel["new_name"] if not new_name: QMessageBox.warning(self, "Error", "Please enter a name for the new column.") return df = self.to_dataframe() try: # Operation between two columns or column and constant if mode == "Arithmetic between columns": col_a, op, col_b, const_str = sel["col_a"], sel["op"], sel["col_b"], sel["const"] series_a = df[col_a].astype(float) if col_b: series_b = df[col_b].astype(float) else: if not const_str: raise ValueError("Constant value required.") series_b = float(const_str) result = { "+": series_a + series_b, "-": series_a - series_b, "*": series_a * series_b, "/": series_a / series_b, "mean": (series_a + series_b) / 2.0 }.get(op) if result is None: raise ValueError("Unknown operation") elif mode == "Custom expression between columns": expr = sel["expr"] allowed_dict = { "np": np, **{col: df[col].values for col in df.columns} } try: result = eval(expr, {"__builtins__": {}}, allowed_dict) except Exception as e: QMessageBox.warning(self, "Error", str(e)) return # Generate linspace elif mode in ("Generate linspace", "Generate logspace"): start, stop, num = float(sel["start"]), float(sel["stop"]), int(sel["num"]) result = np.linspace(start, stop, num) if mode == "Generate linspace" else np.logspace(start, stop, num) # Generate custom function elif mode == "Generate function from linspace/logspace": start, stop, num = float(sel["start"]), float(sel["stop"]), int(sel["num"]) func_str = sel["func"] space_type = sel.get("space_type", "linspace") if not func_str: raise ValueError("Please provide a function f(x).") # Generate indipendent variable x if space_type == "linspace": x = np.linspace(start, stop, num) else: x = np.logspace(start, stop, num) # Compute the function safe_env = {"np": np, "x": x} y = eval(func_str, {"__builtins__": {}}, safe_env) # Naming the new columns x_name = new_name + "_x" y_name = new_name + "_y" for col_name, data in [(x_name, x), (y_name, y)]: new_col_index = self.table.columnCount() self.table.insertColumn(new_col_index) self.table.setHorizontalHeaderItem(new_col_index, QTableWidgetItem(col_name)) for r, val in enumerate(data): self.table.setItem(r, new_col_index, QTableWidgetItem(str(val))) self.sync_to_data() return # Avoid adding extra column below else: raise ValueError(f"Unknown mode {mode}") # Add new single column to the table new_col_index = self.table.columnCount() self.table.insertColumn(new_col_index) self.table.setHorizontalHeaderItem(new_col_index, QTableWidgetItem(new_name)) for r, val in enumerate(result): if pd.notna(val): self.table.setItem(r, new_col_index, QTableWidgetItem(str(val))) self.sync_to_data() except Exception as e: QMessageBox.critical(self, "Error", f"Operation failed:\n{e}")
[docs] def create_plot(self): ''' Open a dialog to select columns and create a plot. The user chooses X, Y, and optionally error bar columns. ''' data = self.app_instance.worksheet_dfs flat_cols = list(data.get_all_columns().keys()) if not flat_cols: QMessageBox.warning(self, "Error", "No data available.") return dialog = ColumnSelectionDialog(flat_cols, self) if dialog.exec_() == QDialog.Accepted: selections = dialog.get_selection() self.open_plot_window(selections)
[docs] def open_plot_window(self, selections, show=True, plot_id=None, customizations=None): ''' Open a new subwindow with a plot of the selected columns. Parameters ---------- selections : list of dict List of selections, each a dict with keys: 'x', 'y', 'x_err', 'y_err' for column names. show : bool, optional Whether to show the plot window immediately (default is True). plot_id : int or None, optional If provided, use this as the plot ID; otherwise, auto-increment (default is customizations : dict or None, optional Customization settings for the plot (default is None). Returns ------- QMdiSubWindow The subwindow containing the plot. ''' # --- handle plot_id --- if plot_id is None: self.plot_count += 1 plot_id = self.plot_count else: # keep track of highest id try: pid_int = int(plot_id) if pid_int > self.plot_count: self.plot_count = pid_int except Exception: pass fig = Figure(figsize=(6, 4)) ax = fig.add_subplot(111) self.figure[plot_id] = { "figure": fig, "ax": ax, "sub": None, "curves": [] } data = self.app_instance.worksheet_dfs flat_map = data.get_all_columns() def resolve_column(col_name): ''' Simple function to ensure retrocompatibility ''' # New format with flat mapping → directly resolve if col_name in flat_map: return flat_map[col_name] # Legacy format without flat mapping current_ws = self.name df = data.get(current_ws) if df is not None and col_name in df.columns: return current_ws, col_name for i, sel in enumerate(selections, start=1): x_ws, x_col = resolve_column(sel["x"]) x = data.get(x_ws)[x_col].values y_ws, y_col = resolve_column(sel["y"]) y = data.get(y_ws)[y_col].values xerr = None if sel["x_err"]: dx_ws, dx_col = resolve_column(sel["x_err"]) xerr = data.get(dx_ws)[dx_col].values yerr = None if sel["y_err"]: dy_ws, dy_col = resolve_column(sel["y_err"]) yerr = data.get(dy_ws)[dy_col].values label = sel["y"] if len(selections) == 1 else f"{sel['y']}" if xerr is not None or yerr is not None: # Plot with error bars err_c = ax.errorbar(x, y, xerr=xerr, yerr=yerr, fmt="o-") # Store the output for customization if not hasattr(ax, "_err_c"): ax._err_c = {} ax._err_c[i-1] = err_c # Set label for legend err_c[0].set_label(label) # Save data in figure fot fitting line_obj = err_c[0] self.figure[plot_id]["curves"].append({ "line": line_obj, "x": np.array(x), "y": np.array(y), "xerr": np.array(xerr) if xerr is not None else None, "yerr": np.array(yerr) if yerr is not None else None, "x_ws": x_ws, "y_ws": y_ws, "x_col": x_col, "y_col": y_col }) else: line_obj, = ax.plot(x, y, "o-", label=label) self.figure[plot_id]["curves"].append({ "line": line_obj, "x": np.array(x), "y": np.array(y), "xerr": None, "yerr": None, "x_ws": x_ws, "y_ws": y_ws, "x_col": x_col, "y_col": y_col }) # Log the creation of the plot with selcected columns if self.logger is not None: msg = f"Created plot (ID: {plot_id if plot_id is not None else self.plot_count + 1}) with selections: " msg += ", ".join([f"(X: {s['x']}, Y: {s['y']})" for s in selections]) self.logger.info(msg) lines = [ln for ln in ax.lines if ln.get_gid() != "fit"] if customizations: for idx, style in customizations.items(): try: idx = int(idx) if idx >= len(lines): continue line = lines[idx] color = style.get("color") marker = style.get("marker") linestyle = style.get("linestyle") label = style.get("label") line.set_color(color) line.set_marker(marker) line.set_linestyle(linestyle) line.set_label(label) if hasattr(ax, "_err_c"): err_collections = ax._err_c if idx in err_collections.keys(): err_c = err_collections[idx] err_c[0].set_marker(marker) err_c[0].set_linestyle(linestyle) err_c[0].set_label(label) err_c[0].set_color(color) err_c[2][0].set_color(color) except Exception: continue fig.canvas.draw_idle() # Store customization settings self.plot_customization[plot_id] = customizations or {} ax.set_xlabel(selections[0]["x"]) ax.set_ylabel("Values") ax.grid(True) # Legend err_c_vals = list(ax._err_c.values()) if hasattr(ax, "_err_c") else [] handles = [ln for ln in ax.lines if all(ln is not e[0] for e in err_c_vals)] + err_c_vals labels = [] for h in handles: if isinstance(h, ErrorbarContainer): labels.append(h[0].get_label()) else: labels.append(h.get_label()) ax.legend(handles, labels, handler_map={ErrorbarContainer: HandlerErrorbar()}) canvas = FigureCanvas(fig) plot_container = QWidget() vlayout = QVBoxLayout(plot_container) vlayout.setContentsMargins(0, 0, 0, 0) toolbar = NavigationToolbar(canvas, plot_container) vlayout.addWidget(toolbar) vlayout.addWidget(canvas) plot_container.setLayout(vlayout) sub = QMdiSubWindow() sub.setWindowTitle(f"From {self.name} plot {plot_id}") sub.setWidget(plot_container) self.mdi_area.addSubWindow(sub) self._plot_widgets[plot_id] = { "sub": sub, "container": plot_container, "canvas": canvas, "toolbar": toolbar } self.figure[plot_id]["sub"] = sub self.plot_customization[plot_id] = { "figure": fig, "ax": ax, "canvas": canvas, "customizations": customizations or {} } canvas.draw_idle() # show only if requested if show: # show after addSubWindow: the MDI may apply a default cascading; caller can still reposition if desired sub.show() # Cleanup references when the subwindow is closed def _cleanup(pid=plot_id): self._plot_widgets.pop(pid, None) self.plot_subwindows.pop(pid, None) self.figure.pop(pid, None) self.plot_customization.pop(pid, None) self.plots.pop(pid, None) # Connect the destroyed signal to cleanup sub.destroyed.connect(lambda _=None, pid=plot_id: _cleanup(pid)) # Save plot info for session management self.plot_subwindows[plot_id] = sub self.plots[plot_id] = { "selections": selections, "geometry": { "x": sub.x(), "y": sub.y(), "width": sub.width(), "height": sub.height(), "minimized": sub.isMinimized() } } def on_subwindow_close(event, pid=plot_id): self._plot_widgets.pop(pid, None) self.plot_subwindows.pop(pid, None) self.figure.pop(pid, None) self.plot_customization.pop(pid, None) self.plots.pop(pid, None) event.accept() sub.closeEvent = lambda event, pid=plot_id: on_subwindow_close(event, pid) return sub
[docs] def customize_plot(self): ''' Open a dialog to customize plot styles (color, marker, linestyle, label). ''' if not self.figure: QMessageBox.critical(self, "Error", "No plot open! Create a plot first.") return dialog = QDialog(self) dialog.setWindowTitle("Customize Plot Style") dialog.setFixedSize(420, 360) layout = QVBoxLayout(dialog) form_layout = QFormLayout() layout.addLayout(form_layout) # Choose plot to customize plot_combo = QComboBox() for pid, info in self.figure.items(): title = info["sub"].windowTitle() plot_combo.addItem(f"Plot {pid}: {title}", pid) form_layout.addRow("Select Plot:", plot_combo) line_combo = QComboBox() color_combo = QComboBox() marker_combo = QComboBox() linestyle_combo = QComboBox() label_edit = QLineEdit() color_combo.addItems(list(mcolors.TABLEAU_COLORS) + list(mcolors.CSS4_COLORS)) color_combo.setEditable(True) marker_combo.addItems([m for m in markers.MarkerStyle.markers.keys() if isinstance(m, str) and len(m) == 1]) marker_combo.setEditable(True) linestyle_combo.addItems(list(mlines.Line2D.lineStyles.keys())) linestyle_combo.setEditable(True) form_layout.addRow("Line:", line_combo) form_layout.addRow("Color:", color_combo) form_layout.addRow("Marker:", marker_combo) form_layout.addRow("Linestyle:", linestyle_combo) form_layout.addRow("Legend label:", label_edit) def update_lines(): pid = plot_combo.currentData() ax = self.figure[pid]["ax"] lines = [ln for ln in ax.lines if ln.get_gid() != "fit"] line_combo.clear() for i, ln in enumerate(lines): line_combo.addItem(ln.get_label() or f"Line {i+1}", i) if lines: label_edit.setText(lines[0].get_label() or "") plot_combo.currentIndexChanged.connect(update_lines) update_lines() apply_button = QPushButton("Apply") layout.addWidget(apply_button) def apply_style(): try: pid = plot_combo.currentData() line_idx = line_combo.currentData() ax = self.figure[pid]["ax"] fig = self.figure[pid]["figure"] line = [ln for ln in ax.lines if ln.get_gid() != "fit"][line_idx] color = color_combo.currentText() marker = marker_combo.currentText() linestyle = linestyle_combo.currentText() legend_label = label_edit.text() or line.get_label() line.set_color(color) line.set_marker(marker) line.set_linestyle(linestyle) line.set_label(legend_label) # Customization for error bars if present if hasattr(ax, "_err_c"): err_collections = ax._err_c if line_idx in err_collections.keys(): err_c = err_collections[line_idx] err_c[0].set_color(color) err_c[0].set_marker(marker) err_c[0].set_linestyle(linestyle) err_c[0].set_label(legend_label) err_c[2][0].set_color(color) self.plot_customization[pid]["customizations"][line_idx] = { "color": color, "marker": marker, "linestyle": linestyle, "label": legend_label, } # Legend update err_c_vals = list(ax._err_c.values()) if hasattr(ax, "_err_c") else [] handles = [ln for ln in ax.lines if all(ln is not e[0] for e in err_c_vals)] + err_c_vals labels = [] for h in handles: if isinstance(h, ErrorbarContainer): labels.append(h[0].get_label()) else: labels.append(h.get_label()) ax.legend(handles, labels, handler_map={ErrorbarContainer: HandlerErrorbar()}) fig.canvas.draw_idle() dialog.accept() except Exception as e: QMessageBox.critical(dialog, "Error", f"Error applying style:\n{e}") apply_button.clicked.connect(apply_style) dialog.exec_()
[docs] def customize_plot_appearance(self): ''' Open a dialog to customize the appearance of the plot (font sizes, minor ticks). ''' if not self.figure: QMessageBox.warning(self, "Error", "No plots available! Create a plot first.") return dialog = QDialog(self) dialog.setWindowTitle("Plot Appearance") dialog.setFixedSize(400, 280) layout = QFormLayout(dialog) # Select plot layout.addRow(QLabel("Select plot:")) plot_combo = QComboBox() for pid, info in self.figure.items(): sub_title = info["sub"].windowTitle() plot_combo.addItem(f"Plot {pid}: {sub_title}", pid) layout.addRow(plot_combo) # Font sizes label_fontsize_edit = QLineEdit("14") tick_fontsize_edit = QLineEdit("10") legend_fontsize_edit = QLineEdit("10") # Minor ticks (safe) minor_ticks_checkbox = QCheckBox("Show minor ticks") minor_ticks_checkbox.setChecked(True) layout.addRow("Axis label fontsize:", label_fontsize_edit) layout.addRow("Tick label fontsize:", tick_fontsize_edit) layout.addRow("Legend fontsize:", legend_fontsize_edit) layout.addRow(minor_ticks_checkbox) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) layout.addWidget(buttons) def apply_changes(): try: pid = plot_combo.currentData() if pid not in self.figure: QMessageBox.warning(dialog, "Error", "Selected plot not found!") return fig_info = self.figure[pid] ax = fig_info["ax"] canvas = self.plot_customization[pid]["canvas"] label_fs = float(label_fontsize_edit.text()) tick_fs = float(tick_fontsize_edit.text()) legend_fs = float(legend_fontsize_edit.text()) # --- Axis labels --- ax.xaxis.label.set_fontsize(label_fs) ax.yaxis.label.set_fontsize(label_fs) # --- Tick labels --- for label in ax.get_xticklabels() + ax.get_yticklabels(): label.set_fontsize(tick_fs) # --- Legend --- leg = ax.get_legend() if leg: for text in leg.get_texts(): text.set_fontsize(legend_fs) # --- Minor ticks --- if minor_ticks_checkbox.isChecked(): ax.minorticks_on() else: ax.minorticks_off() canvas.draw_idle() dialog.accept() except Exception as e: QMessageBox.critical(dialog, "Error", f"Error applying appearance settings:\n{e}") buttons.accepted.connect(apply_changes) buttons.rejected.connect(dialog.reject) dialog.exec_()
[docs] def open_curve_fitting_window(self): ''' Open a window to perform quick curve fitting on selected data. The user selects X and Y columns, fitting function, initial parameters, and range. The fit result is displayed and the fitted curve is plotted on the selected graph. ''' df = self.to_dataframe() if df.empty: QMessageBox.warning(self, "Error", "No data in the worksheet!") return if not self.figure: QMessageBox.warning(self, "Error", "No plots available! Create a plot first.") return # Create the fitting window window = QWidget() window.setWindowTitle("Quick Curve Fitting") layout = QVBoxLayout(window) window.setLayout(layout) def show_help_dialog(): help_text = ( "The fit function must be a function of the variable 'x' and " "the parameter names must be specified in the appropriate field.\n\n" "To establish the range, just read the cursor on the graph, the values are at the top right.\n\n" "ACHTUNG: the function must be written in Python, so for example |x| is abs(x), x^2 is x**2, and all " "other functions must be written with np. in front (i.e. np.cos(x), np.exp(x)), except for special functions, " "for which you must use the name used by the scipy.special library (i.e. scipy.special.erf becomes erf)." ) QMessageBox.information(window, "Fitting Guide", help_text) help_button = QPushButton("Help") help_button.clicked.connect(show_help_dialog) layout.addWidget(help_button, alignment=Qt.AlignLeft) # === left column fit parameter === selection_layout = QHBoxLayout() layout.addLayout(selection_layout) param_layout = QGridLayout() selection_layout.addLayout(param_layout) # Select target plot param_layout.addWidget(QLabel("Select target plot:"), 0, 0) plot_combo = QComboBox() for pid, info in self.figure.items(): sub_title = info["sub"].windowTitle() plot_combo.addItem(f"Plot {pid}: {sub_title}", pid) param_layout.addWidget(plot_combo, 0, 1) param_layout.addWidget(QLabel("Select curve:"), 1, 0) curve_combo = QComboBox() param_layout.addWidget(curve_combo, 1, 1) param_layout.addWidget(QLabel("x_start:"), 2, 0) x_start_edit = QLineEdit("-10") param_layout.addWidget(x_start_edit, 2, 1) param_layout.addWidget(QLabel("x_end:"), 3, 0) x_end_edit = QLineEdit("10") param_layout.addWidget(x_end_edit, 3, 1) param_layout.addWidget(QLabel("Parameter names (e.g. a,b):"), 4, 0) param_names_edit = QLineEdit("a,b") param_layout.addWidget(param_names_edit, 4, 1) param_layout.addWidget(QLabel("Initial values (e.g. 1,1):"), 5, 0) initial_params_edit = QLineEdit("1,1") param_layout.addWidget(initial_params_edit, 5, 1) param_layout.addWidget(QLabel("Fitting function (e.g. a*x + b):"), 6, 0) function_edit = QLineEdit("a*x + b") param_layout.addWidget(function_edit, 6, 1) #======================================================================# def update_range(): pid = plot_combo.currentData() curve_index = curve_combo.currentData() if pid not in self.figure: return curves = self.figure[pid]["curves"] if not curves: return if curve_index is None: return curve_data = curves[curve_index] x_vals = np.array(curve_data["x"], dtype=float) x_vals = x_vals[np.isfinite(x_vals)] if len(x_vals) > 0: x_start_edit.setText(str(np.min(x_vals))) x_end_edit.setText(str(np.max(x_vals))) def update_curves(): curve_combo.clear() pid = plot_combo.currentData() if pid not in self.figure: return for i, curve in enumerate(self.figure[pid]["curves"]): label = curve["line"].get_label() curve_combo.addItem(f"Curve {i+1}: {label}", i) update_range() plot_combo.currentIndexChanged.connect(update_curves) curve_combo.currentIndexChanged.connect(update_range) update_curves() #======================================================================# # Output output_box = QTextEdit() output_box.setReadOnly(True) output_box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) selection_layout.addWidget(output_box) def perform_fit(): ''' Perform the curve fitting and update the plot. ''' try: # Retrieve data and parameters pid = plot_combo.currentData() curve_index = curve_combo.currentData() if pid not in self.figure: QMessageBox.warning(window, "Error", "Plot not found!") return curve_data = self.figure[pid]["curves"][curve_index] x = np.array(curve_data["x"], dtype=float) y = np.array(curve_data["y"], dtype=float) x_start = float(x_start_edit.text()) x_end = float(x_end_edit.text()) mask = (x >= x_start) & (x <= x_end) x_fit, y_fit = x[mask], y[mask] if len(x_fit) == 0: QMessageBox.warning(window, "Error", "No data in selected range!") return param_names = [p.strip() for p in param_names_edit.text().split(",")] initial_params = [float(p.strip()) for p in initial_params_edit.text().split(",")] func_code = f"lambda x, {', '.join(param_names)}: {function_edit.text()}" fit_func = eval(func_code) # Execute the fit params, pcov = curve_fit(fit_func, x_fit, y_fit, p0=initial_params) y_model = fit_func(np.linspace(x_start, x_end, 500), *params) # Show fit results lines = [] for p, val, err in zip(param_names, params, np.sqrt(np.diag(pcov))): try: lines.append(f"{p} = {format_value_error(val, err)}") except Exception as e: lines.append(f"{p} = {val:.6f} ± {err:.6f}") for i , pi in zip(range(len(params)), param_names): for j , pj in zip(range(i+1, len(params)), param_names[i+1:]): corr_ij = pcov[i, j]/np.sqrt(pcov[i, i]*pcov[j, j]) lines.append(f"corr({pi}, {pj}) = {corr_ij:.3f}") result_text = "\n".join(lines) output_box.setPlainText(result_text) # Log fit data and results if self.logger is not None: self.logger.info( f"Performed curve fit on columns X: '{curve_data['x_col']}', Y: '{curve_data['y_col']}' " f"with range [{x_start}, {x_end}] " f"with function: {function_edit.text()} " f"and parameters: {', '.join(param_names)}. Results:\n{str(result_text).replace(chr(10), ' ')}") # Draw fit on the plot pid = plot_combo.currentData() if pid not in self.figure: QMessageBox.warning(window, "Error", "Selected plot not found!") return ax = self.figure[pid]["ax"] canvas = self.plot_customization[pid]["canvas"] fit_line, = ax.plot(np.linspace(x_start, x_end, 500), y_model, linestyle="--", color="red", label="fit") fit_line.set_gid("fit") ax.legend() canvas.draw_idle() except Exception as e: QMessageBox.critical(window, "Error", f"Fit failed:\n{e}") fit_button = QPushButton("Run Fit") fit_button.clicked.connect(perform_fit) param_layout.addWidget(fit_button, 7, 0, 1, 2, alignment=Qt.AlignCenter) # Show the fitting window as a subwindow sub = QMdiSubWindow() sub.setWidget(window) sub.setWindowTitle("Quick Curve Fitting") sub.setMinimumSize(550, 300) sub.adjustSize() self.mdi_area.addSubWindow(sub) sub.show()
[docs] def to_session_data(self): ''' Function to store all information needed to restore the worksheet session. ''' # Current geometry of the worksheet window ws_geom = { "x": self.x(), "y": self.y(), "width": self.width(), "height": self.height(), "minimized": self.isMinimized() } # Retrieve geometry of all plot windows for pid, sub in list(self.plot_subwindows.items()): if sub is None: continue geom = sub.geometry() if pid not in self.plots: continue self.plots[pid]["geometry"] = { "x": geom.x(), "y": geom.y(), "width": geom.width(), "height": geom.height(), "minimized": sub.isMinimized() } # Extract only the serializable part of customizations customizations_serializable = {} for pid, info in self.plot_customization.items(): customizations_serializable[pid] = info.get("customizations", {}) return { "name": self.name, "data": self.to_dataframe(), "geometry": ws_geom, "plots": self.plots, "customizations": customizations_serializable }
[docs] def from_session_data(self, data_dict): ''' Function to restore the worksheet from saved session data. ''' df = data_dict.get("data") if df is not None: self.table.setRowCount(len(df)) self.table.setColumnCount(len(df.columns)) self.table.setHorizontalHeaderLabels([str(c) for c in df.columns]) for r in range(len(df)): # Counter to check an empty row count = 0 for c in range(len(df.columns)): val = df.iat[r, c] if pd.isna(val): item = QTableWidgetItem("") count += 1 else: item = QTableWidgetItem(str(val)) self.table.setItem(r, c, item) if count == len(df.columns): # All empty, remove the row self.table.removeRow(r) # Sync with central data model BEFORE recreating plots if self.app_instance and hasattr(self.app_instance, "worksheet_dfs"): self.app_instance.worksheet_dfs.add(self.name, df) # Restore worksheet geometry: apply move/resize after being added to MDI geom = data_dict.get("geometry") if geom: # Ensure no special window states interfere self.setWindowState(Qt.WindowNoState) # Move/resize while hidden or already in MDI; if needed, delay minimize self.move(geom["x"], geom["y"]) self.resize(geom["width"], geom["height"]) if geom.get("minimized"): QTimer.singleShot(0, self.showMinimized) else: # Ensure it's visible in normal state QTimer.singleShot(0, self.showNormal) # Recreate plots (do not show immediately; set geometry then show) plots_dict = data_dict.get("plots", {}) or {} # Sort by numeric plot id if possible def keyf(k): try: return int(k) except: return k for plot_id in sorted(plots_dict.keys(), key=keyf): plot_info = plots_dict[plot_id] selections = plot_info.get("selections", []) if not selections: continue # Create sub but don't show it yet cst = data_dict.get("customizations", {}).get(plot_id, {}) sub = self.open_plot_window( selections, show=False, plot_id=int(plot_id), customizations=cst ) # Apply geometry (do this before showing to avoid MDI cascading override) pgeom = plot_info.get("geometry") if pgeom and sub: def apply_geom_and_show(sub=sub, pgeom=pgeom): try: sub.setWindowState(Qt.WindowNoState) sub.setGeometry(pgeom["x"], pgeom["y"], pgeom["width"], pgeom["height"]) if pgeom.get("minimized"): sub.showMinimized() else: sub.showNormal() except Exception: # fallback: show normally sub.show() # Schedule after event loop to be sure MDI is ready QTimer.singleShot(0, apply_geom_and_show) else: # If no geom info, just show it QTimer.singleShot(0, sub.show)