app.py 112 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476
  1. """
  2. 缺陷集中性分析 - Streamlit 交互式可视化页面
  3. """
  4. import pandas as pd
  5. import numpy as np
  6. import matplotlib
  7. matplotlib.use("Agg")
  8. import matplotlib.pyplot as plt
  9. import matplotlib.font_manager as fm
  10. import seaborn as sns
  11. import streamlit as st
  12. import plotly.express as px
  13. import plotly.graph_objects as go
  14. import os
  15. from datetime import datetime
  16. from sklearn.cluster import DBSCAN
  17. from sklearn.decomposition import PCA
  18. from sklearn.preprocessing import StandardScaler
  19. from defect_analysis.data_quality import build_data_quality_report
  20. from app_utils import (
  21. apply_defect_filters,
  22. build_diagnostic_dashboard,
  23. build_ml_factor_insights,
  24. calculate_kpis,
  25. calculate_spc_metrics,
  26. generate_industry_diagnosis,
  27. get_missing_required_columns,
  28. normalize_defect_schema,
  29. TEMPLATE_COLUMNS,
  30. )
  31. # --- 中文字体设置 ---
  32. def setup_chinese_font():
  33. """设置中文字体"""
  34. font_paths = [
  35. r"C:\Windows\Fonts\msyh.ttc", # 微软雅黑
  36. r"C:\Windows\Fonts\simhei.ttf", # 黑体
  37. r"C:\Windows\Fonts\simsun.ttc", # 宋体
  38. r"C:\Windows\Fonts\malgun.ttf", # Malgun Gothic
  39. ]
  40. for fp in font_paths:
  41. if os.path.exists(fp):
  42. font_prop = fm.FontProperties(fname=fp)
  43. plt.rcParams["font.family"] = font_prop.get_name()
  44. plt.rcParams["axes.unicode_minus"] = False
  45. return font_prop
  46. # fallback
  47. plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "Arial Unicode MS"]
  48. plt.rcParams["axes.unicode_minus"] = False
  49. return None
  50. setup_chinese_font()
  51. # --- 页面配置 ---
  52. st.set_page_config(
  53. page_title="屏幕缺陷集中性分析",
  54. page_icon="🔍",
  55. layout="wide",
  56. initial_sidebar_state="expanded"
  57. )
  58. # --- 侧边栏 ---
  59. st.sidebar.title("🔍 筛选条件")
  60. # --- 数据源切换 ---
  61. st.sidebar.divider()
  62. st.sidebar.subheader("📂 数据源")
  63. data_source = st.sidebar.radio("选择数据源", ["内置模拟数据", "上传CSV文件"], label_visibility="collapsed")
  64. uploaded_df = None
  65. if data_source == "上传CSV文件":
  66. uploaded_file = st.sidebar.file_uploader("上传CSV文件", type=["csv"], accept_multiple_files=False)
  67. if uploaded_file is not None:
  68. try:
  69. uploaded_df = pd.read_csv(uploaded_file, parse_dates=["timestamp"])
  70. uploaded_df["timestamp"] = pd.to_datetime(uploaded_df["timestamp"])
  71. missing = get_missing_required_columns(uploaded_df)
  72. if missing:
  73. st.sidebar.error(f"缺少字段: {', '.join(missing)}")
  74. uploaded_df = None
  75. else:
  76. uploaded_df = normalize_defect_schema(uploaded_df)
  77. st.sidebar.success(f"已加载 {len(uploaded_df)} 条记录")
  78. st.sidebar.caption("已自动补齐缺陷几何、多工序机台、治具和材料批次等可选行业字段")
  79. # 下载模板
  80. template_df = pd.DataFrame(columns=TEMPLATE_COLUMNS)
  81. csv_template = template_df.to_csv(index=False, encoding="utf-8-sig")
  82. st.sidebar.download_button(
  83. label="📋 下载数据格式模板",
  84. data=csv_template,
  85. file_name="defect_data_template.csv",
  86. mime="text/csv"
  87. )
  88. except Exception as e:
  89. st.sidebar.error(f"CSV解析失败: {e}")
  90. uploaded_df = None
  91. else:
  92. st.sidebar.info("请选择一个CSV文件上传")
  93. # --- 加载数据 ---
  94. @st.cache_data(ttl=300)
  95. def load_data_from_csv():
  96. """加载内置模拟数据"""
  97. if not os.path.exists("defect_data.csv"):
  98. st.error("未找到 defect_data.csv,请先运行 generate_data.py 生成数据")
  99. return None
  100. df = pd.read_csv("defect_data.csv", parse_dates=["timestamp"])
  101. return normalize_defect_schema(df)
  102. @st.cache_data(ttl=300, show_spinner=False)
  103. def build_cached_ml_factor_insights(data, target_defect_type, model_name, top_n):
  104. """缓存 ML 训练洞察,避免页面交互时重复训练。"""
  105. return build_ml_factor_insights(
  106. data,
  107. target_defect_type=target_defect_type,
  108. model_name=model_name,
  109. top_n=top_n,
  110. )
  111. if data_source == "上传CSV文件" and uploaded_df is not None:
  112. df = uploaded_df
  113. else:
  114. df = load_data_from_csv()
  115. if df is None:
  116. st.stop()
  117. # --- 角色视图 ---
  118. st.sidebar.divider()
  119. st.sidebar.subheader("👤 视图模式")
  120. view_mode = st.sidebar.selectbox(
  121. "选择视图模式",
  122. options=["操作员", "工程师", "管理者"],
  123. index=1,
  124. help="操作员: 基础分析 | 工程师: 全部功能 | 管理者: KPI+SPC+健康评分"
  125. )
  126. # 各角色可见的 Tab
  127. tab_visibility = {
  128. "操作员": {
  129. "tabs": ["🗺️ 空间集中性", "📊 类型集中性 (帕累托)", "📈 时间集中性",
  130. "🏗️ 设备座号集中性", "🔬 缺陷模式识别", "🧭 诊断驾驶舱"],
  131. "show_kpi": True,
  132. "show_export": True,
  133. },
  134. "工程师": {
  135. "tabs": "all",
  136. "show_kpi": True,
  137. "show_export": True,
  138. },
  139. "管理者": {
  140. "tabs": ["🚨 SPC 控制图与预警", "🔬 缺陷模式识别", "💚 设备健康与共性分析",
  141. "📊 类型集中性 (帕累托)", "📈 时间集中性", "🧭 诊断驾驶舱"],
  142. "show_kpi": True,
  143. "show_export": True,
  144. },
  145. }
  146. # 应用 Tab 可见性
  147. current_config = tab_visibility[view_mode]
  148. # --- 筛选条件 ---
  149. # 日期范围
  150. min_date = df["timestamp"].min().date()
  151. max_date = df["timestamp"].max().date()
  152. date_range = st.sidebar.date_input(
  153. "日期范围",
  154. value=[min_date, max_date],
  155. min_value=min_date,
  156. max_value=max_date
  157. )
  158. if len(date_range) == 2:
  159. start_date, end_date = pd.Timestamp(date_range[0]), pd.Timestamp(date_range[1])
  160. else:
  161. start_date, end_date = pd.Timestamp(min_date), pd.Timestamp(max_date)
  162. # 缺陷类型
  163. all_types = sorted(df["defect_type"].unique())
  164. selected_types = st.sidebar.multiselect("缺陷类型", options=all_types, default=all_types)
  165. # 班次
  166. shift_options = ["全部", "白班", "夜班"]
  167. selected_shift = st.sidebar.radio("班次", options=shift_options)
  168. # 批次
  169. all_batches = sorted(df["batch_id"].unique())
  170. selected_batches = st.sidebar.multiselect("批次", options=all_batches, default=all_batches)
  171. # 严重程度
  172. all_severities = ["全部", "轻微", "中等", "严重"]
  173. selected_severity = st.sidebar.selectbox("严重程度", options=all_severities)
  174. # 设备
  175. all_equipment = sorted(df["equipment_id"].unique())
  176. selected_equipment = st.sidebar.multiselect("前贴附设备", options=all_equipment, default=all_equipment)
  177. # 座号(随设备联动)
  178. if selected_equipment:
  179. eq_seats = sorted(df[df["equipment_id"].isin(selected_equipment)]["seat_id"].unique())
  180. selected_seats = st.sidebar.multiselect("座号", options=eq_seats, default=eq_seats)
  181. else:
  182. selected_seats = []
  183. filtered_df = apply_defect_filters(
  184. df,
  185. start_date=start_date,
  186. end_date=end_date,
  187. selected_types=selected_types,
  188. selected_batches=selected_batches,
  189. selected_equipment=selected_equipment,
  190. selected_seats=selected_seats,
  191. selected_shift=selected_shift,
  192. selected_severity=selected_severity,
  193. )
  194. # ========== KPI 看板 ==========
  195. kpis = calculate_kpis(df, filtered_df)
  196. total_panels_inspected = kpis["total_panels_inspected"]
  197. defective_panels = kpis["defective_panels"]
  198. yield_rate = kpis["yield_rate"]
  199. total_defects = kpis["total_defects"]
  200. critical_defects = kpis["critical_defects"]
  201. top_defect_type = kpis["top_defect_type"]
  202. kpi1, kpi2, kpi3, kpi4, kpi5, kpi6 = st.columns(6)
  203. kpi1.metric("检测面板数", f"{total_panels_inspected} 块")
  204. kpi2.metric("不良面板数", f"{defective_panels} 块", delta=f"{defective_panels/total_panels_inspected*100:.1f}%" if total_panels_inspected > 0 else "0%")
  205. kpi3.metric("综合良率", f"{yield_rate:.1f}%", delta=f"{yield_rate - 95:.1f}%", delta_color="normal" if yield_rate >= 95 else "inverse")
  206. kpi4.metric("缺陷总数", f"{total_defects} 个")
  207. kpi5.metric("严重缺陷", f"{critical_defects} 个", delta=f"{critical_defects/max(total_defects,1)*100:.1f}%" if total_defects > 0 else "0%")
  208. kpi6.metric("主要缺陷类型", top_defect_type)
  209. # 第二排 KPI
  210. eq_concentrated = False
  211. if "equipment_id" in filtered_df.columns:
  212. eq_stats = filtered_df.groupby("equipment_id").size()
  213. top_eq = eq_stats.idxmax() if len(eq_stats) > 0 else "-"
  214. top_eq_count = eq_stats.max() if len(eq_stats) > 0 else 0
  215. else:
  216. top_eq, top_eq_count = "-", 0
  217. seat_concentrated = False
  218. if "seat_id" in filtered_df.columns and len(filtered_df) > 0:
  219. seat_stats = filtered_df.groupby("seat_id").size()
  220. if len(seat_stats) > 0:
  221. top_seat = seat_stats.idxmax()
  222. top_seat_count = seat_stats.max()
  223. avg_seat_count = seat_stats.mean()
  224. if top_seat_count > avg_seat_count * 2:
  225. seat_concentrated = True
  226. else:
  227. top_seat, top_seat_count = "-", 0
  228. else:
  229. top_seat, top_seat_count = "-", 0
  230. kpi7, kpi8, kpi9 = st.columns(3)
  231. kpi7.metric("最高缺陷设备", str(top_eq), f"{top_eq_count} 个缺陷")
  232. kpi8.metric("最高缺陷座号", str(top_seat), f"{top_seat_count} 个缺陷")
  233. if seat_concentrated:
  234. kpi9.metric("座号集中性", "⚠️ 存在集中", delta="需关注", delta_color="inverse")
  235. else:
  236. kpi9.metric("座号集中性", "✅ 正常分布")
  237. # --- 主标题 ---
  238. st.title("📊 屏幕缺陷集中性分析系统")
  239. st.markdown(f"**数据范围**: {start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')} | "
  240. f"**筛选后缺陷数**: {len(filtered_df)} 条 | "
  241. f"**涉及面板**: {filtered_df['panel_id'].nunique()} 块")
  242. st.divider()
  243. if filtered_df.empty:
  244. st.warning("当前筛选条件下没有缺陷记录,请放宽日期、批次、设备或缺陷类型筛选。")
  245. st.stop()
  246. # --- Tab 布局 (按角色动态) ---
  247. ALL_TABS = [
  248. "🧭 诊断驾驶舱",
  249. "🔬 ML 因子分析",
  250. "🗺️ 空间集中性",
  251. "📊 类型集中性 (帕累托)",
  252. "📈 时间集中性",
  253. "🏭 批次集中性",
  254. "🏗️ 设备座号集中性",
  255. "🔗 关联分析",
  256. "🧠 智能缺陷聚类 (DBSCAN)",
  257. "🚨 SPC 控制图与预警",
  258. "🔬 缺陷模式识别",
  259. "💚 设备健康与共性分析",
  260. "🔲 多层叠加分析"
  261. ]
  262. if current_config["tabs"] == "all":
  263. visible_tabs = ALL_TABS
  264. else:
  265. visible_tabs = [t for t in ALL_TABS if t in current_config["tabs"]]
  266. tab_containers = st.tabs(visible_tabs)
  267. tab_map = {name: container for name, container in zip(visible_tabs, tab_containers)}
  268. def get_tab(name):
  269. """获取指定 Tab 容器,如果不可见则返回 None"""
  270. return tab_map.get(name)
  271. # ========== Tab 0: 诊断驾驶舱 ==========
  272. _t = get_tab("🧭 诊断驾驶舱")
  273. if _t:
  274. with _t:
  275. dashboard = build_diagnostic_dashboard(filtered_df)
  276. industry_diagnosis = generate_industry_diagnosis(filtered_df, dashboard)
  277. quality_report = build_data_quality_report(filtered_df)
  278. level_colors = {
  279. "严重": ("#7f1d1d", "#fee2e2"),
  280. "关注": ("#92400e", "#fef3c7"),
  281. "正常": ("#14532d", "#dcfce7"),
  282. }
  283. level_fg, level_bg = level_colors.get(dashboard["severity_level"], ("#334155", "#e2e8f0"))
  284. st.markdown(
  285. """
  286. <style>
  287. .diag-hero {
  288. padding: 24px 28px;
  289. border-radius: 24px;
  290. background:
  291. radial-gradient(circle at 15% 15%, rgba(20, 184, 166, .18), transparent 28%),
  292. linear-gradient(135deg, #0f172a 0%, #12343b 52%, #294936 100%);
  293. color: #f8fafc;
  294. box-shadow: 0 18px 45px rgba(15, 23, 42, .18);
  295. margin-bottom: 18px;
  296. }
  297. .diag-hero h2 { margin: 0 0 8px 0; font-size: 30px; letter-spacing: .02em; }
  298. .diag-hero p { margin: 0; color: #cbd5e1; font-size: 15px; }
  299. .diag-badge {
  300. display: inline-flex;
  301. align-items: center;
  302. padding: 6px 12px;
  303. border-radius: 999px;
  304. font-weight: 700;
  305. margin-bottom: 12px;
  306. }
  307. .diag-card {
  308. padding: 18px 18px;
  309. border-radius: 18px;
  310. border: 1px solid #dbe4e7;
  311. background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
  312. min-height: 128px;
  313. }
  314. .diag-card .label { color: #64748b; font-size: 13px; margin-bottom: 8px; }
  315. .diag-card .value { color: #0f172a; font-size: 26px; font-weight: 800; line-height: 1.1; }
  316. .diag-card .hint { color: #475569; font-size: 13px; margin-top: 10px; }
  317. </style>
  318. """,
  319. unsafe_allow_html=True,
  320. )
  321. st.markdown(
  322. f"""
  323. <div class="diag-hero">
  324. <div class="diag-badge" style="color:{level_fg}; background:{level_bg};">
  325. 当前诊断等级:{dashboard["severity_level"]}
  326. </div>
  327. <h2>缺陷诊断驾驶舱</h2>
  328. <p>{dashboard["primary_recommendation"]}</p>
  329. </div>
  330. """,
  331. unsafe_allow_html=True,
  332. )
  333. card1, card2, card3, card4 = st.columns(4)
  334. with card1:
  335. st.markdown(
  336. f"""
  337. <div class="diag-card">
  338. <div class="label">筛选后缺陷</div>
  339. <div class="value">{len(filtered_df)}</div>
  340. <div class="hint">涉及 {filtered_df["panel_id"].nunique()} 块面板</div>
  341. </div>
  342. """,
  343. unsafe_allow_html=True,
  344. )
  345. with card2:
  346. st.markdown(
  347. f"""
  348. <div class="diag-card">
  349. <div class="label">主导缺陷类型</div>
  350. <div class="value">{dashboard["top_defect_type"]}</div>
  351. <div class="hint">占全部缺陷 {dashboard["top_defect_share"]:.1%}</div>
  352. </div>
  353. """,
  354. unsafe_allow_html=True,
  355. )
  356. with card3:
  357. st.markdown(
  358. f"""
  359. <div class="diag-card">
  360. <div class="label">严重缺陷占比</div>
  361. <div class="value">{dashboard["serious_share"]:.1%}</div>
  362. <div class="hint">高于 20% 建议立即复盘</div>
  363. </div>
  364. """,
  365. unsafe_allow_html=True,
  366. )
  367. with card4:
  368. top_root = dashboard["root_causes"].iloc[0] if len(dashboard["root_causes"]) else None
  369. root_name = top_root["根因候选"] if top_root is not None else "-"
  370. root_share = top_root["占比"] if top_root is not None else 0
  371. root_lift = top_root["异常倍数"] if top_root is not None else 0
  372. st.markdown(
  373. f"""
  374. <div class="diag-card">
  375. <div class="label">首要根因候选</div>
  376. <div class="value" style="font-size:22px;">{root_name}</div>
  377. <div class="hint">贡献 {root_share:.1%} 缺陷,异常 {root_lift:.2f}x</div>
  378. </div>
  379. """,
  380. unsafe_allow_html=True,
  381. )
  382. st.markdown(
  383. f"""
  384. <div style="
  385. margin-top: 16px;
  386. padding: 18px 20px;
  387. border-radius: 18px;
  388. border: 1px solid #c7d2fe;
  389. background: linear-gradient(135deg, #eef2ff 0%, #f8fafc 55%, #ecfeff 100%);
  390. ">
  391. <div style="font-size: 13px; color: #475569; font-weight: 700; margin-bottom: 6px;">
  392. 3C 面板行业诊断结论
  393. </div>
  394. <div style="font-size: 18px; color: #0f172a; font-weight: 800;">
  395. {industry_diagnosis["headline"]}
  396. </div>
  397. </div>
  398. """,
  399. unsafe_allow_html=True,
  400. )
  401. diag_col1, diag_col2 = st.columns([1, 1])
  402. with diag_col1:
  403. st.subheader("识别到的缺陷模式")
  404. for pattern in industry_diagnosis["patterns"]:
  405. st.markdown(f"- {pattern}")
  406. with diag_col2:
  407. st.subheader("行业化排查建议")
  408. for idx, recommendation in enumerate(industry_diagnosis["recommendations"], 1):
  409. st.markdown(f"{idx}. {recommendation}")
  410. quality_cols = st.columns(5)
  411. quality_cols[0].metric("数据质量分", f"{quality_report['score']:.1f}")
  412. quality_cols[1].metric("必填完整率", f"{quality_report['required_complete_rate']:.1%}")
  413. quality_cols[2].metric("坐标合法率", f"{quality_report['coordinate_valid_rate']:.1%}")
  414. quality_cols[3].metric("枚举合法率", f"{quality_report['enum_valid_rate']:.1%}")
  415. quality_cols[4].metric("追溯覆盖率", f"{quality_report['traceability_rate']:.1%}")
  416. if quality_report["issues"] != ["数据质量良好"]:
  417. st.warning("数据质量提示:" + ";".join(quality_report["issues"]))
  418. st.divider()
  419. left, right = st.columns([1.25, 1])
  420. with left:
  421. st.subheader("交互式面板数字孪生")
  422. panel_w = float(df["panel_width_mm"].iloc[0])
  423. panel_h = float(df["panel_height_mm"].iloc[0])
  424. fig_map = go.Figure()
  425. fig_map.add_shape(
  426. type="rect",
  427. x0=0,
  428. y0=0,
  429. x1=panel_w,
  430. y1=panel_h,
  431. line=dict(color="#0f172a", width=2),
  432. fillcolor="#f8fafc",
  433. layer="below",
  434. )
  435. fig_map.add_trace(
  436. go.Scatter(
  437. x=filtered_df["x_mm"],
  438. y=filtered_df["y_mm"],
  439. mode="markers",
  440. marker=dict(
  441. size=7,
  442. color=filtered_df["severity"].map({"轻微": 1, "中等": 2, "严重": 3}),
  443. colorscale=[[0, "#38bdf8"], [0.5, "#f59e0b"], [1, "#dc2626"]],
  444. showscale=True,
  445. colorbar=dict(title="严重度"),
  446. opacity=0.72,
  447. line=dict(width=0.4, color="#ffffff"),
  448. ),
  449. text=filtered_df["defect_id"],
  450. customdata=filtered_df[["defect_type", "severity", "equipment_id", "seat_id", "batch_id"]],
  451. hovertemplate=(
  452. "缺陷ID: %{text}<br>"
  453. "坐标: (%{x:.1f}, %{y:.1f}) mm<br>"
  454. "类型: %{customdata[0]}<br>"
  455. "严重度: %{customdata[1]}<br>"
  456. "设备/座号: %{customdata[2]} / %{customdata[3]}<br>"
  457. "批次: %{customdata[4]}<extra></extra>"
  458. ),
  459. name="缺陷点",
  460. )
  461. )
  462. fig_map.add_vrect(x0=0, x1=panel_w * 0.1, fillcolor="#f97316", opacity=0.08, line_width=0)
  463. fig_map.add_vrect(x0=panel_w * 0.9, x1=panel_w, fillcolor="#f97316", opacity=0.08, line_width=0)
  464. fig_map.add_hrect(y0=panel_h * 0.72, y1=panel_h * 0.88, fillcolor="#14b8a6", opacity=0.09, line_width=0)
  465. fig_map.update_layout(
  466. height=560,
  467. margin=dict(l=18, r=18, t=30, b=18),
  468. plot_bgcolor="#ffffff",
  469. paper_bgcolor="#ffffff",
  470. xaxis=dict(title="X (mm)", range=[0, panel_w], showgrid=True, gridcolor="#e2e8f0"),
  471. yaxis=dict(title="Y (mm)", range=[0, panel_h], scaleanchor="x", scaleratio=1, showgrid=True, gridcolor="#e2e8f0"),
  472. title="按真实屏幕比例定位缺陷,橙色为边缘敏感区,青色为 FPC 关注区",
  473. )
  474. st.plotly_chart(fig_map, use_container_width=True)
  475. fig_density = px.density_heatmap(
  476. filtered_df,
  477. x="x_mm",
  478. y="y_mm",
  479. nbinsx=28,
  480. nbinsy=42,
  481. color_continuous_scale="YlOrRd",
  482. title="密度热区视图",
  483. labels={"x_mm": "X (mm)", "y_mm": "Y (mm)"},
  484. )
  485. fig_density.update_layout(height=300, margin=dict(l=18, r=18, t=42, b=18))
  486. st.plotly_chart(fig_density, use_container_width=True)
  487. with right:
  488. st.subheader("根因候选榜")
  489. root_causes = dashboard["root_causes"].copy()
  490. fig_root = px.bar(
  491. root_causes.sort_values("风险分", ascending=True),
  492. x="风险分",
  493. y="根因候选",
  494. orientation="h",
  495. color="异常倍数",
  496. color_continuous_scale="Tealrose",
  497. text="风险分",
  498. hover_data={
  499. "缺陷数": True,
  500. "占比": ":.1%",
  501. "异常倍数": ":.2f",
  502. "涉及面板": True,
  503. "主要缺陷": True,
  504. "严重占比": ":.1%",
  505. "风险分": ":.1f",
  506. },
  507. labels={"风险分": "风险分", "根因候选": ""},
  508. )
  509. fig_root.update_traces(texttemplate="%{text:.1f}", textposition="outside")
  510. fig_root.update_layout(height=360, margin=dict(l=8, r=20, t=20, b=20))
  511. st.plotly_chart(fig_root, use_container_width=True)
  512. root_table = root_causes.copy()
  513. root_table["占比"] = root_table["占比"].map(lambda v: f"{v:.1%}")
  514. root_table["异常倍数"] = root_table["异常倍数"].map(lambda v: f"{v:.2f}x")
  515. root_table["严重占比"] = root_table["严重占比"].map(lambda v: f"{v:.1%}")
  516. st.dataframe(root_table, use_container_width=True, hide_index=True)
  517. st.caption("风险分 = 贡献规模 + 异常倍数 + 严重占比 + 涉及面板数。先查高贡献且高偏离的组合。")
  518. trend_col, pareto_col = st.columns([1, 1])
  519. with trend_col:
  520. st.subheader("每日缺陷走势")
  521. daily_trend = dashboard["daily_trend"]
  522. fig_trend_dash = px.area(
  523. daily_trend,
  524. x="day",
  525. y="缺陷数",
  526. markers=True,
  527. color_discrete_sequence=["#0f766e"],
  528. labels={"day": "日期", "缺陷数": "缺陷数"},
  529. )
  530. fig_trend_dash.update_traces(line=dict(width=3), fillcolor="rgba(20, 184, 166, .22)")
  531. fig_trend_dash.update_layout(height=350, margin=dict(l=18, r=18, t=20, b=18))
  532. st.plotly_chart(fig_trend_dash, use_container_width=True)
  533. with pareto_col:
  534. st.subheader("缺陷类型 Pareto")
  535. pareto = dashboard["pareto"].head(8)
  536. fig_pareto_dash = go.Figure()
  537. fig_pareto_dash.add_trace(
  538. go.Bar(
  539. x=pareto["缺陷类型"],
  540. y=pareto["缺陷数"],
  541. marker_color="#334155",
  542. name="缺陷数",
  543. hovertemplate="%{x}<br>缺陷数: %{y}<extra></extra>",
  544. )
  545. )
  546. fig_pareto_dash.add_trace(
  547. go.Scatter(
  548. x=pareto["缺陷类型"],
  549. y=pareto["累计占比"],
  550. yaxis="y2",
  551. mode="lines+markers",
  552. line=dict(color="#dc2626", width=3),
  553. name="累计占比",
  554. hovertemplate="%{x}<br>累计占比: %{y:.1%}<extra></extra>",
  555. )
  556. )
  557. fig_pareto_dash.update_layout(
  558. height=350,
  559. margin=dict(l=18, r=18, t=20, b=18),
  560. yaxis=dict(title="缺陷数"),
  561. yaxis2=dict(title="累计占比", overlaying="y", side="right", tickformat=".0%"),
  562. legend=dict(orientation="h", y=1.12),
  563. )
  564. st.plotly_chart(fig_pareto_dash, use_container_width=True)
  565. # ========== Tab 0.5: ML 因子分析 ==========
  566. _t = get_tab("🔬 ML 因子分析")
  567. if _t:
  568. with _t:
  569. dashboard = build_diagnostic_dashboard(filtered_df)
  570. extended_root_causes = dashboard.get("extended_root_causes")
  571. st.header("根因与关键因子分析")
  572. st.markdown("综合规则评分、统计分析、机器学习验证与行业维度,输出可解释的异常候选。")
  573. ml_col1, ml_col2, ml_col3 = st.columns([1, 1, 1])
  574. with ml_col1:
  575. ml_target_type = st.selectbox(
  576. "目标缺陷",
  577. options=sorted(filtered_df["defect_type"].dropna().unique()),
  578. index=sorted(filtered_df["defect_type"].dropna().unique()).index(dashboard["top_defect_type"])
  579. if dashboard["top_defect_type"] in sorted(filtered_df["defect_type"].dropna().unique())
  580. else 0,
  581. )
  582. with ml_col2:
  583. ml_model_name = st.selectbox(
  584. "ML 模型",
  585. options=["random_forest", "logistic_regression", "xgboost", "lightgbm"],
  586. format_func=lambda name: {
  587. "random_forest": "RandomForest",
  588. "logistic_regression": "LogisticRegression",
  589. "xgboost": "XGBoost",
  590. "lightgbm": "LightGBM",
  591. }[name],
  592. )
  593. with ml_col3:
  594. ml_top_n = st.slider("候选因子数", min_value=5, max_value=30, value=10, step=5)
  595. ml_insights = build_cached_ml_factor_insights(
  596. filtered_df,
  597. ml_target_type,
  598. ml_model_name,
  599. ml_top_n,
  600. )
  601. st.divider()
  602. if extended_root_causes is not None and not extended_root_causes.empty:
  603. st.subheader("扩展根因候选")
  604. extended_table = extended_root_causes.copy()
  605. extended_table["占比"] = extended_table["占比"].map(lambda v: f"{v:.1%}")
  606. extended_table["异常倍数"] = extended_table["异常倍数"].map(lambda v: f"{v:.2f}x")
  607. extended_table["严重占比"] = extended_table["严重占比"].map(lambda v: f"{v:.1%}")
  608. st.dataframe(extended_table, use_container_width=True, hide_index=True)
  609. st.caption("覆盖治具、吸嘴、材料批次、清洗/绑定等维度,用于多前制程链路追溯。")
  610. if ml_insights["error"]:
  611. st.warning(f"ML 模型暂不可用:{ml_insights['error']}")
  612. else:
  613. metric_train = ml_insights["metrics"]
  614. metric_valid = ml_insights["validation_metrics"]
  615. m1, m2, m3, m4 = st.columns(4)
  616. m1.metric("训练准确率", f"{metric_train.get('train_accuracy', 0):.1%}")
  617. m2.metric("训练 AUC", f"{metric_train.get('train_auc', 0):.3f}")
  618. m3.metric("验证准确率", f"{metric_valid.get('validation_accuracy', 0):.1%}")
  619. m4.metric("验证 AUC", f"{metric_valid.get('validation_auc', 0):.3f}")
  620. importance_df = pd.DataFrame(ml_insights["feature_importance"])
  621. if not importance_df.empty:
  622. st.subheader("模型特征贡献 TOP")
  623. importance_df["importance"] = importance_df["importance"].map(lambda v: round(v, 4))
  624. st.dataframe(importance_df.head(15), use_container_width=True, hide_index=True)
  625. st.caption("用于判断模型主要依赖哪些设备、座号、材料批次、坐标或缺陷几何特征。")
  626. key_factors = ml_insights["key_factors"]
  627. if not key_factors.empty:
  628. st.subheader(f"关键因子分析:{ml_insights['target_defect_type']}")
  629. key_factor_table = key_factors.copy()
  630. key_factor_table["目标占比"] = key_factor_table["目标占比"].map(lambda v: f"{v:.1%}")
  631. key_factor_table["基线占比"] = key_factor_table["基线占比"].map(lambda v: f"{v:.1%}")
  632. key_factor_table["异常倍数"] = key_factor_table["异常倍数"].map(lambda v: f"{v:.2f}x")
  633. key_factor_table["支持度"] = key_factor_table["支持度"].map(lambda v: f"{v:.1%}")
  634. if "ml_probability" in key_factor_table.columns:
  635. key_factor_table["ml_probability"] = key_factor_table["ml_probability"].map(lambda v: f"{v:.1%}")
  636. st.dataframe(key_factor_table, use_container_width=True, hide_index=True)
  637. st.caption("关键因子按目标缺陷占比、异常倍数、样本数、支持度和模型概率综合排序。")
  638. else:
  639. st.info("当前数据未找到显著关键因子,可放宽筛选条件或增加样本量。")
  640. # ========== Tab 1: 空间集中性 ==========
  641. _t = get_tab("🗺️ 空间集中性")
  642. if _t:
  643. with _t:
  644. st.header("缺陷空间分布热力图")
  645. col1, col2 = st.columns([2, 1])
  646. with col1:
  647. # 热力图分辨率
  648. grid_size = st.slider("热力图网格分辨率", min_value=5, max_value=50, value=20)
  649. fig, axes = plt.subplots(1, 2, figsize=(14, 6))
  650. # 左图:2D 热力图
  651. x_edges = np.linspace(0, df["panel_width_mm"].iloc[0], grid_size + 1)
  652. y_edges = np.linspace(0, df["panel_height_mm"].iloc[0], grid_size + 1)
  653. H, _, _ = np.histogram2d(
  654. filtered_df["x_mm"], filtered_df["y_mm"],
  655. bins=[x_edges, y_edges]
  656. )
  657. im = axes[0].imshow(
  658. H.T, origin="lower", aspect="auto",
  659. extent=[0, df["panel_width_mm"].iloc[0], 0, df["panel_height_mm"].iloc[0]],
  660. cmap="YlOrRd"
  661. )
  662. axes[0].set_title(f"缺陷密度热力图 (总 {len(filtered_df)} 个)")
  663. axes[0].set_xlabel("X (mm)")
  664. axes[0].set_ylabel("Y (mm)")
  665. plt.colorbar(im, ax=axes[0], label="缺陷数量")
  666. # 右图:散点图(叠加)
  667. axes[1].scatter(
  668. filtered_df["x_mm"], filtered_df["y_mm"],
  669. alpha=0.3, s=5, c="red", edgecolors="none"
  670. )
  671. axes[1].set_title("缺陷位置散点图")
  672. axes[1].set_xlabel("X (mm)")
  673. axes[1].set_ylabel("Y (mm)")
  674. axes[1].set_aspect("equal")
  675. st.pyplot(fig)
  676. plt.close()
  677. with col2:
  678. st.subheader("区域统计")
  679. # 将面板分为 9 宫格
  680. x_bins = pd.cut(filtered_df["x_mm"], bins=3, labels=["左", "中", "右"])
  681. y_bins = pd.cut(filtered_df["y_mm"], bins=3, labels=["上", "中", "下"])
  682. region_df = pd.DataFrame({"X区域": x_bins, "Y区域": y_bins})
  683. region_counts = region_df.groupby(["X区域", "Y区域"], observed=False).size().unstack(fill_value=0)
  684. st.dataframe(region_counts, use_container_width=True)
  685. # 高频缺陷区域 TOP5
  686. st.subheader("高频缺陷区域 TOP5")
  687. region_df["区域"] = region_df["X区域"].astype(str) + "-" + region_df["Y区域"].astype(str)
  688. top_regions = region_df["区域"].value_counts().head(5)
  689. for i, (region, count) in enumerate(top_regions.items(), 1):
  690. st.metric(f"#{i} {region}", f"{count} 个缺陷")
  691. # --- 模拟面板缺陷标注图 ---
  692. st.divider()
  693. st.subheader("🖼️ 模拟面板缺陷标注图")
  694. st.markdown("选择批次和面板,查看缺陷在面板上的实际分布标注(按缺陷类型用不同颜色/形状区分)")
  695. ann_col1, ann_col2, ann_col3 = st.columns(3)
  696. with ann_col1:
  697. ann_batch = st.selectbox("选择批次", options=sorted(filtered_df["batch_id"].unique()), key="ann_batch")
  698. with ann_col2:
  699. panels_in_batch = sorted(filtered_df[filtered_df["batch_id"] == ann_batch]["panel_id"].unique())
  700. ann_panel = st.selectbox("选择面板", options=panels_in_batch, key="ann_panel")
  701. with ann_col3:
  702. ann_show_label = st.checkbox("显示缺陷标签", value=True)
  703. panel_defects = filtered_df[(filtered_df["batch_id"] == ann_batch) & (filtered_df["panel_id"] == ann_panel)]
  704. if len(panel_defects) == 0:
  705. st.warning(f"当前面板 **{ann_panel}** (批次 {ann_batch}) 在筛选条件下无缺陷记录,请调整筛选条件或选择其他面板")
  706. else:
  707. pw = df["panel_width_mm"].iloc[0]
  708. ph = df["panel_height_mm"].iloc[0]
  709. # 缺陷类型 → 颜色/形状映射
  710. type_style = {
  711. "划痕": {"color": "red", "marker": "x", "size": 80},
  712. "亮点": {"color": "yellow", "marker": "o", "size": 60},
  713. "暗点": {"color": "black", "marker": "x", "size": 60},
  714. "气泡": {"color": "cyan", "marker": "o", "size": 100},
  715. "色差": {"color": "magenta", "marker": "s", "size": 70},
  716. "漏光": {"color": "orange", "marker": "D", "size": 80},
  717. "裂纹": {"color": "darkred", "marker": "v", "size": 90},
  718. "异物": {"color": "green", "marker": "P", "size": 80},
  719. }
  720. fig_ann, ax_ann = plt.subplots(figsize=(3.5, 5))
  721. # 面板背景(模拟屏幕灰色渐变)
  722. ax_ann.add_patch(plt.Rectangle((0, 0), pw, ph, facecolor="#1a1a2e", edgecolor="#444", linewidth=2))
  723. # 内框(模拟屏幕可视区域)
  724. margin = 8
  725. ax_ann.add_patch(plt.Rectangle((margin, margin), pw - 2*margin, ph - 2*margin,
  726. facecolor="#16213e", edgecolor="#0f3460", linewidth=1.5))
  727. # FPC绑定区域标注
  728. fpc_y = ph * 0.7
  729. ax_ann.axhline(y=fpc_y, color="#555", linestyle="--", alpha=0.4, linewidth=0.5)
  730. ax_ann.text(pw/2, fpc_y + 2, "FPC区", color="#666", fontsize=7, ha="center", alpha=0.5)
  731. # 绘制缺陷标注
  732. for _, row in panel_defects.iterrows():
  733. style = type_style.get(row["defect_type"], {"color": "white", "marker": "o", "size": 50})
  734. severity_size = {"轻微": 0.7, "中等": 1.0, "严重": 1.4}.get(row["severity"], 1.0)
  735. ax_ann.scatter(row["x_mm"], row["y_mm"],
  736. c=style["color"], marker=style["marker"],
  737. s=style["size"] * severity_size,
  738. edgecolors="white", linewidth=0.3, alpha=0.85, zorder=3)
  739. if ann_show_label:
  740. ax_ann.annotate(row["defect_type"][:2],
  741. (row["x_mm"], row["y_mm"]),
  742. fontsize=5, color="white",
  743. ha="center", va="bottom", alpha=0.7, zorder=4)
  744. # 图例
  745. legend_elements = [plt.Line2D([0], [0], marker=type_style[t]["marker"], color="w",
  746. markerfacecolor=type_style[t]["color"], markersize=8,
  747. label=t, markeredgewidth=0.5, markeredgecolor="white")
  748. for t in type_style]
  749. ax_ann.legend(handles=legend_elements, loc="upper right", fontsize=7,
  750. framealpha=0.7, facecolor="#222", edgecolor="#555")
  751. ax_ann.set_xlim(-5, pw + 5)
  752. ax_ann.set_ylim(-5, ph + 5)
  753. ax_ann.set_title(f"面板 {ann_panel} | 批次 {ann_batch} | {len(panel_defects)} 个缺陷",
  754. fontsize=11, pad=10)
  755. ax_ann.set_xlabel("X (mm)")
  756. ax_ann.set_ylabel("Y (mm)")
  757. ax_ann.set_aspect("equal")
  758. ax_ann.grid(True, alpha=0.1, color="gray")
  759. st.pyplot(fig_ann)
  760. plt.close()
  761. # ========== Tab 2: 帕累托分析 ==========
  762. _t = get_tab("📊 类型集中性 (帕累托)")
  763. if _t:
  764. with _t:
  765. st.header("缺陷类型帕累托分析")
  766. type_counts = filtered_df["defect_type"].value_counts().reset_index()
  767. type_counts.columns = ["缺陷类型", "数量"]
  768. type_counts = type_counts.sort_values("数量", ascending=False).reset_index(drop=True)
  769. type_counts["累计占比"] = type_counts["数量"].cumsum() / type_counts["数量"].sum() * 100
  770. type_counts["占比"] = type_counts["数量"] / type_counts["数量"].sum() * 100
  771. fig, ax1 = plt.subplots(figsize=(10, 5))
  772. # 柱状图
  773. bars = ax1.bar(type_counts["缺陷类型"], type_counts["数量"], color="steelblue", alpha=0.8)
  774. ax1.set_xlabel("缺陷类型")
  775. ax1.set_ylabel("数量", color="steelblue")
  776. ax1.set_title("帕累托图 - 缺陷类型分布")
  777. # 累计占比折线
  778. ax2 = ax1.twinx()
  779. ax2.plot(type_counts["缺陷类型"], type_counts["累计占比"], color="red", marker="o", linewidth=2)
  780. ax2.axhline(y=80, color="green", linestyle="--", alpha=0.5, label="80%线")
  781. ax2.set_ylabel("累计占比 (%)", color="red")
  782. ax2.set_ylim(0, 110)
  783. # 标注数值
  784. for bar, count in zip(bars, type_counts["数量"]):
  785. ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 2,
  786. str(count), ha="center", va="bottom", fontsize=9)
  787. st.pyplot(fig)
  788. plt.close()
  789. # 数据表格
  790. st.subheader("详细数据")
  791. st.dataframe(type_counts, use_container_width=True)
  792. # 严重程度分布
  793. st.subheader("按严重程度分布")
  794. sev_counts = filtered_df["severity"].value_counts()
  795. fig2, ax = plt.subplots(figsize=(6, 4))
  796. colors = {"轻微": "#4CAF50", "中等": "#FF9800", "严重": "#F44336"}
  797. sev_counts.plot(kind="bar", ax=ax, color=[colors.get(s, "gray") for s in sev_counts.index])
  798. ax.set_title("缺陷严重程度分布")
  799. ax.set_ylabel("数量")
  800. st.pyplot(fig2)
  801. plt.close()
  802. # ========== Tab 3: 时间集中性 ==========
  803. _t = get_tab("📈 时间集中性")
  804. if _t:
  805. with _t:
  806. st.header("缺陷时间分布趋势")
  807. col1, col2 = st.columns(2)
  808. with col1:
  809. # 按天趋势
  810. daily = filtered_df.groupby("day").size().reset_index(name="缺陷数")
  811. daily["day"] = pd.to_datetime(daily["day"])
  812. fig1, ax1 = plt.subplots(figsize=(10, 4))
  813. ax1.plot(daily["day"], daily["缺陷数"], marker="o", markersize=3, linewidth=1.5, color="steelblue")
  814. ax1.fill_between(daily["day"], daily["缺陷数"], alpha=0.2, color="steelblue")
  815. ax1.set_title("每日缺陷数量趋势")
  816. ax1.set_ylabel("缺陷数量")
  817. ax1.tick_params(axis="x", rotation=45)
  818. # 移动平均
  819. if len(daily) > 3:
  820. daily["移动平均(3天)"] = daily["缺陷数"].rolling(window=3, min_periods=1).mean()
  821. ax1.plot(daily["day"], daily["移动平均(3天)"], color="red", linestyle="--",
  822. linewidth=2, alpha=0.7, label="3日移动平均")
  823. ax1.legend()
  824. st.pyplot(fig1)
  825. plt.close()
  826. with col2:
  827. # 按小时分布
  828. hourly = filtered_df.groupby("hour").size().reindex(range(24), fill_value=0)
  829. fig2, ax2 = plt.subplots(figsize=(10, 4))
  830. colors = ["#FF6B6B" if (h >= 17 or h < 8) else "#4ECDC4" for h in hourly.index]
  831. ax2.bar(hourly.index, hourly.values, color=colors, alpha=0.8)
  832. ax2.set_title("每小时缺陷分布 (红色=夜班)")
  833. ax2.set_xlabel("小时")
  834. ax2.set_ylabel("缺陷数量")
  835. st.pyplot(fig2)
  836. plt.close()
  837. # 班次对比
  838. st.subheader("班次对比")
  839. shift_stats = filtered_df.groupby("shift").agg({
  840. "defect_id": "count",
  841. "panel_id": "nunique"
  842. }).rename(columns={"defect_id": "缺陷数", "panel_id": "涉及面板数"})
  843. st.dataframe(shift_stats, use_container_width=True)
  844. # 每周分布
  845. st.subheader("按星期分布")
  846. filtered_df_copy = filtered_df.copy()
  847. filtered_df_copy["weekday"] = filtered_df_copy["timestamp"].dt.day_name()
  848. weekday_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
  849. weekday_cn = {"Monday": "周一", "Tuesday": "周二", "Wednesday": "周三",
  850. "Thursday": "周四", "Friday": "周五", "Saturday": "周六", "Sunday": "周日"}
  851. filtered_df_copy["星期"] = filtered_df_copy["weekday"].map(weekday_cn)
  852. weekday_counts = filtered_df_copy.groupby("星期").size().reindex(
  853. [weekday_cn[d] for d in weekday_order], fill_value=0
  854. )
  855. fig3, ax3 = plt.subplots(figsize=(8, 4))
  856. ax3.bar(range(7), weekday_counts.values, color="steelblue", alpha=0.8)
  857. ax3.set_xticks(range(7))
  858. ax3.set_xticklabels(weekday_counts.index)
  859. ax3.set_title("按星期分布")
  860. ax3.set_ylabel("缺陷数量")
  861. st.pyplot(fig3)
  862. plt.close()
  863. # ========== Tab 4: 批次集中性 ==========
  864. _t = get_tab("🏭 批次集中性")
  865. if _t:
  866. with _t:
  867. st.header("批次缺陷集中性分析")
  868. batch_stats = filtered_df.groupby("batch_id").agg({
  869. "defect_id": "count",
  870. "panel_id": "nunique",
  871. "severity": lambda x: (x == "严重").sum()
  872. }).rename(columns={"defect_id": "缺陷数", "panel_id": "面板数", "severity": "严重缺陷数"})
  873. batch_stats["缺陷率"] = batch_stats["缺陷数"] / batch_stats["面板数"]
  874. batch_stats = batch_stats.sort_index()
  875. col1, col2 = st.columns(2)
  876. with col1:
  877. fig1, ax1 = plt.subplots(figsize=(10, 4))
  878. ax1.bar(range(len(batch_stats)), batch_stats["缺陷数"], color="steelblue", alpha=0.8)
  879. ax1.set_title("各批次缺陷数量")
  880. ax1.set_xlabel("批次")
  881. ax1.set_ylabel("缺陷数")
  882. ax1.set_xticks(range(len(batch_stats)))
  883. ax1.set_xticklabels(batch_stats.index, rotation=90, fontsize=7)
  884. st.pyplot(fig1)
  885. plt.close()
  886. with col2:
  887. fig2, ax2 = plt.subplots(figsize=(10, 4))
  888. ax2.plot(range(len(batch_stats)), batch_stats["缺陷率"], marker="o", markersize=3,
  889. color="red", linewidth=1.5)
  890. ax2.axhline(y=batch_stats["缺陷率"].mean(), color="green", linestyle="--",
  891. label=f"平均缺陷率: {batch_stats['缺陷率'].mean():.2%}")
  892. ax2.set_title("各批次缺陷率趋势")
  893. ax2.set_xlabel("批次")
  894. ax2.set_ylabel("缺陷率")
  895. ax2.set_xticks(range(len(batch_stats)))
  896. ax2.set_xticklabels(batch_stats.index, rotation=90, fontsize=7)
  897. ax2.legend()
  898. st.pyplot(fig2)
  899. plt.close()
  900. # 异常批次
  901. st.subheader("异常批次 (缺陷率 > 平均值 + 1倍标准差)")
  902. threshold = batch_stats["缺陷率"].mean() + batch_stats["缺陷率"].std()
  903. abnormal = batch_stats[batch_stats["缺陷率"] > threshold].sort_values("缺陷率", ascending=False)
  904. if len(abnormal) > 0:
  905. st.dataframe(abnormal, use_container_width=True)
  906. else:
  907. st.success("未发现异常批次")
  908. # ========== Tab 5: 设备座号集中性 ==========
  909. _t = get_tab("🏗️ 设备座号集中性")
  910. if _t:
  911. with _t:
  912. st.header("🏗️ 前贴附制程设备座号集中性分析")
  913. st.markdown(
  914. "分析缺陷是否集中在特定设备的特定座号(工位)。"
  915. "如果某个座号缺陷明显多于其他座号,说明该座号对应的设备局部存在问题(如吸嘴老化、加热不均、压力异常等)。"
  916. )
  917. # --- 设备对比 ---
  918. st.subheader("设备级别对比")
  919. eq_stats = filtered_df.groupby("equipment_id").agg({
  920. "defect_id": "count",
  921. "panel_id": "nunique",
  922. "severity": lambda x: (x == "严重").sum()
  923. }).rename(columns={"defect_id": "缺陷数", "panel_id": "面板数", "severity": "严重缺陷"})
  924. eq_stats["缺陷率"] = eq_stats["缺陷数"] / eq_stats["面板数"]
  925. eq_stats = eq_stats.sort_values("缺陷数", ascending=False)
  926. col_eq1, col_eq2 = st.columns(2)
  927. with col_eq1:
  928. fig_eq1, ax_eq1 = plt.subplots(figsize=(8, 4))
  929. bars1 = ax_eq1.bar(range(len(eq_stats)), eq_stats["缺陷数"], color=["#FF6B6B", "#4ECDC4", "#45B7D1"][:len(eq_stats)], alpha=0.8)
  930. ax_eq1.set_xticks(range(len(eq_stats)))
  931. ax_eq1.set_xticklabels(eq_stats.index, fontsize=10)
  932. ax_eq1.set_ylabel("缺陷数量")
  933. ax_eq1.set_title("各设备缺陷总数")
  934. for bar, count in zip(bars1, eq_stats["缺陷数"]):
  935. ax_eq1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 3,
  936. str(count), ha="center", va="bottom", fontsize=10, fontweight="bold")
  937. st.pyplot(fig_eq1)
  938. plt.close()
  939. with col_eq2:
  940. fig_eq2, ax_eq2 = plt.subplots(figsize=(8, 4))
  941. bars2 = ax_eq2.bar(range(len(eq_stats)), eq_stats["缺陷率"] * 100,
  942. color=["#FF6B6B", "#4ECDC4", "#45B7D1"][:len(eq_stats)], alpha=0.8)
  943. ax_eq2.set_xticks(range(len(eq_stats)))
  944. ax_eq2.set_xticklabels(eq_stats.index, fontsize=10)
  945. ax_eq2.set_ylabel("缺陷率 (%)")
  946. ax_eq2.set_title("各设备缺陷率")
  947. for bar, rate in zip(bars2, eq_stats["缺陷率"] * 100):
  948. ax_eq2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.3,
  949. f"{rate:.1f}%", ha="center", va="bottom", fontsize=10, fontweight="bold")
  950. st.pyplot(fig_eq2)
  951. plt.close()
  952. st.dataframe(eq_stats, use_container_width=True)
  953. # --- 座号级别分析 ---
  954. st.divider()
  955. st.subheader("座号级别缺陷分布")
  956. # 选择设备查看座号
  957. eq_for_seat = st.selectbox("选择设备查看座号分布", options=sorted(filtered_df["equipment_id"].unique()), key="eq_seat")
  958. eq_data = filtered_df[filtered_df["equipment_id"] == eq_for_seat]
  959. eq_info = None
  960. for eq_name, info in [("LAM-A01", {"rows": 4, "cols": 5}), ("LAM-A02", {"rows": 4, "cols": 5}), ("LAM-B01", {"rows": 5, "cols": 4})]:
  961. if eq_name == eq_for_seat:
  962. eq_info = info
  963. break
  964. seat_counts = eq_data.groupby("seat_id").size().reset_index(name="缺陷数")
  965. seat_counts = seat_counts.sort_values("缺陷数", ascending=False)
  966. if eq_info:
  967. # 网格热力图
  968. grid = np.zeros((eq_info["rows"], eq_info["cols"]))
  969. seat_to_defects = eq_data.groupby("seat_id").size().to_dict()
  970. for r in range(1, eq_info["rows"] + 1):
  971. for c in range(1, eq_info["cols"] + 1):
  972. seat_name = f"R{r}C{c}"
  973. grid[r - 1, c - 1] = seat_to_defects.get(seat_name, 0)
  974. fig_grid, ax_grid = plt.subplots(figsize=(8, 6))
  975. im = ax_grid.imshow(grid, cmap="YlOrRd", aspect="equal")
  976. ax_grid.set_title(f"{eq_for_seat} 座号缺陷热力图")
  977. ax_grid.set_xlabel("列号")
  978. ax_grid.set_ylabel("行号")
  979. ax_grid.set_xticks(range(eq_info["cols"]))
  980. ax_grid.set_xticklabels([f"C{i+1}" for i in range(eq_info["cols"])])
  981. ax_grid.set_yticks(range(eq_info["rows"]))
  982. ax_grid.set_yticklabels([f"R{i+1}" for i in range(eq_info["rows"])])
  983. # 标注数值
  984. for r in range(eq_info["rows"]):
  985. for c in range(eq_info["cols"]):
  986. val = int(grid[r, c])
  987. color = "white" if val > grid.max() * 0.7 else "black"
  988. ax_grid.text(c, r, str(val), ha="center", va="center", fontsize=10,
  989. color=color, fontweight="bold")
  990. plt.colorbar(im, ax=ax_grid, label="缺陷数量")
  991. st.pyplot(fig_grid)
  992. plt.close()
  993. else:
  994. fig_bar, ax_bar = plt.subplots(figsize=(10, 4))
  995. ax_bar.bar(range(len(seat_counts)), seat_counts["缺陷数"], color="steelblue", alpha=0.8)
  996. ax_bar.set_xticks(range(len(seat_counts)))
  997. ax_bar.set_xticklabels(seat_counts["seat_id"], rotation=45, fontsize=8)
  998. ax_bar.set_ylabel("缺陷数量")
  999. ax_bar.set_title("座号缺陷分布")
  1000. st.pyplot(fig_bar)
  1001. plt.close()
  1002. # 座号数据表格
  1003. st.dataframe(seat_counts, use_container_width=True)
  1004. # --- 异常座号检测 ---
  1005. st.divider()
  1006. st.subheader("异常座号检测")
  1007. all_seat_stats = filtered_df.groupby(["equipment_id", "seat_id"]).size().reset_index(name="缺陷数")
  1008. overall_mean = all_seat_stats["缺陷数"].mean()
  1009. overall_std = all_seat_stats["缺陷数"].std()
  1010. threshold_1x = overall_mean + overall_std
  1011. threshold_2x = overall_mean + 2 * overall_std
  1012. st.info(f"📊 全局统计: 平均每个座号 **{overall_mean:.1f}** 个缺陷 | 标准差 **{overall_std:.1f}**")
  1013. col_anom1, col_anom2 = st.columns(2)
  1014. with col_anom1:
  1015. st.markdown(f"**⚠️ 1σ 预警座号** (缺陷数 > {threshold_1x:.0f})")
  1016. warning_seats = all_seat_stats[all_seat_stats["缺陷数"] > threshold_1x].sort_values("缺陷数", ascending=False)
  1017. if len(warning_seats) > 0:
  1018. st.dataframe(warning_seats.reset_index(drop=True), use_container_width=True)
  1019. else:
  1020. st.success("无预警座号")
  1021. with col_anom2:
  1022. st.markdown(f"**🔴 2σ 异常座号** (缺陷数 > {threshold_2x:.0f})")
  1023. critical_seats = all_seat_stats[all_seat_stats["缺陷数"] > threshold_2x].sort_values("缺陷数", ascending=False)
  1024. if len(critical_seats) > 0:
  1025. st.dataframe(critical_seats.reset_index(drop=True), use_container_width=True)
  1026. else:
  1027. st.success("无异常座号")
  1028. # --- 座号 × 缺陷类型 交叉分析 ---
  1029. st.divider()
  1030. st.subheader("座号 × 缺陷类型 交叉分析")
  1031. st.markdown("识别哪些座号偏向产生特定类型的缺陷(如 R2C3 座号主要产生气泡 → 吸嘴问题)")
  1032. if eq_info:
  1033. eq_seat_type = eq_data.groupby(["seat_id", "defect_type"]).size().unstack(fill_value=0)
  1034. fig_ct, ax_ct = plt.subplots(figsize=(10, 6))
  1035. sns.heatmap(eq_seat_type, annot=True, fmt="d", cmap="YlOrRd", ax=ax_ct,
  1036. linewidths=0.5, linecolor="white")
  1037. ax_ct.set_title(f"{eq_for_seat} 座号 × 缺陷类型 热力图")
  1038. st.pyplot(fig_ct)
  1039. plt.close()
  1040. # ========== Tab 6: 关联分析 ==========
  1041. _t = get_tab("🔗 关联分析")
  1042. if _t:
  1043. with _t:
  1044. st.header("缺陷关联分析")
  1045. col1, col2 = st.columns(2)
  1046. with col1:
  1047. # 缺陷类型 x 严重程度 交叉表
  1048. ct = pd.crosstab(filtered_df["defect_type"], filtered_df["severity"])
  1049. fig1, ax1 = plt.subplots(figsize=(8, 5))
  1050. sns.heatmap(ct, annot=True, fmt="d", cmap="YlOrRd", ax=ax1,
  1051. linewidths=0.5, linecolor="white")
  1052. ax1.set_title("缺陷类型 × 严重程度 热力图")
  1053. st.pyplot(fig1)
  1054. plt.close()
  1055. with col2:
  1056. # 缺陷类型 x 班次 交叉表
  1057. ct2 = pd.crosstab(filtered_df["defect_type"], filtered_df["shift"])
  1058. fig2, ax2 = plt.subplots(figsize=(8, 5))
  1059. sns.heatmap(ct2, annot=True, fmt="d", cmap="Blues", ax=ax2,
  1060. linewidths=0.5, linecolor="white")
  1061. ax2.set_title("缺陷类型 × 班次 热力图")
  1062. st.pyplot(fig2)
  1063. plt.close()
  1064. # 面板缺陷 TOP10
  1065. st.subheader("缺陷最多的面板 TOP10")
  1066. panel_defects = filtered_df.groupby("panel_id").agg({
  1067. "defect_id": "count",
  1068. "defect_type": lambda x: x.mode().iloc[0] if len(x) > 0 else "N/A"
  1069. }).rename(columns={"defect_id": "缺陷数", "defect_type": "主要缺陷类型"})
  1070. panel_defects = panel_defects.sort_values("缺陷数", ascending=False).head(10)
  1071. st.dataframe(panel_defects, use_container_width=True)
  1072. # 面板缺陷分布
  1073. fig3, ax3 = plt.subplots(figsize=(8, 4))
  1074. panel_counts = filtered_df.groupby("panel_id").size()
  1075. ax3.hist(panel_counts, bins=20, color="steelblue", alpha=0.8, edgecolor="white")
  1076. ax3.set_title("单面板缺陷数量分布")
  1077. ax3.set_xlabel("缺陷数/面板")
  1078. ax3.set_ylabel("面板数量")
  1079. ax3.axvline(x=panel_counts.mean(), color="red", linestyle="--", label=f"平均: {panel_counts.mean():.1f}")
  1080. ax3.legend()
  1081. st.pyplot(fig3)
  1082. plt.close()
  1083. # --- 智能缺陷聚类 (DBSCAN + PCA) ---
  1084. _t = get_tab("🧠 智能缺陷聚类 (DBSCAN)")
  1085. if _t:
  1086. with _t:
  1087. st.header("🧠 DBSCAN 智能缺陷空间聚类")
  1088. st.markdown(
  1089. "**原理**: DBSCAN 是基于密度的空间聚类算法,能自动识别任意形状的缺陷聚集区域,"
  1090. "无需预设聚类数量,自动过滤随机散落的噪声缺陷。"
  1091. "行业标准:半导体晶圆/面板缺陷模式识别首选算法。"
  1092. )
  1093. col1, col2 = st.columns([2, 1])
  1094. with col1:
  1095. # --- 参数控制 ---
  1096. st.subheader("参数设置")
  1097. p_col1, p_col2 = st.columns(2)
  1098. with p_col1:
  1099. eps = st.slider(
  1100. "eps (邻域半径 mm)",
  1101. min_value=5.0, max_value=100.0, value=25.0, step=5.0,
  1102. help="两个点被视为'邻居'的最大距离。值越大,簇越大。"
  1103. )
  1104. with p_col2:
  1105. min_samples = st.slider(
  1106. "min_samples (最小簇点数)",
  1107. min_value=3, max_value=50, value=10,
  1108. help="形成一个簇所需的最小点数。值越大,越严格的聚集才算簇。"
  1109. )
  1110. # --- 执行聚类 ---
  1111. coords = filtered_df[["x_mm", "y_mm"]].values
  1112. scaler = StandardScaler()
  1113. coords_scaled = scaler.fit_transform(coords)
  1114. dbscan = DBSCAN(eps=eps / scaler.scale_[0], min_samples=min_samples)
  1115. filtered_df["cluster"] = dbscan.fit_predict(coords_scaled)
  1116. # 统计聚类结果
  1117. n_clusters = len(set(dbscan.labels_)) - (1 if -1 in dbscan.labels_ else 0)
  1118. n_noise = list(dbscan.labels_).count(-1)
  1119. st.info(f"📊 **聚类结果**: 发现 **{n_clusters}** 个缺陷聚集区域,**{n_noise}** 个噪声点(随机散落缺陷)")
  1120. # --- 可视化 ---
  1121. fig, axes = plt.subplots(1, 2, figsize=(14, 6))
  1122. # 左图:聚类结果(空间位置)
  1123. labels = filtered_df["cluster"].values
  1124. unique_labels = set(labels)
  1125. colors = plt.cm.get_cmap("tab20", len(unique_labels) if len(unique_labels) > 0 else 1)
  1126. for k in unique_labels:
  1127. if k == -1:
  1128. # 噪声点
  1129. xy = filtered_df[labels == k][["x_mm", "y_mm"]].values
  1130. axes[0].scatter(xy[:, 0], xy[:, 1], c="lightgray", s=3, alpha=0.3, label="噪声")
  1131. else:
  1132. xy = filtered_df[labels == k][["x_mm", "y_mm"]].values
  1133. axes[0].scatter(xy[:, 0], xy[:, 1], c=[colors(k)], s=15, alpha=0.7,
  1134. label=f"簇 {k+1} ({len(xy)} 点)")
  1135. axes[0].set_title(f"DBSCAN 空间聚类结果 (eps={eps}, min_samples={min_samples})")
  1136. axes[0].set_xlabel("X (mm)")
  1137. axes[0].set_ylabel("Y (mm)")
  1138. axes[0].set_aspect("equal")
  1139. axes[0].legend(fontsize=7, loc="upper right", ncol=2)
  1140. # 右图:PCA 降维可视化(加入更多特征维度)
  1141. if len(filtered_df) > 2:
  1142. # 构建多维特征:x, y, hour, defect_type编码, severity编码
  1143. feature_df = filtered_df[["x_mm", "y_mm", "hour"]].copy()
  1144. # 缺陷类型编码
  1145. type_map = {t: i for i, t in enumerate(filtered_df["defect_type"].unique())}
  1146. feature_df["type_code"] = filtered_df["defect_type"].map(type_map).astype(float)
  1147. # 严重程度编码
  1148. sev_map = {"轻微": 0, "中等": 1, "严重": 2}
  1149. feature_df["sev_code"] = filtered_df["severity"].map(sev_map).astype(float)
  1150. features = feature_df.values
  1151. features_scaled = StandardScaler().fit_transform(features)
  1152. # PCA 降维到 2D
  1153. n_components = min(2, features_scaled.shape[1])
  1154. pca = PCA(n_components=n_components)
  1155. pca_result = pca.fit_transform(features_scaled)
  1156. explained_var = pca.explained_variance_ratio_
  1157. for k in unique_labels:
  1158. mask_k = labels == k
  1159. if k == -1:
  1160. axes[1].scatter(pca_result[mask_k, 0], pca_result[mask_k, 1],
  1161. c="lightgray", s=3, alpha=0.3, label="噪声")
  1162. else:
  1163. axes[1].scatter(pca_result[mask_k, 0], pca_result[mask_k, 1],
  1164. c=[colors(k)], s=15, alpha=0.7, label=f"簇 {k+1}")
  1165. axes[1].set_title(
  1166. f"PCA 多维特征降维\n"
  1167. f"PC1: {explained_var[0]*100:.1f}% | PC2: {explained_var[1]*100:.1f}%"
  1168. )
  1169. axes[1].set_xlabel("主成分 1")
  1170. axes[1].set_ylabel("主成分 2")
  1171. axes[1].legend(fontsize=7, loc="upper right")
  1172. st.pyplot(fig)
  1173. plt.close()
  1174. # --- 簇特征统计 ---
  1175. if n_clusters > 0:
  1176. st.divider()
  1177. st.subheader("各簇特征分析")
  1178. cluster_data = []
  1179. for k in sorted([c for c in unique_labels if c != -1]):
  1180. cluster_df = filtered_df[labels == k]
  1181. cluster_data.append({
  1182. "簇编号": k + 1,
  1183. "缺陷数量": len(cluster_df),
  1184. "占比": f"{len(cluster_df)/len(filtered_df)*100:.1f}%",
  1185. "中心X(mm)": round(cluster_df["x_mm"].mean(), 1),
  1186. "中心Y(mm)": round(cluster_df["y_mm"].mean(), 1),
  1187. "X范围": f"{cluster_df['x_mm'].min():.0f}~{cluster_df['x_mm'].max():.0f}",
  1188. "Y范围": f"{cluster_df['y_mm'].min():.0f}~{cluster_df['y_mm'].max():.0f}",
  1189. "主要缺陷": cluster_df["defect_type"].mode().iloc[0] if len(cluster_df) > 0 else "-",
  1190. "主要严重度": cluster_df["severity"].mode().iloc[0] if len(cluster_df) > 0 else "-",
  1191. "涉及批次": cluster_df["batch_id"].nunique(),
  1192. "涉及面板": cluster_df["panel_id"].nunique(),
  1193. })
  1194. st.dataframe(pd.DataFrame(cluster_data), use_container_width=True)
  1195. with col2:
  1196. # --- 聚类结果说明 ---
  1197. st.subheader("📖 结果解读")
  1198. st.markdown(
  1199. f"""
  1200. **当前参数**: eps={eps}mm, min_samples={min_samples}
  1201. **聚类统计**:
  1202. - 缺陷聚集区域: {n_clusters} 个
  1203. - 随机散落噪声: {n_noise} 个
  1204. - 噪声占比: {n_noise/len(filtered_df)*100:.1f}%
  1205. **参数调优建议**:
  1206. - **eps 调大** → 簇数量减少,簇变大
  1207. - **eps 调小** → 簇数量增加,更精细
  1208. - **min_samples 调大** → 只有高度密集区域才算簇
  1209. - **min_samples 调小** → 更多区域被识别为簇
  1210. **工业应用**:
  1211. - 每个"簇"代表一个**系统性缺陷源**
  1212. (如某台设备、某道工序、某个物料批次)
  1213. - "噪声"点是随机缺陷,通常无需特别关注
  1214. - 重点关注**缺陷数量多、涉及批次集中**的簇
  1215. """
  1216. )
  1217. # --- 簇分布饼图 ---
  1218. if n_clusters > 0:
  1219. st.subheader("簇规模分布")
  1220. cluster_counts = filtered_df[labels >= 0]["cluster"].value_counts().sort_index()
  1221. fig_pie, ax_pie = plt.subplots(figsize=(5, 5))
  1222. pie_labels = [f"簇{i+1}" for i in cluster_counts.index]
  1223. ax_pie.pie(cluster_counts.values, labels=pie_labels, autopct="%1.1f%%",
  1224. colors=plt.cm.tab20.colors[:len(cluster_counts)], startangle=90)
  1225. ax_pie.set_title("各簇缺陷占比")
  1226. st.pyplot(fig_pie)
  1227. plt.close()
  1228. # --- DBSCAN vs K-Means 对比 ---
  1229. st.subheader("为什么选 DBSCAN?")
  1230. st.markdown(
  1231. """
  1232. | 维度 | DBSCAN | K-Means |
  1233. |------|--------|---------|
  1234. | 形状适应 | ✅ 任意形状 | ❌ 仅球形 |
  1235. | 预设K值 | ❌ 不需要 | ✅ 必须 |
  1236. | 噪声处理 | ✅ 自动过滤 | ❌ 干扰聚类 |
  1237. | 环形/线形缺陷 | ✅ 能识别 | ❌ 识别不了 |
  1238. """
  1239. )
  1240. # ========== Tab 8: SPC 控制图与预警 ==========
  1241. _t = get_tab("🚨 SPC 控制图与预警")
  1242. if _t:
  1243. with _t:
  1244. st.header("🚨 SPC 统计过程控制")
  1245. st.markdown(
  1246. "基于统计过程控制(SPC)方法,监控每日缺陷率是否在控制限内,"
  1247. "自动检测异常趋势并给出改善/恶化结论。"
  1248. )
  1249. # --- 数据准备:按天计算缺陷率 ---
  1250. # 需要知道每天检测了多少面板才能算缺陷率
  1251. # 用 batch_id 近似日期
  1252. spc_metrics = calculate_spc_metrics(df)
  1253. daily_all = spc_metrics["daily"]
  1254. if len(daily_all) < 2:
  1255. st.warning("数据天数不足,无法生成控制图")
  1256. else:
  1257. # 控制限计算
  1258. p_bar = spc_metrics["p_bar"]
  1259. sigma_p = spc_metrics["sigma_p"]
  1260. UCL = spc_metrics["ucl"]
  1261. LCL = spc_metrics["lcl"]
  1262. UWL = spc_metrics["uwl"]
  1263. LWL = spc_metrics["lwl"]
  1264. # --- Western Electric 规则检测 ---
  1265. we_violations = []
  1266. # 规则1: 单点超出 3σ 控制限
  1267. for i, row in daily_all.iterrows():
  1268. if row["defect_rate"] > UCL or row["defect_rate"] < LCL:
  1269. we_violations.append({
  1270. "日期": row["day"].strftime("%Y-%m-%d"),
  1271. "规则": "Rule 1: 超出3σ控制限",
  1272. "值": f"{row['defect_rate']:.2%}"
  1273. })
  1274. # 规则2: 连续7点上升或下降
  1275. rates = daily_all["defect_rate"].values
  1276. if len(rates) >= 7:
  1277. for i in range(len(rates) - 6):
  1278. window = rates[i:i+7]
  1279. if all(window[j] < window[j+1] for j in range(6)):
  1280. we_violations.append({
  1281. "日期": daily_all.loc[i+6, "day"].strftime("%Y-%m-%d"),
  1282. "规则": "Rule 2: 连续7点上升",
  1283. "值": f"{rates[i]:.2%} → {rates[i+6]:.2%}"
  1284. })
  1285. elif all(window[j] > window[j+1] for j in range(6)):
  1286. we_violations.append({
  1287. "日期": daily_all.loc[i+6, "day"].strftime("%Y-%m-%d"),
  1288. "规则": "Rule 2: 连续7点下降",
  1289. "值": f"{rates[i]:.2%} → {rates[i+6]:.2%}"
  1290. })
  1291. # 规则3: 连续7点在中心线同一侧
  1292. for i in range(len(rates) - 6):
  1293. window = rates[i:i+7]
  1294. if all(v > p_bar for v in window):
  1295. we_violations.append({
  1296. "日期": daily_all.loc[i+6, "day"].strftime("%Y-%m-%d"),
  1297. "规则": "Rule 3: 连续7点在CL上方",
  1298. "值": f"持续偏高"
  1299. })
  1300. elif all(v < p_bar for v in window):
  1301. we_violations.append({
  1302. "日期": daily_all.loc[i+6, "day"].strftime("%Y-%m-%d"),
  1303. "规则": "Rule 3: 连续7点在CL下方",
  1304. "值": f"持续偏低"
  1305. })
  1306. # --- 趋势分析 ---
  1307. from numpy.polynomial import polynomial as P
  1308. x = np.arange(len(daily_all))
  1309. coeffs = np.polyfit(x, rates, 1)
  1310. slope = coeffs[0]
  1311. daily_all["trend"] = np.polyval(coeffs, x)
  1312. if abs(slope) < sigma_p * 0.1:
  1313. trend_status = "稳定"
  1314. trend_icon = "➡️"
  1315. trend_color = "normal"
  1316. elif slope > 0:
  1317. trend_status = "恶化中"
  1318. trend_icon = "📈"
  1319. trend_color = "inverse"
  1320. else:
  1321. trend_status = "改善中"
  1322. trend_icon = "📉"
  1323. trend_color = "normal"
  1324. # --- KPI 行 ---
  1325. kpi_spc1, kpi_spc2, kpi_spc3, kpi_spc4 = st.columns(4)
  1326. kpi_spc1.metric("平均缺陷率", f"{p_bar:.2%}")
  1327. kpi_spc2.metric("控制限 (UCL/LCL)", f"{UCL:.2%} / {LCL:.2%}")
  1328. kpi_spc3.metric("趋势判断", f"{trend_icon} {trend_status}", delta=f"斜率: {slope*100:.3f}%/天", delta_color=trend_color)
  1329. kpi_spc4.metric("Western Electric 告警", f"{len(we_violations)} 次", delta="需关注" if len(we_violations) > 0 else "正常")
  1330. # --- 控制图 ---
  1331. st.divider()
  1332. st.subheader("X-bar 控制图 (每日缺陷率)")
  1333. fig_spc, ax_spc = plt.subplots(figsize=(14, 5))
  1334. # 数据点
  1335. ax_spc.plot(daily_all["day"], daily_all["defect_rate"],
  1336. marker="o", markersize=4, linewidth=1.5, color="steelblue", label="日缺陷率")
  1337. ax_spc.fill_between(daily_all["day"], daily_all["defect_rate"], alpha=0.15, color="steelblue")
  1338. # 控制限线
  1339. ax_spc.axhline(y=p_bar, color="green", linestyle="-", linewidth=1.5, label=f"CL (中心线): {p_bar:.2%}")
  1340. ax_spc.axhline(y=UCL, color="red", linestyle="--", linewidth=1, label=f"UCL: {UCL:.2%}")
  1341. ax_spc.axhline(y=LCL, color="red", linestyle="--", linewidth=1, label=f"LCL: {LCL:.2%}")
  1342. ax_spc.axhline(y=UWL, color="orange", linestyle=":", linewidth=1, alpha=0.6, label=f"UWL (2σ): {UWL:.2%}")
  1343. ax_spc.axhline(y=LWL, color="orange", linestyle=":", linewidth=1, alpha=0.6, label=f"LWL (2σ): {LWL:.2%}")
  1344. # 标注异常点
  1345. for v in we_violations:
  1346. if "Rule 1" in v["规则"]:
  1347. anomaly_date = pd.Timestamp(v["日期"])
  1348. val = float(v["值"].rstrip("%")) / 100
  1349. ax_spc.annotate("⚠️", (anomaly_date, val), fontsize=12,
  1350. ha="center", va="bottom", color="red")
  1351. ax_spc.set_title("SPC 控制图 - 每日缺陷率")
  1352. ax_spc.set_ylabel("缺陷率")
  1353. ax_spc.tick_params(axis="x", rotation=45)
  1354. ax_spc.legend(fontsize=8, loc="upper right")
  1355. ax_spc.grid(True, alpha=0.3)
  1356. st.pyplot(fig_spc)
  1357. plt.close()
  1358. # --- 趋势图 ---
  1359. st.subheader("缺陷率趋势 (含线性回归)")
  1360. fig_trend, ax_trend = plt.subplots(figsize=(14, 4))
  1361. ax_trend.plot(daily_all["day"], daily_all["defect_rate"],
  1362. marker="o", markersize=3, linewidth=1.5, color="steelblue", label="日缺陷率")
  1363. ax_trend.plot(daily_all["day"], daily_all["trend"],
  1364. color="red", linestyle="--", linewidth=2, label=f"趋势线 (斜率: {slope*100:.3f}%/天)")
  1365. ax_trend.fill_between(daily_all["day"], daily_all["defect_rate"], alpha=0.1, color="steelblue")
  1366. ax_trend.axhline(y=p_bar, color="green", linestyle="--", alpha=0.5, label=f"平均: {p_bar:.2%}")
  1367. ax_trend.set_ylabel("缺陷率")
  1368. ax_trend.tick_params(axis="x", rotation=45)
  1369. ax_trend.legend(fontsize=8)
  1370. ax_trend.grid(True, alpha=0.3)
  1371. st.pyplot(fig_trend)
  1372. plt.close()
  1373. # --- 告警清单 ---
  1374. st.divider()
  1375. st.subheader("⚠️ Western Electric 规则告警清单")
  1376. if we_violations:
  1377. we_df = pd.DataFrame(we_violations)
  1378. st.dataframe(we_df, use_container_width=True)
  1379. st.warning(f"共发现 **{len(we_violations)}** 次统计异常,建议关注对应日期的工艺参数和人员排班")
  1380. else:
  1381. st.success("✅ 未触发 Western Electric 规则告警,过程处于统计控制状态")
  1382. # --- 结论 ---
  1383. st.divider()
  1384. st.subheader("📋 过程能力结论")
  1385. if trend_status == "改善中":
  1386. st.success(
  1387. f"**趋势改善中** 📉\n\n"
  1388. f"每日缺陷率以平均 {abs(slope)*100:.3f}%/天 的速度下降。\n"
  1389. f"当前平均缺陷率为 {p_bar:.2%},控制上限 {UCL:.2%}。\n"
  1390. f"{'已触发' if we_violations else '未触发'} Western Electric 规则告警。"
  1391. )
  1392. elif trend_status == "恶化中":
  1393. st.error(
  1394. f"**趋势恶化中** 📈\n\n"
  1395. f"每日缺陷率以平均 {slope*100:.3f}%/天 的速度上升。\n"
  1396. f"当前平均缺陷率为 {p_bar:.2%},控制上限 {UCL:.2%}。\n"
  1397. f"{'已触发' if we_violations else '未触发'} Western Electric 规则告警。\n\n"
  1398. f"建议:检查近期工艺参数变化、设备状态和原材料批次。"
  1399. )
  1400. else:
  1401. st.info(
  1402. f"**过程稳定** ➡️\n\n"
  1403. f"缺陷率趋势平稳,斜率 {slope*100:.3f}%/天,无显著上升或下降。\n"
  1404. f"当前平均缺陷率为 {p_bar:.2%},控制限 [{LCL:.2%}, {UCL:.2%}]。\n"
  1405. f"{'已触发' if we_violations else '未触发'} Western Electric 规则告警。"
  1406. )
  1407. # ========== 重复缺陷坐标检测 ==========
  1408. _t = get_tab("🗺️ 空间集中性")
  1409. if _t:
  1410. with _t:
  1411. st.divider()
  1412. st.subheader("🎯 重复缺陷坐标检测")
  1413. st.markdown(
  1414. "检测在不同面板上重复出现的缺陷坐标。随机缺陷不会在同一位置反复出现,"
  1415. "而设备硬伤(如吸嘴划伤、夹具压痕)会在相同位置持续产生缺陷。"
  1416. "这是从'描述分析'跨入'根因诊断'的关键一步。"
  1417. )
  1418. # 坐标分桶:将面板划分为网格,找出跨面板重复的缺陷桶
  1419. repeat_bin_size = st.slider("坐标分桶大小 (mm)", min_value=5, max_value=50, value=15, step=5,
  1420. help="将坐标按此大小分桶,同一桶内出现于不同面板的缺陷视为'重复'")
  1421. pw = df["panel_width_mm"].iloc[0]
  1422. ph = df["panel_height_mm"].iloc[0]
  1423. # 计算桶ID
  1424. df_copy = filtered_df.copy()
  1425. df_copy["x_bin"] = (df_copy["x_mm"] // repeat_bin_size).astype(int)
  1426. df_copy["y_bin"] = (df_copy["y_mm"] // repeat_bin_size).astype(int)
  1427. df_copy["bin_key"] = df_copy["x_bin"].astype(str) + "_" + df_copy["y_bin"].astype(str)
  1428. # 统计每个桶出现在多少不同面板上
  1429. bin_panels = df_copy.groupby("bin_key").agg(
  1430. panel_count=("panel_id", "nunique"),
  1431. defect_count=("defect_id", "count"),
  1432. x_center=("x_mm", "mean"),
  1433. y_center=("y_mm", "mean"),
  1434. dominant_type=("defect_type", lambda x: x.mode().iloc[0] if len(x) > 0 else "-"),
  1435. dominant_severity=("severity", lambda x: x.mode().iloc[0] if len(x) > 0 else "-"),
  1436. ).reset_index()
  1437. repeat_threshold = st.slider("重复判定阈值 (跨面板数)", min_value=2, max_value=10, value=3)
  1438. repeated_bins = bin_panels[bin_panels["panel_count"] >= repeat_threshold].sort_values("panel_count", ascending=False)
  1439. col_repeat1, col_repeat2 = st.columns([1, 2])
  1440. with col_repeat1:
  1441. st.metric("重复缺陷桶数", f"{len(repeated_bins)}",
  1442. delta=f"阈值: ≥{repeat_threshold} 块面板")
  1443. if len(repeated_bins) > 0:
  1444. st.dataframe(
  1445. repeated_bins[["panel_count", "defect_count", "x_center", "y_center", "dominant_type", "dominant_severity"]]
  1446. .rename(columns={"panel_count": "涉及面板", "defect_count": "缺陷总数",
  1447. "x_center": "中心X", "y_center": "中心Y",
  1448. "dominant_type": "主要类型", "dominant_severity": "主要严重度"}),
  1449. use_container_width=True, height=400
  1450. )
  1451. else:
  1452. st.info(f"未发现跨 {repeat_threshold}+ 块面板的重复缺陷坐标")
  1453. with col_repeat2:
  1454. if len(repeated_bins) > 0:
  1455. # 在面板图上标注重复缺陷桶
  1456. fig_repeat, ax_repeat = plt.subplots(figsize=(4, 6))
  1457. # 面板背景
  1458. ax_repeat.add_patch(plt.Rectangle((0, 0), pw, ph, facecolor="#1a1a2e", edgecolor="#444", linewidth=2))
  1459. ax_repeat.add_patch(plt.Rectangle((8, 8), pw-16, ph-16, facecolor="#16213e", edgecolor="#0f3460", linewidth=1.5))
  1460. # 所有缺陷散点(淡)
  1461. ax_repeat.scatter(filtered_df["x_mm"], filtered_df["y_mm"],
  1462. alpha=0.1, s=2, c="gray", edgecolors="none", zorder=1)
  1463. # 重复缺陷桶标注重叠圈
  1464. max_count = repeated_bins["panel_count"].max()
  1465. for _, row in repeated_bins.iterrows():
  1466. size = 100 + (row["panel_count"] / max_count) * 400
  1467. ax_repeat.scatter(row["x_center"], row["y_center"],
  1468. s=size, c="red", alpha=0.3, edgecolors="red",
  1469. linewidth=2, zorder=3)
  1470. ax_repeat.text(row["x_center"], row["y_center"],
  1471. str(row["panel_count"]), ha="center", va="center",
  1472. fontsize=8, color="white", fontweight="bold", zorder=4)
  1473. ax_repeat.set_xlim(-5, pw + 5)
  1474. ax_repeat.set_ylim(-5, ph + 5)
  1475. ax_repeat.set_title(f"重复缺陷坐标 (≥{repeat_threshold} 块面板)", fontsize=11)
  1476. ax_repeat.set_xlabel("X (mm)")
  1477. ax_repeat.set_ylabel("Y (mm)")
  1478. ax_repeat.set_aspect("equal")
  1479. ax_repeat.grid(True, alpha=0.1, color="gray")
  1480. st.pyplot(fig_repeat)
  1481. plt.close()
  1482. else:
  1483. st.info("调整分桶大小或阈值以检测重复缺陷")
  1484. # ========== Tab 9: 缺陷模式识别 ==========
  1485. _t = get_tab("🔬 缺陷模式识别")
  1486. if _t:
  1487. with _t:
  1488. st.header("🔬 缺陷空间模式自动识别")
  1489. st.markdown(
  1490. "参考 WM811K 晶圆缺陷图谱分类标准,对每块面板的缺陷分布进行模式评分。"
  1491. "不同模式对应不同的根因机制(如边缘型→贴合工艺,角落型→夹具应力,"
  1492. "中心型→压力不均,线条型→机械刮伤,随机型→来料污染)。"
  1493. )
  1494. from scipy.spatial import ConvexHull
  1495. from scipy.spatial.distance import cdist
  1496. pw = df["panel_width_mm"].iloc[0]
  1497. ph = df["panel_height_mm"].iloc[0]
  1498. # 按面板分组,逐块分析模式
  1499. panel_groups = filtered_df.groupby("panel_id")
  1500. patterns_results = []
  1501. for panel_id, panel_data in panel_groups:
  1502. if len(panel_data) < 3:
  1503. continue
  1504. coords = panel_data[["x_mm", "y_mm"]].values
  1505. # 归一化坐标到 [0,1]
  1506. x_norm = panel_data["x_mm"].values / pw
  1507. y_norm = panel_data["y_mm"].values / ph
  1508. # --- 模式1: 边缘型 (缺陷靠近面板四边) ---
  1509. # 计算每个点到最近边缘的距离比例
  1510. edge_dist = np.minimum(np.minimum(x_norm, 1 - x_norm),
  1511. np.minimum(y_norm, 1 - y_norm))
  1512. edge_ratio = (edge_dist < 0.12).mean() # 12% 以内的点视为边缘点
  1513. edge_score = edge_ratio
  1514. # --- 模式2: 角落型 (缺陷集中在四个角落) ---
  1515. corner_threshold = 0.15 # 15% 范围
  1516. in_corner = (
  1517. ((x_norm < corner_threshold) & (y_norm < corner_threshold)) | # 左下
  1518. ((x_norm < corner_threshold) & (y_norm > 1 - corner_threshold)) | # 左上
  1519. ((x_norm > 1 - corner_threshold) & (y_norm < corner_threshold)) | # 右下
  1520. ((x_norm > 1 - corner_threshold) & (y_norm > 1 - corner_threshold)) # 右上
  1521. )
  1522. corner_score = in_corner.mean()
  1523. # --- 模式3: 中心型 (缺陷集中在面板中心区域) ---
  1524. center_x, center_y = 0.5, 0.5
  1525. dist_to_center = np.sqrt((x_norm - center_x)**2 + (y_norm - center_y)**2)
  1526. center_radius = 0.18 # 18% 半径
  1527. center_score = (dist_to_center < center_radius).mean()
  1528. # --- 模式4: 线条型 (缺陷沿一条线分布) ---
  1529. # 用 PCA 第一主成分占比来判断线性程度
  1530. if len(coords) >= 3:
  1531. from sklearn.decomposition import PCA
  1532. pca = PCA(n_components=2)
  1533. pca.fit(coords)
  1534. linearity = pca.explained_variance_ratio_[0] # 第一主成分占比
  1535. line_score = linearity
  1536. else:
  1537. line_score = 0
  1538. # --- 模式5: 随机型 (均匀分布,无明显模式) ---
  1539. # 用空间变异系数:将面板分为网格,计算各格缺陷数的变异系数
  1540. grid_n = 5
  1541. x_edges = np.linspace(0, pw, grid_n + 1)
  1542. y_edges = np.linspace(0, ph, grid_n + 1)
  1543. H, _, _ = np.histogram2d(panel_data["x_mm"].values, panel_data["y_mm"].values,
  1544. bins=[x_edges, y_edges])
  1545. if H.sum() > 0 and H.std() > 0:
  1546. cv = H.std() / H.mean() if H.mean() > 0 else 999
  1547. # cv 越小越均匀(随机)
  1548. randomness_score = max(0, 1 - cv / 3) # 归一化到 [0,1]
  1549. else:
  1550. randomness_score = 0
  1551. # --- 主导模式判定 ---
  1552. scores = {
  1553. "边缘型": edge_score,
  1554. "角落型": corner_score,
  1555. "中心型": center_score,
  1556. "线条型": line_score,
  1557. "随机型": randomness_score,
  1558. }
  1559. dominant_pattern = max(scores, key=scores.get)
  1560. patterns_results.append({
  1561. "面板ID": panel_id,
  1562. "缺陷数": len(panel_data),
  1563. "主导模式": dominant_pattern,
  1564. "边缘型": round(edge_score, 2),
  1565. "角落型": round(corner_score, 2),
  1566. "中心型": round(center_score, 2),
  1567. "线条型": round(line_score, 2),
  1568. "随机型": round(randomness_score, 2),
  1569. })
  1570. if patterns_results:
  1571. pattern_df = pd.DataFrame(patterns_results)
  1572. # --- 模式统计 ---
  1573. col_pat1, col_pat2, col_pat3 = st.columns([1, 1, 2])
  1574. with col_pat1:
  1575. pattern_counts = pattern_df["主导模式"].value_counts()
  1576. fig_pat, ax_pat = plt.subplots(figsize=(8, 5))
  1577. colors_pat = {"边缘型": "#FF6B6B", "角落型": "#FFA500", "中心型": "#4ECDC4",
  1578. "线条型": "#9B59B6", "随机型": "#95A5A6"}
  1579. bars = ax_pat.bar(pattern_counts.index, pattern_counts.values,
  1580. color=[colors_pat.get(p, "#888") for p in pattern_counts.index],
  1581. alpha=0.8)
  1582. for bar, count in zip(bars, pattern_counts.values):
  1583. ax_pat.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.5,
  1584. str(count), ha="center", va="bottom", fontsize=11, fontweight="bold")
  1585. ax_pat.set_title("缺陷模式分布")
  1586. ax_pat.set_ylabel("面板数量")
  1587. st.pyplot(fig_pat)
  1588. plt.close()
  1589. with col_pat2:
  1590. st.subheader("模式占比")
  1591. total_panels = len(pattern_df)
  1592. for pattern in ["边缘型", "角落型", "中心型", "线条型", "随机型"]:
  1593. count = (pattern_df["主导模式"] == pattern).sum()
  1594. pct = count / total_panels * 100
  1595. st.metric(pattern, f"{count} 块", f"{pct:.1f}%")
  1596. with col_pat3:
  1597. # --- 模式-根因映射 ---
  1598. st.subheader("模式 → 可能根因")
  1599. root_cause_map = {
  1600. "边缘型": {
  1601. "可能原因": "贴合工艺参数异常、边缘夹具压力不均、涂胶厚度不均",
  1602. "建议排查": "检查贴合压力、边缘密封工艺、涂胶均匀性"
  1603. },
  1604. "角落型": {
  1605. "可能原因": "夹具应力集中、面板放置定位偏差、角落散热不良",
  1606. "建议排查": "检查夹具对齐、面板定位精度、角落温度分布"
  1607. },
  1608. "中心型": {
  1609. "可能原因": "压力中心不均、FPC绑定区域工艺异常、中心温度过高",
  1610. "建议排查": "检查压力分布曲线、FPC绑定参数、加热板温度"
  1611. },
  1612. "线条型": {
  1613. "可能原因": "机械刮伤、传送带划痕、清洗刷毛磨损、吸嘴移动轨迹",
  1614. "建议排查": "检查传送带状态、清洗设备、吸嘴运动轨迹"
  1615. },
  1616. "随机型": {
  1617. "可能原因": "来料污染、环境尘埃、化学药液杂质",
  1618. "建议排查": "检查洁净室等级、来料检验记录、药液过滤状态"
  1619. },
  1620. }
  1621. for pattern in ["边缘型", "角落型", "中心型", "线条型", "随机型"]:
  1622. count = (pattern_df["主导模式"] == pattern).sum()
  1623. if count == 0:
  1624. continue
  1625. rc = root_cause_map[pattern]
  1626. with st.expander(f"{pattern} ({count} 块面板)"):
  1627. st.markdown(f"**可能原因**: {rc['可能原因']}")
  1628. st.markdown(f"**建议排查**: {rc['建议排查']}")
  1629. # --- 详细数据表 ---
  1630. st.divider()
  1631. st.subheader("面板模式评分明细")
  1632. st.dataframe(pattern_df, use_container_width=True, height=400)
  1633. else:
  1634. st.warning("当前筛选条件下无足够面板数据进行模式分析(需至少 3 个缺陷/面板)")
  1635. # ========== Tab 10: 设备健康与共性分析 ==========
  1636. _t = get_tab("💚 设备健康与共性分析")
  1637. if _t:
  1638. with _t:
  1639. st.header("💚 设备健康评分 & 共性分析")
  1640. st.markdown(
  1641. "综合评估各台设备的健康状态,并在发现异常批次时自动分析其共性特征。"
  1642. )
  1643. # --- 设备健康评分 ---
  1644. st.subheader("设备健康评分 (0-100)")
  1645. st.markdown("评分维度:缺陷率(40%) + 座号集中度(30%) + 严重度分布(30%)")
  1646. health_data = []
  1647. for eq_id in sorted(df["equipment_id"].unique()):
  1648. eq_all = df[df["equipment_id"] == eq_id]
  1649. eq_filtered = filtered_df[filtered_df["equipment_id"] == eq_id]
  1650. # 维度1: 缺陷率评分 (40%)
  1651. eq_panels = eq_all["panel_id"].nunique()
  1652. eq_defects = len(eq_all)
  1653. eq_defect_rate = eq_defects / max(eq_panels, 1)
  1654. # 缺陷率越低分越高,线性归一化
  1655. # 以 5 个缺陷/面板为最差(0分),0 为最好(100分)
  1656. rate_score = max(0, 100 * (1 - eq_defect_rate / 5))
  1657. # 维度2: 座号集中度评分 (30%)
  1658. # 座号分布越均匀分越高,集中分越低
  1659. eq_seat_counts = eq_all.groupby("seat_id").size()
  1660. if len(eq_seat_counts) > 1:
  1661. seat_cv = eq_seat_counts.std() / max(eq_seat_counts.mean(), 0.001)
  1662. # cv 越小越均匀,得分越高
  1663. seat_score = max(0, 100 * (1 - seat_cv / 3))
  1664. else:
  1665. seat_score = 50
  1666. # 维度3: 严重度评分 (30%)
  1667. eq_sev = eq_all["severity"].value_counts()
  1668. severe_ratio = eq_sev.get("严重", 0) / max(len(eq_all), 1)
  1669. sev_score = max(0, 100 * (1 - severe_ratio * 3)) # 严重占比 33% 时为 0 分
  1670. # 综合得分
  1671. total_score = rate_score * 0.4 + seat_score * 0.3 + sev_score * 0.3
  1672. health_data.append({
  1673. "设备ID": eq_id,
  1674. "缺陷总数": eq_defects,
  1675. "缺陷率": f"{eq_defect_rate:.2f}",
  1676. "座号集中度(CV)": f"{seat_cv:.2f}" if len(eq_seat_counts) > 1 else "N/A",
  1677. "严重占比": f"{severe_ratio:.1%}",
  1678. "缺陷率分(40%)": round(rate_score, 1),
  1679. "座号分(30%)": round(seat_score, 1),
  1680. "严重度分(30%)": round(sev_score, 1),
  1681. "健康总分": round(total_score, 1),
  1682. })
  1683. health_df = pd.DataFrame(health_data).sort_values("健康总分", ascending=False)
  1684. # 显示健康评分
  1685. col_h1, col_h2 = st.columns([3, 2])
  1686. with col_h1:
  1687. st.dataframe(health_df, use_container_width=True, hide_index=True)
  1688. with col_h2:
  1689. # 可视化排名
  1690. fig_health, ax_health = plt.subplots(figsize=(6, 4))
  1691. health_sorted = health_df.sort_values("健康总分", ascending=True)
  1692. colors_health = ["#4CAF50" if s >= 70 else "#FF9800" if s >= 40 else "#F44336"
  1693. for s in health_sorted["健康总分"]]
  1694. bars = ax_health.barh(health_sorted["设备ID"], health_sorted["健康总分"],
  1695. color=colors_health, alpha=0.8, height=0.5)
  1696. for bar, score in zip(bars, health_sorted["健康总分"]):
  1697. ax_health.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2,
  1698. f"{score:.0f}", ha="left", va="center", fontsize=12, fontweight="bold")
  1699. ax_health.set_xlabel("健康评分 (0-100)")
  1700. ax_health.set_title("设备健康排名")
  1701. ax_health.set_xlim(0, 110)
  1702. st.pyplot(fig_health)
  1703. plt.close()
  1704. # --- 共性分析 ---
  1705. st.divider()
  1706. st.subheader("🔍 异常批次共性分析")
  1707. st.markdown("选中异常批次后,自动分析这些批次的共同特征(设备/时段/座号/缺陷类型)。")
  1708. # 自动检测异常批次(基于缺陷率)
  1709. batch_stats = df.groupby("batch_id").agg(
  1710. defects=("defect_id", "count"),
  1711. panels=("panel_id", "nunique")
  1712. )
  1713. batch_stats["defect_rate"] = batch_stats["defects"] / batch_stats["panels"]
  1714. threshold = batch_stats["defect_rate"].mean() + batch_stats["defect_rate"].std()
  1715. abnormal_batches = batch_stats[batch_stats["defect_rate"] > threshold].index.tolist()
  1716. st.info(f"自动检测到的异常批次 (缺陷率 > {threshold:.2%}): **{len(abnormal_batches)}** 个")
  1717. st.write(", ".join(abnormal_batches[:10]))
  1718. if abnormal_batches:
  1719. col_c1, col_c2 = st.columns(2)
  1720. with col_c1:
  1721. # 选择要分析的批次
  1722. selected_abnormal = st.multiselect(
  1723. "选择要分析的异常批次",
  1724. options=abnormal_batches,
  1725. default=abnormal_batches[:3] if len(abnormal_batches) >= 3 else abnormal_batches,
  1726. key="commonality_batch"
  1727. )
  1728. if selected_abnormal:
  1729. abnormal_df = df[df["batch_id"].isin(selected_abnormal)]
  1730. normal_df = df[~df["batch_id"].isin(selected_abnormal)]
  1731. st.divider()
  1732. st.markdown(f"**分析对象**: {len(selected_abnormal)} 个异常批次, "
  1733. f"{len(abnormal_df)} 条缺陷记录")
  1734. # 共性分析:设备
  1735. st.subheader("共性特征 TOP3")
  1736. col_common1, col_common2, col_common3 = st.columns(3)
  1737. with col_common1:
  1738. # 设备共性
  1739. abnormal_eq_rate = abnormal_df.groupby("equipment_id").size() / len(abnormal_df)
  1740. normal_eq_rate = normal_df.groupby("equipment_id").size() / len(normal_df)
  1741. eq_boost = {}
  1742. for eq in abnormal_df["equipment_id"].unique():
  1743. a_rate = abnormal_eq_rate.get(eq, 0)
  1744. n_rate = normal_eq_rate.get(eq, 0)
  1745. if n_rate > 0:
  1746. eq_boost[eq] = (a_rate - n_rate) / n_rate * 100
  1747. else:
  1748. eq_boost[eq] = 999
  1749. eq_top = sorted(eq_boost.items(), key=lambda x: x[1], reverse=True)[:3]
  1750. st.markdown("**设备共用性**")
  1751. for eq, boost in eq_top:
  1752. st.markdown(f"- {eq}: 异常占比 {abnormal_eq_rate.get(eq, 0):.1%}, "
  1753. f"相对正常 **+{boost:.0f}%**")
  1754. with col_common2:
  1755. # 时段共性
  1756. abnormal_hour = abnormal_df.groupby("hour").size() / len(abnormal_df)
  1757. normal_hour = normal_df.groupby("hour").size() / len(normal_df)
  1758. # 按班次聚合
  1759. abnormal_shift = abnormal_df.groupby("shift").size() / len(abnormal_df)
  1760. normal_shift = normal_df.groupby("shift").size() / len(normal_df)
  1761. st.markdown("**时段共性**")
  1762. for shift in ["白班", "夜班"]:
  1763. a_rate = abnormal_shift.get(shift, 0)
  1764. n_rate = normal_shift.get(shift, 0)
  1765. if n_rate > 0:
  1766. boost = (a_rate - n_rate) / n_rate * 100
  1767. else:
  1768. boost = 999
  1769. st.markdown(f"- {shift}: 异常占比 {a_rate:.1%}, "
  1770. f"相对正常 **{'+' if boost > 0 else ''}{boost:.0f}%**")
  1771. with col_common3:
  1772. # 座号共性
  1773. abnormal_seat = abnormal_df.groupby("seat_id").size() / len(abnormal_df)
  1774. normal_seat = normal_df.groupby("seat_id").size() / len(normal_df)
  1775. seat_boost = {}
  1776. for seat in abnormal_df["seat_id"].unique():
  1777. a_rate = abnormal_seat.get(seat, 0)
  1778. n_rate = normal_seat.get(seat, 0)
  1779. if n_rate > 0:
  1780. seat_boost[seat] = (a_rate - n_rate) / n_rate * 100
  1781. else:
  1782. seat_boost[seat] = 999
  1783. seat_top = sorted(seat_boost.items(), key=lambda x: x[1], reverse=True)[:3]
  1784. st.markdown("**座号共性**")
  1785. for seat, boost in seat_top:
  1786. st.markdown(f"- {seat}: 异常占比 {abnormal_seat.get(seat, 0):.1%}, "
  1787. f"相对正常 **+{boost:.0f}%**")
  1788. # --- 缺陷类型偏差 ---
  1789. st.subheader("异常批次缺陷类型偏差")
  1790. abnormal_type = abnormal_df.groupby("defect_type").size() / len(abnormal_df)
  1791. normal_type = normal_df.groupby("defect_type").size() / len(normal_df)
  1792. type_diff = []
  1793. for t in set(list(abnormal_type.index) + list(normal_type.index)):
  1794. a_rate = abnormal_type.get(t, 0)
  1795. n_rate = normal_type.get(t, 0)
  1796. type_diff.append({
  1797. "缺陷类型": t,
  1798. "异常占比": f"{a_rate:.1%}",
  1799. "正常占比": f"{n_rate:.1%}",
  1800. "偏差": f"{'+' if a_rate > n_rate else ''}{(a_rate - n_rate) / max(n_rate, 0.001) * 100:.0f}%",
  1801. })
  1802. st.dataframe(pd.DataFrame(type_diff).sort_values("偏差", key=lambda x: x.str.rstrip("%").astype(float), ascending=False),
  1803. use_container_width=True, hide_index=True)
  1804. # ========== Tab 11: 多层叠加分析 ==========
  1805. _t = get_tab("🔲 多层叠加分析")
  1806. if _t:
  1807. with _t:
  1808. st.header("🔲 多层叠加分析")
  1809. st.markdown(
  1810. "将缺陷数据与面板物理区域、设备座号、时间维度叠加在同一视图上,"
  1811. "揭示单一维度看不到的深层关联。"
  1812. )
  1813. pw = df["panel_width_mm"].iloc[0]
  1814. ph = df["panel_height_mm"].iloc[0]
  1815. # --- 自定义区域定义 ---
  1816. st.subheader("📐 自定义区域缺陷统计")
  1817. st.markdown("将面板划分为不同功能区域,统计各区域缺陷分布")
  1818. # 定义区域:(名称, 判定函数)
  1819. # 边缘区:距四边 < 15%
  1820. # 中心区:距中心 < 20% 半径
  1821. # 角落区:四个角的 15% 范围
  1822. # FPC区:Y > 70% 高度
  1823. # 上半区/下半区
  1824. def classify_zone(x_norm, y_norm):
  1825. """将每个缺陷点分类到区域"""
  1826. zones = []
  1827. for i in range(len(x_norm)):
  1828. zx, zy = x_norm[i], y_norm[i]
  1829. zone_list = []
  1830. # 边缘区
  1831. if min(zx, 1 - zx, zy, 1 - zy) < 0.15:
  1832. zone_list.append("边缘区")
  1833. # 中心区
  1834. if np.sqrt((zx - 0.5)**2 + (zy - 0.5)**2) < 0.20:
  1835. zone_list.append("中心区")
  1836. # 角落区
  1837. if (zx < 0.15 or zx > 0.85) and (zy < 0.15 or zy > 0.85):
  1838. zone_list.append("角落区")
  1839. # FPC区
  1840. if zy > 0.70:
  1841. zone_list.append("FPC区")
  1842. # 上半区
  1843. if zy < 0.50:
  1844. zone_list.append("上半区")
  1845. # 下半区
  1846. if zy > 0.50:
  1847. zone_list.append("下半区")
  1848. if not zone_list:
  1849. zone_list.append("其他区域")
  1850. zones.append(", ".join(zone_list))
  1851. return zones
  1852. # 计算每个缺陷的区域归属
  1853. x_norm_arr = filtered_df["x_mm"].values / pw
  1854. y_norm_arr = filtered_df["y_mm"].values / ph
  1855. filtered_df_copy = filtered_df.copy()
  1856. filtered_df_copy["zone"] = classify_zone(x_norm_arr, y_norm_arr)
  1857. # 统计各区域缺陷数
  1858. zone_counts = {}
  1859. zone_types = ["边缘区", "中心区", "角落区", "FPC区", "上半区", "下半区", "其他区域"]
  1860. for z in zone_types:
  1861. count = filtered_df_copy["zone"].str.contains(z).sum()
  1862. zone_counts[z] = count
  1863. col_z1, col_z2 = st.columns([1, 2])
  1864. with col_z1:
  1865. st.subheader("区域缺陷统计")
  1866. for z in zone_types:
  1867. count = zone_counts.get(z, 0)
  1868. pct = count / max(len(filtered_df_copy), 1) * 100
  1869. bar_len = int(pct / 100 * 200)
  1870. bar = "█" * max(bar_len, 0)
  1871. st.markdown(f"{z} | {bar} **{count}** ({pct:.1f}%)")
  1872. with col_z2:
  1873. # 区域可视化
  1874. fig_zone, ax_zone = plt.subplots(figsize=(4, 6))
  1875. # 面板背景
  1876. ax_zone.add_patch(plt.Rectangle((0, 0), pw, ph, facecolor="#1a1a2e", edgecolor="#444", linewidth=2))
  1877. # 区域边界
  1878. # 边缘区 (15% 边界)
  1879. margin_x = pw * 0.15
  1880. margin_y = ph * 0.15
  1881. ax_zone.add_patch(plt.Rectangle((0, 0), margin_x, ph, fill=False, edgecolor="yellow", linewidth=1, alpha=0.4, linestyle="--"))
  1882. ax_zone.add_patch(plt.Rectangle((pw - margin_x, 0), margin_x, ph, fill=False, edgecolor="yellow", linewidth=1, alpha=0.4, linestyle="--"))
  1883. ax_zone.add_patch(plt.Rectangle((0, 0), pw, margin_y, fill=False, edgecolor="yellow", linewidth=1, alpha=0.4, linestyle="--"))
  1884. ax_zone.add_patch(plt.Rectangle((0, ph - margin_y), pw, margin_y, fill=False, edgecolor="yellow", linewidth=1, alpha=0.4, linestyle="--"))
  1885. # 中心区 (20% 半径)
  1886. center_r = 0.20 * max(pw, ph) / 2
  1887. circle = plt.Circle((pw/2, ph/2), center_r, fill=False, edgecolor="cyan", linewidth=1.5, alpha=0.5, linestyle="--")
  1888. ax_zone.add_patch(circle)
  1889. # FPC区
  1890. fpc_y = ph * 0.70
  1891. ax_zone.add_patch(plt.Rectangle((0, fpc_y), pw, ph - fpc_y, fill=False, edgecolor="magenta", linewidth=1.5, alpha=0.5, linestyle="--"))
  1892. # 缺陷散点
  1893. scatter_colors = {"边缘区": "yellow", "中心区": "cyan", "角落区": "orange",
  1894. "FPC区": "magenta", "上半区": "#4ECDC4", "下半区": "#45B7D1", "其他区域": "gray"}
  1895. for z_name in zone_types:
  1896. z_mask = filtered_df_copy["zone"].str.contains(z_name)
  1897. if z_mask.sum() > 0:
  1898. z_data = filtered_df_copy[z_mask]
  1899. ax_zone.scatter(z_data["x_mm"], z_data["y_mm"],
  1900. c=scatter_colors.get(z_name, "gray"), s=5, alpha=0.3,
  1901. label=f"{z_name} ({z_mask.sum()})", edgecolors="none", zorder=2)
  1902. ax_zone.set_xlim(-5, pw + 5)
  1903. ax_zone.set_ylim(-5, ph + 5)
  1904. ax_zone.set_title("缺陷区域叠加图 (虚线=区域边界)")
  1905. ax_zone.set_xlabel("X (mm)")
  1906. ax_zone.set_ylabel("Y (mm)")
  1907. ax_zone.set_aspect("equal")
  1908. ax_zone.legend(fontsize=7, loc="upper right", ncol=1, framealpha=0.7)
  1909. st.pyplot(fig_zone)
  1910. plt.close()
  1911. # --- 跨批次同座号面板对比 ---
  1912. st.divider()
  1913. st.subheader("🔀 跨批次同座号面板对比")
  1914. st.markdown(
  1915. "选择一台设备和一个座号,查看该座号在不同批次生产的面板上缺陷分布的对比。"
  1916. "如果同一座号持续在相同位置产生缺陷 → 该座号存在系统性问题。"
  1917. )
  1918. col_cmp1, col_cmp2, col_cmp3 = st.columns(3)
  1919. with col_cmp1:
  1920. cmp_eq = st.selectbox("选择设备", options=sorted(df["equipment_id"].unique()), key="cmp_eq")
  1921. with col_cmp2:
  1922. eq_seats = sorted(df[(df["equipment_id"] == cmp_eq)]["seat_id"].unique())
  1923. cmp_seat = st.selectbox("选择座号", options=eq_seats, key="cmp_seat")
  1924. with col_cmp3:
  1925. # 找出有该设备座号缺陷的批次
  1926. eq_seat_batches = sorted(df[(df["equipment_id"] == cmp_eq) & (df["seat_id"] == cmp_seat)]["batch_id"].unique())
  1927. cmp_batches = st.multiselect("选择对比批次", options=eq_seat_batches, default=eq_seat_batches[:3] if len(eq_seat_batches) >= 3 else eq_seat_batches)
  1928. if cmp_batches and len(cmp_batches) >= 2:
  1929. n_cols = min(len(cmp_batches), 3)
  1930. n_rows = (len(cmp_batches) + n_cols - 1) // n_cols
  1931. fig_cmp, axes_cmp = plt.subplots(n_rows, n_cols, figsize=(3.5 * n_cols, 5 * n_rows))
  1932. axes_cmp = axes_cmp.flatten() if n_cols * n_rows > 1 else [axes_cmp]
  1933. for i, batch in enumerate(cmp_batches):
  1934. ax = axes_cmp[i]
  1935. batch_data = df[(df["equipment_id"] == cmp_eq) & (df["seat_id"] == cmp_seat) & (df["batch_id"] == batch)]
  1936. # 面板背景
  1937. ax.add_patch(plt.Rectangle((0, 0), pw, ph, facecolor="#1a1a2e", edgecolor="#444", linewidth=1))
  1938. if len(batch_data) > 0:
  1939. # 按缺陷类型着色
  1940. type_colors = {"划痕": "red", "亮点": "yellow", "暗点": "black", "气泡": "cyan",
  1941. "色差": "magenta", "漏光": "orange", "裂纹": "darkred", "异物": "green"}
  1942. for _, row in batch_data.iterrows():
  1943. c = type_colors.get(row["defect_type"], "white")
  1944. ax.scatter(row["x_mm"], row["y_mm"], c=c, s=30, alpha=0.7, edgecolors="white", linewidth=0.3, zorder=3)
  1945. ax.set_xlim(-3, pw + 3)
  1946. ax.set_ylim(-3, ph + 3)
  1947. ax.set_title(f"{batch}\n{len(batch_data)} 缺陷", fontsize=9)
  1948. ax.set_aspect("equal")
  1949. ax.grid(True, alpha=0.1, color="gray")
  1950. ax.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False)
  1951. # 隐藏多余子图
  1952. for j in range(len(cmp_batches), len(axes_cmp)):
  1953. axes_cmp[j].set_visible(False)
  1954. fig_cmp.suptitle(f"{cmp_eq} / {cmp_seat} 跨批次对比", fontsize=12, y=1.01)
  1955. plt.tight_layout()
  1956. st.pyplot(fig_cmp)
  1957. plt.close()
  1958. # 对比统计
  1959. st.subheader("对比统计")
  1960. comp_stats = []
  1961. for batch in cmp_batches:
  1962. batch_data = df[(df["equipment_id"] == cmp_eq) & (df["seat_id"] == cmp_seat) & (df["batch_id"] == batch)]
  1963. comp_stats.append({
  1964. "批次": batch,
  1965. "缺陷数": len(batch_data),
  1966. "主要类型": batch_data["defect_type"].mode().iloc[0] if len(batch_data) > 0 else "-",
  1967. "严重占比": f"{(batch_data['severity']=='严重').sum() / max(len(batch_data), 1):.0%}",
  1968. "中心X": round(batch_data["x_mm"].mean(), 1) if len(batch_data) > 0 else "-",
  1969. "中心Y": round(batch_data["y_mm"].mean(), 1) if len(batch_data) > 0 else "-",
  1970. })
  1971. st.dataframe(pd.DataFrame(comp_stats), use_container_width=True, hide_index=True)
  1972. # 趋势判断
  1973. if len(cmp_batches) >= 3:
  1974. defect_counts = [len(df[(df["equipment_id"] == cmp_eq) & (df["seat_id"] == cmp_seat) & (df["batch_id"] == b)]) for b in cmp_batches]
  1975. x_trend = np.arange(len(cmp_batches))
  1976. coeffs = np.polyfit(x_trend, defect_counts, 1)
  1977. slope = coeffs[0]
  1978. if slope > 0.5:
  1979. st.warning(f"⚠️ **{cmp_eq}/{cmp_seat}** 缺陷数呈**上升趋势** (斜率: {slope:.1f}/批次),建议安排设备检修")
  1980. elif slope < -0.5:
  1981. st.success(f"✅ **{cmp_eq}/{cmp_seat}** 缺陷数呈**改善趋势** (斜率: {slope:.1f}/批次)")
  1982. else:
  1983. st.info(f"➡️ **{cmp_eq}/{cmp_seat}** 缺陷数**平稳** (斜率: {slope:.1f}/批次)")
  1984. else:
  1985. st.info("请选择至少 2 个批次进行对比")
  1986. # --- 缺陷传播追踪 ---
  1987. st.divider()
  1988. st.subheader("📡 缺陷坐标传播追踪")
  1989. st.markdown(
  1990. "追踪同一坐标区域在时间轴上的缺陷演变,识别持续恶化的位置。"
  1991. "如果某坐标的缺陷数量随时间递增 → 该位置存在渐进性损伤(如吸嘴持续磨损)。"
  1992. )
  1993. # 坐标分桶 + 时间维度
  1994. prop_bin = st.slider("传播追踪分桶大小 (mm)", min_value=10, max_value=50, value=20, step=10)
  1995. df_time = df.copy()
  1996. df_time["x_bin"] = (df_time["x_mm"] // prop_bin).astype(int)
  1997. df_time["y_bin"] = (df_time["y_mm"] // prop_bin).astype(int)
  1998. # 按桶 + 日期聚合
  1999. prop_df = df_time.groupby(["x_bin", "y_bin", "day"]).size().reset_index(name="defect_count")
  2000. # 找出至少有 3 天数据的桶
  2001. bucket_days = prop_df.groupby(["x_bin", "y_bin"])["day"].nunique()
  2002. active_buckets = bucket_days[bucket_days >= 3].index.tolist()
  2003. if active_buckets:
  2004. # 选择要追踪的桶
  2005. bucket_options = [f"({bx},{by})" for bx, by in active_buckets]
  2006. bucket_counts = prop_df.groupby(["x_bin", "y_bin"])["defect_count"].sum().sort_values(ascending=False)
  2007. # 默认选缺陷最多的桶
  2008. default_top = bucket_counts.index[0]
  2009. selected_bucket = st.selectbox(
  2010. "选择要追踪的坐标桶",
  2011. options=bucket_options,
  2012. index=0,
  2013. format_func=lambda x: f"{x} (总缺陷: {bucket_counts.loc[tuple(map(int, x.strip('()').split(',')))]:.0f})"
  2014. )
  2015. bx, by = map(int, selected_bucket.strip("()").split(","))
  2016. bucket_timeline = prop_df[(prop_df["x_bin"] == bx) & (prop_df["y_bin"] == by)].sort_values("day")
  2017. bucket_timeline["day"] = pd.to_datetime(bucket_timeline["day"])
  2018. # 传播趋势图
  2019. fig_prop, ax_prop = plt.subplots(figsize=(12, 4))
  2020. ax_prop.bar(bucket_timeline["day"], bucket_timeline["defect_count"],
  2021. color="steelblue", alpha=0.7, width=0.8)
  2022. # 趋势线
  2023. if len(bucket_timeline) >= 2:
  2024. x_t = np.arange(len(bucket_timeline))
  2025. coeffs_p = np.polyfit(x_t, bucket_timeline["defect_count"].values, 1)
  2026. slope_p = coeffs_p[0]
  2027. trend_y = np.polyval(coeffs_p, x_t)
  2028. ax_prop.plot(bucket_timeline["day"], trend_y, color="red", linestyle="--",
  2029. linewidth=2, label=f"趋势 (斜率: {slope_p:.2f}/天)")
  2030. if slope_p > 0.3:
  2031. ax_prop.set_title(f"坐标桶 ({bx},{by}) — 缺陷数上升 (恶化趋势)")
  2032. elif slope_p < -0.3:
  2033. ax_prop.set_title(f"坐标桶 ({bx},{by}) — 缺陷数下降 (改善趋势)")
  2034. else:
  2035. ax_prop.set_title(f"坐标桶 ({bx},{by}) — 缺陷数平稳")
  2036. else:
  2037. ax_prop.set_title(f"坐标桶 ({bx},{by})")
  2038. ax_prop.set_ylabel("缺陷数量")
  2039. ax_prop.tick_params(axis="x", rotation=45)
  2040. ax_prop.legend()
  2041. ax_prop.grid(True, alpha=0.3, axis="y")
  2042. st.pyplot(fig_prop)
  2043. plt.close()
  2044. # 该桶的缺陷类型演变
  2045. bucket_data = df_time[(df_time["x_bin"] == bx) & (df_time["y_bin"] == by)]
  2046. st.markdown(f"**坐标桶 ({bx},{by}) 缺陷类型演变** (对应面板区域: X {bx*prop_bin}-{(bx+1)*prop_bin}mm, Y {by*prop_bin}-{(by+1)*prop_bin}mm)")
  2047. bucket_type_timeline = bucket_data.groupby(["day", "defect_type"]).size().unstack(fill_value=0)
  2048. bucket_type_timeline.index = pd.to_datetime(bucket_type_timeline.index)
  2049. st.dataframe(bucket_type_timeline, use_container_width=True, height=300)
  2050. else:
  2051. st.info("当前数据中无足够多天数的连续缺陷坐标桶 (需 ≥3 天)")
  2052. # --- 底部:数据导出 ---
  2053. st.divider()
  2054. if current_config["show_export"]:
  2055. st.subheader("📥 数据导出")
  2056. # 综合报告导出
  2057. st.subheader("📋 一键导出综合报告")
  2058. st.markdown("包含所有分析模块的关键结论,适合汇报和存档。")
  2059. report_parts = []
  2060. report_parts.append("# 缺陷集中性分析综合报告\n")
  2061. report_parts.append(f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
  2062. report_parts.append(f"**数据范围**: {start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')}")
  2063. report_parts.append(f"**筛选后缺陷数**: {len(filtered_df)} 条")
  2064. report_parts.append(f"**涉及面板**: {filtered_df['panel_id'].nunique()} 块")
  2065. report_parts.append(f"**视图模式**: {view_mode}\n")
  2066. # 1. KPI 摘要
  2067. report_parts.append("## 1. KPI 摘要\n")
  2068. report_kpis = calculate_kpis(df, filtered_df)
  2069. total_panels_inspected_r = report_kpis["total_panels_inspected"]
  2070. defective_panels_r = report_kpis["defective_panels"]
  2071. yield_rate_r = report_kpis["yield_rate"]
  2072. report_parts.append(f"- 检测面板数: {total_panels_inspected_r} 块")
  2073. defective_rate_r = defective_panels_r / max(total_panels_inspected_r, 1) * 100
  2074. report_parts.append(f"- 不良面板数: {defective_panels_r} 块 ({defective_rate_r:.1f}%)")
  2075. report_parts.append(f"- 综合良率: {yield_rate_r:.1f}%")
  2076. report_parts.append(f"- 缺陷总数: {len(filtered_df)} 个")
  2077. report_parts.append(f"- 严重缺陷: {(filtered_df['severity']=='严重').sum()} 个\n")
  2078. # 2. 缺陷类型
  2079. report_parts.append("## 2. 缺陷类型分布\n")
  2080. type_counts_r = filtered_df["defect_type"].value_counts()
  2081. for t, c in type_counts_r.items():
  2082. report_parts.append(f"- {t}: {c} ({c/len(filtered_df)*100:.1f}%)")
  2083. report_parts.append("")
  2084. # 3. 设备/座号
  2085. if "equipment_id" in filtered_df.columns:
  2086. report_parts.append("## 3. 设备与座号分布\n")
  2087. eq_counts = filtered_df["equipment_id"].value_counts()
  2088. for e, c in eq_counts.items():
  2089. report_parts.append(f"- {e}: {c} 个缺陷")
  2090. seat_top = filtered_df["seat_id"].value_counts().head(5)
  2091. report_parts.append(f"\n**缺陷座号 TOP5**:")
  2092. for i, (s, c) in enumerate(seat_top.items(), 1):
  2093. report_parts.append(f" {i}. {s}: {c} 个")
  2094. report_parts.append("")
  2095. # 4. 趋势
  2096. report_parts.append("## 4. 趋势分析\n")
  2097. daily_r = filtered_df.groupby("day").size()
  2098. if len(daily_r) >= 2:
  2099. x_r = np.arange(len(daily_r))
  2100. coeffs_r = np.polyfit(x_r, daily_r.values.astype(float), 1)
  2101. slope_r = coeffs_r[0]
  2102. if slope_r > 0:
  2103. report_parts.append(f"- 缺陷数趋势: **上升** (斜率 {slope_r:.1f}/天)")
  2104. else:
  2105. report_parts.append(f"- 缺陷数趋势: **下降** (斜率 {slope_r:.1f}/天)")
  2106. report_parts.append("")
  2107. # 5. 异常座号
  2108. report_parts.append("## 5. 异常检测\n")
  2109. if "seat_id" in filtered_df.columns:
  2110. all_seat_stats_r = filtered_df.groupby(["equipment_id", "seat_id"]).size()
  2111. mean_r = all_seat_stats_r.mean()
  2112. std_r = all_seat_stats_r.std()
  2113. threshold_2x_r = mean_r + 2 * std_r
  2114. critical_r = all_seat_stats_r[all_seat_stats_r > threshold_2x_r]
  2115. if len(critical_r) > 0:
  2116. report_parts.append(f"- ⚠️ 2σ 异常座号: {len(critical_r)} 个")
  2117. for (eq, seat), count in critical_r.items():
  2118. report_parts.append(f" - {eq}/{seat}: {count} 个缺陷")
  2119. else:
  2120. report_parts.append("- ✅ 无 2σ 异常座号")
  2121. report_parts.append("")
  2122. # 6. 建议
  2123. report_parts.append("## 6. 建议\n")
  2124. top_type = type_counts_r.index[0] if len(type_counts_r) > 0 else "-"
  2125. top_eq = eq_counts.index[0] if len(eq_counts) > 0 else "-"
  2126. report_parts.append(f"- 重点关注缺陷类型: **{top_type}**")
  2127. report_parts.append(f"- 重点关注设备: **{top_eq}**")
  2128. report_parts.append("- 建议查看 SPC 控制图确认趋势状态")
  2129. report_parts.append("- 建议检查设备健康评分\n")
  2130. report_parts.append("---\n*本报告由缺陷集中性分析系统自动生成*")
  2131. full_report = "\n".join(report_parts)
  2132. col_exp1, col_exp2, col_exp3 = st.columns(3)
  2133. with col_exp1:
  2134. st.download_button(
  2135. label="📥 综合报告 (MD)",
  2136. data=full_report.encode("utf-8"),
  2137. file_name=f"defect_report_{datetime.now().strftime('%Y%m%d')}.md",
  2138. mime="text/markdown",
  2139. use_container_width=True
  2140. )
  2141. with col_exp2:
  2142. csv_data = filtered_df.to_csv(index=False).encode("utf-8-sig")
  2143. st.download_button(
  2144. label="📥 筛选数据 (CSV)",
  2145. data=csv_data,
  2146. file_name=f"defect_data_{datetime.now().strftime('%Y%m%d')}.csv",
  2147. mime="text/csv",
  2148. use_container_width=True
  2149. )
  2150. with col_exp3:
  2151. # 精简版 TXT 报告
  2152. txt_lines = ["缺陷集中性分析报告", f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
  2153. f"缺陷数: {len(filtered_df)} | 面板: {filtered_df['panel_id'].nunique()}",
  2154. f"良率: {yield_rate_r:.1f}%"]
  2155. for t, c in type_counts_r.head(3).items():
  2156. txt_lines.append(f" TOP: {t} {c}个")
  2157. txt_content = "\n".join(txt_lines)
  2158. st.download_button(
  2159. label="📥 精简报告 (TXT)",
  2160. data=txt_content.encode("utf-8"),
  2161. file_name=f"defect_summary_{datetime.now().strftime('%Y%m%d')}.txt",
  2162. mime="text/plain",
  2163. use_container_width=True
  2164. )