machine_event_html.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. """整机多缸振动事件 HTML:振动 1×6 子图 + 异常缸温度子图。"""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from datetime import timedelta
  6. from pathlib import Path
  7. import numpy as np
  8. import pandas as pd
  9. from . import config, data
  10. try:
  11. from backend.app.services import temperature_transition_service as temp_service
  12. except Exception:
  13. temp_service = None
  14. SLOT_MINUTES = 15
  15. WAVEFORM_POINTS = 16
  16. CONTEXT_HOURS = 12
  17. TEMP_SEARCH_HOURS = 24
  18. TEMP_MAX_LABELS = 2
  19. COLORS = ["#2980b9", "#e67e22", "#16a085", "#8e44ad", "#7f8c8d", "#d35400"]
  20. def _finite(value):
  21. return None if pd.isna(value) else round(float(value), 4)
  22. def _slot_of(moment: pd.Timestamp, start: pd.Timestamp) -> int:
  23. return int((pd.Timestamp(moment) - pd.Timestamp(start)).total_seconds() // (SLOT_MINUTES * 60))
  24. def _baseline_rms(meta: dict) -> float:
  25. return float(10 ** float(meta.get("rms_log", [0.0, 1.0])[0]))
  26. def _temp_cylinder(label: str) -> str | None:
  27. match = re.match(r"^(一级|二级)(\d)#缸$", str(label or "").strip())
  28. if not match:
  29. return None
  30. return config.STAGE_CYLINDER_TO_GLOBAL.get((match.group(1), f"{match.group(2)}#"))
  31. def _load_temperature_labels() -> pd.DataFrame:
  32. path = config.BASE_DIR.parent / "_tmp_temperature" / "out" / "温度异常段.csv"
  33. if not path.exists():
  34. return pd.DataFrame()
  35. frame = pd.read_csv(path, encoding="utf-8-sig")
  36. frame["_csv_index"] = frame.index
  37. frame = frame[frame["机组"].astype(str).str.strip() == "5号机"].copy()
  38. frame["开始小时"] = pd.to_datetime(frame["开始小时"], errors="coerce")
  39. frame["结束小时"] = pd.to_datetime(frame["结束小时"], errors="coerce")
  40. frame["cylinder"] = frame["阶段部位"].map(_temp_cylinder)
  41. return frame.dropna(subset=["开始小时", "结束小时", "cylinder"]).reset_index(drop=True)
  42. def _select_temperatures(temp: pd.DataFrame, event_start: pd.Timestamp, event_end: pd.Timestamp) -> pd.DataFrame:
  43. if temp.empty:
  44. return temp
  45. window_start = event_start - pd.Timedelta(hours=TEMP_SEARCH_HOURS)
  46. window_end = event_end + pd.Timedelta(hours=TEMP_SEARCH_HOURS)
  47. part = temp[(temp["结束小时"] >= window_start) & (temp["开始小时"] <= window_end)].copy()
  48. if part.empty:
  49. return part
  50. part["distance_hours"] = part.apply(
  51. lambda row: min(
  52. abs((row["开始小时"] - event_end).total_seconds() / 3600.0),
  53. abs((event_start - row["结束小时"]).total_seconds() / 3600.0),
  54. 0.0,
  55. ),
  56. axis=1,
  57. )
  58. return part.sort_values("distance_hours").head(TEMP_MAX_LABELS)
  59. def _temperature_subplot(row: pd.Series, context_start: pd.Timestamp, context_end: pd.Timestamp) -> dict | None:
  60. if temp_service is None:
  61. return None
  62. stage_name = re.match(r"^(一级|二级)", str(row["阶段部位"] or ""))
  63. if not stage_name:
  64. return None
  65. stage = stage_name.group(1)
  66. columns = temp_service.CYLINDERS.get(5, {}).get(stage)
  67. if not columns:
  68. return None
  69. batch = int(float(row.get("批次") or 0))
  70. rows = temp_service._query(5, batch, context_start, context_end)
  71. if not rows:
  72. return None
  73. target = str(row["阶段部位"]).replace("缸", "")
  74. peer_colors = ["#1f6feb", "#2e9e5b", "#8e44ad"]
  75. target_color = "#c0392b"
  76. stride = max(1, len(rows) // 20000)
  77. series_out = []
  78. all_values = []
  79. peer_index = 0
  80. for column, name in columns:
  81. is_target = name.startswith(target)
  82. color = target_color if is_target else peer_colors[peer_index % len(peer_colors)]
  83. if not is_target:
  84. peer_index += 1
  85. points = []
  86. for row_index in range(0, len(rows), stride):
  87. item = rows[row_index]
  88. value = temp_service._temp_value(item.get(column))
  89. if value is None:
  90. continue
  91. moment = pd.Timestamp(item["t"])
  92. local_slot = (moment - context_start).total_seconds() / (SLOT_MINUTES * 60)
  93. points.append([round(local_slot, 4), round(float(value), 2)])
  94. all_values.append(float(value))
  95. if not points:
  96. continue
  97. series_out.append({
  98. "name": name,
  99. "color": color,
  100. "is_target": is_target,
  101. "data": points,
  102. })
  103. if not series_out:
  104. return None
  105. limits = temp_service._limits(5, [column for column, _ in columns])
  106. highs = [limits[column]["high"] for column, _ in columns if limits[column]["high"] is not None]
  107. high_highs = [limits[column]["high_high"] for column, _ in columns if limits[column]["high_high"] is not None]
  108. refs = []
  109. if highs:
  110. refs.append({"value": min(highs), "label": "高报"})
  111. if high_highs:
  112. refs.append({"value": min(high_highs), "label": "高高报"})
  113. y_min, y_max = temp_service._padded(all_values)
  114. band_start = max(0.0, (row["开始小时"] - context_start).total_seconds() / (SLOT_MINUTES * 60))
  115. band_end = min(
  116. (context_end - context_start).total_seconds() / (SLOT_MINUTES * 60),
  117. (row["结束小时"] - context_start).total_seconds() / (SLOT_MINUTES * 60),
  118. )
  119. band = [round(band_start, 3), round(band_end, 3)] if band_end > band_start else None
  120. return {
  121. "cylinder": row["cylinder"],
  122. "stage_part": str(row["阶段部位"]),
  123. "level": str(row["温度阶段"]),
  124. "direction": str(row["方向"] or ""),
  125. "start": str(row["开始小时"]),
  126. "end": str(row["结束小时"]),
  127. "title": f"{stage}缸排气温度(异常缸:{target})",
  128. "unit": "℃",
  129. "ref_lines": refs,
  130. "y_min": round(y_min, 4),
  131. "y_max": round(y_max, 4),
  132. "series": series_out,
  133. "band": band,
  134. }
  135. def export_machine_events(
  136. frame: pd.DataFrame,
  137. events: pd.DataFrame,
  138. candidates: pd.DataFrame,
  139. validation: pd.DataFrame,
  140. ) -> Path:
  141. frame = frame.copy()
  142. frame["sample_time"] = pd.to_datetime(frame["sample_time"])
  143. frame = frame.dropna(subset=["cylinder"]).sort_values(["cylinder", "sample_time"])
  144. if frame.empty or events.empty:
  145. raise RuntimeError("缺少特征或整机事件,无法生成 HTML")
  146. candidates = candidates.copy()
  147. candidates["开始小时"] = pd.to_datetime(candidates["开始小时"])
  148. candidates["结束小时"] = pd.to_datetime(candidates["结束小时"])
  149. candidates["cylinder"] = candidates["阶段部位"].astype(str).str.replace("5号机", "", regex=False)
  150. temperatures = _load_temperature_labels()
  151. baseline = json.loads((config.OUTPUT_DIR / "baseline_full.json").read_text(encoding="utf-8"))
  152. validation_map = {}
  153. for row in validation.itertuples(index=False):
  154. if row.hit:
  155. validation_map[row.machine_label_id] = {
  156. "event_id": row.event_id,
  157. "lead_days": row.lead_days,
  158. }
  159. segments = []
  160. needed_ids: set[int] = set()
  161. for item in events.itertuples(index=False):
  162. event_start = pd.Timestamp(item.开始小时)
  163. event_end = pd.Timestamp(item.结束小时)
  164. selected_temps = _select_temperatures(temperatures, event_start, event_end)
  165. context_start = event_start - timedelta(hours=CONTEXT_HOURS)
  166. context_end = event_end + timedelta(hours=CONTEXT_HOURS)
  167. for _, temp_row in selected_temps.iterrows():
  168. context_start = min(context_start, pd.Timestamp(temp_row["开始小时"]) - timedelta(hours=2))
  169. context_end = max(context_end, pd.Timestamp(temp_row["结束小时"]) + timedelta(hours=2))
  170. slot_count = max(1, int((context_end - context_start).total_seconds() // (SLOT_MINUTES * 60)) + 1)
  171. part = frame[(frame["sample_time"] >= context_start) & (frame["sample_time"] <= context_end)]
  172. part = part.drop_duplicates(["cylinder", "sample_time"], keep="first")
  173. blocks: dict[str, list[list]] = {}
  174. for name in config.CYLINDERS:
  175. pieces = []
  176. for row in part[part["cylinder"] == name].itertuples(index=False):
  177. local_slot = _slot_of(pd.Timestamp(row.sample_time), context_start)
  178. file_id = int(row.id)
  179. pieces.append([
  180. local_slot,
  181. file_id,
  182. _finite(row.score),
  183. _finite(row.kurt),
  184. _finite(row.kurt_ratio),
  185. _finite(row.rms_ratio),
  186. ])
  187. needed_ids.add(file_id)
  188. blocks[name] = pieces
  189. context_labels = {}
  190. for name in config.CYLINDERS:
  191. areas = []
  192. for row in candidates[candidates["cylinder"] == name].itertuples(index=False):
  193. start = pd.Timestamp(row.开始小时)
  194. end = pd.Timestamp(row.结束小时)
  195. if end <= event_start or start >= event_end:
  196. if start < context_end and end > context_start:
  197. areas.append([
  198. max(0, _slot_of(start, context_start)),
  199. min(slot_count, _slot_of(end, context_start)),
  200. row.阶段,
  201. ])
  202. context_labels[name] = areas
  203. temp_subplots = []
  204. temp_labels = []
  205. for _, temp_row in selected_temps.iterrows():
  206. temp_labels.append({
  207. "cylinder": temp_row["cylinder"],
  208. "stage_part": temp_row["阶段部位"],
  209. "level": temp_row["温度阶段"],
  210. "direction": temp_row["方向"] or "",
  211. "start": str(temp_row["开始小时"]),
  212. "end": str(temp_row["结束小时"]),
  213. })
  214. subplot = _temperature_subplot(temp_row, context_start, context_end)
  215. if subplot:
  216. temp_subplots.append(subplot)
  217. event_start_slot = max(0, _slot_of(event_start, context_start))
  218. event_end_slot = min(slot_count, _slot_of(event_end, context_start))
  219. validation_info = validation_map.get(item.标签ID)
  220. segments.append({
  221. "id": item.标签ID,
  222. "level": item.阶段,
  223. "start": str(event_start),
  224. "end": str(event_end),
  225. "start_slot": event_start_slot,
  226. "end_slot": event_end_slot,
  227. "context_start": str(context_start),
  228. "slot_count": slot_count,
  229. "participating": item.参与缸,
  230. "cylinders": int(item.参与缸数),
  231. "abnormal_cylinders": int(item.异常缸数),
  232. "severe_cylinders": int(item.严重异常缸数),
  233. "bad_labels": int(item.异常标签数),
  234. "severe_labels": int(item.严重异常标签数),
  235. "candidate_labels": int(item.候选标签数),
  236. "max_score": item.最大异常分数,
  237. "rpm": item.转速范围,
  238. "run_start": str(item.运行段开始),
  239. "run_end": str(item.运行段结束),
  240. "reason": item.阶段触发依据,
  241. "validation": validation_info,
  242. "temperature_labels": temp_labels,
  243. "temperature_subplots": temp_subplots,
  244. "blocks": blocks,
  245. "context_labels": context_labels,
  246. })
  247. samples = data.fetch_samples(sorted(needed_ids), chunk_size=200)
  248. waveforms: dict[int, list[list[float]]] = {}
  249. for file_id, signal in samples.items():
  250. if signal is None or signal.size == 0:
  251. continue
  252. row = frame[frame["id"] == file_id]
  253. if row.empty:
  254. continue
  255. cylinder = row.iloc[0]["cylinder"]
  256. scale = _baseline_rms(baseline.get(cylinder, {}))
  257. if not np.isfinite(scale) or scale <= 0:
  258. scale = float(np.sqrt(np.mean(signal ** 2))) or 1.0
  259. chosen = np.linspace(0, signal.size - 1, WAVEFORM_POINTS).astype(int)
  260. waveforms[file_id] = [
  261. [round(i / (WAVEFORM_POINTS - 1), 4), round(float(signal[idx]) / scale, 4)]
  262. for i, idx in enumerate(chosen)
  263. ]
  264. payload = {"segments": segments, "waveforms": waveforms}
  265. out_dir = config.OUTPUT_DIR / "html"
  266. out_dir.mkdir(parents=True, exist_ok=True)
  267. html = """<!doctype html>
  268. <html lang="zh-CN"><head><meta charset="utf-8"><title>5号机整机振动事件</title>
  269. <style>
  270. html,body{height:100%;overflow:hidden}
  271. body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:16px;color:#222}
  272. .panel{display:grid;grid-template-columns:350px 1fr;gap:14px;height:calc(100vh - 32px);align-items:stretch}
  273. .left{min-width:0;display:flex;flex-direction:column;height:100%;min-height:0}
  274. .left h1{font-size:19px;text-align:center;margin:0 0 8px;flex:0 0 auto}
  275. .filters{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:7px;flex:0 0 auto}
  276. .filters button{border:1px solid #ddd;background:#fff;border-radius:4px;padding:4px 7px;font-size:11px;cursor:pointer;color:#444}
  277. .filters button.active{background:#2c3e50;border-color:#2c3e50;color:#fff}
  278. .list{flex:1;min-height:0;overflow:auto;border:1px solid #eee;border-radius:6px;padding:8px;background:#fafafa}
  279. .card{border:1px solid #e5e5e5;border-radius:6px;padding:7px 8px;margin-bottom:6px;cursor:pointer;background:#fff;font-size:12px;line-height:1.5}
  280. .card:hover{border-color:#999}.card.active{border-color:#c0392b;background:#fff5f5}
  281. .card .time{font-weight:600}.card .sub{color:#666;font-size:11px;margin-top:2px}
  282. .card .badges{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}
  283. .badge{color:#c0392b;border:1px solid #e6a5a5;background:#fff1f1;border-radius:999px;padding:1px 6px;font-size:10px;white-space:nowrap}
  284. .badge.gray{color:#555;border-color:#ddd;background:#fafafa}
  285. .badge.temp{color:#1e8449;border-color:#a9dfbf;background:#eafaf1}
  286. .right{min-width:0;display:flex;flex-direction:column;height:100%;min-height:0;overflow-y:auto;overflow-x:hidden;padding-right:2px}
  287. .meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;flex:0 0 auto}
  288. .meta div{border:1px solid #eee;border-radius:5px;padding:5px 8px;font-size:12px;line-height:1.35;background:#fff;display:flex;gap:5px;align-items:baseline;white-space:nowrap}
  289. .meta b{color:#777;font-size:11px;font-weight:500}
  290. .meta .time{flex:0 0 285px}.meta .context{flex:0 0 205px}
  291. .meta .reason{flex:1 1 100%;white-space:normal;color:#555;font-size:11px}
  292. .meta .temp{white-space:normal}
  293. .legend-bar{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin:0 0 8px 2px;font-size:12px;color:#444;flex:0 0 auto}
  294. .legend-bar span{display:inline-flex;align-items:center;white-space:nowrap}
  295. .legend-bar i{display:inline-block;width:22px;height:0;border-top:2px solid #333;margin-right:4px;vertical-align:middle}
  296. .legend-bar i.score{border-top-color:#e67e22}
  297. .legend-bar i.kurt{border-top:2px dashed #8e44ad}
  298. .legend-bar i.temp{border-top-color:#1e8449}
  299. .legend-bar i.bg{height:10px;width:20px;border:1px solid #ddd;border-radius:2px}
  300. .legend-bar i.bg.red{background:rgba(231,76,60,0.25);border-color:#e6a5a5}
  301. .legend-bar i.bg.yellow{background:rgba(241,196,15,0.25);border-color:#e8d58a}
  302. .legend-bar i.bg.blue{background:rgba(52,152,219,0.22);border-color:#9ec9e8}
  303. .legend-bar i.bg.green{background:rgba(39,174,96,0.22);border-color:#9bd4b0}
  304. .legend-bar i.bg.stripe{background:linear-gradient(90deg,#f5f5f5 50%,#fff 50%);background-size:8px 8px}
  305. #chart{flex:1 0 auto;min-height:520px;border:1px solid #eee;border-radius:6px}
  306. </style></head><body>
  307. <div class="panel">
  308. <div class="left">
  309. <h1>5号机整机振动事件</h1>
  310. <div class="filters" id="filters">
  311. <button data-filter="all" class="active">全部</button>
  312. <button data-filter="severe">严重异常</button>
  313. <button data-filter="anomaly">异常</button>
  314. <button data-filter="validated">已验证</button>
  315. </div>
  316. <div id="list" class="list"></div>
  317. </div>
  318. <div class="right">
  319. <div id="meta" class="meta"></div>
  320. <div class="legend-bar">
  321. <span><i></i>振动波形(左轴)</span>
  322. <span><i class="score"></i>异常分数(右轴)</span>
  323. <span><i class="kurt"></i>实际峭度(右轴,虚线)</span>
  324. <span><i class="temp"></i>温度曲线(异常缸同级3缸,左轴)</span>
  325. <span><i class="bg red"></i>参与整机事件缸</span>
  326. <span><i class="bg yellow"></i>事件时间但未参与缸</span>
  327. <span><i class="bg blue"></i>上下文候选</span>
  328. <span><i class="bg green"></i>温度标签</span>
  329. <span><i class="bg stripe"></i>15 分钟槽位交替</span>
  330. </div>
  331. <div id="chart"></div>
  332. </div>
  333. </div>
  334. <script src="echarts.min.js"></script>
  335. <script>
  336. const payload = __PAYLOAD__;
  337. const segments = payload.segments;
  338. const waveforms = payload.waveforms;
  339. const names = ['一缸','二缸','三缸','四缸','五缸','六缸'];
  340. const colors = ['#2980b9','#e67e22','#16a085','#8e44ad','#7f8c8d','#d35400'];
  341. const list = document.getElementById('list');
  342. const chart = echarts.init(document.getElementById('chart'));
  343. let currentIndex = null;
  344. function pointsFor(cylinder, blocks) {
  345. const points = [];
  346. let previous = null;
  347. for (const [slot, fileId] of blocks[cylinder]) {
  348. if (previous !== null && slot > previous + 1) points.push([previous + 1, null]);
  349. const wave = waveforms[fileId] || [];
  350. for (const [fraction, value] of wave) points.push([slot + fraction, value]);
  351. previous = slot;
  352. }
  353. return points;
  354. }
  355. function featurePoints(blocks, field) {
  356. const points = [];
  357. let previous = null;
  358. for (const block of blocks) {
  359. const slot = block[0];
  360. const value = block[field];
  361. if (value === null || value === undefined) continue;
  362. if (previous !== null && slot > previous + 1) points.push([previous + 1, null]);
  363. points.push([slot + 0.5, value]);
  364. previous = slot;
  365. }
  366. return points;
  367. }
  368. const fmt = d => `${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
  369. let activeFilter = 'all';
  370. function applyFilter() {
  371. const cards = Array.from(document.querySelectorAll('.card'));
  372. let firstVisible = null;
  373. for (const card of cards) {
  374. const s = segments[Number(card.dataset.index)];
  375. let show = true;
  376. if (activeFilter === 'severe') show = s.level === '严重异常';
  377. else if (activeFilter === 'anomaly') show = s.level === '异常';
  378. else if (activeFilter === 'validated') show = Boolean(s.validation);
  379. card.style.display = show ? 'block' : 'none';
  380. if (show && firstVisible === null) firstVisible = card;
  381. }
  382. const active = document.querySelector('.card.active');
  383. if (active && active.style.display === 'none' && firstVisible) firstVisible.click();
  384. }
  385. function updateFilterCounts() {
  386. const counts = {
  387. all: segments.length,
  388. severe: segments.filter(s => s.level === '严重异常').length,
  389. anomaly: segments.filter(s => s.level === '异常').length,
  390. validated: segments.filter(s => s.validation).length,
  391. };
  392. const labels = {all: '全部', severe: '严重异常', anomaly: '异常', validated: '已验证'};
  393. document.querySelectorAll('#filters button').forEach(button => {
  394. const key = button.dataset.filter;
  395. button.textContent = `${labels[key]}(${counts[key] || 0})`;
  396. });
  397. }
  398. function makeCards() {
  399. segments.forEach((s, i) => {
  400. const card = document.createElement('div');
  401. card.className = 'card';
  402. card.dataset.index = i;
  403. const validation = s.validation ? `<span class="badge">验证 ${s.validation.event_id} · ${s.validation.lead_days} 天</span>` : '<span class="badge gray">未命中维修事件</span>';
  404. const temp = (s.temperature_labels || []).length ? `<span class="badge temp">温度 ${s.temperature_labels.map(t => t.cylinder).join('/')}</span>` : '';
  405. card.innerHTML = `<div class="time">${s.start.slice(0,16)} ~ ${s.end.slice(0,16)}</div>
  406. <div class="sub">${s.level} · 参与 ${s.cylinders} 缸 · 异常 ${s.abnormal_cylinders} · 严重 ${s.severe_cylinders}</div>
  407. <div class="sub">${s.participating}</div>
  408. <div class="badges"><span class="badge gray">${s.id}</span>${validation}${temp}</div>`;
  409. card.addEventListener('click', () => render(i));
  410. list.appendChild(card);
  411. });
  412. }
  413. function render(index) {
  414. const s = segments[index];
  415. if (!s) return;
  416. currentIndex = index;
  417. document.querySelectorAll('.card').forEach(card => card.classList.toggle('active', Number(card.dataset.index) === index));
  418. const contextStart = new Date(s.context_start.replace(' ', 'T'));
  419. const startMs = contextStart.getTime();
  420. const contextEnd = new Date(startMs + s.slot_count * 15 * 60 * 1000);
  421. const tempMeta = (s.temperature_labels || []).length
  422. ? s.temperature_labels.map(t => `<div class="temp"><b>温度标签</b>${t.cylinder} ${t.stage_part} · ${t.level} · ${t.start.slice(5,16)}~${t.end.slice(5,16)}</div>`).join('')
  423. : '<div class="temp"><b>温度标签</b>附近无 5 号机温度标签</div>';
  424. document.getElementById('meta').innerHTML = `
  425. <div class="time"><b>事件</b>${s.id}</div>
  426. <div><b>时间</b>${s.start} ~ ${s.end}</div>
  427. <div><b>等级</b>${s.level}</div>
  428. <div><b>参与缸</b>${s.participating}</div>
  429. <div><b>异常/严重</b>${s.abnormal_cylinders}/${s.severe_cylinders}</div>
  430. <div><b>候选标签</b>${s.candidate_labels}</div>
  431. <div><b>最大分数</b>${s.max_score ?? '-'}</div>
  432. <div><b>转速</b>${s.rpm || '-'}</div>
  433. <div class="context"><b>上下文</b>${fmt(contextStart)} ~ ${fmt(contextEnd)}</div>
  434. ${tempMeta}
  435. <div class="reason"><b>触发依据</b>${s.reason}</div>`;
  436. const stripes = [];
  437. for (let slot = 1; slot < s.slot_count; slot += 2) stripes.push([{xAxis: slot}, {xAxis: slot + 1}]);
  438. const eventAreas = [[{xAxis: s.start_slot}, {xAxis: s.end_slot}]];
  439. const participating = new Set((s.participating || '').split(' / ').map(item => item.trim()));
  440. const grids = [], xAxes = [], yAxes = [], titles = [], series = [], legends = [];
  441. const tempRows = s.temperature_subplots || [];
  442. const rows = [
  443. ...names.map((name, idx) => ({kind: 'vib', name, idx})),
  444. ...tempRows.map((item, idx) => ({kind: 'temp', ...item, idx: names.length + idx})),
  445. ];
  446. const chartHeight = chart.getHeight() || 800;
  447. const top0 = 22, bottomSpace = 82, gap = 7;
  448. const rowHeight = Math.max(38, (chartHeight - top0 - bottomSpace - gap * (rows.length - 1)) / rows.length);
  449. const xIndexes = rows.map((_, idx) => idx);
  450. rows.forEach((row, ri) => {
  451. const top = top0 + ri * (rowHeight + gap);
  452. const bottomRow = ri === rows.length - 1;
  453. grids.push({left: 82, right: 28, top, height: rowHeight});
  454. xAxes.push({gridIndex: ri, type: 'value', min: 0, max: s.slot_count, interval: Math.max(8, Math.ceil(s.slot_count / 12)),
  455. axisLabel: {show: bottomRow, formatter: v => fmt(new Date(startMs + v * 15 * 60 * 1000)), fontSize: 9}, splitLine: {show: false}});
  456. const rightAxis = row.kind === 'vib';
  457. yAxes.push({gridIndex: ri, type: 'value', scale: row.kind === 'vib', min: row.kind === 'temp' ? row.y_min : undefined, max: row.kind === 'temp' ? row.y_max : undefined, axisLabel: {fontSize: 8}});
  458. yAxes.push({gridIndex: ri, type: 'value', position: 'right', scale: true, show: rightAxis, axisLabel: {fontSize: 7}, splitLine: {show: false}});
  459. if (row.kind === 'vib') {
  460. titles.push({text: `cyl${row.idx+1}`, left: 'center', top: top - 20, textStyle: {fontSize: 11, color: '#333'}});
  461. const contextAreas = (s.context_labels[row.name] || []).map(([a,b]) => [{xAxis:a},{xAxis:b}]);
  462. const eventColor = participating.has(row.name) ? 'rgba(231,76,60,0.20)' : 'rgba(241,196,15,0.20)';
  463. series.push(
  464. {name: '__stripes' + ri, type: 'line', data: [], xAxisIndex: ri, yAxisIndex: ri * 2, silent: true, z: 0, markArea: {silent: true, itemStyle: {color: 'rgba(245,245,245,0.95)'}, data: stripes}},
  465. {name: '__context' + ri, type: 'line', data: [], xAxisIndex: ri, yAxisIndex: ri * 2, silent: true, z: 0.8, markArea: {silent: true, itemStyle: {color: 'rgba(52,152,219,0.18)'}, data: contextAreas}},
  466. {name: '__event' + ri, type: 'line', data: [], xAxisIndex: ri, yAxisIndex: ri * 2, silent: true, z: 1, markArea: {silent: true, itemStyle: {color: eventColor}, data: eventAreas}},
  467. {name: `cyl${row.idx+1}`, type: 'line', data: pointsFor(row.name, s.blocks), xAxisIndex: ri, yAxisIndex: ri * 2,
  468. showSymbol: false, connectNulls: false, sampling: 'lttb', z: 3,
  469. lineStyle: {width: 1.2}, itemStyle: {color: colors[row.idx]}},
  470. {name: `score${row.idx+1}`, type: 'line', data: featurePoints(s.blocks[row.name], 2), xAxisIndex: ri, yAxisIndex: ri * 2 + 1,
  471. showSymbol: false, connectNulls: false, z: 4, lineStyle: {width: 1.2, color: '#e67e22'}, itemStyle: {color: '#e67e22'}},
  472. {name: `kurt${row.idx+1}`, type: 'line', data: featurePoints(s.blocks[row.name], 3), xAxisIndex: ri, yAxisIndex: ri * 2 + 1,
  473. showSymbol: false, connectNulls: false, z: 4, lineStyle: {width: 1.1, type: 'dashed', color: '#8e44ad'}, itemStyle: {color: '#8e44ad'}}
  474. );
  475. } else {
  476. const color = row.level === '严重异常' ? '#8e0000' : (row.level === '异常' ? '#c0392b' : '#b26a00');
  477. titles.push({text: `${row.title}(${row.unit})`, left: 'center', top: top - 20, textStyle: {fontSize: 11, fontWeight: 600, color: color}});
  478. const refs = (row.ref_lines || []).map(line => ({yAxis: line.value, lineStyle: {type: 'dashed', color: '#aab2bd', width: 0.9}, label: {show: true, formatter: line.label || '', position: 'insideEndTop', fontSize: 9, color: '#8a94a6'}}));
  479. const tempAreas = row.band ? [[{xAxis: row.band[0]}, {xAxis: row.band[1]}]] : [];
  480. const tempSeries = row.series || [];
  481. legends.push({data: tempSeries.map(item => item.name), top: top - 25, right: 36, itemWidth: 12, itemHeight: 8, selectedMode: false, textStyle: {fontSize: 10, color: '#5b6472'}});
  482. series.push(
  483. {name: '__stripes-temp' + ri, type: 'line', data: [], xAxisIndex: ri, yAxisIndex: ri * 2, silent: true, z: 0, markArea: {silent: true, itemStyle: {color: 'rgba(245,245,245,0.95)'}, data: stripes}},
  484. {name: '__event-temp' + ri, type: 'line', data: [], xAxisIndex: ri, yAxisIndex: ri * 2, silent: true, z: 1, markArea: {silent: true, itemStyle: {color: 'rgba(241,196,15,0.16)'}, data: eventAreas}},
  485. {name: '__temp-label' + ri, type: 'line', data: [], xAxisIndex: ri, yAxisIndex: ri * 2, silent: true, z: 1.1, markArea: {silent: true, itemStyle: {color: 'rgba(39,174,96,0.22)'}, data: tempAreas}}
  486. );
  487. tempSeries.forEach((item, ti) => {
  488. series.push({
  489. name: item.name, type: 'line', data: item.data, xAxisIndex: ri, yAxisIndex: ri * 2,
  490. showSymbol: false, symbol: 'none', animation: false, sampling: 'lttb', connectNulls: false,
  491. z: item.is_target ? 4 : 3,
  492. lineStyle: {width: item.is_target ? 2.2 : 1.2, color: item.color},
  493. itemStyle: {color: item.color},
  494. markLine: item.is_target && refs.length ? {silent: true, symbol: 'none', data: refs} : undefined
  495. });
  496. });
  497. }
  498. });
  499. chart.setOption({
  500. animation: false,
  501. tooltip: {trigger: 'axis', axisPointer: {link: [{xAxisIndex: 'all'}]}},
  502. title: titles,
  503. legend: legends,
  504. grid: grids,
  505. xAxis: xAxes,
  506. yAxis: yAxes,
  507. dataZoom: [
  508. {type: 'inside', xAxisIndex: xIndexes, start: 0, end: 100},
  509. {type: 'slider', xAxisIndex: xIndexes, bottom: 8, height: 18}
  510. ],
  511. series
  512. }, true);
  513. }
  514. makeCards();
  515. updateFilterCounts();
  516. document.querySelectorAll('#filters button').forEach(button => {
  517. button.addEventListener('click', () => {
  518. document.querySelectorAll('#filters button').forEach(item => item.classList.toggle('active', item === button));
  519. activeFilter = button.dataset.filter;
  520. applyFilter();
  521. });
  522. });
  523. chart.resize();
  524. const initialIndex = Math.max(0, Math.min(segments.length - 1, Number(new URLSearchParams(location.search).get('i') || 0)));
  525. render(initialIndex);
  526. const activeCard = document.querySelector('.card.active');
  527. if (activeCard) activeCard.scrollIntoView({block: 'center'});
  528. window.addEventListener('resize', () => {
  529. chart.resize();
  530. if (currentIndex !== null) render(currentIndex);
  531. });
  532. </script></body></html>"""
  533. html = html.replace("__PAYLOAD__", json.dumps(payload, ensure_ascii=False))
  534. target = out_dir / "machine_events.html"
  535. target.write_text(html, encoding="utf-8")
  536. index = out_dir / "index.html"
  537. index.write_text(
  538. """<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>5号机缸体振动</title>
  539. <style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:24px}a{color:#1a73e8}</style>
  540. </head><body><h1>5号机缸体振动</h1><p><a href="machine_events.html">打开整机多缸事件标签</a></p><p><a href="waveforms.html">打开单缸候选波形(历史)</a></p></body></html>""",
  541. encoding="utf-8",
  542. )
  543. print(f"[machine-html] {target}")
  544. return target