瀏覽代碼

缸体振动:整机事件标签、温度同级曲线与排查页

18922397810 2 天之前
父節點
當前提交
586deed2b0

+ 45 - 17
_tmp_cylinder_vibration/README.md

@@ -2,7 +2,7 @@
 
 > 位置:项目根目录 `_tmp_cylinder_vibration/`。  
 > 参考 `_tmp_temperature/` 的组织方式;**未改动 `models/`**。  
-> 当前阶段只做到:算法、标签、统一字段、HTML、报告和说明文档,暂不合并进模型。
+> 当前阶段只做到:算法、整机事件标签、统一字段、HTML、报告和说明文档,暂不合并进模型。
 
 ## 目标
 
@@ -11,10 +11,13 @@
 - 15 分钟首周期波形特征;
 - 动态局部基线(1~30 天);
 - 轻微 / 异常 / 严重异常三级;
-- 相邻小时合并成小时标签;
+- 相邻小时合并成小时标签,作为单缸候选明细;
+- 最终标签改为**整机多缸振动事件**,不再用单缸标签代表故障;
+- 事件排除开机后 6 小时、停机前 6 小时;
+- 最终事件要求 24 小时内至少 4 个缸有候选、其中至少 2 条异常/严重异常;
 - 同一时刻 6 缸并排查看,标签前后各补 6 小时;
 - 标签带可读的 `阶段触发依据`;
-- 输出统一字段表,后续可进入数据库/模型。
+- 输出整机事件表,后续可进入数据库/模型。
 
 ## 数据口径
 
@@ -37,10 +40,12 @@
 | `hourly.py` | 15 分钟标签汇总为小时标签,相邻小时合并 |
 | `review.py` | 排查指标和 `review_flags` |
 | `unified_labels.py` | 映射为统一故障标签字段 |
+| `machine_events.py` | 稳定运行段、整机多缸事件合并与验证 |
 | `context_html.py` | 生成 `waveforms.html` 交互排查页 |
-| `make_report.py` | 生成统一表、截图和 Word 报告 |
+| `make_report.py` | 生成旧版单缸候选报告(历史口径) |
 | `run_full.py` | 全时段特征和标签 |
 | `run_context.py` | 小时标签、排查指标、HTML |
+| `run_machine_events.py` | 生成最终整机事件标签、验证表、摘要 |
 | `out/` | 标签、统一表、报告、截图 |
 
 ## 快速运行
@@ -52,7 +57,10 @@ python3 _tmp_cylinder_vibration/run_full.py
 # 2. 小时标签 + 排查指标 + HTML
 python3 _tmp_cylinder_vibration/run_context.py
 
-# 3. 统一字段表 + waveforms.html 截图 + Word 报告
+# 3. 整机多缸事件标签 + 验证表 + 摘要
+python3 -m _tmp_cylinder_vibration.run_machine_events
+
+# 4. 旧版单缸候选报告(历史口径,可选)
 python3 -m _tmp_cylinder_vibration.make_report
 ```
 
@@ -70,26 +78,46 @@ open _tmp_cylinder_vibration/out/html/waveforms.html
 | `out/vibration_features_full_local.csv` | 动态局部基线、z、分数、等级前数据 |
 | `out/vibration_labels_full.csv` | 15 分钟点级标签 |
 | `out/vibration_intervals_full.csv` | 自适应 15 分钟段 |
-| `out/vibration_hourly_labels.csv` | 小时标签(最终展示口径) |
+| `out/vibration_hourly_labels.csv` | 单缸小时候选标签 |
 | `out/label_review.csv` | 排查指标和标记 |
-| `out/cylinder_vibration_label_unified.csv` | 振动统一字段表 |
-| `out/fault_label_unified_preview.csv` | 压力/温度/振动三类统一预览 |
+| `out/cylinder_vibration_label_unified.csv` | 单缸候选统一字段表(中间表) |
+| `out/machine_event_candidates.csv` | 最终规则使用的稳定运行段候选标签 |
+| `out/machine_vibration_events.csv` | **最终整机多缸振动事件标签** |
+| `out/machine_event_validation.csv` | 3 次维修事件的整机事件验证 |
+| `out/machine_event_summary.json` | 规则、标签数、运行段和验证摘要 |
+| `out/machine_event_labels.md` | 最终事件结果可读版 |
+| `out/html/machine_events.html` | 整机事件 + 异常缸同级 3 缸温度子图 |
+| `out/fault_label_unified_machine_preview.csv` | 5 号机温度 18 条 + 整机振动 4 条 = 22 行 |
+| `out/fault_label_unified_preview.csv` | 旧版单缸候选三类预览(历史) |
 | `out/html/waveforms.html` | 1×6 子图交互排查页 |
-| `总结文档/缸体振动演化过程故障标签手册.docx` | Word 报告 |
-| `out/report_assets/` | 报告截图(只截右侧图表) |
+| `总结文档/缸体振动演化过程故障标签手册.docx` | 旧版单缸候选 Word 报告(历史) |
+| `out/report_assets/` | 旧版报告截图(只截右侧图表) |
+
+## 当前结果(v2 整机事件)
+
+- 稳定运行段候选:92 条;
+- 最终整机事件:4 个;
+- 运行段:16 个,其中有事件 4 个、无事件 12 个;
+- 3 次维修事件全部命中,另有 1 个未命中事件保留;
+- 最终事件:
 
-## 当前结果(2026-11)
+| 事件 | 时间 | 等级 | 参与缸 | 提前量 |
+|---|---|---|---|---|
+| `5号机-MACH-0001` | 2026-04-16 08:00~2026-04-18 07:00 | 严重异常 | 6 缸 | 未命中 |
+| `5号机-MACH-0002` | 2026-06-10 19:00~2026-06-11 11:00 | 严重异常 | 4 缸 | 1.21 天 |
+| `5号机-MACH-0003` | 2026-06-19 21:00~2026-06-21 01:00 | 严重异常 | 4 缸 | 1.12 天 |
+| `5号机-MACH-0004` | 2026-07-06 09:00~2026-07-08 08:00 | 异常 | 6 缸 | 4.62 天 |
 
-- 小时标签:127 条;
-- 轻微 68、异常 29、严重异常 30;
-- 多特征确认 46;
-- 3 次事件全部命中;
-- 具体结果见 `out/分析报告.md` 和 `总结文档/缸体振动演化过程故障标签手册.docx`。
+- 温度子图只画异常标签所属的同一级 3 个缸:一级异常画一级 3 缸,二级异常画二级 3 缸;
+- 具体结果见 `out/machine_event_labels.md` 和 `out/html/machine_events.html`。
 
 ## 限制
 
 - 振动是首周期快照,不是连续 15 分钟波形;
-- 启动/停机瞬态仍可能形成标签,用 `review_flags` 标记而不是删除;
+- 旧版单缸标签仍保留为候选明细,但不再作为最终故障标签;
+- 最终事件已排除开机后 6 小时和停机前 6 小时;
+- 4/16~4/18 事件没有命中维修记录,但按要求保留;
+- 6/10~6/11 的高转速工况仍可能放大整机振动,事件表保留转速范围供复核;
 - 标签是规则扫描结果,不等同于物理故障结论;
 - 本目录暂不写数据库、不接 API、不合并 `models/`。
 

+ 574 - 0
_tmp_cylinder_vibration/machine_event_html.py

@@ -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

+ 324 - 0
_tmp_cylinder_vibration/machine_events.py

@@ -0,0 +1,324 @@
+"""整机多缸振动事件标签。
+
+最终标签不再按单缸输出,而是按稳定运行段内的整机多缸聚集事件输出:
+
+- 排除开机后 6 小时、停机前 6 小时;
+- 基线至少成熟 7 天;
+- 24 小时窗口内至少 4 个不同缸有候选标签;
+- 其中至少 2 条达到“异常”或“严重异常”;
+- 相邻候选窗口合并为同一个整机事件;
+- 事件只描述“整机可能异常”,不指定故障缸。
+"""
+from __future__ import annotations
+
+from datetime import timedelta
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+
+from . import config, unified_labels
+
+SEVERITY = {"轻微": 1, "异常": 2, "严重异常": 3}
+LEVELS = ("轻微", "异常", "严重异常")
+BAD_LEVELS = {"异常", "严重异常"}
+
+MACHINE_FIELDS = unified_labels.CORE_FIELDS + [
+    "参与缸数", "参与缸", "异常缸数", "严重异常缸数",
+    "异常标签数", "严重异常标签数", "候选标签数",
+    "最大异常分数", "最大RMS", "最大峭度",
+    "转速范围", "运行段开始", "运行段结束", "整机聚集窗口小时",
+]
+
+
+def build_runs(frame: pd.DataFrame, gap_hours: float = 2.0) -> pd.DataFrame:
+    frame = frame.copy()
+    frame["sample_time"] = pd.to_datetime(frame["sample_time"])
+    running = frame.loc[frame["rpm"].astype(float) > 0, ["sample_time"]].drop_duplicates()
+    running = running.sort_values("sample_time").reset_index(drop=True)
+    if running.empty:
+        return pd.DataFrame(columns=["run_id", "start", "end", "hours"])
+    running["gap_hours"] = running["sample_time"].diff().dt.total_seconds() / 3600.0
+    running["run_id"] = (running["gap_hours"] > gap_hours).cumsum().astype(int)
+    rows = []
+    for run_id, group in running.groupby("run_id", sort=True):
+        start = pd.Timestamp(group["sample_time"].min())
+        end = pd.Timestamp(group["sample_time"].max())
+        rows.append({
+            "run_id": int(run_id),
+            "start": start,
+            "end": end,
+            "hours": round((end - start).total_seconds() / 3600.0, 2),
+        })
+    return pd.DataFrame(rows)
+
+
+def _run_index(timestamp: pd.Timestamp, runs: pd.DataFrame, tolerance_hours: float = 1.0) -> int | None:
+    tolerance = pd.Timedelta(hours=tolerance_hours)
+    for row in runs.itertuples(index=False):
+        if row.start - tolerance <= timestamp <= row.end + tolerance:
+            return int(row.run_id)
+    return None
+
+
+def prepare_candidates(
+    labels: pd.DataFrame,
+    frame: pd.DataFrame,
+    runs: pd.DataFrame,
+    guard_start_hours: int = 6,
+    guard_end_hours: int = 6,
+) -> pd.DataFrame:
+    labels = labels.copy()
+    labels["开始小时"] = pd.to_datetime(labels["开始小时"])
+    labels["结束小时"] = pd.to_datetime(labels["结束小时"])
+    labels["cylinder"] = labels["阶段部位"].astype(str).str.replace("5号机", "", regex=False)
+
+    run_by_id = runs.set_index("run_id")
+
+    rows = []
+    for row in labels.itertuples(index=False):
+        run_id = _run_index(pd.Timestamp(row.开始小时), runs)
+        if run_id is None:
+            continue
+        run = run_by_id.loc[run_id]
+        start = pd.Timestamp(row.开始小时)
+        end = pd.Timestamp(row.结束小时)
+        stable = bool(start >= run["start"] + pd.Timedelta(hours=guard_start_hours)
+                      and end <= run["end"] - pd.Timedelta(hours=guard_end_hours))
+        rows.append({
+            **row._asdict(),
+            "run_id": run_id,
+            "run_start": run["start"],
+            "run_end": run["end"],
+            "stable": stable,
+        })
+    result = pd.DataFrame(rows)
+    if result.empty:
+        return result
+    return result
+
+
+def _merge_candidate_windows(candidates: list[dict], merge_gap_hours: int = 6) -> list[dict]:
+    if not candidates:
+        return []
+    candidates = sorted(candidates, key=lambda item: item["window_start"])
+    merged = [candidates[0].copy()]
+    for candidate in candidates[1:]:
+        current = merged[-1]
+        if candidate["window_start"] <= current["window_end"] + pd.Timedelta(hours=merge_gap_hours):
+            current["window_end"] = max(current["window_end"], candidate["window_end"])
+            current["indices"] = sorted(set(current["indices"]) | set(candidate["indices"]))
+        else:
+            merged.append(candidate.copy())
+    return merged
+
+
+def _ordered_cylinders(values: list[str]) -> list[str]:
+    present = set(values)
+    return [name for name in config.CYLINDERS if name in present]
+
+
+def build_machine_events(
+    labels: pd.DataFrame,
+    frame: pd.DataFrame,
+    hourly: pd.DataFrame,
+    runs: pd.DataFrame,
+    guard_start_hours: int = 6,
+    guard_end_hours: int = 6,
+    window_hours: int = 24,
+    min_cylinders: int = 4,
+    min_bad_labels: int = 2,
+    merge_gap_hours: int = 6,
+) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
+    candidates = prepare_candidates(
+        labels, frame, runs,
+        guard_start_hours=guard_start_hours,
+        guard_end_hours=guard_end_hours,
+    )
+    if candidates.empty:
+        return pd.DataFrame(columns=MACHINE_FIELDS), candidates, pd.DataFrame()
+    candidates = candidates[candidates["stable"]].copy()
+    candidates = candidates.sort_values(["开始小时", "cylinder"]).reset_index(drop=True)
+    candidates["severity_num"] = candidates["阶段"].map(SEVERITY).fillna(0).astype(int)
+
+    score_map = {
+        (row.cylinder, pd.Timestamp(row.start_hour)): float(row.peak_score)
+        for row in hourly.itertuples(index=False)
+    }
+
+    windows = []
+    for index, row in candidates.iterrows():
+        start = pd.Timestamp(row["开始小时"])
+        end = start + pd.Timedelta(hours=window_hours)
+        part = candidates[(candidates["结束小时"] >= start) & (candidates["开始小时"] <= end)]
+        if part["cylinder"].nunique() < min_cylinders:
+            continue
+        if int(part["阶段"].isin(BAD_LEVELS).sum()) < min_bad_labels:
+            continue
+        windows.append({
+            "window_start": start,
+            "window_end": end,
+            "indices": part.index.tolist(),
+        })
+    windows = _merge_candidate_windows(windows, merge_gap_hours=merge_gap_hours)
+
+    frame = frame.copy()
+    frame["sample_time"] = pd.to_datetime(frame["sample_time"])
+    rows = []
+    for index, window in enumerate(windows, start=1):
+        part = candidates.loc[window["indices"]].copy()
+        start = pd.Timestamp(part["开始小时"].min())
+        end = pd.Timestamp(part["结束小时"].max())
+        cylinders = _ordered_cylinders(part["cylinder"].tolist())
+        bad = part[part["阶段"].isin(BAD_LEVELS)]
+        severe = part[part["阶段"] == "严重异常"]
+        bad_cylinders = _ordered_cylinders(bad["cylinder"].tolist())
+        severe_cylinders = _ordered_cylinders(severe["cylinder"].tolist())
+        max_level = max(part["阶段"], key=lambda value: SEVERITY.get(value, 0))
+        peak_score = max(
+            (score_map.get((row.cylinder, pd.Timestamp(row.开始小时)), np.nan) for row in part.itertuples(index=False)),
+            default=np.nan,
+        )
+        rpm_part = frame[
+            (frame["sample_time"] >= start)
+            & (frame["sample_time"] <= end)
+            & (frame["cylinder"].isin(cylinders))
+        ]
+        rpm_min = float(rpm_part["rpm"].min()) if not rpm_part.empty else np.nan
+        rpm_max = float(rpm_part["rpm"].max()) if not rpm_part.empty else np.nan
+        rms_col = pd.to_numeric(part.get("阶段最大RMS"), errors="coerce")
+        kurt_col = pd.to_numeric(part.get("阶段最大峭度"), errors="coerce")
+        rms_max = float(rms_col.max()) if rms_col.notna().any() else np.nan
+        kurt_max = float(kurt_col.max()) if kurt_col.notna().any() else np.nan
+        run_start = pd.Timestamp(part["run_start"].min())
+        run_end = pd.Timestamp(part["run_end"].max())
+        hours = round((end - start).total_seconds() / 3600.0, 2)
+        bad_hours = int(bad["持续自然小时数"].sum()) if not bad.empty else 0
+        severe_hours = int(severe["持续自然小时数"].sum()) if not severe.empty else 0
+        trigger = (
+            f"5号机整机稳定运行段:{start:%Y-%m-%d %H:%M}~{end:%Y-%m-%d %H:%M},"
+            f"24小时窗口内{len(cylinders)}个缸出现候选标签({'、'.join(cylinders)}),"
+            f"其中{len(bad)}条达到异常/严重异常({'、'.join(bad_cylinders)});"
+            f"最大等级{max_level},最大异常分数{peak_score:.2f};"
+            f"参与缸数={len(cylinders)},异常缸数={len(bad_cylinders)}"
+        )
+        rows.append({
+            "标签ID": f"5号机-MACH-{index:04d}",
+            "标签类型": "振动",
+            "机组": "5号机",
+            "批次": "全量",
+            "周期编号": f"5号机-MACH-{index:04d}",
+            "阶段": max_level,
+            "阶段部位": "5号机整机",
+            "阶段点位": "5号机一缸~六缸缸体振动",
+            "开始小时": start,
+            "结束小时": end,
+            "持续自然小时数": hours,
+            "有效运行小时数": int(part["持续自然小时数"].sum()),
+            "基线值": np.nan,
+            "正常波动带": "",
+            "阶段开始值": np.nan,
+            "阶段结束值": np.nan,
+            "阶段最大值": rms_max,
+            "相对基线变化": np.nan,
+            "阶段最大连续异常有效小时数": bad_hours,
+            "阶段高报警小时数": bad_hours,
+            "阶段高高报警小时数": severe_hours,
+            "方向": "整机多缸",
+            "特征类型": "整机振动",
+            "置信度": "高" if severe_cylinders else "中",
+            "是否形成确认等级": "是",
+            "阶段触发依据": trigger,
+            "事件关联": "",
+            "启动过渡比例": 0.0,
+            "局部基线可用比例": 1.0,
+            "数据来源": "wave_file 首周期波形",
+            "算法版本": "cylinder_vibration_machine_v2",
+            "参与缸数": len(cylinders),
+            "参与缸": " / ".join(cylinders),
+            "异常缸数": len(bad_cylinders),
+            "严重异常缸数": len(severe_cylinders),
+            "异常标签数": int(len(bad)),
+            "严重异常标签数": int(len(severe)),
+            "候选标签数": int(len(part)),
+            "最大异常分数": round(peak_score, 3) if np.isfinite(peak_score) else np.nan,
+            "最大RMS": round(rms_max, 4) if np.isfinite(rms_max) else np.nan,
+            "最大峭度": round(kurt_max, 3) if np.isfinite(kurt_max) else np.nan,
+            "转速范围": f"{rpm_min:.1f}~{rpm_max:.1f}" if np.isfinite(rpm_min) and np.isfinite(rpm_max) else "",
+            "运行段开始": run_start,
+            "运行段结束": run_end,
+            "整机聚集窗口小时": window_hours,
+        })
+    result = pd.DataFrame(rows, columns=MACHINE_FIELDS)
+    return result, candidates, pd.DataFrame(windows)
+
+
+def validate_events(events: pd.DataFrame, events_config: list[dict] | None = None) -> pd.DataFrame:
+    rows = []
+    for event in events_config or config.EVENTS:
+        event_id = event["event_id"]
+        event_date = pd.Timestamp(event["date"])
+        window_start = event_date - pd.Timedelta(days=7)
+        window_end = event_date + pd.Timedelta(days=1)
+        matched = events[
+            (events["开始小时"] <= window_end)
+            & (events["结束小时"] >= window_start)
+        ].sort_values("开始小时") if not events.empty else events
+        if matched.empty:
+            rows.append({
+                "event_id": event_id,
+                "event_date": event["date"],
+                "fault_cylinder": event["cylinder"],
+                "hit": False,
+                "machine_label_id": "",
+                "start_time": pd.NaT,
+                "end_time": pd.NaT,
+                "duration_hours": np.nan,
+                "lead_days": np.nan,
+                "max_level": "",
+                "participating_cylinders": "",
+                "abnormal_cylinders": "",
+            })
+            continue
+        matched_event = matched.iloc[0]
+        start = pd.Timestamp(matched_event["开始小时"])
+        rows.append({
+            "event_id": event_id,
+            "event_date": event["date"],
+            "fault_cylinder": event["cylinder"],
+            "hit": True,
+            "machine_label_id": matched_event["标签ID"],
+            "start_time": start,
+            "end_time": pd.Timestamp(matched_event["结束小时"]),
+            "duration_hours": float(matched_event["持续自然小时数"]),
+            "lead_days": round((event_date - start).total_seconds() / 86400.0, 2),
+            "max_level": matched_event["阶段"],
+            "participating_cylinders": matched_event["参与缸"],
+            "abnormal_cylinders": matched_event["异常缸数"],
+        })
+    return pd.DataFrame(rows)
+
+
+def build_summary(events: pd.DataFrame, candidates: pd.DataFrame, validation: pd.DataFrame, runs: pd.DataFrame) -> dict:
+    event_run_ids = sorted(set(events["运行段开始"].dropna().tolist()))
+    all_run_ids = set(runs["run_id"].tolist())
+    return {
+        "rule": {
+            "stable_start_hours": 6,
+            "stable_end_hours": 6,
+            "window_hours": 24,
+            "min_cylinders": 4,
+            "min_bad_labels": 2,
+            "merge_gap_hours": 6,
+        },
+        "candidate_labels": int(len(candidates)),
+        "events": int(len(events)),
+        "runs": int(len(runs)),
+        "event_run_ids": event_run_ids,
+        "event_runs": int(len(event_run_ids)),
+        "normal_runs": int(len(all_run_ids) - len(event_run_ids)),
+        "validation": {
+            "total": int(len(validation)),
+            "hit": int(validation["hit"].sum()) if not validation.empty else 0,
+        },
+    }

文件差異過大導致無法顯示
+ 23 - 0
_tmp_cylinder_vibration/out/fault_label_unified_machine_preview.csv


+ 1 - 1
_tmp_cylinder_vibration/out/html/index.html

@@ -1,3 +1,3 @@
 <!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>
+</head><body><h1>5号机缸体振动</h1><p><a href="machine_events.html">打开整机多缸事件标签</a></p><p><a href="waveforms.html">打开单缸候选波形(历史)</a></p></body></html>

文件差異過大導致無法顯示
+ 266 - 0
_tmp_cylinder_vibration/out/html/machine_events.html


文件差異過大導致無法顯示
+ 93 - 0
_tmp_cylinder_vibration/out/machine_event_candidates.csv


+ 36 - 0
_tmp_cylinder_vibration/out/machine_event_labels.md

@@ -0,0 +1,36 @@
+# 5 号机缸体振动整机事件标签
+
+## 规则
+
+- 稳定运行段:开机后 6 小时、停机前 6 小时不生成最终事件;
+- 不排除首次基线预热段;4 月以前只是未取得数据;
+- 24 小时窗口内至少 4 个不同缸出现候选标签;
+- 其中至少 2 条达到“异常”或“严重异常”;
+- 相邻候选窗口合并为一个整机事件;
+- 事件不指定故障缸,故障缸只用于事后验证。
+
+## 结果
+
+- 候选标签:92 条;
+- 整机事件:4 个;
+- 运行段:16 个;
+- 有事件运行段:4 个;
+- 无事件运行段:12 个;
+- 验证事件:3/3 命中。
+
+## 事件列表
+
+| 标签ID | 开始 | 结束 | 等级 | 参与缸 | 异常缸 | 严重异常缸 | 验证提前量 |
+|---|---|---|---|---|---:|---:|---:|
+| 5号机-MACH-0001 | 2026-04-16 08:00:00 | 2026-04-18 07:00:00 | 严重异常 | 一缸 / 二缸 / 三缸 / 四缸 / 五缸 / 六缸 | 3 | 1 | — |
+| 5号机-MACH-0002 | 2026-06-10 19:00:00 | 2026-06-11 11:00:00 | 严重异常 | 一缸 / 二缸 / 四缸 / 六缸 | 3 | 2 | 1.21 天 |
+| 5号机-MACH-0003 | 2026-06-19 21:00:00 | 2026-06-21 01:00:00 | 严重异常 | 一缸 / 二缸 / 三缸 / 四缸 | 4 | 1 | 1.12 天 |
+| 5号机-MACH-0004 | 2026-07-06 09:00:00 | 2026-07-08 08:00:00 | 异常 | 一缸 / 二缸 / 三缸 / 四缸 / 五缸 / 六缸 | 2 | 0 | 4.62 天 |
+
+## 验证
+
+| 事件 | 故障缸 | 命中 | 整机事件 | 开始 | 结束 | 提前量 |
+|---|---|---|---|---|---|---:|
+| 2026-06-12 | 二缸 | 是 | 5号机-MACH-0002 | 2026-06-10 19:00:00 | 2026-06-11 11:00:00 | 1.21 天 |
+| 2026-06-21 | 五缸 | 是 | 5号机-MACH-0003 | 2026-06-19 21:00:00 | 2026-06-21 01:00:00 | 1.12 天 |
+| 2026-07-11 | 二缸 | 是 | 5号机-MACH-0004 | 2026-07-06 09:00:00 | 2026-07-08 08:00:00 | 4.62 天 |

+ 25 - 0
_tmp_cylinder_vibration/out/machine_event_summary.json

@@ -0,0 +1,25 @@
+{
+  "rule": {
+    "stable_start_hours": 6,
+    "stable_end_hours": 6,
+    "window_hours": 24,
+    "min_cylinders": 4,
+    "min_bad_labels": 2,
+    "merge_gap_hours": 6
+  },
+  "candidate_labels": 92,
+  "events": 4,
+  "runs": 16,
+  "event_run_ids": [
+    "2026-04-14 22:30:02",
+    "2026-06-10 09:00:02",
+    "2026-06-18 09:45:03",
+    "2026-07-03 20:45:04"
+  ],
+  "event_runs": 4,
+  "normal_runs": 12,
+  "validation": {
+    "total": 3,
+    "hit": 3
+  }
+}

+ 4 - 0
_tmp_cylinder_vibration/out/machine_event_validation.csv

@@ -0,0 +1,4 @@
+event_id,event_date,fault_cylinder,hit,machine_label_id,start_time,end_time,duration_hours,lead_days,max_level,participating_cylinders,abnormal_cylinders
+2026-06-12,2026-06-12,二缸,True,5号机-MACH-0002,2026-06-10 19:00:00,2026-06-11 11:00:00,16.0,1.21,严重异常,一缸 / 二缸 / 四缸 / 六缸,3
+2026-06-21,2026-06-21,五缸,True,5号机-MACH-0003,2026-06-19 21:00:00,2026-06-21 01:00:00,28.0,1.12,严重异常,一缸 / 二缸 / 三缸 / 四缸,4
+2026-07-11,2026-07-11,二缸,True,5号机-MACH-0004,2026-07-06 09:00:00,2026-07-08 08:00:00,47.0,4.62,异常,一缸 / 二缸 / 三缸 / 四缸 / 五缸 / 六缸,2

文件差異過大導致無法顯示
+ 5 - 0
_tmp_cylinder_vibration/out/machine_vibration_events.csv


+ 2 - 0
_tmp_cylinder_vibration/out/分析报告.md

@@ -1,5 +1,7 @@
 # 5号机缸体振动标签 · 结果摘要
 
+> 本文件是旧版单缸候选结果;最终整机事件标签见 `out/machine_event_labels.md` 和 `out/machine_vibration_events.csv`。
+
 - 全时段运行态文件:23,901
 - 15 分钟特征:23,901 行
 - 15 分钟点级标签:144

+ 124 - 0
_tmp_cylinder_vibration/run_machine_events.py

@@ -0,0 +1,124 @@
+"""生成整机多缸振动事件标签、验证表和摘要。"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pandas as pd
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+    sys.path.insert(0, str(ROOT))
+
+from _tmp_cylinder_vibration import config, machine_event_html, machine_events, unified_labels
+
+
+def main() -> None:
+    frame = pd.read_csv(config.OUTPUT_DIR / "vibration_features_full_local.csv")
+    frame["sample_time"] = pd.to_datetime(frame["sample_time"])
+    labels = pd.read_csv(config.OUTPUT_DIR / "cylinder_vibration_label_unified.csv")
+    hourly = pd.read_csv(config.OUTPUT_DIR / "vibration_hourly_labels.csv")
+    hourly["start_hour"] = pd.to_datetime(hourly["start_hour"])
+
+    runs = machine_events.build_runs(frame)
+    events, candidates, windows = machine_events.build_machine_events(labels, frame, hourly, runs)
+    validation = machine_events.validate_events(events)
+    summary = machine_events.build_summary(events, candidates, validation, runs)
+
+    events.to_csv(
+        config.OUTPUT_DIR / "machine_vibration_events.csv",
+        index=False,
+        date_format="%Y-%m-%d %H:%M:%S",
+    )
+    candidates.to_csv(
+        config.OUTPUT_DIR / "machine_event_candidates.csv",
+        index=False,
+        date_format="%Y-%m-%d %H:%M:%S",
+    )
+    validation.to_csv(
+        config.OUTPUT_DIR / "machine_event_validation.csv",
+        index=False,
+        date_format="%Y-%m-%d %H:%M:%S",
+    )
+    (config.OUTPUT_DIR / "machine_event_summary.json").write_text(
+        json.dumps(summary, ensure_ascii=False, indent=2, default=str),
+        encoding="utf-8",
+    )
+
+    temperature = unified_labels._map_external(
+        Path("/Users/zhouhao/Code/Python/Compressor/_tmp_temperature/out/温度异常段.csv"),
+        "温度异常",
+        "T",
+        unit="5号机",
+    )
+    machine = events.reindex(columns=unified_labels.CORE_FIELDS)
+    combined = pd.concat([temperature, machine], ignore_index=True)
+    combined.to_csv(
+        config.OUTPUT_DIR / "fault_label_unified_machine_preview.csv",
+        index=False,
+        date_format="%Y-%m-%d %H:%M:%S",
+    )
+
+
+    lines = [
+        "# 5 号机缸体振动整机事件标签",
+        "",
+        "## 规则",
+        "",
+        "- 稳定运行段:开机后 6 小时、停机前 6 小时不生成最终事件;",
+        "- 不排除首次基线预热段;4 月以前只是未取得数据;",
+        "- 24 小时窗口内至少 4 个不同缸出现候选标签;",
+        "- 其中至少 2 条达到“异常”或“严重异常”;",
+        "- 相邻候选窗口合并为一个整机事件;",
+        "- 事件不指定故障缸,故障缸只用于事后验证。",
+        "",
+        "## 结果",
+        "",
+        f"- 候选标签:{len(candidates)} 条;",
+        f"- 整机事件:{len(events)} 个;",
+        f"- 运行段:{len(runs)} 个;",
+        f"- 有事件运行段:{summary['event_runs']} 个;",
+        f"- 无事件运行段:{summary['normal_runs']} 个;",
+        f"- 验证事件:{summary['validation']['hit']}/{summary['validation']['total']} 命中。",
+        "",
+        "## 事件列表",
+        "",
+    ]
+    lines.append("| 标签ID | 开始 | 结束 | 等级 | 参与缸 | 异常缸 | 严重异常缸 | 验证提前量 |")
+    lines.append("|---|---|---|---|---|---:|---:|---:|")
+    lead_map = {
+        row.machine_label_id: row.lead_days
+        for row in validation.itertuples(index=False)
+        if row.hit
+    }
+    for row in events.itertuples(index=False):
+        lead = lead_map.get(row.标签ID)
+        lines.append(
+            f"| {row.标签ID} | {row.开始小时} | {row.结束小时} | {row.阶段} | "
+            f"{row.参与缸} | {row.异常缸数} | {row.严重异常缸数} | "
+            f"{'—' if lead is None else f'{lead:.2f} 天'} |"
+        )
+    lines.extend([
+        "",
+        "## 验证",
+        "",
+        "| 事件 | 故障缸 | 命中 | 整机事件 | 开始 | 结束 | 提前量 |",
+        "|---|---|---|---|---|---|---:|",
+    ])
+    for row in validation.itertuples(index=False):
+        lines.append(
+            f"| {row.event_id} | {row.fault_cylinder} | {'是' if row.hit else '否'} | "
+            f"{row.machine_label_id} | {row.start_time} | {row.end_time} | "
+            f"{'—' if not row.hit else f'{row.lead_days:.2f} 天'} |"
+        )
+    (config.OUTPUT_DIR / "machine_event_labels.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
+    machine_event_html.export_machine_events(frame, events, candidates, validation)
+
+    print(f"[machine] candidates={len(candidates)}, events={len(events)}, runs={len(runs)}, event_runs={summary['event_runs']}, preview={len(combined)}")
+    print(f"[machine] validation={summary['validation']['hit']}/{summary['validation']['total']}")
+    print(events[["标签ID", "开始小时", "结束小时", "阶段", "参与缸", "异常缸数", "严重异常缸数"]].to_string(index=False))
+
+
+if __name__ == "__main__":
+    main()

+ 3 - 1
_tmp_cylinder_vibration/unified_labels.py

@@ -141,10 +141,12 @@ def build_vibration_unified(hourly: pd.DataFrame, frame: pd.DataFrame, review: p
     return pd.DataFrame(rows, columns=VIBRATION_FIELDS)
 
 
-def _map_external(path: Path, label_type: str, prefix: str) -> pd.DataFrame:
+def _map_external(path: Path, label_type: str, prefix: str, unit: str | None = None) -> pd.DataFrame:
     if not path.exists():
         return pd.DataFrame(columns=CORE_FIELDS)
     frame = pd.read_csv(path, encoding="utf-8-sig")
+    if unit and "机组" in frame.columns:
+        frame = frame[frame["机组"].astype(str).str.strip() == unit].copy().reset_index(drop=True)
     if frame.empty:
         return pd.DataFrame(columns=CORE_FIELDS)
     stage_column = "压力阶段" if label_type == "润滑油压力" else "温度阶段"

+ 12 - 6
_tmp_cylinder_vibration/合并方案.md

@@ -25,6 +25,7 @@ models/cylinder_vibration/
   labels.py
   adaptive.py
   hourly.py
+  machine_events.py
   unified.py
   report.py
 ```
@@ -33,9 +34,9 @@ models/cylinder_vibration/
 
 ## 3. 合并步骤
 
-1. 固定 `_tmp_cylinder_vibration` 结果和报告,确认标签口径。
+1. 固定 `_tmp_cylinder_vibration` 的整机事件标签和验证结果,确认标签口径。
 2. 把纯算法模块迁入 `models/cylinder_vibration/`:
-   - `data.py`、`features.py`、`local_labels.py`、`adaptive.py`、`hourly.py`、`unified_labels.py`
+   - `data.py`、`features.py`、`local_labels.py`、`adaptive.py`、`hourly.py`、`machine_events.py`、`unified_labels.py`
 3. 把数据库写入、API、页面相关代码留在 backend:
    - 新增统一表 `compressor_fault_segment` 或复用现有故障标签表;
    - 提供 `GET /api/fault-segments`;
@@ -45,6 +46,9 @@ models/cylinder_vibration/
    - 启动/断档跳过;
    - 三等级阈值;
    - 相邻小时合并;
+   - 开机/停机 6 小时门控;
+   - 24 小时 ≥4 缸且 ≥2 条异常;
+   - 整机事件合并;
    - 统一字段映射;
    - 3 次事件回归。
 5. 后端接入后再把 `waveforms.html` 的交互能力迁入正式页面。
@@ -53,8 +57,10 @@ models/cylinder_vibration/
 
 - 核心字段见 `字段说明.md`;
 - 振动专有字段放 `details_json`;
-- `事件关联` 仅用于排查,不参与算法;
-- `启动过渡比例`、`局部基线可用比例` 只做质量标记。
+- `事件关联` 只放在验证表,不参与算法;
+- 故障缸不参与整机事件生成,只用于事后验证;
+- `启动过渡比例`、`局部基线可用比例` 只做质量标记;
+- 最终标签以 `machine_vibration_events.csv` 为准,单缸小时表只作为候选明细。
 
 ## 5. 不做的事
 
@@ -68,6 +74,6 @@ models/cylinder_vibration/
 
 - 同一批数据重复运行结果一致;
 - 3 次事件回归结果稳定;
-- 统一表能由压力、温度、振动三类映射生成;
-- 报告能复现;
+- 最终事件不依赖故障缸;
+- 统一表能由压力、温度、整机振动三类映射生成;
 - 无事件专用分支。

+ 28 - 7
_tmp_cylinder_vibration/字段说明.md

@@ -6,22 +6,25 @@
 
 - 压力:`models/results/pressure/润滑油压力小时阶段段.csv`
 - 温度:`_tmp_temperature/out/温度异常段.csv`
-- 振动:`_tmp_cylinder_vibration/out/cylinder_vibration_label_unified.csv`
+- 振动候选明细:`_tmp_cylinder_vibration/out/cylinder_vibration_label_unified.csv`
+- 振动最终标签:`_tmp_cylinder_vibration/out/machine_vibration_events.csv`
 
-三类统一预览:`_tmp_cylinder_vibration/out/fault_label_unified_preview.csv`。
+5 号机温度 + 整机振动统一预览:`_tmp_cylinder_vibration/out/fault_label_unified_machine_preview.csv`(18 + 4 = 22 行)。
+
+润滑油压力标签当前只有 8 号机 61 条,没有 5 号机,不纳入 5 号机振动-温度关系预览。
 
 ## 2. 统一核心字段
 
 | 字段 | 说明 |
 |---|---|
-| 标签ID | 统一标签编号,如 `5号机-VIB-0001` |
+| 标签ID | 统一标签编号;单缸候选如 `5号机-VIB-0001`,最终事件如 `5号机-MACH-0001` |
 | 标签类型 | 润滑油压力 / 温度异常 / 振动 |
 | 机组 | 如 `5号机` |
 | 批次 | 数据批次;振动全量扫描时为 `全量` |
-| 周期编号 | 压力/温度沿用原周期;振动为 `5号机-VIB-0001` |
+| 周期编号 | 压力/温度沿用原周期;振动候选为 `5号机-VIB-0001`,最终事件为 `5号机-MACH-0001` |
 | 阶段 | 轻微 / 异常 / 严重异常 |
-| 阶段部位 | 如 `5号机二缸`、`一级3#缸` |
-| 阶段点位 | 具体点位名 |
+| 阶段部位 | 如 `5号机二缸`、`一级3#缸`;最终振动事件为 `5号机整机` |
+| 阶段点位 | 具体点位名;最终振动事件为 `5号机一缸~六缸缸体振动` |
 | 开始小时 | 标签开始 |
 | 结束小时 | 标签结束(不含) |
 | 持续自然小时数 | 自然时间跨度 |
@@ -58,6 +61,17 @@
 | 阶段最大残差峭度z | 跨缸残差峭度 z-score |
 | 触发特征 | 分数最大的特征,如 `resid_kurt` |
 | 多特征确认 | RMS 与峭度是否同时抬升 |
+| 参与缸数 | 最终事件中 24 小时窗口内出现候选的缸数 |
+| 参与缸 | 参与的缸名列表 |
+| 异常缸数 | 至少一条异常/严重异常的缸数 |
+| 严重异常缸数 | 出现严重异常的缸数 |
+| 异常标签数 / 严重异常标签数 | 候选标签条数 |
+| 候选标签数 | 合并到该事件的单缸候选数 |
+| 最大异常分数 | 事件内最大分数 |
+| 最大RMS / 最大峭度 | 事件内最大特征值 |
+| 转速范围 | 事件期间转速范围 |
+| 运行段开始 / 结束 | 事件所在运行段 |
+| 整机聚集窗口小时 | 当前为 24 小时 |
 
 ## 4. 压力/温度映射
 
@@ -75,13 +89,20 @@
 - `阶段部位`、`阶段点位` 直接沿用
 - `方向`、`特征类型`、`置信度`、`是否形成确认等级`、`阶段触发依据` 直接沿用
 
-振动表:
+振动候选表:
 
 - `阶段` = 当前等级
 - `阶段部位` = `5号机{缸}`
 - `周期编号` = 振动标签编号
 - `阶段触发依据` 由 RMS、峭度、局部基线和持续时间自动生成
 
+最终整机事件表:
+
+- 一行代表一个整机多缸事件,不再要求故障缸自身振动最强;
+- `阶段部位` = `5号机整机`;
+- `事件关联` 留空,事件与维修记录的对应关系放在 `machine_event_validation.csv`;
+- 物理故障缸只做事后验证,不参与事件生成。
+
 ## 5. 数据库建议
 
 后续进入数据库时建议使用一张统一表,例如:

+ 8 - 4
_tmp_cylinder_vibration/报告生成.md

@@ -1,4 +1,7 @@
-# 报告生成说明
+# 报告生成说明(旧版单缸候选)
+
+> 当前最终标签已改为整机多缸事件,见 `out/machine_vibration_events.csv` 和 `out/machine_event_labels.md`。
+> 本说明只对应历史单缸候选报告,不再作为最终标签口径。
 
 ## 生成命令
 
@@ -8,8 +11,8 @@ python3 -m _tmp_cylinder_vibration.make_report
 
 会依次生成:
 
-1. `out/cylinder_vibration_label_unified.csv`
-2. `out/fault_label_unified_preview.csv`
+1. `out/cylinder_vibration_label_unified.csv`(单缸候选,中间表)
+2. `out/fault_label_unified_preview.csv`(旧版单缸预览)
 3. `out/report_assets/shot_0001.png` ~ 最后一条
 4. `总结文档/缸体振动演化过程故障标签手册.docx`
 
@@ -42,4 +45,5 @@ python3 -m _tmp_cylinder_vibration.make_report
 - 截图和统一表按同一标签顺序生成;
 - `waveforms.html` 更新后需重新运行报告生成;
 - 不要只截全页,避免把左侧列表带进报告;
-- 报告是排查材料,不是物理故障最终结论。
+- 报告是排查材料,不是物理故障最终结论;
+- 整机事件最终结果请使用 `out/machine_vibration_events.csv`。

+ 40 - 11
_tmp_cylinder_vibration/算法说明.md

@@ -87,26 +87,55 @@ z = (feature - local_median) / local_scale
 
 所有标签都保留,HTML 通过筛选查看。
 
-## 8. 事件验证
+## 8. 整机多缸事件层(v2 最终标签)
+
+单缸小时标签只作为候选明细,最终标签改为整机多缸事件:
+
+- 按 `rpm > 0`、断档 >2 小时切分运行段;
+- 排除开机后 6 小时、停机前 6 小时;
+- 24 小时窗口内至少 4 个不同缸有候选标签;
+- 其中至少 2 条达到“异常”或“严重异常”;
+- 相邻候选窗口间隔不超过 6 小时时合并;
+- 事件只表示“整机可能异常”,不要求故障缸自身振动最强;
+- 故障缸只用于事后验证,不参与事件生成。
+
+当前结果:
+
+```text
+稳定运行段候选标签:92 条
+最终整机事件:4 个
+运行段:16 个
+有事件运行段:4 个
+无事件运行段:12 个
+验证事件:3/3
+```
+
+其中 4/16~4/18 是未命中维修事件的整机事件,按要求保留,不做事先删除。
+
+## 9. 事件验证
 
 事件只用于验证,不参与标签生成:
 
-| 事件 | 缸 | 当前结果 | 等级 | 提前量 |
+| 事件 | 缸 | 整机事件 | 等级 | 提前量 |
 |---|---|---|---|---|
-| 2026-06-12 | 二缸 | 6/11 11:00~15:45 | 严重异常 | 0.54 天 |
-| 2026-06-21 | 五缸 | 6/18 09:45~11:15 | 轻微 | 2.59 天 |
-| 2026-07-11 | 二缸 | 7/05 01:15~03:00 | 轻微 | 5.95 天 |
+| 2026-06-12 | 二缸 | 6/10 19:00~6/11 11:00 | 严重异常 | 1.21 天 |
+| 2026-06-21 | 五缸 | 6/19 21:00~6/21 01:00 | 严重异常 | 1.12 天 |
+| 2026-07-11 | 二缸 | 7/06 09:00~7/08 08:00 | 异常 | 4.62 天 |
+
+另有 4/16 08:00~4/18 07:00 整机事件,未命中维修记录。
+
+HTML 中如果事件附近有温度标签,会额外画该温度标签所属级的 3 个同级缸温度曲线,不只画单个异常缸:例如 6/12 画一级 1#/2#/3#,6/21 画二级 1#/2#/3#。
 
-当前 3/3 事件有振动标签;6/21 是低幅高冲击,不再是漏报。
+整机事件不指定故障缸;6/12 的故障缸是二缸,但参与事件的是 4 个缸;6/21、7/11 也不要求故障缸自身振动最强。
 
-## 9. 已知限制
+## 10. 已知限制
 
 1. 首周期是快照,无法覆盖完整 15 分钟内的所有冲击;
-2. 启动/停机瞬态可能提高峭度,需要结合 `review_flags` 复核;
-3. 动态基线在早期数据不足 1 天时无法判断;
-4. 6/21 的振动证据较弱,温度是主要参考;
+2. 6/10~6/11 的高转速工况(约 997 rpm)仍可能放大整机振动,事件表保留转速范围供复核;
+3. 4 月以前数据只是未取得,不做“基线预热”排除;早期事件保留;
+4. 3 次维修事件同时用于当前验证,样本小,3/3 不代表泛化准确率;
 5. 等级是规则分级,不是物理故障严重度的绝对标定。
 
-## 10. 后续合并
+## 11. 后续合并
 
 见 `合并方案.md`。当前不修改 `models/`。