pair_reporting.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. """只呈现本次资料支持的双机一致性分析,不把未开展项目写进报告。"""
  2. import base64
  3. from datetime import datetime
  4. from html import escape
  5. import re
  6. from algorithms.main_fan.analysis import LOCAL_ZONE, local_time
  7. from algorithms.main_fan.equivalence import three_check_statuses
  8. from extensions.main_fan.reporting import _text
  9. from extensions.main_fan.result_output import judgment_result
  10. def _value(field, value):
  11. if field == "rated_power":
  12. parts = value["components_kw"]
  13. expression = f"{len(parts)}×{parts[0]:g}" if len(set(parts)) == 1 and len(parts) > 1 else "+".join(f"{p:g}" for p in parts)
  14. return f"{expression} kW(合计{value['total_kw']:g} kW)" if len(parts) > 1 else f"{value['total_kw']:g} kW"
  15. if isinstance(value, list):
  16. value = "~".join(f"{v:g}" for v in value)
  17. units = {"rated_speed_rpm": " r/min", "impeller_diameter_m": " m", "rated_flow_range_m3_min": " m³/min", "rated_pressure_range_pa": " Pa"}
  18. if isinstance(value, (int, float)):
  19. value = f"{value:g}"
  20. return _text(value) + units.get(field, "")
  21. def performance_chart(curves, names):
  22. """按物理量分别画双机曲线;与差异计算共用插值点,不混用纵轴。"""
  23. charts = []
  24. metrics = ((0, "静压", "Pa", 0), (1, "轴功率", "kW", 1), (2, "静压效率", "%", 1))
  25. # 多于三个频率或角度组合时继续分页绘图,不丢弃后续曲线。
  26. for index, label, unit, decimals in metrics:
  27. for begin in range(0, len(curves), 3):
  28. panels = curves[begin:begin+3]
  29. charts.append(_performance_metric_chart(panels, names, index, label, unit, decimals))
  30. return "\n\n".join(charts)
  31. def _performance_metric_chart(panels, names, index, label, unit, decimals):
  32. width, height, panel_width = 1080, 365, 1080/len(panels)
  33. title = f"两台主通风机风量—{label}对比曲线"
  34. body = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img">',
  35. f'<title>{title}</title><rect width="{width}" height="{height}" rx="8" fill="#f8fafc"/>',
  36. '<g font-family="sans-serif" font-size="13" fill="#263445">',
  37. f'<text x="24" y="30" font-size="18" font-weight="600" fill="#173e65">风量—{label}对比</text>']
  38. styles = (("fan_1", "#2563eb", ""), ("fan_2", "#c26a08", ' stroke-dasharray="7 4"'))
  39. for i, (_, color, dash) in enumerate(styles):
  40. left = 24+i*width/2
  41. body.append(f'<path d="M{left:g},54h32" stroke="{color}" stroke-width="2.8"{dash}/>'
  42. f'<text x="{left+42:g}" y="58">{escape(_text(names[i]))}</text>')
  43. for panel, result in enumerate(panels):
  44. rows = result["curve_samples"]
  45. x0, top, plot_width, plot_height = panel*panel_width+68, 118, panel_width-92, 169
  46. xmin, xmax = rows[0]["flow_m3_min"], rows[-1]["flow_m3_min"]
  47. values = [r[key][index] for r in rows for key, _, _ in styles]
  48. ymin, ymax = min(values), max(values)
  49. padding = max((ymax-ymin)*.1, 1 if index == 0 else .5)
  50. ymin, ymax = max(0, ymin-padding), ymax+padding
  51. frequency = f"{result['frequency_hz']:g} Hz"
  52. angle = result.get("blade_angle_deg")
  53. if angle is not None:
  54. frequency += f" / 叶片角度 {angle:g}°"
  55. body.append(f'<text x="{x0+plot_width/2:.1f}" y="88" text-anchor="middle" font-size="15" font-weight="600">{frequency}</text>'
  56. f'<text x="{x0:.1f}" y="109">{label} ({unit})</text>')
  57. for tick in range(4):
  58. y = top+plot_height-tick*plot_height/3
  59. value = ymin+tick*(ymax-ymin)/3
  60. x = x0+tick*plot_width/3
  61. flow = xmin+tick*(xmax-xmin)/3
  62. body.append(f'<path d="M{x0:.1f},{y:.1f}h{plot_width:.1f} M{x:.1f},{top}v{plot_height}" stroke="#dce4ee"/>'
  63. f'<text x="{x0-8:.1f}" y="{y+4:.1f}" text-anchor="end">{value:.{decimals}f}</text>'
  64. f'<text x="{x:.1f}" y="309" text-anchor="middle">{flow:.0f}</text>')
  65. body.append(f'<path d="M{x0:.1f},{top}v{plot_height}h{plot_width:.1f}" fill="none" stroke="#64748b"/>'
  66. f'<text x="{x0+plot_width/2:.1f}" y="332" text-anchor="middle">风量 (m³/min)</text>')
  67. for key, color, dash in styles:
  68. points = " ".join(f"{x0+(r['flow_m3_min']-xmin)/(xmax-xmin)*plot_width:.2f},{top+plot_height-(r[key][index]-ymin)/(ymax-ymin)*plot_height:.2f}" for r in rows)
  69. body.append(f'<polyline points="{points}" fill="none" stroke="{color}" stroke-width="2.8" stroke-linejoin="round"{dash}/>')
  70. body.append('</g></svg>')
  71. encoded = base64.b64encode("".join(body).encode()).decode()
  72. return f"![{title}](data:image/svg+xml;base64,{encoded})"
  73. def switch_chart(event, names):
  74. """同一时间轴展示受控开停序列和风量,开停采用阶梯线。"""
  75. chart = event['chart_data']
  76. state_rows, flow_rows = chart['state_curves'], chart['flow_curves']
  77. rows = state_rows + flow_rows
  78. timestamps = [local_time(datetime.fromisoformat(p['observed_at'])).timestamp() for row in rows for p in row['samples']]
  79. if not timestamps or min(timestamps) == max(timestamps):
  80. return ''
  81. low, high = min(timestamps), max(timestamps)
  82. left, plot_width = 80, 770
  83. flow_top = 225 if state_rows else 45
  84. bottom = flow_top+145 if flow_rows else 175
  85. height = bottom+75
  86. x = lambda at: left+(at-low)/(high-low)*plot_width
  87. colors = {event['from_fan_id']:'#74b6ff',event['to_fan_id']:'#ffca76'}
  88. body = [f'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="{height}" viewBox="0 0 900 {height}">',
  89. f'<rect width="900" height="{height}" fill="#151a23"/>',
  90. '<g font-family="sans-serif" font-size="12" fill="#dbe5f3">',
  91. f'<text x="80" y="22">{"模拟运行时序" if chart["synthetic"] else "运行监测时序"}</text>']
  92. for i,fan_id in enumerate((event['from_fan_id'],event['to_fan_id'])):
  93. body.append(f'<text x="{600+i*135}" y="22" fill="{colors[fan_id]}">{escape(names[fan_id])}</text>')
  94. timing = event['timing']
  95. if timing['interval_minutes'] is not None:
  96. stop = local_time(datetime.fromisoformat(timing['from_stopped_at'])).timestamp()
  97. start = local_time(datetime.fromisoformat(timing['to_started_at'])).timestamp()
  98. body.append(f'<rect x="{x(min(stop,start)):.2f}" y="35" width="{abs(x(start)-x(stop)):.2f}" height="{bottom-35}" fill="#e7aa50" opacity="0.12"/>')
  99. for at in (stop,start):
  100. body.append(f'<path d="M{x(at):.2f},35V{bottom}" stroke="#dba454" stroke-dasharray="4 4"/>')
  101. def draw(row, y, step=False):
  102. # 保留状态跳变;风量按分桶极值压缩,避免大量历史点撑大网页报告。
  103. samples = row['samples']
  104. if step:
  105. samples = [p for i,p in enumerate(samples) if i in {0,len(samples)-1} or p['value'] != samples[i-1]['value'] or
  106. (i+1<len(samples) and p['value'] != samples[i+1]['value'])]
  107. elif len(samples)>800:
  108. size = (len(samples)+399)//400
  109. selected = {0,len(samples)-1}
  110. for begin in range(0,len(samples),size):
  111. indices = range(begin,min(begin+size,len(samples)))
  112. selected.update((min(indices,key=lambda i:samples[i]['value']),max(indices,key=lambda i:samples[i]['value'])))
  113. samples = [samples[i] for i in sorted(selected)]
  114. commands, previous_at = [], None
  115. for point in samples:
  116. at = local_time(datetime.fromisoformat(point['observed_at'])).timestamp()
  117. px, py = x(at), y(point['value'])
  118. if previous_at is None:
  119. commands.append(f'M{px:.2f},{py:.2f}')
  120. else:
  121. commands.append(f'H{px:.2f}V{py:.2f}' if step else f'L{px:.2f},{py:.2f}')
  122. previous_at = at
  123. body.append(f'<path d="{" ".join(commands)}" fill="none" stroke="{colors[row["fan_id"]]}" stroke-width="2.4"/>')
  124. if state_rows:
  125. body.append('<text x="80" y="45">主备风机开停曲线(1=开,0=停)</text>')
  126. for i,row in enumerate(state_rows):
  127. top = 68+i*58
  128. for value in (0,1):
  129. y = top+(1-value)*28
  130. body.append(f'<path d="M80,{y}H850" stroke="#303b4a"/><text x="65" y="{y+4}" text-anchor="end">{value}</text>')
  131. body.append(f'<text x="65" y="{top+18}" text-anchor="end" font-size="10" fill="{colors[row["fan_id"]]}">{escape(names[row["fan_id"]])}</text>')
  132. draw(row,lambda value,top=top:top+(1-value)*28,step=True)
  133. if flow_rows:
  134. maximum = max((p['value'] for row in flow_rows for p in row['samples']),default=1)*1.15 or 1
  135. body.append(f'<text x="80" y="{flow_top-15}">主备风机风量曲线(m³/min)</text>')
  136. for tick in range(4):
  137. y = flow_top+145-tick*145/3
  138. body.append(f'<path d="M80,{y:.2f}H850" stroke="#303b4a"/><text x="65" y="{y+4:.2f}" text-anchor="end">{maximum*tick/3:.0f}</text>')
  139. for row in flow_rows:
  140. draw(row,lambda value:flow_top+145-value/maximum*145)
  141. for tick in range(6):
  142. at = low+(high-low)*tick/5
  143. label = datetime.fromtimestamp(at,LOCAL_ZONE).strftime('%H:%M')
  144. body.append(f'<text x="{x(at):.2f}" y="{bottom+23}" text-anchor="middle">{label}</text>')
  145. if timing['interval_minutes'] is not None:
  146. labels = [local_time(datetime.fromisoformat(timing[key])).strftime('%H:%M:%S') for key in ('from_stopped_at','to_started_at')]
  147. body.append(f'<text x="80" y="{height-15}">原机停机 {labels[0]} 备用启动 {labels[1]} 切换间隔 {timing["interval_minutes"]:g} 分钟</text>')
  148. body.append('</g></svg>')
  149. return '![主备风机开停与风量时序曲线](data:image/svg+xml;base64,'+base64.b64encode(''.join(body).encode()).decode()+')'
  150. def render_fan_assessment_report(dataset, assessment, analysis):
  151. fans, basic, curves, switches = dataset.fans, assessment["basic_comparison"], assessment["performance_comparison"], assessment["switch_comparison"]
  152. names = [f["fan_name"] for f in fans]
  153. reports = [f["report"] for f in fans if f["report"]]
  154. now = local_time(dataset.data.evaluation_time or datetime.now(LOCAL_ZONE)).date()
  155. lines = [f"**研判对象:** {_text(dataset.data.mine_name)} {'、'.join(_text(n) for n in names)}", f"**研判日期:** {now.isoformat()}", ""]
  156. section = 0
  157. def heading(title):
  158. nonlocal section
  159. section += 1
  160. lines.extend(["", f"## {'一二三四五六七八九十'[section-1]}、{title}", ""])
  161. heading("综合研判结论")
  162. code, short_result = judgment_result(assessment, analysis)
  163. lines.extend([f"**研判结果:{code}**。{short_result}", ""])
  164. items = three_check_statuses(basic, switches)
  165. summary = []
  166. if basic["rows"]:
  167. mismatches = [r["label"] for r in basic["rows"] if r["status"] == "mismatch"]
  168. summary.append(("两台风机参数一致性", "、".join(mismatches)+"存在差异" if mismatches else "已比较的基础参数一致", "parameters"))
  169. events = switches["events"]
  170. if events:
  171. intervals = [f"{e['metrics']['switch_interval_minutes']:g}分钟(上限{e['timing']['limit_minutes']:g}分钟)"
  172. for e in events if e['metrics'].get('switch_interval_minutes') is not None]
  173. flows = [f"{e['metrics']['flow_deviation_percent']:.2f}%(上限{e['limits']['flow_deviation_percent']:g}%)"
  174. for e in events if e['metrics'].get('flow_deviation_percent') is not None]
  175. if intervals:
  176. summary.append(("停机至启动间隔", ";".join(intervals), "switch_interval"))
  177. if flows:
  178. summary.append(("切换前后稳定风量差异", ";".join(flows), "flow_change"))
  179. if summary:
  180. lines.extend(["| 判识项目 | 本次结果 | 分项结论 |", "| --- | --- | --- |"])
  181. labels = {"pass": "通过", "fail": "不通过", "insufficient": "未形成有效对照"}
  182. for label, detail, key in summary:
  183. lines.append(f"| {label} | {detail} | {labels[items[key]]} |")
  184. lines.append("")
  185. compared = [r["label"] for r in basic["rows"]]
  186. if basic["rows"]:
  187. mismatches = [r["label"] for r in basic["rows"] if r["status"] == "mismatch"]
  188. sentence = f"本次对{'与'.join(_text(n) for n in names)}的检测报告进行联合研判,重点核对两台设备的报告参数及性能测定数据。"
  189. sentence += "基础参数比较发现"+"、".join(mismatches)+"存在差异。" if mismatches else "已比较的"+"、".join(compared)+"一致。"
  190. lines.append(sentence)
  191. elif reports:
  192. lines.append("本次依据已取得的主通风机检测报告整理设备身份及性能记录,研判范围以本次实际参与比较的数据为准。")
  193. elif switches["events"]:
  194. lines.append(f"本次依据{'、'.join(_text(n) for n in names)}的运行记录与同步监测序列,分析实际切换事件前后的稳定参数变化。")
  195. else:
  196. lines.append("本次已完成资料整理,尚未形成可发布的双机一致性比较结果。")
  197. if curves["results"]:
  198. frequencies = "、".join(f"{r['frequency_hz']:g} Hz" for r in curves["results"])
  199. max_pressure = max(r["metrics"]["maximum_pressure_deviation_percent"] for r in curves["results"])
  200. max_eta = max(r["metrics"]["efficiency_difference_points"] for r in curves["results"])
  201. lines.extend(["", f"两份报告共同记录了{frequencies}现场测定数据。本次在各自共同风量区间内比较,最大静压相对偏差为{max_pressure:.2f}%,最大静压效率差为{max_eta:.2f}个百分点。偏差均以表列数据为依据,计算结果及运行工况比较见下文。"])
  202. formal = [r for r in curves["results"] if r["comparability"] == "same_angle_frequency"]
  203. if formal:
  204. lines.extend(["", "相同叶片角度、相同频率下的可比曲线" + ("存在超过本次配置阈值的差异。" if any(r['status']=='fail' for r in formal) else "各项差异处于本次配置阈值内。")])
  205. if switches["events"]:
  206. synthetic = any(e['chart_data']['synthetic'] for e in switches['events'])
  207. sentence = f"本次分析{len(switches['events'])}次{'模拟' if synthetic else '运行'}切换事件,分别判断停机至启动间隔和前后稳定风量差异。"
  208. sentence += '两项均符合本次规则。' if all(items[k]=='pass' for k in ('switch_interval','flow_change')) else '具体分项结果见切换工况分析。'
  209. lines.extend(['',sentence])
  210. if reports:
  211. heading("研判资料与设备身份")
  212. lines.extend(["本次使用的检测资料按矿井和风机身份分别归集,两台设备的原始参数保持独立,比较过程中统一风量、静压和功率单位。", "",
  213. "| 设备 | 报告编号 | 出厂编号 | 检测日期 |", "| --- | --- | --- | --- |"])
  214. for fan in fans:
  215. report = fan["report"]
  216. if report:
  217. lines.append(f"| {_text(fan['fan_name'])} | {_text(report.report_id)} | {_text(report.serial_number)} | {report.inspected_on.isoformat() if report.inspected_on else ''} |")
  218. measured = sum(p.condition == "measured" for r in reports for p in r.points)
  219. converted = sum(p.condition == "converted" for r in reports for p in r.points)
  220. lines.extend(["", f"两份报告共整理现场实测记录{measured}行、折算记录{converted}行。性能比较使用现场实测记录;折算记录单独保留,未作为现场实测工况参与两机差异计算。检测日期和出厂编号用于确认资料对象及所属检测批次,出厂编号不同表明资料分别对应两台设备。"])
  221. if basic["rows"]:
  222. heading("报告基础参数一致性")
  223. lines.extend([f"| 比较项目 | {_text(names[0])} | {_text(names[1])} | 比较结果 |", "| --- | --- | --- | --- |"])
  224. for row in basic["rows"]:
  225. values = [_value(row["field"], value) for value in row["values"]]
  226. lines.append(f"| {row['label']} | {values[0]} | {values[1]} | {'一致' if row['status']=='match' else '不一致'} |")
  227. power = next((r for r in basic["rows"] if r["field"] == "rated_power"), None)
  228. if power:
  229. lines.extend(["", "额定功率比较采用报告基本参数表中的电动机铭牌配置,保留电动机组成并统一计算总额定功率。组合表达式由计算工具解析,运行工况下的轴功率及电输入功率另行用于性能分析。"])
  230. matching = [r["label"] for r in basic["rows"] if r["status"] == "match"]
  231. if matching:
  232. lines.extend(["", "上述一致项表明,两台设备在已载明的"+"、".join(matching)+"方面具有一致的报告参数。基础参数一致性以型号、额定功率及双方共同载明的设备参数为依据。"])
  233. if basic["backup_availability"]:
  234. lines.extend(["", "运行记录所列备用风机状态为" + ("可用。" if basic['backup_availability']['status']=='available' else "不可用。")])
  235. if curves["results"]:
  236. heading("报告性能测定数据比较")
  237. lines.extend(["性能测定数据作为补充分析,单独说明两机性能曲线差异,不增加综合判识的必过条件。本次按共同检测频率分别组织两台风机的风量—静压、风量—轴功率和风量—静压效率数据,在双方原始测量范围的交集内进行分段线性插值。比较范围覆盖率按共同风量跨度占各自测量跨度的比例计算,所有比较点均位于原始测量范围内。", "",
  238. "| 频率 | 共同风量区间(m³/min) | 平均静压偏差 | 最大静压偏差 | 最大效率差(百分点) |",
  239. "| --- | ---: | ---: | ---: | ---: |"])
  240. for result in curves["results"]:
  241. m, q = result["metrics"], result["common_flow_range_m3_min"]
  242. lines.append(f"| {result['frequency_hz']:g} Hz | {q[0]:.2f}~{q[1]:.2f} | {m['mean_pressure_deviation_percent']:.2f}% | {m['maximum_pressure_deviation_percent']:.2f}% | {m['efficiency_difference_points']:.2f} |")
  243. details = ";".join(f"{r['frequency_hz']:g} Hz共同区间覆盖率{r['common_flow_coverage']*100:.2f}%、静压均方根误差{r['metrics']['pressure_rmse_pa']:.2f} Pa、最大轴功率偏差{r['metrics']['maximum_power_deviation_percent']:.2f}%" for r in curves['results'])
  244. lines.extend(["", details+"。相对偏差以表格第一台设备为基准;效率差按百分点表达,避免与相对百分比混用。", "", performance_chart(curves['results'], names)])
  245. operating = [r for r in curves["results"] if r["operating_points"]]
  246. if operating:
  247. lines.extend(["", "**报告所列运行工况比较。** 以下为两份检测报告明示的运行工况,分别保留各自对应的风量、静压及功率。", "",
  248. "| 频率与设备 | 风量(m³/min) | 静压(Pa) | 轴功率(kW) | 静压效率(%) |",
  249. "| --- | ---: | ---: | ---: | ---: |"])
  250. operating_notes = []
  251. for result in operating:
  252. for name, point in zip(names, result['operating_points']):
  253. lines.append(f"| {result['frequency_hz']:g} Hz {_text(name)} | {point['flow_m3_min']:.2f} | {point['pressure_pa']:.2f} | {point['shaft_power_kw']:.2f} | {point['efficiency_pct']:.2f} |")
  254. m = result["metrics"]
  255. pieces = []
  256. if m.get('operating_flow_deviation_percent') is not None:
  257. pieces.append(f"风量偏差{m['operating_flow_deviation_percent']:.2f}%")
  258. if m.get('operating_pressure_deviation_percent') is not None:
  259. pieces.append(f"静压偏差{m['operating_pressure_deviation_percent']:.2f}%")
  260. if pieces:
  261. operating_notes.append(f"{result['frequency_hz']:g} Hz所列运行工况的"+"、".join(pieces)+"。这些参数描述各自检测时的运行点,与公共风量区间内的曲线数据差异分别计算。")
  262. for note in operating_notes:
  263. lines.extend(["", note])
  264. if switches["events"]:
  265. heading("切换前后稳定工况比较")
  266. lookup = {f['fan_id']: f['fan_name'] for f in fans}
  267. for event in switches['events']:
  268. if event['chart_data']['synthetic']:
  269. lines.extend(['本节采用明确标注的开停、风量模拟时序,验证切换间隔和风量差异两项业务规则。',''])
  270. lines.extend([f"**切换事件:** {event['switched_at']},{_text(lookup[event['from_fan_id']])}切换至{_text(lookup[event['to_fan_id']])}。", ""])
  271. timing = event['timing']
  272. if timing['interval_minutes'] is not None:
  273. sentence = f"原运行风机于{timing['from_stopped_at']}停机,另一台风机于{timing['to_started_at']}启动,切换间隔为**{timing['interval_minutes']:.2f}分钟**。"
  274. sentence += f"按不超过{timing['limit_minutes']:g}分钟的规则,判定为**{'通过' if timing['status']=='pass' else '超限'}**。"
  275. if timing['overlap_minutes']:
  276. sentence += f"两机运行重叠{timing['overlap_minutes']:.2f}分钟,停机等待间隔按0分钟计。"
  277. lines.extend([sentence,''])
  278. chart = switch_chart(event,lookup)
  279. if chart:
  280. lines.extend([chart,''])
  281. if event['changes']:
  282. lines.extend(["| 监测指标 | 切换前稳定均值 | 切换后稳定均值 | 数值变化 | 相对变化 |", "| --- | ---: | ---: | ---: | ---: |"])
  283. for metric, change in event['changes'].items():
  284. flow = metric == 'flow'
  285. label, factor, unit = ('风量', 60, 'm³/min') if flow else ('静压' if metric=='static_pressure' else '风压', 1, 'Pa')
  286. lines.append(f"| {label}({unit}) | {change['before']*factor:.2f} | {change['after']*factor:.2f} | {change['absolute_change']*factor:+.2f} | {change['relative_change_percent']:+.2f}% |")
  287. if event['changes']:
  288. window = next(iter(event['before_window'].values()))
  289. after = next(iter(event['after_window'].values()))
  290. lines.extend(["", f"前稳定窗口为{window['start']}至{window['end']},后稳定窗口为{after['start']}至{after['end']}。统计采用有效样本,窗口完整率分别为{window['completeness']*100:.2f}%和{after['completeness']*100:.2f}%。"])
  291. if 'flow' in event['changes']:
  292. change = event['changes']['flow']
  293. limit = event['limits']['flow_deviation_percent']
  294. lines.extend(['',f"风量差异按 |切换后均值-切换前均值| ÷ 切换前均值 × 100% 计算,即 |{change['after']*60:.2f}-{change['before']*60:.2f}| ÷ {change['before']*60:.2f} × 100%=**{event['metrics']['flow_deviation_percent']:.2f}%**。按不超过{limit:g}%的规则,判定为**{'通过' if event['checks']['flow_change']=='pass' else '超限'}**。"])
  295. if event['metrics']['recovery_time_minutes'] is not None:
  296. lines.extend(["", f"切换后在{event['metrics']['recovery_time_minutes']:.1f}分钟开始形成连续恢复窗口。"])
  297. if all(v == 'pass' for v in event['checks'].values()):
  298. lines.extend(['','本次切换间隔与稳定风量差异均符合配置规则,支持该次工况下的主备切换及供风衔接结论。'])
  299. consistency = [f for f in analysis.findings if f.code in {'curve_table_disagreement', 'report_formula_inconsistent', 'curve_formula_inconsistent'}]
  300. if consistency:
  301. heading("检测资料一致性事项")
  302. lines.extend(["以下事项记录检测资料自身的表图或公式差异,独立于基础参数、切换间隔和稳定风量三项判识,不作为综合结论的否决项。", ""])
  303. for fan in fans:
  304. report = fan['report']
  305. if not report:
  306. continue
  307. for caption in report.curves:
  308. points = [p for p in report.points if p.condition==caption.condition and p.frequency_hz==caption.frequency_hz and '运行工况' in p.label
  309. and abs(p.flow_m3_s-caption.flow_m3_s)<=max(.001,caption.flow_m3_s*.005)
  310. and abs(p.shaft_power_kw-caption.shaft_power_kw)<=max(.001,caption.shaft_power_kw*.005)]
  311. if len(points)!=1:
  312. continue
  313. row = points[0]
  314. pressure_diff = abs(row.static_pressure_pa-caption.static_pressure_pa)
  315. eta_diff = abs(row.reported_efficiency_pct-caption.reported_efficiency_pct) if row.reported_efficiency_pct is not None and caption.reported_efficiency_pct is not None else None
  316. if pressure_diff <= max(1,row.static_pressure_pa*.005) and (eta_diff is None or eta_diff <= .5):
  317. continue
  318. text = f"{_text(fan['fan_name'])}{caption.frequency_hz:g} Hz报告的数据表静压为{row.static_pressure_pa:.2f} Pa,曲线图注为{caption.static_pressure_pa:.2f} Pa,相差{pressure_diff:.2f} Pa。"
  319. if eta_diff is not None:
  320. text += f"表列静压效率为{row.reported_efficiency_pct:.2f}%,图注为{caption.reported_efficiency_pct:.2f}%,相差{eta_diff:.2f}个百分点。"
  321. lines.extend([text+"本次两机性能数据比较采用数据表,图注数值独立保留。", ""])
  322. formula = [f for f in consistency if f.code in {'report_formula_inconsistent','curve_formula_inconsistent'}]
  323. if formula:
  324. lines.append(f"本次发现{len(formula)}项性能数值与同一组参数的效率公式不能一致对应,已在原始计算记录中保留具体数值和原文位置。")
  325. lines.extend(["", "建议将上述表图差异交由检测机构说明,统一正式采用的报表与曲线参数口径,避免在后续两机性能比较中交叉使用不同来源数值。"])
  326. historical = [c for c in analysis.calculations if c.scope=='historical_report']
  327. if historical:
  328. differences = [c.difference_percentage_points for c in historical if c.difference_percentage_points is not None]
  329. if differences:
  330. lines.extend(["", f"本次同时检查了两台风机共{len(historical)}行历史表格数据的静压效率公式,计算值与表列效率的最大差值为{max(differences):.4f}个百分点。逐行原始数值、公式及文件位置已与报告一并保存。"])
  331. return re.sub(r"(\*\*[^*\n]+\*\*)(?=\S)", r"\1 ", "\n".join(lines))