context_html.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. """小时标签 + 前后 6 小时上下文 + 15 分钟槽位波形。"""
  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
  9. SLOT_MINUTES = 15
  10. WAVEFORM_POINTS = 16
  11. CONTEXT_HOURS = 6
  12. COLORS = {
  13. "一缸": "#2980b9",
  14. "二缸": "#e67e22",
  15. "三缸": "#16a085",
  16. "四缸": "#8e44ad",
  17. "五缸": "#7f8c8d",
  18. "六缸": "#d35400",
  19. }
  20. def _slot_of(moment: pd.Timestamp, start: pd.Timestamp) -> int:
  21. return int((pd.Timestamp(moment) - start).total_seconds() // (SLOT_MINUTES * 60))
  22. def _baseline_rms(meta: dict) -> float:
  23. return float(10 ** float(meta.get("rms_log", [0.0, 1.0])[0]))
  24. def export_context(frame: pd.DataFrame, intervals: pd.DataFrame, hourly_labels: pd.DataFrame, baseline: dict) -> None:
  25. frame = frame.copy()
  26. frame["sample_time"] = pd.to_datetime(frame["sample_time"])
  27. frame = frame.dropna(subset=["cylinder"]).sort_values(["cylinder", "sample_time"])
  28. if frame.empty or hourly_labels.empty:
  29. return
  30. global_start = frame["sample_time"].min().floor(f"{SLOT_MINUTES}min")
  31. global_end = frame["sample_time"].max().ceil(f"{SLOT_MINUTES}min")
  32. frame["slot"] = ((frame["sample_time"] - global_start).dt.total_seconds() // (SLOT_MINUTES * 60)).astype(int)
  33. frame = frame.drop_duplicates(["cylinder", "slot"], keep="first")
  34. frame_by_slot: dict[int, dict[str, pd.Series]] = {}
  35. for row in frame.itertuples(index=False):
  36. frame_by_slot.setdefault(int(row.slot), {})[row.cylinder] = row
  37. segments: list[dict] = []
  38. needed_ids: set[int] = set()
  39. for item in hourly_labels.itertuples(index=False):
  40. context_start = pd.Timestamp(item.start_hour) - timedelta(hours=CONTEXT_HOURS)
  41. context_end = pd.Timestamp(item.end_hour) + timedelta(hours=CONTEXT_HOURS)
  42. context_start = max(context_start, global_start)
  43. context_end = min(context_end, global_end)
  44. context_start_slot = _slot_of(context_start, global_start)
  45. context_end_slot = _slot_of(context_end, global_start)
  46. slot_count = context_end_slot - context_start_slot + 1
  47. label_start = _slot_of(item.start_hour, global_start) - context_start_slot
  48. label_end = _slot_of(item.end_hour, global_start) - context_start_slot
  49. blocks: dict[str, list[list[int]]] = {}
  50. for name in config.CYLINDERS:
  51. pieces = []
  52. for local_slot in range(slot_count):
  53. row = frame_by_slot.get(context_start_slot + local_slot, {}).get(name)
  54. if row is None:
  55. continue
  56. file_id = int(row.id)
  57. def finite(value):
  58. return None if pd.isna(value) else round(float(value), 4)
  59. pieces.append([
  60. local_slot,
  61. file_id,
  62. finite(row.score),
  63. finite(row.kurt),
  64. finite(row.kurt_ratio),
  65. finite(row.rms_ratio),
  66. ])
  67. needed_ids.add(file_id)
  68. blocks[name] = pieces
  69. event_hits = []
  70. for event in config.EVENTS:
  71. if event["cylinder"] != item.cylinder:
  72. continue
  73. center = pd.Timestamp(event["date"])
  74. if pd.Timestamp(item.start_hour) <= center + timedelta(days=1) and pd.Timestamp(item.end_hour) >= center - timedelta(days=7):
  75. event_hits.append(event["event_id"])
  76. segments.append(
  77. {
  78. "cylinder": item.cylinder,
  79. "event_hits": event_hits,
  80. "start_hour": str(item.start_hour),
  81. "end_hour": str(item.end_hour),
  82. "hour_count": int(item.hour_count),
  83. "direction": item.direction,
  84. "level": item.level,
  85. "confirmed": bool(item.confirmed),
  86. "max_score": float(item.max_score),
  87. "context_start": str(context_start),
  88. "slot_count": slot_count,
  89. "label_start": label_start,
  90. "label_end": label_end,
  91. "blocks": blocks,
  92. }
  93. )
  94. print(f"[context] segments={len(segments)}, unique files={len(needed_ids)}", flush=True)
  95. samples = data.fetch_samples(sorted(needed_ids), chunk_size=200)
  96. waveforms: dict[int, list[list[float]]] = {}
  97. for file_id, signal in samples.items():
  98. if signal is None or signal.size == 0:
  99. continue
  100. row = frame[frame["id"] == file_id]
  101. if row.empty:
  102. continue
  103. cylinder = row.iloc[0]["cylinder"]
  104. scale = _baseline_rms(baseline.get(cylinder, {}))
  105. if not np.isfinite(scale) or scale <= 0:
  106. scale = float(np.sqrt(np.mean(signal ** 2))) or 1.0
  107. chosen = np.linspace(0, signal.size - 1, WAVEFORM_POINTS).astype(int)
  108. waveforms[file_id] = [
  109. [round(i / (WAVEFORM_POINTS - 1), 4), round(float(signal[idx]) / scale, 4)]
  110. for i, idx in enumerate(chosen)
  111. ]
  112. print(f"[context] cached waveforms={len(waveforms)}", flush=True)
  113. payload = {"segments": segments, "waveforms": waveforms}
  114. out_dir = config.OUTPUT_DIR / "html"
  115. out_dir.mkdir(parents=True, exist_ok=True)
  116. html = """<!doctype html>
  117. <html lang="zh-CN"><head><meta charset="utf-8"><title>5号机小时标签</title>
  118. <style>
  119. html,body{height:100%;overflow:hidden}
  120. body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:16px;color:#222}
  121. .panel{display:grid;grid-template-columns:330px 1fr;gap:14px;height:calc(100vh - 32px);align-items:stretch}
  122. .left{min-width:0;display:flex;flex-direction:column;height:100%;min-height:0}
  123. .left h1{font-size:19px;text-align:center;margin:0 0 8px;flex:0 0 auto}
  124. .filters{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:7px;flex:0 0 auto}
  125. .filters button{border:1px solid #ddd;background:#fff;border-radius:4px;padding:4px 7px;font-size:11px;cursor:pointer;color:#444}
  126. .filters button.active{background:#2c3e50;border-color:#2c3e50;color:#fff}
  127. .list{flex:1;min-height:0;overflow:auto;border:1px solid #eee;border-radius:6px;padding:8px;background:#fafafa}
  128. .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;display:flex;justify-content:space-between;gap:8px;align-items:center}
  129. .card:hover{border-color:#999}.card.active{border-color:#c0392b;background:#fff5f5}
  130. .card .time{font-weight:600}.card .sub{color:#666;font-size:11px}
  131. .badge{flex:0 0 auto;color:#c0392b;border:1px solid #e6a5a5;background:#fff1f1;border-radius:999px;padding:2px 7px;font-size:11px;white-space:nowrap}
  132. .right{min-width:0;display:flex;flex-direction:column;height:100%;min-height:0}
  133. .meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;flex:0 0 auto}
  134. .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}
  135. .meta b{color:#777;font-size:11px;font-weight:500}
  136. .meta .time{flex:0 0 285px}.meta .context{flex:0 0 205px}
  137. .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}
  138. .legend-bar span{display:inline-flex;align-items:center;white-space:nowrap}
  139. .legend-bar i{display:inline-block;width:22px;height:0;border-top:2px solid #333;margin-right:4px;vertical-align:middle}
  140. .legend-bar i.score{border-top-color:#e67e22}
  141. .legend-bar i.kurt{border-top:2px dashed #8e44ad}
  142. .legend-bar i.bg{height:10px;width:20px;border:1px solid #ddd;border-radius:2px}
  143. .legend-bar i.bg.red{background:rgba(231,76,60,0.25);border-color:#e6a5a5}
  144. .legend-bar i.bg.yellow{background:rgba(241,196,15,0.25);border-color:#e8d58a}
  145. .legend-bar i.bg.stripe{background:linear-gradient(90deg,#f5f5f5 50%,#fff 50%);background-size:8px 8px}
  146. .legend-bar i.bg.context{background:rgba(52,152,219,0.22);border-color:#9ec9e8}
  147. #chart{flex:1;min-height:0;border:1px solid #eee;border-radius:6px}
  148. </style></head><body>
  149. <div class="panel">
  150. <div class="left">
  151. <h1>5号机小时标签</h1>
  152. <div class="filters" id="filters">
  153. <button data-filter="all" class="active">全部</button>
  154. <button data-filter="event">事件窗口</button>
  155. <button data-filter="minor">轻微</button>
  156. <button data-filter="anomaly">异常</button>
  157. <button data-filter="severe">严重异常</button>
  158. <button data-filter="confirmed">确认</button>
  159. </div>
  160. <div id="list" class="list"></div>
  161. </div>
  162. <div class="right">
  163. <div id="meta" class="meta"></div>
  164. <div class="legend-bar">
  165. <span><i></i>波形(各缸颜色,左轴)</span>
  166. <span><i class="score"></i>异常分数(右轴)</span>
  167. <span><i class="kurt"></i>实际峭度(右轴,虚线)</span>
  168. <span><i class="bg red"></i>当前缸故障段</span>
  169. <span><i class="bg yellow"></i>其他缸故障段</span>
  170. <span><i class="bg stripe"></i>15 分钟槽位交替</span>
  171. <span><i class="bg context"></i>上下文内其他标签</span>
  172. </div>
  173. <div id="chart"></div>
  174. </div>
  175. </div>
  176. <script src="echarts.min.js"></script>
  177. <script>
  178. const payload = __PAYLOAD__;
  179. const segments = payload.segments;
  180. const waveforms = payload.waveforms;
  181. const names = ['一缸','二缸','三缸','四缸','五缸','六缸'];
  182. const colors = ['#2980b9','#e67e22','#16a085','#8e44ad','#7f8c8d','#d35400'];
  183. const list = document.getElementById('list');
  184. const chart = echarts.init(document.getElementById('chart'));
  185. let currentIndex = null;
  186. function pointsFor(cylinder, blocks) {
  187. const points = [];
  188. let previous = null;
  189. for (const [slot, fileId] of blocks[cylinder]) {
  190. if (previous !== null && slot > previous + 1) points.push([previous + 1, null]);
  191. const wave = waveforms[fileId] || [];
  192. for (const [fraction, value] of wave) points.push([slot + fraction, value]);
  193. previous = slot;
  194. }
  195. return points;
  196. }
  197. const fmt = d => `${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:00`;
  198. let activeFilter = 'all';
  199. function applyFilter() {
  200. const cards = Array.from(document.querySelectorAll('.card'));
  201. let firstVisible = null;
  202. for (const card of cards) {
  203. const s = segments[Number(card.dataset.index)];
  204. let show = true;
  205. if (activeFilter === 'event') show = card.dataset.event === '1';
  206. else if (activeFilter === 'confirmed') show = card.dataset.confirmed === '1';
  207. else if (activeFilter === 'minor') show = s.level === '轻微';
  208. else if (activeFilter === 'anomaly') show = s.level === '异常';
  209. else if (activeFilter === 'severe') show = s.level === '严重异常';
  210. card.style.display = show ? 'flex' : 'none';
  211. if (show && firstVisible === null) firstVisible = card;
  212. }
  213. const active = document.querySelector('.card.active');
  214. if (active && active.style.display === 'none' && firstVisible) firstVisible.click();
  215. }
  216. function updateFilterCounts() {
  217. const counts = {
  218. all: segments.length,
  219. event: segments.filter(s => s.event_hits && s.event_hits.length).length,
  220. minor: segments.filter(s => s.level === '轻微').length,
  221. anomaly: segments.filter(s => s.level === '异常').length,
  222. severe: segments.filter(s => s.level === '严重异常').length,
  223. confirmed: segments.filter(s => s.confirmed).length,
  224. };
  225. const labels = {all: '全部', event: '事件窗口', minor: '轻微', anomaly: '异常', severe: '严重异常', confirmed: '确认'};
  226. document.querySelectorAll('#filters button').forEach(button => {
  227. const key = button.dataset.filter;
  228. button.textContent = `${labels[key]}(${counts[key] || 0})`;
  229. });
  230. }
  231. function makeCards() {
  232. segments.forEach((s, i) => {
  233. const card = document.createElement('div');
  234. card.className = 'card';
  235. card.dataset.index = i;
  236. card.dataset.event = s.event_hits && s.event_hits.length ? '1' : '0';
  237. card.dataset.confirmed = s.confirmed ? '1' : '0';
  238. card.dataset.direction = s.direction;
  239. const badge = s.event_hits && s.event_hits.length ? `<span class="badge">事件 ${s.event_hits.join(' / ')}</span>` : '';
  240. card.innerHTML = `<div><div class="time">${s.start_hour.slice(0,16)} ~ ${s.end_hour.slice(0,16)}</div>
  241. <div class="sub">${s.cylinder} · ${s.direction} · ${s.level}${s.confirmed ? ' · 确认' : ''}</div></div>${badge}`;
  242. card.addEventListener('click', () => render(i));
  243. list.appendChild(card);
  244. });
  245. }
  246. function featurePoints(blocks, field) {
  247. const points = [];
  248. let previous = null;
  249. for (const block of blocks) {
  250. const slot = block[0];
  251. const value = block[field];
  252. if (value === null || value === undefined) continue;
  253. if (previous !== null && slot > previous + 1) points.push([previous + 1, null]);
  254. points.push([slot + 0.5, value]);
  255. previous = slot;
  256. }
  257. return points;
  258. }
  259. function render(index) {
  260. const s = segments[index];
  261. if (!s) return;
  262. currentIndex = index;
  263. document.querySelectorAll('.card').forEach(card => card.classList.toggle('active', Number(card.dataset.index) === index));
  264. const contextStart = new Date(s.context_start.replace(' ', 'T'));
  265. const startMs = contextStart.getTime();
  266. const contextEnd = new Date(startMs + s.slot_count * 15 * 60 * 1000);
  267. document.getElementById('meta').innerHTML = `
  268. <div class="time"><b>时间</b>${s.start_hour} ~ ${s.end_hour}</div>
  269. <div><b>缸</b>${s.cylinder}</div>
  270. <div><b>方向</b>${s.direction}</div>
  271. <div><b>等级</b>${s.level}</div>
  272. <div><b>多特征确认</b>${s.confirmed ? '是' : '否'}</div>
  273. <div class="context"><b>上下文</b>${fmt(contextStart)} ~ ${fmt(contextEnd)}</div>
  274. <div><b>最大分数</b>${s.max_score}</div>
  275. <div><b>小时数</b>${s.hour_count}</div>`;
  276. const stripes = [];
  277. for (let slot = 1; slot < s.slot_count; slot += 2) stripes.push([{xAxis: slot}, {xAxis: slot + 1}]);
  278. const labelAreas = [[{xAxis: s.label_start}, {xAxis: s.label_end}]];
  279. const grids = [], xAxes = [], yAxes = [], titles = [], series = [];
  280. const chartHeight = chart.getHeight() || 800;
  281. const top0 = 22, bottomSpace = 62, gap = 8;
  282. const rowHeight = Math.max(50, (chartHeight - top0 - bottomSpace - gap * 5) / 6);
  283. const toLocalSlot = hour => (new Date(hour.replace(' ', 'T')).getTime() - startMs) / (15 * 60 * 1000);
  284. for (let idx = 0; idx < names.length; idx++) {
  285. const name = names[idx];
  286. const top = top0 + idx * (rowHeight + gap);
  287. const bottomRow = idx === names.length - 1;
  288. grids.push({left: 82, right: 28, top, height: rowHeight});
  289. xAxes.push({gridIndex: idx, type: 'value', min: 0, max: s.slot_count, interval: 8,
  290. axisLabel: {show: bottomRow, formatter: v => fmt(new Date(startMs + v * 15 * 60 * 1000)), fontSize: 9}, splitLine: {show: false}});
  291. yAxes.push({gridIndex: idx, type: 'value', scale: true, axisLabel: {fontSize: 8}});
  292. yAxes.push({gridIndex: idx, type: 'value', position: 'right', scale: true, axisLabel: {fontSize: 7}, splitLine: {show: false}});
  293. titles.push({text: `cyl${idx+1}`, left: 'center', top: top - 20, textStyle: {fontSize: 11, color: name === s.cylinder ? '#c0392b' : '#333'}});
  294. const labelColor = name === s.cylinder ? 'rgba(231,76,60,0.20)' : 'rgba(241,196,15,0.22)';
  295. const otherAreas = [];
  296. for (let oi = 0; oi < segments.length; oi++) {
  297. if (oi === index) continue;
  298. const other = segments[oi];
  299. if (other.cylinder !== name) continue;
  300. const a = Math.max(0, toLocalSlot(other.start_hour));
  301. const b = Math.min(s.slot_count, toLocalSlot(other.end_hour));
  302. if (b > a + 0.01) otherAreas.push([{xAxis: a}, {xAxis: b}]);
  303. }
  304. series.push(
  305. {name: '__stripes' + idx, type: 'line', data: [], xAxisIndex: idx, yAxisIndex: idx * 2, silent: true, z: 0, markArea: {silent: true, itemStyle: {color: 'rgba(245,245,245,0.95)'}, data: stripes}},
  306. {name: '__context' + idx, type: 'line', data: [], xAxisIndex: idx, yAxisIndex: idx * 2, silent: true, z: 0.8, markArea: {silent: true, itemStyle: {color: 'rgba(52,152,219,0.18)'}, data: otherAreas}},
  307. {name: '__label' + idx, type: 'line', data: [], xAxisIndex: idx, yAxisIndex: idx * 2, silent: true, z: 1, markArea: {silent: true, itemStyle: {color: labelColor}, data: labelAreas}},
  308. {name: `cyl${idx+1}`, type: 'line', data: pointsFor(name, s.blocks), xAxisIndex: idx, yAxisIndex: idx * 2,
  309. showSymbol: false, connectNulls: false, sampling: 'lttb', z: 3,
  310. lineStyle: {width: name === s.cylinder ? 2.0 : 1.1},
  311. itemStyle: {color: name === s.cylinder ? '#c0392b' : colors[idx]}},
  312. {name: `score${idx+1}`, type: 'line', data: featurePoints(s.blocks[name], 2), xAxisIndex: idx, yAxisIndex: idx * 2 + 1,
  313. showSymbol: false, connectNulls: false, z: 4, lineStyle: {width: 1.2, color: '#e67e22'}, itemStyle: {color: '#e67e22'}},
  314. {name: `kurt${idx+1}`, type: 'line', data: featurePoints(s.blocks[name], 3), xAxisIndex: idx, yAxisIndex: idx * 2 + 1,
  315. showSymbol: false, connectNulls: false, z: 4, lineStyle: {width: 1.1, type: 'dashed', color: '#8e44ad'}, itemStyle: {color: '#8e44ad'}}
  316. );
  317. }
  318. chart.setOption({
  319. animation: false,
  320. tooltip: {trigger: 'axis', axisPointer: {link: [{xAxisIndex: 'all'}]}},
  321. title: titles,
  322. grid: grids,
  323. xAxis: xAxes,
  324. yAxis: yAxes,
  325. dataZoom: [
  326. {type: 'inside', xAxisIndex: [0,1,2,3,4,5], start: 0, end: 100},
  327. {type: 'slider', xAxisIndex: [0,1,2,3,4,5], bottom: 12, height: 20}
  328. ],
  329. series
  330. }, true);
  331. }
  332. makeCards();
  333. updateFilterCounts();
  334. document.querySelectorAll('#filters button').forEach(button => {
  335. button.addEventListener('click', () => {
  336. document.querySelectorAll('#filters button').forEach(item => item.classList.toggle('active', item === button));
  337. activeFilter = button.dataset.filter;
  338. applyFilter();
  339. });
  340. });
  341. const first = segments.map((s, i) => [s, i]).filter(([s]) => s.event_hits && s.event_hits.length)[0]
  342. || segments.map((s, i) => [s, i]).filter(([s]) => s.confirmed).sort((a, b) => b[0].max_score - a[0].max_score)[0]
  343. || [segments[0], 0];
  344. chart.resize();
  345. render(first[1]);
  346. const activeCard = document.querySelector('.card.active');
  347. if (activeCard) activeCard.scrollIntoView({block: 'center'});
  348. window.addEventListener('resize', () => {
  349. chart.resize();
  350. if (currentIndex !== null) render(currentIndex);
  351. });
  352. </script></body></html>"""
  353. html = html.replace("__PAYLOAD__", json.dumps(payload, ensure_ascii=False))
  354. (out_dir / "waveforms.html").write_text(html, encoding="utf-8")
  355. (out_dir / "index.html").write_text(
  356. """<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>5号机缸体振动</title>
  357. <style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:24px}a{color:#1a73e8}</style>
  358. </head><body><h1>5号机缸体振动</h1><p><a href="waveforms.html">打开小时标签波形</a></p></body></html>""",
  359. encoding="utf-8",
  360. )
  361. print(f"[html] {out_dir / 'waveforms.html'}")