Переглянути джерело

新增运行期PKS故障候选扫描器

18922397810 7 годин тому
батько
коміт
16418dc412

+ 1 - 1
backend/LabelingPreTraining/detect_cycle_index.py

@@ -364,7 +364,7 @@ def main() -> int:
         with predict.get_connection() as connection:
             measurement_types = list_pending_types(connection)
         if not measurement_types:
-            print("没有待回写的测量类型(无 device_status = 1 且周期缺失/为 -1 的文件),退出")
+            print("没有待回写的测量类型(无 cycle_start IS NULL 的文件),退出")
             return 0
 
     start_time = args.start_time.strip()

+ 2 - 2
backend/app/services/data_service.py

@@ -1880,8 +1880,8 @@ class DataService:
             "diagnostics": diagnostics,
             "firstCycleMode": True,
             "firstCycleNotice": (
-                f"窗口内 {missing_cycles} 个文件尚未回写首个周期索引(cycle_start),"
-                "请先运行 detect_cycle_index.py 回写后再查看。"
+                f"窗口内 {missing_cycles} 个文件没有可用的单周期采样数据(wave_sample_one),"
+                "多为停机(rpm=0)文件,无法绘制首周期曲线。"
                 if missing_cycles > 0
                 else None
             ),

+ 6 - 6
backend/console.sql

@@ -6,7 +6,7 @@ group by device_part, device_point,measurement_type,point_name
 
 select point_name, measurement_type, min(sample_time) minTime,max(sample_time) maxTime, count(*)
 from wave_file
-where   rpm>0
+where point_name like '8号机%'
 group by measurement_type,point_name
 
 select point_name, measurement_type,count(*)
@@ -21,7 +21,7 @@ where point_name='7号机组一缸压力盖侧' and measurement_type='压力'
 order by sample_time desc
 
 select sample_index,signal_value,second_value
-from wave_sample where wave_file_id=1572
+from wave_sample_one where wave_file_id=1572
 order by sample_index asc
 
 # 查找第一个有压力数据的文件
@@ -33,13 +33,13 @@ order by sample_time asc
 limit 2
 
 # 7号机组一缸压力盖侧 压力 数据
-select * from wave_sample where wave_file_id=288146 order by wave_file_id,sample_index
+select * from wave_sample_one where wave_file_id=288146 order by wave_file_id,sample_index
 
 # 8号机组一缸压力盖侧 压力 数据
-select * from wave_sample where wave_file_id in(360903, 360904) order by wave_file_id,sample_index
+select * from wave_sample_one where wave_file_id in(360903, 360904) order by wave_file_id,sample_index
 
 # 9号机组一缸压力盖侧 压力 数据
-select * from wave_sample where wave_file_id in(434810, 434968) order by wave_file_id,sample_index
+select * from wave_sample_one where wave_file_id in(434810, 434968) order by wave_file_id,sample_index
 
 
 select min(tspluse_status),max(tspluse_status)
@@ -57,4 +57,4 @@ alter table wave_file add tspluse_status tinyint null default 0;
 alter table wave_file
     modify tspluse_status tinyint default 0 null comment '标注之前的小模型通用简单自动标注,0 没有异常,大于0有异常';
 
-update wave_file set tspluse_status=0;
+select * from machine_run_status order by import_batch_id,status_start

+ 262 - 0
backend/scan_pks_fault_candidates.py

@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""Scan running PKS data for interpretable fault-candidate segments.
+
+Read-only, bounded scanner. MySQL performs hourly aggregation inside each
+(batch, time-chunk) range; Python only receives the small hourly summary.
+The output is a candidate list for human review, not a confirmed diagnosis.
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import math
+import os
+import sys
+from collections import defaultdict
+from datetime import datetime, timedelta
+from pathlib import Path
+from statistics import median
+
+import pymysql
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from app.config import settings  # noqa: E402
+
+BATCH_TO_UNIT = {30: "7号机", 31: "8号机", 32: "9号机"}
+DEFAULT_COLUMNS = ("YSJ_5", "YSJ_7", "YSJ_8", "YSJ_9", "YSJ_10", "YSJ_11", "YSJ_14", "YSJ_40", "YSJ_41")
+EXPECTED_PER_HOUR = 720
+MIN_COVERAGE = 0.80
+
+HOURLY_FIELDS = [
+    "batch", "unit", "hour", "samples", "running_samples", "coverage",
+    "oil_avg", "oil_min", "oil_max", "p1_avg", "p2_avg", "p3_avg",
+    "coupling_avg", "coupling_max", "chain_avg", "chain_max",
+    "oil_temp_avg", "valve_avg",
+]
+
+SQL = """
+SELECT
+    FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
+    COUNT(*) AS samples,
+    SUM(YSJ_41 > 0) AS running_samples,
+    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
+    MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_min,
+    MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_max,
+    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_avg,
+    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_avg,
+    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_avg,
+    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
+    MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
+    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
+    MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
+    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_14 END) AS oil_temp_avg,
+    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_40 END) AS valve_avg
+FROM pks_long_sample
+WHERE import_batch_id = %s
+  AND sample_time >= %s
+  AND sample_time < %s
+GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
+ORDER BY hour_start
+"""
+
+
+def connect():
+    return pymysql.connect(
+        host=settings.db_host, port=settings.db_port, user=settings.db_user,
+        password=settings.db_password, database=settings.db_name,
+        charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
+        connect_timeout=settings.db_connect_timeout, read_timeout=600,
+        write_timeout=60, autocommit=True,
+    )
+
+
+def parse_dt(value: str) -> datetime:
+    return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
+
+
+def chunks(start: datetime, end: datetime, days: int):
+    cursor = start
+    step = timedelta(days=days)
+    while cursor < end:
+        nxt = min(cursor + step, end)
+        yield cursor, nxt
+        cursor = nxt
+
+
+def to_float(value):
+    if value is None:
+        return None
+    try:
+        value = float(value)
+    except (TypeError, ValueError):
+        return None
+    return value if math.isfinite(value) else None
+
+
+def fetch_hourly(batch: int, start: datetime, end: datetime, chunk_days: int, limit_chunks: int | None):
+    rows = []
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            for index, (chunk_start, chunk_end) in enumerate(chunks(start, end, chunk_days), 1):
+                if limit_chunks is not None and index > limit_chunks:
+                    break
+                cursor.execute("EXPLAIN " + SQL, (batch, chunk_start, chunk_end))
+                plan = cursor.fetchone()
+                if not plan or plan.get("key") not in ("PRIMARY",):
+                    raise RuntimeError(f"安全检查失败:batch={batch} chunk={chunk_start} EXPLAIN 未使用 PRIMARY: {plan}")
+                cursor.execute(SQL, (batch, chunk_start, chunk_end))
+                chunk_rows = cursor.fetchall()
+                for row in chunk_rows:
+                    item = {
+                        "batch": batch, "unit": BATCH_TO_UNIT[batch],
+                        "hour": row["hour_start"].strftime("%Y-%m-%d %H:%M:%S"),
+                        "samples": int(row["samples"] or 0),
+                        "running_samples": int(row["running_samples"] or 0),
+                    }
+                    item["coverage"] = item["running_samples"] / EXPECTED_PER_HOUR
+                    for key in HOURLY_FIELDS[6:]:
+                        item[key] = to_float(row[key])
+                    rows.append(item)
+                print(f"batch={batch} chunk={index} {chunk_start}..{chunk_end}: {len(chunk_rows):,} hourly rows")
+    finally:
+        connection.close()
+    return rows
+
+
+def robust_baseline(values):
+    values = [v for v in values if v is not None]
+    if len(values) < 12:
+        return None, None
+    center = median(values)
+    mad = median([abs(v - center) for v in values])
+    scale = max(1.4826 * mad, abs(center) * 0.01, 1e-9)
+    return center, scale
+
+
+def slope(values):
+    pairs = [(i, v) for i, v in enumerate(values) if v is not None]
+    if len(pairs) < 4:
+        return None
+    xbar = sum(x for x, _ in pairs) / len(pairs)
+    ybar = sum(y for _, y in pairs) / len(pairs)
+    denominator = sum((x - xbar) ** 2 for x, _ in pairs)
+    if denominator == 0:
+        return None
+    return sum((x - xbar) * (y - ybar) for x, y in pairs) / denominator
+
+
+def add_reason(candidates, row, kind, score, reason):
+    candidates.append({
+        "candidate_id": f"{row['unit']}-{kind}-{row['hour']}",
+        "unit": row["unit"], "batch": row["batch"], "fault_hint": kind,
+        "start_time": row["hour"], "end_time": row["hour"],
+        "peak_time": row["hour"], "score": round(score, 3),
+        "coverage": round(row["coverage"], 3),
+        "running_samples": row["running_samples"], "reason": reason,
+    })
+
+
+def detect(rows, baseline_hours: int):
+    by_batch = defaultdict(list)
+    for row in rows:
+        by_batch[row["batch"]].append(row)
+    candidates = []
+    for batch, series in by_batch.items():
+        series.sort(key=lambda r: r["hour"])
+        valid = [r for r in series if r["coverage"] >= MIN_COVERAGE]
+        for i, row in enumerate(series):
+            if row["coverage"] < MIN_COVERAGE:
+                continue
+            prior = [r for r in valid if r["hour"] < row["hour"]][-baseline_hours:]
+            if len(prior) < 12:
+                continue
+
+            oil_center, oil_scale = robust_baseline([r["oil_avg"] for r in prior])
+            oil_values = [r["oil_avg"] for r in prior[-6:]] + [row["oil_avg"]]
+            oil_slope = slope(oil_values)
+            if oil_center is not None and row["oil_avg"] is not None and oil_slope is not None:
+                oil_drop = (oil_center - row["oil_avg"]) / oil_scale
+                if oil_drop >= 2.5 and oil_slope < 0:
+                    add_reason(candidates, row, "润滑油压力下降", oil_drop + min(abs(oil_slope) / oil_scale, 5),
+                               f"YSJ_5低于近期基线{oil_drop:.1f}倍稳健尺度,近6个有效小时斜率为{oil_slope:.5f}")
+
+            for side, avg_key, max_key in (("联轴器端", "coupling_avg", "coupling_max"), ("链轮端", "chain_avg", "chain_max")):
+                avg_center, avg_scale = robust_baseline([r[avg_key] for r in prior])
+                max_center, max_scale = robust_baseline([r[max_key] for r in prior])
+                if max_center is None or row[max_key] is None:
+                    continue
+                peak_score = (row[max_key] - max_center) / max_scale
+                avg_score = ((row[avg_key] - avg_center) / avg_scale) if avg_center is not None and row[avg_key] is not None else 0
+                if peak_score >= 4:
+                    kind = "活塞/机械冲击候选" if side == "链轮端" else "机械振动候选"
+                    add_reason(candidates, row, kind, peak_score + max(avg_score, 0),
+                               f"{side}峰值{row[max_key]:.3f},高于近期基线{peak_score:.1f}倍稳健尺度;均值偏离{avg_score:.1f}")
+                elif avg_score >= 3:
+                    add_reason(candidates, row, "气阀相关振动候选", avg_score,
+                               f"{side}平均振动{row[avg_key]:.3f},高于近期基线{avg_score:.1f}倍稳健尺度")
+
+    return merge_candidates(candidates)
+
+
+def merge_candidates(candidates):
+    candidates.sort(key=lambda r: (r["batch"], r["fault_hint"], r["start_time"]))
+    merged = []
+    for item in candidates:
+        if merged and item["batch"] == merged[-1]["batch"] and item["fault_hint"] == merged[-1]["fault_hint"]:
+            previous = datetime.strptime(merged[-1]["end_time"], "%Y-%m-%d %H:%M:%S")
+            current = datetime.strptime(item["start_time"], "%Y-%m-%d %H:%M:%S")
+            if current - previous <= timedelta(hours=6):
+                merged[-1]["end_time"] = item["end_time"]
+                if item["score"] > merged[-1]["score"]:
+                    merged[-1]["score"] = item["score"]
+                    merged[-1]["peak_time"] = item["peak_time"]
+                if item["reason"] not in merged[-1]["reason"]:
+                    merged[-1]["reason"] += "; " + item["reason"]
+                continue
+        merged.append(dict(item))
+    return merged
+
+
+def write_csv(path: Path, rows, fields):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("w", encoding="utf-8-sig", newline="") as handle:
+        writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
+        writer.writeheader()
+        writer.writerows(rows)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="只读扫描运行期 PKS,生成故障候选时间段")
+    parser.add_argument("--batch", nargs="+", type=int, choices=sorted(BATCH_TO_UNIT), default=list(BATCH_TO_UNIT))
+    parser.add_argument("--start", default="2025-04-01 00:00:00")
+    parser.add_argument("--end", default="2026-05-01 00:00:00")
+    parser.add_argument("--chunk-days", type=int, default=14)
+    parser.add_argument("--baseline-hours", type=int, default=336, help="近期有效运行小时数,默认14天")
+    parser.add_argument("--limit-chunks", type=int, default=None, help="仅用于安全试跑")
+    parser.add_argument("--output-dir", default=str(Path(__file__).resolve().parents[1] / "cache" / "pks_scanner"))
+    args = parser.parse_args()
+    if args.chunk_days < 1 or args.baseline_hours < 12:
+        parser.error("chunk-days 必须>=1,baseline-hours 必须>=12")
+    start, end = parse_dt(args.start), parse_dt(args.end)
+    if end <= start:
+        parser.error("end 必须晚于 start")
+
+    print("只读模式:不修改任何数据库数据")
+    all_rows = []
+    for batch in args.batch:
+        all_rows.extend(fetch_hourly(batch, start, end, args.chunk_days, args.limit_chunks))
+    all_rows.sort(key=lambda r: (r["batch"], r["hour"]))
+    candidates = detect(all_rows, args.baseline_hours)
+    out = Path(args.output_dir)
+    write_csv(out / "hourly_summary.csv", all_rows, HOURLY_FIELDS)
+    write_csv(out / "fault_candidates.csv", candidates,
+              ["candidate_id", "unit", "batch", "fault_hint", "start_time", "end_time", "peak_time", "score", "coverage", "running_samples", "reason"])
+    print(f"小时摘要:{len(all_rows):,} 行")
+    print(f"候选段:{len(candidates):,} 段")
+    print(f"输出:{out / 'hourly_summary.csv'}")
+    print(f"输出:{out / 'fault_candidates.csv'}")
+
+
+if __name__ == "__main__":
+    main()

+ 350 - 0
异常检测方案讨论.md

@@ -46,3 +46,353 @@
 4. 输出形态:事件库 + 每列 top-N 清单/图表,还是整表 excel?
 5. 人工标注回写是否第一版就纳入闭环?
 6. 是否需要先解析 8/9号 正式运行区间、7号 rpm 推断区间并做一致性核对,再进入建模?
+
+## 六、2026-09 初步故障特征观察记录
+
+> 目的:先记录人工对维修事件附近数据的浅观察,不作为最终故障结论。观察范围严格限定在已知机组、有限故障前后时间窗口;没有对亿级/千万级表做全量查询。
+>
+> 重要限制:本轮实际统计主要使用 `pks_long_sample` 的 5 秒数据;尚未系统读取对应 `wave_file` 的压力功图,也尚未读取 `wave_sample_one` 中的高频压力/振动单周期数据。因此下面关于功图形状的内容只是下一步观察方向,不是本轮已验证结论。
+
+### 6.1 润滑油压力低
+
+代表事件:8 号机,2025-10-30、2025-10-31、2025-11-01。
+
+数据:`pks_long_sample`,`import_batch_id=31`;`YSJ_5` 为 8 号机润滑油压力,`YSJ_41>0` 作为运行判断。
+
+观察到的变化:
+
+- 2025-10-23 油压日均约 0.437;
+- 2025-10-24~10-29 逐步下降到约 0.360;
+- 2025-10-30 故障相关日期出现明显波动,最小值接近 0;
+- 2025-10-31 运行期间油压继续处于低位;
+- 故障前约一周存在连续下降趋势,不是单个采样点突然掉落。
+
+当前判断:这是本轮四类故障中最清楚的 PKS 特征。初步可描述为:
+
+```text
+稳定运行期油压持续下降 + 波动范围扩大 + 故障临近时出现低压/接近零值
+```
+
+注意:停机后的油压低值不能作为故障特征;油压还应结合转速、油温和负荷进行判断,不能直接使用固定阈值。
+
+### 6.2 气阀故障
+
+代表事件:
+
+- 7 号机,2025-08-08,在线振动异常,后发现气阀污染严重;
+- 7 号机,2025-10-07,链轮端振动高报警,后更换一级排气阀阀片;
+- 8 号机,2025-02-17,回流调节阀偏差故障。
+
+数据:本轮主要使用 `pks_long_sample` 的振动、压力和阀位字段;没有读取对应 `wave_file` 功图。
+
+7 号机 2025-10-07 事件的 PKS 观察:
+
+- 10-04~10-05 联轴器端/链轮端振动较稳定;
+- 10-06 两侧振动上升;
+- 10-07 故障当天链轮端振动最大值约 9.895,联轴器端最大值约 7.25;
+- 平均值的变化没有最大值和波动范围那么明显。
+
+当前判断:气阀故障可以先关注:
+
+```text
+某侧振动升高 + 振动峰值/波动范围扩大 + 对应气缸功图吸排气过程异常
+```
+
+但本轮尚未验证功图,因此“气阀导致的功图形状特征”仍待查看。不能把所有气阀故障都归纳成同一种振动模式。
+
+### 6.3 活塞故障
+
+代表事件:8 号机,2025-12-06,活塞开裂;维修措施包括更换活塞体、活塞杆、活塞环和支撑环,备注有振动报警。
+
+故障日前几天停机,但向前查到最近运行段为 2025-10-22~2025-11-05。
+
+观察到的变化:
+
+- 10-23~10-29 基本连续运行;
+- 10-30 以后运行变得不完整,出现多次运行/停机;
+- 10-23~10-30 链轮端振动最大值大约 3.7~4.2;
+- 11-04~11-05 链轮端振动最大值升至约 8.2~8.5;
+- 链轮端振动平均值没有同步大幅上升,表现更像间歇性冲击峰值;
+- 排气压力在个别日期有变化,但没有得到持续单调下降的证据;
+- 油温在后期较高,但受停机/重新启动影响,暂不能直接归因于活塞故障。
+
+当前判断:这个案例支持一个初步方向:
+
+```text
+故障前运行不稳定 + 链轮端振动峰值突然增加 + 平均值不一定明显增加
+```
+
+这更像机械部件的间歇性冲击,应重点回看 2025-11-04、11-05 的振动峰值对应时刻及其附近功图。当前还不能证明所有活塞故障都有该模式。
+
+其他活塞事件:7 号机 2025-01-10、9 号机 2026-01-18,在本次限定查询范围内没有找到有效故障前运行段,因此只能标记为“故障记录存在但运行数据不足”,不能当作“没有特征”。
+
+### 6.4 十字头故障
+
+代表事件:8 号机,2026-03-10,更换十字头;备注为“8 号机 4000 小时保养”,同时涉及三级气阀盖垫。
+
+故障日前停机,向前查到最近运行段为 2026-01-10~01-13。
+
+观察到的变化:
+
+- 01-10~01-13 有运行数据;
+- 联轴器端振动平均约 3.0~3.5,最大约 3.7~3.9;
+- 链轮端振动平均约 2.8~2.9,最大约 3.2~3.9;
+- 没有看到类似活塞案例中链轮端 8 以上的冲击峰;
+- 没有观察到维修前振动持续上升或排气压力明显失稳。
+
+当前判断:本案例暂时没有支持“十字头故障特征”的证据,并且这条记录可能是计划保养,而不是运行中发生的明确十字头故障。应暂时标记为:
+
+```text
+十字头相关维修/保养,故障证据等级低
+```
+
+如果以后找到明确的十字头损坏、间隙异常、断裂或振动报警记录,应重点检查链轮端冲击峰、两端振动差值、重复性和维修后是否消失。
+
+### 6.5 四类故障当前证据等级
+
+| 类型 | 当前结果 | 证据等级 |
+|---|---|---|
+| 润滑油压力低 | 故障前一周油压持续下降,临近故障时低压波动 | 较高 |
+| 气阀故障 | 振动峰值和波动范围可能增加,功图尚未核查 | 中等 |
+| 活塞故障 | 8 号案例出现运行不稳和链轮端冲击峰,功图尚未核查 | 中等偏低 |
+| 十字头故障 | 当前案例无明显故障前趋势,且可能是计划保养 | 低 |
+
+## 七、关于本轮分析方法的记录
+
+本轮不是把所有数据加载出来,而是先利用维修记录缩小搜索范围:
+
+1. 先确定故障设备和故障日期;
+2. 将设备映射到 `import_batch_id`;
+3. 只查询该批次在有限日期范围内的 `pks_long_sample` 主键区间;
+4. 按天聚合运行时长、均值、最小值、最大值;
+5. 先用 `YSJ_41>0` 去掉停机数据;
+6. 只对出现变化的日期再缩小到具体时间点;
+7. 最后才考虑根据具体 `wave_file_id` 读取 `wave_sample_one`。
+
+这是一种“故障事件锚定 + 时间窗口缩小 + 运行状态过滤 + 统计摘要 + 少量明细复核”的方法,适合千万级数据,不需要全表扫描。
+
+## 八、下一步分析计划(暂不进入训练)
+
+### 8.1 先补看 wave_file 功图和高频振动
+
+本轮没有系统观察:
+
+- `wave_file` 对应的压力功图;
+- `wave_file` 对应的十字头/机械振动高频波形;
+- `wave_sample_one` 中的单周期形状。
+
+下一步不按日期扫描 `wave_file`,而是:
+
+1. 先从 PKS 异常峰值的具体时间点找少量匹配的 `wave_file`;
+2. 根据 `wave_file.id` 读取 `wave_sample_one`;
+3. 每个事件只选少量代表样本:正常、异常前、异常峰值、维修后;
+4. 对比同一气缸/测点的功图或振动周期;
+5. 重点确认活塞与气阀的差异是否真的存在。
+
+### 8.2 先做“故障特征观察表”,不要马上做模型
+
+每个事件先记录:
+
+```text
+故障类型
+设备/气缸/测点
+故障日期
+最后稳定运行段
+故障前异常开始时间
+异常点位
+异常是均值、趋势、波动还是峰值
+是否只发生在一个气缸/一侧
+功图是否同步异常
+维修后是否恢复
+```
+
+### 8.3 关于长期缓慢变化是否适合训练和预测
+
+长期变化不是“不适合模型”,但不适合直接做“一条数据一个故障分类”。原因是:
+
+- 它更像退化过程,而不是瞬时分类;
+- 维修日期不是精确故障开始时间;
+- 长期趋势容易与季节、负荷、运行小时数、传感器漂移混淆;
+- 如果把故障前几周全部标成故障,标签会很粗糙;
+- 如果只把维修当天标成故障,又会错过提前变化。
+
+当前阶段应把它理解成两层问题:
+
+1. **变化检测**:某个运行变量是否持续偏离自己的近期正常水平;
+2. **故障归因**:这种变化更像气阀、活塞、十字头还是润滑系统。
+
+第一层可以较早实现,第二层必须依赖更多事件和功图证据。现阶段最合适的算法不是直接训练四分类模型,而是先做:
+
+```text
+运行状态过滤
+→ 近期基线
+→ 趋势/波动/峰值特征
+→ 持续性判断
+→ 多点位联合排序
+→ 人工查看少量代表波形
+```
+
+对于长期变化,推荐输出“趋势和风险候选”,而不是直接输出“确定故障”。例如:
+
+```text
+润滑油压力:连续 6 个运行日下降,风险升高
+链轮端振动:P99 和峰值连续 2 个运行日增加,疑似机械冲击
+功图残差:连续多个采样周期偏离近期基线,待判断气阀/活塞
+```
+
+只有当同一故障类型积累了多个有效运行期事件,并且能用功图、PKS 和维修结果互相印证后,再考虑监督训练。
+
+## 九、运行期 PKS 故障候选扫描器(第一版)
+
+已实现只读脚本:`backend/scan_pks_fault_candidates.py`。
+
+### 运行方式
+
+必须使用有限时间范围试跑;例如:
+
+```bash
+conda run -n tspulse python backend/scan_pks_fault_candidates.py \\
+   --batch 31 \\
+   --start "2025-10-20 00:00:00" \\
+   --end "2025-11-10 00:00:00" \\
+   --chunk-days 7 \\
+   --output-dir cache/pks_scanner/test
+```
+
+正式扫描前应先使用 `--limit-chunks 1` 做单块试跑。脚本每个批次、每个时间块先执行 `EXPLAIN`,只有确认使用 `pks_long_sample` 的 `PRIMARY(import_batch_id, sample_time)` 才继续;脚本只执行 SELECT,不修改数据库。
+
+### 第一版实现的内容
+
+- 数据库端按小时聚合,Python 不接收原始 5 秒明细;
+- 只按 `import_batch_id + sample_time` 分块;
+- 每小时计算运行样本数和运行覆盖率;
+- 默认只把覆盖率至少 80% 的小时用于趋势判断;
+- 使用 `YSJ_41>0` 过滤运行状态;
+- 润滑油压力使用 `YSJ_5` 的近期基线、下降趋势和低值偏离;
+- 振动使用 `YSJ_10/YSJ_11` 的平均值、最大值和近期稳健基线;
+- 输出“润滑油压力下降”“气阀相关振动”“活塞/机械冲击”等候选提示,不代表最终故障诊断;
+- 暂不对十字头做强判定。
+
+### 输出文件
+
+默认输出到 `cache/pks_scanner/`:
+
+- `hourly_summary.csv`:每个批次每小时一行;
+- `fault_candidates.csv`:合并后的候选时间段,包含时间、分数、运行覆盖率和触发原因。
+
+### 小范围试跑结果
+
+使用 8 号机(batch 31)2025-10-20~2025-11-10 的 21 天范围试跑:
+
+- 返回 504 行小时摘要;
+- 生成 6 个候选段;
+- 找回了 10 月下旬的润滑油压力下降候选;
+- 找回了 11 月 4 日链轮端机械冲击候选;
+- 这与已知 8 号润滑油压力低、活塞开裂事件附近的人工观察方向一致。
+
+这个结果只能说明候选扫描器能够找回已知线索,不能说明规则已经具备故障诊断准确率。当前还需要:
+
+1. 调整候选连续性和合并规则;
+2. 排除启停过渡造成的峰值;
+3. 找到候选时间对应的 `wave_file.id`;
+4. 用少量 `wave_sample_one` 验证压力功图和高频振动;
+5. 再决定是否扩展为全时间范围扫描。
+
+## 十、第二轮故障特征确认结果
+
+> 本轮继续只查已知故障附近的小时间窗口,按 `import_batch_id + sample_time` 读取 `pks_long_sample`;没有执行全表统计。小时聚合用于确认前一轮的日级观察是否真实。
+
+### 9.1 润滑油压力低:趋势在小时级仍然成立
+
+8 号机 2025-10-29~11-01 的小时数据表明,故障前油压不是某一个采样点偶然下降,而是运行期间逐小时降低:
+
+- 10-29 晚间约 0.35 左右;
+- 10-30 00:00~08:00 继续降到约 0.343;
+- 10-30 13:00 前仍在运行,之后出现停机/状态变化;
+- 10-31 重新运行后油压在 0.35~0.42 之间波动,随后再次停机;
+- 11-01 只有极少运行采样,不能用于正常运行特征。
+
+新增判断:润滑油压力低具备“小时级连续趋势 + 故障临近运行中断”的特征。但 10-30 后的停机过渡不能再作为油压故障样本。
+
+### 9.2 气阀故障:振动上升和峰值出现的时间更集中
+
+7 号机 2025-10-07 气阀故障事件的小时级观察:
+
+- 10-04~10-05 两侧振动大多稳定在约 4.1~4.4;
+- 10-06 联轴器端和链轮端平均振动逐步升到约 4.5~4.8;
+- 10-07 01:00~09:00 联轴器端平均约 4.7~5.3,链轮端平均约 4.8~5.2;
+- 10-07 12:00 的运行样本只剩 80 个,但链轮端最大值 9.895、联轴器端最大值 7.25;
+- 排气压力总体仍在正常量级,没有看到明显的长期压力塌陷。
+
+新增判断:该气阀案例更像“故障前数小时振动整体抬高,随后出现峰值”,但最后一个峰值所在小时运行样本很少,可能包含停机过渡。算法必须同时记录 `run_n / 720`,低覆盖率小时不能与完整运行小时同权。
+
+### 9.3 活塞故障:确认是少数小时的链轮端冲击峰
+
+8 号机 2025-12-06 活塞开裂,往前找到的最后运行段为 2025-11-03~11-05。
+
+小时级新增观察:
+
+- 11-03 15:00~20:00 处于重新运行/工况恢复阶段,不能直接当作稳定故障特征;
+- 11-04 01:00 链轮端最大值约 7.36,07:00 最大值约 8.21;
+- 11-04 08:00 后峰值回落,平均值也逐步下降;
+- 11-05 11:00 再次出现链轮端最大值约 8.47;
+- 两次冲击峰之间,链轮端平均值约 3.0~4.2,并没有持续单调上升。
+
+新增判断:活塞案例的特征更准确地描述为:
+
+```text
+稳定运行背景下出现间歇性机械冲击峰,峰值在不同运行日重复出现;
+平均振动不一定持续升高。
+```
+
+这比“链轮端振动持续升高”更准确。下一步应回看 11-04 01:00、07:00 和 11-05 11:00 附近的高频波形,确认这些峰值是否具有固定周期/冲击形状。
+
+### 9.4 十字头相关维修:仍无故障前证据
+
+8 号机 2026-03-10 十字头更换,向前找到的最近运行段为 2026-01-10~01-13。
+
+小时级数据仍显示:
+
+- 联轴器端最大值约 3.0~3.9;
+- 链轮端最大值约 2.8~3.9;
+- 没有重复出现的高冲击峰;
+- 没有明显持续上升趋势;
+- 排气压力没有持续失稳。
+
+新增判断没有改变:当前记录不能证明存在“十字头故障前特征”。由于维修备注是“4000 小时保养”,应将该事件视为低置信度十字头故障样本。
+
+### 9.5 wave_file / wave_sample_one 高频验证状态
+
+本轮尝试从 PKS 异常时刻直接按 `point_name + sample_time` 查找 `wave_file`,但:
+
+- `wave_file` 的相关日期查询执行计划仍可能扫描约 93 万行;
+- 使用当前点位名称和小时范围没有返回匹配记录;
+- 因没有拿到可靠的 `wave_file.id`,没有继续读取 `wave_sample_one`,避免扩大查询。
+
+因此截至本记录:
+
+- PKS 的趋势、振动峰值结论已做了第二轮确认;
+- 压力功图形状、十字头/机械高频振动形状仍未验证;
+- 后续必须先解决 `wave_file` 的可靠定位键(设备编码、点位规范或已知文件 ID),再做小批量高频读取。
+
+### 9.6 对后续算法的直接启示
+
+当前四类故障不能使用同一种异常指标:
+
+| 故障 | 更适合的初步指标 |
+|---|---|
+| 润滑油压力低 | 运行期油压斜率、小时/日均值、低压持续时长、波动扩大 |
+| 气阀故障 | 振动短期抬升、峰值/P99、两端振动同步性、功图吸排气段变化 |
+| 活塞故障 | 链轮端冲击峰、峰值重复出现、峰值与平均值分离、运行不稳定 |
+| 十字头故障 | 需要高频机械波形、链轮端冲击、两端差值;当前证据不足 |
+
+特别需要保留以下质量字段:
+
+```text
+运行样本数 / 应有样本数
+是否处于启停过渡
+小时内运行覆盖率
+峰值是否发生在完整运行段
+```
+
+否则很容易把“停机前最后一小段数据”的峰值误认为故障特征。