import pandas as pd import tkinter as tk from tkinter import filedialog import os from datetime import datetime import numpy as np import matplotlib.pyplot as plt import seaborn as sns from io import BytesIO import base64 import multiprocessing as mp from concurrent.futures import ProcessPoolExecutor, as_completed import time import json import traceback # 设置中文字体 plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False def plot_worker(args): """工作进程函数:生成单个分组的图表""" try: group_key, feature_data_dict, limits_dict = args # 每个进程重新设置matplotlib配置,避免线程冲突 plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False results = {} for feature_name, feature_data in feature_data_dict.items(): if len(feature_data) == 0: results[feature_name] = "" continue usl, lsl = limits_dict[feature_name] # 创建图表 fig, axes = plt.subplots(2, 2, figsize=(12, 10)) fig.suptitle(f'{group_key} - {feature_name} 统计分析', fontsize=14) # 1. 直方图 axes[0, 0].hist(feature_data, bins=15, alpha=0.7, color='skyblue', edgecolor='black') axes[0, 0].axvline(usl, color='red', linestyle='--', label=f'上限: {usl:.2f}', linewidth=1) axes[0, 0].axvline(lsl, color='green', linestyle='--', label=f'下限: {lsl:.2f}', linewidth=1) axes[0, 0].axvline(feature_data.mean(), color='orange', linestyle='-', label=f'均值: {feature_data.mean():.2f}', linewidth=1.5) axes[0, 0].set_title('直方图') axes[0, 0].set_xlabel(feature_name) axes[0, 0].set_ylabel('频数') axes[0, 0].legend(fontsize=8) axes[0, 0].grid(True, alpha=0.3) # 2. 箱线图 sns.boxplot(y=feature_data, ax=axes[0, 1], color='lightblue') axes[0, 1].axhline(usl, color='red', linestyle='--', label=f'上限: {usl:.2f}', linewidth=1) axes[0, 1].axhline(lsl, color='green', linestyle='--', label=f'下限: {lsl:.2f}', linewidth=1) axes[0, 1].set_title('箱线图') axes[0, 1].set_ylabel(feature_name) axes[0, 1].legend(fontsize=8) axes[0, 1].grid(True, alpha=0.3) # 3. 序列图 axes[1, 0].plot(range(len(feature_data)), feature_data, 'o-', color='blue', alpha=0.7, markersize=3, linewidth=1) axes[1, 0].axhline(usl, color='red', linestyle='--', label=f'上限: {usl:.2f}', linewidth=1) axes[1, 0].axhline(lsl, color='green', linestyle='--', label=f'下限: {lsl:.2f}', linewidth=1) axes[1, 0].axhline(feature_data.mean(), color='orange', linestyle='-', label=f'均值: {feature_data.mean():.2f}', linewidth=1.5) axes[1, 0].set_title('序列图') axes[1, 0].set_xlabel('数据点序号') axes[1, 0].set_ylabel(feature_name) axes[1, 0].legend(fontsize=8) axes[1, 0].grid(True, alpha=0.3) # 4. 概率密度图 sns.kdeplot(feature_data, ax=axes[1, 1], color='blue', fill=True, alpha=0.5) axes[1, 1].axvline(usl, color='red', linestyle='--', label=f'上限: {usl:.2f}', linewidth=1) axes[1, 1].axvline(lsl, color='green', linestyle='--', label=f'下限: {lsl:.2f}', linewidth=1) axes[1, 1].axvline(feature_data.mean(), color='orange', linestyle='-', label=f'均值: {feature_data.mean():.2f}', linewidth=1.5) axes[1, 1].set_title('概率密度图') axes[1, 1].set_xlabel(feature_name) axes[1, 1].set_ylabel('密度') axes[1, 1].legend(fontsize=8) axes[1, 1].grid(True, alpha=0.3) plt.tight_layout() # 转换为base64 buffer = BytesIO() plt.savefig(buffer, format='png', dpi=80, bbox_inches='tight') buffer.seek(0) image_base64 = base64.b64encode(buffer.getvalue()).decode() plt.close(fig) results[feature_name] = image_base64 return group_key, results except Exception as e: print(f"❌ 图表生成失败 {group_key}: {e}") print(f" 错误详情: {traceback.format_exc()}") return group_key, {} class DataProcessor: def __init__(self): self.data = None self.filename = None self.file_path = None self.file_dir = None # 新增:存储输入文件所在目录 self.stats = None self.output_dir = None self.progress_file = None def select_file(self): """手动选择数据文件""" print("打开文件选择对话框...") root = tk.Tk() root.withdraw() self.file_path = filedialog.askopenfilename( title="选择数据文件", filetypes=[("Excel files", "*.xlsx"), ("CSV files", "*.csv"), ("All files", "*.*")] ) if self.file_path: self.filename = os.path.basename(self.file_path) self.file_dir = os.path.dirname(self.file_path) # 获取文件所在目录 print(f"✅ 已选择文件: {self.filename}") print(f"📁 文件所在目录: {self.file_dir}") return True else: print("❌ 未选择文件") return False def _load_data(self): """加载数据文件""" print("开始加载数据文件...") try: if self.file_path.endswith('.csv'): self.data = pd.read_csv(self.file_path) print("✅ 成功加载CSV文件") elif self.file_path.endswith('.xlsx'): self.data = pd.read_excel(self.file_path) print("✅ 成功加载Excel文件") else: raise ValueError("不支持的文件格式") print(f"📊 数据文件形状: {self.data.shape}") except Exception as e: print(f"❌ 加载数据文件时出错: {e}") print(f" 错误详情: {traceback.format_exc()}") raise def _validate_data(self): """验证数据完整性 - 增强验证:检查上下限列""" print("验证数据完整性...") # 检查必要的测量列 required_measure_columns = ['PAD ID', 'Component ID', 'Height(mil)', 'Volume(%)', 'Area(%)'] missing_measure_columns = [col for col in required_measure_columns if col not in self.data.columns] if missing_measure_columns: error_msg = f"数据文件中缺少必要的测量列: {missing_measure_columns}" print(f"❌ {error_msg}") raise ValueError(error_msg) # 检查必要的上下限列 required_limit_columns = ['Height_Low(mil)', 'Height_High(mil)', 'Vol_Min(%)', 'Vol_Max(%)', 'Area_Min(%)', 'Area_Max(%)'] missing_limit_columns = [col for col in required_limit_columns if col not in self.data.columns] if missing_limit_columns: error_msg = f"数据文件中缺少必要的上下限列: {missing_limit_columns}" print(f"❌ {error_msg}") raise ValueError(error_msg) print("✅ 数据验证通过") # 检查数据是否存在空值 all_required_columns = required_measure_columns + required_limit_columns null_counts = self.data[all_required_columns].isnull().sum() if null_counts.any(): print(f"⚠️ 数据中存在空值 - {null_counts[null_counts > 0].to_dict()}") def _setup_output_directory(self): """设置输出目录""" timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') base_name = os.path.splitext(self.filename)[0] # 优化:输出目录放置在输入文件所在文件夹下 self.output_dir = os.path.join(self.file_dir, f"{base_name}_report_{timestamp}") # 创建主目录 os.makedirs(self.output_dir, exist_ok=True) # 创建分组报告子目录 os.makedirs(os.path.join(self.output_dir, 'group_reports'), exist_ok=True) # 创建进度文件 self.progress_file = os.path.join(self.output_dir, 'progress.json') print(f"📁 输出目录: {self.output_dir}") def _save_progress(self, completed_groups=None, current_stage=None): """保存处理进度""" try: progress = { 'filename': self.filename, 'total_groups': len(self.stats.index) if self.stats is not None else 0, 'completed_groups': completed_groups or [], 'current_stage': current_stage, 'last_update': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'input_file_directory': self.file_dir, # 记录输入文件目录 'output_directory': self.output_dir # 记录输出目录 } with open(self.progress_file, 'w', encoding='utf-8') as f: json.dump(progress, f, indent=2, ensure_ascii=False) except Exception as e: print(f"⚠️ 保存进度失败: {e}") def generate_report(self): """生成统计报告 - 分阶段输出""" if self.data is None: raise ValueError("请先选择数据文件") try: # 验证数据 self._validate_data() # 设置输出目录 self._setup_output_directory() print("开始数据处理...") # 创建分组键 self.data['Group_Key'] = self.data['PAD ID'].astype(str) + '_' + self.data['Component ID'].astype(str) group_count = self.data['Group_Key'].nunique() print(f"📊 共发现 {group_count} 个分组") # 阶段1:快速生成基本统计信息和汇总报告 print("\n=== 阶段1: 生成基本统计信息 ===") # 计算测量数据的统计信息 self.stats = self.data.groupby('Group_Key').agg({ 'Height(mil)': ['min', 'max', 'mean', 'std'], 'Volume(%)': ['min', 'max', 'mean', 'std'], 'Area(%)': ['min', 'max', 'mean', 'std'] }).round(4) # 重命名测量统计列 self.stats.columns = [ 'Height_Measured_Min(mil)', 'Height_Measured_Max(mil)', 'Height_Mean(mil)', 'Height_Std(mil)', 'Vol_Measured_Min(%)', 'Vol_Measured_Max(%)', 'Vol_Mean(%)', 'Vol_Std(%)', 'Area_Measured_Min(%)', 'Area_Measured_Max(%)', 'Area_Mean(%)', 'Area_Std(%)' ] print("基本统计信息计算完成") # 获取预设的上下限信息 print("获取预设上下限信息...") limits = self.data.groupby('Group_Key').agg({ 'Height_Low(mil)': 'first', # 取第一个值作为该分组的预设下限 'Height_High(mil)': 'first', # 取第一个值作为该分组的预设上限 'Vol_Min(%)': 'first', 'Vol_Max(%)': 'first', 'Area_Min(%)': 'first', 'Area_Max(%)': 'first' }).round(4) # 合并统计信息和预设上下限信息 self.stats = pd.concat([self.stats, limits], axis=1) print("预设上下限信息获取完成") # 计算CPK - 使用预设的上下限值 print("计算CPK值...") self.stats = self._calculate_cpk(self.stats) # 立即生成汇总报告 summary_report_path = self._create_summary_report() print(f"✅ 汇总报告生成完成: {summary_report_path}") # 保存Excel excel_path = self._save_to_excel_advanced() print(f"✅ Excel文件保存完成: {excel_path}") # 阶段2:分批生成详细分组报告 print("\n=== 阶段2: 分批生成详细分组报告 ===") self._generate_group_reports_incremental() # 阶段3:生成索引文件(可选) print("\n=== 阶段3: 生成报告索引 ===") index_path = self._create_report_index() print(f"✅ 报告索引生成完成: {index_path}") return summary_report_path except Exception as e: print(f"❌ 程序执行失败: {e}") print(f" 错误详情: {traceback.format_exc()}") # 即使失败,也尝试保存当前进度 if hasattr(self, 'output_dir'): print(f"📁 当前结果已保存到: {self.output_dir}") raise def _create_summary_report(self): """创建快速汇总报告(区分预设上下限和实测值)""" print("生成快速汇总报告...") # 使用明确的空值检查 if self.stats is None or len(self.stats.index) == 0: print("⚠️ 统计数据为空,生成空报告") return self._create_empty_report() # 将索引转换为列表,避免DataFrame布尔判断问题 stats_index = list(self.stats.index) total_groups = len(stats_index) # 安全地检查CPK列是否存在 valid_height_cpk = 0 valid_volume_cpk = 0 valid_area_cpk = 0 if 'Height_Cpk' in self.stats.columns: valid_height_cpk = self.stats['Height_Cpk'].notna().sum() if 'Volume_Cpk' in self.stats.columns: valid_volume_cpk = self.stats['Volume_Cpk'].notna().sum() if 'Area_Cpk' in self.stats.columns: valid_area_cpk = self.stats['Area_Cpk'].notna().sum() html_content = f"""
生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
输入文件位置: {self.file_dir}
此报告为快速生成的汇总报告,包含所有分组的基本统计信息。
CPK计算使用预设的上下限值,而不是实测的最小最大值。
注意:分组详细报告可能需要较长时间生成,请勿关闭程序。
总分组数量: {total_groups}
有效Height CPK数量: {valid_height_cpk}
有效Volume CPK数量: {valid_volume_cpk}
有效Area CPK数量: {valid_area_cpk}
输出目录: {self.output_dir}
| 分组标识 (PAD ID + Component ID) |
Height(mil) | Volume(%) | Area(%) | {'CPK值 | ' if 'Height_Cpk' in self.stats.columns else ''}|||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 预设下限 (LSL) |
预设上限 (USL) |
实测最小值 | 实测最大值 | 平均值 | 标准差 | 数据点数 | CPK | 预设下限 (LSL) |
预设上限 (USL) |
实测最小值 | 实测最大值 | 平均值 | 标准差 | 数据点数 | CPK | 预设下限 (LSL) |
预设上限 (USL) |
实测最小值 | 实测最大值 | 平均值 | 标准差 | 数据点数 | CPK | 分组 | {format_value(row['Height_Cpk'])} | """, "volume": f"""{format_value(row['Volume_Cpk'])} | """, "area": f"""{format_value(row['Area_Cpk'])} | """ } # 为CPK值添加颜色标识 def get_cpk_color(cpk_value): """根据CPK值返回颜色标识""" if pd.isna(cpk_value): return '' try: cpk_val = float(cpk_value) if cpk_val >= 1.33: return 'style="background-color: #90EE90;"' # 绿色 - 优秀 elif cpk_val >= 1.0: return 'style="background-color: #FFFFE0;"' # 黄色 - 合格 else: return 'style="background-color: #FFB6C1;"' # 红色 - 不合格 except: return '' # 如果存在CPK列,添加颜色 if 'Height_Cpk' in self.stats.columns: # 这里需要为每个CPK单元格单独设置颜色 height_color = get_cpk_color(row['Height_Cpk']) volume_color = get_cpk_color(row['Volume_Cpk']) area_color = get_cpk_color(row['Area_Cpk']) cpk_columns = { "height": f"""{format_value(row['Height_Cpk'])} | """, "volume": f"""{format_value(row['Volume_Cpk'])} | """, "area": f"""{format_value(row['Area_Cpk'])} | """ } html_content += f"""
| {group_key} | {format_value(row['Height_Low(mil)'])} | {format_value(row['Height_High(mil)'])} | {format_value(row['Height_Measured_Min(mil)'])} | {format_value(row['Height_Measured_Max(mil)'])} | {format_value(row['Height_Mean(mil)'])} | {format_value(row['Height_Std(mil)'])} | {data_count} | {cpk_columns["height"]}{format_value(row['Vol_Min(%)'])} | {format_value(row['Vol_Max(%)'])} | {format_value(row['Vol_Measured_Min(%)'])} | {format_value(row['Vol_Measured_Max(%)'])} | {format_value(row['Vol_Mean(%)'])} | {format_value(row['Vol_Std(%)'])} | {data_count} | {cpk_columns["volume"]}{format_value(row['Area_Min(%)'])} | {format_value(row['Area_Max(%)'])} | {format_value(row['Area_Measured_Min(%)'])} | {format_value(row['Area_Measured_Max(%)'])} | {format_value(row['Area_Mean(%)'])} | {format_value(row['Area_Std(%)'])} | {data_count} | {cpk_columns["area"]}{group_key} | |||||
绿色背景: 预设的上下限值(用于CPK计算)
黄色背景: 实测数据的最小最大值
白色背景: 统计计算值
CPK计算公式: CPK = min[(USL - mean) / (3×std), (mean - LSL) / (3×std)]
上下限取值: 使用数据文件中的预设上下限值,而不是实测的最小最大值
绿色 CPK ≥ 1.33 (过程能力优秀)
黄色 1.0 ≤ CPK < 1.33 (过程能力合格)
红色 CPK < 1.0 (过程能力不足)
未找到有效数据或统计数据为空。
生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
输入文件位置: {self.file_dir}
生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
输入文件位置: {self.file_dir}
| 特征 | 预设下限(LSL) | 预设上限(USL) | 实测最小值 | 实测最大值 | 平均值 | 标准差 | CPK |
|---|---|---|---|---|---|---|---|
| Height(mil) | {safe_format(row.get('Height_Low(mil)'))} | {safe_format(row.get('Height_High(mil)'))} | {safe_format(row.get('Height_Measured_Min(mil)'))} | {safe_format(row.get('Height_Measured_Max(mil)'))} | {safe_format(row.get('Height_Mean(mil)'))} | {safe_format(row.get('Height_Std(mil)'))} | {safe_format(row.get('Height_Cpk'))} |
| Volume(%) | {safe_format(row.get('Vol_Min(%)'))} | {safe_format(row.get('Vol_Max(%)'))} | {safe_format(row.get('Vol_Measured_Min(%)'))} | {safe_format(row.get('Vol_Measured_Max(%)'))} | {safe_format(row.get('Vol_Mean(%)'))} | {safe_format(row.get('Vol_Std(%)'))} | {safe_format(row.get('Volume_Cpk'))} |
| Area(%) | {safe_format(row.get('Area_Min(%)'))} | {safe_format(row.get('Area_Max(%)'))} | {safe_format(row.get('Area_Measured_Min(%)'))} | {safe_format(row.get('Area_Measured_Max(%)'))} | {safe_format(row.get('Area_Mean(%)'))} | {safe_format(row.get('Area_Std(%)'))} | {safe_format(row.get('Area_Cpk'))} |
该特征的图表生成失败或数据不足。
""" html_content += """ """ filename = self._sanitize_filename(group_key) + '.html' group_reports_dir = os.path.join(self.output_dir, 'group_reports') report_path = os.path.join(group_reports_dir, filename) try: with open(report_path, 'w', encoding='utf-8') as f: f.write(html_content) except Exception as e: print(f"❌ 保存分组报告失败 {filename}: {e}") def _create_report_index(self): """创建分组报告索引""" # 确保使用正确的索引获取方式 if self.stats is None or len(self.stats.index) == 0: print("⚠️ 统计数据为空,创建空索引") return self._create_empty_index() stats_index = list(self.stats.index) # 转换为列表 html_content = """共生成 """ + str(len(stats_index)) + """ 个分组报告
输入文件位置: """ + self.file_dir + """
当前没有生成任何分组报告。
输入文件位置: """ + self.file_dir + """