timeline_html.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. """全时段单图:15 分钟槽位拼接 60ms 首周期波形 + 小时标签背景。"""
  2. from __future__ import annotations
  3. import json
  4. from datetime import timedelta
  5. from pathlib import Path
  6. import numpy as np
  7. import pandas as pd
  8. from . import config, data, hourly
  9. SLOT_MINUTES = 15
  10. WAVEFORM_POINTS = 20
  11. COLORS = {
  12. "一缸": "#c0392b",
  13. "二缸": "#e67e22",
  14. "三缸": "#16a085",
  15. "四缸": "#2980b9",
  16. "五缸": "#8e44ad",
  17. "六缸": "#7f8c8d",
  18. }
  19. def _slot_of(moment: pd.Timestamp, start: pd.Timestamp) -> int:
  20. return int((pd.Timestamp(moment) - start).total_seconds() // (SLOT_MINUTES * 60))
  21. def _baseline_rms(meta: dict) -> float:
  22. return float(10 ** float(meta.get("rms_log", [0.0, 1.0])[0]))
  23. def export_timeline(frame: pd.DataFrame, intervals: pd.DataFrame, baseline: dict) -> None:
  24. frame = frame.copy()
  25. frame["sample_time"] = pd.to_datetime(frame["sample_time"])
  26. frame = frame.dropna(subset=["cylinder"]).sort_values(["cylinder", "sample_time"])
  27. if frame.empty:
  28. return
  29. start = frame["sample_time"].min().floor(f"{SLOT_MINUTES}min")
  30. end = frame["sample_time"].max().ceil(f"{SLOT_MINUTES}min")
  31. slot_count = int((end - start).total_seconds() // (SLOT_MINUTES * 60)) + 1
  32. frame["slot"] = ((frame["sample_time"] - start).dt.total_seconds() // (SLOT_MINUTES * 60)).astype(int)
  33. frame = frame.drop_duplicates(["cylinder", "slot"], keep="first").sort_values(["cylinder", "slot"])
  34. rows_by_cylinder = {
  35. name: frame[frame["cylinder"] == name].sort_values("slot") for name in config.CYLINDERS
  36. }
  37. all_ids = [int(item) for item in frame["id"].tolist()]
  38. print(f"[timeline] slots={slot_count:,}, files={len(all_ids):,}, fetching waveforms ...", flush=True)
  39. samples = data.fetch_samples(all_ids, chunk_size=200)
  40. series: dict[str, list] = {}
  41. for name, group in rows_by_cylinder.items():
  42. points: list[list] = []
  43. previous_slot = None
  44. scale = _baseline_rms(baseline.get(name, {})) or 1.0
  45. for row in group.itertuples(index=False):
  46. slot = int(row.slot)
  47. if previous_slot is not None and slot > previous_slot + 1:
  48. points.append([previous_slot + 1, None])
  49. signal = samples.get(int(row.id))
  50. if signal is None or signal.size == 0:
  51. previous_slot = slot
  52. continue
  53. chosen = np.linspace(0, signal.size - 1, WAVEFORM_POINTS).astype(int)
  54. for index, sample_index in enumerate(chosen):
  55. x = slot + index / (WAVEFORM_POINTS - 1)
  56. y = float(signal[sample_index]) / scale
  57. points.append([round(x, 4), round(y, 4)])
  58. previous_slot = slot
  59. series[name] = points
  60. print(f"[timeline] {name}: slots={len(group):,}, points={len(points):,}", flush=True)
  61. hourly_labels = hourly.build_hourly(intervals)
  62. if not hourly_labels.empty:
  63. hourly_labels["start_slot"] = hourly_labels["start_hour"].map(lambda value: _slot_of(value, start))
  64. hourly_labels["end_slot"] = hourly_labels["end_hour"].map(lambda value: _slot_of(value, start))
  65. confirmed_areas = []
  66. observation_areas = []
  67. if not hourly_labels.empty:
  68. for row in hourly_labels.itertuples(index=False):
  69. area = [{"xAxis": int(row.start_slot)}, {"xAxis": int(row.end_slot)}]
  70. if row.confirmed:
  71. confirmed_areas.append(area)
  72. else:
  73. observation_areas.append(area)
  74. # 15 分钟斑马纹:只画奇数槽位。
  75. stripes = [
  76. [{"xAxis": slot}, {"xAxis": slot + 1}]
  77. for slot in range(1, slot_count, 2)
  78. ]
  79. # 初始聚焦到分数最高的标签附近,避免全时段一次性太密;底部滑块可缩放到全时段。
  80. focus_slot = slot_count // 2
  81. if not intervals.empty:
  82. peak = intervals.loc[intervals["max_score"].idxmax()]
  83. focus_slot = _slot_of(peak["max_time"], start)
  84. half_window = 48
  85. zoom_start_slot = max(0, focus_slot - half_window)
  86. zoom_end_slot = min(slot_count, focus_slot + half_window)
  87. zoom_start = round(zoom_start_slot / max(slot_count, 1) * 100, 2)
  88. zoom_end = round(zoom_end_slot / max(slot_count, 1) * 100, 2)
  89. start_ms = int(start.timestamp() * 1000)
  90. chart_series = [
  91. {
  92. "name": "__stripes",
  93. "type": "line",
  94. "data": [],
  95. "silent": True,
  96. "z": 0,
  97. "markArea": {
  98. "silent": True,
  99. "itemStyle": {"color": "rgba(245,245,245,0.95)"},
  100. "data": stripes,
  101. },
  102. },
  103. {
  104. "name": "__observation",
  105. "type": "line",
  106. "data": [],
  107. "silent": True,
  108. "z": 1,
  109. "markArea": {
  110. "silent": True,
  111. "itemStyle": {"color": "rgba(241,196,15,0.22)"},
  112. "data": observation_areas,
  113. },
  114. },
  115. {
  116. "name": "__confirmed",
  117. "type": "line",
  118. "data": [],
  119. "silent": True,
  120. "z": 1,
  121. "markArea": {
  122. "silent": True,
  123. "itemStyle": {"color": "rgba(231,76,60,0.22)"},
  124. "data": confirmed_areas,
  125. },
  126. },
  127. ]
  128. for name in config.CYLINDERS:
  129. chart_series.append(
  130. {
  131. "name": f"cyl{config.CYLINDER_TO_INDEX[name]}",
  132. "type": "line",
  133. "data": series[name],
  134. "showSymbol": False,
  135. "sampling": "lttb",
  136. "large": True,
  137. "largeThreshold": 2000,
  138. "connectNulls": False,
  139. "lineStyle": {"width": 1.0},
  140. "itemStyle": {"color": COLORS[name]},
  141. "z": 3,
  142. }
  143. )
  144. option = {
  145. "animation": False,
  146. "tooltip": {"trigger": "axis", "axisPointer": {"type": "line"}},
  147. "legend": {"top": 0, "data": [f"cyl{i}" for i in range(1, 7)]},
  148. "grid": {"left": 55, "right": 25, "top": 35, "bottom": 65},
  149. "xAxis": {
  150. "type": "value",
  151. "min": 0,
  152. "max": slot_count,
  153. "interval": 24,
  154. "axisLabel": {
  155. "formatter": "__XFORMAT__",
  156. "fontSize": 10,
  157. },
  158. "splitLine": {"show": False},
  159. },
  160. "yAxis": {"type": "value", "name": "waveform / baseline RMS", "scale": True},
  161. "dataZoom": [
  162. {"type": "inside", "start": zoom_start, "end": zoom_end},
  163. {"type": "slider", "start": zoom_start, "end": zoom_end, "height": 20, "bottom": 15},
  164. ],
  165. "series": chart_series,
  166. }
  167. formatter_js = (
  168. "function(v){const d=new Date(" + str(start_ms) + "+v*15*60*1000);"
  169. "const p=n=>String(n).padStart(2,'0');"
  170. "return p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':00';}"
  171. )
  172. option["xAxis"]["axisLabel"]["formatter"] = "__FORMATTER__"
  173. payload = json.dumps(option, ensure_ascii=False)
  174. payload = payload.replace('"__FORMATTER__"', formatter_js)
  175. out_dir = config.OUTPUT_DIR / "html"
  176. out_dir.mkdir(parents=True, exist_ok=True)
  177. html = f"""<!doctype html>
  178. <html lang="zh-CN"><head><meta charset="utf-8"><title>5号机缸体振动全时段</title>
  179. <style>
  180. body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:18px;color:#222}}
  181. h1{{font-size:21px;margin:0 0 6px}} .note{{color:#666;font-size:13px;line-height:1.6;margin-bottom:10px}}
  182. #chart{{height:720px;border:1px solid #eee;border-radius:6px}}
  183. </style></head><body>
  184. <h1>5号机全时段 · 15 分钟槽位拼接首周期波形</h1>
  185. <div class="note">每个 15 分钟槽位画该文件 0~60ms 首周期波形(归一化);浅灰/白交替 = 15 分钟槽位斑马纹;红色背景 = 多特征确认小时标签;黄色背景 = 观察小时标签;相邻小时已合并。初始聚焦最高分标签,底部滑块可看全时段。</div>
  186. <div id="chart"></div>
  187. <script src="echarts.min.js"></script>
  188. <script>
  189. const chart = echarts.init(document.getElementById('chart'));
  190. const option = {payload};
  191. chart.setOption(option);
  192. window.addEventListener('resize', () => chart.resize());
  193. </script></body></html>"""
  194. (out_dir / "timeline.html").write_text(html, encoding="utf-8")
  195. (out_dir / "index.html").write_text(
  196. """<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>5号机缸体振动</title>
  197. <style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:24px}a{color:#1a73e8}</style>
  198. </head><body><h1>5号机缸体振动</h1><p><a href="timeline.html">打开全时段 15 分钟槽位波形</a></p></body></html>""",
  199. encoding="utf-8",
  200. )
  201. print(f"[html] {out_dir / 'timeline.html'}")