| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """厂家振动报告 (docx) → 结构化提取 + 消费端格式的评估报告 (2026-09-12 用户令).
- ## 范围与诚实边界 (先读这段, 再看代码)
- 数据层「CMS 振动评估报告」的源件有两类, 处理方式**不同**:
- · CMS 原始测量导出 (Brande TCM `*_decode.json`) → 可重算, 走 scripts/vib_raw_build.py
- (索引/谱/六层链), 这一路是本目录下 `report_CMS振动状态评估报告_*.md` 的**正路**。
- · 厂家月度评估报告 (上海电气 12 份用印版 PDF + 2026年07月 docx; 大生科技传动链 docx)
- → **PDF 全部是扫描件** (2026-09-12 用 pypdf 抽检: 12 页 12 图, extract_text 长度 0),
- 没有 OCR 就取不出数值 ⇒ 只登记归档, 不进判级;
- **docx 有文本层**, 本脚本把里面的逐台判级**逐字转录**成结构化件与一份 report md。
- **转录纪律** (照抄本包既有做法, 防"看起来像算出来的"): 每个产物的 meta 里写清源文件+sha256+表头+行数;
- 取值一律来自报告原文, 不补不猜; 报告没给的列写 '—' 并在说明里点明"厂家未给"; 聚合量(如综合级)
- 必须标注它是**取严规则**得出的, 不是厂家判的。
- ## 输出的两份东西 (都在 outputs/<场>/ 下, 与既有消费者兼容)
- windcms/厂家报告提取_<报告期>.json 逐台原文 + provenance (给机器读/复核)
- windcms/报告_CMS振动状态评估报告_<报告期>.md
- 与 windcms 自产报告**同构**: `## 附录 A` 下 `| WTGxx | 主轴承前 | 主轴承后 | 齿轮箱 | 发电机 | 综合 | …`
- (消费者: src/windscada/subsys/fusion.py::windcms_grades 取第 6 列=综合,
- src/windscada/taxonomy.py 取第 2..5 列)。日期用**报告期**, 于是它不会顶掉更新的自产报告,
- 但随包自产报告缺失时它就是最新版 (从零重算的机器上正好用得上)。
- m5_cms_tcm/厂家报告提取_<报告期>.json + 报告_TCM传动链振动分析_<报告期>.md
- 大生科技 (TCM M-system 数据) 逐台状态等级/分析/建议, 原文透传; **不**冒充 handoff 判级。
- 用法:
- python scripts/vib_reports_build.py --dump # 只打印解析结果 (人工核对用, 不写盘)
- python scripts/vib_reports_build.py # 摄入并写产物
- """
- from __future__ import annotations
- import argparse
- import hashlib
- import json
- import pathlib
- import re
- import sys
- import time
- ROOT = pathlib.Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(ROOT))
- from src.console import soft # noqa: E402
- soft()
- STATE_ORDER = ['危险', '报警', '良好', '优秀', '不可判'] # 取严: 越靠前越严重 (报告用四级 + 不可判)
- NO_DATA_MARKS = ('--', '—', '无数据', '通讯中断', '无通讯')
- def sha256(p: pathlib.Path) -> str:
- h = hashlib.sha256()
- with open(p, 'rb') as f:
- for chunk in iter(lambda: f.read(1 << 20), b''):
- h.update(chunk)
- return h.hexdigest()[:16]
- def grid_of(table):
- """docx 表 → 二维网格, **正确还原合并单元格**。
- 为什么不能直接用 `row.cells`: python-docx 对合并区会把同一个 `tc` 在相邻列/相邻行重复给出 ——
- 照抄等于把 "优秀 | 优秀 | 优秀" 压成 "优秀", 列就对不齐了 (第一版手工 dump 就吃了这个亏:
- WTG-01 行显示成 `WTG-01 | 优秀 | 无`, 而实际是 主轴承=优秀 齿轮箱=优秀 发电机=优秀 结论=无)。
- 规则: 同一行里 tc 与左邻相同 = 横向合并 (沿用左值); tc 与上方同行同列相同 = 纵向合并 (沿用上值)。
- """
- ncol = len(table.columns)
- out, tc_above = [], [None] * ncol
- for row in table.rows:
- vals, tcs, prev = [], [], None
- for ci, cell in enumerate(row.cells):
- tc = cell._tc
- if tc is prev: # 横向合并的续格
- vals.append(vals[-1] if vals else '')
- tcs.append(tc)
- continue
- if tc is tc_above[ci] and ci < len(tc_above): # 纵向合并的续格
- vals.append(out[-1][ci] if out else '')
- else:
- vals.append(cell.text.strip().replace('\n', ' '))
- tcs.append(tc)
- prev = tc
- tc_above = tcs
- out.append(vals)
- return out
- def find_table(doc, must_have, header_hint=None):
- """按表头关键字找表 → (index, grid)。must_have: 表头行必须都含这些词。"""
- import docx
- for i, tb in enumerate(doc.tables):
- g = grid_of(tb)
- if g and all(any(k in c for c in g[0]) for k in must_have):
- return i, g
- return None, None
- def norm_turbine(s):
- m = re.search(r'WTG[\s_-]*0*(\d{1,2})', str(s).upper())
- return f'WTG{int(m.group(1)):02d}' if m else None
- def state_of(s):
- """文本 → 五级之一; 认不出给 '不可判' (宁可说不可判, 不冒充优秀)。"""
- t = str(s or '').strip()
- if any(k in t for k in NO_DATA_MARKS) or t == '':
- return '不可判'
- for v in ('危险', '报警', '预警', '良好', '优秀'):
- if v in t:
- return {'预警': '报警'}.get(v, v) # 上海电气四级里没有"预警", 大生科技有; 此处按四级归一
- if '异常' in t:
- return '不可判'
- return '不可判'
- def worst(*states):
- """取严 (综合列的口径; 厂家未给综合列时必须写明这是我们的聚合规则)。"""
- live = [s for s in states if s]
- return min(live, key=lambda s: STATE_ORDER.index(s) if s in STATE_ORDER else 99) if live else '不可判'
- def parse_sa(path: pathlib.Path):
- """上海电气月度振动分析报告: 表「机组号|主轴承|齿轮箱|发电机|诊断结论和维护建议」+ 总览句校验和。"""
- import docx
- d = docx.Document(str(path))
- ti, g = find_table(d, ['机组号', '主轴承', '齿轮箱'])
- if g is None:
- return None
- head = g[0]
- idx = {name: next((i for i, c in enumerate(head) if name in c), None)
- for name in ('机组号', '主轴承', '齿轮箱', '发电机', '诊断')}
- rows = []
- for r in g[1:]:
- t = norm_turbine(r[idx['机组号']] if idx['机组号'] is not None else '')
- if not t:
- continue
- rows.append(dict(turbine=t,
- 主轴承=state_of(r[idx['主轴承']]) if idx['主轴承'] is not None else '不可判',
- 齿轮箱=state_of(r[idx['齿轮箱']]) if idx['齿轮箱'] is not None else '不可判',
- 发电机=state_of(r[idx['发电机']]) if idx['发电机'] is not None else '不可判',
- 厂家结论=(r[idx['诊断']] if idx['诊断'] is not None and idx['诊断'] < len(r) else '')))
- # 总览句 = 报告的**自带校验和** (2026-07 报告原文: 优秀34 良好3 预警0 无数据1 测点异常1)。
- # ★它在**表格单元格**里, 不是段落 (第一版只扫 paragraphs → 拿到空串, 于是"校验和"形同虚设)。
- overview, sums = '', {}
- texts = [p.text for p in d.paragraphs]
- for tb in d.tables:
- for row in tb.rows:
- for c in row.cells:
- texts.append(c.text)
- for t in texts:
- if '检测结果' in t and '机组' in t and '台' in t:
- overview = t.strip()
- break
- for k in ('优秀', '良好', '预警', '无数据', '测点异常'):
- # 原话是"运行状态优秀机组34台" —— 等级词与数字之间夹着"机组"二字, 只写 `{k}\s*(\d+)` 会漏掉优秀
- m = re.search(rf'{k}[^0-9]{{0,6}}(\d+)\s*台', overview)
- if m:
- sums[k] = int(m.group(1))
- period = re.search(r'(20\d\d)\s*年\s*(\d{1,2})\s*月', path.name)
- # 报告期: 文件名 2026年07月 → 用**月末** (报告覆盖整个月; 用月初会让它比同类报告显得更新)
- import calendar
- if period:
- y, mo = int(period.group(1)), int(period.group(2))
- period = f'{y:04d}-{mo:02d}-{calendar.monthrange(y, mo)[1]:02d}'
- else:
- period = time.strftime('%Y-%m-%d')
- return dict(kind='上海电气月度', file=path.name, sha256=sha256(path), table_index=ti,
- header=[c for c in head], period=period, overview=overview, overview_counts=sums,
- rows=rows)
- def parse_ds(path: pathlib.Path):
- """大生科技传动链振动分析报告: 表「机组号|状态等级|分析与结论|建议」+ 数据窗 (报告正文)。"""
- import docx
- d = docx.Document(str(path))
- ti, g = find_table(d, ['机组号', '状态等级', '分析'])
- if g is None:
- return None
- head = g[0]
- idx = {name: next((i for i, c in enumerate(head) if name in c), None)
- for name in ('机组号', '状态等级', '分析', '建议')}
- rows = []
- for r in g[1:]:
- t = norm_turbine(r[idx['机组号']] if idx['机组号'] is not None else '')
- if not t:
- continue
- rows.append(dict(turbine=t,
- 状态等级=str(r[idx['状态等级']]).strip() if idx['状态等级'] is not None else '',
- 分析与结论=r[idx['分析']] if idx['分析'] is not None else '',
- 建议=r[idx['建议']] if idx['建议'] is not None else ''))
- txt = '\n'.join(p.text for p in d.paragraphs)
- # 数据窗: 正文原话 "本次分析采集了 A 至 B 期间的振动监测数据" (两个时间戳都要带时刻)
- win = re.search(r'(\d{4}年\d{1,2}月\d{1,2}日\s*\d{1,2}:\d{2}:\d{2})\s*至\s*'
- r'(\d{4}年\d{1,2}月\d{1,2}日\s*\d{1,2}:\d{2}:\d{2})', txt)
- rep = re.search(r'报告日期[::]\s*([\d.]{8,10})', txt)
- period = (rep.group(1).replace('.', '-') if rep else time.strftime('%Y-%m-%d'))
- if re.fullmatch(r'\d{4}-\d{1,2}-\d{1,2}', period):
- y, m, dd = period.split('-')
- period = f'{y}-{int(m):02d}-{int(dd):02d}'
- return dict(kind='大生科技传动链', file=path.name, sha256=sha256(path), table_index=ti,
- header=[c for c in head], period=period,
- data_window=(f'{win.group(1)} ~ {win.group(2)}' if win else '未标'),
- rows=rows)
- def md_sa(ext, src_note):
- """上海电气提取 → 与 windcms 自产报告同构的评估报告 (附录 A 表)。"""
- L = [f'# CMS 振动状态评估报告 (厂家件转录) {ext["period"]}', '',
- f'> 来源: `{ext["file"]}` (sha256 {ext["sha256"]}, 表 {ext["table_index"]}) —— {src_note}', '',
- f'> 报告原文总览: {ext["overview"] or "(未找到总览句)"}', '',
- '> ★ 本表是从**厂家月度振动分析报告**逐字转录的(上海电气, 依据 VDI3834 与 NB/T 31129-2018):',
- '> ① 厂家按「主轴承 / 齿轮箱 / 发电机」给级, **主轴承不分前后** —— 本表两列填同一值, 不是我们分的;',
- '> ② 厂家**未给综合列**, 本表「综合」= 三项**取严**(危险>报警>良好>优秀>不可判) 的聚合规则结果;',
- '> ③ 厂家未给「融合级/CMS 红黄/行动等级建议」, 一律写 `—` (不猜);',
- '> ④ 判级细节(谱/门槛)以厂家报告与 CMS 原始导出分析为准, 本表只承接"哪台什么级"。', '',
- '## 附录 A 38 台状态等级表(厂家报告转录,不代表确诊数量)', '',
- '| 机组号 | 主轴承前 | 主轴承后 | 齿轮箱 | 发电机 | 综合 | 融合级(模型) | 厂家结论与建议 |',
- '|---|---|---|---|---|---|---|---|']
- for r in sorted(ext['rows'], key=lambda x: x['turbine']):
- zh = worst(r['主轴承'], r['齿轮箱'], r['发电机'])
- note = (r['厂家结论'] or '—').replace('|', '/')
- L.append(f"| {r['turbine']} | {r['主轴承']} | {r['主轴承']} | {r['齿轮箱']} | {r['发电机']} | "
- f"{zh} | — | {note} |")
- L += ['', '## 说明事项', '',
- '1. 本件是**厂家报告的转录**, 不是观澜自算结果; 观澜自算报告 (CMS 原始导出 → 六层链) 见同目录 `报告_CMS振动状态评估报告_*.md`。',
- '2. 厂家报告里的「无数据/通讯中断/测点异常」在本表记为 `不可判` —— 没测到 ≠ 正常。',
- f'3. 报告期为 {ext["period"]} (文件名月份, 取月末)。', '']
- return '\n'.join(L)
- def md_ds(ext, src_note):
- L = [f'# TCM 传动链振动分析报告 (厂家件转录) {ext["period"]}', '',
- f'> 来源: `{ext["file"]}` (sha256 {ext["sha256"]}, 表 {ext["table_index"]}) —— {src_note}', '',
- f'> 数据窗: {ext["data_window"]} 报告日期: {ext["period"]}', '',
- '> ★ 原文透传: 状态等级/分析与结论/建议按厂家报告逐字记录。**本件不构成 handoff 判级** ——',
- '> handoff 的定谳链判级 (候选以上/证据窗) 是振动线接口件, 本报告只作 TCM 侧证据与交叉参考。', '',
- '| 机组 | 状态等级 | 分析与结论 | 建议 |', '|---|---|---|---|']
- for r in sorted(ext['rows'], key=lambda x: x['turbine']):
- cells = [r['turbine'], r['状态等级'], r['分析与结论'] or '—', r['建议'] or '—']
- L.append('| ' + ' | '.join(str(c).replace('|', '/') for c in cells) + ' |')
- L += ['', '## 说明事项', '',
- '1. 判据/图谱在厂家报告原文 (本包只落可解析的 docx; 若同批有 PDF 扫描件, 扫描件只归档不解析)。',
- '2. 状态等级为厂家四级口径 (优秀/良好/预警/报警) + "数据异常"(系统故障无通讯), 与观澜五级不是同一套, 消费时别直接比。', '']
- return '\n'.join(L)
- def main() -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument('--farm', default='rudong')
- ap.add_argument('--dump', action='store_true', help='只打印, 不写盘')
- a = ap.parse_args()
- from src.windscada.config import raw_station_dir
- from src import paths as P
- station = pathlib.Path(raw_station_dir(a.farm))
- sa_dir = station / 'windcms' / '厂家报告' / '上海电气_月度'
- ds_dir = station / 'm5_cms_tcm' / '厂家报告'
- out_cms = P.cms(a.farm)
- out_m5 = P.m5(a.farm)
- sa_files = sorted(sa_dir.glob('*.docx')) if sa_dir.is_dir() else []
- ds_files = sorted(ds_dir.glob('*.docx')) if ds_dir.is_dir() else []
- print(f'上海电气 docx: {len(sa_files)} 件 {sa_dir}')
- for p in sa_files:
- print(f' {p.name} {p.stat().st_size / 1048576:.2f} MB')
- print(f'大生科技 docx: {len(ds_files)} 件 {ds_dir}')
- for p in ds_files:
- print(f' {p.name} {p.stat().st_size / 1048576:.2f} MB')
- pdfs = sorted(sa_dir.glob('*.pdf')) if sa_dir.is_dir() else []
- if pdfs:
- print(f'扫描件 PDF: {len(pdfs)} 件 (登记归档, 不解析)')
- if not sa_files and not ds_files:
- print('没有可解析的 docx 厂家报告 (只有扫描件 PDF 属正常) → 空跑退出')
- return 0
- written = []
- for p in sa_files:
- ext = parse_sa(p)
- if not ext:
- print(f'⚠ {p.name}: 没找到"机组检测结果列表"表 (跳过, 不猜)')
- continue
- zh = [worst(r['主轴承'], r['齿轮箱'], r['发电机']) for r in ext['rows']]
- print(f'\n== {p.name} ({ext["kind"]}, 表{ext["table_index"]}) ==')
- print(f' 报告期 {ext["period"]} 台数 {len(ext["rows"])} 原文总览: {ext["overview"]}')
- from collections import Counter
- got = Counter(zh)
- print(f' 转录结果取严分布: {dict(got)}')
- print(f' 厂家总览句: {ext["overview_counts"]}')
- if a.dump:
- for r in ext['rows'][:6]:
- print(' ', r)
- continue
- out_cms.mkdir(parents=True, exist_ok=True)
- jp = out_cms / f'厂家报告提取_{ext["period"]}.json'
- jp.write_text(json.dumps(ext, ensure_ascii=False, indent=1), encoding='utf-8')
- mp = out_cms / f'报告_CMS振动状态评估报告_{ext["period"]}.md'
- mp.write_text(md_sa(ext, '现场包 厂家月度评估报告'), encoding='utf-8')
- written += [jp, mp]
- print(f' → {jp.name}\n → {mp.name}')
- for p in ds_files:
- ext = parse_ds(p)
- if not ext:
- print(f'⚠ {p.name}: 没找到"信号分析与结论描述"表 (跳过, 不猜)')
- continue
- print(f'\n== {p.name} ({ext["kind"]}, 表{ext["table_index"]}) ==')
- print(f' 报告期 {ext["period"]} 数据窗 {ext["data_window"]} 台数 {len(ext["rows"])}')
- from collections import Counter
- print(f' 状态等级分布: {dict(Counter(r["状态等级"] for r in ext["rows"]))}')
- if a.dump:
- for r in ext['rows'][:4]:
- print(' ', {k: (v[:60] + '…' if isinstance(v, str) and len(v) > 60 else v) for k, v in r.items()})
- continue
- out_m5.mkdir(parents=True, exist_ok=True)
- jp = out_m5 / f'厂家报告提取_{ext["period"]}.json'
- jp.write_text(json.dumps(ext, ensure_ascii=False, indent=1), encoding='utf-8')
- mp = out_m5 / f'报告_TCM传动链振动分析_{ext["period"]}.md'
- mp.write_text(md_ds(ext, '现场包 大生科技 TCM 传动链分析报告 (docx)'), encoding='utf-8')
- written += [jp, mp]
- print(f' → {jp.name}\n → {mp.name}')
- if not a.dump and pdfs:
- reg = dict(note='扫描件登记: 无文本层 → 只归档, 数值不进系统; 需数值须 OCR 或取厂家电子件',
- verified='2026-09-12 用 pypdf 抽检一件: 12 页 / 每页 1 图 / extract_text() 长度 0',
- files=[dict(name=q.name, bytes=q.stat().st_size, sha256=sha256(q)) for q in pdfs])
- rp = sa_dir / '_扫描件清单.json'
- rp.write_text(json.dumps(reg, ensure_ascii=False, indent=1), encoding='utf-8')
- written.append(rp)
- print(f'\n扫描件登记 → {rp}')
- # 来源自登记 (见 src/derived_manifest.py): 转录件也是"从 data/raw 来的", 台账要记 raw-derived
- if not a.dump and written:
- try:
- from src.derived_manifest import record as _rec
- from src import paths as _P
- rels = {}
- for p in written:
- if p.suffix.lower() not in ('.json', '.md') or '_扫描件清单' in p.name:
- continue
- if p.parent == out_cms:
- rels[f'windcms/{p.name}'] = 'scripts/vib_reports_build.py (厂家 docx 报告逐台转录)'
- elif p.parent == out_m5:
- rels[f'm5_cms_tcm/{p.name}'] = 'scripts/vib_reports_build.py (厂家 docx 报告逐台转录)'
- if rels:
- _rec(_P.out_root(a.farm), rels, by='scripts/vib_reports_build.py')
- print(f'自登记 {len(rels)} 件 → {_P.out_root(a.farm) / "_derived_manifest.json"}')
- except Exception as _e:
- print(f'⚠ 来源自登记失败 ({type(_e).__name__}: {_e})')
- print(f'\n完成: 写出 {len(written)} 件')
- return 0
- if __name__ == '__main__':
- sys.exit(main())
|