|
|
@@ -0,0 +1,574 @@
|
|
|
+"""整机多缸振动事件 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
|