build_tag_manual.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import shutil
  6. import subprocess
  7. import sys
  8. import tempfile
  9. import time
  10. from collections import Counter, defaultdict
  11. from datetime import date
  12. from pathlib import Path
  13. from urllib.parse import quote
  14. from urllib.request import urlopen
  15. from PIL import Image
  16. from docx import Document
  17. from docx.enum.section import WD_ORIENT
  18. from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
  19. from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
  20. from docx.oxml import OxmlElement
  21. from docx.oxml.ns import qn
  22. from docx.shared import Cm, Pt, RGBColor
  23. ROOT = Path(__file__).resolve().parent
  24. OUT_DIR = ROOT / "总结文档"
  25. FIG_DIR = OUT_DIR / "figures"
  26. DEFAULT_DOCX = OUT_DIR / "组态数据高质量演化过程故障标签手册.docx"
  27. BASE_URL = "http://127.0.0.1:5173/preview.html"
  28. API_URL = "http://127.0.0.1:8000/api/tag-manual/labels"
  29. CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
  30. BODY_FONT = "宋体"
  31. HEAD_FONT = "黑体"
  32. BODY_SIZE = 12.0
  33. TABLE_SIZE = 9.0
  34. DEVICE_ORDER = {"7号机": 7, "8号机": 8, "9号机": 9}
  35. STAGE_ORDER = {"轻微": 0, "异常": 1, "严重异常": 2}
  36. KIND_ORDER = {"pressure": 0, "vibration": 1}
  37. KIND_LABEL = {"pressure": "润滑油压力", "vibration": "振动"}
  38. def set_run_font(run, name: str = BODY_FONT, size: float = BODY_SIZE, bold: bool = False) -> None:
  39. run.font.name = name
  40. run.font.size = Pt(size)
  41. run.font.bold = bold
  42. run.font.color.rgb = RGBColor(0, 0, 0)
  43. run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
  44. def set_style_font(style, name: str, size: float, bold: bool) -> None:
  45. style.font.name = name
  46. style.font.size = Pt(size)
  47. style.font.bold = bold
  48. style.font.color.rgb = RGBColor(0, 0, 0)
  49. style.element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), name)
  50. def configure_document(doc: Document) -> None:
  51. section = doc.sections[0]
  52. section.orientation = WD_ORIENT.LANDSCAPE
  53. section.page_width = Cm(29.7)
  54. section.page_height = Cm(21.0)
  55. section.top_margin = Cm(2.0)
  56. section.bottom_margin = Cm(2.0)
  57. section.left_margin = Cm(2.0)
  58. section.right_margin = Cm(2.0)
  59. normal = doc.styles["Normal"]
  60. set_style_font(normal, BODY_FONT, BODY_SIZE, False)
  61. normal.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
  62. normal.paragraph_format.space_before = Pt(0)
  63. normal.paragraph_format.space_after = Pt(0)
  64. heading1 = doc.styles["Heading 1"]
  65. set_style_font(heading1, HEAD_FONT, 16, True)
  66. heading1.paragraph_format.space_before = Pt(18)
  67. heading1.paragraph_format.space_after = Pt(10)
  68. heading1.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  69. heading1.paragraph_format.keep_with_next = True
  70. heading2 = doc.styles["Heading 2"]
  71. set_style_font(heading2, HEAD_FONT, 14, True)
  72. heading2.paragraph_format.space_before = Pt(12)
  73. heading2.paragraph_format.space_after = Pt(6)
  74. heading2.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  75. heading2.paragraph_format.keep_with_next = True
  76. add_footer(doc)
  77. def add_footer(doc: Document) -> None:
  78. section = doc.sections[0]
  79. footer = section.footer
  80. paragraph = footer.paragraphs[0]
  81. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  82. run = paragraph.add_run("组态数据高质量演化过程故障标签手册 第 ")
  83. set_run_font(run, BODY_FONT, 9)
  84. field = OxmlElement("w:fldSimple")
  85. field.set(qn("w:instr"), "PAGE")
  86. paragraph._p.append(field)
  87. tail = paragraph.add_run(" 页")
  88. set_run_font(tail, BODY_FONT, 9)
  89. def body_paragraph(doc: Document, text: str, bold_prefix: str = "") -> None:
  90. paragraph = doc.add_paragraph()
  91. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
  92. paragraph.paragraph_format.space_before = Pt(0)
  93. paragraph.paragraph_format.space_after = Pt(6)
  94. if bold_prefix and text.startswith(bold_prefix):
  95. first = paragraph.add_run(bold_prefix)
  96. set_run_font(first, BODY_FONT, BODY_SIZE, True)
  97. rest = paragraph.add_run(text[len(bold_prefix):])
  98. set_run_font(rest, BODY_FONT, BODY_SIZE)
  99. else:
  100. run = paragraph.add_run(text)
  101. set_run_font(run, BODY_FONT, BODY_SIZE)
  102. def set_cell_margins(cell, left: int = 60, right: int = 60, top: int = 30, bottom: int = 30) -> None:
  103. tc_pr = cell._tc.get_or_add_tcPr()
  104. mar = tc_pr.first_child_found_in("w:tcMar")
  105. if mar is None:
  106. mar = OxmlElement("w:tcMar")
  107. tc_pr.append(mar)
  108. for key, value in (("w:left", left), ("w:right", right), ("w:top", top), ("w:bottom", bottom)):
  109. node = mar.find(qn(key))
  110. if node is None:
  111. node = OxmlElement(key)
  112. mar.append(node)
  113. node.set(qn("w:w"), str(value))
  114. node.set(qn("w:type"), "dxa")
  115. def fix_table_layout(table, widths: list[float]) -> None:
  116. tbl_pr = table._tbl.tblPr
  117. layout = OxmlElement("w:tblLayout")
  118. layout.set(qn("w:type"), "fixed")
  119. tbl_pr.append(layout)
  120. tbl_w = tbl_pr.find(qn("w:tblW"))
  121. if tbl_w is None:
  122. tbl_w = OxmlElement("w:tblW")
  123. tbl_pr.append(tbl_w)
  124. tbl_w.set(qn("w:w"), str(int(sum(widths) * 567)))
  125. tbl_w.set(qn("w:type"), "dxa")
  126. for index, column in enumerate(table._tbl.tblGrid.gridCol_lst):
  127. column.set(qn("w:w"), str(int(widths[index] * 567)))
  128. def no_split(row) -> None:
  129. tr_pr = row._tr.get_or_add_trPr()
  130. cant = OxmlElement("w:cantSplit")
  131. tr_pr.append(cant)
  132. def repeat_header(row) -> None:
  133. tr_pr = row._tr.get_or_add_trPr()
  134. header = OxmlElement("w:tblHeader")
  135. tr_pr.append(header)
  136. def add_table(
  137. doc: Document,
  138. headers: list[str],
  139. rows: list[list[str]],
  140. widths: list[float],
  141. font_size: float = TABLE_SIZE,
  142. align_center: bool = False,
  143. ) -> None:
  144. table = doc.add_table(rows=1, cols=len(headers))
  145. table.style = "Table Grid"
  146. table.alignment = WD_TABLE_ALIGNMENT.CENTER
  147. table.autofit = False
  148. fix_table_layout(table, widths)
  149. header = table.rows[0]
  150. repeat_header(header)
  151. no_split(header)
  152. for index, text in enumerate(headers):
  153. cell = header.cells[index]
  154. cell.width = Cm(widths[index])
  155. set_cell_margins(cell)
  156. cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
  157. paragraph = cell.paragraphs[0]
  158. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  159. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  160. run = paragraph.add_run(text)
  161. set_run_font(run, BODY_FONT, font_size, True)
  162. for values in rows:
  163. row = table.add_row()
  164. no_split(row)
  165. for index, text in enumerate(values):
  166. cell = row.cells[index]
  167. cell.width = Cm(widths[index])
  168. set_cell_margins(cell)
  169. cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
  170. paragraph = cell.paragraphs[0]
  171. paragraph.alignment = (
  172. WD_ALIGN_PARAGRAPH.CENTER if align_center or index > 1 else WD_ALIGN_PARAGRAPH.LEFT
  173. )
  174. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  175. run = paragraph.add_run(str(text))
  176. set_run_font(run, BODY_FONT, font_size)
  177. doc.add_paragraph()
  178. def add_attr_table(doc: Document, fields: list[dict]) -> None:
  179. widths = [2.0, 6.5, 2.0, 6.5, 2.0, 6.5]
  180. table = doc.add_table(rows=0, cols=6)
  181. table.style = "Table Grid"
  182. table.alignment = WD_TABLE_ALIGNMENT.CENTER
  183. table.autofit = False
  184. fix_table_layout(table, widths)
  185. row = None
  186. pair = 0
  187. def new_row():
  188. nonlocal row, pair
  189. row = table.add_row()
  190. no_split(row)
  191. pair = 0
  192. for index, width in enumerate(widths):
  193. cell = row.cells[index]
  194. cell.width = Cm(width)
  195. set_cell_margins(cell)
  196. cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
  197. for field in fields:
  198. label = str(field.get("label") or field.get("key") or "")
  199. value = str(field.get("value") or "—")
  200. span = int(field.get("span") or 1)
  201. if span >= 2:
  202. if row is not None and pair > 0:
  203. row = None
  204. new_row()
  205. label_cell = row.cells[0]
  206. value_cell = row.cells[1].merge(row.cells[5])
  207. for cell, text, bold in ((label_cell, label, True), (value_cell, value, False)):
  208. cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
  209. paragraph = cell.paragraphs[0]
  210. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  211. run = paragraph.add_run(text)
  212. set_run_font(run, BODY_FONT, TABLE_SIZE, bold)
  213. row = None
  214. pair = 0
  215. continue
  216. if row is None or pair >= 3:
  217. new_row()
  218. label_cell = row.cells[pair * 2]
  219. value_cell = row.cells[pair * 2 + 1]
  220. for cell, text, bold in ((label_cell, label, True), (value_cell, value, False)):
  221. cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
  222. paragraph = cell.paragraphs[0]
  223. paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  224. run = paragraph.add_run(text)
  225. set_run_font(run, BODY_FONT, TABLE_SIZE, bold)
  226. pair += 1
  227. doc.add_paragraph()
  228. def load_labels() -> list[dict]:
  229. with urlopen(API_URL, timeout=20) as response:
  230. payload = json.load(response)
  231. return payload["items"]
  232. def fetch_ready() -> None:
  233. for name, url in (
  234. ("后端服务", "http://127.0.0.1:8000/api/tag-manual/labels"),
  235. ("前端预览", "http://127.0.0.1:5173/preview.html"),
  236. ):
  237. try:
  238. with urlopen(url, timeout=5):
  239. pass
  240. except Exception as error:
  241. raise SystemExit(
  242. f"{name}未启动或不可访问:{error}\n"
  243. "请先启动:uvicorn app.main:app --app-dir backend --port 8000\n"
  244. "并启动:cd frontend && npm run dev"
  245. ) from error
  246. def screenshot(label: dict, profile_dir: Path, force: bool = False) -> Path:
  247. FIG_DIR.mkdir(parents=True, exist_ok=True)
  248. jpg_path = FIG_DIR / f"{label['key'].replace(':', '_')}.jpg"
  249. if jpg_path.exists() and not force:
  250. return jpg_path
  251. png_path = FIG_DIR / f"{label['key'].replace(':', '_')}.png"
  252. url = f"{BASE_URL}?key={quote(label['key'])}&chart=1"
  253. command = [
  254. CHROME,
  255. "--headless=new",
  256. "--disable-gpu",
  257. "--hide-scrollbars",
  258. "--window-size=1800,900",
  259. "--force-device-scale-factor=2",
  260. "--virtual-time-budget=20000",
  261. "--run-all-compositor-stages-before-draw",
  262. f"--user-data-dir={profile_dir}",
  263. f"--screenshot={png_path}",
  264. url,
  265. ]
  266. for attempt in range(2):
  267. subprocess.run(
  268. command,
  269. check=True,
  270. stdout=subprocess.DEVNULL,
  271. stderr=subprocess.DEVNULL,
  272. timeout=120,
  273. )
  274. if png_path.exists() and png_path.stat().st_size > 5000:
  275. break
  276. time.sleep(1)
  277. if not png_path.exists() or png_path.stat().st_size <= 5000:
  278. raise RuntimeError(f"截图失败:{label['key']}")
  279. image = Image.open(png_path).convert("RGB")
  280. image = image.resize((2200, 1100), Image.LANCZOS)
  281. image.save(jpg_path, quality=90, optimize=True)
  282. png_path.unlink(missing_ok=True)
  283. return jpg_path
  284. def cycle_name(label: dict) -> str:
  285. cycle = label.get("cycle") or ""
  286. unit = label.get("device") or ""
  287. prefix = f"{unit}-"
  288. return cycle[len(prefix):] if cycle.startswith(prefix) else cycle
  289. def sort_key(label: dict) -> tuple:
  290. unit = label.get("device") or ""
  291. return (
  292. DEVICE_ORDER.get(unit, 99),
  293. KIND_ORDER.get(label.get("kind"), 9),
  294. label.get("start") or "",
  295. )
  296. def add_cover(doc: Document, labels: list[dict]) -> None:
  297. for _ in range(4):
  298. doc.add_paragraph()
  299. paragraph = doc.add_paragraph()
  300. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  301. run = paragraph.add_run("组态数据高质量演化过程")
  302. set_run_font(run, HEAD_FONT, 26, True)
  303. paragraph = doc.add_paragraph()
  304. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  305. run = paragraph.add_run("故障标签手册")
  306. set_run_font(run, HEAD_FONT, 26, True)
  307. doc.add_paragraph()
  308. paragraph = doc.add_paragraph()
  309. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  310. run = paragraph.add_run("润滑油压力故障 · 振动故障")
  311. set_run_font(run, BODY_FONT, 16, False)
  312. for _ in range(6):
  313. doc.add_paragraph()
  314. paragraph = doc.add_paragraph()
  315. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  316. run = paragraph.add_run(f"共 {len(labels)} 条演化标签")
  317. set_run_font(run, BODY_FONT, 12, False)
  318. paragraph = doc.add_paragraph()
  319. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  320. run = paragraph.add_run(f"{date.today().year} 年 {date.today().month} 月")
  321. set_run_font(run, BODY_FONT, 12, False)
  322. doc.add_page_break()
  323. def add_reading_guide(doc: Document) -> None:
  324. doc.add_heading("文档阅读说明", level=1)
  325. body_paragraph(
  326. doc,
  327. "用途:本手册汇总设备运行过程中由规则扫描形成的润滑油压力、振动两类演化标签,"
  328. "逐条给出标签属性、演化阶段和标签前后运行曲线,用于复核、诊断和维修决策辅助。",
  329. "用途:",
  330. )
  331. body_paragraph(
  332. doc,
  333. "阅读方法:先阅读标签数量总结,了解总体分布和演化过程;再按章节、机组或阶段查阅详情。"
  334. "在 Word 中打开导航窗格,可通过一级、二级标题直接跳转到对应章节或标签。",
  335. "阅读方法:",
  336. )
  337. body_paragraph(
  338. doc,
  339. "排序方式:详情部分分为润滑油压力、振动两章,章内按机组和开始时间排序;"
  340. "同一标签的所有属性集中在一张表中,图表位于属性表下方。",
  341. "排序方式:",
  342. )
  343. body_paragraph(
  344. doc,
  345. "数据口径:标签来自演化阶段统计记录;曲线来自设备运行监测数据,只使用运行样本,"
  346. "停机、坏点和无效值不参与曲线绘制。标签是规则扫描结果,不等同于物理故障结论。",
  347. "数据口径:",
  348. )
  349. doc.add_heading("图形解释", level=2)
  350. rows = [
  351. ["红色背景", "当前标签对应的故障阶段,画在当前类型子图上。"],
  352. ["淡黄色背景", "总表中其他故障标签与当前展示窗口重合的时间段;与红色故障段重叠的部分不重复标注。"],
  353. ["竖向虚线", "停机边界。两条虚线为一组,左侧为关机,右侧为开机。"],
  354. ["标题下备注", "故障段时间、图表展示时间,以及各停机段的关机时间和开机时间。"],
  355. ["相对基线子图", "润滑油压力按周期基线计算百分比;振动按近期基线计算倍数;虚线为基线。"],
  356. ["原值子图", "测点实际值,用于核对曲线形态和数值范围。"],
  357. ["右上角角标", "标签阶段:轻微、异常或严重异常。"],
  358. ["底部滑块", "时间轴缩放控件,默认显示全部时间范围。"],
  359. ["子图", "每个子图只画一条曲线,所有子图共用同一时间轴。"],
  360. ]
  361. add_table(doc, ["图形元素", "含义"], rows, [5.0, 20.5], font_size=9.5)
  362. body_paragraph(
  363. doc,
  364. "展示范围:故障段前后各补充 6 小时有效运行数据。若期间发生停机,会继续向更早或更晚的"
  365. "运行数据取数,停机段压缩为空档并用竖向虚线标出。停机后 20 分钟的启动瞬态在故障段外"
  366. "不展示,故障段内的数据不剔除。",
  367. "展示范围:",
  368. )
  369. body_paragraph(
  370. doc,
  371. "每条标签均附属性表和曲线图;曲线用于核对标签前后的运行状态和演化过程。",
  372. "图表说明:",
  373. )
  374. def add_summary(doc: Document, labels: list[dict]) -> None:
  375. doc.add_heading("一、标签数量总结", level=1)
  376. body_paragraph(
  377. doc,
  378. f"本手册共收录 {len(labels)} 条演化标签,覆盖润滑油压力故障和振动故障两类,"
  379. "按机组和阶段统计如下。",
  380. )
  381. kind_counts = Counter(label["kindLabel"] for label in labels)
  382. device_kind = Counter((label["device"], label["kindLabel"]) for label in labels)
  383. device_stage = Counter((label["device"], label["stage"]) for label in labels)
  384. doc.add_heading("1.1 按机组统计", level=2)
  385. rows = []
  386. for device in ("7号机", "8号机", "9号机"):
  387. pressure = device_kind.get((device, "润滑油压力"), 0)
  388. vibration = device_kind.get((device, "振动"), 0)
  389. rows.append([device, str(pressure), str(vibration), str(pressure + vibration)])
  390. rows.append([
  391. "合计",
  392. str(kind_counts.get("润滑油压力", 0)),
  393. str(kind_counts.get("振动", 0)),
  394. str(len(labels)),
  395. ])
  396. add_table(doc, ["机组", "润滑油压力", "振动", "合计"], rows, [6.0, 6.0, 6.0, 6.0], align_center=True)
  397. doc.add_heading("1.2 按机组和阶段统计", level=2)
  398. rows = []
  399. for device in ("7号机", "8号机", "9号机"):
  400. values = [device_stage.get((device, stage), 0) for stage in ("轻微", "异常", "严重异常")]
  401. rows.append([device, *[str(value) for value in values], str(sum(values))])
  402. total_stage = Counter(label["stage"] for label in labels)
  403. rows.append([
  404. "合计",
  405. *[str(total_stage.get(stage, 0)) for stage in ("轻微", "异常", "严重异常")],
  406. str(len(labels)),
  407. ])
  408. add_table(
  409. doc,
  410. ["机组", "轻微", "异常", "严重异常", "合计"],
  411. rows,
  412. [6.0, 4.5, 4.5, 5.0, 5.0],
  413. align_center=True,
  414. )
  415. doc.add_heading("1.3 总体演化过程", level=2)
  416. pressure = kind_counts.get("润滑油压力", 0)
  417. vibration = kind_counts.get("振动", 0)
  418. slight = total_stage.get("轻微", 0)
  419. abnormal = total_stage.get("异常", 0)
  420. severe = total_stage.get("严重异常", 0)
  421. min_start = min(label["start"] for label in labels)
  422. max_end = max(label["end"] for label in labels)
  423. body_paragraph(
  424. doc,
  425. f"标签时间覆盖 {min_start[:10]} 至 {max_end[:10]}。润滑油压力 {pressure} 条,"
  426. f"占 {pressure / len(labels) * 100:.1f}%;振动 {vibration} 条,占 "
  427. f"{vibration / len(labels) * 100:.1f}%。",
  428. )
  429. body_paragraph(
  430. doc,
  431. f"按阶段看,轻微 {slight} 条、异常 {abnormal} 条、严重异常 {severe} 条。"
  432. "多数标签处于轻微阶段,异常和严重异常数量较少,符合演化过程由轻到重逐步发展的特点。",
  433. )
  434. body_paragraph(
  435. doc,
  436. "润滑油压力标签以轻微阶段为主,异常和严重异常主要出现在持续下降过程中;"
  437. "振动标签在轻微、异常和严重异常三个阶段均有分布,说明振动异常既可能表现为短时冲击,"
  438. "也可能持续发展。",
  439. )
  440. body_paragraph(
  441. doc,
  442. "阅读详情时,应结合周期基线、相对变化、连续有效小时数、报警小时数和阶段触发依据,"
  443. "判断标签是单次波动还是持续演化,并结合现场维修记录进行确认。",
  444. )
  445. rows = [
  446. ["轻微", "相对基线偏离达到轻微门槛,持续时间较短或尚未形成连续确认。"],
  447. ["异常", "相对基线偏离扩大,连续有效小时数增加,形成异常阶段。"],
  448. ["严重异常", "达到严重门槛或触发低低报、高高报,应优先复核。"],
  449. ]
  450. add_table(doc, ["阶段", "含义"], rows, [4.0, 21.5], font_size=9.5)
  451. def add_detail_chapters(
  452. doc: Document,
  453. labels: list[dict],
  454. profile_dir: Path,
  455. force: bool,
  456. limit: int,
  457. single_image: str,
  458. ) -> None:
  459. chapters = (
  460. ("pressure", "二、润滑油压力故障标签详情", "润滑油压力"),
  461. ("vibration", "三、振动故障标签详情", "振动"),
  462. )
  463. for chapter_index, (kind, title, kind_label) in enumerate(chapters):
  464. items = sorted(
  465. [label for label in labels if label.get("kind") == kind],
  466. key=sort_key,
  467. )
  468. if limit:
  469. items = items[:limit]
  470. doc.add_page_break()
  471. doc.add_heading(title, level=1)
  472. stage_counts = Counter(item.get("stage") for item in items)
  473. body_paragraph(
  474. doc,
  475. f"本章共 {len(items)} 条{kind_label}故障标签,其中轻微 {stage_counts.get('轻微', 0)} 条、"
  476. f"异常 {stage_counts.get('异常', 0)} 条、严重异常 {stage_counts.get('严重异常', 0)} 条。"
  477. "章内按机组和开始时间排列;每个二级标题对应一条标签,标题下方为属性表。"
  478. "每条标签均附属性表和曲线图。",
  479. )
  480. for index, label in enumerate(items, start=1):
  481. print(f"[{kind} {index}/{len(items)}] {label['title']}")
  482. if index > 1:
  483. doc.add_page_break()
  484. heading = (
  485. f"{label['device']}-{label['kindLabel']}-{cycle_name(label)}-{label['start'][:16]}"
  486. )
  487. doc.add_heading(heading, level=2)
  488. add_attr_table(doc, label.get("fields") or [])
  489. if not single_image or label["key"] == single_image:
  490. image_path = screenshot(label, profile_dir, force=force)
  491. picture_paragraph = doc.add_paragraph()
  492. picture_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  493. picture_paragraph.paragraph_format.space_before = Pt(4)
  494. picture_paragraph.paragraph_format.space_after = Pt(2)
  495. picture_paragraph.paragraph_format.keep_with_next = True
  496. run = picture_paragraph.add_run()
  497. run.add_picture(str(image_path), width=Cm(24.0))
  498. caption = doc.add_paragraph()
  499. caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
  500. caption.paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
  501. caption.paragraph_format.space_after = Pt(6)
  502. run = caption.add_run("图:标签前后运行曲线(红色为故障段,竖向虚线为关机/开机边界)")
  503. set_run_font(run, BODY_FONT, 9.0)
  504. def main() -> None:
  505. parser = argparse.ArgumentParser()
  506. parser.add_argument("--limit", type=int, default=0, help="只生成前 N 条,用于冒烟测试")
  507. parser.add_argument("--output", type=Path, default=DEFAULT_DOCX)
  508. parser.add_argument("--force", action="store_true", help="强制重新截图")
  509. parser.add_argument(
  510. "--single-image",
  511. default="",
  512. help="只插入该标签的曲线图;传空字符串表示全部插入",
  513. )
  514. args = parser.parse_args()
  515. fetch_ready()
  516. labels = load_labels()
  517. print(f"标签总数:{len(labels)}")
  518. args.output.parent.mkdir(parents=True, exist_ok=True)
  519. profile_dir = Path(tempfile.mkdtemp(prefix="tag_manual_chrome_"))
  520. try:
  521. doc = Document()
  522. configure_document(doc)
  523. add_cover(doc, labels)
  524. add_reading_guide(doc)
  525. add_summary(doc, labels)
  526. add_detail_chapters(
  527. doc,
  528. labels,
  529. profile_dir,
  530. args.force,
  531. args.limit,
  532. args.single_image,
  533. )
  534. doc.save(args.output)
  535. print(f"已生成:{args.output}")
  536. print(f"截图目录:{FIG_DIR}")
  537. finally:
  538. shutil.rmtree(profile_dir, ignore_errors=True)
  539. if __name__ == "__main__":
  540. main()