| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- """小时标签 + 前后 6 小时上下文 + 15 分钟槽位波形。"""
- from __future__ import annotations
- import json
- from datetime import timedelta
- from pathlib import Path
- import numpy as np
- import pandas as pd
- from . import config, data
- SLOT_MINUTES = 15
- WAVEFORM_POINTS = 16
- CONTEXT_HOURS = 6
- COLORS = {
- "一缸": "#2980b9",
- "二缸": "#e67e22",
- "三缸": "#16a085",
- "四缸": "#8e44ad",
- "五缸": "#7f8c8d",
- "六缸": "#d35400",
- }
- def _slot_of(moment: pd.Timestamp, start: pd.Timestamp) -> int:
- return int((pd.Timestamp(moment) - start).total_seconds() // (SLOT_MINUTES * 60))
- def _baseline_rms(meta: dict) -> float:
- return float(10 ** float(meta.get("rms_log", [0.0, 1.0])[0]))
- def export_context(frame: pd.DataFrame, intervals: pd.DataFrame, hourly_labels: pd.DataFrame, baseline: dict) -> None:
- frame = frame.copy()
- frame["sample_time"] = pd.to_datetime(frame["sample_time"])
- frame = frame.dropna(subset=["cylinder"]).sort_values(["cylinder", "sample_time"])
- if frame.empty or hourly_labels.empty:
- return
- global_start = frame["sample_time"].min().floor(f"{SLOT_MINUTES}min")
- global_end = frame["sample_time"].max().ceil(f"{SLOT_MINUTES}min")
- frame["slot"] = ((frame["sample_time"] - global_start).dt.total_seconds() // (SLOT_MINUTES * 60)).astype(int)
- frame = frame.drop_duplicates(["cylinder", "slot"], keep="first")
- frame_by_slot: dict[int, dict[str, pd.Series]] = {}
- for row in frame.itertuples(index=False):
- frame_by_slot.setdefault(int(row.slot), {})[row.cylinder] = row
- segments: list[dict] = []
- needed_ids: set[int] = set()
- for item in hourly_labels.itertuples(index=False):
- context_start = pd.Timestamp(item.start_hour) - timedelta(hours=CONTEXT_HOURS)
- context_end = pd.Timestamp(item.end_hour) + timedelta(hours=CONTEXT_HOURS)
- context_start = max(context_start, global_start)
- context_end = min(context_end, global_end)
- context_start_slot = _slot_of(context_start, global_start)
- context_end_slot = _slot_of(context_end, global_start)
- slot_count = context_end_slot - context_start_slot + 1
- label_start = _slot_of(item.start_hour, global_start) - context_start_slot
- label_end = _slot_of(item.end_hour, global_start) - context_start_slot
- blocks: dict[str, list[list[int]]] = {}
- for name in config.CYLINDERS:
- pieces = []
- for local_slot in range(slot_count):
- row = frame_by_slot.get(context_start_slot + local_slot, {}).get(name)
- if row is None:
- continue
- file_id = int(row.id)
- def finite(value):
- return None if pd.isna(value) else round(float(value), 4)
- pieces.append([
- local_slot,
- file_id,
- finite(row.score),
- finite(row.kurt),
- finite(row.kurt_ratio),
- finite(row.rms_ratio),
- ])
- needed_ids.add(file_id)
- blocks[name] = pieces
- event_hits = []
- for event in config.EVENTS:
- if event["cylinder"] != item.cylinder:
- continue
- center = pd.Timestamp(event["date"])
- if pd.Timestamp(item.start_hour) <= center + timedelta(days=1) and pd.Timestamp(item.end_hour) >= center - timedelta(days=7):
- event_hits.append(event["event_id"])
- segments.append(
- {
- "cylinder": item.cylinder,
- "event_hits": event_hits,
- "start_hour": str(item.start_hour),
- "end_hour": str(item.end_hour),
- "hour_count": int(item.hour_count),
- "direction": item.direction,
- "level": item.level,
- "confirmed": bool(item.confirmed),
- "max_score": float(item.max_score),
- "context_start": str(context_start),
- "slot_count": slot_count,
- "label_start": label_start,
- "label_end": label_end,
- "blocks": blocks,
- }
- )
- print(f"[context] segments={len(segments)}, unique files={len(needed_ids)}", flush=True)
- samples = data.fetch_samples(sorted(needed_ids), chunk_size=200)
- waveforms: dict[int, list[list[float]]] = {}
- for file_id, signal in samples.items():
- if signal is None or signal.size == 0:
- continue
- row = frame[frame["id"] == file_id]
- if row.empty:
- continue
- cylinder = row.iloc[0]["cylinder"]
- scale = _baseline_rms(baseline.get(cylinder, {}))
- if not np.isfinite(scale) or scale <= 0:
- scale = float(np.sqrt(np.mean(signal ** 2))) or 1.0
- chosen = np.linspace(0, signal.size - 1, WAVEFORM_POINTS).astype(int)
- waveforms[file_id] = [
- [round(i / (WAVEFORM_POINTS - 1), 4), round(float(signal[idx]) / scale, 4)]
- for i, idx in enumerate(chosen)
- ]
- print(f"[context] cached waveforms={len(waveforms)}", flush=True)
- payload = {"segments": segments, "waveforms": waveforms}
- out_dir = config.OUTPUT_DIR / "html"
- out_dir.mkdir(parents=True, exist_ok=True)
- html = """<!doctype html>
- <html lang="zh-CN"><head><meta charset="utf-8"><title>5号机小时标签</title>
- <style>
- html,body{height:100%;overflow:hidden}
- body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:16px;color:#222}
- .panel{display:grid;grid-template-columns:330px 1fr;gap:14px;height:calc(100vh - 32px);align-items:stretch}
- .left{min-width:0;display:flex;flex-direction:column;height:100%;min-height:0}
- .left h1{font-size:19px;text-align:center;margin:0 0 8px;flex:0 0 auto}
- .filters{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:7px;flex:0 0 auto}
- .filters button{border:1px solid #ddd;background:#fff;border-radius:4px;padding:4px 7px;font-size:11px;cursor:pointer;color:#444}
- .filters button.active{background:#2c3e50;border-color:#2c3e50;color:#fff}
- .list{flex:1;min-height:0;overflow:auto;border:1px solid #eee;border-radius:6px;padding:8px;background:#fafafa}
- .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}
- .card:hover{border-color:#999}.card.active{border-color:#c0392b;background:#fff5f5}
- .card .time{font-weight:600}.card .sub{color:#666;font-size:11px}
- .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}
- .right{min-width:0;display:flex;flex-direction:column;height:100%;min-height:0}
- .meta{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;flex:0 0 auto}
- .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}
- .meta b{color:#777;font-size:11px;font-weight:500}
- .meta .time{flex:0 0 285px}.meta .context{flex:0 0 205px}
- .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}
- .legend-bar span{display:inline-flex;align-items:center;white-space:nowrap}
- .legend-bar i{display:inline-block;width:22px;height:0;border-top:2px solid #333;margin-right:4px;vertical-align:middle}
- .legend-bar i.score{border-top-color:#e67e22}
- .legend-bar i.kurt{border-top:2px dashed #8e44ad}
- .legend-bar i.bg{height:10px;width:20px;border:1px solid #ddd;border-radius:2px}
- .legend-bar i.bg.red{background:rgba(231,76,60,0.25);border-color:#e6a5a5}
- .legend-bar i.bg.yellow{background:rgba(241,196,15,0.25);border-color:#e8d58a}
- .legend-bar i.bg.stripe{background:linear-gradient(90deg,#f5f5f5 50%,#fff 50%);background-size:8px 8px}
- .legend-bar i.bg.context{background:rgba(52,152,219,0.22);border-color:#9ec9e8}
- #chart{flex:1;min-height:0;border:1px solid #eee;border-radius:6px}
- </style></head><body>
- <div class="panel">
- <div class="left">
- <h1>5号机小时标签</h1>
- <div class="filters" id="filters">
- <button data-filter="all" class="active">全部</button>
- <button data-filter="event">事件窗口</button>
- <button data-filter="minor">轻微</button>
- <button data-filter="anomaly">异常</button>
- <button data-filter="severe">严重异常</button>
- <button data-filter="confirmed">确认</button>
- </div>
- <div id="list" class="list"></div>
- </div>
- <div class="right">
- <div id="meta" class="meta"></div>
- <div class="legend-bar">
- <span><i></i>波形(各缸颜色,左轴)</span>
- <span><i class="score"></i>异常分数(右轴)</span>
- <span><i class="kurt"></i>实际峭度(右轴,虚线)</span>
- <span><i class="bg red"></i>当前缸故障段</span>
- <span><i class="bg yellow"></i>其他缸故障段</span>
- <span><i class="bg stripe"></i>15 分钟槽位交替</span>
- <span><i class="bg context"></i>上下文内其他标签</span>
- </div>
- <div id="chart"></div>
- </div>
- </div>
- <script src="echarts.min.js"></script>
- <script>
- const payload = __PAYLOAD__;
- const segments = payload.segments;
- const waveforms = payload.waveforms;
- const names = ['一缸','二缸','三缸','四缸','五缸','六缸'];
- const colors = ['#2980b9','#e67e22','#16a085','#8e44ad','#7f8c8d','#d35400'];
- const list = document.getElementById('list');
- const chart = echarts.init(document.getElementById('chart'));
- let currentIndex = null;
- function pointsFor(cylinder, blocks) {
- const points = [];
- let previous = null;
- for (const [slot, fileId] of blocks[cylinder]) {
- if (previous !== null && slot > previous + 1) points.push([previous + 1, null]);
- const wave = waveforms[fileId] || [];
- for (const [fraction, value] of wave) points.push([slot + fraction, value]);
- previous = slot;
- }
- return points;
- }
- const fmt = d => `${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:00`;
- let activeFilter = 'all';
- function applyFilter() {
- const cards = Array.from(document.querySelectorAll('.card'));
- let firstVisible = null;
- for (const card of cards) {
- const s = segments[Number(card.dataset.index)];
- let show = true;
- if (activeFilter === 'event') show = card.dataset.event === '1';
- else if (activeFilter === 'confirmed') show = card.dataset.confirmed === '1';
- else if (activeFilter === 'minor') show = s.level === '轻微';
- else if (activeFilter === 'anomaly') show = s.level === '异常';
- else if (activeFilter === 'severe') show = s.level === '严重异常';
- card.style.display = show ? 'flex' : 'none';
- if (show && firstVisible === null) firstVisible = card;
- }
- const active = document.querySelector('.card.active');
- if (active && active.style.display === 'none' && firstVisible) firstVisible.click();
- }
- function updateFilterCounts() {
- const counts = {
- all: segments.length,
- event: segments.filter(s => s.event_hits && s.event_hits.length).length,
- minor: segments.filter(s => s.level === '轻微').length,
- anomaly: segments.filter(s => s.level === '异常').length,
- severe: segments.filter(s => s.level === '严重异常').length,
- confirmed: segments.filter(s => s.confirmed).length,
- };
- const labels = {all: '全部', event: '事件窗口', minor: '轻微', anomaly: '异常', severe: '严重异常', confirmed: '确认'};
- document.querySelectorAll('#filters button').forEach(button => {
- const key = button.dataset.filter;
- button.textContent = `${labels[key]}(${counts[key] || 0})`;
- });
- }
- function makeCards() {
- segments.forEach((s, i) => {
- const card = document.createElement('div');
- card.className = 'card';
- card.dataset.index = i;
- card.dataset.event = s.event_hits && s.event_hits.length ? '1' : '0';
- card.dataset.confirmed = s.confirmed ? '1' : '0';
- card.dataset.direction = s.direction;
- const badge = s.event_hits && s.event_hits.length ? `<span class="badge">事件 ${s.event_hits.join(' / ')}</span>` : '';
- card.innerHTML = `<div><div class="time">${s.start_hour.slice(0,16)} ~ ${s.end_hour.slice(0,16)}</div>
- <div class="sub">${s.cylinder} · ${s.direction} · ${s.level}${s.confirmed ? ' · 确认' : ''}</div></div>${badge}`;
- card.addEventListener('click', () => render(i));
- list.appendChild(card);
- });
- }
- function featurePoints(blocks, field) {
- const points = [];
- let previous = null;
- for (const block of blocks) {
- const slot = block[0];
- const value = block[field];
- if (value === null || value === undefined) continue;
- if (previous !== null && slot > previous + 1) points.push([previous + 1, null]);
- points.push([slot + 0.5, value]);
- previous = slot;
- }
- return points;
- }
- function render(index) {
- const s = segments[index];
- if (!s) return;
- currentIndex = index;
- document.querySelectorAll('.card').forEach(card => card.classList.toggle('active', Number(card.dataset.index) === index));
- const contextStart = new Date(s.context_start.replace(' ', 'T'));
- const startMs = contextStart.getTime();
- const contextEnd = new Date(startMs + s.slot_count * 15 * 60 * 1000);
- document.getElementById('meta').innerHTML = `
- <div class="time"><b>时间</b>${s.start_hour} ~ ${s.end_hour}</div>
- <div><b>缸</b>${s.cylinder}</div>
- <div><b>方向</b>${s.direction}</div>
- <div><b>等级</b>${s.level}</div>
- <div><b>多特征确认</b>${s.confirmed ? '是' : '否'}</div>
- <div class="context"><b>上下文</b>${fmt(contextStart)} ~ ${fmt(contextEnd)}</div>
- <div><b>最大分数</b>${s.max_score}</div>
- <div><b>小时数</b>${s.hour_count}</div>`;
- const stripes = [];
- for (let slot = 1; slot < s.slot_count; slot += 2) stripes.push([{xAxis: slot}, {xAxis: slot + 1}]);
- const labelAreas = [[{xAxis: s.label_start}, {xAxis: s.label_end}]];
- const grids = [], xAxes = [], yAxes = [], titles = [], series = [];
- const chartHeight = chart.getHeight() || 800;
- const top0 = 22, bottomSpace = 62, gap = 8;
- const rowHeight = Math.max(50, (chartHeight - top0 - bottomSpace - gap * 5) / 6);
- const toLocalSlot = hour => (new Date(hour.replace(' ', 'T')).getTime() - startMs) / (15 * 60 * 1000);
- for (let idx = 0; idx < names.length; idx++) {
- const name = names[idx];
- const top = top0 + idx * (rowHeight + gap);
- const bottomRow = idx === names.length - 1;
- grids.push({left: 82, right: 28, top, height: rowHeight});
- xAxes.push({gridIndex: idx, type: 'value', min: 0, max: s.slot_count, interval: 8,
- axisLabel: {show: bottomRow, formatter: v => fmt(new Date(startMs + v * 15 * 60 * 1000)), fontSize: 9}, splitLine: {show: false}});
- yAxes.push({gridIndex: idx, type: 'value', scale: true, axisLabel: {fontSize: 8}});
- yAxes.push({gridIndex: idx, type: 'value', position: 'right', scale: true, axisLabel: {fontSize: 7}, splitLine: {show: false}});
- titles.push({text: `cyl${idx+1}`, left: 'center', top: top - 20, textStyle: {fontSize: 11, color: name === s.cylinder ? '#c0392b' : '#333'}});
- const labelColor = name === s.cylinder ? 'rgba(231,76,60,0.20)' : 'rgba(241,196,15,0.22)';
- const otherAreas = [];
- for (let oi = 0; oi < segments.length; oi++) {
- if (oi === index) continue;
- const other = segments[oi];
- if (other.cylinder !== name) continue;
- const a = Math.max(0, toLocalSlot(other.start_hour));
- const b = Math.min(s.slot_count, toLocalSlot(other.end_hour));
- if (b > a + 0.01) otherAreas.push([{xAxis: a}, {xAxis: b}]);
- }
- series.push(
- {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}},
- {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}},
- {name: '__label' + idx, type: 'line', data: [], xAxisIndex: idx, yAxisIndex: idx * 2, silent: true, z: 1, markArea: {silent: true, itemStyle: {color: labelColor}, data: labelAreas}},
- {name: `cyl${idx+1}`, type: 'line', data: pointsFor(name, s.blocks), xAxisIndex: idx, yAxisIndex: idx * 2,
- showSymbol: false, connectNulls: false, sampling: 'lttb', z: 3,
- lineStyle: {width: name === s.cylinder ? 2.0 : 1.1},
- itemStyle: {color: name === s.cylinder ? '#c0392b' : colors[idx]}},
- {name: `score${idx+1}`, type: 'line', data: featurePoints(s.blocks[name], 2), xAxisIndex: idx, yAxisIndex: idx * 2 + 1,
- showSymbol: false, connectNulls: false, z: 4, lineStyle: {width: 1.2, color: '#e67e22'}, itemStyle: {color: '#e67e22'}},
- {name: `kurt${idx+1}`, type: 'line', data: featurePoints(s.blocks[name], 3), xAxisIndex: idx, yAxisIndex: idx * 2 + 1,
- showSymbol: false, connectNulls: false, z: 4, lineStyle: {width: 1.1, type: 'dashed', color: '#8e44ad'}, itemStyle: {color: '#8e44ad'}}
- );
- }
- chart.setOption({
- animation: false,
- tooltip: {trigger: 'axis', axisPointer: {link: [{xAxisIndex: 'all'}]}},
- title: titles,
- grid: grids,
- xAxis: xAxes,
- yAxis: yAxes,
- dataZoom: [
- {type: 'inside', xAxisIndex: [0,1,2,3,4,5], start: 0, end: 100},
- {type: 'slider', xAxisIndex: [0,1,2,3,4,5], bottom: 12, height: 20}
- ],
- series
- }, true);
- }
- makeCards();
- updateFilterCounts();
- document.querySelectorAll('#filters button').forEach(button => {
- button.addEventListener('click', () => {
- document.querySelectorAll('#filters button').forEach(item => item.classList.toggle('active', item === button));
- activeFilter = button.dataset.filter;
- applyFilter();
- });
- });
- const first = segments.map((s, i) => [s, i]).filter(([s]) => s.event_hits && s.event_hits.length)[0]
- || segments.map((s, i) => [s, i]).filter(([s]) => s.confirmed).sort((a, b) => b[0].max_score - a[0].max_score)[0]
- || [segments[0], 0];
- chart.resize();
- render(first[1]);
- const activeCard = document.querySelector('.card.active');
- if (activeCard) activeCard.scrollIntoView({block: 'center'});
- window.addEventListener('resize', () => {
- chart.resize();
- if (currentIndex !== null) render(currentIndex);
- });
- </script></body></html>"""
- html = html.replace("__PAYLOAD__", json.dumps(payload, ensure_ascii=False))
- (out_dir / "waveforms.html").write_text(html, encoding="utf-8")
- (out_dir / "index.html").write_text(
- """<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>5号机缸体振动</title>
- <style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:24px}a{color:#1a73e8}</style>
- </head><body><h1>5号机缸体振动</h1><p><a href="waveforms.html">打开小时标签波形</a></p></body></html>""",
- encoding="utf-8",
- )
- print(f"[html] {out_dir / 'waveforms.html'}")
|