report_export.py 18 KB

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