make_event_dashboard.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. #!/usr/bin/env python3
  2. """Build an interactive ECharts dashboard for the high-density one-cylinder events."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import sys
  7. from collections import defaultdict
  8. from datetime import datetime, timedelta
  9. from pathlib import Path
  10. import numpy as np
  11. BACKEND = Path(__file__).resolve().parents[1]
  12. sys.path.insert(0, str(BACKEND))
  13. from extract_from_db import fetch_cycles, fetch_file_rows, point_name, sample_per_day # noqa: E402
  14. from indicator_features.indicator_features import smooth, _plateau_levels, volume_from_angle # noqa: E402
  15. from app.config import settings # noqa: E402
  16. from extract_from_db import connect # noqa: E402
  17. from new_unit_events import collect_events, top_cylinder_event # noqa: E402
  18. EVENTS = {
  19. "7号机": {"unit": "7", "date": "2026-05-21", "label": "7号机 2026-05-21 气阀事件", "pre_days": 30, "post_days": 30},
  20. "8号机(2026)": {"unit": "8", "date": "2026-03-30", "label": "8号机 2026-03-29/04-01 合并事件", "pre_days": 30, "post_days": 30},
  21. "8号机(2025同期)": {"unit": "8", "date": "2025-03-30", "label": "8号机 2025年3月底/4月初同期工况", "pre_days": 30, "post_days": 60},
  22. }
  23. SIDES = ("盖侧", "轴侧")
  24. COLORS = {"pre": "#4f7cac", "post": "#d95d39", "early": "#91a7c2", "late": "#e69f72"}
  25. def collect(connection, unit: str, side: str, event_date: str, per_day: int, bins: int, pre_days: int, post_days: int):
  26. t0 = datetime.fromisoformat(event_date)
  27. start = (t0 - timedelta(days=pre_days)).date().isoformat()
  28. end = (t0 + timedelta(days=post_days + 1)).date().isoformat()
  29. files = fetch_file_rows(connection, point_name(unit[0], "一", side), start, end)
  30. picked = sample_per_day(files, per_day)
  31. cycles = fetch_cycles(connection, [int(r["id"]) for r in picked], 200)
  32. result = []
  33. for row in picked:
  34. signal = cycles.get(int(row["id"]))
  35. if signal is None:
  36. continue
  37. if len(signal) < 60 or not np.all(np.isfinite(signal)):
  38. continue
  39. pressure = smooth(signal, 21)
  40. suction, discharge, _ = _plateau_levels(pressure)
  41. if discharge - suction < 0.3:
  42. continue
  43. profile = np.interp(np.arange(bins) * len(pressure) / bins,
  44. np.arange(len(pressure)), pressure)
  45. profile = (profile - suction) / (discharge - suction)
  46. dt = row["sample_time"]
  47. if not isinstance(dt, datetime):
  48. dt = datetime.fromisoformat(str(dt))
  49. result.append({
  50. "date": dt.strftime("%Y-%m-%d %H:%M:%S"),
  51. "day": (dt.date() - t0.date()).days,
  52. "phase": "修前" if dt < t0 else "修后",
  53. "profile": [round(float(x), 6) for x in profile],
  54. "absolute": [round(float(x), 6) for x in np.interp(np.arange(bins) * len(pressure) / bins, np.arange(len(pressure)), pressure)],
  55. "volume": [round(float(x), 6) for x in np.interp(
  56. np.arange(bins) * len(pressure) / bins,
  57. np.arange(len(pressure)),
  58. volume_from_angle(len(pressure), 360.0),
  59. )],
  60. "p_suction": round(suction, 6),
  61. "p_discharge": round(discharge, 6),
  62. "p_span": round(discharge - suction, 6),
  63. })
  64. return result
  65. def build_data(per_day: int, bins: int):
  66. data = {"angle": [round(i * 360 / bins, 2) for i in range(bins)], "machines": {}}
  67. connection = connect()
  68. try:
  69. for machine, config in EVENTS.items():
  70. unit = config["unit"]
  71. data["machines"][machine] = {"label": config["label"], "sides": {}}
  72. for side in SIDES:
  73. samples = collect(connection, unit, side, config["date"], per_day, bins, config["pre_days"], config["post_days"])
  74. pre = [r for r in samples if r["phase"] == "修前"]
  75. post = [r for r in samples if r["phase"] == "修后"]
  76. pre_template = np.median([r["profile"] for r in pre], axis=0) if pre else None
  77. if pre_template is None:
  78. pre_template = np.zeros(bins)
  79. for r in samples:
  80. r["distance"] = round(float(np.linalg.norm(np.asarray(r["profile"]) - pre_template)), 5)
  81. def median_profile(items):
  82. return [round(float(x), 6) for x in np.median([r["profile"] for r in items], axis=0)] if items else []
  83. def median_absolute(items):
  84. return [round(float(x), 6) for x in np.median([r["absolute"] for r in items], axis=0)] if items else []
  85. trend = [{"date": r["date"], "day": r["day"], "distance": r["distance"]} for r in samples]
  86. data["machines"][machine]["sides"][f"一缸{side}"] = {
  87. "samples": samples,
  88. "preMedian": median_profile(pre),
  89. "postMedian": median_profile(post),
  90. "preAbsoluteMedian": median_absolute(pre),
  91. "postAbsoluteMedian": median_absolute(post),
  92. "trend": trend,
  93. "counts": {"pre": len(pre), "post": len(post)}, "markerDays": [0],
  94. }
  95. for key, event in collect_events(connection, per_day=per_day, bins=bins).items():
  96. data["machines"][key] = top_cylinder_event(event)
  97. finally:
  98. connection.close()
  99. return data
  100. def html(data: dict) -> str:
  101. payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
  102. return f'''<!doctype html>
  103. <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  104. <title>一缸功图事件前后对比</title>
  105. <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
  106. <style>
  107. body{{margin:0;background:#fff;color:#263238;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}}
  108. .wrap{{width:100%;box-sizing:border-box;padding:20px 18px 42px}} h1{{margin:0 0 8px;font-size:28px}} .sub{{color:#66727a;margin-bottom:18px}}
  109. .machine{{margin:24px 0 34px;padding:0}}
  110. .machine h2{{margin:0 0 14px;font-size:21px}} .grid{{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}}
  111. .chart{{height:390px;border:1px solid #eee8de;border-radius:8px;background:#fff}} .chart-wrap{{min-width:0}}
  112. .chart-legend{{display:flex;flex-wrap:wrap;gap:7px 10px;margin:6px 1px 0;padding:5px;font-size:12px;line-height:1.2;color:#4c5960}}
  113. .chart-legend-container{{margin:6px 1px 0}}
  114. .legend-item{{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;cursor:pointer;font-size:12px;line-height:1.2}} .legend-item input{{accent-color:#4f7cac;margin:0;width:13px;height:13px}}
  115. .legend-line{{display:inline-block;width:20px;height:0;border-top:3px solid}} .legend-line.thin{{border-top-width:2px}}
  116. .chart-meta{{font-size:12px;color:#66727a;margin:0 2px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}} .chart-actions{{display:flex;gap:6px;margin:0 0 5px}}
  117. .chart-actions button{{border:1px solid #d8d1c7;background:#fff;border-radius:5px;padding:3px 8px;color:#44515a;cursor:pointer;font-size:12px}} .note{{font-size:13px;color:#737b80;margin:8px 2px 0}}
  118. @media(max-width:1100px){{.grid{{grid-template-columns:repeat(2,minmax(0,1fr))}}}} @media(max-width:700px){{.wrap{{padding:14px 8px}}.grid{{grid-template-columns:1fr}}.chart{{height:360px}}}}
  119. </style></head><body><div class="wrap">
  120. <h1>一缸 P-V 功图事件前后对比</h1><div class="sub">每台机组每个腔体显示:四阶段 P-V 功图、压力-角度曲线、逐周期形状距离。图例在图外,可逐时间点勾选;页面按宽度自适应,每行最多3张图。</div>
  121. <div id="app"></div></div>
  122. <script>
  123. const colors = {{pre:'#4f7cac',post:'#d95d39',early:'#91a7c2',late:'#e69f72'}};
  124. const DATA = {payload}; const app=document.getElementById('app'); const charts=[];
  125. function el(tag, cls, text){{const e=document.createElement(tag);if(cls)e.className=cls;if(text)e.textContent=text;return e;}}
  126. function profileChart(dom, side, d, legendDom){{
  127. const chart=echarts.init(dom); const samples=d.samples.filter(s=>s.phase!=='过程内'); const stages=[['排气',0,120,'#7b1fa2'],['压缩',120,195,'#c62828'],['膨胀',195,270,'#1565c0'],['进气',270,360,'#2e7d32']]; const series=[]; const groups=[{{label:'修前单周期',indices:[],color:colors.early,input:null}},{{label:'修后单周期',indices:[],color:colors.late,input:null}}];
  128. samples.forEach((s,i)=>{{const color=s.phase==='修前'?colors.early:colors.late;const group=s.phase==='修前'?groups[0]:groups[1];const base='pv'+i;stages.forEach(([stage,lo,hi,stageColor])=>{{const data=s.volume.map((v,j)=>DATA.angle[j]>=lo&&DATA.angle[j]<=hi?[v,s.profile[j]]:null).filter(Boolean);group.indices.push(series.length);series.push({{name:base+stage,type:'line',showSymbol:false,smooth:false,data,lineStyle:{{width:1.5,opacity:.58,color}},itemStyle:{{color}},legendHoverLink:false}})}})}});
  129. series.push({{name:'修前中位',type:'line',data:d.preMedian.map((y,i)=>[d.samples[0]?.volume[i],y]),showSymbol:false,lineStyle:{{width:3.5,color:colors.pre}}}});const preMedianIndex=series.length-1;series.push({{name:'修后中位',type:'line',data:d.postMedian.map((y,i)=>[d.samples[0]?.volume[i],y]),showSymbol:false,lineStyle:{{width:3.5,color:colors.post}}}});groups.push({{label:'修前中位',indices:[preMedianIndex],color:colors.pre,input:null}});groups.push({{label:'修后中位',indices:[series.length-1],color:colors.post,input:null}});
  130. chart.setOption({{animation:false,title:{{text:side+':P-V 功图',left:'center',top:8}},tooltip:{{trigger:'axis'}},legend:{{show:false}},grid:{{left:62,right:20,top:62,bottom:58}},xAxis:{{type:'value',name:'气缸体积 L'}},yAxis:{{type:'value',name:'压力'}},dataZoom:[{{type:'inside'}},{{type:'slider',bottom:25}}],series}});
  131. const toggle=(g,show)=>g.indices.forEach(index=>chart.dispatchAction({{type:show?'legendSelect':'legendUnSelect',name:series[index].name}})); const list=document.createElement('div');list.className='chart-legend';legendDom.append(list);groups.forEach(g=>{{const label=document.createElement('label');label.className='legend-item';const input=document.createElement('input');input.type='checkbox';input.checked=true;input.onchange=()=>toggle(g,input.checked);g.input=input;const dot=document.createElement('i');dot.className='legend-line';dot.style.borderColor=g.color;label.append(input,dot,document.createTextNode(g.label));list.append(label)}}); return chart;
  132. }}
  133. function absoluteChart(dom,side,d,legendDom){{const chart=echarts.init(dom);const series=[];const early=d.samples.filter(x=>x.phase==='修前').slice(0,12),late=d.samples.filter(x=>x.phase==='修后').slice(-12);[...early,...late].forEach((s,i)=>series.push({{name:'abs'+i,type:'line',showSymbol:false,smooth:true,data:s.absolute,lineStyle:{{width:1.5,opacity:.58,color:i<early.length?colors.early:colors.late}}}}));series.push({{name:'absPre',type:'line',data:d.preAbsoluteMedian,showSymbol:false,lineStyle:{{width:3.5,color:colors.pre}}}});series.push({{name:'absPost',type:'line',data:d.postAbsoluteMedian,showSymbol:false,lineStyle:{{width:3.5,color:colors.post}}}});chart.setOption({{animation:false,title:{{text:side+':绝对压力功图',left:'center',top:8}},tooltip:{{trigger:'axis'}},legend:{{show:false}},grid:{{left:58,right:20,top:62,bottom:48}},xAxis:{{type:'category',name:'曲轴角度',data:DATA.angle,axisLabel:{{formatter:v=>v+'°'}}}},yAxis:{{type:'value',name:'压力'}},dataZoom:[{{type:'inside'}},{{type:'slider',bottom:25}}],series}});const groups=[{{label:'修前单周期',indices:Array.from({{length:early.length}},(_,i)=>i),color:colors.early}},{{label:'修后单周期',indices:Array.from({{length:late.length}},(_,i)=>early.length+i),color:colors.late}},{{label:'修前中位',indices:[series.length-2],color:colors.pre}},{{label:'修后中位',indices:[series.length-1],color:colors.post}}];const list=document.createElement('div');list.className='chart-legend';legendDom.append(list);groups.forEach(g=>{{const label=document.createElement('label');label.className='legend-item';const input=document.createElement('input');input.type='checkbox';input.checked=true;input.onchange=()=>g.indices.forEach(index=>chart.dispatchAction({{type:input.checked?'legendSelect':'legendUnSelect',name:series[index].name}}));g.input=input;const dot=document.createElement('i');dot.className='legend-line';dot.style.borderColor=g.color;label.append(input,dot,document.createTextNode(g.label));list.append(label)}});return chart;}}
  134. function trendChart(dom,side,d,legendDom){{const chart=echarts.init(dom);const max=Math.max(...d.trend.map(x=>x.distance),.1);const markers=d.markerDays||[0];const series=[{{name:'形状距离',type:'scatter',symbolSize:7,data:d.trend.map(x=>[x.day,x.distance]),itemStyle:{{color:'#2f6f8f'}}}},...markers.map((day,i)=>({{name:'事件日'+(i+1),type:'line',data:[[day,0],[day,max]],showSymbol:false,lineStyle:{{type:'dashed',color:'#b33'}}}}))];chart.setOption({{animation:false,title:{{text:side+':逐周期形状偏离',left:'center',top:8}},tooltip:{{trigger:'axis'}},legend:{{show:false}},grid:{{left:58,right:20,top:62,bottom:50}},xAxis:{{type:'value',name:'相对事件日',axisLabel:{{formatter:v=>v+'天'}}}},yAxis:{{type:'value',name:'形状距离'}},dataZoom:[{{type:'inside'}},{{type:'slider',bottom:20,height:16}}],series}});legendDom.innerHTML='<label class="legend-item"><input type="checkbox" checked data-series="0"><i class="legend-line" style="border-color:#2f6f8f"></i>形状距离</label><label class="legend-item"><input type="checkbox" checked data-events><i class="legend-line" style="border-color:#b33"></i>事件日</label>';legendDom.querySelector('[data-series]').addEventListener('change',e=>chart.dispatchAction({{type:e.target.checked?'legendSelect':'legendUnSelect',name:'形状距离'}}));legendDom.querySelector('[data-events]').addEventListener('change',e=>markers.forEach((_,i)=>chart.dispatchAction({{type:e.target.checked?'legendSelect':'legendUnSelect',name:'事件日'+(i+1)}})));return chart;}}
  135. function renderPage(data){{app.innerHTML='';
  136. for(const [machine,m] of Object.entries(DATA.machines)){{const box=el('section','machine');box.appendChild(el('h2',null,m.label));const grid=el('div','grid');box.appendChild(grid);app.appendChild(box);
  137. for(const side of Object.keys(m.sides)){{const d=m.sides[side];
  138. const pw=el('div','chart-wrap');grid.appendChild(pw);pw.appendChild(el('div','chart-meta','样本:修前 '+d.counts.pre+' 条,修后 '+d.counts.post+' 条'));const p=el('div','chart');pw.appendChild(p);const pl=el('div','chart-legend-container');pw.appendChild(pl);charts.push(profileChart(p,side,d,pl));
  139. const aw=el('div','chart-wrap');grid.appendChild(aw);aw.appendChild(el('div','chart-meta','绝对压力:同一批周期,显示原始压力水平'));const a=el('div','chart');aw.appendChild(a);const al=el('div','chart-legend-container');aw.appendChild(al);charts.push(absoluteChart(a,side,d,al));
  140. const tw=el('div','chart-wrap');grid.appendChild(tw);tw.appendChild(el('div','chart-meta','距离:每个周期相对事件前模板的形状距离'));const t=el('div','chart');tw.appendChild(t);const tl=el('div','chart-legend-container');tw.appendChild(tl);charts.push(trendChart(t,side,d,tl));}}
  141. const ranking=m.ranking?.map(x=>x.cylinder+'缸 '+x.residual).join(',');const note=el('div','note', (ranking?'候选缸局部残差排名:'+ranking+'。':'')+'展示测点是全测点扫描后的变化候选,不等于已确认故障缸。原始周期角度,不做自动相位旋转。');box.appendChild(note);}}
  142. }}
  143. window.addEventListener('resize',()=>charts.forEach(c=>c.resize()));
  144. </script>
  145. <script>renderPage(DATA);</script>
  146. </body></html>'''
  147. def main():
  148. parser = argparse.ArgumentParser(description="生成一缸功图ECharts网页")
  149. parser.add_argument("--per-day", type=int, default=6)
  150. parser.add_argument("--bins", type=int, default=90)
  151. parser.add_argument("--out", required=True)
  152. args = parser.parse_args()
  153. data = build_data(args.per_day, args.bins)
  154. out = Path(args.out)
  155. out.parent.mkdir(parents=True, exist_ok=True)
  156. out.write_text(html(data), encoding="utf-8")
  157. print(f"HTML -> {out}")
  158. for machine, item in data["machines"].items():
  159. for side, value in item["sides"].items():
  160. print(machine, side, value["counts"])
  161. if __name__ == "__main__":
  162. main()