report_export.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. # -*- coding: utf-8 -*-
  2. """报告导出 Word / Excel (2026-08-28 用户令: 导出 demo)。
  3. 汇报画布选中的块 → .docx / .xlsx。图表块不丢信息: 转成对应的数据表 —
  4. Word 里放位图既不可编辑也不能核对, 上报集团要的是能核的数字。
  5. 脱敏跟随受众设置 (上报版沿用服务端 _redact 的规则)。
  6. """
  7. import io, datetime as dt
  8. TITLE = '如东海上风电场 · 运行分析报告'
  9. def _fmt(v, n=0):
  10. try:
  11. return f'{float(v):,.{n}f}'
  12. except Exception:
  13. return str(v)
  14. def blocks_data(fl, blocks, narr=None):
  15. """把画布块转成 [(标题, 段落[], 表格{head, rows})] — Word/Excel 共用同一份中间表示。"""
  16. E = (fl.get('control') or {}).get('energy') or {}
  17. R = fl.get('rel') or {}
  18. M = ((fl.get('m8') or {}).get('mtbf')) or {}
  19. F = fl.get('faults') or {}
  20. FU = fl.get('fus') or {}
  21. BD = FU.get('链盘') or {}
  22. C = fl.get('control') or {}
  23. out = []
  24. _cur = [''] # ★P101 当前块键(供图形使用)
  25. def add(t, paras=None, head=None, rows=None):
  26. out.append(dict(k=_cur[0], title=t, paras=paras or [], head=head, rows=rows or []))
  27. for k in blocks:
  28. _cur[0] = k
  29. if k == 'kpi':
  30. add('一、核心指标', head=['指标', '数值', '口径'], rows=[
  31. ['上网电量', f"{_fmt(E.get('act'))} MWh", f"理论可发 {_fmt(E.get('theo'))} − 损失 {_fmt(E.get('loss'))}"],
  32. ['等效满发小时', f"{_fmt(E.get('eflh'))} h", '上网电量 ÷ (台数×额定容量)'],
  33. ['时间可用率', f"{C.get('avail')}%", '分母已剔调度令与低风待机'],
  34. ['损失率', f"{E.get('loss_pct')}%", '损失 ÷ 理论可发'],
  35. ['平均停机间隔', f"{M.get('MTBF_h')} h", f"停机 {M.get('停机事件数')} 起, 单次均 {M.get('MDT_h')} h; 分母=窗内实测台时"],
  36. ['需行动机组', f"{len(BD.get('rows') or [])} 台", f"四源融合判级; 定论 {(FU.get('kpi') or {}).get('定论')} · 预警 {(FU.get('kpi') or {}).get('预警')} · 候选 {(FU.get('kpi') or {}).get('候选观察')}"],
  37. ])
  38. elif k == 'water':
  39. rows = [['理论可发', _fmt(E.get('theo')), '100%']]
  40. for x in (E.get('items') or []):
  41. rows.append([x['k'], '−' + _fmt(x['loss']), f"{x['loss']/max(E.get('theo') or 1,1)*100:.1f}%"])
  42. rows.append(['实际上网', _fmt(E.get('act')), f"{(E.get('act') or 0)/max(E.get('theo') or 1,1)*100:.1f}%"])
  43. add('二、发电完成情况', [f"理论可发 {_fmt(E.get('theo'))} MWh = 上网 {_fmt(E.get('act'))} + 损失 {_fmt(E.get('loss'))} MWh, 损失率 {E.get('loss_pct')}%。"],
  44. ['项目', 'MWh', '占理论可发'], rows)
  45. elif k == 'emon':
  46. add('逐月发电', head=['月份', '上网 MWh', '损失 MWh', '损失率', '等效满发 h', '数据覆盖'],
  47. rows=[[x['m'], _fmt(x['act']), _fmt(x['loss']), f"{x['loss_pct']}%", _fmt(x['eflh']),
  48. f"{x['cov']*100:.0f}%" + ('(不满月)' if x['cov'] < 0.5 else '')] for x in (E.get('monthly') or [])])
  49. elif k == 'blame':
  50. cat = lambda kk: '交易与调度侧' if ('限电' in kk or '调度' in kk) else ('设备侧' if kk == '停机' else '运行侧')
  51. g = {}
  52. for x in (E.get('items') or []):
  53. g[cat(x['k'])] = g.get(cat(x['k']), 0) + x['loss']
  54. tot = sum(g.values()) or 1
  55. add('损失责任分账', ['进设备考核的仅"故障与检修停机"; 限电属交易与调度侧。'],
  56. ['责任方', '损失 MWh', '占比'], [[a, _fmt(b), f'{b/tot*100:.0f}%'] for a, b in sorted(g.items(), key=lambda x: -x[1])])
  57. elif k in ('ram', 'parts') and (R.get('表')):
  58. add('三、设备可靠性' if k == 'ram' else '部件停机明细',
  59. [f"总停机 {_fmt(R.get('总停机h'))} h = 设备类 {_fmt(R.get('设备类停机h'))} + 外部类 {_fmt(R.get('外部类停机h'))} (电网/风况/运维操作不计设备可靠性)。"],
  60. ['部件', '停机 h', '事件数', 'MTBF h', 'MDT h', '影响台数'],
  61. [[x['部件'] + ('(外部)' if x['外部'] else ''), _fmt(x['停机时长h']), x['事件数'],
  62. x['MTBF_h'] or '—', x['MDT_h'], x['影响台数']] for x in (R.get('表') or [])])
  63. elif k == 'attrib' and R.get('表'):
  64. nr = next((x['停机时长h'] for x in R['表'] if x['部件'] == '无记录停机'), 0)
  65. add('归因覆盖', [f"无记录停机 {_fmt(nr)} h, 占总停机 "
  66. f"{nr/max(R.get('总停机h') or 1,1)*100:.1f}% — 属归因缺口, 非无故障; 成因待定。"])
  67. elif k == 'pareto':
  68. add('四、故障统计', [f"窗内 {_fmt(F.get('total'))} 条报警 / {F.get('n_codes')} 个码。"],
  69. ['报警码与名称', '条数'], [[x['k'], _fmt(x['v'])] for x in (F.get('pareto_n') or [])])
  70. elif k == 'quad':
  71. add('故障模式分型', ['条数高不等于问题严重: 单次时长在分钟级以下的属重复触发, 不应按条数排定优先级。'],
  72. ['码', '名称', '条数', '单次中位 s', '影响台数'],
  73. [[x['code'], x['k'], _fmt(x['n']), x['med'], x['nt']] for x in (F.get('quad') or [])[:12]])
  74. elif k == 'frate':
  75. mo = F.get('monthly') or {}
  76. add('月度故障率', ['按天归一, 数据不满月单独标注。'], ['月份', '条数', '条/日', '数据覆盖'],
  77. [[m, _fmt(v), (mo.get('per_day') or [None])[i] if i < len(mo.get('per_day') or []) else '—',
  78. f"{(mo.get('cov') or [1])[i]*100:.0f}%" if i < len(mo.get('cov') or []) else '—']
  79. for i, (m, v) in enumerate(zip(mo.get('months') or [], mo.get('vals') or []))])
  80. elif k == 'units':
  81. add('五、重点机组与处置',
  82. [f"需跟踪 {len(BD.get('rows') or [])} 台 / 38。瓶颈: " +
  83. ', '.join(f"{v} 台处于「{kk}」环节" for kk, v in (BD.get('stuck') or {}).items()) + '。'],
  84. ['机组', '设备状态', '部件', '证据源', '六步进度', '当前环节'],
  85. [[r['t'].replace('WTG', '') + '#', r.get('ostate') or '—',
  86. (r.get('part') or '')[:22], '/'.join(r.get('srcs') or []) or '—',
  87. f"{r['done']}/6", r.get('stuck') or '全通'] for r in (BD.get('rows') or [])])
  88. elif k == 'chain':
  89. add('决策链进度', ['六步: 证据 → 机制 → 判级 → 排期 → 动作 → 验收。'],
  90. ['环节', '停滞机组数'], [[kk, v] for kk, v in (BD.get('stuck') or {}).items()])
  91. elif k == 'loop':
  92. add('六、检修执行与闭环', ['检出 → 换件 → 复测回落的历史例证。'],
  93. ['机组', '部件', '换件日期', '换前', '换后', '比值'],
  94. [[x['turbine'].replace('WTG', '') + '#', x['component'], x['replace_date'], x['pre'], x['post'], f"{x['ratio']}×"]
  95. for x in (BD.get('closed') or [])])
  96. elif k == 'plan':
  97. add('七、下期计划与建议', [
  98. f"1. 补机制定性 — {(BD.get('stuck') or {}).get('机制', 0)} 台已判级但根因未定, 排期与动作缺乏依据, 建议按预警级优先安排现场检查与取样。",
  99. '2. 回收 2025~2026 工单 — 台账止于 2024-11, 本期检修执行与闭环无法自动判定。',
  100. '3. 油液采样 — 目前多数机组末样时效偏旧, 仅可作回测锚, 建议纳入定期采样。',
  101. '4. 限电侧 — 损失主要发生在交易与调度侧, 设备侧改善空间有限。'])
  102. elif k == 'caliber':
  103. part = F.get('partial') or []
  104. add('八、口径与数据边界', [
  105. f"统计期 {fl.get('win')}" + (f"; 其中 {'/'.join(part)} 数据不满月, 月度对比已按天归一, 环比不纳入残月。" if part else '。'),
  106. '时间可用率分母已剔除调度令与低风待机; 损失率分母为理论可发, 两者分母不同, 不可直接互推。',
  107. f"停机口径: 可靠性按 ≥1h 段计 {_fmt(R.get('总停机h'))} h, 电量账按逐条工况态计; 两者差额为短于 1h 的停机段, 不可相减。",
  108. '工单台账 2020-01 ~ 2024-11, 现场未提供 2025~2026, 本报告不对本期检修执行下结论。',
  109. '判级由振动/温度/润滑/油液四源融合得出; 行动等级由综合报告出具。'])
  110. elif k == 'narr' and narr:
  111. add('运行总结', [narr])
  112. return out
  113. # ── ★P101 报告配图(用户令 2026-10-06:导出 word 要有图形)────────────────────────────
  114. # 说明:页面上的图是前端 SVG 渲染的;Word 里改用 matplotlib 画**同类图**(同数据、同语义),
  115. # 不经前端字节,保证离线可复现。图类型与页面章节一一对应。
  116. _CHART_KINDS = {'water', 'blame', 'ram', 'attrib', 'pareto', 'quad', 'frate'}
  117. def _figure_png(k, fl):
  118. """按块键画一张 PNG(bytes);不支持的块返回 None。"""
  119. import matplotlib
  120. matplotlib.use('Agg')
  121. import matplotlib.pyplot as plt
  122. from matplotlib import font_manager # noqa: F401
  123. E = (fl.get('control') or {}).get('energy') or {}
  124. Rl = fl.get('rel') or {}
  125. F = fl.get('faults') or {}
  126. try:
  127. plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'DejaVu Sans']
  128. plt.rcParams['axes.unicode_minus'] = False
  129. except Exception:
  130. pass
  131. fig = None
  132. try:
  133. if k == 'water':
  134. items = list(E.get('items') or [])
  135. labels = ['理论可发'] + [str(x.get('k')) for x in items] + ['实际上网']
  136. vals = [float(E.get('theo') or 0)] + [-float(x.get('loss') or 0) for x in items] + [float(E.get('act') or 0)]
  137. fig, ax = plt.subplots(figsize=(7.2, 3.2), dpi=170)
  138. colors = ['#1E7FC2'] + ['#C8302B'] * len(items) + ['#2E8B57']
  139. ax.bar(range(len(vals)), vals, color=colors)
  140. ax.set_xticks(range(len(vals)))
  141. ax.set_xticklabels(labels, fontsize=8, rotation=18)
  142. ax.axhline(0, color='#888', lw=.8)
  143. ax.set_title('发电完成(瀑布)', fontsize=10)
  144. ax.grid(axis='y', ls=':', alpha=.35)
  145. elif k in ('blame', 'attrib'):
  146. if k == 'blame':
  147. cat = lambda kk: '交易与调度侧' if ('限电' in kk or '调度' in kk) else ('设备侧' if kk == '停机' else '运行侧')
  148. g = {}
  149. for x in (E.get('items') or []):
  150. g[cat(x.get('k'))] = g.get(cat(x.get('k')), 0) + float(x.get('loss') or 0)
  151. title = '损失归因'
  152. else:
  153. g = {str(x.get('部件')): float(x.get('停机时长h') or 0) for x in (Rl.get('表') or [])}
  154. title = '停机时长归因'
  155. items = sorted(g.items(), key=lambda kv: -kv[1])[:10]
  156. fig, ax = plt.subplots(figsize=(7.2, 3.0), dpi=170)
  157. ax.barh([a for a, _ in items][::-1], [b for _, b in items][::-1], color='#C8302B')
  158. ax.set_title(title, fontsize=10)
  159. ax.grid(axis='x', ls=':', alpha=.35)
  160. elif k == 'ram':
  161. rows = (Rl.get('表') or [])[:10]
  162. fig, ax = plt.subplots(figsize=(7.2, 3.0), dpi=170)
  163. ax.bar([str(x.get('部件'))[:10] for x in rows], [float(x.get('停机时长h') or 0) for x in rows], color='#D99A2B')
  164. ax.set_title('可靠性(按部件停机时长)', fontsize=10)
  165. ax.tick_params(axis='x', labelrotation=20, labelsize=8)
  166. ax.grid(axis='y', ls=':', alpha=.35)
  167. elif k == 'pareto':
  168. rows = sorted(((str(x.get('k')), float(x.get('v') or 0)) for x in (F.get('pareto_n') or [])),
  169. key=lambda kv: -kv[1])[:12]
  170. fig, ax = plt.subplots(figsize=(7.2, 3.0), dpi=170)
  171. ax.bar([a[:12] for a, _ in rows], [b for _, b in rows], color='#1E7FC2')
  172. tot = sum(b for _, b in rows) or 1
  173. cum, acc = [], 0.0
  174. for _, b in rows:
  175. acc += b
  176. cum.append(acc / tot * 100)
  177. ax2 = ax.twinx()
  178. ax2.plot(range(len(cum)), cum, color='#C8302B', marker='o', ms=3)
  179. ax2.set_ylim(0, 105)
  180. ax.set_title('报警帕累托', fontsize=10)
  181. ax.tick_params(axis='x', labelrotation=20, labelsize=8)
  182. ax.grid(axis='y', ls=':', alpha=.35)
  183. elif k == 'quad':
  184. pts = [(float(x.get('x') or 0), float(x.get('y') or 0), str(x.get('k') or '')) for x in (F.get('quad') or [])]
  185. fig, ax = plt.subplots(figsize=(5.6, 4.0), dpi=170)
  186. if pts:
  187. ax.scatter([p[0] for p in pts], [p[1] for p in pts], s=26, color='#1E7FC2')
  188. for x, y, lab in pts[:20]:
  189. ax.annotate(lab[:8], (x, y), fontsize=6, xytext=(2, 2), textcoords='offset points')
  190. ax.set_title('故障四象限', fontsize=10)
  191. ax.grid(ls=':', alpha=.35)
  192. elif k == 'frate':
  193. mo = (F.get('monthly') or {})
  194. months, vals = list(mo.get('months') or []), list(mo.get('vals') or [])
  195. fig, ax = plt.subplots(figsize=(7.2, 2.8), dpi=170)
  196. ax.plot(range(len(vals)), vals, color='#C8302B', marker='o', ms=3)
  197. ax.set_xticks(range(len(months)))
  198. ax.set_xticklabels([str(m)[-2:] for m in months], fontsize=8)
  199. ax.set_title('故障率(月)', fontsize=10)
  200. ax.grid(ls=':', alpha=.35)
  201. if fig is None:
  202. return None
  203. buf = io.BytesIO()
  204. fig.tight_layout()
  205. fig.savefig(buf, format='png')
  206. plt.close(fig)
  207. return buf.getvalue()
  208. except Exception:
  209. try:
  210. if fig is not None:
  211. plt.close(fig)
  212. except Exception:
  213. pass
  214. return None
  215. def to_docx(fl, blocks, win, aud='in', narr=None):
  216. from docx import Document
  217. from docx.shared import Pt
  218. from docx.enum.text import WD_ALIGN_PARAGRAPH
  219. doc = Document()
  220. st = doc.styles['Normal']
  221. st.font.name = 'Microsoft YaHei'
  222. st.font.size = Pt(10.5)
  223. h = doc.add_heading(TITLE, level=0)
  224. h.alignment = WD_ALIGN_PARAGRAPH.CENTER
  225. sub = doc.add_paragraph(f"统计期 {win} | 38 × SWT-4.0-130" + (' | 上报版(判据门槛与特征频率已脱敏)' if aud == 'up' else ''))
  226. sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
  227. for b in blocks_data(fl, blocks, narr):
  228. doc.add_heading(b['title'], level=1)
  229. _png = _figure_png(b.get('k'), fl) if b.get('k') in _CHART_KINDS else None
  230. if _png:
  231. try:
  232. from docx.shared import Inches
  233. doc.add_picture(io.BytesIO(_png), width=Inches(6.4))
  234. except Exception:
  235. pass
  236. for p in b['paras']:
  237. doc.add_paragraph(p)
  238. if b['head'] and b['rows']:
  239. t = doc.add_table(rows=1, cols=len(b['head']))
  240. t.style = 'Light Grid Accent 1'
  241. for i, c in enumerate(b['head']):
  242. t.rows[0].cells[i].text = str(c)
  243. for r in b['rows']:
  244. cells = t.add_row().cells
  245. for i, c in enumerate(r[:len(b['head'])]):
  246. cells[i].text = str(c)
  247. doc.add_paragraph()
  248. f = doc.add_paragraph(f"数据源 SCADA 10min · 报警事件 · 工单台账 · CMS 振动 · 油液化验 | 出件 {dt.datetime.now():%Y-%m-%d}")
  249. f.runs[0].font.size = Pt(8.5)
  250. buf = io.BytesIO(); doc.save(buf); return buf.getvalue()
  251. def to_xlsx(fl, blocks, win, aud='in', narr=None):
  252. from openpyxl import Workbook
  253. from openpyxl.styles import Font, PatternFill
  254. wb = Workbook(); wb.remove(wb.active)
  255. hdr_f = Font(bold=True, color='FFFFFF')
  256. hdr_b = PatternFill('solid', fgColor='1E4D6B')
  257. for b in blocks_data(fl, blocks, narr):
  258. name = b['title'].split('、')[-1][:28] or 'sheet'
  259. ws = wb.create_sheet(name[:31])
  260. r = 1
  261. for p in b['paras']:
  262. ws.cell(r, 1, p); r += 1
  263. if b['paras']:
  264. r += 1
  265. if b['head']:
  266. for i, c in enumerate(b['head'], 1):
  267. cell = ws.cell(r, i, c); cell.font = hdr_f; cell.fill = hdr_b
  268. r += 1
  269. for row in b['rows']:
  270. for i, c in enumerate(row[:len(b['head'])], 1):
  271. ws.cell(r, i, c)
  272. r += 1
  273. for i, c in enumerate(b['head'], 1):
  274. w = max([len(str(c))] + [len(str(x[i-1])) for x in b['rows'] if len(x) >= i]) + 2
  275. ws.column_dimensions[ws.cell(1, i).column_letter].width = min(w * 1.6, 52)
  276. if not wb.sheetnames:
  277. wb.create_sheet('空')
  278. buf = io.BytesIO(); wb.save(buf); return buf.getvalue()