html_waveforms.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """全时间段标签的 HTML:选中一个标签,看该时刻的首周期多缸归一化波形。"""
  2. from __future__ import annotations
  3. import json
  4. from pathlib import Path
  5. import numpy as np
  6. import pandas as pd
  7. from . import config, data
  8. COLORS = {
  9. "一缸": "#c0392b",
  10. "二缸": "#e67e22",
  11. "三缸": "#16a085",
  12. "四缸": "#2980b9",
  13. "五缸": "#8e44ad",
  14. "六缸": "#7f8c8d",
  15. }
  16. MAX_POINTS = 520
  17. def _nearest_row(frame: pd.DataFrame, cylinder: str, target: pd.Timestamp, tolerance=pd.Timedelta(hours=2)):
  18. group = frame[frame["cylinder"] == cylinder]
  19. if group.empty:
  20. return None
  21. distance = (group["sample_time"] - target).abs()
  22. index = distance.idxmin()
  23. if distance.loc[index] > tolerance:
  24. return None
  25. return group.loc[index]
  26. def _baseline_rms(meta: dict) -> float:
  27. median = float(meta.get("rms_log", [0.0, 1.0])[0])
  28. return float(10 ** median)
  29. def export_waveforms(frame: pd.DataFrame, intervals: pd.DataFrame, baseline: dict) -> None:
  30. if frame.empty or intervals.empty:
  31. return
  32. out_dir = config.OUTPUT_DIR / "html"
  33. out_dir.mkdir(parents=True, exist_ok=True)
  34. records: list[dict] = []
  35. id_to_row: dict[int, pd.Series] = {}
  36. for row in intervals.itertuples(index=False):
  37. seed_time = pd.Timestamp(row.max_time)
  38. rows: dict[str, pd.Series] = {}
  39. for name in config.CYLINDERS:
  40. item = _nearest_row(frame, name, seed_time)
  41. if item is not None:
  42. rows[name] = item
  43. id_to_row[int(item["id"])] = item
  44. if not rows:
  45. continue
  46. records.append(
  47. {
  48. "id": len(records),
  49. "cylinder": row.cylinder,
  50. "start": str(row.start_time),
  51. "end": str(row.end_time),
  52. "duration": float(row.duration_hours),
  53. "max_score": float(row.max_score),
  54. "direction": str(row.direction),
  55. "level": str(row.level),
  56. "confirmed": bool(row.multi_feature_confirmed),
  57. "seed_time": str(seed_time),
  58. "rows": {name: int(item["id"]) for name, item in rows.items()},
  59. }
  60. )
  61. samples = data.fetch_samples(list(id_to_row), chunk_size=200)
  62. for record in records:
  63. series = []
  64. for name, file_id in record.pop("rows").items():
  65. signal = samples.get(int(file_id))
  66. if signal is None or signal.size == 0:
  67. continue
  68. item = id_to_row[int(file_id)]
  69. scale = _baseline_rms(baseline.get(name, {}))
  70. if not np.isfinite(scale) or scale <= 0:
  71. scale = float(np.sqrt(np.mean(signal ** 2))) or 1.0
  72. fs = float(item["sample_frequency_hz"] or 0) or 25600.0
  73. step = max(1, int(np.ceil(signal.size / MAX_POINTS)))
  74. chosen = np.arange(0, signal.size, step)
  75. x = chosen / fs * 1000.0
  76. y = signal[chosen] / scale
  77. series.append(
  78. {
  79. "name": f"cyl{config.CYLINDER_TO_INDEX[name]}",
  80. "cylinder": name,
  81. "color": "#c0392b" if name == record["cylinder"] else COLORS[name],
  82. "width": 2.2 if name == record["cylinder"] else 1.0,
  83. "data": [[round(float(x[i]), 3), round(float(y[i]), 4)] for i in range(len(chosen))],
  84. }
  85. )
  86. record["series"] = series
  87. html = """<!doctype html>
  88. <html lang="zh-CN"><head><meta charset="utf-8"><title>5号机全时段缸体振动标签</title>
  89. <style>
  90. body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:18px;color:#222;background:#fff}
  91. h1{font-size:22px;margin:0 0 6px} .note{color:#666;font-size:13px;line-height:1.6;margin-bottom:12px}
  92. .panel{display:grid;grid-template-columns:300px 1fr;gap:16px}
  93. select{width:100%;padding:8px;font-size:13px}
  94. .info{border:1px solid #eee;border-radius:6px;padding:10px;margin-top:10px;font-size:13px;line-height:1.7}
  95. #chart{height:520px;border:1px solid #eee;border-radius:6px}
  96. table{border-collapse:collapse;margin-top:10px;font-size:12px;width:100%}
  97. th,td{border:1px solid #ddd;padding:5px 7px;text-align:left} th{background:#f7f7f7}
  98. </style></head><body>
  99. <h1>5号机全时段缸体振动标签 · 首周期多缸归一化波形</h1>
  100. <div class="note">下拉选择标签;横轴是首周期时间(ms),纵轴是“波形 / 该缸正常基线 RMS”。事件缸加粗红色。缺数据的缸不画。</div>
  101. <div class="panel">
  102. <div>
  103. <select id="pick"></select>
  104. <div id="info" class="info"></div>
  105. <table id="table"><thead><tr><th>开始</th><th>方向</th><th>等级</th><th>确认</th></tr></thead><tbody></tbody></table>
  106. </div>
  107. <div id="chart"></div>
  108. </div>
  109. <script src="echarts.min.js"></script>
  110. <script>
  111. const records = __RECORDS__;
  112. const pick = document.getElementById('pick');
  113. const chart = echarts.init(document.getElementById('chart'));
  114. for (const r of records) {
  115. const opt = document.createElement('option');
  116. opt.value = r.id;
  117. opt.text = `${r.start} | ${r.cylinder} | ${r.direction} | ${r.level} | 分数 ${r.max_score}${r.confirmed ? ' | 确认' : ''}`;
  118. pick.appendChild(opt);
  119. }
  120. function render(id) {
  121. const r = records.find(x => x.id === Number(id));
  122. if (!r) return;
  123. document.getElementById('info').innerHTML = `<b>${r.start} ~ ${r.end}</b><br>缸:${r.cylinder}<br>方向:${r.direction}<br>等级:${r.level}<br>多特征确认:${r.confirmed ? '是' : '否'}<br>最大分数:${r.max_score}<br>种子时刻:${r.seed_time}`;
  124. const tbody = document.querySelector('#table tbody');
  125. tbody.innerHTML = `<tr><td>${r.start}</td><td>${r.direction}</td><td>${r.level}</td><td>${r.confirmed ? '是' : '否'}</td></tr>`;
  126. chart.setOption({
  127. tooltip: {trigger: 'axis'},
  128. legend: {top: 0},
  129. grid: {left: 55, right: 20, top: 35, bottom: 45},
  130. xAxis: {type: 'value', name: 'ms', scale: true},
  131. yAxis: {type: 'value', name: 'waveform / baseline RMS', scale: true},
  132. dataZoom: [{type: 'inside'}, {type: 'slider', height: 18, bottom: 10}],
  133. series: r.series.map(s => ({
  134. name: s.name, type: 'line', showSymbol: false, sampling: 'lttb',
  135. lineStyle: {width: s.width}, itemStyle: {color: s.color}, data: s.data
  136. }))
  137. }, true);
  138. }
  139. pick.addEventListener('change', () => render(pick.value));
  140. const first = records.filter(r => r.confirmed).sort((a, b) => b.max_score - a.max_score)[0] || records[0];
  141. if (first) { pick.value = first.id; render(first.id); }
  142. window.addEventListener('resize', () => chart.resize());
  143. </script></body></html>"""
  144. html = html.replace("__RECORDS__", json.dumps(records, ensure_ascii=False))
  145. (out_dir / "waveforms.html").write_text(html, encoding="utf-8")
  146. (out_dir / "index.html").write_text(
  147. """<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>5号机缸体振动</title>
  148. <style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:24px}a{color:#1a73e8}</style>
  149. </head><body><h1>5号机缸体振动</h1><p><a href="waveforms.html">打开全时段标签波形</a></p></body></html>""",
  150. encoding="utf-8",
  151. )
  152. print(f"[html] {out_dir / 'waveforms.html'} ({len(records)} labels)")