make_spring_dashboard.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. #!/usr/bin/env python3
  2. """Standalone actual-waveform spring comparison for units 7, 8 and 9."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. import json
  7. from collections import defaultdict
  8. from pathlib import Path
  9. import numpy as np
  10. from extract_from_db import connect, fetch_cycles
  11. from indicator_features.indicator_features import CYLINDER_BORE_MM, _plateau_levels, smooth, volume_from_angle
  12. BAD = {("7", "四", "盖侧"), ("7", "六", "盖侧"), ("8", "六", "盖侧")}
  13. def daily_median(samples, key):
  14. if not samples:
  15. return []
  16. days = defaultdict(list)
  17. for sample in samples:
  18. days[sample["date"][:10]].append(sample[key])
  19. return np.median([np.median(values, axis=0) for values in days.values()], axis=0).round(6).tolist()
  20. def build(root):
  21. bins = 90
  22. machines = []
  23. connection = connect()
  24. try:
  25. for unit in ("7", "8", "9"):
  26. filename = f"高密度特征_{unit}号_2026.csv" if unit != "9" else "高密度特征_9号_2026春季.csv"
  27. with (root / filename).open(encoding="utf-8-sig", newline="") as handle:
  28. rows = list(csv.DictReader(handle))
  29. rows = [r for r in rows if "2026-03-01" <= r["sample_time"][:10] < "2026-04-20"
  30. and float(r.get("p_span") or 0) >= .3
  31. and (unit, r["cylinder"], r["side"]) not in BAD]
  32. grouped = defaultdict(list)
  33. # Fetch bounded batches; retain only the compact plotted profiles.
  34. for offset in range(0, len(rows), 100):
  35. chunk = rows[offset:offset + 100]
  36. cycles = fetch_cycles(connection, [int(r["wave_file_id"]) for r in chunk], 100)
  37. for row in chunk:
  38. signal = cycles.get(int(row["wave_file_id"]))
  39. if signal is None or len(signal) < 60 or not np.all(np.isfinite(signal)):
  40. continue
  41. pressure = smooth(signal, 21)
  42. suction, discharge, _ = _plateau_levels(pressure)
  43. if discharge - suction < .3:
  44. continue
  45. indices = np.arange(bins) * len(pressure) / bins
  46. absolute = np.interp(indices, np.arange(len(pressure)), pressure)
  47. profile = (absolute - suction) / (discharge - suction)
  48. grouped[(row["cylinder"], row["side"])].append({
  49. "date": row["sample_time"], "profile": profile.round(6).tolist(),
  50. "absolute": absolute.round(6).tolist(),
  51. })
  52. points = []
  53. for (cylinder, side), samples in sorted(grouped.items()):
  54. samples.sort(key=lambda s: s["date"])
  55. march = [s for s in samples if s["date"][:10] < "2026-03-21"]
  56. april = [s for s in samples if s["date"][:10] >= "2026-04-01"]
  57. if not march or not april:
  58. continue
  59. template = np.asarray(daily_median(march, "profile"))
  60. for sample in samples:
  61. sample["distance"] = round(float(np.linalg.norm(np.asarray(sample["profile"]) - template)), 5)
  62. sample["period"] = "3月上中旬" if sample["date"][:10] < "2026-03-21" else "4月" if sample["date"][:10] >= "2026-04-01" else "3月下旬"
  63. points.append({
  64. "point": f"{cylinder}缸{side}", "samples": samples,
  65. "volume": volume_from_angle(bins, CYLINDER_BORE_MM[cylinder]).round(6).tolist(),
  66. "preMedian": template.tolist(), "postMedian": daily_median(april, "profile"),
  67. "preAbsolute": daily_median(march, "absolute"), "postAbsolute": daily_median(april, "absolute"),
  68. "preDates": sorted({s["date"][:10] for s in march}),
  69. "postDates": sorted({s["date"][:10] for s in april}),
  70. "preCount": len(march), "postCount": len(april),
  71. })
  72. machines.append({"unit": unit, "points": points})
  73. print(f"{unit}号:{len(points)}个可比较测点,{sum(len(p['samples']) for p in points)}条实际周期")
  74. finally:
  75. connection.close()
  76. return {"angle": (np.arange(bins) * 360 / bins).tolist(), "machines": machines}
  77. def make_html(data):
  78. template = r'''<!doctype html>
  79. <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  80. <title>7、8、9号春季同期工况对照</title><script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
  81. <style>
  82. body{margin:0;background:#fff;color:#263238;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}
  83. .wrap{width:100%;padding:20px 18px 40px;box-sizing:border-box}h1{margin:0 0 8px;font-size:26px}.sub{color:#66727a;line-height:1.6}
  84. .machine{margin:26px 0 38px}h2{font-size:21px;margin:0 0 8px}.toolbar{margin-bottom:10px;font-size:13px}select{padding:4px 8px;margin-left:6px;background:#fff;border:1px solid #cfd6d9;border-radius:4px}
  85. .coverage{font-size:12px;color:#66727a;line-height:1.6;margin:8px 0}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.chart-wrap{min-width:0}.chart{height:390px;border:1px solid #eee8de;border-radius:8px}
  86. .legend{display:flex;flex-wrap:wrap;gap:7px 10px;margin:6px 1px;padding:5px;font-size:12px;line-height:1.2;color:#4c5960}.legend label{display:inline-flex;align-items:center;gap:4px;cursor:pointer;font-size:12px}.legend input{margin:0;width:13px;height:13px}.line{width:20px;border-top:3px solid;display:inline-block}.note{font-size:12px;color:#66727a;line-height:1.6}
  87. @media(max-width:1100px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.wrap{padding:12px 8px}.grid{grid-template-columns:1fr}}
  88. </style></head><body><main class="wrap"><h1>7、8、9号春季同期工况对照</h1>
  89. <p class="sub">蓝色为3月上中旬,红色为4月。中位功图按每天等权计算;形状距离以同一测点3月上中旬模板为参考。每台机可切换测点,每行三图。</p>
  90. <div id="app"></div><p class="note">同期变化不等于季节因果,也不等于故障。不同机器的可用日期并不一致;缺测处不连线、不推断变化发生时间。P-V采用现有前端体积映射,没有自动相位旋转;平台归一化不能消除全部工况影响。单周期曲线经过21点平滑和90点重采样,适合看整体形状,不用于判断高频毛刺。</p></main>
  91. <script>
  92. const DATA=__PAYLOAD__;
  93. const colors={pre:'#4f7cac',post:'#d95d39',early:'#7296b8',late:'#db8a60'};
  94. const resizers=[];
  95. function el(tag,cls,text){const e=document.createElement(tag);if(cls)e.className=cls;if(text)e.textContent=text;return e}
  96. function legend(container,chart,groups){
  97. groups.forEach(g=>{const label=el('label');const input=document.createElement('input');input.type='checkbox';input.checked=true;
  98. const line=el('i','line');line.style.borderColor=g.color;label.append(input,line,document.createTextNode(g.label));
  99. input.onchange=()=>g.names.forEach(name=>chart.dispatchAction({type:input.checked?'legendSelect':'legendUnSelect',name}));container.append(label)});
  100. }
  101. function drawCurve(dom,legendDom,p,kind){
  102. const series=[],groups=[{label:'3月单周期',names:[],color:colors.early},{label:'4月单周期',names:[],color:colors.late}];
  103. function coordinates(values){const points=values.map((v,i)=>[kind==='pv'?p.volume[i]:DATA.angle[i],v]);if(kind==='pv'&&points.length)points.push(points[0]);return points}
  104. p.samples.filter(s=>s.period!=='3月下旬').forEach((s,i)=>{const g=s.period==='4月'?groups[1]:groups[0];const name=s.date+' #'+i;g.names.push(name);
  105. series.push({name,type:'line',data:coordinates(kind==='pv'?s.profile:s.absolute),showSymbol:false,lineStyle:{width:1.5,opacity:.58,color:g.color},itemStyle:{color:g.color},emphasis:{lineStyle:{width:2.5}}})});
  106. [{name:'3月中位',values:kind==='pv'?p.preMedian:p.preAbsolute,color:colors.pre},{name:'4月中位',values:kind==='pv'?p.postMedian:p.postAbsolute,color:colors.post}].forEach(s=>{
  107. series.push({name:s.name,type:'line',data:coordinates(s.values),showSymbol:false,lineStyle:{width:3.5,color:s.color},itemStyle:{color:s.color}});groups.push({label:s.name,names:[s.name],color:s.color})});
  108. const chart=echarts.init(dom);
  109. chart.setOption({animation:false,title:{text:kind==='pv'?'P-V功图(归一化压力)':'绝对压力-角度曲线',left:'center',top:8,textStyle:{fontSize:17}},legend:{show:false,data:series.map(s=>s.name)},tooltip:{trigger:'item',formatter:param=>`${param.seriesName}<br>${kind==='pv'?'体积':'角度'}:${param.value[0].toFixed(3)}${kind==='pv'?' L':'°'}<br>压力:${param.value[1].toFixed(5)}`},grid:{left:64,right:25,top:72,bottom:64},xAxis:{type:'value',name:kind==='pv'?'气缸体积 (L)':'曲轴角度 (°)',nameLocation:'middle',nameGap:30},yAxis:{type:'value',name:kind==='pv'?'归一化压力':'压力 (MPa)'},dataZoom:[{type:'inside'},{type:'slider',bottom:8,height:16}],series});
  110. legend(legendDom,chart,groups);return chart;
  111. }
  112. function drawTrend(dom,legendDom,p){
  113. const series=['3月上中旬','3月下旬','4月'].map((name,i)=>({name,type:'scatter',symbolSize:6,data:p.samples.filter(s=>s.period===name).map(s=>({value:[s.date.replace(' ','T'),s.distance],date:s.date})),itemStyle:{color:[colors.pre,'#8b7c5c',colors.post][i]}}));
  114. const days=new Map();p.samples.forEach(s=>{const day=s.date.slice(0,10);if(!days.has(day))days.set(day,[]);days.get(day).push(s.distance)});
  115. const med=a=>{a=[...a].sort((x,y)=>x-y);const i=Math.floor(a.length/2);return a.length%2?a[i]:(a[i-1]+a[i])/2};
  116. const daily=[];let previous=null;days.forEach((values,day)=>{const time=Date.parse(day+'T12:00:00');if(previous!==null&&time-previous>2*86400000)daily.push([new Date(previous+86400000).toISOString(),null]);daily.push([day+'T12:00:00',med(values)]);previous=time});
  117. series.push({name:'每日中位',type:'line',data:daily,connectNulls:false,showSymbol:true,symbolSize:5,lineStyle:{width:2,color:'#2f6f8f'},itemStyle:{color:'#2f6f8f'}});
  118. const chart=echarts.init(dom);chart.setOption({animation:false,title:{text:'逐周期形状偏离',left:'center',top:8,textStyle:{fontSize:17}},legend:{show:false,data:series.map(s=>s.name)},tooltip:{trigger:'item',formatter:params=>`${params.seriesName}<br>${params.data.date||params.value[0]}<br>形状距离:${Number(params.value[1]).toFixed(5)}`},grid:{left:64,right:25,top:72,bottom:64},xAxis:{type:'time',name:'实际采样日期',nameLocation:'middle',nameGap:30},yAxis:{type:'value',name:'形状距离(90点)'},dataZoom:[{type:'inside'},{type:'slider',bottom:8,height:16}],series});
  119. legend(legendDom,chart,series.map(s=>({label:s.name,names:[s.name],color:s.itemStyle.color})));return chart;
  120. }
  121. function renderMachine(machine){
  122. const section=el('section','machine');section.append(el('h2',null,machine.unit+'号机'));
  123. const toolbar=el('div','toolbar','测点:'),select=el('select');machine.points.forEach((p,i)=>{const o=el('option',null,p.point);o.value=i;select.append(o)});toolbar.append(select);section.append(toolbar);
  124. const coverage=el('div','coverage'),grid=el('div','grid');section.append(coverage,grid);document.getElementById('app').append(section);
  125. let charts=[];
  126. function render(){charts.forEach(c=>c.dispose());charts=[];grid.replaceChildren();const p=machine.points[Number(select.value)];if(!p){coverage.textContent='无可比较测点';return}
  127. coverage.textContent=`3月上中旬:${p.preDates[0]} ~ ${p.preDates.at(-1)}(${p.preDates.length}天 / ${p.preCount}周期);4月:${p.postDates[0]} ~ ${p.postDates.at(-1)}(${p.postDates.length}天 / ${p.postCount}周期)。基线:3月上中旬每日中位功图的中位。`;
  128. ['pv','abs','trend'].forEach(kind=>{const wrap=el('div','chart-wrap'),dom=el('div','chart'),labels=el('div','legend');wrap.append(dom,labels);grid.append(wrap);charts.push(kind==='trend'?drawTrend(dom,labels,p):drawCurve(dom,labels,p,kind))});
  129. }
  130. select.onchange=render;render();resizers.push(()=>charts.forEach(c=>c.resize()));
  131. }
  132. if(typeof echarts==='undefined')document.getElementById('app').textContent='ECharts加载失败,请联网后刷新。';else DATA.machines.forEach(renderMachine);
  133. window.addEventListener('resize',()=>resizers.forEach(resize=>resize()));
  134. </script></body></html>'''
  135. return template.replace("__PAYLOAD__", json.dumps(data, ensure_ascii=False, separators=(",", ":"), allow_nan=False))
  136. def main():
  137. parser = argparse.ArgumentParser(description="生成实际功图的7/8/9号春季同期对照")
  138. parser.add_argument("--root", default="cache/indicator")
  139. parser.add_argument("--out", required=True)
  140. args = parser.parse_args()
  141. data = build(Path(args.root))
  142. out = Path(args.out)
  143. out.parent.mkdir(parents=True, exist_ok=True)
  144. out.write_text(make_html(data), encoding="utf-8")
  145. print(f"春季同期网页 -> {out}")
  146. if __name__ == "__main__":
  147. main()