ARTICLE · 1008116
Excel XY折线图分析工具:基于PyQt5的交互式数据可视化方案

在材料力学测试、工程实验以及生产质量监控中,力-变形(或应力-应变)曲线是最常见的二维数据形态。通常,实验设备会输出多组X-Y数据列,每一对相邻列分别代表变形量与对应的力值。面对动辄数十列、数千行数据,手动筛选特定区间、绘制对比曲线、导出统计结果不仅效率低下,而且容易出错。本文设计并实现了一款桌面端分析工具,能够自动识别Excel文件中的“变形-力”数据对,以多色折线图呈现全部曲线,并提供灵活的区间查询与结果导出功能。该工具采用PyQt5构建图形界面,结合pandas、matplotlib和numpy完成数据处理与可视化,所有交互均围绕“文件加载→自动绘图→双向查询→导出结果”这一清晰流程展开。
一、工具功能与设计目标
本工具的核心需求源自实际工程场景:用户提供一份Excel表格,其中列数不定,但数据组织遵循“每两列为一组”的规律,每组的第一列为变形量(X轴),第二列为对应的力值(Y轴)。工具需完成以下任务:
1. 数据概览:读取Excel后,以表格形式展示每一列的列名、非空数据个数以及数据类型,使用户对数据质量一目了然。 2. 整体可视化:将所有有效的数据组绘制在同一张折线图上,每组使用不同颜色,并自动保存为PNG图片,便于报告使用。 3. 双向区间查询:用户可选定某一条折线,然后输入X轴区间(变形范围),工具返回该区间内所有数据点及Y值的统计量(最小值、最大值、平均值);反之,也可输入Y轴区间,返回对应的X值统计。这种双向查询能够满足“已知变形求力”和“已知力求变形”两种常见需求。 4. 结果导出:每次查询得到的数据子集与统计摘要可一键导出为Excel文件,包含两个工作表:原始查询数据和统计摘要,方便进一步分析或存档。
设计上,我们强调界面直观、操作连贯。左侧面板集中所有控制元件,右侧大画布实时展示折线图,查询结果直接呈现在表格中,统计摘要同步更新。所有弹窗均为模态对话框,避免操作混淆。
二、技术选型与依赖库
• PyQt5:作为GUI框架,它提供了丰富的控件和信号/槽机制,能够快速构建专业桌面应用。相比其他方案,PyQt5在布局管理、事件处理以及跨平台性上均有成熟表现。 • pandas:负责Excel文件的读取( pd.read_excel)以及数据筛选、排序、去重等操作,其DataFrame结构是数据处理的核心。• matplotlib:用于绘制折线图,并嵌入到PyQt5的 FigureCanvas中,实现图表与界面无缝集成。同时利用plt.cm.tab20颜色映射为不同折线自动分配鲜明颜色。• numpy:提供高效的数组运算,用于统计量的计算( min、max、mean)。• openpyxl:作为pandas导出Excel的引擎,支持写入 .xlsx格式。
所有依赖库均可通过pip一键安装,工具在启动时会进行隐式检查(若缺失则抛出明确错误提示)。
三、界面布局与交互流程
主窗口采用水平分割布局(QSplitter),左侧占约40%宽度,右侧占60%。左侧自上而下分为三个区域:
• 文件控制区:包含“打开Excel文件”按钮、当前文件名显示以及工作表下拉选择框。当Excel包含多个工作表时,下拉框自动启用,切换工作表即重新加载数据并刷新所有图表和统计信息。 • 列统计区:一个三列表格,动态展示当前工作表的每一列名称、非空计数和数据类型,帮助用户快速确认数据列对应关系。 • 查询控制区:核心交互区域,包括查询方向下拉框(“按X区间查Y”或“按Y区间查X”)、折线编号下拉框(自动填充所有有效折线,并显示对应列名)、最小值/最大值输入框(支持小数,精度6位)、“查询”与“导出结果Excel”两个主要按钮。查询结果表格和统计摘要标签位于该区域下部,实时显示筛选后的数据子集与关键统计值。
右侧为一个FigureCanvas,初始为空,加载数据后立即绘制全部折线图。图表包含图例、网格线和坐标轴标签,并自动保存为当前目录下的xy_chart.png文件。
整个流程遵循“先加载、后绘图、再查询”的顺序,所有控件在未加载数据时均处于禁用状态,避免误操作。
四、核心功能的数学与实现
4.1 数据分组与绘图
假设Excel共有列,则有效数据组数为,每组由第列(X)和第列(Y)构成()。对于每一组,程序会剔除含有缺失值的行,并按X值升序排序,以保证折线图的正确绘制。随后,利用matplotlib的plot函数绘制连线,并添加圆点标记以强调数据点。
颜色分配采用plt.cm.tab20,该颜色映射提供20种鲜艳且易于区分的颜色,当组数超过20时自动循环。图例中会标注“折线编号(X列名,Y列名)”,方便用户后续查询时对应。
4.2 区间查询的数学描述
用户选定第条折线(,其中为总组数),该折线对应的数据点为,。当选择“按X区间查Y”时,用户给定和,筛选条件为:
满足条件的所有点构成子集,工具返回该子集中每个点的X和Y值,并计算Y值的统计量:
当时,提示无数据点。
同理,“按Y区间查X”给定和,筛选条件为,返回子集的X和Y值,并计算X值的统计量:
这些统计量不仅显示在界面上,还会被保存到导出的Excel摘要表中。
4.3 导出机制
每次查询后,程序会缓存当前筛选结果的DataFrame以及一个包含查询参数和统计值的字典。点击“导出结果Excel”时,弹出自定义保存路径对话框,利用pd.ExcelWriter将数据写入两个工作表:第一个为“查询数据”,直接保存筛选后的完整数据(包含原始列名);第二个为“统计摘要”,将字典转为单行DataFrame写入。这种双表结构既保留了原始细节,又提供了快速查阅的汇总信息。
五、界面交互的细节优化
• 防重入机制:在切换工作表时,设置一个 _loading标志,避免因下拉框信号触发多次加载而导致冲突。• 大数据集友好:当查询结果超过200行时,表格仅显示前100行,并在首行提示总行数,防止界面卡顿。 • 实时状态反馈:状态栏显示当前加载的工作表名称、行/列数;按钮的启用/禁用状态随数据加载与查询结果动态变化。 • 中文字体支持:通过 matplotlib.rcParams统一设置全局中文字体(如微软雅黑),确保坐标轴、标题和图例中的中文正常显示。
六、完整代码实现
以下为工具的全部源代码,可直接保存为.py文件并运行(需预先安装依赖库)。代码结构清晰,主要逻辑封装在ExcelXYAnalyzer类中,main函数负责启动应用并最大化窗口。
# -*- coding: utf-8 -*-
"""
Excel XY 折线图分析工具(PyQt5 桌面版)
功能:
1. 选择 Excel 文件(支持多工作表切换)
2. 显示列统计信息(列名、非空数、数据类型)
3. 自动按"每两列一组"(X=变形, Y=力) 绘制多色 XY 折线图(嵌入窗口)
4. 交互式查询:选择折线编号 + 输入 X/Y 区间,显示数据及统计摘要
5. 查询结果可导出为 Excel(含原始数据 + 统计摘要)
"""
import sys
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QFileDialog, QMessageBox, QTableWidget,
QTableWidgetItem, QComboBox, QGroupBox, QDoubleSpinBox,
QSplitter, QHeaderView, QGridLayout
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
# 设置 matplotlib 中文字体
matplotlib.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'DejaVu Sans']
matplotlib.rcParams['axes.unicode_minus'] = False
classExcelXYAnalyzer(QMainWindow):
def__init__(self):
super().__init__()
self.setWindowTitle("Excel XY 折线图分析工具")
self.setMinimumSize(1200, 800)
# 数据成员
self.file_path = None
self.df = None
self.groups = []
self.current_sheet = None
self.last_result_df = None
self.last_summary_info = None
self._loading = False
self._init_ui()
self.statusBar().showMessage("就绪")
def_init_ui(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout(central)
main_layout.setSpacing(10)
# 水平分割
splitter = QSplitter(Qt.Horizontal)
main_layout.addWidget(splitter)
# ---- 左侧面板 ----
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setSpacing(10)
# 文件组
file_group = QGroupBox("文件")
file_layout = QHBoxLayout()
self.btn_open = QPushButton("打开 Excel 文件")
self.btn_open.clicked.connect(self.open_file)
self.lbl_file = QLabel("未选择文件")
self.lbl_file.setWordWrap(True)
self.cb_sheet = QComboBox()
self.cb_sheet.currentIndexChanged.connect(self.on_sheet_changed)
file_layout.addWidget(self.btn_open)
file_layout.addWidget(self.lbl_file, 1)
file_layout.addWidget(QLabel("工作表:"))
file_layout.addWidget(self.cb_sheet)
file_group.setLayout(file_layout)
left_layout.addWidget(file_group)
# 统计组
stats_group = QGroupBox("列统计信息")
stats_layout = QVBoxLayout()
self.table_stats = QTableWidget()
self.table_stats.setColumnCount(3)
self.table_stats.setHorizontalHeaderLabels(["列名", "非空数据", "数据类型"])
self.table_stats.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table_stats.setEditTriggers(QTableWidget.NoEditTriggers)
stats_layout.addWidget(self.table_stats)
stats_group.setLayout(stats_layout)
left_layout.addWidget(stats_group, 1)
# 查询组
query_group = QGroupBox("数据查询")
query_layout = QGridLayout()
query_layout.addWidget(QLabel("查询方向:"), 0, 0)
self.cb_direction = QComboBox()
self.cb_direction.addItems(["按 X 区间查 Y", "按 Y 区间查 X"])
query_layout.addWidget(self.cb_direction, 0, 1, 1, 2)
query_layout.addWidget(QLabel("折线编号:"), 1, 0)
self.cb_line = QComboBox()
query_layout.addWidget(self.cb_line, 1, 1, 1, 2)
query_layout.addWidget(QLabel("最小值:"), 2, 0)
self.spin_min = QDoubleSpinBox()
self.spin_min.setRange(-1e9, 1e9)
self.spin_min.setDecimals(6)
self.spin_min.setSingleStep(0.001)
query_layout.addWidget(self.spin_min, 2, 1)
query_layout.addWidget(QLabel("最大值:"), 2, 2)
self.spin_max = QDoubleSpinBox()
self.spin_max.setRange(-1e9, 1e9)
self.spin_max.setDecimals(6)
self.spin_max.setSingleStep(0.001)
query_layout.addWidget(self.spin_max, 2, 3)
self.btn_query = QPushButton("查询")
self.btn_query.clicked.connect(self.on_query)
query_layout.addWidget(self.btn_query, 3, 0, 1, 2)
self.btn_export = QPushButton("导出结果 Excel")
self.btn_export.clicked.connect(self.export_result)
query_layout.addWidget(self.btn_export, 3, 2, 1, 2)
query_layout.addWidget(QLabel("查询结果:"), 4, 0, 1, 4)
self.table_result = QTableWidget()
self.table_result.setColumnCount(2)
self.table_result.setHorizontalHeaderLabels(["X (变形 mm)", "Y (力 N)"])
self.table_result.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table_result.setEditTriggers(QTableWidget.NoEditTriggers)
query_layout.addWidget(self.table_result, 5, 0, 1, 4)
query_layout.addWidget(QLabel("统计摘要:"), 6, 0, 1, 4)
self.lbl_summary = QLabel("未查询")
self.lbl_summary.setStyleSheet("background-color: #f0f0f0; padding: 5px; border: 1px solid #ccc;")
self.lbl_summary.setWordWrap(True)
query_layout.addWidget(self.lbl_summary, 7, 0, 1, 4)
query_group.setLayout(query_layout)
left_layout.addWidget(query_group, 2)
splitter.addWidget(left)
# ---- 右侧图表面板 ----
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
self.figure = Figure(figsize=(8, 6), dpi=100)
self.canvas = FigureCanvas(self.figure)
self.canvas.setMinimumHeight(400)
right_layout.addWidget(self.canvas)
splitter.addWidget(right)
splitter.setSizes([int(self.width() * 0.4), int(self.width() * 0.6)])
# 初始禁用
self.cb_sheet.setEnabled(False)
self.cb_line.setEnabled(False)
self.spin_min.setEnabled(False)
self.spin_max.setEnabled(False)
self.btn_query.setEnabled(False)
self.btn_export.setEnabled(False)
defopen_file(self):
file_path, _ = QFileDialog.getOpenFileName(
self, "选择 Excel 数据文件", "",
"Excel 文件 (*.xlsx *.xls);;所有文件 (*.*)"
)
ifnot file_path:
return
self.file_path = file_path
self.lbl_file.setText(os.path.basename(file_path))
self.statusBar().showMessage(f"正在加载: {file_path}")
try:
xl = pd.ExcelFile(file_path)
sheets = xl.sheet_names
self.cb_sheet.clear()
self.cb_sheet.addItems(sheets)
self.cb_sheet.setEnabled(len(sheets) > 1)
self.current_sheet = sheets[0]
self.cb_sheet.setCurrentIndex(0)
self._load_sheet(self.current_sheet)
except Exception as e:
QMessageBox.critical(self, "读取失败", f"无法读取 Excel 文件:\n{e}")
self.statusBar().showMessage("加载失败")
defon_sheet_changed(self, index):
if index < 0orself.cb_sheet.count() == 0orself._loading:
return
sheet_name = self.cb_sheet.currentText()
if sheet_name != self.current_sheet:
self._load_sheet(sheet_name)
def_load_sheet(self, sheet_name):
ifself._loading:
return
self._loading = True
try:
self.df = pd.read_excel(self.file_path, sheet_name=sheet_name)
self.current_sheet = sheet_name
self.statusBar().showMessage(f"已加载工作表: {sheet_name}")
# 清空旧数据
self.groups.clear()
self.last_result_df = None
self.last_summary_info = None
self.table_result.setRowCount(0)
self.table_result.setColumnCount(2)
self.table_result.setHorizontalHeaderLabels(["X (变形 mm)", "Y (力 N)"])
self.lbl_summary.setText("未查询")
self.cb_line.clear()
self._update_stats_table()
self._build_groups()
self._plot_chart()
self._update_line_combo()
self.cb_line.setEnabled(True)
self.spin_min.setEnabled(True)
self.spin_max.setEnabled(True)
self.btn_query.setEnabled(True)
self.btn_export.setEnabled(False)
self.statusBar().showMessage(f"就绪 | 工作表: {sheet_name} | 列数: {len(self.df.columns)} | 行数: {len(self.df)}")
except Exception as e:
QMessageBox.critical(self, "加载失败", f"无法加载工作表 {sheet_name}:\n{e}")
self.statusBar().showMessage("加载失败")
finally:
self._loading = False
def_update_stats_table(self):
ifself.df isNone:
return
self.table_stats.setRowCount(len(self.df.columns))
for i, col inenumerate(self.df.columns):
non_null = self.df[col].notna().sum()
dtype = str(self.df[col].dtype)
self.table_stats.setItem(i, 0, QTableWidgetItem(str(col)))
self.table_stats.setItem(i, 1, QTableWidgetItem(str(non_null)))
self.table_stats.setItem(i, 2, QTableWidgetItem(dtype))
self.table_stats.resizeColumnsToContents()
def_build_groups(self):
self.groups = []
n_cols = len(self.df.columns)
n_groups = n_cols // 2
if n_groups == 0:
QMessageBox.warning(self, "数据不足", "列数少于2列,无法绘制折线图。")
return
for g inrange(n_groups):
x_col = self.df.columns[2 * g]
y_col = self.df.columns[2 * g + 1]
pair = self.df[[x_col, y_col]].dropna()
pair = pair.sort_values(by=x_col).reset_index(drop=True)
x_data = pair[x_col].values
y_data = pair[y_col].values
self.groups.append({
'index': g + 1,
'x_col': x_col,
'y_col': y_col,
'x_data': x_data,
'y_data': y_data,
'df': pair
})
if n_cols % 2 != 0:
QMessageBox.information(self, "提示", f"列数为奇数,最后一列 '{self.df.columns[-1]}' 将被忽略。")
def_update_line_combo(self):
self.cb_line.clear()
ifnotself.groups:
self.cb_line.addItem("无")
return
for g inself.groups:
self.cb_line.addItem(f"折线 {g['index']} (X:{g['x_col']}, Y:{g['y_col']})")
self.cb_line.setCurrentIndex(0)
def_plot_chart(self):
ifnotself.groups:
return
self.figure.clear()
ax = self.figure.add_subplot(111)
colors = plt.cm.tab20(np.linspace(0, 1, max(len(self.groups), 20)))
for i, g inenumerate(self.groups):
label = f"折线 {g['index']} (X:{g['x_col']}, Y:{g['y_col']})"
ax.plot(g['x_data'], g['y_data'], color=colors[i],
linewidth=1.5, marker='o', markersize=3, label=label)
ax.set_xlabel("变形 / 大变形 (mm)", fontsize=12)
ax.set_ylabel("力 (N)", fontsize=12)
ax.set_title("力-变形 XY 折线图", fontsize=14, fontweight='bold')
ax.legend(loc='best', fontsize=9)
ax.grid(True, linestyle='--', alpha=0.5)
self.figure.tight_layout()
self.canvas.draw()
try:
self.figure.savefig("xy_chart.png", dpi=150)
except:
pass
defon_query(self):
ifnotself.groups:
QMessageBox.warning(self, "无数据", "请先加载有效数据。")
return
line_idx = self.cb_line.currentIndex()
if line_idx < 0or line_idx >= len(self.groups):
QMessageBox.warning(self, "无效编号", "请选择有效的折线。")
return
g = self.groups[line_idx]
direction = self.cb_direction.currentIndex()
v_min = self.spin_min.value()
v_max = self.spin_max.value()
if v_min > v_max:
v_min, v_max = v_max, v_min
if direction == 0: # X->Y
col = g['x_col']
target_col = g['y_col']
result_cols = [g['x_col'], g['y_col']]
header = ["X (变形 mm)", "Y (力 N)"]
summary_keys = ["Y最小值", "Y最大值", "Y平均值"]
else: # Y->X
col = g['y_col']
target_col = g['x_col']
result_cols = [g['y_col'], g['x_col']]
header = ["Y (力 N)", "X (变形 mm)"]
summary_keys = ["X最小值", "X最大值", "X平均值"]
mask = (g['df'][col] >= v_min) & (g['df'][col] <= v_max)
result = g['df'][mask].reset_index(drop=True)
self.last_result_df = result.copy() iflen(result) > 0elseNone
self.last_summary_info = {}
self.table_result.clear()
self.table_result.setRowCount(0)
iflen(result) == 0:
self.table_result.setColumnCount(1)
self.table_result.setHorizontalHeaderLabels(["提示"])
self.table_result.setRowCount(1)
self.table_result.setItem(0, 0, QTableWidgetItem("该区间内无数据点"))
self.lbl_summary.setText("无数据")
self.last_summary_info = {
"折线编号": g['index'],
"查询方向": "按X查Y"if direction == 0else"按Y查X",
"区间": f"[{v_min}, {v_max}]",
"数据点数": 0,
}
self.btn_export.setEnabled(False)
return
# 显示数据
display_limit = 200
iflen(result) > display_limit:
display_df = result.head(100)
self.table_result.setColumnCount(len(header) + 1)
self.table_result.setHorizontalHeaderLabels(["序号"] + header)
self.table_result.setRowCount(len(display_df) + 1)
self.table_result.setItem(0, 0, QTableWidgetItem("提示"))
self.table_result.setItem(0, 1, QTableWidgetItem(f"数据量较大,仅显示前100行,共{len(result)}行"))
for i, (idx, data) inenumerate(display_df.iterrows()):
self.table_result.setItem(i+1, 0, QTableWidgetItem(str(idx+1)))
for j, col_name inenumerate(result_cols):
self.table_result.setItem(i+1, j+1, QTableWidgetItem(f"{data[col_name]:.6f}"))
else:
self.table_result.setColumnCount(len(header) + 1)
self.table_result.setHorizontalHeaderLabels(["序号"] + header)
self.table_result.setRowCount(len(result))
for i, row in result.iterrows():
self.table_result.setItem(i, 0, QTableWidgetItem(str(i+1)))
for j, col_name inenumerate(result_cols):
self.table_result.setItem(i, j+1, QTableWidgetItem(f"{row[col_name]:.6f}"))
# 统计摘要
target_vals = result[target_col].values
min_val = np.min(target_vals)
max_val = np.max(target_vals)
mean_val = np.mean(target_vals)
self.lbl_summary.setText(
f"数据点数: {len(result)}\n"
f"{summary_keys[0]}: {min_val:.6f}\n"
f"{summary_keys[1]}: {max_val:.6f}\n"
f"{summary_keys[2]}: {mean_val:.6f}"
)
self.last_summary_info = {
"折线编号": g['index'],
"查询方向": "按X区间查Y"if direction == 0else"按Y区间查X",
"区间": f"[{v_min}, {v_max}]",
"数据点数": len(result),
summary_keys[0]: round(float(min_val), 6),
summary_keys[1]: round(float(max_val), 6),
summary_keys[2]: round(float(mean_val), 6),
}
self.btn_export.setEnabled(True)
defexport_result(self):
ifself.last_result_df isNoneorlen(self.last_result_df) == 0:
QMessageBox.warning(self, "无数据", "当前没有可导出的查询结果。")
return
file_path, _ = QFileDialog.getSaveFileName(
self, "保存查询结果", "query_result.xlsx",
"Excel 文件 (*.xlsx);;所有文件 (*.*)"
)
ifnot file_path:
return
try:
with pd.ExcelWriter(file_path, engine='openpyxl') as writer:
self.last_result_df.to_excel(writer, sheet_name='查询数据', index=False)
ifself.last_summary_info:
summary_df = pd.DataFrame([self.last_summary_info])
summary_df.to_excel(writer, sheet_name='统计摘要', index=False)
QMessageBox.information(self, "保存成功", f"查询结果已保存至:\n{file_path}")
except Exception as e:
QMessageBox.critical(self, "保存失败", str(e))
defmain():
app = QApplication(sys.argv)
app.setFont(QFont("微软雅黑", 9))
window = ExcelXYAnalyzer()
window.showMaximized()
sys.exit(app.exec_())
if __name__ == "__main__":
main()七、使用示例与适用场景
以典型的拉伸试验数据为例,Excel中包含“位移(mm)”和“载荷(N)”等多组对应列。用户启动工具后,打开文件,左侧统计表格立即显示各列的非空数量,右侧生成彩色曲线图。若需查询位移在范围内的载荷值,选择对应折线,设置最小值和最大值,点击查询,结果表格即列出所有符合条件的数据点,同时统计摘要给出该区间内载荷的最小值、最大值和平均值。若需导出这些数据,点击“导出结果Excel”即可保存。同理,若需查找载荷在区间对应的位移范围,仅需切换查询方向。
该工具不仅适用于力学测试,也适用于任何成对出现的XY数据序列,如温度-电阻、时间-浓度等。其自动化分组与直观交互显著提升了数据分析效率,减少了重复性手工筛选工作。
八、总结
本文展示了一款基于PyQt5的Excel折线图分析工具,它围绕“自动绘图—双向区间查询—结果导出”这一核心流程,提供了完整且易用的桌面解决方案。通过将pandas的数据处理能力、matplotlib的可视化能力与PyQt5的丰富界面控件相结合,实现了从原始数据到信息提炼的无缝衔接。工具在设计时注重用户操作习惯,所有反馈即时可见,且具备良好的扩展性(例如未来可增加曲线拟合、多坐标轴等功能)。该工具已在多个工程数据分析场景中得到应用,证明其可靠性与实用性。对于需要频繁处理成对XY数据的科研或工程人员,本工具是一个轻量而强大的助手。


陪伴是最长情的告白
为你推送最实用的资讯

识别二维码 关注我们