vib_reports_build.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """厂家振动报告 (docx) → 结构化提取 + 消费端格式的评估报告 (2026-09-12 用户令).
  4. ## 范围与诚实边界 (先读这段, 再看代码)
  5. 数据层「CMS 振动评估报告」的源件有两类, 处理方式**不同**:
  6. · CMS 原始测量导出 (Brande TCM `*_decode.json`) → 可重算, 走 scripts/vib_raw_build.py
  7. (索引/谱/六层链), 这一路是本目录下 `report_CMS振动状态评估报告_*.md` 的**正路**。
  8. · 厂家月度评估报告 (上海电气 12 份用印版 PDF + 2026年07月 docx; 大生科技传动链 docx)
  9. → **PDF 全部是扫描件** (2026-09-12 用 pypdf 抽检: 12 页 12 图, extract_text 长度 0),
  10. 没有 OCR 就取不出数值 ⇒ 只登记归档, 不进判级;
  11. **docx 有文本层**, 本脚本把里面的逐台判级**逐字转录**成结构化件与一份 report md。
  12. **转录纪律** (照抄本包既有做法, 防"看起来像算出来的"): 每个产物的 meta 里写清源文件+sha256+表头+行数;
  13. 取值一律来自报告原文, 不补不猜; 报告没给的列写 '—' 并在说明里点明"厂家未给"; 聚合量(如综合级)
  14. 必须标注它是**取严规则**得出的, 不是厂家判的。
  15. ## 输出的两份东西 (都在 outputs/<场>/ 下, 与既有消费者兼容)
  16. windcms/厂家报告提取_<报告期>.json 逐台原文 + provenance (给机器读/复核)
  17. windcms/报告_CMS振动状态评估报告_<报告期>.md
  18. 与 windcms 自产报告**同构**: `## 附录 A` 下 `| WTGxx | 主轴承前 | 主轴承后 | 齿轮箱 | 发电机 | 综合 | …`
  19. (消费者: src/windscada/subsys/fusion.py::windcms_grades 取第 6 列=综合,
  20. src/windscada/taxonomy.py 取第 2..5 列)。日期用**报告期**, 于是它不会顶掉更新的自产报告,
  21. 但随包自产报告缺失时它就是最新版 (从零重算的机器上正好用得上)。
  22. m5_cms_tcm/厂家报告提取_<报告期>.json + 报告_TCM传动链振动分析_<报告期>.md
  23. 大生科技 (TCM M-system 数据) 逐台状态等级/分析/建议, 原文透传; **不**冒充 handoff 判级。
  24. 用法:
  25. python scripts/vib_reports_build.py --dump # 只打印解析结果 (人工核对用, 不写盘)
  26. python scripts/vib_reports_build.py # 摄入并写产物
  27. """
  28. from __future__ import annotations
  29. import argparse
  30. import hashlib
  31. import json
  32. import pathlib
  33. import re
  34. import sys
  35. import time
  36. ROOT = pathlib.Path(__file__).resolve().parents[1]
  37. sys.path.insert(0, str(ROOT))
  38. from src.console import soft # noqa: E402
  39. soft()
  40. STATE_ORDER = ['危险', '报警', '良好', '优秀', '不可判'] # 取严: 越靠前越严重 (报告用四级 + 不可判)
  41. NO_DATA_MARKS = ('--', '—', '无数据', '通讯中断', '无通讯')
  42. def sha256(p: pathlib.Path) -> str:
  43. h = hashlib.sha256()
  44. with open(p, 'rb') as f:
  45. for chunk in iter(lambda: f.read(1 << 20), b''):
  46. h.update(chunk)
  47. return h.hexdigest()[:16]
  48. def grid_of(table):
  49. """docx 表 → 二维网格, **正确还原合并单元格**。
  50. 为什么不能直接用 `row.cells`: python-docx 对合并区会把同一个 `tc` 在相邻列/相邻行重复给出 ——
  51. 照抄等于把 "优秀 | 优秀 | 优秀" 压成 "优秀", 列就对不齐了 (第一版手工 dump 就吃了这个亏:
  52. WTG-01 行显示成 `WTG-01 | 优秀 | 无`, 而实际是 主轴承=优秀 齿轮箱=优秀 发电机=优秀 结论=无)。
  53. 规则: 同一行里 tc 与左邻相同 = 横向合并 (沿用左值); tc 与上方同行同列相同 = 纵向合并 (沿用上值)。
  54. """
  55. ncol = len(table.columns)
  56. out, tc_above = [], [None] * ncol
  57. for row in table.rows:
  58. vals, tcs, prev = [], [], None
  59. for ci, cell in enumerate(row.cells):
  60. tc = cell._tc
  61. if tc is prev: # 横向合并的续格
  62. vals.append(vals[-1] if vals else '')
  63. tcs.append(tc)
  64. continue
  65. if tc is tc_above[ci] and ci < len(tc_above): # 纵向合并的续格
  66. vals.append(out[-1][ci] if out else '')
  67. else:
  68. vals.append(cell.text.strip().replace('\n', ' '))
  69. tcs.append(tc)
  70. prev = tc
  71. tc_above = tcs
  72. out.append(vals)
  73. return out
  74. def find_table(doc, must_have, header_hint=None):
  75. """按表头关键字找表 → (index, grid)。must_have: 表头行必须都含这些词。"""
  76. import docx
  77. for i, tb in enumerate(doc.tables):
  78. g = grid_of(tb)
  79. if g and all(any(k in c for c in g[0]) for k in must_have):
  80. return i, g
  81. return None, None
  82. def norm_turbine(s):
  83. m = re.search(r'WTG[\s_-]*0*(\d{1,2})', str(s).upper())
  84. return f'WTG{int(m.group(1)):02d}' if m else None
  85. def state_of(s):
  86. """文本 → 五级之一; 认不出给 '不可判' (宁可说不可判, 不冒充优秀)。"""
  87. t = str(s or '').strip()
  88. if any(k in t for k in NO_DATA_MARKS) or t == '':
  89. return '不可判'
  90. for v in ('危险', '报警', '预警', '良好', '优秀'):
  91. if v in t:
  92. return {'预警': '报警'}.get(v, v) # 上海电气四级里没有"预警", 大生科技有; 此处按四级归一
  93. if '异常' in t:
  94. return '不可判'
  95. return '不可判'
  96. def worst(*states):
  97. """取严 (综合列的口径; 厂家未给综合列时必须写明这是我们的聚合规则)。"""
  98. live = [s for s in states if s]
  99. return min(live, key=lambda s: STATE_ORDER.index(s) if s in STATE_ORDER else 99) if live else '不可判'
  100. def parse_sa(path: pathlib.Path):
  101. """上海电气月度振动分析报告: 表「机组号|主轴承|齿轮箱|发电机|诊断结论和维护建议」+ 总览句校验和。"""
  102. import docx
  103. d = docx.Document(str(path))
  104. ti, g = find_table(d, ['机组号', '主轴承', '齿轮箱'])
  105. if g is None:
  106. return None
  107. head = g[0]
  108. idx = {name: next((i for i, c in enumerate(head) if name in c), None)
  109. for name in ('机组号', '主轴承', '齿轮箱', '发电机', '诊断')}
  110. rows = []
  111. for r in g[1:]:
  112. t = norm_turbine(r[idx['机组号']] if idx['机组号'] is not None else '')
  113. if not t:
  114. continue
  115. rows.append(dict(turbine=t,
  116. 主轴承=state_of(r[idx['主轴承']]) if idx['主轴承'] is not None else '不可判',
  117. 齿轮箱=state_of(r[idx['齿轮箱']]) if idx['齿轮箱'] is not None else '不可判',
  118. 发电机=state_of(r[idx['发电机']]) if idx['发电机'] is not None else '不可判',
  119. 厂家结论=(r[idx['诊断']] if idx['诊断'] is not None and idx['诊断'] < len(r) else '')))
  120. # 总览句 = 报告的**自带校验和** (2026-07 报告原文: 优秀34 良好3 预警0 无数据1 测点异常1)。
  121. # ★它在**表格单元格**里, 不是段落 (第一版只扫 paragraphs → 拿到空串, 于是"校验和"形同虚设)。
  122. overview, sums = '', {}
  123. texts = [p.text for p in d.paragraphs]
  124. for tb in d.tables:
  125. for row in tb.rows:
  126. for c in row.cells:
  127. texts.append(c.text)
  128. for t in texts:
  129. if '检测结果' in t and '机组' in t and '台' in t:
  130. overview = t.strip()
  131. break
  132. for k in ('优秀', '良好', '预警', '无数据', '测点异常'):
  133. # 原话是"运行状态优秀机组34台" —— 等级词与数字之间夹着"机组"二字, 只写 `{k}\s*(\d+)` 会漏掉优秀
  134. m = re.search(rf'{k}[^0-9]{{0,6}}(\d+)\s*台', overview)
  135. if m:
  136. sums[k] = int(m.group(1))
  137. period = re.search(r'(20\d\d)\s*年\s*(\d{1,2})\s*月', path.name)
  138. # 报告期: 文件名 2026年07月 → 用**月末** (报告覆盖整个月; 用月初会让它比同类报告显得更新)
  139. import calendar
  140. if period:
  141. y, mo = int(period.group(1)), int(period.group(2))
  142. period = f'{y:04d}-{mo:02d}-{calendar.monthrange(y, mo)[1]:02d}'
  143. else:
  144. period = time.strftime('%Y-%m-%d')
  145. return dict(kind='上海电气月度', file=path.name, sha256=sha256(path), table_index=ti,
  146. header=[c for c in head], period=period, overview=overview, overview_counts=sums,
  147. rows=rows)
  148. def parse_ds(path: pathlib.Path):
  149. """大生科技传动链振动分析报告: 表「机组号|状态等级|分析与结论|建议」+ 数据窗 (报告正文)。"""
  150. import docx
  151. d = docx.Document(str(path))
  152. ti, g = find_table(d, ['机组号', '状态等级', '分析'])
  153. if g is None:
  154. return None
  155. head = g[0]
  156. idx = {name: next((i for i, c in enumerate(head) if name in c), None)
  157. for name in ('机组号', '状态等级', '分析', '建议')}
  158. rows = []
  159. for r in g[1:]:
  160. t = norm_turbine(r[idx['机组号']] if idx['机组号'] is not None else '')
  161. if not t:
  162. continue
  163. rows.append(dict(turbine=t,
  164. 状态等级=str(r[idx['状态等级']]).strip() if idx['状态等级'] is not None else '',
  165. 分析与结论=r[idx['分析']] if idx['分析'] is not None else '',
  166. 建议=r[idx['建议']] if idx['建议'] is not None else ''))
  167. txt = '\n'.join(p.text for p in d.paragraphs)
  168. # 数据窗: 正文原话 "本次分析采集了 A 至 B 期间的振动监测数据" (两个时间戳都要带时刻)
  169. win = re.search(r'(\d{4}年\d{1,2}月\d{1,2}日\s*\d{1,2}:\d{2}:\d{2})\s*至\s*'
  170. r'(\d{4}年\d{1,2}月\d{1,2}日\s*\d{1,2}:\d{2}:\d{2})', txt)
  171. rep = re.search(r'报告日期[::]\s*([\d.]{8,10})', txt)
  172. period = (rep.group(1).replace('.', '-') if rep else time.strftime('%Y-%m-%d'))
  173. if re.fullmatch(r'\d{4}-\d{1,2}-\d{1,2}', period):
  174. y, m, dd = period.split('-')
  175. period = f'{y}-{int(m):02d}-{int(dd):02d}'
  176. return dict(kind='大生科技传动链', file=path.name, sha256=sha256(path), table_index=ti,
  177. header=[c for c in head], period=period,
  178. data_window=(f'{win.group(1)} ~ {win.group(2)}' if win else '未标'),
  179. rows=rows)
  180. def md_sa(ext, src_note):
  181. """上海电气提取 → 与 windcms 自产报告同构的评估报告 (附录 A 表)。"""
  182. L = [f'# CMS 振动状态评估报告 (厂家件转录) {ext["period"]}', '',
  183. f'> 来源: `{ext["file"]}` (sha256 {ext["sha256"]}, 表 {ext["table_index"]}) —— {src_note}', '',
  184. f'> 报告原文总览: {ext["overview"] or "(未找到总览句)"}', '',
  185. '> ★ 本表是从**厂家月度振动分析报告**逐字转录的(上海电气, 依据 VDI3834 与 NB/T 31129-2018):',
  186. '> ① 厂家按「主轴承 / 齿轮箱 / 发电机」给级, **主轴承不分前后** —— 本表两列填同一值, 不是我们分的;',
  187. '> ② 厂家**未给综合列**, 本表「综合」= 三项**取严**(危险>报警>良好>优秀>不可判) 的聚合规则结果;',
  188. '> ③ 厂家未给「融合级/CMS 红黄/行动等级建议」, 一律写 `—` (不猜);',
  189. '> ④ 判级细节(谱/门槛)以厂家报告与 CMS 原始导出分析为准, 本表只承接"哪台什么级"。', '',
  190. '## 附录 A 38 台状态等级表(厂家报告转录,不代表确诊数量)', '',
  191. '| 机组号 | 主轴承前 | 主轴承后 | 齿轮箱 | 发电机 | 综合 | 融合级(模型) | 厂家结论与建议 |',
  192. '|---|---|---|---|---|---|---|---|']
  193. for r in sorted(ext['rows'], key=lambda x: x['turbine']):
  194. zh = worst(r['主轴承'], r['齿轮箱'], r['发电机'])
  195. note = (r['厂家结论'] or '—').replace('|', '/')
  196. L.append(f"| {r['turbine']} | {r['主轴承']} | {r['主轴承']} | {r['齿轮箱']} | {r['发电机']} | "
  197. f"{zh} | — | {note} |")
  198. L += ['', '## 说明事项', '',
  199. '1. 本件是**厂家报告的转录**, 不是观澜自算结果; 观澜自算报告 (CMS 原始导出 → 六层链) 见同目录 `报告_CMS振动状态评估报告_*.md`。',
  200. '2. 厂家报告里的「无数据/通讯中断/测点异常」在本表记为 `不可判` —— 没测到 ≠ 正常。',
  201. f'3. 报告期为 {ext["period"]} (文件名月份, 取月末)。', '']
  202. return '\n'.join(L)
  203. def md_ds(ext, src_note):
  204. L = [f'# TCM 传动链振动分析报告 (厂家件转录) {ext["period"]}', '',
  205. f'> 来源: `{ext["file"]}` (sha256 {ext["sha256"]}, 表 {ext["table_index"]}) —— {src_note}', '',
  206. f'> 数据窗: {ext["data_window"]} 报告日期: {ext["period"]}', '',
  207. '> ★ 原文透传: 状态等级/分析与结论/建议按厂家报告逐字记录。**本件不构成 handoff 判级** ——',
  208. '> handoff 的定谳链判级 (候选以上/证据窗) 是振动线接口件, 本报告只作 TCM 侧证据与交叉参考。', '',
  209. '| 机组 | 状态等级 | 分析与结论 | 建议 |', '|---|---|---|---|']
  210. for r in sorted(ext['rows'], key=lambda x: x['turbine']):
  211. cells = [r['turbine'], r['状态等级'], r['分析与结论'] or '—', r['建议'] or '—']
  212. L.append('| ' + ' | '.join(str(c).replace('|', '/') for c in cells) + ' |')
  213. L += ['', '## 说明事项', '',
  214. '1. 判据/图谱在厂家报告原文 (本包只落可解析的 docx; 若同批有 PDF 扫描件, 扫描件只归档不解析)。',
  215. '2. 状态等级为厂家四级口径 (优秀/良好/预警/报警) + "数据异常"(系统故障无通讯), 与观澜五级不是同一套, 消费时别直接比。', '']
  216. return '\n'.join(L)
  217. def main() -> int:
  218. ap = argparse.ArgumentParser()
  219. ap.add_argument('--farm', default='rudong')
  220. ap.add_argument('--dump', action='store_true', help='只打印, 不写盘')
  221. a = ap.parse_args()
  222. from src.windscada.config import raw_station_dir
  223. from src import paths as P
  224. station = pathlib.Path(raw_station_dir(a.farm))
  225. sa_dir = station / 'windcms' / '厂家报告' / '上海电气_月度'
  226. ds_dir = station / 'm5_cms_tcm' / '厂家报告'
  227. out_cms = P.cms(a.farm)
  228. out_m5 = P.m5(a.farm)
  229. sa_files = sorted(sa_dir.glob('*.docx')) if sa_dir.is_dir() else []
  230. ds_files = sorted(ds_dir.glob('*.docx')) if ds_dir.is_dir() else []
  231. print(f'上海电气 docx: {len(sa_files)} 件 {sa_dir}')
  232. for p in sa_files:
  233. print(f' {p.name} {p.stat().st_size / 1048576:.2f} MB')
  234. print(f'大生科技 docx: {len(ds_files)} 件 {ds_dir}')
  235. for p in ds_files:
  236. print(f' {p.name} {p.stat().st_size / 1048576:.2f} MB')
  237. pdfs = sorted(sa_dir.glob('*.pdf')) if sa_dir.is_dir() else []
  238. if pdfs:
  239. print(f'扫描件 PDF: {len(pdfs)} 件 (登记归档, 不解析)')
  240. if not sa_files and not ds_files:
  241. print('没有可解析的 docx 厂家报告 (只有扫描件 PDF 属正常) → 空跑退出')
  242. return 0
  243. written = []
  244. for p in sa_files:
  245. ext = parse_sa(p)
  246. if not ext:
  247. print(f'⚠ {p.name}: 没找到"机组检测结果列表"表 (跳过, 不猜)')
  248. continue
  249. zh = [worst(r['主轴承'], r['齿轮箱'], r['发电机']) for r in ext['rows']]
  250. print(f'\n== {p.name} ({ext["kind"]}, 表{ext["table_index"]}) ==')
  251. print(f' 报告期 {ext["period"]} 台数 {len(ext["rows"])} 原文总览: {ext["overview"]}')
  252. from collections import Counter
  253. got = Counter(zh)
  254. print(f' 转录结果取严分布: {dict(got)}')
  255. print(f' 厂家总览句: {ext["overview_counts"]}')
  256. if a.dump:
  257. for r in ext['rows'][:6]:
  258. print(' ', r)
  259. continue
  260. out_cms.mkdir(parents=True, exist_ok=True)
  261. jp = out_cms / f'厂家报告提取_{ext["period"]}.json'
  262. jp.write_text(json.dumps(ext, ensure_ascii=False, indent=1), encoding='utf-8')
  263. mp = out_cms / f'报告_CMS振动状态评估报告_{ext["period"]}.md'
  264. mp.write_text(md_sa(ext, '现场包 厂家月度评估报告'), encoding='utf-8')
  265. written += [jp, mp]
  266. print(f' → {jp.name}\n → {mp.name}')
  267. for p in ds_files:
  268. ext = parse_ds(p)
  269. if not ext:
  270. print(f'⚠ {p.name}: 没找到"信号分析与结论描述"表 (跳过, 不猜)')
  271. continue
  272. print(f'\n== {p.name} ({ext["kind"]}, 表{ext["table_index"]}) ==')
  273. print(f' 报告期 {ext["period"]} 数据窗 {ext["data_window"]} 台数 {len(ext["rows"])}')
  274. from collections import Counter
  275. print(f' 状态等级分布: {dict(Counter(r["状态等级"] for r in ext["rows"]))}')
  276. if a.dump:
  277. for r in ext['rows'][:4]:
  278. print(' ', {k: (v[:60] + '…' if isinstance(v, str) and len(v) > 60 else v) for k, v in r.items()})
  279. continue
  280. out_m5.mkdir(parents=True, exist_ok=True)
  281. jp = out_m5 / f'厂家报告提取_{ext["period"]}.json'
  282. jp.write_text(json.dumps(ext, ensure_ascii=False, indent=1), encoding='utf-8')
  283. mp = out_m5 / f'报告_TCM传动链振动分析_{ext["period"]}.md'
  284. mp.write_text(md_ds(ext, '现场包 大生科技 TCM 传动链分析报告 (docx)'), encoding='utf-8')
  285. written += [jp, mp]
  286. print(f' → {jp.name}\n → {mp.name}')
  287. if not a.dump and pdfs:
  288. reg = dict(note='扫描件登记: 无文本层 → 只归档, 数值不进系统; 需数值须 OCR 或取厂家电子件',
  289. verified='2026-09-12 用 pypdf 抽检一件: 12 页 / 每页 1 图 / extract_text() 长度 0',
  290. files=[dict(name=q.name, bytes=q.stat().st_size, sha256=sha256(q)) for q in pdfs])
  291. rp = sa_dir / '_扫描件清单.json'
  292. rp.write_text(json.dumps(reg, ensure_ascii=False, indent=1), encoding='utf-8')
  293. written.append(rp)
  294. print(f'\n扫描件登记 → {rp}')
  295. # 来源自登记 (见 src/derived_manifest.py): 转录件也是"从 data/raw 来的", 台账要记 raw-derived
  296. if not a.dump and written:
  297. try:
  298. from src.derived_manifest import record as _rec
  299. from src import paths as _P
  300. rels = {}
  301. for p in written:
  302. if p.suffix.lower() not in ('.json', '.md') or '_扫描件清单' in p.name:
  303. continue
  304. if p.parent == out_cms:
  305. rels[f'windcms/{p.name}'] = 'scripts/vib_reports_build.py (厂家 docx 报告逐台转录)'
  306. elif p.parent == out_m5:
  307. rels[f'm5_cms_tcm/{p.name}'] = 'scripts/vib_reports_build.py (厂家 docx 报告逐台转录)'
  308. if rels:
  309. _rec(_P.out_root(a.farm), rels, by='scripts/vib_reports_build.py')
  310. print(f'自登记 {len(rels)} 件 → {_P.out_root(a.farm) / "_derived_manifest.json"}')
  311. except Exception as _e:
  312. print(f'⚠ 来源自登记失败 ({type(_e).__name__}: {_e})')
  313. print(f'\n完成: 写出 {len(written)} 件')
  314. return 0
  315. if __name__ == '__main__':
  316. sys.exit(main())