app_utils.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737
  1. """缺陷分析页面的可测试业务逻辑。"""
  2. import base64
  3. import html
  4. import io
  5. import os
  6. import matplotlib
  7. matplotlib.use("Agg")
  8. import matplotlib.pyplot as plt
  9. from matplotlib import font_manager as fm
  10. import numpy as np
  11. import pandas as pd
  12. def _setup_chinese_font():
  13. """配置 matplotlib 中文字体,与 app.py 保持一致。"""
  14. font_paths = [
  15. r"C:\Windows\Fonts\msyh.ttc",
  16. r"C:\Windows\Fonts\simhei.ttf",
  17. r"C:\Windows\Fonts\simsun.ttc",
  18. r"C:\Windows\Fonts\malgun.ttf",
  19. ]
  20. for fp in font_paths:
  21. if os.path.exists(fp):
  22. font_prop = fm.FontProperties(fname=fp)
  23. plt.rcParams["font.family"] = font_prop.get_name()
  24. plt.rcParams["axes.unicode_minus"] = False
  25. return font_prop
  26. plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "Arial Unicode MS"]
  27. plt.rcParams["axes.unicode_minus"] = False
  28. return None
  29. _CHINESE_FONT_PROP = _setup_chinese_font()
  30. from defect_analysis.ml.model_bundle import create_model_bundle
  31. from defect_analysis.ml.predict import predict_key_factors
  32. from defect_analysis.root_cause import EXTENDED_ROOT_CAUSE_DIMENSIONS, build_extended_root_causes
  33. from defect_analysis.schemas import (
  34. CORE_REQUIRED_COLUMNS,
  35. INDUSTRY_OPTIONAL_COLUMNS,
  36. TEMPLATE_COLUMNS,
  37. get_missing_required_columns,
  38. normalize_defect_schema,
  39. )
  40. DEFECT_SOP_RECOMMENDATIONS = {
  41. "划痕": ["检查搬运轨道、吸嘴和治具接触面", "复核清洗滚刷与擦拭工位是否有硬质颗粒"],
  42. "气泡": ["检查贴合压力、真空度、OCA 状态和贴合速度", "复核贴合前清洁与材料开封时长"],
  43. "漏光": ["检查边缘贴合、背光组装、框胶和压合均匀性", "复核四角/边缘区应力与夹持状态"],
  44. "色差": ["检查背光、偏光片批次、贴合应力和老化条件", "对比同批材料与相邻工艺参数"],
  45. "异物": ["检查洁净度、清洗段、静电控制和材料暴露时间", "追溯同批材料与工位环境记录"],
  46. "亮点": ["复核点灯/AOI 判定、TFT 像素缺陷和异物压伤", "抽查高发区域是否存在压接或污染"],
  47. "暗点": ["复核点灯/AOI 判定、TFT 像素缺陷和异物压伤", "检查绑定/驱动相关区域异常"],
  48. "裂纹": ["立即检查切割、搬运、夹持和跌落冲击风险", "对同批面板执行 Hold 与复检"],
  49. }
  50. def normalize_date_bounds(start_date, end_date):
  51. """把日期范围转换成左闭右开的时间边界,确保结束日期整天被包含。"""
  52. start_ts = pd.Timestamp(start_date).normalize()
  53. end_exclusive = pd.Timestamp(end_date).normalize() + pd.Timedelta(days=1)
  54. return start_ts, end_exclusive
  55. def apply_defect_filters(
  56. df,
  57. *,
  58. start_date,
  59. end_date,
  60. selected_types,
  61. selected_batches,
  62. selected_equipment,
  63. selected_seats,
  64. selected_shift="全部",
  65. selected_severity="全部",
  66. ):
  67. """应用页面筛选条件。"""
  68. start_ts, end_exclusive = normalize_date_bounds(start_date, end_date)
  69. mask = (
  70. (df["timestamp"] >= start_ts)
  71. & (df["timestamp"] < end_exclusive)
  72. & (df["defect_type"].isin(selected_types))
  73. & (df["batch_id"].isin(selected_batches))
  74. & (df["equipment_id"].isin(selected_equipment))
  75. )
  76. if selected_shift != "全部":
  77. mask &= df["shift"] == selected_shift
  78. if selected_severity != "全部":
  79. mask &= df["severity"] == selected_severity
  80. if selected_seats:
  81. mask &= df["seat_id"].isin(selected_seats)
  82. return df[mask].copy()
  83. def classify_panel_zone(df):
  84. """按 3C 面板行业常用语义把坐标映射到关键区域。"""
  85. width = df.get("panel_width_mm", pd.Series(155.0, index=df.index)).replace(0, np.nan)
  86. height = df.get("panel_height_mm", pd.Series(340.0, index=df.index)).replace(0, np.nan)
  87. x = df.get("x_mm", width * 0.5)
  88. y = df.get("y_mm", height * 0.5)
  89. x_norm = x / width
  90. y_norm = y / height
  91. zones = []
  92. for x, y in zip(x_norm.fillna(0.5), y_norm.fillna(0.5)):
  93. labels = []
  94. if x <= 0.1:
  95. labels.append("左边缘区")
  96. if x >= 0.9:
  97. labels.append("右边缘区")
  98. if y <= 0.1:
  99. labels.append("下边缘区")
  100. if y >= 0.9:
  101. labels.append("上边缘区")
  102. if (x <= 0.12 or x >= 0.88) and (y <= 0.12 or y >= 0.88):
  103. labels.append("角落区")
  104. if 0.68 <= y <= 0.88 and 0.25 <= x <= 0.75:
  105. labels.append("FPC/绑定区")
  106. if not labels:
  107. labels.append("显示中心区")
  108. zones.append(" / ".join(labels))
  109. return pd.Series(zones, index=df.index, name="panel_zone")
  110. def calculate_kpis(source_df, filtered_df):
  111. """基于当前筛选结果计算页面 KPI。"""
  112. total_panels_inspected = filtered_df["panel_id"].nunique()
  113. defective_panels = filtered_df["panel_id"].nunique()
  114. total_defects = len(filtered_df)
  115. critical_defects = int((filtered_df["severity"] == "严重").sum()) if total_defects else 0
  116. top_defect_type = filtered_df["defect_type"].mode().iloc[0] if total_defects else "-"
  117. yield_rate = (1 - defective_panels / max(total_panels_inspected, 1)) * 100
  118. return {
  119. "total_panels_inspected": int(total_panels_inspected),
  120. "defective_panels": int(defective_panels),
  121. "yield_rate": float(yield_rate),
  122. "total_defects": int(total_defects),
  123. "critical_defects": int(critical_defects),
  124. "top_defect_type": top_defect_type,
  125. }
  126. def calculate_spc_metrics(df):
  127. """计算 SPC 所需数据,防止模拟分母造成非法概率。"""
  128. daily = df.groupby("day").agg(
  129. total_defects=("defect_id", "count"),
  130. panels_with_defects=("panel_id", "nunique"),
  131. ).reset_index()
  132. daily["day"] = pd.to_datetime(daily["day"])
  133. daily = daily.sort_values("day").reset_index(drop=True)
  134. if len(daily) < 2:
  135. return {
  136. "daily": daily,
  137. "p_bar": 0.0,
  138. "ucl": 0.0,
  139. "lcl": 0.0,
  140. "uwl": 0.0,
  141. "lwl": 0.0,
  142. "sigma_p": 0.0,
  143. }
  144. total_days = (df["timestamp"].max() - df["timestamp"].min()).days + 1
  145. total_unique_panels = df["panel_id"].nunique()
  146. estimated = max(total_unique_panels // max(total_days // 7, 1), 1)
  147. daily["estimated_inspected"] = np.maximum(estimated, daily["panels_with_defects"])
  148. daily["defect_rate"] = (
  149. daily["panels_with_defects"] / daily["estimated_inspected"]
  150. ).clip(lower=0, upper=1)
  151. p_bar = float(np.clip(daily["defect_rate"].mean(), 0, 1))
  152. n_avg = float(daily["estimated_inspected"].mean())
  153. sigma_p = float(np.sqrt(max(p_bar * (1 - p_bar), 0) / n_avg)) if n_avg > 0 else 0.0
  154. return {
  155. "daily": daily,
  156. "p_bar": p_bar,
  157. "ucl": min(1.0, p_bar + 3 * sigma_p),
  158. "lcl": max(0.0, p_bar - 3 * sigma_p),
  159. "uwl": min(1.0, p_bar + 2 * sigma_p),
  160. "lwl": max(0.0, p_bar - 2 * sigma_p),
  161. "sigma_p": sigma_p,
  162. }
  163. def build_diagnostic_dashboard(df):
  164. """生成诊断驾驶舱需要的摘要、根因候选和趋势数据。"""
  165. total_defects = len(df)
  166. if total_defects == 0:
  167. return {
  168. "severity_level": "正常",
  169. "top_defect_type": "-",
  170. "top_defect_share": 0.0,
  171. "serious_share": 0.0,
  172. "root_causes": pd.DataFrame(),
  173. "extended_root_causes": pd.DataFrame(),
  174. "daily_trend": pd.DataFrame(),
  175. "pareto": pd.DataFrame(),
  176. "primary_recommendation": "当前筛选条件下没有缺陷记录。",
  177. }
  178. type_counts = df["defect_type"].value_counts()
  179. zones = classify_panel_zone(df)
  180. zone_counts = zones.value_counts()
  181. top_defect_type = type_counts.index[0]
  182. top_defect_share = float(type_counts.iloc[0] / total_defects)
  183. top_zone = zone_counts.index[0]
  184. top_zone_share = float(zone_counts.iloc[0] / total_defects)
  185. serious_share = float((df["severity"] == "严重").sum() / total_defects)
  186. root_causes = (
  187. df.groupby(["equipment_id", "seat_id"])
  188. .agg(
  189. 缺陷数=("defect_id", "count"),
  190. 涉及面板=("panel_id", "nunique"),
  191. 主要缺陷=("defect_type", lambda s: s.mode().iloc[0]),
  192. 严重数=("severity", lambda s: int((s == "严重").sum())),
  193. )
  194. .reset_index()
  195. )
  196. root_causes["根因候选"] = root_causes["equipment_id"] + " / " + root_causes["seat_id"]
  197. root_causes["占比"] = root_causes["缺陷数"] / total_defects
  198. root_causes["严重占比"] = root_causes["严重数"] / root_causes["缺陷数"].clip(lower=1)
  199. equipment_totals = df.groupby("equipment_id")["defect_id"].count()
  200. equipment_seat_counts = df.groupby("equipment_id")["seat_id"].nunique().clip(lower=1)
  201. root_causes["期望缺陷数"] = root_causes["equipment_id"].map(
  202. equipment_totals / equipment_seat_counts
  203. ).clip(lower=0.001)
  204. root_causes["异常倍数"] = (root_causes["缺陷数"] / root_causes["期望缺陷数"]).round(2)
  205. count_score = root_causes["缺陷数"] / root_causes["缺陷数"].max()
  206. panel_score = root_causes["涉及面板"] / df["panel_id"].nunique()
  207. lift_score = (root_causes["异常倍数"] / 3).clip(upper=1)
  208. root_causes["风险分"] = (
  209. count_score * 55 + lift_score * 25 + root_causes["严重占比"] * 15 + panel_score * 5
  210. ).round(1)
  211. root_causes = root_causes.sort_values(["风险分", "缺陷数"], ascending=False).head(8)
  212. root_causes = root_causes[
  213. ["根因候选", "缺陷数", "占比", "异常倍数", "涉及面板", "主要缺陷", "严重占比", "风险分"]
  214. ].reset_index(drop=True)
  215. pareto = type_counts.rename_axis("缺陷类型").reset_index(name="缺陷数")
  216. pareto["占比"] = pareto["缺陷数"] / total_defects
  217. pareto["累计占比"] = pareto["占比"].cumsum()
  218. daily_trend = df.groupby("day").size().rename("缺陷数").reset_index()
  219. daily_trend["day"] = pd.to_datetime(daily_trend["day"])
  220. daily_trend = daily_trend.sort_values("day")
  221. extended_root_causes = build_extended_root_causes(df)
  222. if serious_share >= 0.2 or (len(root_causes) > 0 and root_causes.iloc[0]["占比"] >= 0.15):
  223. severity_level = "严重"
  224. elif serious_share >= 0.1 or top_defect_share >= 0.35:
  225. severity_level = "关注"
  226. else:
  227. severity_level = "正常"
  228. if len(root_causes) > 0:
  229. top_root = root_causes.iloc[0]
  230. primary_recommendation = (
  231. f"优先排查 {top_root['根因候选']},该组合贡献 {top_root['占比']:.1%} "
  232. f"缺陷,异常倍数 {top_root['异常倍数']:.2f}x,主要类型为 {top_root['主要缺陷']}。"
  233. )
  234. else:
  235. primary_recommendation = f"优先排查 {top_defect_type} 相关工艺参数。"
  236. return {
  237. "severity_level": severity_level,
  238. "top_defect_type": top_defect_type,
  239. "top_defect_share": top_defect_share,
  240. "top_zone": top_zone,
  241. "top_zone_share": top_zone_share,
  242. "zone_distribution": zone_counts.rename_axis("区域").reset_index(name="缺陷数"),
  243. "serious_share": serious_share,
  244. "root_causes": root_causes,
  245. "extended_root_causes": extended_root_causes,
  246. "daily_trend": daily_trend,
  247. "pareto": pareto,
  248. "primary_recommendation": primary_recommendation,
  249. }
  250. def detect_industry_patterns(df):
  251. """识别面板行业常见缺陷模式。"""
  252. if df.empty:
  253. return []
  254. patterns = []
  255. zones = classify_panel_zone(df)
  256. zone_share = zones.value_counts(normalize=True)
  257. if any(idx != "显示中心区" and share >= 0.35 for idx, share in zone_share.items()):
  258. patterns.append(f"区域集中: {zone_share.index[0]} 占比 {zone_share.iloc[0]:.1%}")
  259. coord_df = df.copy()
  260. coord_df["x_bin"] = (coord_df["x_mm"] // 5).astype(int)
  261. coord_df["y_bin"] = (coord_df["y_mm"] // 5).astype(int)
  262. repeat = coord_df.groupby(["x_bin", "y_bin"])["panel_id"].nunique().max()
  263. if repeat >= min(3, max(2, df["panel_id"].nunique())):
  264. patterns.append("跨面板重复坐标: 疑似治具、吸嘴、压头或固定接触点异常")
  265. if df["x_mm"].nunique() >= 3 and df["y_mm"].nunique() >= 3 and len(df) >= 6:
  266. corr = abs(pd.Series(df["x_mm"]).corr(pd.Series(df["y_mm"])))
  267. if pd.notna(corr) and corr >= 0.85:
  268. patterns.append("线状分布: 疑似搬运划伤、滚轮轨迹或线性压伤")
  269. batch_share = df["batch_id"].value_counts(normalize=True).iloc[0]
  270. if batch_share >= 0.5 and df["batch_id"].nunique() > 1:
  271. patterns.append(f"批次集中: {df['batch_id'].value_counts().index[0]} 占比 {batch_share:.1%}")
  272. return patterns or ["随机点状分布: 更偏向材料、环境尘埃或偶发检出"]
  273. def generate_industry_diagnosis(df, dashboard):
  274. """生成 3C 面板行业化诊断结论和排查建议。"""
  275. if df.empty:
  276. return {
  277. "headline": "当前筛选条件下没有可诊断缺陷。",
  278. "patterns": [],
  279. "recommendations": ["放宽筛选条件或上传更多检测记录后再诊断。"],
  280. }
  281. top_type = dashboard["top_defect_type"]
  282. top_zone = dashboard.get("top_zone", classify_panel_zone(df).value_counts().index[0])
  283. top_root = dashboard["root_causes"].iloc[0]["根因候选"] if len(dashboard["root_causes"]) else "当前筛选范围"
  284. patterns = detect_industry_patterns(df)
  285. recommendations = []
  286. if top_type in DEFECT_SOP_RECOMMENDATIONS:
  287. recommendations.extend(DEFECT_SOP_RECOMMENDATIONS[top_type])
  288. if "边缘" in top_zone or "角落" in top_zone:
  289. recommendations.append("优先复核边缘贴合、切割/搬运夹持、吸附接触面和四角应力状态")
  290. if "FPC" in top_zone or "绑定" in top_zone:
  291. recommendations.append("重点检查绑定压力、FPC/COF 区域异物、压接参数和 AOI 复判样本")
  292. if any("跨面板重复" in p for p in patterns):
  293. recommendations.append("对高发座号对应治具、吸嘴、压头做点检,并抽查同坐标复现样本")
  294. if dashboard["serious_share"] >= 0.2:
  295. recommendations.append("严重缺陷占比较高,建议对相关批次执行 Hold、复检或加严抽样")
  296. deduped = []
  297. for item in recommendations:
  298. if item not in deduped:
  299. deduped.append(item)
  300. headline = (
  301. f"{top_zone} 的 {top_type} 最突出,首要候选为 {top_root}。"
  302. f"建议按工序链路优先排查材料、贴合/搬运接触面和对应治具状态。"
  303. )
  304. return {
  305. "headline": headline,
  306. "patterns": patterns,
  307. "recommendations": deduped[:5],
  308. }
  309. def build_ml_factor_insights(
  310. df,
  311. *,
  312. target_defect_type=None,
  313. target_severity=None,
  314. model_name="random_forest",
  315. top_n=10,
  316. ):
  317. """构建页面可展示的 ML 关键因子、验证指标和特征解释。"""
  318. normalized = normalize_defect_schema(df)
  319. resolved_target_type = target_defect_type
  320. if resolved_target_type is None and not normalized.empty:
  321. resolved_target_type = normalized["defect_type"].mode().iloc[0]
  322. base = {
  323. "target_defect_type": resolved_target_type,
  324. "target_severity": target_severity,
  325. "model_name": model_name,
  326. "key_factors": pd.DataFrame(),
  327. "metrics": {},
  328. "validation_metrics": {},
  329. "feature_importance": [],
  330. "error": None,
  331. }
  332. if normalized.empty:
  333. base["error"] = "当前筛选条件下没有可训练数据。"
  334. return base
  335. try:
  336. base["key_factors"] = predict_key_factors(
  337. normalized,
  338. target_defect_type=resolved_target_type,
  339. target_severity=target_severity,
  340. model_name=model_name,
  341. top_n=top_n,
  342. )
  343. bundle = create_model_bundle(
  344. normalized,
  345. model_name=model_name,
  346. target_defect_type=resolved_target_type,
  347. target_severity=target_severity,
  348. )
  349. except (RuntimeError, ValueError) as exc:
  350. base["error"] = str(exc)
  351. return base
  352. base["metrics"] = bundle["metrics"]
  353. base["validation_metrics"] = bundle["validation_metrics"]
  354. base["feature_importance"] = bundle["feature_importance"]
  355. return base
  356. def _fig_to_base64(fig, *, dpi=120):
  357. """把 matplotlib Figure 转成 base64 PNG data URI。"""
  358. buf = io.BytesIO()
  359. fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", facecolor="white")
  360. buf.seek(0)
  361. encoded = base64.b64encode(buf.read()).decode("utf-8")
  362. buf.close()
  363. plt.close(fig)
  364. return f"data:image/png;base64,{encoded}"
  365. def generate_report_charts(filtered_df, *, daily_trend_df=None):
  366. """生成报告内嵌的四张核心图表,返回 dict of base64 data URIs。"""
  367. charts = {}
  368. # --- 1. 缺陷类型分布条形图 ---
  369. type_counts = filtered_df["defect_type"].value_counts().head(10)
  370. if not type_counts.empty:
  371. fig, ax = plt.subplots(figsize=(7, 3.5))
  372. colors = ["#0f766e", "#14b8a6", "#22d3ee", "#38bdf8", "#60a5fa",
  373. "#a78bfa", "#c084fc", "#e879f9", "#f472b6", "#fb7185"]
  374. bars = ax.barh(
  375. range(len(type_counts)),
  376. type_counts.values,
  377. color=colors[: len(type_counts)],
  378. )
  379. ax.set_yticks(range(len(type_counts)))
  380. ax.set_yticklabels(type_counts.index, fontsize=11)
  381. ax.invert_yaxis()
  382. for i, (bar, val) in enumerate(zip(bars, type_counts.values)):
  383. ax.text(bar.get_width() + max(type_counts.values) * 0.01,
  384. bar.get_y() + bar.get_height() / 2,
  385. str(val), va="center", fontsize=10, fontweight="bold")
  386. ax.set_title("缺陷类型 TOP 10", fontsize=13, fontweight="bold", pad=12)
  387. ax.spines["top"].set_visible(False)
  388. ax.spines["right"].set_visible(False)
  389. ax.set_xlabel("缺陷数")
  390. charts["type_distribution"] = _fig_to_base64(fig)
  391. # --- 2. 每日趋势折线图 ---
  392. if daily_trend_df is not None and not daily_trend_df.empty:
  393. daily = daily_trend_df.copy()
  394. daily["day"] = pd.to_datetime(daily["day"])
  395. daily = daily.sort_values("day")
  396. fig, ax = plt.subplots(figsize=(7, 3))
  397. ax.plot(daily["day"], daily["缺陷数"], marker="o", linewidth=2,
  398. markersize=5, color="#0f766e")
  399. ax.fill_between(daily["day"], daily["缺陷数"], alpha=0.15, color="#0f766e")
  400. ax.set_title("每日缺陷数趋势", fontsize=13, fontweight="bold", pad=10)
  401. ax.spines["top"].set_visible(False)
  402. ax.spines["right"].set_visible(False)
  403. ax.tick_params(axis="x", rotation=30)
  404. charts["daily_trend"] = _fig_to_base64(fig)
  405. # --- 3. 设备缺陷分布 ---
  406. eq_counts = filtered_df.get("equipment_id")
  407. if eq_counts is not None:
  408. eq_counts = eq_counts.value_counts().head(8)
  409. if not eq_counts.empty:
  410. fig, ax = plt.subplots(figsize=(7, 3))
  411. ax.bar(
  412. range(len(eq_counts)),
  413. eq_counts.values,
  414. color=["#1e3a5f", "#2563eb", "#3b82f6", "#60a5fa",
  415. "#93c5fd", "#0d9488", "#14b8a6", "#2dd4bf"][: len(eq_counts)],
  416. )
  417. ax.set_xticks(range(len(eq_counts)))
  418. ax.set_xticklabels(eq_counts.index, rotation=25, ha="right", fontsize=10)
  419. ax.set_title("设备缺陷分布 TOP 8", fontsize=13, fontweight="bold", pad=10)
  420. ax.spines["top"].set_visible(False)
  421. ax.spines["right"].set_visible(False)
  422. ax.set_ylabel("缺陷数")
  423. for i, val in enumerate(eq_counts.values):
  424. ax.text(i, val + max(eq_counts.values) * 0.02, str(val),
  425. ha="center", fontsize=9, fontweight="bold")
  426. charts["equipment_distribution"] = _fig_to_base64(fig)
  427. # --- 4. 严重程度饼图 ---
  428. if "severity" in filtered_df.columns and not filtered_df.empty:
  429. sev_counts = filtered_df["severity"].value_counts()
  430. if not sev_counts.empty:
  431. fig, ax = plt.subplots(figsize=(4.5, 3.5))
  432. sev_colors = {"轻微": "#22c55e", "中等": "#f59e0b", "严重": "#ef4444"}
  433. colors = [sev_colors.get(name, "#94a3b8") for name in sev_counts.index]
  434. wedges, texts, autotexts = ax.pie(
  435. sev_counts.values, labels=sev_counts.index,
  436. autopct="%1.1f%%", colors=colors, startangle=90,
  437. textprops={"fontsize": 11},
  438. )
  439. for at in autotexts:
  440. at.set_fontweight("bold")
  441. ax.set_title("严重程度占比", fontsize=13, fontweight="bold", pad=10)
  442. charts["severity_pie"] = _fig_to_base64(fig)
  443. return charts
  444. def _escape(value):
  445. return html.escape(str(value), quote=True)
  446. def _series_rows(series):
  447. if series is None:
  448. return []
  449. return list(series.items())
  450. def build_report_chart_summaries(type_counts, equipment_counts=None, severity_counts=None, trend_summary="-"):
  451. """生成图表旁的结构化摘要,便于报告快速阅读和追溯。"""
  452. summaries = []
  453. type_rows = _series_rows(type_counts)
  454. if type_rows:
  455. total = max(sum(int(count) for _, count in type_rows), 1)
  456. name, count = type_rows[0]
  457. summaries.append(f"TOP1 缺陷类型:{name},{int(count)} 个,占比 {count / total:.1%}")
  458. equipment_rows = _series_rows(equipment_counts)
  459. if equipment_rows:
  460. name, count = equipment_rows[0]
  461. summaries.append(f"最高缺陷设备:{name},{int(count)} 个缺陷")
  462. severity_rows = _series_rows(severity_counts)
  463. if severity_rows:
  464. total = max(sum(int(count) for _, count in severity_rows), 1)
  465. serious_count = int(dict(severity_rows).get("严重", 0))
  466. summaries.append(f"严重缺陷占比:{serious_count / total:.1%}")
  467. if trend_summary and trend_summary != "-":
  468. summaries.append(str(trend_summary))
  469. return summaries
  470. def build_html_report(
  471. *,
  472. generated_at,
  473. date_range_text,
  474. view_mode,
  475. defect_count,
  476. panel_count,
  477. kpis,
  478. type_counts,
  479. equipment_counts=None,
  480. seat_top=None,
  481. trend_summary="-",
  482. anomaly_rows=None,
  483. recommendations=None,
  484. charts=None,
  485. chart_summaries=None,
  486. ):
  487. """生成可直接在浏览器打开的自包含综合 HTML 报告。"""
  488. anomaly_rows = anomaly_rows or []
  489. recommendations = recommendations or []
  490. charts = charts or {}
  491. chart_summaries = chart_summaries or []
  492. type_rows = _series_rows(type_counts)
  493. equipment_rows = _series_rows(equipment_counts)
  494. seat_rows = _series_rows(seat_top)
  495. type_total = max(sum(int(count) for _, count in type_rows), 1)
  496. type_items = "\n".join(
  497. f"""
  498. <tr>
  499. <td>{_escape(name)}</td>
  500. <td>{int(count)}</td>
  501. <td>{count / type_total:.1%}</td>
  502. </tr>
  503. """
  504. for name, count in type_rows
  505. ) or '<tr><td colspan="3">暂无数据</td></tr>'
  506. equipment_items = "\n".join(
  507. f"<tr><td>{_escape(name)}</td><td>{int(count)}</td></tr>"
  508. for name, count in equipment_rows
  509. ) or '<tr><td colspan="2">暂无数据</td></tr>'
  510. seat_items = "\n".join(
  511. f"<tr><td>{_escape(name)}</td><td>{int(count)}</td></tr>"
  512. for name, count in seat_rows
  513. ) or '<tr><td colspan="2">暂无数据</td></tr>'
  514. anomaly_items = "\n".join(
  515. f"<tr><td>{_escape(row['equipment'])}</td><td>{_escape(row['seat'])}</td><td>{int(row['count'])}</td></tr>"
  516. for row in anomaly_rows
  517. ) or '<tr><td colspan="3">无 2σ 异常座号</td></tr>'
  518. recommendation_items = "\n".join(
  519. f"<li>{_escape(item)}</li>" for item in recommendations
  520. ) or "<li>暂无建议</li>"
  521. chart_summary_items = "\n".join(
  522. f"<li>{_escape(item)}</li>" for item in chart_summaries
  523. ) or "<li>暂无图表摘要</li>"
  524. return f"""<!doctype html>
  525. <html lang="zh-CN">
  526. <head>
  527. <meta charset="utf-8">
  528. <meta name="viewport" content="width=device-width, initial-scale=1">
  529. <title>缺陷集中性分析综合报告</title>
  530. <style>
  531. :root {{
  532. --ink: #10202f;
  533. --muted: #617386;
  534. --line: #dbe5ee;
  535. --card: #ffffff;
  536. --bg: #eef4f7;
  537. --brand: #0f766e;
  538. --warn: #b45309;
  539. }}
  540. * {{ box-sizing: border-box; }}
  541. body {{
  542. margin: 0;
  543. color: var(--ink);
  544. font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", Arial, sans-serif;
  545. background:
  546. radial-gradient(circle at 12% 8%, rgba(15, 118, 110, .18), transparent 28%),
  547. linear-gradient(135deg, #f8fbfc 0%, var(--bg) 100%);
  548. }}
  549. .page {{ max-width: 1180px; margin: 0 auto; padding: 36px 28px 48px; }}
  550. .hero {{
  551. padding: 30px;
  552. border-radius: 28px;
  553. color: white;
  554. background: linear-gradient(135deg, #0f172a 0%, #115e59 58%, #365314 100%);
  555. box-shadow: 0 22px 55px rgba(15, 23, 42, .18);
  556. }}
  557. .hero h1 {{ margin: 0 0 10px; font-size: 34px; letter-spacing: .03em; }}
  558. .hero p {{ margin: 0; color: #d8eef0; }}
  559. .grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 22px 0; }}
  560. .card {{
  561. padding: 18px;
  562. border-radius: 20px;
  563. border: 1px solid var(--line);
  564. background: rgba(255, 255, 255, .92);
  565. box-shadow: 0 12px 28px rgba(15, 23, 42, .07);
  566. }}
  567. .label {{ color: var(--muted); font-size: 13px; margin-bottom: 8px; }}
  568. .value {{ font-size: 28px; font-weight: 800; }}
  569. section {{ margin-top: 22px; }}
  570. h2 {{ font-size: 21px; margin: 0 0 12px; }}
  571. table {{ width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 16px; background: white; }}
  572. th, td {{ padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; }}
  573. th {{ background: #e8f3f2; color: #134e4a; font-size: 13px; }}
  574. .two {{ display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }}
  575. .note {{ color: var(--muted); font-size: 13px; margin-top: 8px; }}
  576. .recommend {{ border-left: 5px solid var(--brand); }}
  577. li {{ margin: 8px 0; }}
  578. .chart {{ max-width: 100%; border-radius: 12px; margin-top: 8px; }}
  579. @media print {{
  580. body {{ background: white; }}
  581. .page {{ max-width: none; padding: 20px; }}
  582. .card {{ box-shadow: none; }}
  583. }}
  584. @media (max-width: 860px) {{
  585. .grid, .two {{ grid-template-columns: 1fr; }}
  586. }}
  587. </style>
  588. </head>
  589. <body>
  590. <main class="page">
  591. <header class="hero">
  592. <h1>缺陷集中性分析综合报告</h1>
  593. <p>生成时间:{_escape(generated_at)} | 数据范围:{_escape(date_range_text)} | 视图模式:{_escape(view_mode)}</p>
  594. </header>
  595. <div class="grid">
  596. <div class="card"><div class="label">筛选后缺陷数</div><div class="value">{int(defect_count)}</div></div>
  597. <div class="card"><div class="label">涉及面板</div><div class="value">{int(panel_count)}</div></div>
  598. <div class="card"><div class="label">综合良率</div><div class="value">{float(kpis.get('yield_rate', 0)):.1f}%</div></div>
  599. <div class="card"><div class="label">严重缺陷</div><div class="value">{int(kpis.get('critical_defects', 0))}</div></div>
  600. </div>
  601. <section class="two">
  602. <div class="card">
  603. <h2>1. KPI 摘要</h2>
  604. <table>
  605. <tr><th>指标</th><th>数值</th></tr>
  606. <tr><td>检测面板数</td><td>{int(kpis.get('total_panels_inspected', 0))} 块</td></tr>
  607. <tr><td>不良面板数</td><td>{int(kpis.get('defective_panels', 0))} 块</td></tr>
  608. <tr><td>严重缺陷</td><td>{int(kpis.get('critical_defects', 0))} 个</td></tr>
  609. </table>
  610. </div>
  611. <div class="card">
  612. <h2>图表摘要</h2>
  613. <ul>{chart_summary_items}</ul>
  614. <p class="note">摘要由导出时的筛选数据自动生成,适合会议或邮件快速阅读。</p>
  615. </div>
  616. <div class="card">
  617. <h2>2. 趋势分析</h2>
  618. <p>{_escape(trend_summary)}</p>
  619. {('<img class="chart" src="' + charts["daily_trend"] + '" alt="每日缺陷数趋势"/>') if "daily_trend" in charts else ""}
  620. <p class="note">建议结合 SPC 控制图确认是否越过预警线或控制线。</p>
  621. </div>
  622. </section>
  623. <section class="card">
  624. <h2>3. 缺陷类型分布</h2>
  625. {('<img class="chart" src="' + charts["type_distribution"] + '" alt="缺陷类型分布"/>') if "type_distribution" in charts else ""}
  626. <table><tr><th>缺陷类型</th><th>缺陷数</th><th>占比</th></tr>{type_items}</table>
  627. </section>
  628. <section class="two">
  629. <div class="card">
  630. <h2>4. 设备分布</h2>
  631. {('<img class="chart" src="' + charts["equipment_distribution"] + '" alt="设备缺陷分布"/>') if "equipment_distribution" in charts else ""}
  632. <table><tr><th>设备</th><th>缺陷数</th></tr>{equipment_items}</table>
  633. </div>
  634. <div class="card">
  635. <h2>5. 座号 TOP</h2>
  636. <table><tr><th>座号</th><th>缺陷数</th></tr>{seat_items}</table>
  637. </div>
  638. </section>
  639. {('<section class="card"><h2>6. 严重程度占比</h2><div style="display:flex;justify-content:center;"><img class="chart" src="' + charts["severity_pie"] + '" alt="严重程度占比" style="max-width:320px;"/></div></section>') if "severity_pie" in charts else ""}
  640. <section class="card">
  641. <h2>{"6. 异常检测" if "severity_pie" not in charts else "7. 异常检测"}</h2>
  642. <table><tr><th>设备</th><th>座号</th><th>缺陷数</th></tr>{anomaly_items}</table>
  643. </section>
  644. <section class="card recommend">
  645. <h2>{"7. 排查建议" if "severity_pie" not in charts else "8. 排查建议"}</h2>
  646. <ul>{recommendation_items}</ul>
  647. </section>
  648. <p class="note">本报告由缺陷集中性分析系统自动生成,可直接归档、邮件发送或浏览器打印为 PDF。</p>
  649. </main>
  650. </body>
  651. </html>
  652. """