make_unit_shape_timeline.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. #!/usr/bin/env python3
  2. """Build a daily-sampled, full-history indicator shape-distance dashboard."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. import json
  7. import sys
  8. from collections import defaultdict
  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 indicator_features.extract_from_db import ( # noqa: E402
  14. CYLINDERS,
  15. SIDES,
  16. connect,
  17. fetch_cycles,
  18. point_name,
  19. sample_per_day,
  20. )
  21. from indicator_features.indicator_features import _plateau_levels, smooth # noqa: E402
  22. EVENTS_BY_UNIT = {
  23. "4": [
  24. {"date": "2026-06-10", "label": "气阀故障"},
  25. ],
  26. "5": [
  27. {"date": "2026-03-26", "label": "气阀故障"},
  28. {"date": "2026-04-01", "label": "气阀故障"},
  29. {"date": "2026-04-08", "label": "气阀故障"},
  30. {"date": "2026-06-12", "label": "气阀故障"},
  31. {"date": "2026-06-21", "label": "气阀故障"},
  32. {"date": "2026-07-11", "label": "气阀故障"},
  33. ],
  34. "6": [
  35. {"date": "2026-01-19", "label": "气阀故障"},
  36. {"date": "2026-01-30", "label": "气阀故障"},
  37. {"date": "2026-02-18", "label": "气阀故障"},
  38. {"date": "2026-02-27", "label": "气阀故障"},
  39. {"date": "2026-04-22", "label": "气阀故障"},
  40. {"date": "2026-04-26", "label": "气阀故障"},
  41. ],
  42. "7": [
  43. {"date": "2025-08-08", "label": "气阀污染"},
  44. {"date": "2025-10-07", "label": "气阀故障"},
  45. {"date": "2026-05-21", "label": "气阀事件"},
  46. ],
  47. "8": [
  48. {"date": "2025-02-17", "label": "气阀故障"},
  49. {"date": "2026-03-29", "label": "气阀故障"},
  50. {"date": "2026-04-01", "label": "气阀故障"},
  51. ],
  52. "9": [],
  53. }
  54. BAD_POINTS_BY_UNIT = {
  55. "4": {("二", "盖侧"), ("四", "盖侧"), ("六", "盖侧")},
  56. "5": set(),
  57. "6": {("二", "盖侧"), ("四", "盖侧"), ("六", "盖侧")},
  58. "7": {("四", "盖侧"), ("六", "盖侧")},
  59. "8": {("六", "盖侧")},
  60. "9": set(),
  61. }
  62. MIN_SPAN = 0.3
  63. def fetch_files_with_cycles(connection, point: str) -> list[dict]:
  64. """Fetch metadata only for files that have a stored first cycle."""
  65. with connection.cursor() as cursor:
  66. cursor.execute(
  67. """
  68. SELECT wf.id, wf.sample_time, wf.rpm
  69. FROM wave_file wf
  70. WHERE wf.point_name = %s AND wf.measurement_type = '压力'
  71. AND EXISTS (
  72. SELECT 1 FROM wave_sample_one ws WHERE ws.wave_file_id = wf.id
  73. )
  74. ORDER BY wf.sample_time
  75. """,
  76. (point,),
  77. )
  78. return list(cursor.fetchall())
  79. def profile(signal: np.ndarray, bins: int) -> np.ndarray | None:
  80. signal = np.asarray(signal, dtype=float)
  81. signal = signal[np.isfinite(signal)]
  82. if len(signal) < 60:
  83. return None
  84. pressure = smooth(signal, 21)
  85. pressure = np.where(np.isfinite(pressure), pressure, signal)
  86. suction, discharge, _ = _plateau_levels(pressure)
  87. span = discharge - suction
  88. if not np.isfinite(span) or span < MIN_SPAN:
  89. return None
  90. indices = np.arange(bins, dtype=float) * len(pressure) / bins
  91. resampled = np.interp(indices, np.arange(len(pressure)), pressure)
  92. result = (resampled - suction) / span
  93. return result if np.all(np.isfinite(result)) else None
  94. def daily_template(samples: list[dict]) -> tuple[np.ndarray, dict[str, np.ndarray]]:
  95. by_day: dict[str, list[np.ndarray]] = defaultdict(list)
  96. for sample in samples:
  97. by_day[sample["time"][:10]].append(sample["profile"])
  98. daily_profiles = {
  99. day: np.median(np.vstack(values), axis=0)
  100. for day, values in by_day.items()
  101. }
  102. template = np.median(np.vstack(list(daily_profiles.values())), axis=0)
  103. return template, daily_profiles
  104. def collect(unit: str, per_day: int, bins: int) -> dict:
  105. result = {
  106. "unit": unit,
  107. "bins": bins,
  108. "perDay": per_day,
  109. "events": EVENTS_BY_UNIT[unit],
  110. "points": [],
  111. }
  112. connection = connect()
  113. try:
  114. for cylinder in CYLINDERS:
  115. for side in SIDES:
  116. if (cylinder, side) in BAD_POINTS_BY_UNIT[unit]:
  117. continue
  118. point = point_name(unit, cylinder, side)
  119. files = fetch_files_with_cycles(connection, point)
  120. picked = sample_per_day(files, per_day)
  121. cycles = fetch_cycles(connection, [int(row["id"]) for row in picked], 100)
  122. samples = []
  123. for row in picked:
  124. file_id = int(row["id"])
  125. values = profile(cycles.get(file_id, np.array([])), bins)
  126. if values is None:
  127. continue
  128. samples.append({
  129. "fileId": file_id,
  130. "time": str(row["sample_time"]),
  131. "profile": values,
  132. })
  133. samples.sort(key=lambda item: item["time"])
  134. if not samples:
  135. print(f"{point}: 无有效周期", flush=True)
  136. continue
  137. template, daily_profiles = daily_template(samples)
  138. by_day_distance: dict[str, list[float]] = defaultdict(list)
  139. for sample in samples:
  140. distance = float(np.linalg.norm(sample.pop("profile") - template))
  141. sample["distance"] = round(distance, 6)
  142. by_day_distance[sample["time"][:10]].append(distance)
  143. daily = [
  144. {"date": day, "distance": round(float(np.median(values)), 6), "count": len(values)}
  145. for day, values in sorted(by_day_distance.items())
  146. ]
  147. result["points"].append({
  148. "name": f"{cylinder}缸{side}",
  149. "availableFiles": len(files),
  150. "sampleCount": len(samples),
  151. "dayCount": len(daily_profiles),
  152. "start": samples[0]["time"][:10],
  153. "end": samples[-1]["time"][:10],
  154. "template": np.round(template, 6).tolist(),
  155. "samples": samples,
  156. "daily": daily,
  157. })
  158. print(
  159. f"{point}: 有周期文件 {len(files):,},抽取有效周期 {len(samples):,},"
  160. f"有效日期 {len(daily_profiles)},{samples[0]['time'][:10]}~{samples[-1]['time'][:10]}",
  161. flush=True,
  162. )
  163. finally:
  164. connection.close()
  165. return result
  166. def write_csv(data: dict, output: Path) -> None:
  167. output.parent.mkdir(parents=True, exist_ok=True)
  168. with output.open("w", encoding="utf-8-sig", newline="") as handle:
  169. writer = csv.DictWriter(
  170. handle,
  171. fieldnames=["机组", "测点", "文件ID", "采样时间", "形状距离", "当日样本数", "当日距离中位数"],
  172. )
  173. writer.writeheader()
  174. for point in data["points"]:
  175. daily = {row["date"]: row for row in point["daily"]}
  176. for sample in point["samples"]:
  177. summary = daily[sample["time"][:10]]
  178. writer.writerow({
  179. "机组": data["unit"], "测点": point["name"], "文件ID": sample["fileId"],
  180. "采样时间": sample["time"], "形状距离": sample["distance"],
  181. "当日样本数": summary["count"], "当日距离中位数": summary["distance"],
  182. })
  183. def make_html(data: dict) -> str:
  184. payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
  185. event_summary = "".join(
  186. f'<span class="event-line"></span>{event["date"]} {event["label"]}'
  187. for event in data["events"]
  188. ) or "该机组现有记录中没有气阀故障日期"
  189. excluded = "、".join(
  190. f"{cylinder}缸{side}" for cylinder, side in sorted(BAD_POINTS_BY_UNIT[data["unit"]])
  191. ) or "无已知坏道"
  192. return r'''<!doctype html>
  193. <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
  194. <title>__UNIT__号机全时段功图形状偏离</title><script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
  195. <style>
  196. 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{text-align:center;margin:0 0 8px;font-size:26px}.intro{max-width:1500px;margin:0 auto 14px;color:#657279;font-size:13px;line-height:1.65}.events{text-align:center;font-size:13px;margin:10px 0 18px;color:#434f55}.event-line{display:inline-block;width:22px;border-top:1.5px dashed #a33;margin:0 5px 3px 14px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px 16px}.card{min-width:0}.meta{font-size:12px;color:#68757b;line-height:1.5;min-height:36px;text-align:center}.chart{height:420px;border:1px solid #eee9e2;border-radius:7px}.legend{display:flex;gap:18px;justify-content:center;padding:7px 0 0;font-size:12px;color:#4a565c}.legend label{display:inline-flex;align-items:center;gap:5px;cursor:pointer}.legend input{margin:0;width:13px;height:13px}.dot{width:9px;height:9px;border-radius:50%;background:#6085a6;display:inline-block}.line{width:22px;border-top:3px solid #cf593d;display:inline-block}.foot{font-size:12px;color:#68757b;line-height:1.65;margin-top:18px}@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}.chart{height:390px}}
  197. </style></head><body><main class="wrap"><h1>__UNIT__号机全时段功图形状偏离</h1>
  198. <div class="intro">每个测点每天从实际存在首周期的文件中按时间均匀抽取最多__PER_DAY__个周期。每周期采用原始角度顺序,经21点平滑、90点重采样和吸排气平台归一化;不自动旋转相位。先求每日中位功图,再对所有日期等权取中位,形成该测点固定的全历史中心模板。Y轴为每个抽样周期与该模板的90点欧氏距离。</div>
  199. <div class="events">__EVENTS__</div>
  200. <div id="grid" class="grid"></div><div class="foot">排除情况:__EXCLUDED__。蓝点是抽样周期,红线是每日距离中位数;超过2天的缺测区间断线。虚线只在该测点实际数据范围内显示,且只标气阀故障日期。全历史模板是中心状态,不是经现场确认的健康模板;距离升高表示形状不像中心模板,不自动等于故障恶化。</div></main>
  201. <script>
  202. const DATA=__PAYLOAD__,charts=[];
  203. function create(tag,cls,text){const node=document.createElement(tag);if(cls)node.className=cls;if(text)node.textContent=text;return node}
  204. function brokenDaily(rows){const result=[];let previous=null;for(const row of rows){const time=Date.parse(row.date+'T12:00:00');if(previous!==null&&time-previous>2*86400000)result.push([new Date(previous+86400000).toISOString(),null]);result.push([row.date+'T12:00:00',row.distance,row.count]);previous=time}return result}
  205. function eventLines(point){return DATA.events.filter(event=>event.date>=point.start&&event.date<=point.end).map(event=>({name:event.date+' '+event.label,xAxis:event.date+'T00:00:00',lineStyle:{color:'#a33',type:'dashed',width:1.3},label:{show:true,formatter:event.date+'\n'+event.label,position:'insideEndTop',rotate:90,color:'#8a302b',fontSize:11,distance:5}}))}
  206. for(const point of DATA.points){
  207. const card=create('section','card'),meta=create('div','meta',`${point.start} ~ ${point.end} · ${point.dayCount}个有效日期 · ${point.sampleCount}个周期 · 全库有周期文件${point.availableFiles}个`),dom=create('div','chart'),labels=create('div','legend');card.append(meta,dom,labels);document.getElementById('grid').append(card);
  208. const scatterName='抽样周期',dailyName='每日中位';
  209. const scatter={name:scatterName,type:'scatter',symbolSize:5,large:true,largeThreshold:1000,data:point.samples.map(s=>({value:[s.time.replace(' ','T'),s.distance],fileId:s.fileId,time:s.time})),itemStyle:{color:'#6085a6',opacity:.58},emphasis:{itemStyle:{opacity:1}}};
  210. const daily={name:dailyName,type:'line',connectNulls:false,showSymbol:true,symbolSize:5,data:brokenDaily(point.daily),lineStyle:{color:'#cf593d',width:2.5},itemStyle:{color:'#cf593d'},markLine:{silent:false,symbol:['none','none'],animation:false,data:eventLines(point)}};
  211. const chart=echarts.init(dom);chart.setOption({animation:false,title:{text:point.name,left:'center',top:10,textStyle:{fontSize:17}},legend:{show:false,data:[scatterName,dailyName]},tooltip:{trigger:'item',confine:true,formatter:param=>{if(param.seriesName===scatterName)return `${point.name}<br>采样时间:${param.data.time}<br>形状距离:${Number(param.value[1]).toFixed(6)}<br>文件ID:${param.data.fileId}`;if(param.value&&param.value[1]!=null)return `${point.name}<br>日期:${String(param.value[0]).slice(0,10)}<br>每日中位:${Number(param.value[1]).toFixed(6)}<br>当日样本:${param.value[2]||''}`;return param.name||''}},grid:{left:68,right:24,top:72,bottom:74},xAxis:{type:'time',name:'采样日期',nameLocation:'middle',nameGap:32,axisLabel:{hideOverlap:true}},yAxis:{type:'value',name:'形状偏离距离',scale:true,splitLine:{lineStyle:{color:'#edf1f2'}}},dataZoom:[{type:'inside',filterMode:'filter'},{type:'slider',filterMode:'filter',bottom:15,height:18}],series:[scatter,daily]});
  212. [{name:scatterName,kind:'dot'},{name:dailyName,kind:'line'}].forEach(item=>{const label=create('label'),input=document.createElement('input');input.type='checkbox';input.checked=true;label.append(input,create('i',item.kind),document.createTextNode(item.name));input.onchange=()=>chart.dispatchAction({type:input.checked?'legendSelect':'legendUnSelect',name:item.name});labels.append(label)});charts.push(chart);
  213. }
  214. if(typeof echarts==='undefined')document.getElementById('grid').textContent='ECharts加载失败,请联网后刷新。';
  215. window.addEventListener('resize',()=>charts.forEach(chart=>chart.resize()));
  216. </script></body></html>'''.replace("__PAYLOAD__", payload).replace("__UNIT__", data["unit"]).replace("__PER_DAY__", str(data["perDay"])).replace("__EVENTS__", event_summary).replace("__EXCLUDED__", excluded)
  217. def main() -> None:
  218. parser = argparse.ArgumentParser(description="生成单机全时段功图形状偏离网页")
  219. parser.add_argument("--unit", default="7", choices=("4", "5", "6", "7", "8", "9"))
  220. parser.add_argument("--per-day", type=int, default=6)
  221. parser.add_argument("--bins", type=int, default=90)
  222. parser.add_argument("--html", required=True)
  223. parser.add_argument("--csv", required=True)
  224. args = parser.parse_args()
  225. data = collect(args.unit, args.per_day, args.bins)
  226. html_path, csv_path = Path(args.html), Path(args.csv)
  227. html_path.parent.mkdir(parents=True, exist_ok=True)
  228. html_path.write_text(make_html(data), encoding="utf-8")
  229. write_csv(data, csv_path)
  230. print(f"HTML -> {html_path}")
  231. print(f"CSV -> {csv_path}")
  232. if __name__ == "__main__":
  233. main()