| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- #!/usr/bin/env python3
- """Standalone actual-waveform spring comparison for units 7, 8 and 9."""
- from __future__ import annotations
- import argparse
- import csv
- import json
- from collections import defaultdict
- from pathlib import Path
- import numpy as np
- from extract_from_db import connect, fetch_cycles
- from indicator_features.indicator_features import CYLINDER_BORE_MM, _plateau_levels, smooth, volume_from_angle
- BAD = {("7", "四", "盖侧"), ("7", "六", "盖侧"), ("8", "六", "盖侧")}
- def daily_median(samples, key):
- if not samples:
- return []
- days = defaultdict(list)
- for sample in samples:
- days[sample["date"][:10]].append(sample[key])
- return np.median([np.median(values, axis=0) for values in days.values()], axis=0).round(6).tolist()
- def build(root):
- bins = 90
- machines = []
- connection = connect()
- try:
- for unit in ("7", "8", "9"):
- filename = f"高密度特征_{unit}号_2026.csv" if unit != "9" else "高密度特征_9号_2026春季.csv"
- with (root / filename).open(encoding="utf-8-sig", newline="") as handle:
- rows = list(csv.DictReader(handle))
- rows = [r for r in rows if "2026-03-01" <= r["sample_time"][:10] < "2026-04-20"
- and float(r.get("p_span") or 0) >= .3
- and (unit, r["cylinder"], r["side"]) not in BAD]
- grouped = defaultdict(list)
- # Fetch bounded batches; retain only the compact plotted profiles.
- for offset in range(0, len(rows), 100):
- chunk = rows[offset:offset + 100]
- cycles = fetch_cycles(connection, [int(r["wave_file_id"]) for r in chunk], 100)
- for row in chunk:
- signal = cycles.get(int(row["wave_file_id"]))
- if signal is None or len(signal) < 60 or not np.all(np.isfinite(signal)):
- continue
- pressure = smooth(signal, 21)
- suction, discharge, _ = _plateau_levels(pressure)
- if discharge - suction < .3:
- continue
- indices = np.arange(bins) * len(pressure) / bins
- absolute = np.interp(indices, np.arange(len(pressure)), pressure)
- profile = (absolute - suction) / (discharge - suction)
- grouped[(row["cylinder"], row["side"])].append({
- "date": row["sample_time"], "profile": profile.round(6).tolist(),
- "absolute": absolute.round(6).tolist(),
- })
- points = []
- for (cylinder, side), samples in sorted(grouped.items()):
- samples.sort(key=lambda s: s["date"])
- march = [s for s in samples if s["date"][:10] < "2026-03-21"]
- april = [s for s in samples if s["date"][:10] >= "2026-04-01"]
- if not march or not april:
- continue
- template = np.asarray(daily_median(march, "profile"))
- for sample in samples:
- sample["distance"] = round(float(np.linalg.norm(np.asarray(sample["profile"]) - template)), 5)
- sample["period"] = "3月上中旬" if sample["date"][:10] < "2026-03-21" else "4月" if sample["date"][:10] >= "2026-04-01" else "3月下旬"
- points.append({
- "point": f"{cylinder}缸{side}", "samples": samples,
- "volume": volume_from_angle(bins, CYLINDER_BORE_MM[cylinder]).round(6).tolist(),
- "preMedian": template.tolist(), "postMedian": daily_median(april, "profile"),
- "preAbsolute": daily_median(march, "absolute"), "postAbsolute": daily_median(april, "absolute"),
- "preDates": sorted({s["date"][:10] for s in march}),
- "postDates": sorted({s["date"][:10] for s in april}),
- "preCount": len(march), "postCount": len(april),
- })
- machines.append({"unit": unit, "points": points})
- print(f"{unit}号:{len(points)}个可比较测点,{sum(len(p['samples']) for p in points)}条实际周期")
- finally:
- connection.close()
- return {"angle": (np.arange(bins) * 360 / bins).tolist(), "machines": machines}
- def make_html(data):
- template = r'''<!doctype html>
- <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
- <title>7、8、9号春季同期工况对照</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%;padding:20px 18px 40px;box-sizing:border-box}h1{margin:0 0 8px;font-size:26px}.sub{color:#66727a;line-height:1.6}
- .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}
- .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}
- .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}
- @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}}
- </style></head><body><main class="wrap"><h1>7、8、9号春季同期工况对照</h1>
- <p class="sub">蓝色为3月上中旬,红色为4月。中位功图按每天等权计算;形状距离以同一测点3月上中旬模板为参考。每台机可切换测点,每行三图。</p>
- <div id="app"></div><p class="note">同期变化不等于季节因果,也不等于故障。不同机器的可用日期并不一致;缺测处不连线、不推断变化发生时间。P-V采用现有前端体积映射,没有自动相位旋转;平台归一化不能消除全部工况影响。单周期曲线经过21点平滑和90点重采样,适合看整体形状,不用于判断高频毛刺。</p></main>
- <script>
- const DATA=__PAYLOAD__;
- const colors={pre:'#4f7cac',post:'#d95d39',early:'#7296b8',late:'#db8a60'};
- const resizers=[];
- function el(tag,cls,text){const e=document.createElement(tag);if(cls)e.className=cls;if(text)e.textContent=text;return e}
- function legend(container,chart,groups){
- groups.forEach(g=>{const label=el('label');const input=document.createElement('input');input.type='checkbox';input.checked=true;
- const line=el('i','line');line.style.borderColor=g.color;label.append(input,line,document.createTextNode(g.label));
- input.onchange=()=>g.names.forEach(name=>chart.dispatchAction({type:input.checked?'legendSelect':'legendUnSelect',name}));container.append(label)});
- }
- function drawCurve(dom,legendDom,p,kind){
- const series=[],groups=[{label:'3月单周期',names:[],color:colors.early},{label:'4月单周期',names:[],color:colors.late}];
- 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}
- 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);
- 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}}})});
- [{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=>{
- 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})});
- const chart=echarts.init(dom);
- 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});
- legend(legendDom,chart,groups);return chart;
- }
- function drawTrend(dom,legendDom,p){
- 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]}}));
- 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)});
- 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};
- 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});
- series.push({name:'每日中位',type:'line',data:daily,connectNulls:false,showSymbol:true,symbolSize:5,lineStyle:{width:2,color:'#2f6f8f'},itemStyle:{color:'#2f6f8f'}});
- 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});
- legend(legendDom,chart,series.map(s=>({label:s.name,names:[s.name],color:s.itemStyle.color})));return chart;
- }
- function renderMachine(machine){
- const section=el('section','machine');section.append(el('h2',null,machine.unit+'号机'));
- 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);
- const coverage=el('div','coverage'),grid=el('div','grid');section.append(coverage,grid);document.getElementById('app').append(section);
- let charts=[];
- function render(){charts.forEach(c=>c.dispose());charts=[];grid.replaceChildren();const p=machine.points[Number(select.value)];if(!p){coverage.textContent='无可比较测点';return}
- 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月上中旬每日中位功图的中位。`;
- ['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))});
- }
- select.onchange=render;render();resizers.push(()=>charts.forEach(c=>c.resize()));
- }
- if(typeof echarts==='undefined')document.getElementById('app').textContent='ECharts加载失败,请联网后刷新。';else DATA.machines.forEach(renderMachine);
- window.addEventListener('resize',()=>resizers.forEach(resize=>resize()));
- </script></body></html>'''
- return template.replace("__PAYLOAD__", json.dumps(data, ensure_ascii=False, separators=(",", ":"), allow_nan=False))
- def main():
- parser = argparse.ArgumentParser(description="生成实际功图的7/8/9号春季同期对照")
- parser.add_argument("--root", default="cache/indicator")
- parser.add_argument("--out", required=True)
- args = parser.parse_args()
- data = build(Path(args.root))
- out = Path(args.out)
- out.parent.mkdir(parents=True, exist_ok=True)
- out.write_text(make_html(data), encoding="utf-8")
- print(f"春季同期网页 -> {out}")
- if __name__ == "__main__":
- main()
|