| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- #!/usr/bin/env python3
- """Build an interactive ECharts dashboard for the high-density one-cylinder events."""
- from __future__ import annotations
- import argparse
- import json
- import sys
- from collections import defaultdict
- from datetime import datetime, timedelta
- from pathlib import Path
- import numpy as np
- BACKEND = Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(BACKEND))
- from extract_from_db import fetch_cycles, fetch_file_rows, point_name, sample_per_day # noqa: E402
- from indicator_features.indicator_features import smooth, _plateau_levels, volume_from_angle # noqa: E402
- from app.config import settings # noqa: E402
- from extract_from_db import connect # noqa: E402
- from new_unit_events import collect_events, top_cylinder_event # noqa: E402
- EVENTS = {
- "7号机": {"unit": "7", "date": "2026-05-21", "label": "7号机 2026-05-21 气阀事件", "pre_days": 30, "post_days": 30},
- "8号机(2026)": {"unit": "8", "date": "2026-03-30", "label": "8号机 2026-03-29/04-01 合并事件", "pre_days": 30, "post_days": 30},
- "8号机(2025同期)": {"unit": "8", "date": "2025-03-30", "label": "8号机 2025年3月底/4月初同期工况", "pre_days": 30, "post_days": 60},
- }
- SIDES = ("盖侧", "轴侧")
- COLORS = {"pre": "#4f7cac", "post": "#d95d39", "early": "#91a7c2", "late": "#e69f72"}
- def collect(connection, unit: str, side: str, event_date: str, per_day: int, bins: int, pre_days: int, post_days: int):
- t0 = datetime.fromisoformat(event_date)
- start = (t0 - timedelta(days=pre_days)).date().isoformat()
- end = (t0 + timedelta(days=post_days + 1)).date().isoformat()
- files = fetch_file_rows(connection, point_name(unit[0], "一", side), start, end)
- picked = sample_per_day(files, per_day)
- cycles = fetch_cycles(connection, [int(r["id"]) for r in picked], 200)
- result = []
- for row in picked:
- signal = cycles.get(int(row["id"]))
- if signal is None:
- continue
- if len(signal) < 60 or not np.all(np.isfinite(signal)):
- continue
- pressure = smooth(signal, 21)
- suction, discharge, _ = _plateau_levels(pressure)
- if discharge - suction < 0.3:
- continue
- profile = np.interp(np.arange(bins) * len(pressure) / bins,
- np.arange(len(pressure)), pressure)
- profile = (profile - suction) / (discharge - suction)
- dt = row["sample_time"]
- if not isinstance(dt, datetime):
- dt = datetime.fromisoformat(str(dt))
- result.append({
- "date": dt.strftime("%Y-%m-%d %H:%M:%S"),
- "day": (dt.date() - t0.date()).days,
- "phase": "修前" if dt < t0 else "修后",
- "profile": [round(float(x), 6) for x in profile],
- "absolute": [round(float(x), 6) for x in np.interp(np.arange(bins) * len(pressure) / bins, np.arange(len(pressure)), pressure)],
- "volume": [round(float(x), 6) for x in np.interp(
- np.arange(bins) * len(pressure) / bins,
- np.arange(len(pressure)),
- volume_from_angle(len(pressure), 360.0),
- )],
- "p_suction": round(suction, 6),
- "p_discharge": round(discharge, 6),
- "p_span": round(discharge - suction, 6),
- })
- return result
- def build_data(per_day: int, bins: int):
- data = {"angle": [round(i * 360 / bins, 2) for i in range(bins)], "machines": {}}
- connection = connect()
- try:
- for machine, config in EVENTS.items():
- unit = config["unit"]
- data["machines"][machine] = {"label": config["label"], "sides": {}}
- for side in SIDES:
- samples = collect(connection, unit, side, config["date"], per_day, bins, config["pre_days"], config["post_days"])
- pre = [r for r in samples if r["phase"] == "修前"]
- post = [r for r in samples if r["phase"] == "修后"]
- pre_template = np.median([r["profile"] for r in pre], axis=0) if pre else None
- if pre_template is None:
- pre_template = np.zeros(bins)
- for r in samples:
- r["distance"] = round(float(np.linalg.norm(np.asarray(r["profile"]) - pre_template)), 5)
- def median_profile(items):
- return [round(float(x), 6) for x in np.median([r["profile"] for r in items], axis=0)] if items else []
- def median_absolute(items):
- return [round(float(x), 6) for x in np.median([r["absolute"] for r in items], axis=0)] if items else []
- trend = [{"date": r["date"], "day": r["day"], "distance": r["distance"]} for r in samples]
- data["machines"][machine]["sides"][f"一缸{side}"] = {
- "samples": samples,
- "preMedian": median_profile(pre),
- "postMedian": median_profile(post),
- "preAbsoluteMedian": median_absolute(pre),
- "postAbsoluteMedian": median_absolute(post),
- "trend": trend,
- "counts": {"pre": len(pre), "post": len(post)}, "markerDays": [0],
- }
- for key, event in collect_events(connection, per_day=per_day, bins=bins).items():
- data["machines"][key] = top_cylinder_event(event)
- finally:
- connection.close()
- return data
- def html(data: dict) -> str:
- payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
- return f'''<!doctype html>
- <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
- <title>一缸功图事件前后对比</title>
- <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
- <style>
- body{{margin:0;background:#fff;color:#263238;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}}
- .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}}
- .machine{{margin:24px 0 34px;padding:0}}
- .machine h2{{margin:0 0 14px;font-size:21px}} .grid{{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}}
- .chart{{height:390px;border:1px solid #eee8de;border-radius:8px;background:#fff}} .chart-wrap{{min-width:0}}
- .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}}
- .chart-legend-container{{margin:6px 1px 0}}
- .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}}
- .legend-line{{display:inline-block;width:20px;height:0;border-top:3px solid}} .legend-line.thin{{border-top-width:2px}}
- .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}}
- .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}}
- @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}}}}
- </style></head><body><div class="wrap">
- <h1>一缸 P-V 功图事件前后对比</h1><div class="sub">每台机组每个腔体显示:四阶段 P-V 功图、压力-角度曲线、逐周期形状距离。图例在图外,可逐时间点勾选;页面按宽度自适应,每行最多3张图。</div>
- <div id="app"></div></div>
- <script>
- const colors = {{pre:'#4f7cac',post:'#d95d39',early:'#91a7c2',late:'#e69f72'}};
- const DATA = {payload}; const app=document.getElementById('app'); const charts=[];
- function el(tag, cls, text){{const e=document.createElement(tag);if(cls)e.className=cls;if(text)e.textContent=text;return e;}}
- function profileChart(dom, side, d, legendDom){{
- 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}}];
- 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}})}})}});
- 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}});
- 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}});
- 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;
- }}
- 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;}}
- 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;}}
- function renderPage(data){{app.innerHTML='';
- 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);
- for(const side of Object.keys(m.sides)){{const d=m.sides[side];
- 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));
- 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));
- 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));}}
- const ranking=m.ranking?.map(x=>x.cylinder+'缸 '+x.residual).join(',');const note=el('div','note', (ranking?'候选缸局部残差排名:'+ranking+'。':'')+'展示测点是全测点扫描后的变化候选,不等于已确认故障缸。原始周期角度,不做自动相位旋转。');box.appendChild(note);}}
- }}
- window.addEventListener('resize',()=>charts.forEach(c=>c.resize()));
- </script>
- <script>renderPage(DATA);</script>
- </body></html>'''
- def main():
- parser = argparse.ArgumentParser(description="生成一缸功图ECharts网页")
- parser.add_argument("--per-day", type=int, default=6)
- parser.add_argument("--bins", type=int, default=90)
- parser.add_argument("--out", required=True)
- args = parser.parse_args()
- data = build_data(args.per_day, args.bins)
- out = Path(args.out)
- out.parent.mkdir(parents=True, exist_ok=True)
- out.write_text(html(data), encoding="utf-8")
- print(f"HTML -> {out}")
- for machine, item in data["machines"].items():
- for side, value in item["sides"].items():
- print(machine, side, value["counts"])
- if __name__ == "__main__":
- main()
|