| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- """整机多缸振动事件 HTML:振动 1×6 子图 + 异常缸温度子图。"""
- from __future__ import annotations
- import json
- import re
- from datetime import timedelta
- from pathlib import Path
- import numpy as np
- import pandas as pd
- from . import config, data
- try:
- from backend.app.services import temperature_transition_service as temp_service
- except Exception:
- temp_service = None
- SLOT_MINUTES = 15
- WAVEFORM_POINTS = 16
- CONTEXT_HOURS = 12
- TEMP_SEARCH_HOURS = 24
- TEMP_MAX_LABELS = 2
- COLORS = ["#2980b9", "#e67e22", "#16a085", "#8e44ad", "#7f8c8d", "#d35400"]
- def _finite(value):
- return None if pd.isna(value) else round(float(value), 4)
- def _slot_of(moment: pd.Timestamp, start: pd.Timestamp) -> int:
- return int((pd.Timestamp(moment) - pd.Timestamp(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 _temp_cylinder(label: str) -> str | None:
- match = re.match(r"^(一级|二级)(\d)#缸$", str(label or "").strip())
- if not match:
- return None
- return config.STAGE_CYLINDER_TO_GLOBAL.get((match.group(1), f"{match.group(2)}#"))
- def _load_temperature_labels() -> pd.DataFrame:
- path = config.BASE_DIR.parent / "_tmp_temperature" / "out" / "温度异常段.csv"
- if not path.exists():
- return pd.DataFrame()
- frame = pd.read_csv(path, encoding="utf-8-sig")
- frame["_csv_index"] = frame.index
- frame = frame[frame["机组"].astype(str).str.strip() == "5号机"].copy()
- frame["开始小时"] = pd.to_datetime(frame["开始小时"], errors="coerce")
- frame["结束小时"] = pd.to_datetime(frame["结束小时"], errors="coerce")
- frame["cylinder"] = frame["阶段部位"].map(_temp_cylinder)
- return frame.dropna(subset=["开始小时", "结束小时", "cylinder"]).reset_index(drop=True)
- def _select_temperatures(temp: pd.DataFrame, event_start: pd.Timestamp, event_end: pd.Timestamp) -> pd.DataFrame:
- if temp.empty:
- return temp
- window_start = event_start - pd.Timedelta(hours=TEMP_SEARCH_HOURS)
- window_end = event_end + pd.Timedelta(hours=TEMP_SEARCH_HOURS)
- part = temp[(temp["结束小时"] >= window_start) & (temp["开始小时"] <= window_end)].copy()
- if part.empty:
- return part
- part["distance_hours"] = part.apply(
- lambda row: min(
- abs((row["开始小时"] - event_end).total_seconds() / 3600.0),
- abs((event_start - row["结束小时"]).total_seconds() / 3600.0),
- 0.0,
- ),
- axis=1,
- )
- return part.sort_values("distance_hours").head(TEMP_MAX_LABELS)
- def _temperature_subplot(row: pd.Series, context_start: pd.Timestamp, context_end: pd.Timestamp) -> dict | None:
- if temp_service is None:
- return None
- stage_name = re.match(r"^(一级|二级)", str(row["阶段部位"] or ""))
- if not stage_name:
- return None
- stage = stage_name.group(1)
- columns = temp_service.CYLINDERS.get(5, {}).get(stage)
- if not columns:
- return None
- batch = int(float(row.get("批次") or 0))
- rows = temp_service._query(5, batch, context_start, context_end)
- if not rows:
- return None
- target = str(row["阶段部位"]).replace("缸", "")
- peer_colors = ["#1f6feb", "#2e9e5b", "#8e44ad"]
- target_color = "#c0392b"
- stride = max(1, len(rows) // 20000)
- series_out = []
- all_values = []
- peer_index = 0
- for column, name in columns:
- is_target = name.startswith(target)
- color = target_color if is_target else peer_colors[peer_index % len(peer_colors)]
- if not is_target:
- peer_index += 1
- points = []
- for row_index in range(0, len(rows), stride):
- item = rows[row_index]
- value = temp_service._temp_value(item.get(column))
- if value is None:
- continue
- moment = pd.Timestamp(item["t"])
- local_slot = (moment - context_start).total_seconds() / (SLOT_MINUTES * 60)
- points.append([round(local_slot, 4), round(float(value), 2)])
- all_values.append(float(value))
- if not points:
- continue
- series_out.append({
- "name": name,
- "color": color,
- "is_target": is_target,
- "data": points,
- })
- if not series_out:
- return None
- limits = temp_service._limits(5, [column for column, _ in columns])
- highs = [limits[column]["high"] for column, _ in columns if limits[column]["high"] is not None]
- high_highs = [limits[column]["high_high"] for column, _ in columns if limits[column]["high_high"] is not None]
- refs = []
- if highs:
- refs.append({"value": min(highs), "label": "高报"})
- if high_highs:
- refs.append({"value": min(high_highs), "label": "高高报"})
- y_min, y_max = temp_service._padded(all_values)
- band_start = max(0.0, (row["开始小时"] - context_start).total_seconds() / (SLOT_MINUTES * 60))
- band_end = min(
- (context_end - context_start).total_seconds() / (SLOT_MINUTES * 60),
- (row["结束小时"] - context_start).total_seconds() / (SLOT_MINUTES * 60),
- )
- band = [round(band_start, 3), round(band_end, 3)] if band_end > band_start else None
- return {
- "cylinder": row["cylinder"],
- "stage_part": str(row["阶段部位"]),
- "level": str(row["温度阶段"]),
- "direction": str(row["方向"] or ""),
- "start": str(row["开始小时"]),
- "end": str(row["结束小时"]),
- "title": f"{stage}缸排气温度(异常缸:{target})",
- "unit": "℃",
- "ref_lines": refs,
- "y_min": round(y_min, 4),
- "y_max": round(y_max, 4),
- "series": series_out,
- "band": band,
- }
- def export_machine_events(
- frame: pd.DataFrame,
- events: pd.DataFrame,
- candidates: pd.DataFrame,
- validation: pd.DataFrame,
- ) -> Path:
- 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 events.empty:
- raise RuntimeError("缺少特征或整机事件,无法生成 HTML")
- candidates = candidates.copy()
- candidates["开始小时"] = pd.to_datetime(candidates["开始小时"])
- candidates["结束小时"] = pd.to_datetime(candidates["结束小时"])
- candidates["cylinder"] = candidates["阶段部位"].astype(str).str.replace("5号机", "", regex=False)
- temperatures = _load_temperature_labels()
- baseline = json.loads((config.OUTPUT_DIR / "baseline_full.json").read_text(encoding="utf-8"))
- validation_map = {}
- for row in validation.itertuples(index=False):
- if row.hit:
- validation_map[row.machine_label_id] = {
- "event_id": row.event_id,
- "lead_days": row.lead_days,
- }
- segments = []
- needed_ids: set[int] = set()
- for item in events.itertuples(index=False):
- event_start = pd.Timestamp(item.开始小时)
- event_end = pd.Timestamp(item.结束小时)
- selected_temps = _select_temperatures(temperatures, event_start, event_end)
- context_start = event_start - timedelta(hours=CONTEXT_HOURS)
- context_end = event_end + timedelta(hours=CONTEXT_HOURS)
- for _, temp_row in selected_temps.iterrows():
- context_start = min(context_start, pd.Timestamp(temp_row["开始小时"]) - timedelta(hours=2))
- context_end = max(context_end, pd.Timestamp(temp_row["结束小时"]) + timedelta(hours=2))
- slot_count = max(1, int((context_end - context_start).total_seconds() // (SLOT_MINUTES * 60)) + 1)
- part = frame[(frame["sample_time"] >= context_start) & (frame["sample_time"] <= context_end)]
- part = part.drop_duplicates(["cylinder", "sample_time"], keep="first")
- blocks: dict[str, list[list]] = {}
- for name in config.CYLINDERS:
- pieces = []
- for row in part[part["cylinder"] == name].itertuples(index=False):
- local_slot = _slot_of(pd.Timestamp(row.sample_time), context_start)
- file_id = int(row.id)
- 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
- context_labels = {}
- for name in config.CYLINDERS:
- areas = []
- for row in candidates[candidates["cylinder"] == name].itertuples(index=False):
- start = pd.Timestamp(row.开始小时)
- end = pd.Timestamp(row.结束小时)
- if end <= event_start or start >= event_end:
- if start < context_end and end > context_start:
- areas.append([
- max(0, _slot_of(start, context_start)),
- min(slot_count, _slot_of(end, context_start)),
- row.阶段,
- ])
- context_labels[name] = areas
- temp_subplots = []
- temp_labels = []
- for _, temp_row in selected_temps.iterrows():
- temp_labels.append({
- "cylinder": temp_row["cylinder"],
- "stage_part": temp_row["阶段部位"],
- "level": temp_row["温度阶段"],
- "direction": temp_row["方向"] or "",
- "start": str(temp_row["开始小时"]),
- "end": str(temp_row["结束小时"]),
- })
- subplot = _temperature_subplot(temp_row, context_start, context_end)
- if subplot:
- temp_subplots.append(subplot)
- event_start_slot = max(0, _slot_of(event_start, context_start))
- event_end_slot = min(slot_count, _slot_of(event_end, context_start))
- validation_info = validation_map.get(item.标签ID)
- segments.append({
- "id": item.标签ID,
- "level": item.阶段,
- "start": str(event_start),
- "end": str(event_end),
- "start_slot": event_start_slot,
- "end_slot": event_end_slot,
- "context_start": str(context_start),
- "slot_count": slot_count,
- "participating": item.参与缸,
- "cylinders": int(item.参与缸数),
- "abnormal_cylinders": int(item.异常缸数),
- "severe_cylinders": int(item.严重异常缸数),
- "bad_labels": int(item.异常标签数),
- "severe_labels": int(item.严重异常标签数),
- "candidate_labels": int(item.候选标签数),
- "max_score": item.最大异常分数,
- "rpm": item.转速范围,
- "run_start": str(item.运行段开始),
- "run_end": str(item.运行段结束),
- "reason": item.阶段触发依据,
- "validation": validation_info,
- "temperature_labels": temp_labels,
- "temperature_subplots": temp_subplots,
- "blocks": blocks,
- "context_labels": context_labels,
- })
- 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)
- ]
- 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:350px 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}
- .card:hover{border-color:#999}.card.active{border-color:#c0392b;background:#fff5f5}
- .card .time{font-weight:600}.card .sub{color:#666;font-size:11px;margin-top:2px}
- .card .badges{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}
- .badge{color:#c0392b;border:1px solid #e6a5a5;background:#fff1f1;border-radius:999px;padding:1px 6px;font-size:10px;white-space:nowrap}
- .badge.gray{color:#555;border-color:#ddd;background:#fafafa}
- .badge.temp{color:#1e8449;border-color:#a9dfbf;background:#eafaf1}
- .right{min-width:0;display:flex;flex-direction:column;height:100%;min-height:0;overflow-y:auto;overflow-x:hidden;padding-right:2px}
- .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}
- .meta .reason{flex:1 1 100%;white-space:normal;color:#555;font-size:11px}
- .meta .temp{white-space:normal}
- .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.temp{border-top-color:#1e8449}
- .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.blue{background:rgba(52,152,219,0.22);border-color:#9ec9e8}
- .legend-bar i.bg.green{background:rgba(39,174,96,0.22);border-color:#9bd4b0}
- .legend-bar i.bg.stripe{background:linear-gradient(90deg,#f5f5f5 50%,#fff 50%);background-size:8px 8px}
- #chart{flex:1 0 auto;min-height:520px;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="severe">严重异常</button>
- <button data-filter="anomaly">异常</button>
- <button data-filter="validated">已验证</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="temp"></i>温度曲线(异常缸同级3缸,左轴)</span>
- <span><i class="bg red"></i>参与整机事件缸</span>
- <span><i class="bg yellow"></i>事件时间但未参与缸</span>
- <span><i class="bg blue"></i>上下文候选</span>
- <span><i class="bg green"></i>温度标签</span>
- <span><i class="bg stripe"></i>15 分钟槽位交替</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;
- }
- 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;
- }
- 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')}`;
- 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 === 'severe') show = s.level === '严重异常';
- else if (activeFilter === 'anomaly') show = s.level === '异常';
- else if (activeFilter === 'validated') show = Boolean(s.validation);
- card.style.display = show ? 'block' : '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,
- severe: segments.filter(s => s.level === '严重异常').length,
- anomaly: segments.filter(s => s.level === '异常').length,
- validated: segments.filter(s => s.validation).length,
- };
- const labels = {all: '全部', severe: '严重异常', anomaly: '异常', validated: '已验证'};
- 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;
- const validation = s.validation ? `<span class="badge">验证 ${s.validation.event_id} · ${s.validation.lead_days} 天</span>` : '<span class="badge gray">未命中维修事件</span>';
- const temp = (s.temperature_labels || []).length ? `<span class="badge temp">温度 ${s.temperature_labels.map(t => t.cylinder).join('/')}</span>` : '';
- card.innerHTML = `<div class="time">${s.start.slice(0,16)} ~ ${s.end.slice(0,16)}</div>
- <div class="sub">${s.level} · 参与 ${s.cylinders} 缸 · 异常 ${s.abnormal_cylinders} · 严重 ${s.severe_cylinders}</div>
- <div class="sub">${s.participating}</div>
- <div class="badges"><span class="badge gray">${s.id}</span>${validation}${temp}</div>`;
- card.addEventListener('click', () => render(i));
- list.appendChild(card);
- });
- }
- 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);
- const tempMeta = (s.temperature_labels || []).length
- ? 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('')
- : '<div class="temp"><b>温度标签</b>附近无 5 号机温度标签</div>';
- document.getElementById('meta').innerHTML = `
- <div class="time"><b>事件</b>${s.id}</div>
- <div><b>时间</b>${s.start} ~ ${s.end}</div>
- <div><b>等级</b>${s.level}</div>
- <div><b>参与缸</b>${s.participating}</div>
- <div><b>异常/严重</b>${s.abnormal_cylinders}/${s.severe_cylinders}</div>
- <div><b>候选标签</b>${s.candidate_labels}</div>
- <div><b>最大分数</b>${s.max_score ?? '-'}</div>
- <div><b>转速</b>${s.rpm || '-'}</div>
- <div class="context"><b>上下文</b>${fmt(contextStart)} ~ ${fmt(contextEnd)}</div>
- ${tempMeta}
- <div class="reason"><b>触发依据</b>${s.reason}</div>`;
- const stripes = [];
- for (let slot = 1; slot < s.slot_count; slot += 2) stripes.push([{xAxis: slot}, {xAxis: slot + 1}]);
- const eventAreas = [[{xAxis: s.start_slot}, {xAxis: s.end_slot}]];
- const participating = new Set((s.participating || '').split(' / ').map(item => item.trim()));
- const grids = [], xAxes = [], yAxes = [], titles = [], series = [], legends = [];
- const tempRows = s.temperature_subplots || [];
- const rows = [
- ...names.map((name, idx) => ({kind: 'vib', name, idx})),
- ...tempRows.map((item, idx) => ({kind: 'temp', ...item, idx: names.length + idx})),
- ];
- const chartHeight = chart.getHeight() || 800;
- const top0 = 22, bottomSpace = 82, gap = 7;
- const rowHeight = Math.max(38, (chartHeight - top0 - bottomSpace - gap * (rows.length - 1)) / rows.length);
- const xIndexes = rows.map((_, idx) => idx);
- rows.forEach((row, ri) => {
- const top = top0 + ri * (rowHeight + gap);
- const bottomRow = ri === rows.length - 1;
- grids.push({left: 82, right: 28, top, height: rowHeight});
- xAxes.push({gridIndex: ri, type: 'value', min: 0, max: s.slot_count, interval: Math.max(8, Math.ceil(s.slot_count / 12)),
- axisLabel: {show: bottomRow, formatter: v => fmt(new Date(startMs + v * 15 * 60 * 1000)), fontSize: 9}, splitLine: {show: false}});
- const rightAxis = row.kind === 'vib';
- 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}});
- yAxes.push({gridIndex: ri, type: 'value', position: 'right', scale: true, show: rightAxis, axisLabel: {fontSize: 7}, splitLine: {show: false}});
- if (row.kind === 'vib') {
- titles.push({text: `cyl${row.idx+1}`, left: 'center', top: top - 20, textStyle: {fontSize: 11, color: '#333'}});
- const contextAreas = (s.context_labels[row.name] || []).map(([a,b]) => [{xAxis:a},{xAxis:b}]);
- const eventColor = participating.has(row.name) ? 'rgba(231,76,60,0.20)' : 'rgba(241,196,15,0.20)';
- series.push(
- {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}},
- {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}},
- {name: '__event' + ri, type: 'line', data: [], xAxisIndex: ri, yAxisIndex: ri * 2, silent: true, z: 1, markArea: {silent: true, itemStyle: {color: eventColor}, data: eventAreas}},
- {name: `cyl${row.idx+1}`, type: 'line', data: pointsFor(row.name, s.blocks), xAxisIndex: ri, yAxisIndex: ri * 2,
- showSymbol: false, connectNulls: false, sampling: 'lttb', z: 3,
- lineStyle: {width: 1.2}, itemStyle: {color: colors[row.idx]}},
- {name: `score${row.idx+1}`, type: 'line', data: featurePoints(s.blocks[row.name], 2), xAxisIndex: ri, yAxisIndex: ri * 2 + 1,
- showSymbol: false, connectNulls: false, z: 4, lineStyle: {width: 1.2, color: '#e67e22'}, itemStyle: {color: '#e67e22'}},
- {name: `kurt${row.idx+1}`, type: 'line', data: featurePoints(s.blocks[row.name], 3), xAxisIndex: ri, yAxisIndex: ri * 2 + 1,
- showSymbol: false, connectNulls: false, z: 4, lineStyle: {width: 1.1, type: 'dashed', color: '#8e44ad'}, itemStyle: {color: '#8e44ad'}}
- );
- } else {
- const color = row.level === '严重异常' ? '#8e0000' : (row.level === '异常' ? '#c0392b' : '#b26a00');
- titles.push({text: `${row.title}(${row.unit})`, left: 'center', top: top - 20, textStyle: {fontSize: 11, fontWeight: 600, color: color}});
- 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'}}));
- const tempAreas = row.band ? [[{xAxis: row.band[0]}, {xAxis: row.band[1]}]] : [];
- const tempSeries = row.series || [];
- legends.push({data: tempSeries.map(item => item.name), top: top - 25, right: 36, itemWidth: 12, itemHeight: 8, selectedMode: false, textStyle: {fontSize: 10, color: '#5b6472'}});
- series.push(
- {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}},
- {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}},
- {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}}
- );
- tempSeries.forEach((item, ti) => {
- series.push({
- name: item.name, type: 'line', data: item.data, xAxisIndex: ri, yAxisIndex: ri * 2,
- showSymbol: false, symbol: 'none', animation: false, sampling: 'lttb', connectNulls: false,
- z: item.is_target ? 4 : 3,
- lineStyle: {width: item.is_target ? 2.2 : 1.2, color: item.color},
- itemStyle: {color: item.color},
- markLine: item.is_target && refs.length ? {silent: true, symbol: 'none', data: refs} : undefined
- });
- });
- }
- });
- chart.setOption({
- animation: false,
- tooltip: {trigger: 'axis', axisPointer: {link: [{xAxisIndex: 'all'}]}},
- title: titles,
- legend: legends,
- grid: grids,
- xAxis: xAxes,
- yAxis: yAxes,
- dataZoom: [
- {type: 'inside', xAxisIndex: xIndexes, start: 0, end: 100},
- {type: 'slider', xAxisIndex: xIndexes, bottom: 8, height: 18}
- ],
- 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();
- });
- });
- chart.resize();
- const initialIndex = Math.max(0, Math.min(segments.length - 1, Number(new URLSearchParams(location.search).get('i') || 0)));
- render(initialIndex);
- 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))
- target = out_dir / "machine_events.html"
- target.write_text(html, encoding="utf-8")
- index = out_dir / "index.html"
- index.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="machine_events.html">打开整机多缸事件标签</a></p><p><a href="waveforms.html">打开单缸候选波形(历史)</a></p></body></html>""",
- encoding="utf-8",
- )
- print(f"[machine-html] {target}")
- return target
|