make_report.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. """生成振动统一标签表、截图和 Word 报告。"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import subprocess
  6. import tempfile
  7. from pathlib import Path
  8. import pandas as pd
  9. from docx import Document
  10. from docx.enum.section import WD_ORIENT
  11. from docx.enum.text import WD_ALIGN_PARAGRAPH
  12. from docx.oxml.ns import qn
  13. from docx.shared import Cm, Pt, RGBColor
  14. from . import config, unified_labels
  15. SHOT_SCRIPT = r'''
  16. const { chromium } = require('playwright');
  17. (async () => {
  18. const html = process.argv[2];
  19. const outDir = process.argv[3];
  20. const count = Number(process.argv[4]);
  21. const browser = await chromium.launch({ channel: 'chrome', headless: true });
  22. const page = await browser.newPage({ viewport: { width: 1700, height: 1200 } });
  23. await page.goto('file://' + html, { waitUntil: 'networkidle' });
  24. await page.waitForTimeout(1500);
  25. const cards = page.locator('.card');
  26. for (let i = 0; i < count; i++) {
  27. if (i > 0) await cards.nth(i).evaluate(el => el.click());
  28. await page.waitForTimeout(180);
  29. const name = 'shot_' + String(i + 1).padStart(4, '0') + '.png';
  30. await page.locator('#chart').screenshot({ path: outDir + '/' + name });
  31. }
  32. await browser.close();
  33. })();
  34. '''
  35. def capture_screenshots(count: int) -> Path:
  36. out_dir = config.OUTPUT_DIR / "report_assets"
  37. out_dir.mkdir(parents=True, exist_ok=True)
  38. script = Path(tempfile.gettempdir()) / "compressor_cylinder_capture.js"
  39. script.write_text(SHOT_SCRIPT, encoding="utf-8")
  40. env = dict(os.environ)
  41. env["NODE_PATH"] = "/Users/zhouhao/Code/Python/Compressor/frontend/node_modules"
  42. subprocess.run(
  43. ["node", str(script), str(config.OUTPUT_DIR / "html" / "waveforms.html"), str(out_dir), str(count)],
  44. check=True,
  45. env=env,
  46. timeout=900,
  47. )
  48. return out_dir
  49. def _set_black(style) -> None:
  50. style.font.color.rgb = RGBColor(0, 0, 0)
  51. rPr = style.element.get_or_add_rPr()
  52. color = rPr.find(qn("w:color"))
  53. if color is not None:
  54. for attr in ("themeColor", "themeTint", "themeShade"):
  55. color.attrib.pop(qn("w:" + attr), None)
  56. def add_table(doc: Document, rows: list[list], headers: list[str]) -> None:
  57. table = doc.add_table(rows=1, cols=len(headers))
  58. table.style = "Table Grid"
  59. for index, header in enumerate(headers):
  60. cell = table.rows[0].cells[index]
  61. cell.text = str(header)
  62. for paragraph in cell.paragraphs:
  63. for run in paragraph.runs:
  64. run.font.name = "宋体"
  65. run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  66. run.font.size = Pt(9.5)
  67. run.bold = True
  68. run.font.color.rgb = RGBColor(0, 0, 0)
  69. for row in rows:
  70. cells = table.add_row().cells
  71. for index, value in enumerate(row):
  72. cells[index].text = "" if value is None else str(value)
  73. for paragraph in cells[index].paragraphs:
  74. for run in paragraph.runs:
  75. run.font.name = "宋体"
  76. run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  77. run.font.size = Pt(9.5)
  78. run.font.color.rgb = RGBColor(0, 0, 0)
  79. def _style_attr_cell(cell, bold_label: bool = True) -> None:
  80. for paragraph in cell.paragraphs:
  81. for run in paragraph.runs:
  82. run.font.name = "宋体"
  83. run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  84. run.font.size = Pt(9.5)
  85. run.font.color.rgb = RGBColor(0, 0, 0)
  86. def add_attribute_table(doc: Document, attributes: list[tuple[str, object]], trigger: str) -> None:
  87. """标签属性:一行 3 个属性;阶段触发依据单独占一整行。"""
  88. table = doc.add_table(rows=0, cols=3)
  89. table.style = "Table Grid"
  90. for index in range(0, len(attributes), 3):
  91. row = table.add_row()
  92. for offset in range(3):
  93. cell = row.cells[offset]
  94. if index + offset < len(attributes):
  95. field, value = attributes[index + offset]
  96. paragraph = cell.paragraphs[0]
  97. label = paragraph.add_run(f"{field}:")
  98. label.bold = True
  99. label.font.name = "宋体"
  100. label._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  101. label.font.size = Pt(9.5)
  102. label.font.color.rgb = RGBColor(0, 0, 0)
  103. content = paragraph.add_run("" if value is None else str(value))
  104. content.font.name = "宋体"
  105. content._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  106. content.font.size = Pt(9.5)
  107. content.font.color.rgb = RGBColor(0, 0, 0)
  108. row = table.add_row()
  109. merged = row.cells[0].merge(row.cells[1]).merge(row.cells[2])
  110. paragraph = merged.paragraphs[0]
  111. label = paragraph.add_run("阶段触发依据:")
  112. label.bold = True
  113. for run in paragraph.runs:
  114. run.font.name = "宋体"
  115. run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  116. run.font.size = Pt(9.5)
  117. run.font.color.rgb = RGBColor(0, 0, 0)
  118. content = paragraph.add_run("" if trigger is None else str(trigger))
  119. content.font.name = "宋体"
  120. content._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  121. content.font.size = Pt(9.5)
  122. content.font.color.rgb = RGBColor(0, 0, 0)
  123. _style_attr_cell(merged)
  124. def build_docx(unified: pd.DataFrame, validation: pd.DataFrame, assets: Path) -> Path:
  125. doc = Document()
  126. for section in doc.sections:
  127. section.orientation = WD_ORIENT.LANDSCAPE
  128. section.page_width = Cm(29.7)
  129. section.page_height = Cm(21.0)
  130. section.left_margin = Cm(2.0)
  131. section.right_margin = Cm(2.0)
  132. section.top_margin = Cm(2.0)
  133. section.bottom_margin = Cm(2.0)
  134. normal = doc.styles["Normal"]
  135. normal.font.name = "宋体"
  136. normal._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  137. normal.font.size = Pt(12)
  138. _set_black(normal)
  139. normal.paragraph_format.line_spacing = 1.5
  140. for name, size, before, after in (("Heading 1", 16, 18, 10), ("Heading 2", 14, 12, 6)):
  141. style = doc.styles[name]
  142. style.font.name = "黑体"
  143. style._element.rPr.rFonts.set(qn("w:eastAsia"), "黑体")
  144. style.font.size = Pt(size)
  145. style.font.bold = True
  146. _set_black(style)
  147. style.paragraph_format.line_spacing = 1.0
  148. style.paragraph_format.space_before = Pt(before)
  149. style.paragraph_format.space_after = Pt(after)
  150. for text in ("组态振动数据演化过程", "故障标签手册"):
  151. title = doc.add_paragraph()
  152. title.alignment = WD_ALIGN_PARAGRAPH.CENTER
  153. run = title.add_run(text)
  154. run.bold = True
  155. run.font.name = "黑体"
  156. run._element.rPr.rFonts.set(qn("w:eastAsia"), "黑体")
  157. run.font.size = Pt(26)
  158. run.font.color.rgb = RGBColor(0, 0, 0)
  159. subtitle = doc.add_paragraph()
  160. subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
  161. run = subtitle.add_run(f"振动事件 · 小时标签(共 {len(unified)} 条)")
  162. run.font.name = "宋体"
  163. run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
  164. run.font.size = Pt(16)
  165. run.font.color.rgb = RGBColor(0, 0, 0)
  166. date_line = doc.add_paragraph("2026 年 11 月")
  167. date_line.alignment = WD_ALIGN_PARAGRAPH.CENTER
  168. for run in date_line.runs:
  169. run.font.color.rgb = RGBColor(0, 0, 0)
  170. doc.add_heading("文档阅读说明", level=1)
  171. doc.add_paragraph(
  172. "本手册汇总 5 号机缸体振动的规则扫描标签,逐条给出标签属性、演化等级、局部基线、"
  173. "阶段触发依据和标签前后曲线。标签是规则扫描结果,不等同于物理故障结论;温度只作参考,不参与振动标签生成。"
  174. )
  175. doc.add_paragraph(
  176. "曲线截图来自 waveforms.html:只截取右侧 1×6 子图,不包含左侧标签列表。"
  177. "图中每个 15 分钟槽位拼接该文件 0~60ms 首周期波形;橙色为异常分数,紫色虚线为实际峭度;"
  178. "红色/黄色背景分别为当前缸/其他缸的标签段,淡蓝色为上下文内其他标签。"
  179. )
  180. doc.add_heading("一、标签数量总结", level=1)
  181. counts = unified.groupby(["阶段"]).size().reset_index(name="数量")
  182. add_table(doc, counts.values.tolist(), ["阶段", "数量"])
  183. doc.add_paragraph(f"标签总数:{len(unified)};多特征确认:{int((unified['是否形成确认等级'] == '是').sum())}。")
  184. doc.add_paragraph(
  185. "严重异常、异常、轻微分别对应不同分数阈值;阈值只由局部基线和稳健尺度决定,"
  186. "不针对具体维修事件单独调整。"
  187. )
  188. doc.add_heading("二、事件验证", level=1)
  189. rows = []
  190. for row in validation.itertuples(index=False):
  191. rows.append([
  192. row.event_id, row.cylinder, row.hit, row.confirmed, row.start_time,
  193. row.end_time, row.duration_hours, row.direction, row.level, row.lead_days,
  194. ])
  195. add_table(
  196. doc,
  197. rows,
  198. ["事件", "缸", "命中", "确认", "开始", "结束", "小时", "方向", "等级", "提前天"],
  199. )
  200. doc.add_paragraph(
  201. "事件只用于验证和抽样观察,不参与标签生成;同一次事件可能对应多个标签,"
  202. "也可能只有温度证据而没有振动标签。"
  203. )
  204. doc.add_heading("三、字段说明", level=1)
  205. doc.add_paragraph(
  206. "统一核心字段包括:标签ID、标签类型、机组、批次、周期编号、阶段、阶段部位、阶段点位、"
  207. "开始小时、结束小时、持续自然小时数、有效运行小时数、基线值、正常波动带、阶段开始/结束/最大值、"
  208. "相对基线变化、阶段最大连续异常有效小时数、高/高高报警小时数、方向、特征类型、置信度、"
  209. "是否形成确认等级、阶段触发依据、事件关联、启动过渡比例、局部基线可用比例、数据来源、算法版本。"
  210. )
  211. doc.add_paragraph(
  212. "振动专有字段:近期基线RMS、RMS正常波动带、阶段开始/结束/最大RMS、阶段最大峭度、"
  213. "阶段最大峭度/基线、阶段最大残差峭度z、触发特征、多特征确认。"
  214. )
  215. doc.add_heading("四、标签详情", level=1)
  216. ordered = unified.to_dict("records")
  217. for index, row in enumerate(ordered, start=1):
  218. doc.add_heading(
  219. f"{row['阶段部位']}-{row['阶段']}-{row['方向']}-{str(row['开始小时'])[:16]}", level=2
  220. )
  221. attributes = [
  222. ("标签ID", row.get("标签ID")),
  223. ("阶段", row.get("阶段")),
  224. ("阶段部位", row.get("阶段部位")),
  225. ("开始小时", row.get("开始小时")),
  226. ("结束小时", row.get("结束小时")),
  227. ("持续自然小时数", row.get("持续自然小时数")),
  228. ("方向", row.get("方向")),
  229. ("近期基线RMS", row.get("近期基线RMS")),
  230. ("正常波动带", row.get("RMS正常波动带")),
  231. ("阶段最大RMS", row.get("阶段最大RMS")),
  232. ("阶段最大峭度", row.get("阶段最大峭度")),
  233. ("阶段最大峭度/基线", row.get("阶段最大峭度/基线")),
  234. ("是否形成确认等级", row.get("是否形成确认等级")),
  235. ]
  236. add_attribute_table(doc, attributes, row.get("阶段触发依据"))
  237. shot = assets / f"shot_{index:04d}.png"
  238. if shot.exists():
  239. doc.add_picture(str(shot), width=Cm(19.5))
  240. caption = doc.add_paragraph("图:标签前后运行曲线(仅右侧 1×6 子图)")
  241. caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
  242. if index % 8 == 0:
  243. doc.add_page_break()
  244. path = config.BASE_DIR.parent / "总结文档" / "缸体振动演化过程故障标签手册.docx"
  245. path.parent.mkdir(parents=True, exist_ok=True)
  246. doc.save(path)
  247. return path
  248. def main() -> None:
  249. hourly = pd.read_csv(config.OUTPUT_DIR / "vibration_hourly_labels.csv", parse_dates=["start_hour", "end_hour"])
  250. review = pd.read_csv(config.OUTPUT_DIR / "label_review.csv", parse_dates=["start_hour", "end_hour"])
  251. frame = pd.read_csv(config.OUTPUT_DIR / "vibration_features_full_local.csv", parse_dates=["sample_time"])
  252. validation = pd.read_csv(config.OUTPUT_DIR / "event_validation_full.csv")
  253. unified = unified_labels.build_vibration_unified(hourly, frame, review)
  254. unified_path = config.OUTPUT_DIR / "cylinder_vibration_label_unified.csv"
  255. unified.to_csv(unified_path, index=False, date_format="%Y-%m-%d %H:%M:%S")
  256. combined = unified_labels.build_combined_preview(config.OUTPUT_DIR)
  257. combined.to_csv(config.OUTPUT_DIR / "fault_label_unified_preview.csv", index=False, date_format="%Y-%m-%d %H:%M:%S")
  258. assets = capture_screenshots(len(unified))
  259. path = build_docx(unified, validation, assets)
  260. print(f"written -> {unified_path}")
  261. print(f"written -> {config.OUTPUT_DIR / 'fault_label_unified_preview.csv'}")
  262. print(f"written -> {path}")
  263. if __name__ == "__main__":
  264. main()