Browse Source

整理 indicator_features 脚本,清理旧 PKS 扫描脚本,更新缓存索引

18922397810 1 month ago
parent
commit
9cd23c8f30

+ 47 - 0
backend/indicator_features/README.md

@@ -37,3 +37,50 @@ python indicator_features/analyze.py cache/indicator/7_1stage_valve_202605.csv
 3. **数据覆盖**:功图只在设备运行日存在,很多故障发生在停机附近,故障前数据可能不足。
 
 见 `观察记录.md`。
+
+## 每 10° 分箱局部统计
+
+整周期统计会互相抵消,故障可能只在某个小角度区间。把每周期按键相 0-360° 切成 36 个 10° 箱,箱内直接在原始点上算 **23 个**特征(不重采样),再做**同点**前后对比;另外每周期还算 **13 个**阀时序特征。
+
+- 分箱 23:均值/极差/标准差/中位绝对偏差/斜率/粗糙度 + 10/50/90分位/四分位距 + 偏度/峰度 + 导数族 + 形状族(二阶导最大/总变差/过均次数/移动度/复杂度)。
+- 整周期 13:吸气压力/排气压力/压比/吸气占比/排气占比 + 最小、最大压力角 + 上升、下降穿越角 + 最大上升、下降斜率及其角度。
+- 中文名对照见 `cn_names.py`。
+
+```bash
+# 0) 看某机组的 rpm>0 运行段(开机表按数据本身统计)
+python indicator_features/run_segments.py --unit 7
+
+# 1) 逐周期逐箱抽特征(只读,rpm>0;--bin-deg 可选 5/10/15/20)
+python indicator_features/angle_bins.py --unit 7 \
+  --start 2025-04-30 --end 2026-06-07 --per-day 3 \
+  --output cache/indicator/bins/7_all.csv
+
+# 2) 同点前后对比(--normalize span 压工况;--z 变化阈值;前段取事件前最近运行段)
+python indicator_features/event_bins.py --unit 7 \
+  --csv cache/indicator/bins/7_all.csv --normalize span --z 2 \
+  --pre-days 6 --post-days 30 --max-gap 45 --trend-days 60 \
+  --out cache/indicator/bins/event_final_7.csv
+
+# 3) 跨事件候选汇总(事件×测点×角度箱 合并指标;含“整体/局部”范围列)
+python indicator_features/summary_features.py \
+  --csv cache/indicator/bins/event_final_7.csv cache/indicator/bins/event_final_8.csv cache/indicator/bins/event_final_9.csv \
+  --threshold 2 --out cache/indicator/bins/summary_final.csv
+
+# 4) 生成中文交付(CSV + 特征汇总.md)
+cd indicator_features && python3 make_report.py && cd ..
+
+# 5) 单指标全时间轴演化轨迹(查事件前是否有长期渐变)
+python indicator_features/metric_trace.py --csv cache/indicator/bins/7_all.csv \
+  --unit 7 --cylinder 二 --side 盖侧 --bin-start 140 --metric std \
+  --out cache/indicator/bins/plots/7号_二缸盖侧_140-150_波动随时间.png
+
+# 6) 单点 停机前/开机后 三联简图(左 平均压力 / 中 波动 / 右 关注段随时间)
+python indicator_features/pair_plot.py --csv cache/indicator/bins/7_all.csv \
+  --unit 7 --date 2026-05-21 --cylinder 二 --side 盖侧 --highlight 140-150 \
+  --series-metric std --out cache/indicator/bins/plots/7号_2026-05-21_二缸盖侧_140-150.png
+```
+
+- 对比只限**同一点**(同缸同侧);每个指标分成 **整体**(全角度一起动=负荷/传感器)与 **局部**(个别箱动=阀/局部故障)。
+- `--normalize span`:压力幅度类指标除以本周期压力幅度,压掉负荷/压比变化。
+- 坏道/卡死(压力幅度<0.3)自动跳过;角度型指标按 0-360° 环形处理。
+- 中文交付在 `cache/indicator/bins/results_cn/`。

+ 313 - 0
backend/indicator_features/angle_bins.py

@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+"""Per-10-degree local statistics from every stored indicator cycle (read-only).
+
+Each ``wave_sample_one`` row set is exactly one crankshaft revolution of chamber
+pressure.  Map every raw sample to a crank angle (0-360, linear in sample index,
+0 = keyphasor marker) and split it into 36 ten-degree bins.  On the **raw**
+samples of each bin compute six simple numbers:
+
+    mean      箱内平均压力
+    range     箱内 max - min
+    std       箱内标准差(波动)
+    mad       箱内中位绝对偏差(稳健波动)
+    slope     箱内压力对角度的线性斜率
+    roughness 去掉箱内线性趋势后的残差 RMS(平滑 / 毛糙)
+
+No resampling.  One long CSV row per (cycle, bin), so the file is large by
+design and the caller should keep ``--per-day`` small.  Only running cycles
+(``wave_file.rpm > 0``) are used.  Same-point before/after comparison is done by
+``event_bins.py``.
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+import pymysql
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+from app.config import settings  # noqa: E402
+from indicator_features.bins_common import BIN_METRICS, CYCLE_METRICS, FIELDNAMES  # noqa: E402
+from indicator_features.indicator_features import CYLINDER_STAGE, otsu_threshold, smooth  # noqa: E402
+
+CYLINDERS = ("一", "二", "三", "四", "五", "六")
+SIDES = ("盖侧", "轴侧")
+DEFAULT_BIN_DEG = 10.0
+
+
+def connect() -> pymysql.connections.Connection:
+    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=8, read_timeout=600,
+    )
+
+
+def point_name(unit: str, cylinder: str, side: str) -> str:
+    return f"{unit}号机组{cylinder}缸压力{side}"
+
+
+def fetch_file_rows(connection, point: str, start: str, end: str) -> list[dict]:
+    """Running pressure files for one point in [start, end)."""
+    with connection.cursor() as cursor:
+        cursor.execute(
+            """
+            SELECT id, sample_time, rpm
+            FROM wave_file
+            WHERE point_name = %s AND measurement_type = '压力'
+              AND rpm > 0
+              AND sample_time >= %s AND sample_time < %s
+            ORDER BY sample_time
+            """,
+            (point, start, end),
+        )
+        return list(cursor.fetchall())
+
+
+def sample_per_day(rows: list[dict], per_day: int) -> list[dict]:
+    if per_day <= 0:
+        return rows
+    by_day: dict = defaultdict(list)
+    for row in rows:
+        by_day[row["sample_time"].date()].append(row)
+    picked: list[dict] = []
+    for day in sorted(by_day):
+        day_rows = by_day[day]
+        if len(day_rows) <= per_day:
+            picked.extend(day_rows)
+            continue
+        idx = np.linspace(0, len(day_rows) - 1, per_day).round().astype(int)
+        picked.extend(day_rows[int(i)] for i in idx)
+    return picked
+
+
+def fetch_cycles(connection, file_ids: list[int], batch: int) -> dict[int, np.ndarray]:
+    cycles: dict[int, list[tuple[int, float]]] = defaultdict(list)
+    with connection.cursor() as cursor:
+        for start in range(0, len(file_ids), batch):
+            chunk = file_ids[start:start + batch]
+            placeholders = ", ".join(["%s"] * len(chunk))
+            cursor.execute(
+                f"""
+                SELECT wave_file_id, sample_index, CAST(signal_value AS FLOAT) AS sig
+                FROM wave_sample_one
+                WHERE wave_file_id IN ({placeholders})
+                ORDER BY wave_file_id, sample_index
+                """,
+                tuple(chunk),
+            )
+            for row in cursor.fetchall():
+                if row["sig"] is None:
+                    continue
+                cycles[int(row["wave_file_id"])].append((int(row["sample_index"]), float(row["sig"])))
+    result: dict[int, np.ndarray] = {}
+    for file_id, rows in cycles.items():
+        rows.sort()
+        result[file_id] = np.asarray([value for _, value in rows], dtype=float)
+    return result
+
+
+def _safe_ratio(numerator: float, denominator: float) -> float | None:
+    return numerator / denominator if abs(denominator) > 1e-12 else None
+
+
+def bin_features(signal: np.ndarray, bin_deg: float = DEFAULT_BIN_DEG) -> list[dict]:
+    """Distribution / derivative / oscillation numbers for each angular bin (raw samples)."""
+    p = np.asarray(signal, dtype=float)
+    p = p[np.isfinite(p)]
+    count = len(p)
+    if count < 60:
+        return []
+    nbins = int(round(360.0 / bin_deg))
+    angle = 360.0 * np.arange(count) / count
+    features = []
+    for b in range(nbins):
+        low = b * bin_deg
+        high = low + bin_deg
+        mask = (angle >= low) & (angle < high)
+        y = p[mask]
+        x = angle[mask]
+        if y.size < 3:
+            continue
+        center = float(np.mean(y))
+        std = float(np.std(y))
+        span_x = float(np.ptp(x))
+        if span_x > 0:
+            slope, intercept = np.polyfit(x, y, 1)
+            residual = y - (slope * x + intercept)
+        else:
+            slope, residual = 0.0, y - center
+        p10, p50, p90 = (float(v) for v in np.percentile(y, [10, 50, 90]))
+        if std > 1e-12:
+            z = (y - center) / std
+            skew = float(np.mean(z ** 3))
+            kurt = float(np.mean(z ** 4))
+        else:
+            skew = kurt = 0.0
+        dy = np.diff(y)
+        d2 = np.diff(dy) if dy.size > 1 else np.empty(0)
+        if dy.size:
+            abs_dy = np.abs(dy)
+            d_abs_mean = float(np.mean(abs_dy))
+            d_abs_max = float(np.max(abs_dy))
+            d_rms = float(np.sqrt(np.mean(dy ** 2)))
+            tv = float(np.sum(abs_dy))
+            if d2.size:
+                d2_rms = float(np.sqrt(np.mean(d2 ** 2)))
+                d2_abs_max = float(np.max(np.abs(d2)))
+                sign = np.sign(dy)
+                sign[sign == 0] = 1
+                zero_cross = int(np.count_nonzero(np.diff(sign) != 0))
+            else:
+                d2_rms = d2_abs_max = 0.0
+                zero_cross = 0
+        else:
+            d_abs_mean = d_abs_max = d_rms = d2_rms = d2_abs_max = tv = 0.0
+            zero_cross = 0
+        n_extrema = int(np.count_nonzero(np.diff(np.sign(np.diff(y))) != 0)) if y.size > 2 else 0
+        centered = y - center
+        mc = np.sign(centered)
+        mc[mc == 0] = 1
+        mean_cross = int(np.count_nonzero(np.diff(mc) != 0)) if y.size > 1 else 0
+        # Hjorth mobility / complexity (shape/irregularity)
+        var_y = float(np.var(y))
+        var_dy = float(np.var(dy)) if dy.size else 0.0
+        var_d2 = float(np.var(d2)) if d2.size else 0.0
+        mobility = float(np.sqrt(var_dy / var_y)) if var_y > 1e-18 else 0.0
+        complexity = (float(np.sqrt(var_d2 / var_dy)) / mobility) if var_dy > 1e-18 and mobility > 1e-18 else 0.0
+        features.append({
+            "bin": b, "bin_start": low, "n_raw": int(y.size),
+            "mean": center, "range": float(np.max(y) - np.min(y)), "std": std,
+            "mad": float(np.median(np.abs(y - np.median(y)))), "slope": float(slope),
+            "roughness": float(np.sqrt(np.mean(residual ** 2))),
+            "p10": p10, "p50": p50, "p90": p90, "iqr": p90 - p10,
+            "skew": skew, "kurt": kurt,
+            "d_abs_mean": d_abs_mean, "d_abs_max": d_abs_max, "d_rms": d_rms,
+            "d2_rms": d2_rms, "zero_cross": zero_cross, "n_extrema": n_extrema,
+            "d2_abs_max": d2_abs_max, "tv": tv, "mean_cross": mean_cross,
+            "mobility": mobility, "complexity": complexity,
+        })
+    return features
+
+
+def cycle_timing(signal: np.ndarray) -> dict:
+    """Whole-cycle plateau / valve-event features (Otsu plateaus + derivative corners)."""
+    p = np.asarray(signal, dtype=float)
+    p = p[np.isfinite(p)]
+    count = len(p)
+    out = {name: None for name in CYCLE_METRICS}
+    if count < 60:
+        return out
+    ps = smooth(p, 21)
+    angle = 360.0 * np.arange(count) / count
+    threshold = otsu_threshold(ps)
+    low = ps[ps <= threshold]
+    high = ps[ps > threshold]
+    suction = float(np.median(low)) if low.size else float(np.min(ps))
+    discharge = float(np.median(high)) if high.size else float(np.max(ps))
+    high_mask = ps >= threshold
+    out["p_suction"] = suction
+    out["p_discharge"] = discharge
+    out["pressure_ratio"] = _safe_ratio(discharge, suction)
+    out["suction_frac"] = float(np.mean(~high_mask))
+    out["discharge_frac"] = float(np.mean(high_mask))
+    out["angle_min"] = float(angle[int(np.argmin(ps))])
+    out["angle_max"] = float(angle[int(np.argmax(ps))])
+    signed = np.sign(ps - threshold)
+    signed[signed == 0] = 1
+    rising = np.flatnonzero(np.diff(signed) > 0)
+    falling = np.flatnonzero(np.diff(signed) < 0)
+    if rising.size:
+        out["cross_up"] = float(angle[rising[0]])
+    if falling.size:
+        out["cross_down"] = float(angle[falling[0]])
+    dp = np.gradient(ps)
+    out["max_rise"] = float(np.max(dp))
+    out["max_fall"] = float(np.min(dp))
+    out["max_rise_angle"] = float(angle[int(np.argmax(dp))])
+    out["max_fall_angle"] = float(angle[int(np.argmin(dp))])
+    return out
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="功图每10°分箱局部统计(只读,抽样)")
+    parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
+    parser.add_argument("--cylinders", nargs="+", default=list(CYLINDERS), choices=CYLINDERS)
+    parser.add_argument("--sides", nargs="+", default=list(SIDES), choices=SIDES)
+    parser.add_argument("--start", required=True, help="YYYY-MM-DD")
+    parser.add_argument("--end", required=True, help="YYYY-MM-DD(不含)")
+    parser.add_argument("--per-day", type=int, default=4, help="每测点每天最多取几个运行文件")
+    parser.add_argument("--bin-deg", type=float, default=DEFAULT_BIN_DEG, help="角度箱宽度(默认10°,可选5/15/20)")
+    parser.add_argument("--batch", type=int, default=200, help="IN 查询批大小")
+    parser.add_argument("--output", required=True)
+    args = parser.parse_args()
+    if not (0 < args.bin_deg <= 90):
+        parser.error("--bin-deg 必须在 (0, 90] 之间")
+
+    connection = connect()
+    rows_out: list[dict] = []
+    try:
+        for cylinder in args.cylinders:
+            for side in args.sides:
+                point = point_name(args.unit, cylinder, side)
+                files = fetch_file_rows(connection, point, args.start, args.end)
+                if not files:
+                    print(f"{point}: 无运行文件", flush=True)
+                    continue
+                picked = sample_per_day(files, args.per_day)
+                cycles = fetch_cycles(connection, [int(r["id"]) for r in picked], args.batch)
+                id_to_time = {int(r["id"]): r for r in picked}
+                kept = 0
+                for file_id, signal in cycles.items():
+                    bins = bin_features(signal, args.bin_deg)
+                    if not bins:
+                        continue
+                    meta = id_to_time[file_id]
+                    finite = signal[np.isfinite(signal)]
+                    base = {
+                        "unit": args.unit,
+                        "cylinder": cylinder,
+                        "stage": CYLINDER_STAGE[cylinder],
+                        "side": side,
+                        "point_name": point,
+                        "wave_file_id": file_id,
+                        "sample_time": str(meta["sample_time"]),
+                        "rpm": float(meta["rpm"] or 0.0),
+                        "n_samples": int(finite.size),
+                        "p_min": float(finite.min()),
+                        "p_max": float(finite.max()),
+                        "p_span": float(finite.max() - finite.min()),
+                    }
+                    timing = cycle_timing(signal)
+                    for item in bins:
+                        rows_out.append({**base, **item, **timing})
+                    kept += 1
+                days = len({r["sample_time"].date() for r in picked})
+                print(f"{point}: 运行文件 {len(files)},抽样 {len(picked)}({days}天),出周期 {kept}", flush=True)
+    finally:
+        connection.close()
+
+    if not rows_out:
+        print("没有特征行,退出")
+        return
+    rows_out.sort(key=lambda r: (r["cylinder"], r["side"], r["sample_time"], r["bin"]))
+    output = Path(args.output)
+    output.parent.mkdir(parents=True, exist_ok=True)
+    with output.open("w", encoding="utf-8-sig", newline="") as handle:
+        writer = csv.DictWriter(handle, fieldnames=FIELDNAMES)
+        writer.writeheader()
+        writer.writerows(rows_out)
+    periods = len({(r["point_name"], r["wave_file_id"]) for r in rows_out})
+    print(f"周期 {periods:,},特征行 {len(rows_out):,} -> {output}")
+    print(f"时间:{datetime.now():%Y-%m-%d %H:%M:%S}")
+
+
+if __name__ == "__main__":
+    main()

+ 128 - 0
backend/indicator_features/audit_wave_metadata.py

@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Read-only inventory of indicator-diagram files; does not fetch sample points."""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from pathlib import Path
+
+import pymysql
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+from app.config import settings  # noqa: E402
+
+
+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=8,
+        read_timeout=120,
+    )
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="只读盘点功图文件元数据,不读取采样点")
+    parser.add_argument("--units", nargs="+", default=["7", "8", "9"])
+    parser.add_argument("--out", default="cache/indicator/元数据盘点.csv")
+    args = parser.parse_args()
+
+    unit_like = [f"{unit}号机组%" for unit in args.units]
+    connection = connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT
+                    CASE
+                      WHEN point_name LIKE '7号机组%%' THEN '7'
+                      WHEN point_name LIKE '8号机组%%' THEN '8'
+                      WHEN point_name LIKE '9号机组%%' THEN '9'
+                    END AS unit,
+                    DATE(sample_time) AS day,
+                    COUNT(*) AS files,
+                    SUM(CASE WHEN rpm > 0 THEN 1 ELSE 0 END) AS running_files,
+                    MIN(sample_time) AS first_time,
+                    MAX(sample_time) AS last_time
+                FROM wave_file
+                WHERE measurement_type = '压力'
+                  AND (point_name LIKE %s OR point_name LIKE %s OR point_name LIKE %s)
+                GROUP BY unit, DATE(sample_time)
+                ORDER BY unit, day
+                """,
+                tuple(unit_like),
+            )
+            daily = list(cursor.fetchall())
+
+            cursor.execute(
+                """
+                SELECT
+                    CASE
+                      WHEN point_name LIKE '7号机组%%' THEN '7'
+                      WHEN point_name LIKE '8号机组%%' THEN '8'
+                      WHEN point_name LIKE '9号机组%%' THEN '9'
+                    END AS unit,
+                    COUNT(*) AS files,
+                    SUM(CASE WHEN rpm > 0 THEN 1 ELSE 0 END) AS running_files,
+                    COUNT(DISTINCT point_name) AS points,
+                    MIN(sample_time) AS first_time,
+                    MAX(sample_time) AS last_time
+                FROM wave_file
+                WHERE measurement_type = '压力'
+                  AND (point_name LIKE %s OR point_name LIKE %s OR point_name LIKE %s)
+                GROUP BY unit
+                ORDER BY unit
+                """,
+                tuple(unit_like),
+            )
+            totals = list(cursor.fetchall())
+
+            cursor.execute(
+                """
+                SELECT measurement_type, COUNT(*) AS files
+                FROM wave_file
+                WHERE point_name LIKE %s OR point_name LIKE %s OR point_name LIKE %s
+                GROUP BY measurement_type
+                ORDER BY measurement_type
+                """,
+                tuple(unit_like),
+            )
+            types = list(cursor.fetchall())
+    finally:
+        connection.close()
+
+    selected = {str(row["unit"]) for row in totals}
+    totals = [row for row in totals if str(row["unit"]) in selected]
+    out = Path(args.out)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    with out.open("w", encoding="utf-8-sig", newline="") as handle:
+        writer = csv.DictWriter(
+            handle,
+            fieldnames=["unit", "day", "files", "running_files", "first_time", "last_time"],
+        )
+        writer.writeheader()
+        writer.writerows(daily)
+
+    print("功图文件元数据盘点完成(没有读取 wave_sample_one)")
+    print(f"按天明细:{out},共 {len(daily):,} 个有记录的机组-日期")
+    print("\n按机组汇总:")
+    for row in totals:
+        print(
+            f"  {row['unit']}号:文件 {row['files']:,},运行文件 {row['running_files']:,},"
+            f"测点 {row['points']},时间 {row['first_time']} ~ {row['last_time']}"
+        )
+    print("\n测量类型汇总:")
+    for row in types:
+        print(f"  {row['measurement_type']}: {row['files']:,}")
+
+
+if __name__ == "__main__":
+    main()

+ 145 - 0
backend/indicator_features/bin_plot.py

@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Plot the per-angular-bin statistics before/after a chosen event (read-only).
+
+Uses the same running-segment selection as ``event_bins.py`` (rpm>0 segments, at
+least ``--pre-days`` / ``--post-days`` running days).  Two figures per event:
+
+* profile: for every measurement point, the pre-event median and post-event
+  median of a chosen metric across the bins (whole profile shift vs shape);
+* series : for every point, the time trace of the bin whose local (shape) change
+  is the largest for a chosen metric, pre and post coloured differently.
+"""
+from __future__ import annotations
+
+import argparse
+import sys
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt  # noqa: E402
+
+matplotlib.rcParams["font.sans-serif"] = ["Arial Unicode MS", "Heiti TC", "Hiragino Sans GB", "DejaVu Sans"]
+matplotlib.rcParams["axes.unicode_minus"] = False
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+from event_bins import load as load_rows, collect_before, collect_after, robust_scale  # noqa: E402
+from run_segments import unit_segments  # noqa: E402
+
+CYLINDERS = ("一", "二", "三", "四", "五", "六")
+SIDES = ("盖侧", "轴侧")
+VOLATILE = ("std", "mad", "range", "roughness")
+
+
+def median_at(rows, metric, bin_index):
+    values = [r[metric] for r in rows if r["bin"] == bin_index and r[metric] is not None]
+    return float(np.median(values)) if values else None
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="每角度箱 前后剖面 / 时间序列图(运行段口径)")
+    parser.add_argument("--csv", required=True, help="angle_bins.py 长表 CSV")
+    parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
+    parser.add_argument("--date", required=True, help="事件日期 YYYY-MM-DD")
+    parser.add_argument("--pre-days", type=int, default=15)
+    parser.add_argument("--post-days", type=int, default=15)
+    parser.add_argument("--merge-gap", type=int, default=2)
+    parser.add_argument("--max-gap", type=int, default=30)
+    parser.add_argument("--profile-metric", default="mean")
+    parser.add_argument("--series-metric", default="std")
+    parser.add_argument("--outdir", default="cache/indicator/bins/plots")
+    args = parser.parse_args()
+
+    rows = load_rows(args.csv)
+    t0 = datetime.fromisoformat(args.date)
+    segments = unit_segments(args.unit, args.merge_gap)
+    bins = sorted({r["bin"] for r in rows})
+    bin_start = {r["bin"]: r["bin_start"] for r in rows}
+    bin_deg = round(bin_start[bins[1]] - bin_start[bins[0]], 6) if len(bins) > 1 else 10.0
+    points = [(c, s) for c in CYLINDERS for s in SIDES if any(r["cylinder"] == c and r["side"] == s for r in rows)]
+    outdir = Path(args.outdir)
+    outdir.mkdir(parents=True, exist_ok=True)
+
+    # pre/post rows per point via running segments
+    selected = {}
+    for cylinder, side in points:
+        point_rows = [r for r in rows if r["cylinder"] == cylinder and r["side"] == side]
+        pre, _ = collect_before(point_rows, t0, segments, args.pre_days)
+        post, _ = collect_after(point_rows, t0, segments, args.post_days, args.max_gap)
+        selected[(cylinder, side)] = (pre, post)
+
+    cols = 3
+    rows_n = (len(points) + cols - 1) // cols
+
+    # ---- profile figure ----
+    fig, axes = plt.subplots(rows_n, cols, figsize=(4.2 * cols, 2.6 * rows_n), squeeze=False)
+    for ax, key in zip(axes.ravel(), points):
+        pre, post = selected[key]
+        xs = [bin_start[b] + bin_deg / 2 for b in bins]
+        prof_pre = [median_at(pre, args.profile_metric, b) for b in bins]
+        prof_post = [median_at(post, args.profile_metric, b) for b in bins]
+        ax.plot(xs, prof_pre, color="crimson", lw=1.4, label="pre")
+        ax.plot(xs, prof_post, color="royalblue", lw=1.4, label="post")
+        ax.set_title(f"{key[0]}缸{key[1]}", fontsize=10)
+        ax.set_xlim(0, 360)
+        ax.set_xticks([0, 90, 180, 270, 360])
+        ax.tick_params(labelsize=8)
+        ax.grid(alpha=0.25)
+    for ax in axes.ravel()[len(points):]:
+        ax.axis("off")
+    axes.ravel()[0].legend(fontsize=7)
+    fig.suptitle(f"{args.unit}号机 {args.date} 每角度箱 {args.profile_metric}(pre红 post蓝)")
+    fig.tight_layout()
+    p1 = outdir / f"{args.unit}_{args.date}_profile_{args.profile_metric}.png"
+    fig.savefig(p1, dpi=110)
+    plt.close(fig)
+    print("图:", p1)
+
+    # ---- series figure: strongest local shape change per point ----
+    fig, axes = plt.subplots(rows_n, cols, figsize=(4.2 * cols, 2.6 * rows_n), squeeze=False)
+    for ax, key in zip(axes.ravel(), points):
+        pre, post = selected[key]
+        best_bin, best_score = None, 0.0
+        for b in bins:
+            a = [r[args.series_metric] for r in pre if r["bin"] == b and r[args.series_metric] is not None]
+            c = [r[args.series_metric] for r in post if r["bin"] == b and r[args.series_metric] is not None]
+            if len(a) < 3 or len(c) < 3:
+                continue
+            scale = robust_scale(np.asarray(a))
+            if scale <= 1e-12:
+                continue
+            score = abs(float(np.median(c)) - float(np.median(a))) / scale
+            if score > best_score:
+                best_bin, best_score = b, score
+        if best_bin is None:
+            ax.set_title(f"{key[0]}缸{key[1]} 无数据", fontsize=9)
+            ax.axis("off")
+            continue
+        trace = sorted([r for r in rows if r["cylinder"] == key[0] and r["side"] == key[1]
+                        and r["bin"] == best_bin and r[args.series_metric] is not None], key=lambda r: r["time"])
+        for r in trace:
+            color = "royalblue" if r["time"] >= t0 else "crimson"
+            ax.plot(r["time"], r[args.series_metric], ".", color=color, markersize=3)
+        ax.axvline(t0, color="k", lw=0.8, ls="--")
+        start = bin_start[best_bin]
+        ax.set_title(f"{key[0]}缸{key[1]} 箱{start:g}-{start + bin_deg:g}° ({best_score:.1f}MAD)", fontsize=9)
+        ax.tick_params(labelsize=7, rotation=30)
+        ax.grid(alpha=0.25)
+    for ax in axes.ravel()[len(points):]:
+        ax.axis("off")
+    fig.suptitle(f"{args.unit}号机 {args.date} 各点局部变化最大的箱 {args.series_metric} 时间序列")
+    fig.tight_layout()
+    p2 = outdir / f"{args.unit}_{args.date}_series_{args.series_metric}.png"
+    fig.savefig(p2, dpi=110)
+    plt.close(fig)
+    print("图:", p2)
+
+
+if __name__ == "__main__":
+    main()

+ 72 - 0
backend/indicator_features/bins_common.py

@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""Shared metric registry for the per-angle-bin indicator analysis.
+
+Two families:
+
+* ``BIN_METRICS``   : computed on the raw samples **inside each angular bin**;
+* ``CYCLE_METRICS`` : computed **once per cycle** (plateau levels, valve-event
+  angles, corner slopes) and repeated on every bin row of that cycle.
+
+``VOLATILE`` metrics are amplitude/irregularity-like (a change means the local
+waveform got rougher/noisier); the rest are level/shape-like.
+"""
+from __future__ import annotations
+
+BIN_METRICS = (
+    # 旧的 6 个
+    "mean", "range", "std", "mad", "slope", "roughness",
+    # 分布
+    "p10", "p50", "p90", "iqr", "skew", "kurt",
+    # 导数 / 振荡
+    "d_abs_mean", "d_abs_max", "d_rms", "d2_rms", "zero_cross", "n_extrema",
+    # 形状 / 复杂度
+    "d2_abs_max", "tv", "mean_cross", "mobility", "complexity",
+)
+
+CYCLE_METRICS = (
+    "p_suction", "p_discharge", "pressure_ratio", "suction_frac", "discharge_frac",
+    "angle_min", "angle_max", "cross_up", "cross_down",
+    "max_rise", "max_fall", "max_rise_angle", "max_fall_angle",
+)
+
+META_COLUMNS = (
+    "unit", "cylinder", "stage", "side", "point_name", "wave_file_id",
+    "sample_time", "rpm", "n_samples", "p_min", "p_max", "p_span",
+    "bin", "bin_start", "n_raw",
+)
+
+VOLATILE = (
+    "std", "mad", "range", "roughness",
+    "d_abs_mean", "d_abs_max", "d_rms", "d2_rms", "zero_cross", "n_extrema",
+    "d2_abs_max", "tv", "mean_cross", "mobility", "complexity",
+)
+
+# 整周期指标里偏“波动/不稳定”的(其余按水平处理)
+CYCLE_VOLATILE = ("suction_frac", "discharge_frac", "max_rise", "max_fall")
+
+# 角度型指标:差值与中位数必须按 0-360 环形处理(否则 wrap 会产生假跳变)
+ANGLE_METRICS = ("angle_min", "angle_max", "cross_up", "cross_down", "max_rise_angle", "max_fall_angle")
+
+# 随压力幅度(p_span)成比例的量;--normalize span 时除以本周期 p_span 以消工况
+BIN_SCALED = (
+    "mean", "range", "std", "mad", "slope", "roughness",
+    "p10", "p50", "p90", "iqr",
+    "d_abs_mean", "d_abs_max", "d_rms", "d2_rms", "d2_abs_max", "tv",
+)
+CYCLE_SCALED = ("p_suction", "p_discharge", "max_rise", "max_fall")
+
+
+def apply_normalization(rows, mode: str) -> None:
+    """In place: divide pressure-scale metrics by each cycle's own ``p_span``."""
+    if mode != "span":
+        return
+    scaled = set(BIN_SCALED) | set(CYCLE_SCALED)
+    for row in rows:
+        span = row.get("p_span") or 0.0
+        for name in scaled:
+            if row.get(name) is None:
+                continue
+            row[name] = row[name] / span if span > 1e-9 else None
+
+ALL_METRICS = BIN_METRICS + CYCLE_METRICS
+FIELDNAMES = META_COLUMNS + BIN_METRICS + CYCLE_METRICS

+ 82 - 0
backend/indicator_features/cn_names.py

@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""英文键名 -> 中文名映射,供把最终结果表/报告中文化使用。"""
+from __future__ import annotations
+
+META_CN = {
+    "unit": "机组", "cylinder": "缸", "stage": "级", "side": "腔",
+    "point_name": "测点", "point": "测点", "label": "测点全名",
+    "wave_file_id": "文件ID", "sample_time": "时间", "date": "事件日期", "kind": "故障类型",
+    "rpm": "转速", "n_samples": "点数", "p_min": "最小压力", "p_max": "最大压力",
+    "p_span": "压力幅度", "bin": "角箱号", "bin_start": "角箱起点", "n_raw": "箱内点数",
+    "family": "类别", "type": "类型", "candidate": "是否候选", "scope": "范围",
+    "pre": "停机前", "post": "开机后", "delta": "变化量", "mad": "MAD倍数",
+    "rel": "相对变化", "score": "总分", "n_metrics": "指标数", "max_mad": "最大MAD",
+    "gap_days": "相隔天数", "pre_span_days": "前跨度天数",
+    "pre_start": "前段起", "pre_end": "前段止", "post_start": "后段起", "post_end": "后段止",
+    "metrics": "指标明细",
+}
+
+BIN_CN = {
+    "mean": "均值", "range": "极差", "std": "标准差", "mad": "中位绝对偏差",
+    "slope": "斜率", "roughness": "粗糙度", "p10": "10分位", "p50": "50分位", "p90": "90分位",
+    "iqr": "四分位距", "skew": "偏度", "kurt": "峰度",
+    "d_abs_mean": "平均|导数|", "d_abs_max": "最大|导数|", "d_rms": "导数RMS",
+    "d2_rms": "二阶导RMS", "zero_cross": "导数过零数", "n_extrema": "极值数",
+    "d2_abs_max": "二阶导最大", "tv": "总变差", "mean_cross": "过均次数",
+    "mobility": "移动度", "complexity": "复杂度",
+}
+
+CYCLE_CN = {
+    "p_suction": "吸气压力", "p_discharge": "排气压力", "pressure_ratio": "压比",
+    "suction_frac": "吸气占比", "discharge_frac": "排气占比",
+    "angle_min": "最小压力角", "angle_max": "最大压力角",
+    "cross_up": "上升穿越角", "cross_down": "下降穿越角",
+    "max_rise": "最大上升斜率", "max_fall": "最大下降斜率",
+    "max_rise_angle": "最大上升角", "max_fall_angle": "最大下降角",
+}
+
+FAMILY_CN = {
+    "bin": "分箱", "cycle": "整周期", "bin_trend": "分箱趋势", "cycle_trend": "整周期趋势",
+    "event_pressure": "事件-整周期", "event_pressure_bin": "事件-分箱",
+    "event_vib": "事件-振动", "corr": "日度相关",
+}
+
+METRIC_CN = {**META_CN, **BIN_CN, **CYCLE_CN}
+
+# 表头里与指标同名的列需覆盖(mad 列是"MAD倍数",不是"中位绝对偏差"指标)
+HEADER_CN = {
+    "metric": "指标", "mad": "MAD倍数", "raw_score": "原始分数", "split": "突变时间",
+    "before": "突变前", "after": "突变后", "volatile": "波动类", "day": "日期",
+}
+
+
+def col(name: str) -> str:
+    return HEADER_CN.get(name, METRIC_CN.get(name, name))
+
+
+def metric_col(name: str) -> str:
+    return METRIC_CN.get(name, name)
+
+
+def translate_headers(fieldnames: list[str]) -> list[str]:
+    return [col(name) for name in fieldnames]
+
+
+import re as _re
+
+_METRIC_TOKEN = _re.compile(r"^(.*)(up|dn)(-?\d[0-9.eE+-]*)$")
+
+
+def translate_metrics_field(text: str) -> str:
+    """把汇总里的 'd_rmsup23.1;stddn4.0;cross_updn4.3' 翻成 '导数RMS↑23.1;标准差↓4.0;上升穿越角↓4.3'。"""
+    if not text:
+        return text
+    parts = []
+    for token in str(text).split(";"):
+        match = _METRIC_TOKEN.match(token)
+        if match:
+            name, direction, value = match.groups()
+            parts.append(f"{metric_col(name)}{'↑' if direction == 'up' else '↓'}{value}")
+        else:
+            parts.append(token)
+    return ";".join(parts)

+ 503 - 0
backend/indicator_features/event_bins.py

@@ -0,0 +1,503 @@
+#!/usr/bin/env python3
+"""Same-point before/after comparison of the per-angular-bin and whole-cycle features.
+
+Reads the long CSV produced by ``angle_bins.py`` and, for each valve/piston repair
+event, compares the **same measurement point** between the running period before
+the event and the running period after it.  Only same-point comparisons are made
+(never across cylinders).
+
+Two feature families (see ``bins_common.py``):
+
+* per-bin features  : level/shape split into whole-point shift (all bins move =
+  load / sensor) and local deviation (only some bins move = valve / local fault);
+* cycle features    : suction/discharge plateaus and valve-event angles/slopes.
+
+Running periods come from ``run_segments.py`` (``wave_file.rpm > 0``); the pre
+window is carried back over earlier running segments until it has enough days
+(short post windows are used as-is).  Output is a ranked list per event plus a
+detail CSV.  Read-only.
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+
+from bins_common import (
+    ANGLE_METRICS, BIN_METRICS, CYCLE_METRICS, CYCLE_VOLATILE, VOLATILE, apply_normalization,
+)
+from run_segments import build_segments, unit_segments
+
+
+def circ_median(values):
+    radians = np.radians(np.asarray(values, dtype=float))
+    return float(np.degrees(np.arctan2(np.mean(np.sin(radians)), np.mean(np.cos(radians)))) % 360.0)
+
+
+def circ_diff(a, b):
+    return ((b - a + 180.0) % 360.0) - 180.0
+
+Z = 3.0
+FLOOR_FRAC = 0.2  # per-bin scale floor = 20% of that metric's median bin scale
+MIN_SPAN = 0.3  # a dead/stuck channel has a near-zero per-cycle pressure span
+ANGLE_SCALE_FLOOR = 1.0  # degrees; a constant valve-angle must not inflate z
+
+# 故障汇总.xlsx 中的气阀/活塞事件(unit, date, kind)
+EVENTS = [
+    ("7", "2025-01-10", "活塞"),
+    ("7", "2025-08-08", "气阀"),
+    ("7", "2025-10-07", "气阀"),
+    ("7", "2026-05-21", "气阀"),
+    ("8", "2025-02-17", "气阀"),
+    ("8", "2025-12-06", "活塞"),
+    ("8", "2026-03-29", "气阀"),
+    ("8", "2026-04-01", "气阀"),
+    ("9", "2026-01-18", "活塞"),
+]
+
+
+def to_float(value):
+    try:
+        result = float(value)
+    except (TypeError, ValueError):
+        return None
+    return result if np.isfinite(result) else None
+
+
+def load(path: str) -> list[dict]:
+    rows = []
+    for row in csv.DictReader(open(path, encoding="utf-8-sig")):
+        try:
+            row["time"] = datetime.fromisoformat(row["sample_time"])
+        except ValueError:
+            continue
+        row["bin"] = int(row["bin"])
+        row["bin_start"] = float(row["bin_start"])
+        row["p_span"] = to_float(row.get("p_span"))
+        for name in BIN_METRICS + CYCLE_METRICS:
+            row[name] = to_float(row.get(name))
+        rows.append(row)
+    return rows
+
+
+def robust_scale(values: np.ndarray) -> float:
+    if values.size == 0:
+        return 0.0
+    center = float(np.median(values))
+    scale = float(np.median(np.abs(values - center))) * 1.4826
+    if scale <= 1e-12:
+        scale = float(np.std(values))
+    return scale
+
+
+def cycle_count(rows: list[dict]) -> int:
+    return len({r["wave_file_id"] for r in rows})
+
+
+def median_span(rows: list[dict]) -> float:
+    spans = [r["p_span"] for r in rows if r.get("p_span") is not None]
+    return float(np.median(spans)) if spans else 0.0
+
+
+def fallback_segments(rows: list[dict], merge_gap: int) -> list[tuple]:
+    days = sorted({r["time"].date() for r in rows})
+    return build_segments(days, merge_gap)
+
+
+def collect_before(rows, t0, segments, min_days):
+    segs = sorted([s for s in segments if s[0] <= t0.date()], key=lambda s: s[0], reverse=True)
+    chosen, days = [], set()
+    for start, end in segs:
+        part = [r for r in rows if start <= r["time"].date() <= end and r["time"] < t0]
+        chosen.extend(part)
+        days |= {r["time"].date() for r in part}
+        if len(days) >= min_days:
+            break
+    return sorted(chosen, key=lambda r: r["time"]), days
+
+
+def collect_after(rows, t0, segments, min_days, max_gap):
+    """First running segment after the event; extend only over small gaps."""
+    segs = sorted([s for s in segments if s[1] >= t0.date()], key=lambda s: s[0])
+    chosen, days = [], set()
+    prev_end = None
+    for start, end in segs:
+        if prev_end is not None and (start - prev_end).days > max_gap:
+            break
+        part = [r for r in rows if start <= r["time"].date() <= end and r["time"] >= t0]
+        chosen.extend(part)
+        days |= {r["time"].date() for r in part}
+        prev_end = end
+        if len(days) >= min_days:
+            break
+    return sorted(chosen, key=lambda r: r["time"]), days
+
+
+def window_rows(rows, t0, segments, pre_days, post_days, max_gap):
+    pre, pre_set = collect_before(rows, t0, segments, pre_days)
+    post, post_set = collect_after(rows, t0, segments, post_days, max_gap)
+    return pre, post, pre_set, post_set
+
+
+def compare_bins(pre, post, min_cycles):
+    """Per-bin level/shape comparison (BIN_METRICS)."""
+    pre_bins = defaultdict(lambda: defaultdict(list))
+    post_bins = defaultdict(lambda: defaultdict(list))
+    bin_start_of = {}
+    for row in pre:
+        bin_start_of[row["bin"]] = row["bin_start"]
+        for name in BIN_METRICS:
+            if row[name] is not None:
+                pre_bins[row["bin"]][name].append(row[name])
+    for row in post:
+        for name in BIN_METRICS:
+            if row[name] is not None:
+                post_bins[row["bin"]][name].append(row[name])
+
+    all_bins = sorted(set(pre_bins) & set(post_bins))
+    changes = []
+    for name in BIN_METRICS:
+        items = []
+        for b in all_bins:
+            a = np.asarray(pre_bins[b][name])
+            c = np.asarray(post_bins[b][name])
+            if a.size < min_cycles or c.size < min_cycles:
+                continue
+            pre_med = float(np.median(a))
+            post_med = float(np.median(c))
+            items.append({
+                "bin": b, "bin_start": bin_start_of.get(b, b * 10.0), "metric": name,
+                "pre_med": pre_med, "post_med": post_med, "delta": post_med - pre_med,
+                "scale": robust_scale(a), "pre_n": int(a.size), "post_n": int(c.size),
+            })
+        if len(items) < 4:
+            continue
+        scales = [it["scale"] for it in items if it["scale"] > 1e-12]
+        point_scale = float(np.median(scales)) if scales else 0.0
+        floor = FLOOR_FRAC * point_scale
+        pre_center = float(np.median([it["pre_med"] for it in items]))
+        global_delta = float(np.median([it["delta"] for it in items]))
+        global_z = global_delta / point_scale if point_scale > 1e-12 else None
+        for it in items:
+            denom = max(it["scale"], floor)
+            it["local_delta"] = it["delta"] - global_delta
+            it["local_z"] = it["local_delta"] / denom if denom > 1e-12 else None
+            it["rel"] = it["local_delta"] / abs(pre_center) if abs(pre_center) > 1e-9 else None
+            it["global_delta"] = global_delta
+            it["global_z"] = global_z
+            it["pre_center"] = pre_center
+        changes.extend(items)
+    return changes
+
+
+def compare_cycle(pre, post, min_cycles):
+    """Whole-cycle (valve timing / plateau) comparison, one record per metric."""
+    records = []
+    for name in CYCLE_METRICS:
+        a = np.asarray([r[name] for r in pre if r[name] is not None])
+        b = np.asarray([r[name] for r in post if r[name] is not None])
+        if a.size < min_cycles or b.size < min_cycles:
+            continue
+        if name in ANGLE_METRICS:  # circular
+            pre_med = circ_median(a)
+            post_med = circ_median(b)
+            delta = circ_diff(pre_med, post_med)
+            residual = np.asarray([circ_diff(pre_med, v) for v in a])
+            scale = max(robust_scale(residual), ANGLE_SCALE_FLOOR)
+            rel = None
+        else:
+            pre_med = float(np.median(a))
+            post_med = float(np.median(b))
+            delta = post_med - pre_med
+            scale = robust_scale(a)
+            rel = delta / abs(pre_med) if abs(pre_med) > 1e-9 else None
+        records.append({
+            "metric": name, "pre_med": pre_med, "post_med": post_med, "delta": delta,
+            "scale": scale, "z": delta / scale if scale > 1e-12 else None,
+            "rel": rel, "pre_n": int(a.size), "post_n": int(b.size),
+        })
+    return records
+
+
+def trend_bins(pre, min_cycles):
+    series = defaultdict(lambda: defaultdict(list))
+    bin_start_of = {}
+    for row in pre:
+        bin_start_of[row["bin"]] = row["bin_start"]
+        for name in BIN_METRICS:
+            if row[name] is not None:
+                series[row["bin"]][name].append((row["time"], row[name]))
+    results = []
+    for name in BIN_METRICS:
+        items = []
+        for b, per_name in series.items():
+            points = sorted(per_name.get(name, []))
+            if len(points) < max(min_cycles, 8):
+                continue
+            x = np.asarray([(p[0] - points[0][0]).total_seconds() / 86400.0 for p in points])
+            y = np.asarray([p[1] for p in points])
+            if np.ptp(x) <= 0:
+                continue
+            slope = float(np.polyfit(x, y, 1)[0])
+            items.append({
+                "bin": b, "bin_start": bin_start_of.get(b, b * 10.0), "metric": name,
+                "total_drift": slope * float(np.ptp(x)), "scale": robust_scale(y), "n": len(points),
+            })
+        if len(items) < 4:
+            continue
+        scales = [it["scale"] for it in items if it["scale"] > 1e-12]
+        floor = FLOOR_FRAC * (float(np.median(scales)) if scales else 0.0)
+        global_drift = float(np.median([it["total_drift"] for it in items]))
+        for it in items:
+            denom = max(it["scale"], floor)
+            it["local_drift"] = it["total_drift"] - global_drift
+            it["local_mad"] = it["local_drift"] / denom if denom > 1e-12 else None
+        results.extend(items)
+    return results
+
+
+def trend_cycle(pre, min_cycles):
+    results = []
+    for name in CYCLE_METRICS:
+        if name in ANGLE_METRICS:  # circular; skip linear trend
+            continue
+        points = sorted((r["time"], r[name]) for r in pre if r[name] is not None)
+        if len(points) < max(min_cycles, 8):
+            continue
+        x = np.asarray([(p[0] - points[0][0]).total_seconds() / 86400.0 for p in points])
+        y = np.asarray([p[1] for p in points])
+        if np.ptp(x) <= 0:
+            continue
+        slope = float(np.polyfit(x, y, 1)[0])
+        scale = robust_scale(y)
+        drift = slope * float(np.ptp(x))
+        results.append({"metric": name, "total_drift": drift, "scale": scale,
+                        "mad": drift / scale if scale > 1e-12 else None, "n": len(points)})
+    return results
+
+
+def point_name(unit, cylinder, side):
+    return f"{unit}号机组{cylinder}缸压力{side}"
+
+
+def bin_label(bin_start, bin_deg):
+    return f"{bin_start:g}-{bin_start + bin_deg:g}°"
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="同点 分箱+整周期 运行段前后对比 + 趋势")
+    parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
+    parser.add_argument("--csv", nargs="+", required=True, help="angle_bins.py 长表 CSV(可多个)")
+    parser.add_argument("--pre-days", type=int, default=15, help="前段至少累计的运行日(不足也可)")
+    parser.add_argument("--post-days", type=int, default=15, help="后段目标运行日(不足也可)")
+    parser.add_argument("--trend-days", type=int, default=30, help="故障前趋势至少累计的运行日")
+    parser.add_argument("--max-gap", type=int, default=30, help="后段累加允许跨越的最大停机天数")
+    parser.add_argument("--merge-gap", type=int, default=2, help="运行段桥接允许的停机天数")
+    parser.add_argument("--min-cycles", type=int, default=3, help="每段最少周期数")
+    parser.add_argument("--z", type=float, default=3.0, help="变化阈值(MAD 倍数)")
+    parser.add_argument("--normalize", choices=("none", "span"), default="none",
+                        help="span: 用本周期 p_span 归一再比(压工况)")
+    parser.add_argument("--top", type=int, default=15, help="每张表打印条数")
+    parser.add_argument("--out", default="", help="明细 CSV 输出路径")
+    args = parser.parse_args()
+    global Z
+    Z = args.z
+
+    rows = []
+    for path in args.csv:
+        part = load(path)
+        print(f"  {path}: {len(part):,} 行")
+        rows.extend(part)
+    apply_normalization(rows, args.normalize)
+    if args.normalize != "none":
+        print(f"归一化: {args.normalize}")
+    starts = sorted({r["bin_start"] for r in rows})
+    bin_deg = round(starts[1] - starts[0], 6) if len(starts) >= 2 else 10.0
+    print(f"载入 {len(rows):,} 行,测点 {len({r['point_name'] for r in rows})},角度箱宽 {bin_deg:g}°")
+
+    try:
+        segments = unit_segments(args.unit, args.merge_gap)
+        print(f"运行段(rpm>0): {len(segments)} 个")
+    except Exception as error:
+        print(f"运行段取 DB 失败({error}),改用数据日期间隔")
+        segments = fallback_segments(rows, args.merge_gap)
+
+    detail_rows: list[dict] = []
+    for unit, date, kind in EVENTS:
+        if unit != args.unit:
+            continue
+        t0 = datetime.fromisoformat(date)
+        points = sorted({(r["cylinder"], r["side"]) for r in rows})
+        bin_changes, cycle_changes, bin_trends, cycle_trends = [], [], [], []
+        coverage = None
+        for cylinder, side in points:
+            point_rows = [r for r in rows if r["cylinder"] == cylinder and r["side"] == side]
+            pre, post, pre_set, post_set = window_rows(point_rows, t0, segments, args.pre_days, args.post_days, args.max_gap)
+            if cycle_count(pre) < args.min_cycles or cycle_count(post) < args.min_cycles:
+                continue
+            if median_span(pre) < MIN_SPAN or median_span(post) < MIN_SPAN:
+                continue
+            if coverage is None:
+                coverage = (min(r["time"] for r in pre), max(r["time"] for r in pre), pre_set,
+                            min(r["time"] for r in post), max(r["time"] for r in post), post_set)
+            point = f"{cylinder}缸{side}"
+            label = point_name(unit, cylinder, side)
+            pre_dates = sorted({r["time"] for r in pre})
+            post_dates = sorted({r["time"] for r in post})
+            wmeta = {
+                "pre_start": str(pre_dates[0].date()), "pre_end": str(pre_dates[-1].date()),
+                "post_start": str(post_dates[0].date()), "post_end": str(post_dates[-1].date()),
+                "gap_days": (post_dates[0].date() - pre_dates[-1].date()).days,
+                "pre_span_days": (pre_dates[-1].date() - pre_dates[0].date()).days,
+            }
+
+            changes = compare_bins(pre, post, args.min_cycles)
+            strong_bins = defaultdict(int)
+            for c in changes:
+                if c["local_z"] is not None and abs(c["local_z"]) >= Z:
+                    strong_bins[c["bin"]] += 1
+            for c in changes:
+                if c["local_z"] is None or abs(c["local_z"]) < Z:
+                    continue
+                bin_changes.append({
+                    "point": point, "label": label, "bin": c["bin"], "bin_start": c["bin_start"],
+                    "type": "局部波动" if c["metric"] in VOLATILE else "局部均值",
+                    "metric": c["metric"], "pre": c["pre_med"], "post": c["post_med"],
+                    "delta": c["local_delta"], "mad": c["local_z"], "rel": c["rel"],
+                    "candidate": strong_bins[c["bin"]] >= 2,
+                })
+            for name in BIN_METRICS:
+                items = [c for c in changes if c["metric"] == name]
+                if not items or items[0]["global_z"] is None or abs(items[0]["global_z"]) < Z:
+                    continue
+                pre_c = items[0]["pre_center"]
+                bin_changes.append({
+                    "point": point, "label": label, "bin": -1, "bin_start": -1.0,
+                    "type": "整体波动" if name in VOLATILE else "整体水平", "metric": name,
+                    "pre": pre_c, "post": pre_c + items[0]["global_delta"], "delta": items[0]["global_delta"],
+                    "mad": items[0]["global_z"], "rel": None, "candidate": False,
+                })
+
+            for c in compare_cycle(pre, post, args.min_cycles):
+                if c["z"] is None or abs(c["z"]) < Z:
+                    continue
+                cycle_changes.append({
+                    "point": point, "label": label,
+                    "type": "整周期波动" if c["metric"] in CYCLE_VOLATILE else "整周期水平",
+                    "metric": c["metric"], "pre": c["pre_med"], "post": c["post_med"],
+                    "delta": c["delta"], "mad": c["z"], "rel": c["rel"],
+                })
+
+            for t in trend_bins(pre, args.min_cycles):
+                if t.get("local_mad") is None or abs(t["local_mad"]) < Z:
+                    continue
+                bin_trends.append({
+                    "point": point, "label": label, "bin": t["bin"], "bin_start": t["bin_start"],
+                    "type": "局部趋势" + ("波动" if t["metric"] in VOLATILE else "均值"),
+                    "metric": t["metric"], "drift": t["local_drift"], "mad": t["local_mad"],
+                    "n": t["n"],
+                })
+            for t in trend_cycle(pre, args.min_cycles):
+                if t["mad"] is None or abs(t["mad"]) < Z:
+                    continue
+                cycle_trends.append({
+                    "point": point, "label": label,
+                    "type": "整周期趋势" + ("波动" if t["metric"] in CYCLE_VOLATILE else "水平"),
+                    "metric": t["metric"], "drift": t["total_drift"], "mad": t["mad"], "n": t["n"],
+                })
+
+        print(f"\n{'=' * 100}")
+        print(f"== {unit}号机 {date} {kind}")
+        if coverage:
+            print(f"   前段 {coverage[0]:%Y-%m-%d}~{coverage[1]:%Y-%m-%d}({len(coverage[2])}运行日)"
+                  f"    后段 {coverage[3]:%Y-%m-%d}~{coverage[4]:%Y-%m-%d}({len(coverage[5])}运行日)")
+        if not (bin_changes or cycle_changes or bin_trends or cycle_trends):
+            print("   事件前后无足够运行数据")
+            continue
+
+        bin_changes.sort(key=lambda r: (r["type"].startswith("整体"), -abs(r["mad"])))
+        if bin_changes:
+            print(f"   【分箱前后变化】{len(bin_changes)} 条(★=同角度箱≥2指标一致)")
+            print(f"   {'#':>3}  {'测点':<9} {'角度箱':<10} {'类型':<8} {'指标':<11} {'前':>9} {'后':>9} {'变化':>9} {'相对':>8}")
+            for i, r in enumerate(bin_changes[:args.top], 1):
+                binst = "全箱" if r["bin"] < 0 else bin_label(r["bin_start"], bin_deg)
+                rel = f"{r['rel'] * 100:+.0f}%" if r["rel"] is not None else "-"
+                print(f"   {i:>3}  {r['point']:<9} {binst:<10} {r['type']:<8} {r['metric']:<11} "
+                      f"{r['pre']:>9.4g} {r['post']:>9.4g} {r['mad']:>+8.1f}M {rel:>8} {'★' if r['candidate'] else ''}")
+
+        if cycle_changes:
+            cycle_changes.sort(key=lambda r: -abs(r["mad"]))
+            print(f"   【整周期/阀时序 前后变化】{len(cycle_changes)} 条")
+            print(f"   {'#':>3}  {'测点':<9} {'类型':<10} {'指标':<15} {'前':>10} {'后':>10} {'变化':>9} {'相对':>8}")
+            for i, r in enumerate(cycle_changes[:args.top], 1):
+                rel = f"{r['rel'] * 100:+.0f}%" if r["rel"] is not None else "-"
+                print(f"   {i:>3}  {r['point']:<9} {r['type']:<10} {r['metric']:<15} "
+                      f"{r['pre']:>10.4g} {r['post']:>10.4g} {r['mad']:>+8.1f}M {rel:>8}")
+
+        if bin_trends:
+            bin_trends.sort(key=lambda r: -abs(r["mad"]))
+            print(f"   【故障前分箱趋势】{len(bin_trends)} 条")
+            print(f"   {'#':>3}  {'测点':<9} {'角度箱':<10} {'类型':<12} {'指标':<11} {'总变化':>11} {'变化':>9}")
+            for i, r in enumerate(bin_trends[:args.top], 1):
+                print(f"   {i:>3}  {r['point']:<9} {bin_label(r['bin_start'], bin_deg):<10} {r['type']:<12} "
+                      f"{r['metric']:<11} {r['drift']:>+11.4g} {r['mad']:>+8.1f}M")
+
+        if cycle_trends:
+            cycle_trends.sort(key=lambda r: -abs(r["mad"]))
+            print(f"   【故障前整周期趋势】{len(cycle_trends)} 条")
+            print(f"   {'#':>3}  {'测点':<9} {'类型':<14} {'指标':<15} {'总变化':>12} {'变化':>9}")
+            for i, r in enumerate(cycle_trends[:args.top], 1):
+                print(f"   {i:>3}  {r['point']:<9} {r['type']:<14} {r['metric']:<15} "
+                      f"{r['drift']:>+12.4g} {r['mad']:>+8.1f}M")
+
+        for r in bin_changes:
+            detail_rows.append({
+                **wmeta,
+                "unit": unit, "date": date, "kind": kind, "family": "bin", "point": r["point"],
+                "label": r["label"], "bin": r["bin"], "bin_start": r["bin_start"], "type": r["type"],
+                "metric": r["metric"], "pre": r["pre"], "post": r["post"], "delta": r["delta"],
+                "mad": r["mad"], "rel": r["rel"] if r["rel"] is not None else "",
+                "candidate": int(r["candidate"]),
+            })
+        for r in cycle_changes:
+            detail_rows.append({
+                **wmeta,
+                "unit": unit, "date": date, "kind": kind, "family": "cycle", "point": r["point"],
+                "label": r["label"], "bin": "", "bin_start": "", "type": r["type"],
+                "metric": r["metric"], "pre": r["pre"], "post": r["post"], "delta": r["delta"],
+                "mad": r["mad"], "rel": r["rel"] if r["rel"] is not None else "", "candidate": "",
+            })
+        for r in bin_trends:
+            detail_rows.append({
+                **wmeta,
+                "unit": unit, "date": date, "kind": kind, "family": "bin_trend", "point": r["point"],
+                "label": r["label"], "bin": r["bin"], "bin_start": r["bin_start"], "type": r["type"],
+                "metric": r["metric"], "pre": "", "post": "", "delta": r["drift"], "mad": r["mad"],
+                "rel": "", "candidate": "",
+            })
+        for r in cycle_trends:
+            detail_rows.append({
+                **wmeta,
+                "unit": unit, "date": date, "kind": kind, "family": "cycle_trend", "point": r["point"],
+                "label": r["label"], "bin": "", "bin_start": "", "type": r["type"],
+                "metric": r["metric"], "pre": "", "post": "", "delta": r["drift"], "mad": r["mad"],
+                "rel": "", "candidate": "",
+            })
+
+    if args.out and detail_rows:
+        out = Path(args.out)
+        out.parent.mkdir(parents=True, exist_ok=True)
+        with out.open("w", encoding="utf-8-sig", newline="") as handle:
+            writer = csv.DictWriter(handle, fieldnames=list(detail_rows[0].keys()))
+            writer.writeheader()
+            writer.writerows(detail_rows)
+        print(f"\n明细 -> {out}({len(detail_rows)} 行)")
+    print(f"时间:{datetime.now():%Y-%m-%d %H:%M:%S}")
+
+
+if __name__ == "__main__":
+    main()

+ 96 - 0
backend/indicator_features/event_shape_audit.py

@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""Audit event coverage and normalized whole-cycle shape changes from sampled features."""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from collections import defaultdict
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import numpy as np
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+from event_bins import EVENTS  # noqa: E402
+
+
+def load(path: str) -> list[dict]:
+    with open(path, encoding="utf-8-sig", newline="") as handle:
+        rows = list(csv.DictReader(handle))
+    for row in rows:
+        row["time"] = datetime.fromisoformat(row["sample_time"])
+        row["span"] = float(row.get("p_span") or 0)
+        for key in row:
+            if key.startswith("prof_"):
+                row[key] = float(row[key]) if row[key] else np.nan
+    return rows
+
+
+def median_profile(rows: list[dict], names: list[str]) -> np.ndarray | None:
+    values = [[r[n] for n in names] for r in rows]
+    if not values:
+        return None
+    result = np.nanmedian(np.asarray(values, dtype=float), axis=0)
+    return result if np.all(np.isfinite(result)) else None
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="事件样本覆盖和整条功图形状审计")
+    parser.add_argument("--csv", nargs="+", required=True)
+    parser.add_argument("--unit", nargs="+", required=True)
+    parser.add_argument("--pre-days", type=int, default=30)
+    parser.add_argument("--post-days", type=int, default=30)
+    parser.add_argument("--out", required=True)
+    args = parser.parse_args()
+
+    by_unit = {unit: load(path) for unit, path in zip(args.unit, args.csv)}
+    output = []
+    for unit, date_text, kind in EVENTS:
+        rows = [r for r in by_unit.get(unit, []) if r["span"] >= 0.3]
+        t0 = datetime.fromisoformat(date_text)
+        pre_start = t0 - timedelta(days=args.pre_days)
+        post_end = t0 + timedelta(days=args.post_days)
+        pre = [r for r in rows if pre_start <= r["time"] < t0]
+        post = [r for r in rows if t0 <= r["time"] <= post_end]
+        points = sorted({(r["cylinder"], r["side"]) for r in rows})
+        profile_names = sorted(k for k in rows[0] if k.startswith("prof_")) if rows else []
+        for cylinder, side in points:
+            a = [r for r in pre if r["cylinder"] == cylinder and r["side"] == side]
+            b = [r for r in post if r["cylinder"] == cylinder and r["side"] == side]
+            pa = median_profile(a, profile_names)
+            pb = median_profile(b, profile_names)
+            distance = float(np.linalg.norm(pa - pb)) if pa is not None and pb is not None else None
+            output.append({
+                "机组": unit,
+                "事件": date_text,
+                "故障类型": kind,
+                "测点": f"{cylinder}缸{side}",
+                "修前有效周期": len(a),
+                "修前日期数": len({r["time"].date() for r in a}),
+                "修后有效周期": len(b),
+                "修后日期数": len({r["time"].date() for r in b}),
+                "形状距离": "" if distance is None else round(distance, 4),
+            })
+
+    fields = list(output[0]) if output else []
+    out = Path(args.out)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    with out.open("w", encoding="utf-8-sig", newline="") as handle:
+        writer = csv.DictWriter(handle, fieldnames=fields)
+        writer.writeheader()
+        writer.writerows(output)
+
+    print(f"事件审计 -> {out}")
+    for event in EVENTS:
+        unit, date_text, kind = event
+        selected = [r for r in output if r["机组"] == unit and r["事件"] == date_text]
+        comparable = [r for r in selected if r["修前有效周期"] and r["修后有效周期"]]
+        print(f"{unit}号 {date_text} {kind}: 可比较测点 {len(comparable)}/{len(selected)}")
+        for row in sorted(comparable, key=lambda r: float(r["形状距离"] or 0), reverse=True)[:3]:
+            print(f"  {row['测点']}: {row['修前有效周期']}->{row['修后有效周期']} 个周期,形状距离 {row['形状距离']}")
+
+
+if __name__ == "__main__":
+    main()

+ 179 - 0
backend/indicator_features/make_report.py

@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""把英文中间结果整理成中文交付物(CSV + 汇总 md)。"""
+from __future__ import annotations
+
+import argparse
+import csv
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import cn_names as CN
+from bins_common import BIN_METRICS, CYCLE_METRICS
+
+BIN_DESC = {
+    "均值": "箱内平均压力(水平)", "极差": "箱内 max-min(幅度)", "标准差": "箱内波动",
+    "中位绝对偏差": "稳健波动", "斜率": "箱内压力对角度的斜率",
+    "粗糙度": "去线性趋势后的残差 RMS(平滑/毛糙)", "10分位": "箱内 10% 分位",
+    "50分位": "箱内中位数", "90分位": "箱内 90% 分位", "四分位距": "90 分位-10 分位",
+    "偏度": "分布不对称度", "峰度": "尖峰程度(4 阶矩)",
+    "平均|导数|": "相邻点压力差的平均绝对值(局部抖动)", "最大|导数|": "最大单步压力跳变",
+    "导数RMS": "一阶导数均方根(抖动强度)", "二阶导RMS": "二阶导均方根(拐角尖锐)",
+    "导数过零数": "一阶导数变号次数(振荡次数)", "极值数": "箱内局部极值个数",
+    "二阶导最大": "最大二阶导(最尖的拐角)", "总变差": "Σ|相邻差|(弧长/毛糙)",
+    "过均次数": "穿过箱均值的次数", "移动度": "Hjorth 移动度(频率感)",
+    "复杂度": "Hjorth 复杂度(形状复杂度)",
+}
+CYCLE_DESC = {
+    "吸气压力": "Otsu 低压平台", "排气压力": "Otsu 高压平台", "压比": "排气/吸气",
+    "吸气占比": "低压样本占比", "排气占比": "高压样本占比",
+    "最小压力角": "压力最小处的角度", "最大压力角": "压力最大处的角度",
+    "上升穿越角": "压力上穿中间阈值角", "下降穿越角": "压力下穿中间阈值角",
+    "最大上升斜率": "整周期最大正斜率", "最大下降斜率": "整周期最大负斜率",
+    "最大上升角": "最大正斜率所在角", "最大下降角": "最大负斜率所在角",
+}
+
+
+def translate_csv(src: Path, dst: Path, metric_map: dict, family_map: dict | None = None) -> list[dict]:
+    if not src.is_file():
+        return []
+    raw_headers = next(csv.reader(open(src, encoding="utf-8-sig")), [])
+    rows = list(csv.DictReader(open(src, encoding="utf-8-sig")))
+    headers = [CN.col(name) for name in raw_headers]
+    out = []
+    for row in rows:
+        new = {}
+        for key, value in row.items():
+            if key == "metric":
+                value = metric_map.get(value, value)
+            elif key == "family" and family_map:
+                value = family_map.get(value, value)
+            elif key == "metrics":
+                value = CN.translate_metrics_field(value)
+            new[CN.col(key)] = value
+        out.append(new)
+    dst.parent.mkdir(parents=True, exist_ok=True)
+    with dst.open("w", encoding="utf-8-sig", newline="") as handle:
+        writer = csv.DictWriter(handle, fieldnames=headers)
+        writer.writeheader()
+        writer.writerows(out)
+    return out
+
+
+def num(value):
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def write_summary_md(summary_rows: list[dict], bins_dir: Path, out: Path) -> None:
+    lines = []
+    lines.append("# 功图分角度特征 汇总报告(中文)\n")
+    lines.append(f"> 生成时间:{datetime.now():%Y-%m-%d %H:%M}。数据:7/8/9 号机,"
+                 "每个周期按键相 0-360° 切 10° 箱,逐箱算 23 个形状/波动/分布指标,"
+                 "再加 13 个整周期阀时序指标;对比在**同一测点**上做停机前 vs 开机后。\n")
+
+    lines.append("## 一、怎么读结果\n")
+    lines.append("- **特征候选汇总.csv**:每个候选 = 一个事件 × 一个测点 × 一个角度箱,把该箱里同时变化的指标合并在一行。")
+    lines.append("  - `总分` = 该组所有指标 |MAD倍数| 之和;`指标数` = 变化 ≥3MAD 的指标个数。")
+    lines.append("  - `相隔天数` = 后段起点 − 前段终点;`前跨度天数` = 前段首末之差。**这两个越小越可信**(避免把跨年/跨季工况当成故障)。")
+    lines.append("- **事件明细_X号.csv**:每个角度箱/整周期/指标一行的前后值,供细查。")
+    lines.append("- **图片**:`plots/` 下三联图——左=平均压力随角度、中=波动随角度、右=关注段随时间(红=停机前,蓝=开机后,虚线=事件日)。\n")
+
+    lines.append("## 二、特征字典\n")
+    lines.append("| 中文名 | 英文键 | 含义 |")
+    lines.append("|---|---|---|")
+    for name, en in [(CN.BIN_CN[m], m) for m in BIN_METRICS]:
+        lines.append(f"| {name} | {en} | {BIN_DESC.get(name, '')} |")
+    for name, en in [(CN.CYCLE_CN[m], m) for m in CYCLE_METRICS]:
+        lines.append(f"| {name} | {en} | {CYCLE_DESC.get(name, '')} |")
+    lines.append("")
+
+    # 按事件分组
+    groups = defaultdict(list)
+    for row in summary_rows:
+        groups[(row["机组"], row["事件日期"], row["故障类型"])].append(row)
+
+    lines.append("## 三、各事件候选\n")
+    for key in sorted(groups, key=lambda k: (k[0], k[1])):
+        unit, date, kind = key
+        items = sorted(groups[key], key=lambda r: -num(r["总分"]) if num(r["总分"]) else 0)
+        lines.append(f"### {unit}号机 {date} {kind}\n")
+        top = items[0]
+        lines.append(f"- 前段 {top['前段止']} ← 后段 {top['后段起']}(相隔 {top['相隔天数']} 天,前跨度 {top['前跨度天数']} 天)")
+        lines.append(f"- 候选组 {len(items)} 个,Top5:\n")
+        lines.append("| 测点 | 角度箱 | 范围 | 指标数 | 总分 | 指标明细 |")
+        lines.append("|---|---|---|---|---|---|")
+        for r in items[:5]:
+            bs = r["角箱起点"]
+            if bs in ("", None):
+                binst = "整周期"
+            elif float(bs) < 0:
+                binst = "全箱(整体)"
+            else:
+                binst = f"{float(bs):g}-{float(bs) + 10:g}°"
+            lines.append(f"| {r['测点']} | {binst} | {r['范围']} | {r['指标数']} | {r['总分']} | {r['指标明细']} |")
+        if any(str(r.get("范围", "")).startswith("整体") for r in items):
+            lines.append("")
+            lines.append("> 注:该事件存在**整体水平变化**(全角度一起动,可能负荷/工况),已单列。")
+        lines.append("")
+
+    clean = [r for r in summary_rows
+             if num(r.get("前跨度天数")) is not None and num(r["前跨度天数"]) <= 20
+             and num(r.get("相隔天数")) is not None and num(r["相隔天数"]) <= 40]
+    clean.sort(key=lambda r: -num(r["总分"]))
+    lines.append("## 四、干净短名单(前跨度≤20天 且 相隔≤40天)\n")
+    lines.append("| # | 机组 | 事件 | 测点 | 角度箱 | 范围 | 指标数 | 总分 | 指标明细 |")
+    lines.append("|---|---|---|---|---|---|---|---|---|")
+    for i, r in enumerate(clean[:15], 1):
+        _bs = r["角箱起点"]
+        if _bs in ("", None):
+            binst = "整周期"
+        elif float(_bs) < 0:
+            binst = "全箱(整体)"
+        else:
+            binst = f"{float(_bs):g}-{float(_bs) + 10:g}°"
+        lines.append(f"| {i} | {r['机组']}号 | {r['事件日期']} {r['故障类型']} | {r['测点']} | {binst} | {r['范围']} | "
+                     f"{r['指标数']} | {r['总分']} | {r['指标明细']} |")
+    lines.append("")
+
+    lines.append("## 五、图片索引\n")
+    plots = sorted((bins_dir / "plots").glob("*.png"))
+    for p in plots:
+        lines.append(f"- `plots/{p.name}`")
+    lines.append("")
+
+    out.write_text("\n".join(lines), encoding="utf-8")
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="生成中文交付物")
+    parser.add_argument("--dir", default=str(Path(__file__).resolve().parents[1] / "cache" / "indicator" / "bins"))
+    parser.add_argument("--outdir", default="")
+    args = parser.parse_args()
+    bins_dir = Path(args.dir)
+    outdir = Path(args.outdir) if args.outdir else bins_dir / "results_cn"
+    outdir.mkdir(parents=True, exist_ok=True)
+
+    all_metrics = {**CN.BIN_CN, **CN.CYCLE_CN}
+    # 汇总
+    summary_rows = translate_csv(bins_dir / "summary_final.csv", outdir / "特征候选汇总.csv",
+                                 all_metrics, CN.FAMILY_CN)
+    # 事件明细
+    for unit in ("7", "8", "9"):
+        src = bins_dir / f"event_final_{unit}.csv"
+        if src.is_file():
+            translate_csv(src, outdir / f"事件明细_{unit}号.csv", all_metrics, CN.FAMILY_CN)
+        ssrc = bins_dir / f"scan_final_{unit}.csv"
+        if ssrc.is_file():
+            translate_csv(ssrc, outdir / f"全时间轴扫描_{unit}号.csv", all_metrics, CN.FAMILY_CN)
+
+    write_summary_md(summary_rows, bins_dir, outdir / "特征汇总.md")
+    print("中文交付 ->", outdir)
+    for p in sorted(outdir.iterdir()):
+        print("  ", p.name)
+
+
+if __name__ == "__main__":
+    main()

+ 108 - 0
backend/indicator_features/make_table.py

@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+"""生成规范的多值总表与事件前后对照表(中文表头)。
+
+- 特征总表.csv:机组,缸,腔,角度箱,时间,压力幅度,极差,粗糙度,水平,波动次数,极值数
+  (每个 测点×角度箱×日 取当日中位数)
+- 事件前后对照表.csv:机组,事件,故障类型,缸,腔,角度箱,指标,停机前,开机后,变化
+  (5 个指标各一行,方便按"波动次数"筛角度)
+
+只用现成的 `angle_bins.py` 输出,不重算数据库。
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+from event_bins import EVENTS, collect_after, collect_before, cycle_count, load  # noqa: E402
+from run_segments import unit_segments  # noqa: E402
+
+# 输出列 -> 原始列
+METRICS = [
+    ("极差", "range"), ("粗糙度", "roughness"), ("水平", "mean"),
+    ("波动次数", "zero_cross"), ("极值数", "n_extrema"),
+]
+META = ["机组", "缸", "腔", "角度箱", "时间", "压力幅度"]
+
+
+def write_total(unit: str, rows: list[dict], out: Path) -> None:
+    buckets = defaultdict(lambda: defaultdict(list))
+    for r in rows:
+        key = (r["cylinder"], r["side"], r["bin_start"], r["time"].date())
+        buckets[key]["压力幅度"].append(r["p_span"])
+        for cn, en in METRICS:
+            if r[en] is not None:
+                buckets[key][cn].append(r[en])
+    with out.open("w", encoding="utf-8-sig", newline="") as fh:
+        w = csv.writer(fh)
+        w.writerow(META + [cn for cn, _ in METRICS])
+        for (cyl, side, binstart, day) in sorted(buckets, key=lambda k: (k[0], k[1], k[2], k[3])):
+            b = buckets[(cyl, side, binstart, day)]
+            med = lambda k: (f"{np.median(b[k]):.4g}" if b.get(k) else "")
+            w.writerow([unit, cyl, side, f"{binstart:g}", day.isoformat(), med("压力幅度")]
+                       + [med(cn) for cn, _ in METRICS])
+
+
+def write_event_table(unit: str, rows: list[dict], out: Path) -> None:
+    segments = unit_segments(unit, 2)
+    out_rows = []
+    for eu, date, kind in EVENTS:
+        if eu != unit:
+            continue
+        t0 = datetime.fromisoformat(date)
+        for cyl, side in sorted({(r["cylinder"], r["side"]) for r in rows}):
+            point_rows = [r for r in rows if r["cylinder"] == cyl and r["side"] == side]
+            pre, _ = collect_before(point_rows, t0, segments, 6)
+            post, _ = collect_after(point_rows, t0, segments, 30, 45)
+            if cycle_count(pre) < 3 or cycle_count(post) < 3:
+                continue
+            for b in range(36):
+                pr = [r for r in pre if r["bin"] == b]
+                po = [r for r in post if r["bin"] == b]
+                if len(pr) < 3 or len(po) < 3:
+                    continue
+                for cn, en in METRICS:
+                    a = [r[en] for r in pr if r[en] is not None]
+                    c = [r[en] for r in po if r[en] is not None]
+                    if len(a) < 3 or len(c) < 3:
+                        continue
+                    am, cm = float(np.median(a)), float(np.median(c))
+                    out_rows.append({
+                        "机组": unit, "事件": date, "故障类型": kind, "缸": cyl, "腔": side,
+                        "角度箱": f"{b * 10}-{b * 10 + 10}", "指标": cn,
+                        "停机前": f"{am:.4g}", "开机后": f"{cm:.4g}", "变化": f"{cm - am:+.4g}",
+                    })
+    with out.open("w", encoding="utf-8-sig", newline="") as fh:
+        w = csv.DictWriter(fh, fieldnames=["机组", "事件", "故障类型", "缸", "腔", "角度箱", "指标",
+                                           "停机前", "开机后", "变化"])
+        w.writeheader()
+        w.writerows(out_rows)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="生成规范多值总表")
+    parser.add_argument("--csv", nargs="+", required=True, help="一个或多个 angle_bins CSV")
+    parser.add_argument("--unit", nargs="+", required=True, help="与 --csv 对应的机组号")
+    parser.add_argument("--outdir", required=True)
+    args = parser.parse_args()
+
+    outdir = Path(args.outdir)
+    outdir.mkdir(parents=True, exist_ok=True)
+    for unit, path in zip(args.unit, args.csv):
+        print(f"读 {path} ...", flush=True)
+        rows = load(path)
+        write_total(unit, rows, outdir / f"特征总表_{unit}号.csv")
+        write_event_table(unit, rows, outdir / f"事件前后对照_{unit}号.csv")
+        print(f"  {unit}号:特征总表 + 事件前后对照 已生成")
+
+
+if __name__ == "__main__":
+    main()

+ 114 - 0
backend/indicator_features/metric_trace.py

@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""Trace one (point, angle bin, metric) over the whole available timeline.
+
+Prints the daily medians, splits them into data segments (day gaps), fits a
+linear trend per segment and overall, and optionally saves a figure.  This is
+for checking whether a candidate feature has a slow pre-event evolution.
+"""
+from __future__ import annotations
+
+import argparse
+import sys
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt  # noqa: E402
+
+matplotlib.rcParams["font.sans-serif"] = ["Arial Unicode MS", "Heiti TC", "Hiragino Sans GB", "DejaVu Sans"]
+matplotlib.rcParams["axes.unicode_minus"] = False
+
+import cn_names as CN  # noqa: E402
+from event_bins import load  # noqa: E402
+
+EVENT_DATES = ["2025-08-08", "2025-10-07", "2026-05-21"]
+
+
+def robust_scale(values):
+    if len(values) == 0:
+        return 0.0
+    values = np.asarray(values)
+    mad = float(np.median(np.abs(values - np.median(values)))) * 1.4826
+    return mad if mad > 1e-12 else float(np.std(values))
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="单指标全时间轴演化轨迹")
+    parser.add_argument("--csv", required=True)
+    parser.add_argument("--unit", required=True)
+    parser.add_argument("--cylinder", required=True)
+    parser.add_argument("--side", required=True)
+    parser.add_argument("--bin-start", type=float, required=True)
+    parser.add_argument("--metric", required=True)
+    parser.add_argument("--gap", type=int, default=10, help="数据段切分的天数间隔")
+    parser.add_argument("--event", action="append", default=[], help="事件日期(画竖线)")
+    parser.add_argument("--out", default="")
+    args = parser.parse_args()
+
+    rows = load(args.csv)
+    sel = [r for r in rows if r["unit"] == args.unit and r["cylinder"] == args.cylinder
+           and r["side"] == args.side and abs(r["bin_start"] - args.bin_start) < 1e-6
+           and r[args.metric] is not None]
+    if not sel:
+        print("没有匹配数据")
+        return
+    sel.sort(key=lambda r: r["time"])
+    by_day = defaultdict(list)
+    for r in sel:
+        by_day[r["time"].date()].append(r[args.metric])
+    days = sorted(by_day)
+    daily = {d: float(np.median(by_day[d])) for d in days}
+    print(f"{args.unit}号机 {args.cylinder}缸{args.side} 箱{args.bin_start:g}° {args.metric}"
+          f"  周期 {len(sel)}  日 {len(days)}  {days[0]}~{days[-1]}")
+
+    segments = []
+    for d in days:
+        if segments and (d - segments[-1][-1]).days <= args.gap:
+            segments[-1].append(d)
+        else:
+            segments.append([d])
+    all_scale = robust_scale(list(daily.values()))
+    for seg in segments:
+        y = np.asarray([daily[d] for d in seg])
+        span_days = (seg[-1] - seg[0]).days
+        line = f"  段 {seg[0]}~{seg[-1]} ({len(seg)}天有数据/{span_days}天)  中位 {np.median(y):.5g}  范围 {y.min():.5g}~{y.max():.5g}"
+        if len(seg) >= 4 and span_days > 0:
+            x = np.asarray([(d - seg[0]).days for d in seg], dtype=float)
+            slope = float(np.polyfit(x, y, 1)[0])
+            drift = slope * span_days
+            line += f"  趋势 {drift:+.4g} ({drift / all_scale:+.1f}全局MAD)"
+        print(line)
+    if len(days) >= 6 and (days[-1] - days[0]).days > 0:
+        x = np.asarray([(d - days[0]).days for d in days], dtype=float)
+        y = np.asarray([daily[d] for d in days])
+        # only fit inside the last contiguous-ish run toward the event
+        slope = float(np.polyfit(x, y, 1)[0])
+        print(f"  全程线性趋势 {slope * (days[-1] - days[0]).days:+.4g} / {all_scale:+.4g} = {(slope * (days[-1] - days[0]).days) / all_scale:+.1f} 全局MAD")
+
+    print("  逐日: " + ", ".join(f"{d}:{daily[d]:.4g}" for d in days))
+
+    if args.out:
+        fig, ax = plt.subplots(figsize=(11, 3.6))
+        xs = [datetime.combine(d, datetime.min.time()) for d in days]
+        ys = [daily[d] for d in days]
+        ax.plot(xs, ys, "o-", color="crimson", ms=3, lw=1)
+        for ev in (args.event or EVENT_DATES):
+            ax.axvline(datetime.fromisoformat(ev), color="k", ls="--", lw=0.8)
+            ax.text(datetime.fromisoformat(ev), max(ys), ev, fontsize=7, rotation=90, va="top")
+        ax.set_title(f"{args.unit}号机 {args.cylinder}缸{args.side} 箱{args.bin_start:g}° {CN.metric_col(args.metric)} 日度演化")
+        ax.grid(alpha=0.25)
+        fig.tight_layout()
+        Path(args.out).parent.mkdir(parents=True, exist_ok=True)
+        fig.savefig(args.out, dpi=110)
+        print("图:", args.out)
+
+
+if __name__ == "__main__":
+    main()

+ 114 - 0
backend/indicator_features/one_page.py

@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""一页纸候选:每个事件只留最强的 2 条(机组/测点/角度段/指标/前后值/一句话)。"""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+from event_bins import EVENTS, collect_after, collect_before, cycle_count, load  # noqa: E402
+from run_segments import unit_segments  # noqa: E402
+
+METRICS = {"极差": "range", "粗糙度": "roughness", "水平": "mean",
+           "波动次数": "zero_cross", "极值数": "n_extrema"}
+# 已知坏道/传感器通道(观察记录里确认过)
+BAD = {("7", "四", "盖侧"), ("7", "六", "盖侧"), ("8", "六", "盖侧")}
+UP = {"极差": "波动变大", "粗糙度": "变毛糙", "水平": "抬高",
+      "波动次数": "起伏次数变多", "极值数": "转折变多"}
+DOWN = {"极差": "波动变小", "粗糙度": "变平滑", "水平": "降低",
+        "波动次数": "起伏次数变少", "极值数": "转折变少"}
+
+
+def robust_z(pre, post):
+    if len(pre) < 3 or len(post) < 3:
+        return None
+    med = float(np.median(pre))
+    mad = float(np.median(np.abs(np.asarray(pre) - med))) * 1.4826
+    if mad <= 1e-9:
+        mad = float(np.std(pre))
+    if mad <= 1e-9:
+        return None
+    return (float(np.median(post)) - med) / mad
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="一页纸候选")
+    parser.add_argument("--csv", nargs="+", required=True)
+    parser.add_argument("--unit", nargs="+", required=True)
+    parser.add_argument("--top", type=int, default=2, help="每个事件保留几条")
+    parser.add_argument("--z", type=float, default=4.0, help="强度阈值")
+    parser.add_argument("--out", required=True)
+    args = parser.parse_args()
+
+    picked = []
+    for unit, path in zip(args.unit, args.csv):
+        rows = load(path)
+        segments = unit_segments(unit, 2)
+        for eu, date, kind in EVENTS:
+            if eu != unit:
+                continue
+            t0 = datetime.fromisoformat(date)
+            cands = []
+            for cyl, side in sorted({(r["cylinder"], r["side"]) for r in rows}):
+                if (unit, cyl, side) in BAD:
+                    continue
+                point = [r for r in rows if r["cylinder"] == cyl and r["side"] == side]
+                pre, _ = collect_before(point, t0, segments, 6)
+                post, _ = collect_after(point, t0, segments, 30, 45)
+                if cycle_count(pre) < 3 or cycle_count(post) < 3:
+                    continue
+                span = np.median([r["p_span"] for r in pre if r["p_span"]]) if pre else 0
+                if span < 0.3:  # 坏道/卡死
+                    continue
+                for b in range(36):
+                    pr = [r for r in pre if r["bin"] == b]
+                    po = [r for r in post if r["bin"] == b]
+                    for cn, en in METRICS.items():
+                        a = [r[en] for r in pr if r[en] is not None]
+                        c = [r[en] for r in po if r[en] is not None]
+                        z = robust_z(a, c)
+                        if z is None or abs(z) < args.z:
+                            continue
+                        am, cm = float(np.median(a)), float(np.median(c))
+                        cands.append({
+                            "机组": unit, "事件": date, "故障类型": kind,
+                            "测点": f"{cyl}缸{side}", "角度段": f"{b*10}-{b*10+10}°",
+                            "指标": cn, "停机前": round(am, 3), "开机后": round(cm, 3),
+                            "强度": round(abs(z), 1),
+                            "结论": (UP if z > 0 else DOWN)[cn],
+                        })
+            cands.sort(key=lambda r: -r["强度"])
+            seen = set()
+            for row in cands:
+                key = (row["测点"], row["角度段"])
+                if key in seen:
+                    continue
+                seen.add(key)
+                picked.append(row)
+                if len(seen) >= args.top:
+                    break
+
+    out = Path(args.out)
+    out.parent.mkdir(parents=True, exist_ok=True)
+    fields = ["机组", "事件", "故障类型", "测点", "角度段", "指标", "停机前", "开机后", "强度", "结论"]
+    with out.open("w", encoding="utf-8-sig", newline="") as fh:
+        w = csv.DictWriter(fh, fieldnames=fields)
+        w.writeheader()
+        w.writerows(picked)
+    print(f"一页纸候选 -> {out}({len(picked)} 行)")
+    print(f"{'机组':<4}{'事件':<12}{'测点':<9}{'角度段':<10}{'指标':<6}{'前':>8}{'后':>8}{'强度':>6}  结论")
+    for r in picked:
+        print(f"{r['机组']+'号':<4}{r['事件']:<12}{r['测点']:<9}{r['角度段']:<10}{r['指标']:<6}"
+              f"{r['停机前']:>8}{r['开机后']:>8}{r['强度']:>6}  {r['结论']}")
+
+
+if __name__ == "__main__":
+    main()

+ 122 - 0
backend/indicator_features/pair_plot.py

@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""A simple, readable before/after figure for one measurement point.
+
+Three panels, all in Chinese:
+
+1. 平均压力 对 曲柄角度(停机前红 / 开机后蓝),高亮关注的角度段;
+2. 波动 std 对 曲柄角度(同色);
+3. 关注角度段的 std 随时间变化,停机前红、开机后蓝,并标出事件日。
+"""
+from __future__ import annotations
+
+import argparse
+import sys
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt  # noqa: E402
+
+matplotlib.rcParams["font.sans-serif"] = ["Arial Unicode MS", "Heiti TC", "Hiragino Sans GB", "DejaVu Sans"]
+matplotlib.rcParams["axes.unicode_minus"] = False
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+import cn_names as CN  # noqa: E402
+from event_bins import collect_after, collect_before, load  # noqa: E402
+from run_segments import unit_segments  # noqa: E402
+
+
+def med(rows, metric, bin_index):
+    vals = [r[metric] for r in rows if r["bin"] == bin_index and r[metric] is not None]
+    return float(np.median(vals)) if vals else np.nan
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="单点 停机前/开机后 简明对照图")
+    parser.add_argument("--csv", required=True)
+    parser.add_argument("--unit", required=True)
+    parser.add_argument("--date", required=True)
+    parser.add_argument("--cylinder", required=True)
+    parser.add_argument("--side", required=True)
+    parser.add_argument("--highlight", default="", help="关注角度段,如 140-150")
+    parser.add_argument("--series-metric", default="std", help="右图随时间画哪个指标(默认 std)")
+    parser.add_argument("--pre-days", type=int, default=15)
+    parser.add_argument("--post-days", type=int, default=15)
+    parser.add_argument("--max-gap", type=int, default=30)
+    parser.add_argument("--merge-gap", type=int, default=2)
+    parser.add_argument("--out", required=True)
+    args = parser.parse_args()
+
+    rows = load(args.csv)
+    point = [r for r in rows if r["cylinder"] == args.cylinder and r["side"] == args.side]
+    t0 = datetime.fromisoformat(args.date)
+    segments = unit_segments(args.unit, args.merge_gap)
+    pre, _ = collect_before(point, t0, segments, args.pre_days)
+    post, _ = collect_after(point, t0, segments, args.post_days, args.max_gap)
+    bins = sorted({r["bin"] for r in point})
+    bin_start = {r["bin"]: r["bin_start"] for r in point}
+    bin_deg = round(bin_start[bins[1]] - bin_start[bins[0]], 6) if len(bins) > 1 else 10.0
+    centers = [bin_start[b] + bin_deg / 2 for b in bins]
+
+    lo = hi = None
+    if "-" in args.highlight:
+        lo, hi = (float(x) for x in args.highlight.split("-"))
+    hi_bins = [b for b in bins if lo is not None and lo <= bin_start[b] < hi]
+
+    def daily_series(rows, bin_index, metric):
+        by_day = defaultdict(list)
+        for r in rows:
+            if r["bin"] == bin_index and r[metric] is not None:
+                by_day[r["time"].date()].append(r[metric])
+        days = sorted(by_day)
+        return days, [float(np.median(by_day[d])) for d in days]
+
+    fig, axes = plt.subplots(1, 3, figsize=(15, 3.8))
+
+    for ax, metric, name in ((axes[0], "mean", "平均压力 (MPa)"), (axes[1], "std", "波动 std (MPa)")):
+        ax.plot(centers, [med(pre, metric, b) for b in bins], color="crimson", lw=1.8, label="停机前")
+        ax.plot(centers, [med(post, metric, b) for b in bins], color="royalblue", lw=1.8, label="开机后")
+        if hi_bins:
+            ax.axvspan(lo, hi, color="orange", alpha=0.25)
+        ax.set_xlim(0, 360)
+        ax.set_xticks([0, 90, 180, 270, 360])
+        ax.set_xlabel("曲柄角度 (°)")
+        ax.set_title(name)
+        ax.grid(alpha=0.25)
+    axes[0].legend(fontsize=9)
+
+    ax = axes[2]
+    if hi_bins:
+        b0 = hi_bins[len(hi_bins) // 2]
+        days, values = daily_series(point, b0, args.series_metric)
+        for d, v in zip(days, values):
+            color = "royalblue" if datetime.combine(d, datetime.min.time()) >= t0 else "crimson"
+            ax.plot(d, v, "o", color=color, ms=4)
+        ax.axvline(t0, color="k", ls="--", lw=1)
+        ax.set_title(f"关注段 {lo:g}-{hi:g}° 的 {CN.metric_col(args.series_metric)} 随时间")
+    else:
+        ax.set_title("(未指定关注段)")
+    ax.set_xlabel("日期")
+    ax.grid(alpha=0.25)
+    ax.tick_params(axis="x", labelrotation=30)
+
+    fig.suptitle(f"{args.unit}号机 {args.date}  {args.cylinder}缸{args.side}  停机前 vs 开机后")
+    fig.tight_layout()
+    Path(args.out).parent.mkdir(parents=True, exist_ok=True)
+    fig.savefig(args.out, dpi=120)
+    print("图:", args.out)
+    print(f"前段周期 {len({r['wave_file_id'] for r in pre})},后段周期 {len({r['wave_file_id'] for r in post})}")
+    if hi_bins:
+        pre_b = [r for r in pre if r["bin"] == b0]
+        post_b = [r for r in post if r["bin"] == b0]
+        print(f"关注段 {lo:g}-{hi:g}° {CN.metric_col(args.series_metric)}: 前中位 {np.median([r[args.series_metric] for r in pre_b]):.5g} -> 后中位 {np.median([r[args.series_metric] for r in post_b]):.5g}")
+
+
+if __name__ == "__main__":
+    main()

+ 95 - 0
backend/indicator_features/run_segments.py

@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Machine running segments from ``wave_file.rpm > 0`` (read-only).
+
+The site's 开机停机表 is known to be inaccurate, so the running state is taken
+from the data itself: every calendar day that has at least one pressure record
+with ``rpm > 0`` is a running day.  Consecutive running days (allowing a small
+merge gap for missing daily records) form one *running segment*.  ``event_bins``
+uses these segments to pick the pre/post running periods instead of fixed
+event-relative windows.
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from datetime import date
+from pathlib import Path
+
+import pymysql
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+from app.config import settings  # noqa: E402
+
+
+def connect() -> pymysql.connections.Connection:
+    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=8, read_timeout=120,
+    )
+
+
+def running_days(unit: str, connection=None) -> list[date]:
+    """Sorted distinct days with a running pressure record for one unit."""
+    own = connection is None
+    connection = connection or connect()
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute(
+                """
+                SELECT DISTINCT DATE(sample_time) AS d
+                FROM wave_file
+                WHERE point_name LIKE %s AND measurement_type = '压力' AND rpm > 0
+                ORDER BY d
+                """,
+                (f"{unit}号机组%",),
+            )
+            return [row["d"] for row in cursor.fetchall()]
+    finally:
+        if own:
+            connection.close()
+
+
+def build_segments(days: list[date], merge_gap: int = 2) -> list[tuple[date, date]]:
+    """Merge running days into segments; a gap of at most ``merge_gap`` off-days is bridged."""
+    segments: list[list[date]] = []
+    for day in days:
+        if segments and (day - segments[-1][1]).days <= merge_gap + 1:
+            segments[-1][1] = day
+        else:
+            segments.append([day, day])
+    return [(start, end) for start, end in segments]
+
+
+def unit_segments(unit: str, merge_gap: int = 2, connection=None) -> list[tuple[date, date]]:
+    return build_segments(running_days(unit, connection), merge_gap)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="wave_file.rpm>0 统计开机日与运行段(只读)")
+    parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
+    parser.add_argument("--merge-gap", type=int, default=2, help="允许跨过的停机天数(桥接)")
+    parser.add_argument("--out", default="", help="运行段 CSV 输出路径(可选)")
+    args = parser.parse_args()
+
+    days = running_days(args.unit)
+    segments = build_segments(days, args.merge_gap)
+    print(f"{args.unit}号机 rpm>0 开机日 {len(days)} 天,运行段 {len(segments)} 个:")
+    for start, end in segments:
+        print(f"  {start} ~ {end}  ({(end - start).days + 1}天)")
+    if args.out:
+        out = Path(args.out)
+        out.parent.mkdir(parents=True, exist_ok=True)
+        with out.open("w", encoding="utf-8-sig", newline="") as handle:
+            writer = csv.writer(handle)
+            writer.writerow(["unit", "start", "end", "days"])
+            for start, end in segments:
+                writer.writerow([args.unit, start, end, (end - start).days + 1])
+        print("->", out)
+
+
+if __name__ == "__main__":
+    main()

+ 159 - 0
backend/indicator_features/scan_bins.py

@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+"""Full-timeline unsupervised change scan over the per-bin/-cycle features.
+
+For every ``(point, bin, metric)`` the whole available time series is searched
+for the single best two-segment split (a step change), scored as the between
+segment mean difference over the pooled standard deviation (Cohen's d).  The
+point-wide (all-bins) part is removed per metric so only the bins that move
+*differently* rank high -- the same local/whole decomposition used by
+``event_bins.py``, but now applied to the entire timeline without needing a
+known event or a pre/post window.
+
+Output: a ranked candidate list and a CSV.  Read-only.
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+
+from bins_common import ANGLE_METRICS, BIN_METRICS, CYCLE_METRICS, VOLATILE, apply_normalization
+from event_bins import EVENTS, load
+
+MIN_DEG = 1.0  # minimum pooled scale for angle metrics
+
+
+def best_split(times, values, min_days: int = 3):
+    """Best two-segment step (mean difference / pooled std). Returns (score, index)."""
+    n = len(values)
+    if n < 8:
+        return None, None
+    x = np.asarray(values, dtype=float)
+    csum = np.cumsum(x)
+    csum2 = np.cumsum(x ** 2)
+    total, total_sq = float(csum[-1]), float(csum2[-1])
+    best_score, best_i = 0.0, None
+    min_seg = max(5, n // 12)
+    for i in range(min_seg, n - min_seg):
+        if (times[i - 1] - times[0]).days < min_days or (times[-1] - times[i]).days < min_days:
+            continue
+        left, right = i, n - i
+        sl, sl2 = float(csum[i - 1]), float(csum2[i - 1])
+        mean_l, mean_r = sl / left, (total - sl) / right
+        var_l = max(sl2 / left - mean_l ** 2, 0.0)
+        var_r = max((total_sq - sl2) / right - mean_r ** 2, 0.0)
+        pooled = float(np.sqrt(0.5 * (var_l + var_r)))
+        if pooled <= 1e-12:
+            continue
+        score = abs(mean_r - mean_l) / pooled
+        if score > best_score:
+            best_score, best_i = score, i
+    return (best_score, best_i) if best_i is not None else (None, None)
+
+
+def daily_series(rows, getter):
+    return sorted(((r["time"], getter(r)) for r in rows), key=lambda t: t[0])
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="全时间轴 分箱特征 无监督变点扫描")
+    parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
+    parser.add_argument("--csv", nargs="+", required=True)
+    parser.add_argument("--top", type=int, default=40)
+    parser.add_argument("--z", type=float, default=5.0, help="最小 |Cohen's d| 阈值")
+    parser.add_argument("--normalize", choices=("none", "span"), default="none",
+                        help="span: 用本周期 p_span 归一再扫(压工况)")
+    parser.add_argument("--out", default="")
+    args = parser.parse_args()
+
+    rows = []
+    for path in args.csv:
+        rows.extend(load(path))
+    apply_normalization(rows, args.normalize)
+    print(f"载入 {len(rows):,} 行,测点 {len({r['point_name'] for r in rows})},归一 {args.normalize}")
+
+    # records[(point,metric)][bin] = (time, score)
+    bin_scores = defaultdict(dict)
+    cycle_scores = defaultdict(list)
+    points = sorted({(r["cylinder"], r["side"]) for r in rows})
+
+    for cylinder, side in points:
+        point_rows = [r for r in rows if r["cylinder"] == cylinder and r["side"] == side]
+        # per-bin
+        by_bin = defaultdict(list)
+        for r in point_rows:
+            by_bin[r["bin"]].append(r)
+        for name in BIN_METRICS:
+            for b, items in by_bin.items():
+                series = daily_series(items, lambda r, nm=name: r[nm])
+                series = [(t, v) for t, v in series if v is not None]
+                times = [t for t, _ in series]
+                values = [v for _, v in series]
+                score, idx = best_split(times, values)
+                if score is None:
+                    continue
+                bin_scores[(f"{cylinder}缸{side}", name)][b] = (score, times[idx], np.mean(values[:idx]), np.mean(values[idx:]))
+        # whole-cycle
+        for name in CYCLE_METRICS:
+            if name in ANGLE_METRICS:
+                continue
+            series = daily_series(point_rows, lambda r, nm=name: r[nm])
+            series = [(t, v) for t, v in series if v is not None]
+            times = [t for t, _ in series]
+            values = [v for _, v in series]
+            score, idx = best_split(times, values)
+            if score is not None:
+                cycle_scores[f"{cylinder}缸{side}"].append((name, score, times[idx], np.mean(values[:idx]), np.mean(values[idx:])))
+
+    records = []
+    for (point, metric), per_bin in bin_scores.items():
+        if len(per_bin) < 4:
+            continue
+        raw = np.asarray([v[0] for v in per_bin.values()], dtype=float)
+        median_score = float(np.median(raw))
+        for b, (score, split_time, before, after) in per_bin.items():
+            local = score - median_score  # remove whole-point level shift
+            if local >= args.z:
+                records.append({
+                    "family": "bin", "point": point, "bin": b,
+                    "metric": metric, "score": local, "raw_score": score,
+                    "split": split_time, "before": before, "after": after,
+                    "volatile": metric in VOLATILE,
+                })
+    for point, items in cycle_scores.items():
+        for name, score, split_time, before, after in items:
+            if score >= args.z:
+                records.append({
+                    "family": "cycle", "point": point, "bin": "", "metric": name,
+                    "score": score, "raw_score": score, "split": split_time,
+                    "before": before, "after": after, "volatile": False,
+                })
+
+    records.sort(key=lambda r: -r["score"])
+    print(f"候选 {len(records)} 条(分箱局部分数 >= {args.z},整周期 >= {args.z})")
+    print(f"{'#':>3}  {'家族':<6} {'测点':<9} {'角度箱':<8} {'指标':<13} {'分数':>6} {'突变时间':<17} {'前':>9} {'后':>9}")
+    for i, r in enumerate(records[: args.top], 1):
+        binst = f"{r['bin'] * 10:g}-{r['bin'] * 10 + 10:g}°" if r["family"] == "bin" else "整周期"
+        near = min(EVENTS, key=lambda e: abs((datetime.fromisoformat(e[1]) - r["split"]).days))
+        gap = abs((datetime.fromisoformat(near[1]) - r["split"]).days)
+        print(f"{i:>3}  {r['family']:<6} {r['point']:<9} {binst:<8} {r['metric']:<13} {r['score']:>6.1f} "
+              f"{r['split']:%Y-%m-%d %H:%M} {r['before']:>9.4g} {r['after']:>9.4g}   ~{near[0]}号{near[1]}({gap}天)")
+
+    if args.out and records:
+        out = Path(args.out)
+        out.parent.mkdir(parents=True, exist_ok=True)
+        with out.open("w", encoding="utf-8-sig", newline="") as handle:
+            writer = csv.DictWriter(handle, fieldnames=["family", "point", "bin", "metric", "score", "raw_score", "split", "before", "after", "volatile"])
+            writer.writeheader()
+            for r in records:
+                writer.writerow({**r, "split": r["split"].isoformat()})
+        print(f"明细 -> {out}")
+    print(f"时间:{datetime.now():%Y-%m-%d %H:%M:%S}")
+
+
+if __name__ == "__main__":
+    main()

+ 123 - 0
backend/indicator_features/segment_compare.py

@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""按运行段逐段对比:每一段开机 vs 上一段,列出最明显的变化(一页纸/段)。"""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from datetime import datetime
+from pathlib import Path
+
+import numpy as np
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+from event_bins import load  # noqa: E402
+from run_segments import unit_segments  # noqa: E402
+
+METRICS = {"极差": "range", "粗糙度": "roughness", "水平": "mean",
+           "波动次数": "zero_cross", "极值数": "n_extrema"}
+UP = {"极差": "波动变大", "粗糙度": "变毛糙", "水平": "抬高",
+      "波动次数": "起伏次数变多", "极值数": "转折变多"}
+DOWN = {"极差": "波动变小", "粗糙度": "变平滑", "水平": "降低",
+        "波动次数": "起伏次数变少", "极值数": "转折变少"}
+BAD = {("7", "四", "盖侧"), ("7", "六", "盖侧"), ("8", "六", "盖侧")}
+
+
+def med_mad(values):
+    if not values:
+        return None, None
+    arr = np.asarray(values, dtype=float)
+    med = float(np.median(arr))
+    mad = float(np.median(np.abs(arr - med))) * 1.4826
+    if mad <= 1e-9:
+        mad = float(np.std(arr))
+    return med, mad
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="运行段逐段对比")
+    parser.add_argument("--csv", nargs="+", required=True)
+    parser.add_argument("--unit", nargs="+", required=True)
+    parser.add_argument("--top", type=int, default=5, help="每段保留几条")
+    parser.add_argument("--z", type=float, default=6.0)
+    parser.add_argument("--outdir", required=True)
+    args = parser.parse_args()
+
+    outdir = Path(args.outdir)
+    outdir.mkdir(parents=True, exist_ok=True)
+    for unit, path in zip(args.unit, args.csv):
+        rows = load(path)
+        segments = unit_segments(unit, 2)
+        print(f"\n===== {unit}号机 运行段({len(segments)} 段)=====")
+        records = []
+        for i in range(1, len(segments)):
+            prev, cur = segments[i - 1], segments[i]
+            pdate, cdate = f"{prev[0]}~{prev[1]}", f"{cur[0]}~{cur[1]}"
+            cands = []
+            for cyl, side in sorted({(r["cylinder"], r["side"]) for r in rows}):
+                if (unit, cyl, side) in BAD:
+                    continue
+                p_rows = [r for r in rows if r["cylinder"] == cyl and r["side"] == side
+                          and prev[0] <= r["time"].date() <= prev[1]]
+                c_rows = [r for r in rows if r["cylinder"] == cyl and r["side"] == side
+                          and cur[0] <= r["time"].date() <= cur[1]]
+                if len({r["wave_file_id"] for r in p_rows}) < 3 or len({r["wave_file_id"] for r in c_rows}) < 3:
+                    continue
+                if np.median([r["p_span"] for r in p_rows if r["p_span"]] or [0]) < 0.3:
+                    continue
+                for b in range(36):
+                    pb = [r for r in p_rows if r["bin"] == b]
+                    cb = [r for r in c_rows if r["bin"] == b]
+                    for cn, en in METRICS.items():
+                        pm, mad = med_mad([r[en] for r in pb if r[en] is not None])
+                        cm, _ = med_mad([r[en] for r in cb if r[en] is not None])
+                        if pm is None or cm is None or mad is None or mad <= 1e-9:
+                            continue
+                        z = (cm - pm) / mad
+                        if abs(z) < args.z:
+                            continue
+                        # 还要有"实际幅度":幅度类≥3%,次数类≥2 次
+                        if cn in ("波动次数", "极值数"):
+                            if abs(cm - pm) < 2:
+                                continue
+                        elif abs(pm) > 1e-9 and abs(cm - pm) < 0.03 * abs(pm):
+                            continue
+                        z = max(-999.0, min(z, 999.0))
+                        cands.append({
+                            "机组": unit, "本段": cdate, "上段": pdate,
+                            "测点": f"{cyl}缸{side}", "角度段": f"{b*10}-{b*10+10}°",
+                            "指标": cn, "上段值": round(pm, 3), "本段值": round(cm, 3),
+                            "强度": round(abs(z), 1),
+                            "结论": (UP if z > 0 else DOWN)[cn],
+                        })
+            cands.sort(key=lambda r: -r["强度"])
+            seen = set()
+            picked = []
+            for row in cands:
+                key = (row["测点"], row["角度段"])
+                if key in seen:
+                    continue
+                seen.add(key)
+                picked.append(row)
+                if len(picked) >= args.top:
+                    break
+            records.extend(picked)
+            print(f"  {cdate} 相对于 上段 {pdate}:")
+            if not picked:
+                print("     无明显变化")
+            for r in picked:
+                print(f"     {r['测点']:<9}{r['角度段']:<10}{r['指标']:<6}"
+                      f"{r['上段值']:>8}->{r['本段值']:<8} 强度{r['强度']:<5}{r['结论']}")
+        if records:
+            out = outdir / f"分段对比_{unit}号.csv"
+            with out.open("w", encoding="utf-8-sig", newline="") as fh:
+                w = csv.DictWriter(fh, fieldnames=list(records[0].keys()))
+                w.writeheader()
+                w.writerows(records)
+            print(f"  -> {out}")
+
+
+if __name__ == "__main__":
+    main()

+ 127 - 0
backend/indicator_features/summary_features.py

@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+"""Aggregate the per-event change tables into one ranked summary.
+
+Reads one or more ``event_bins.py`` detail CSVs and groups the per-bin and
+whole-cycle changes by ``(event, point, bin)`` so several metrics that move in
+the same bin are seen as one candidate.  Ranks by the summed |MAD| and prints a
+compact metric list per candidate.  This is the table to hand to later
+summarisation.
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+from collections import defaultdict
+from pathlib import Path
+
+VOLATILE = ("std", "mad", "range", "roughness", "d_abs_mean", "d_abs_max", "d_rms",
+            "d2_rms", "zero_cross", "n_extrema", "d2_abs_max", "tv", "mean_cross",
+            "mobility", "complexity")
+
+
+def to_float(value):
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="事件候选汇总(跨指标合并)")
+    parser.add_argument("--csv", nargs="+", required=True)
+    parser.add_argument("--threshold", type=float, default=2.0, help="计入的 |MAD| 下限")
+    parser.add_argument("--max-gap", type=int, default=0, help="只保留前后段相隔 <= N 天的(0=不限)")
+    parser.add_argument("--max-pre-span", type=int, default=0, help="只保留前段跨度 <= N 天的(0=不限)")
+    parser.add_argument("--top", type=int, default=40)
+    parser.add_argument("--out", default="")
+    args = parser.parse_args()
+
+    groups = defaultdict(list)
+    for path in args.csv:
+        for row in csv.DictReader(open(path, encoding="utf-8-sig")):
+            if row.get("family") not in ("bin", "cycle"):
+                continue
+            mad = to_float(row.get("mad"))
+            if mad is None or abs(mad) < args.threshold:
+                continue
+            bin_start = row.get("bin_start") or ""
+            key = (row["unit"], row["date"], row["kind"], row["family"], row["point"], bin_start)
+            groups[key].append({
+                "metric": row["metric"], "type": row.get("type", ""), "mad": mad,
+                "delta": to_float(row.get("delta")), "rel": to_float(row.get("rel")),
+                "pre": to_float(row.get("pre")), "post": to_float(row.get("post")),
+                "candidate": row.get("candidate") == "1",
+                "gap_days": to_float(row.get("gap_days")),
+                "pre_span_days": to_float(row.get("pre_span_days")),
+                "pre_end": row.get("pre_end", ""), "post_start": row.get("post_start", ""),
+            })
+
+    records = []
+    for key, items in groups.items():
+        unit, date, kind, family, point, bin_start = key
+        gap = items[0]["gap_days"]
+        pre_span = items[0].get("pre_span_days")
+        if args.max_gap and (gap is None or gap > args.max_gap):
+            continue
+        if args.max_pre_span and (pre_span is None or pre_span > args.max_pre_span):
+            continue
+        score = sum(abs(i["mad"]) for i in items)
+        strongest = max(items, key=lambda i: abs(i["mad"]))
+        if bin_start in ("", None):
+            scope = "整周期"
+        elif float(bin_start) < 0:
+            scope = "整体(全角度)"
+        else:
+            scope = "局部(单角度箱)"
+        records.append({
+            "unit": unit, "date": date, "kind": kind, "family": family, "point": point,
+            "bin_start": bin_start, "scope": scope,
+            "n_metrics": len(items), "score": score,
+            "max_mad": abs(strongest["mad"]), "candidate": any(i["candidate"] for i in items),
+            "gap_days": gap, "pre_span_days": pre_span,
+            "pre_end": items[0]["pre_end"], "post_start": items[0]["post_start"],
+            "items": items,
+        })
+    records.sort(key=lambda r: -r["score"])
+
+    print(f"候选组 {len(records)}(每个 = 事件×测点×角度箱/整周期,跨指标合并;|MAD|>={args.threshold}"
+          + (f";前后相隔<={args.max_gap}天" if args.max_gap else "") + ")")
+    print(f"{'#':>3} {'机组':<4} {'事件':<16} {'测点':<9} {'角度箱':<10} {'范围':<10} {'指标数':>5} {'总|MAD|':>8} {'相隔':>4} {'前跨度':>5} 指标(前→后方向)")
+    out_rows = []
+    for i, r in enumerate(records[:args.top], 1):
+        binst = "整周期" if r["bin_start"] == "" else (
+            f"{float(r['bin_start']):g}-{float(r['bin_start']) + 10:g}°")
+        parts = []
+        for it in sorted(r["items"], key=lambda x: -abs(x["mad"])):
+            arrow = "↑" if (it["delta"] or 0) > 0 else "↓"
+            parts.append(f"{it['metric']}{arrow}{abs(it['mad']):.0f}")
+        summary = ", ".join(parts[:8])
+        tag = "★" if r["candidate"] else ""
+        gap = "" if r["gap_days"] is None else f"{int(r['gap_days'])}"
+        pspan = "" if r["pre_span_days"] is None else f"{int(r['pre_span_days'])}"
+        print(f"{i:>3} {r['unit']+'号':<4} {r['date'][5:]+' '+r['kind']:<16} {r['point']:<9} {binst:<10} "
+              f"{r['scope']:<10} {r['n_metrics']:>5} {r['score']:>8.0f} {gap:>4} {pspan:>5} {summary} {tag}")
+        out_rows.append({
+            "unit": r["unit"], "date": r["date"], "kind": r["kind"], "family": r["family"],
+            "point": r["point"], "bin_start": r["bin_start"], "scope": r["scope"],
+            "n_metrics": r["n_metrics"],
+            "score": round(r["score"], 2), "max_mad": round(r["max_mad"], 2),
+            "gap_days": r["gap_days"], "pre_span_days": r["pre_span_days"],
+            "pre_end": r["pre_end"], "post_start": r["post_start"],
+            "candidate": int(r["candidate"]), "metrics": ";".join(
+                f"{it['metric']}{'up' if (it['delta'] or 0) > 0 else 'dn'}{abs(it['mad']):.1f}"
+                for it in sorted(r["items"], key=lambda x: -abs(x["mad"]))),
+        })
+
+    if args.out and out_rows:
+        out = Path(args.out)
+        out.parent.mkdir(parents=True, exist_ok=True)
+        with out.open("w", encoding="utf-8-sig", newline="") as handle:
+            writer = csv.DictWriter(handle, fieldnames=list(out_rows[0].keys()))
+            writer.writeheader()
+            writer.writerows(out_rows)
+        print(f"\n汇总 -> {out}({len(out_rows)} 组)")
+
+
+if __name__ == "__main__":
+    main()

+ 147 - 0
backend/indicator_features/watch.py

@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""单个角度箱看板:波动"幅度"与"次数"各两条曲线 + 自适应正常带。
+
+- 波动幅度:① 极差 range/span   ② 粗糙度 roughness/span(去趋势残差)
+- 波动次数:① 导数过零数 zero_cross   ② 极值数 n_extrema
+每个量用过去 `--window` 天的中位数 ± k×MAD 作正常带;越界点标红。
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import numpy as np
+
+BACKEND = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(BACKEND))
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt  # noqa: E402
+
+matplotlib.rcParams["font.sans-serif"] = ["Arial Unicode MS", "Heiti TC", "Hiragino Sans GB", "DejaVu Sans"]
+matplotlib.rcParams["axes.unicode_minus"] = False
+
+from event_bins import EVENTS, load  # noqa: E402
+
+
+def band_series(times, values, window_days, k, min_base=10):
+    center = np.full(len(values), np.nan)
+    lo = np.full(len(values), np.nan)
+    hi = np.full(len(values), np.nan)
+    out = np.zeros(len(values), dtype=bool)
+    for i in range(len(values)):
+        t0 = times[i] - timedelta(days=window_days)
+        idx = [j for j in range(i) if times[j] >= t0 and np.isfinite(values[j])]
+        if len(idx) < min_base:
+            continue
+        hist = values[idx]
+        med = float(np.median(hist))
+        mad = float(np.median(np.abs(hist - med))) * 1.4826
+        if mad <= 1e-12:
+            mad = float(np.std(hist))
+        center[i], lo[i], hi[i] = med, med - k * mad, med + k * mad
+        if np.isfinite(values[i]):
+            out[i] = values[i] > hi[i] or values[i] < lo[i]
+    return center, lo, hi, out
+
+
+def plot_panel(ax, times, values, center, lo, hi, out, ylabel, events, plot_start, plot_end):
+    ax.fill_between(times, lo, hi, color="0.85", label="正常带(中位±3MAD)")
+    ax.plot(times, center, color="0.5", lw=1, label="带中位")
+    ax.plot(times, values, "-", color="tab:blue", lw=1, alpha=0.6)
+    ax.plot(times, values, "o", ms=3, color="tab:blue")
+    if out.any():
+        ax.plot(np.asarray(times)[out], np.asarray(values)[out], "o", ms=5, color="red", label="越界")
+    for ev in events:
+        ax.axvline(datetime.fromisoformat(ev), color="k", ls="--", lw=0.7)
+    ax.set_ylabel(ylabel)
+    ax.grid(alpha=0.25)
+    ax.legend(fontsize=7)
+    if plot_start or plot_end:
+        mask = np.ones(len(times), dtype=bool)
+        if plot_start:
+            mask &= np.asarray(times) >= plot_start
+        if plot_end:
+            mask &= np.asarray(times) < plot_end
+        val = np.asarray(values)[mask]
+        band = np.r_[np.asarray(lo)[mask], np.asarray(hi)[mask]]
+        allv = np.r_[val[np.isfinite(val)], band[np.isfinite(band)]]
+        if allv.size:
+            pad = 0.1 * (np.nanmax(allv) - np.nanmin(allv) + 1e-9)
+            ax.set_ylim(np.nanmin(allv) - pad, np.nanmax(allv) + pad)
+        if plot_start and plot_end:
+            ax.set_xlim(plot_start, plot_end)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="单箱 波动幅度/次数 看板")
+    parser.add_argument("--csv", required=True)
+    parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
+    parser.add_argument("--cylinder", required=True)
+    parser.add_argument("--side", required=True)
+    parser.add_argument("--bin-start", type=float, required=True)
+    parser.add_argument("--window", type=int, default=30)
+    parser.add_argument("--k", type=float, default=3.0)
+    parser.add_argument("--plot-start", default="", help="只显示该日期之后(含);基线仍用全部历史")
+    parser.add_argument("--plot-end", default="")
+    parser.add_argument("--out", required=True)
+    args = parser.parse_args()
+
+    rows = load(args.csv)
+    sel = [r for r in rows if r["cylinder"] == args.cylinder and r["side"] == args.side
+           and abs(r["bin_start"] - args.bin_start) < 1e-6]
+    sel.sort(key=lambda r: r["time"])
+    if not sel:
+        print("没有匹配数据")
+        return
+    times = [r["time"] for r in sel]
+    span = np.asarray([r["p_span"] or np.nan for r in sel], dtype=float)
+
+    def col(name):
+        return np.asarray([r[name] if r[name] is not None else np.nan for r in sel], dtype=float)
+
+    series = [
+        (col("range") / span, "波动幅度·极差\nrange/span"),
+        (col("roughness") / span, "波动幅度·粗糙度\nroughness/span"),
+        (col("zero_cross"), "波动次数·导数过零数"),
+        (col("n_extrema"), "波动次数·极值数"),
+    ]
+    events = [e[1] for e in EVENTS if e[0] == args.unit]
+    plot_start = datetime.fromisoformat(args.plot_start) if args.plot_start else None
+    plot_end = datetime.fromisoformat(args.plot_end) if args.plot_end else None
+
+    fig, axes = plt.subplots(4, 1, figsize=(13, 10), sharex=True)
+    bands = []
+    for ax, (values, label) in zip(axes, series):
+        c, lo, hi, out = band_series(times, values, args.window, args.k)
+        bands.append((values, c, lo, hi, out))
+        plot_panel(ax, times, values, c, lo, hi, out, label, events, plot_start, plot_end)
+    fig.suptitle(f"{args.unit}号机 {args.cylinder}缸{args.side} 箱{args.bin_start:g}°"
+                 f"(基线窗口 {args.window} 天,±{args.k:g}MAD)")
+    fig.tight_layout()
+    out_path = Path(args.out)
+    out_path.parent.mkdir(parents=True, exist_ok=True)
+    fig.savefig(out_path, dpi=120)
+    plt.close(fig)
+
+    header = ["时间", "极差/span", "粗糙度/span", "导数过零数", "极值数",
+              "极差越界", "粗糙度越界", "过零越界", "极值越界"]
+    with out_path.with_suffix(".csv").open("w", encoding="utf-8-sig", newline="") as fh:
+        wr = csv.writer(fh)
+        wr.writerow(header)
+        for i, t in enumerate(times):
+            wr.writerow([t.isoformat()] + [f"{bands[j][0][i]:.4g}" for j in range(4)]
+                        + [int(bands[j][4][i]) for j in range(4)])
+    print("图:", out_path)
+    print("表:", out_path.with_suffix(".csv"))
+    for label, (values, c, lo, hi, out) in zip(
+            ["极差", "粗糙度", "过零数", "极值数"], bands):
+        print(f"  {label}: 越界 {int(out.sum())} 次")
+
+
+if __name__ == "__main__":
+    main()

+ 251 - 157
backend/indicator_features/观察记录.md

@@ -1,243 +1,337 @@
-# 功图找气阀/活塞故障 —— 观察记录
+# 功图找气阀/活塞故障 —— 观察记录(中文)
 
-> 只读分析。样本 `wave_file` + `wave_sample_one`(首周期,约 1545 点)。故障锚点:`7,8,9号压缩机故障汇总.xlsx`;异常段对齐 `cache/pks_scanner/evolution/`。
-> `backend/indicator_features/` 为**新增独立**目录,只读数据库、只 `import app.config`,不影响现有算法/程序。
+> 只读分析。样本:`wave_file` + `wave_sample_one`(每条文件一个首周期,约 1545 点)。故障锚点:`7,8,9号压缩机故障汇总.xlsx`。
+> `backend/indicator_features/` 为新增独立目录,只读数据库,不影响现有程序。数据跨度约 2025-03 ~ 2026-06。
 
 ## 1. 数据方法
 
-- 25.6 kHz、8 脉冲/转、≈996 RPM → 单周期≈60 ms;15 分钟一个文件。
-- `wave_sample_one` = 每文件**一个首周期**;`cycle_start/end` 废弃。
-- 级配:**一级=2/4/6、二级=3/5、三级=1**。
-- **同一缸的“压力盖侧 / 压力轴侧”可互换替代**(同一缸两个腔的测点)。
+- 采样 25.6 kHz、每转 8 个键相脉冲、约 996 转/分 → 单周期约 60 毫秒;每 15 分钟存一条文件。
+- `wave_sample_one` = 每条文件**一个首周期**;旧的 `cycle_start/end` 已废弃。
+- 级配:**一级=2/4/6 缸、二级=3/5 缸、三级=1 缸**。
+- 同一缸的"压力盖侧 / 压力轴侧"是同一缸两个腔,**可互换补充**。
+- 键相 0°:`second_value>=30` 的脉冲,每转 8 个,较宽的那个是 0° 标记。
 
 ## 2. 可用性(以 `wave_sample_one` 计数为准)
 
-`wave_sample`(全量)基本为空;真正存数据的是 `wave_sample_one`(每文件一个首周期)。
-`availability.py` 现在**直接统计有多少文件存在 `wave_sample_one` 数据**,并区分“有样本”与“有效周期”:
+`wave_sample`(全量)整表基本为空,真正存数据的是 `wave_sample_one`(每文件一个首周期)。`availability.py` 直接统计有多少文件存在 `wave_sample_one` 数据:
 
 ```
-9号机一缸盖侧: 文件1489, 抽样日16, 有 wave_sample_one 的日11, 其中有效周期日11
-   有样本数据段: 2026-01-30 ~ 2026-02-09
-8号机三缸盖侧: 文件521,  抽样日7,  有 wave_sample_one 的日0,  其中有效周期日0   (2025-10~12)
-7号机三缸盖侧: 文件0                                                          (2024-12~2025-02)
+9号机一缸盖侧: 文件1489, 有首周期的日11
+8号机三缸盖侧: 文件521,  有首周期的日0   (2025-10~12)
+7号机三缸盖侧: 文件0                    (2024-12~2025-02)
 ```
 
-**活塞事件前无数据的直接计数证据**:
+**活塞事件前无数据的直接证据**:
 
-| 事件 | 测点 | 窗口内文件 | 有 wave_sample_one |
+| 事件 | 测点 | 窗口内文件 | 有首周期 |
 |---|---|---:|---:|
 | 8 号机活塞 2025.12.6 | 三缸/二缸 | 521 | **0** |
 | 9 号机活塞 2026.1.18 | 一缸 | 1489 | 仅 2026-01-30 起 |
 | 7 号机活塞 2025.1.10 | 三缸/二缸 | 0 | 0 |
 | 对照:8 号机气阀 2026.3.29 | 三缸 | 3594 | 535 |
 
-结论:活塞事件前**确实没有功图周期**(不是抽样误判),气阀有、活塞没有是**数据采集期差异**。
+结论:活塞事件前**确实没有功图周期**(不是抽样误判),"气阀有、活塞没有"是**数据采集期差异**。
 
-### 其它可用段
+## 3. 特征与检测(早期完成)
 
-## 3. 特征与检测(已完成)
+- 稳健平台:用 Otsu 双峰法求**吸气压力 / 排气压力 / 压比**。
+- 相位对齐:整转内取 PV 环面积最大的偏移 → 指示功、**等熵效率(0.68~0.89,合理)**、多变指数。
+- 通道质控:**按"级"**判断坏道(压力≈0)/弱道/偏高(修正了跨级误判,如三级一缸压力天然高)。
+- `analyze.py`:同级缸间对照、同缸双腔对照(相位剖面 L2)、本缸自身基线 z。
+- `trend.py`:按天自身基线 z + **突发跳变** + 同级偏差的突发跳变。
 
-- 稳健平台:Otsu 双峰求 `P_s/P_d/pressure_ratio`。
-- 相位对齐:整转内取 PV 环面积最大的偏移 → 指示功、**等熵效率 efficiency(0.68~0.89,合理)**、`n_comp`。
-- 通道 QC:**按“级”**判断 dead/weak/high(修正了跨级误判,如三级一缸压力天然高)。
-- `analyze.py`:同级缸间、同缸双腔(相位剖面 L2)、本缸自身 z。
-- `trend.py`:按天自身基线 z + **突发跳变** + **同级偏差的突发跳变**,并对齐 evolution 段。
-
-## 4. 关键观察
+## 4. 关键观察(早期)
 
 ### 4.6 全事件慢漂移扫描(`event_scan.py`)
-对 9 个维修事件各回看 120~180 天,自动计算 peer 相对/自基线的线性漂移并排序:
+对 9 个维修事件各回看 120~180 天,自动计算同级相对/自基线的线性漂移并排序:
 
-- 多数事件**数据不足**(活塞事件、8 号机 2025/2026 事件,故障前只有几天或完全没有周期);
-- 7 号机 2026-05-21:只有**六缸盖侧**显著漂移(传感器失效,见 4.5);
-- 7 号机 2025-08-08:扫描出的“漂移”其实是 **二缸盖侧传感器异常**——其 p_discharge 在 **2025-04-30~06-11 卡在 13.3 MPa**,06-12 降到 7.5,06-13 起恢复约 1.69;
-- 其余“漂移”为数据稀疏/启停造成的假象。
+- 多数事件**数据不足**;
+- 7 号机 2026-05-21:只有**六缸盖侧**显著漂移(传感器失效);
+- 7 号机 2025-08-08:扫描出的"漂移"其实是**二缸盖侧传感器异常**——其排气压力在 **2025-04-30~06-11 卡在 13.3 MPa**,06-12 降到 7.5,06-13 起恢复到约 1.69;
+- 其余"漂移"为数据稀疏/启停造成的假象。
 
-**结论:全事件扫描仍未发现气阀/活塞的缸级故障漂移;找到的异常几乎都是传感器故障。**
+**结论:全事件扫描未发现气阀/活塞的缸级漂移;找到的异常几乎都是传感器故障。**
 
 ### 4.7 往前追溯 1~2 个有效段(`lookback_scan.py`)
-把“有功图周期”的日期聚成段(相邻间隔>10 天分段),对每个事件取之前最近的 1~2 段,比较段间 peer 相对值,并在长段(≥8 天)内做漂移。
+把"有功图周期"的日期聚成段(相邻间隔>10 天分段),对每个事件取之前最近的 1~2 段,比较段间同级相对值,并在长段(≥8 天)内做漂移。
 
-| 事件 | 追溯到的段(最近→更早) | 观察 |
+| 事件 | 追溯到的段 | 观察 |
 |---|---|---|
-| 7 号机 2025-08-08 气阀 | 2025-07-25~27、2025-06-11~13 | 两段 peer 几乎一致 → **无变化** |
-| 7 号机 2025-10-07 气阀 | 2025-07-25~27、2025-06-11~13(2025-10 无数据) | 同上,**无变化** |
-| 7 号机 2026-05-21 气阀 | 2026-03-12~04-04(22天)、2026-02-10~16 | **六缸盖侧**:段内漂移(pressure_ratio −3.4MAD)× 段间 +4.8%→−23.0% → **传感器退化,事件前两段即可见** |
-| 8 号机 2025-12-06 活塞 | 2025-03-13~19、2025-04-29~05-03 | 多缸同步变(一缸 Pd、四/六压比)→ 工况/启停;且距事件 8~9 个月 |
-| 8 号机 2026-03-10 十字头 | 同上 | 同上 |
-| 8 号机 2026-03-29 / 04-01 气阀 | 2026-03-15~20、2025-04-29~05-03 | 六缸盖侧已死(−99%,传感器);四轴压比 +24%→+79%(配置) |
+| 7 号机 2025-08-08 气阀 | 2025-07-25~27、2025-06-11~13 | 两段同级几乎一致 → **无变化** |
+| 7 号机 2025-10-07 气阀 | 同上(2025-10 无数据) | **无变化** |
+| 7 号机 2026-05-21 气阀 | 2026-03-12~04-04、2026-02-10~16 | **六缸盖侧**:段内漂移(压比 −3.4MAD)× 段间 +4.8%→−23.0% → **传感器退化,事件前两段即可见** |
+| 8 号机 2025-12-06 活塞 | 2025-03-13~19、2025-04-29~05-03 | 多缸同步变 → 工况/启停;距事件 8~9 个月 |
+| 8 号机 2026-03-29 / 04-01 气阀 | 2026-03-15~20、2025-04-29~05-03 | 六缸盖侧已死(传感器);四缸轴侧压比变化(配置) |
 | 9 号机 2026-01-18 活塞 | 2025-10-13~16、2025-09-04~09 | 二/三缸一致 → **无变化** |
-| 7 号机 2025-01-10 活塞、8 号机 2025-02-17 气阀 | 无 | 该时段**完全无 data** |
+| 7 号机 2025-01-10 活塞、8 号机 2025-02-17 气阀 | 无 | 该时段**完全无数据** |
 
-结论:往前追溯 1~2 段后,**仍未发现气阀/活塞的缸级特征**;唯一在事件前两段内可见的是 **7 号机六缸盖侧的传感器退化**。
+结论:往前追溯后**仍未发现气阀/活塞的缸级特征**;唯一在事件前两段内可见的是 **7 号机六缸盖侧的传感器退化**。
 
-### 4.5 长窗口慢趋势(`trend.py` 的 slow_trends,数月、每点每天4条)
+### 4.5 长窗口慢趋势(`trend.py`)
 | 机组 | 最主要的慢变化 | 判断 |
 |---|---|---|
-| 7 号机 | **六缸盖侧 p_discharge 从 4 月 ~1.9 骤降到 5 月后 ~0.81 并保持**(p_span 0.85→0.27);六缸**轴侧正常** | **单侧传感器失效**(不是缸故障);方法能抓到阶跃 |
-| 8 号机 | 一缸(三级)压比/指示功缓降(负荷耦合);四缸轴侧 peer 压比 +30%→+11%(配置) | 无单缸故障 |
-| 9 号机 | **全部缸** p_discharge/指示功同步 +3~4%/30天 | 负荷级整体漂移,peer 相对无单缸趋势 |
+| 7 号机 | **六缸盖侧排气压力从 4 月约 1.9 骤降到 5 月后约 0.81 并保持**(压力幅度 0.85→0.27);六缸轴侧正常 | **单侧传感器失效**;方法能抓到阶跃 |
+| 8 号机 | 一缸(三级)压比/指示功缓降(负荷耦合);四缸轴侧同级压比 +30%→+11%(配置) | 无单缸故障 |
+| 9 号机 | **全部缸**排气压力/指示功同步 +3~4%/30天 | 负荷级整体漂移 |
 
-结论:长窗口慢趋势能稳定捕捉**缓慢/阶跃变化**(已用 7 号机六缸盖侧传感器失效验证),但**这些变化都不是气阀/活塞缸故障**。
+结论:长窗口慢趋势能稳定捕捉**缓慢/阶跃变化**,但**这些变化都不是气阀/活塞缸故障**。
 
 ### 4.4 形状/波形角度(`shape_analysis.py`)
-用归一化相位剖面(PV 环面积对齐 + 平台归一化,消工况),比较“修前/修后 L2、同级 peer、双腔”:
+用归一化相位剖面(对齐 PV 环 + 平台归一化,消工况),比较"修前/修后 L2、同级、双腔":
 
 | 事件 | 修前/修后 剖面 L2 | 结论 |
 |---|---|---|
-| 8 号机 2026.3.29/4.1 | 二/四缸 ≈0.13~0.16(极稳);三级一缸 3.0~3.3 | 只有三级一缸形状变化大,但三级受负荷耦合 |
-| 7 号机 2025.8.8 | 全部 0.13~0.82 | 无单缸形状畸变 |
-| 7 号机 2026.5.21 | 全部 0.05~0.54 | 无单缸形状畸变 |
+| 8 号机 2026.3.29/4.1 | 二/四缸约 0.13~0.16(极稳);三级一缸 3.0~3.3 | 只有三级一缸变化大,但受负荷耦合 |
+| 7 号机 2025.8.8 | 全部 0.13~0.82 | 无单缸畸变 |
+| 7 号机 2026.5.21 | 全部 0.05~0.54 | 无单缸畸变 |
 
-剖面图:`cache/indicator/shape/*.png`。
-**结论**:形状角度也**没有**找到可归因于气阀/活塞的单缸畸变;唯一较大变化是三级一缸(负荷耦合)。
+**结论**:形状角度也**没有**找到可归因于气阀/活塞的单缸畸变。
 
-### 4.0 已找到的候选特征(修前/修后同级对照)
-方法:把“维修前段”与“维修后段”的**同级缸间偏差**做对比,找维修后恢复的缸。
+### 4.0 早期候选特征(修前/修后同级对照)
+把"维修前段"与"维修后段"的**同级缸间偏差**对比,找维修后恢复的缸。
 
-**8 号机 2026.4.1 气阀(更换阀片/弹簧/缓冲片)** —— 一级轴侧压比同级偏差:
+**8 号机 2026.4.1 气阀** —— 一级轴侧压比同级偏差:
 
 | 段 | 四缸轴侧 | 六缸轴侧 | 同级中位 |
 |---|---:|---:|---:|
 | 修前 2026-03-15~20 | **+34%** | −22% | 3.42 |
 | 修后 2026-04-18~05-06 | **+13%** | −16% | 3.10 |
 
-- **四缸轴侧压比(相对同级)在维修前后由 +34% 降到 +13%**,相对稳定缸(二缸)降约 16%;`efficiency` 同期 0.558→0.626(+12%)。
-- 其余一级缸变化仅 ±2~10%。这是目前**唯一**在维修前后明显变化的缸级特征。
-- **对照分析(任务 2)——结论:大概率是配置变化,不是纯故障**:
-
-  | 时期 | 四缸/二缸 压比 | 同级排序 |
-  |---|---:|---|
-  | 2025-03~05(无维修) | 0.96~0.98 | 二 > 四 > 六 |
-  | 2026-03-15~20(修前) | 1.27~1.34 | 四 > 二 > 六 |
-  | 2026-04~06(修后) | 1.12~1.17 | 四 > 二 > 六 |
-
-  四缸轴侧相对二缸在 2025→2026 之间发生**持久排序反转**(2026 全年仍偏高,维修后也回不到 2025 水平)→ 更像**年度拆装/配置变化**;维修只解释 1.34→1.12 这一部分。
-- **全事件修前/修后对照**(`repair_compare.py`):
-  - 8 号机 2026.3.29/4.1:仅四缸轴侧显著(见上);
-  - 7 号机 2025.8.8、2026.5.21 气阀:各缸 peer 相对仅 ±1~2%,**无恢复特征**;
-  - 其余活塞事件无可用的故障前段。
+- 四缸轴侧压比(相对同级)由 +34% 降到 +13%;效率同期 0.558→0.626。
+- 对照:2025-03~05 四缸/二缸压比 0.96~0.98(排序 二>四>六)→ 2026 全为 四>二>六,**排序持久反转** → 更像**年度拆装/配置变化**。
+- 其余活塞事件无可用的故障前段。
 
-### 4.1 8 号机 2025 春季(活塞事件前最后运行段)
-- 全通道正常;**二缸盖侧** `p_discharge` 比同级低约 8%、指示功低约 9%,但**二缸轴侧正常** → 倾向单侧偏置,不是活塞双腔特征。
-- 同级偏差的“突发跳变”集中在 **2025-03-17/18、05-01~03**,是**工况切换日**(多缸同步移动)。
+### 4.1 8 号机 2025 春季
+- **二缸盖侧**排气压力比同级低约 8%、指示功低约 9%,但**二缸轴侧正常** → 倾向单侧偏置。
+- 突发跳变集中在 2025-03-17/18、05-01~03,是**工况切换日**。
 
 ### 4.2 8 号机 2026 春季(3.29/4.1 气阀)
-- 3-16~20 一级 Pd 比 4/5 月低约 10 MAD,03-18 落在 `压力/轻微` 段内;
-- 但**同级缸间 + 盖/轴双腔同步移动** → 工况级变化,非单缸故障。
+- 3-16~20 一级排气压力偏低;但**同级缸间 + 盖/轴双腔同步移动** → 工况级变化,非单缸故障。
 
 ### 4.3 7 号机 2026-05 气阀前两周
 - 同级轴侧偏差稳定、无趋势(该事件为管道突发)。
 
-## 5. 结论
+## 5. 结论(早期)
 
-- 工具链完备(平台/效率/三种归一化/突发跳变/evolution 对齐/通道 QC)。
+- 工具链完备(平台/效率/三种归一化/突发跳变/通道质控)。
 - 但**活塞事件在现有功图数据里没有故障前可用段**;气阀事件多为突发,故障前无单缸趋势。
-- 观察到的较大变化都能被“同级/双腔同步”解释为**工况**,说明归一化是必需的,也说明目前**功图暂未提供可区分的故障特征**。
+- 观察到的较大变化都能被"同级/双腔同步"解释为**工况**,说明目前**功图暂未提供可区分的故障特征**。
 
-## 6. 建议
-1. 用**未来/新采集**的密集段(4/6 号机与 7/8/9 持续运行 + 有维修记录)做验证;
-2. 稳定 `n_comp`(限制拟合区间、最小样本);
-3. 将“同级偏差 + 双腔差”的长期基线与 evolution 段联合,做多证据互验与偏离报警。
+## 6. 建议(早期)
+1. 用**未来/新采集**的密集段做验证;
+2. 稳定多变指数(限制拟合区间、最小样本);
+3. 将"同级偏差 + 双腔差"的长期基线与异常段联合,做多证据互验。
 
 ---
 
-# 附录:方法总览与总结
-
-## A. 名词解释
+# 附录 A. 名词解释
 
 | 名词 | 含义 |
 |---|---|
-| 功图(示功图) | 气缸一个压缩循环内的“压力–曲柄转角 / 压力–容积”曲线,反映吸气、压缩、排气、膨胀四过程 |
-| `wave_file` | 高频波形文件记录,每测点约 15 分钟一条;字段含 `point_name/device_part/measurement_type/sample_time/rpm` 等 |
-| `wave_sample_one` | 每条 `wave_file` 对应的**一个首周期**采样点:`sample_index, signal_value(压力), second_value(键相触发)`;约 1540 点 |
-| `wave_sample` | 全量采样表,本项目**基本为空**,实际用的是 `wave_sample_one` |
-| 键相/触发 | `second_value>=30` 的脉冲;8 个脉冲/转,较宽的那个是 0° 标记 → 一周期≈60 ms(≈996 RPM)、25.6 kHz |
-| 缸 / 级 | 该机 6 缸按压缩级串联:**一级=2/4/6、二级=3/5、三级=1**,压力逐级升高 |
-| 盖侧 / 轴侧 | 同一缸两个腔的测点(压力盖侧、压力轴侧),**可互换互补** |
-| `P_s` / `P_d` | 吸气平台压力 / 排气平台压力 |
-| `pressure_ratio` | 压比 = `P_d / P_s`(单腔的压缩能力) |
-| 指示功 | 一圈内 `∮P dV`(算在气体上的净功) |
-| 等熵效率 `efficiency` | 理想等熵功 / 实际指示功,<1 合理,实测 0.68~0.89 |
-| `n_comp` | 压缩线多变指数(拟合 logP–logV 斜率),不稳定 |
-| peer 相对 | 同一“级 + 侧”内,各缸相对其它缸中位数的偏差(消工况) |
-| 双腔差 | 同一缸“盖侧 − 轴侧”的差(活塞/窜气类故障会两侧不对称) |
-| 自身基线 z | 本测点相对自己近期中位数/MAD 的稳健 z(看时间趋势) |
-| MAD | 中位数绝对偏差,稳健尺度 |
-| dead / weak / high | 通道 QC 标记:压力≈0(坏)/ 明显偏低 / 明显偏高 |
-
-## B. 观察对象(故障点)
-
-故障锚点来自 `7,8,9号压缩机故障汇总.xlsx`,逐事件查功图:
+| 功图(示功图) | 气缸一个压缩循环内的"压力–曲柄转角 / 压力–容积"曲线,反映吸气、压缩、排气、膨胀四过程 |
+| `wave_file` | 高频波形文件记录,每测点约 15 分钟一条 |
+| `wave_sample_one` | 每条文件对应的**一个首周期**采样点(约 1545 点) |
+| 键相/触发 | `second_value>=30` 的脉冲;8 个脉冲/转,较宽的是 0° 标记 |
+| 缸 / 级 | **一级=2/4/6、二级=3/5、三级=1** |
+| 盖侧 / 轴侧 | 同一缸两个腔的测点 |
+| 吸气压力 / 排气压力 | 一个周期内低压平台 / 高压平台压力 |
+| 压比 | 排气压力 / 吸气压力 |
+| 指示功 | 一圈内 PV 环面积 |
+| 等熵效率 | 理想等熵功 / 实际指示功,实测 0.68~0.89 |
+| 同级 | 同一"级 + 侧"内的各缸 |
+| 双腔差 | 同一缸"盖侧 − 轴侧" |
+| 自身基线 z | 本测点相对自己近期中位数/MAD 的稳健 z |
+| MAD | 中位数绝对偏差(稳健尺度) |
+| 坏道/弱道/偏高 | 通道质控标记:压力≈0 / 明显偏低 / 明显偏高 |
+
+# 附录 B. 观察对象(故障点)
 
 | 机组 | 日期 | 类型 | 故障前是否有功图 |
 |---|---|---|---|
-| 7 号机 | 2025-01-10 | 活塞(一级3#缸,笔误) | ❌ 无 |
-| 7 号机 | 2025-08-08 | 气阀(污染) | ✅ 2025-07-25~27 |
-| 7 号机 | 2025-10-07 | 气阀(一级排气阀阀片) | ❌ 2025-10 无;已追溯 07-25~27、06-11~13 两段,无变化 |
-| 7 号机 | 2026-05-21 | 气阀(进气阀组油压管) | ✅ 2026-05-07~21(连续) |
-| 8 号机 | 2025-02-17 | 气阀(阀片) | ❌ 数据 2025-03 才开始 |
-| 8 号机 | 2025-12-06 | 活塞(开裂) | ❌ 故障前无 |
-| 8 号机 | 2026-03-10 | 十字头 | ❌ 故障前无 |
-| 8 号机 | 2026-03-29 | 气阀(一级进气滤网) | ⚠ 仅 2026-03-16~20 |
-| 8 号机 | 2026-04-01 | 气阀(阀片/弹簧/缓冲片) | ⚠ 仅 2026-03-16~20 |
-| 9 号机 | 2026-01-18 | 活塞(三级活塞杆断裂) | ❌ 数据 2026-01-30 才起 |
+| 7 号机 | 2025-01-10 | 活塞 | 无 |
+| 7 号机 | 2025-08-08 | 气阀(污染) | 有(2025-07-25~27) |
+| 7 号机 | 2025-10-07 | 气阀(一级排气阀阀片) | 2025-10 无 |
+| 7 号机 | 2026-05-21 | 气阀(进气阀组油压管) | 有(2026-05-07~21 连续) |
+| 8 号机 | 2025-02-17 | 气阀(阀片) | 无(数据 2025-03 才开始) |
+| 8 号机 | 2025-12-06 | 活塞(开裂) | 无 |
+| 8 号机 | 2026-03-10 | 十字头 | 无 |
+| 8 号机 | 2026-03-29 | 气阀(一级进气滤网) | 有(2026-03-15~20) |
+| 8 号机 | 2026-04-01 | 气阀(阀片/弹簧/缓冲片) | 有(2026-03-15~20) |
+| 9 号机 | 2026-01-18 | 活塞(三级活塞杆断裂) | 无(数据 2026-01-30 才起) |
+
+# 附录 C. 为什么活塞事件"数据不足"
+
+- **7 号机 2025.1.10 活塞**:窗口 2024-12~2025-02 内 `wave_file` 0 条。
+- **8 号机 2025.12.6 活塞**:窗口内有 `wave_file` 521 条,但 `wave_sample_one` 0 条(有功图文件但未生成首周期)。
+- **9 号机 2026.1.18 活塞**:`wave_sample_one` 从 2026-01-30 才开始(事件后 12 天)。
+
+---
+
+# 7. 每 10° 分箱局部统计
+
+> 思路:整周期平均值会互相抵消,故障可能只体现在某个小角度区间。把每周期按键相 0-360° 切成 36 个 10° 箱,箱内直接在**原始点**上算指标(不重采样),再做**同一点**故障前后对比。
+
+## 7.1 方法(`run_segments.py` → `angle_bins.py` → `event_bins.py` → `bin_plot.py`)
+
+- `wave_sample_one` 每文件约 1545 点、0°=键相宽脉冲;按 `角度=360×序号/点数` 落箱(10° 箱约 43 个原始点;`--bin-deg` 可选 5/15/20)。
+- 每箱指标见 §8.1 与 `cn_names.py`;只用**转速>0** 的周期。
+- **运行段**(`run_segments.py`):以 `wave_file.rpm>0` 的日期为开机日(官网开机表不准,弃用),相邻开机日(允许桥接 2 天)合成运行段。
+- **前段**:事件前最近的运行段,不足 15 运行日就继续往**更早的运行段**累加;**后段**:事件后第一个运行段(允许 30 天内桥接),不跨大停机;**趋势**:事件前累计 ≥30 运行日。
+- 对比**只限同一点**(同缸同侧)。每个指标分成:**整体**(全箱变化的中位数,全箱一起动=负荷/传感器)与**局部**(各箱变化−整体,个别箱动=阀/局部故障);波动类与水平类分开。
+- 质控:**压力幅度<0.3** 的坏道/卡死自动跳过;箱尺度下限=该指标各箱尺度中位数的 20%。
+
+## 7.2 数据覆盖(转速>0 开机天数)
+
+```
+7号机: 65 天 / 8 个运行段
+8号机: 58 天 / 6 个运行段
+9号机: 84 天 / 11 个运行段
+```
+
+## 7.3 结果
+
+- **7 号机 2026-05-21 气阀(进气阀组油压管崩开)** — 唯一干净候选:
+  ```
+  前段 2026-05-06~05-20(15运行日)  后段 2026-05-21~06-06(9运行日)
+  #  测点      角度箱     类型     指标     前       后       变化     相对
+  1  二缸盖侧  140-150°  局部波动  标准差   0.0227   0.0333   +6.0M   +50%
+  2  二缸盖侧  140-150°  局部均值  斜率    -0.00775 -0.0112   -5.6M   -342%
+  3  二缸盖侧  140-150°  局部波动  极差     0.0718   0.0923   +3.9M   +30%
+  ```
+  同箱 3 个指标一致(波动/幅度增大、斜率变陡),均值剖面几乎重合。
+- 其余事件受**跨年/跨季工况**、**传感器卡死**、或**故障缸无前段**干扰。
+
+## 7.4 结论与下一步
+
+- **唯一可复现的"局部角度箱"候选**:7 号机二缸盖侧(一级)140-150°。
+- 下一步:按本周期压力幅度归一再比、只保留同月对比。
 
-涉及的测点:7/8/9 号机 × 6 缸 × {盖侧, 轴侧}(共 35~36 个压力测点)。
+---
+
+# 8. 特征扩展与全时间轴扫描
+
+对照第 7 节,本节把特征从 6 个扩到"分箱 18 + 整周期 13",并新增全时间轴无监督扫描。
 
-## C. 用过的方法、原理与结果
+## 8.1 特征集(`bins_common.py` 统一注册)
 
-1. **稳健平台检测(Otsu 双峰)**
-   - 原理:对一周期压力做直方图,用 Otsu 找双峰分界,分别取低压/高压簇中位数作 `P_s/P_d`。
-   - 结果:同段内稳定,是后续所有特征的基础;比“分位数”更抗噪。
+- **分箱 18 个**:均值/极差/标准差/中位绝对偏差/斜率/粗糙度 + 10/50/90 分位/四分位距 + 偏度/峰度 + 导数族(平均|导数|、最大|导数|、导数RMS、二阶导RMS、导数过零数、极值数)。
+- **整周期 13 个**:吸气压力/排气压力/压比、吸气占比/排气占比、最小压力角/最大压力角、上升穿越角/下降穿越角、最大上升斜率/最大下降斜率、最大上升角/最大下降角。
+- 角度型指标按 0-360° **环形**处理(差值取最短弧,尺度下限 1°),避免跨 0° 假跳变。
 
-2. **物理相位对齐 + 指示功/等熵效率**
-   - 原理:用曲柄连杆几何把转角换算成容积 `V(θ)`;在整转内搜索使 PV 环面积(指示功)最大的曲柄偏移,再用等熵理想功算效率。
-   - 结果:`efficiency` 合理(0.68~0.89);`n_comp` 不稳定(部分为 0/负),不宜作主特征。
+## 8.2 事件对比结果(`event_bins.py`)
 
-3. **通道 QC(按级)**
-   - 原理:以“同级的期望排气压力”为基准,把明显偏离的测点标记为 dead/weak/high 并排除。
-   - 结果:发现 **8 号机六缸盖侧 dead、7 号机四缸盖侧 dead、7 号机六缸盖侧 weak** 等;修正了“三级一缸天然高压被误判 high”的跨级错误。
+- **7 号机 2026-05-21 仍是最干净候选**,新指标佐证:二缸盖侧 140-150° 同箱 `四分位距+46% / 标准差+50% / 斜率-342% / 导数RMS+31% / 极差+30%`。
+- 8 号 2026-03-29:一缸(三级)40-50°、220-230° 的斜率/导数族变化最集中。
+- 9 号 2026-01-18:三缸(二级)多处变化;故障缸一缸无前段。
+- 偏度/峰度/导数过零数集中在**四缸轴侧 70-80°**(已知配置/传感器问题)。
 
-4. **三种归一化对照(`analyze.py`)**
-   - 同级(peer)/ 双腔(chamber)/ 自身(self),用于消工况。
-   - 结果:证明 8 号机 2026-03 的大偏离是**工况级**(同级与双腔同步移动);四缸轴侧为**单侧配置变化**。
+## 8.3 全时间轴无监督扫描(`scan_bins.py`)
 
-5. **修前 / 修后同级对照(`repair_compare.py`)**
-   - 原理:对比维修前段与维修后段的 peer 相对值,找“维修后恢复”的缸。
-   - 结果:8 号机 2026.3.29/4.1 仅四缸轴侧显著(+30.9%→+13.4%);7 号机两个气阀事件无恢复特征;活塞事件无故障前段。
+对每个(测点、箱/整周期、指标)在整条时间轴上找最佳二分段,再减去整点中位数 → 局部分数。
 
-6. **波形形状 / 归一化相位剖面(`shape_analysis.py`)**
-   - 原理:PV 环对齐 + 平台归一化,得到与工况无关的 P–θ 形状;比较修前/修后、同级、双腔的 L2 距离。
-   - 结果:7 号机两气阀、8 号机一级二/四缸形状**极稳(L2≈0.05~0.8)**;唯一大变化是三级一缸(负荷耦合)。**无单缸形状畸变**。
+- **7 号**:榜首是**二缸盖侧传感器卡死**(2025-06-12 从 13.3 恢复)与**四缸盖侧坏道**(2026-02)。
+- **8 号**:榜首是**六缸盖侧坏道**(2026-03-15 起压力→0.015)。
+- **9 号**:阈值 5 无候选,阈值 4 仅 2 条弱候选。
+- 价值:扫描器**无需事件、无需前段**即可自动定位已知传感器故障 → 方法有效;但仍**未自动浮现阀/活塞特征**。
 
-7. **突发跳变检测(`trend.py`)**
-   - 原理:自身基线与 peer 相对的“按天跳变”,阈值取 MAD 的若干倍。
-   - 结果:跳变集中在 8 号机 2025-03-17/18、05-01~03 等**工况切换日**,多缸同步,非单缸故障。
+## 8.4 结论与下一步
 
-8. **长窗口慢趋势(`trend.py` slow_trends)**
-   - 原理:数月、每点每天多条,按天中位数做线性回归,找缓慢漂移。
-   - 结果:7 号机六缸盖侧 p_discharge 从 ~1.9 骤降到 ~0.81(**单侧传感器失效**);9 号机全缸同步漂移(**负荷**);8 号机一缸(负荷)/四轴(配置)。
+- 特征维度已扩展(分箱 6→18、整周期 0→13)。
+- 仍只有 7 号 2026-05-21 一个可复现的局部压力候选;扫描器自动找到的都是传感器故障。
+- (曾尝试压力×振动联合:日度相关以负荷共动为主,压力候选与同缸十字头振动无同步,**振动未产出特征,已删除相关代码与数据**。)
 
-9. **全事件慢漂移自动扫描(`event_scan.py`)**
-   - 原理:对每个维修事件回看 120~180 天,计算 peer 相对/自基线的漂移量并用 MAD 归一化、按强度排序。
-   - 结果:多数事件**数据不足**;7 号机 2026-05-21 只有六缸盖侧(传感器);7 号机 2025-08-08 的“漂移”实为 **二缸盖侧传感器卡死**(2025-04-30~06-11 卡在 13.3 MPa,后恢复 1.69)。**未见气阀/活塞缸级漂移。**
+---
+
+# 9. 压力幅度归一
 
-## D. 为什么活塞事件“数据不足”
+## 9.1 压力幅度归一(`--normalize span`)
 
-按 `wave_sample_one` **直接计数**(不是抽样)确认:
+- 原理:压力幅度类指标(均值/标准差/极差/四分位距/导数族、吸气压力/排气压力/最大上升/最大下降斜率等)除以**本周期压力幅度**,消掉负荷/压比的整体变化。
+- 结果:**7 号 2026-05-21 二缸盖侧 140-150° 候选在归一后依然存在**(标准差+51% / 四分位距+45% / 斜率-366% / 导数RMS+31%)→ 是真实**波形形状**变化,不是负荷。
+- 归一后**浮现一类新变化:阀时序角度**——多个缸的上升穿越角/下降穿越角/最小压力角/最大压力角同时移动 3~9°。多缸同步 → 更像**全机工况/相位参考**变化,不是单缸故障。
+- 注意:压力幅度归一**不能**自动剔除传感器道(卡死道的幅度不一定为 0,坏道的无量纲量也仍会参与)。
 
-- **7 号机 2025.1.10 活塞**:窗口 2024-12~2025-02 内 `wave_file` **0 条** → 完全没有采集。
-- **8 号机 2025.12.6 活塞**:窗口 2025-10~12 内 `wave_file` 521 条,但 `wave_sample_one` **0 条** → 有功图文件但**未生成首周期**(机器停机 / 采集处理缺口)。其后一直无数据到 2026-03。
-- **9 号机 2026.1.18 活塞**:`wave_file` 与 `wave_sample_one` 都**从 2026-01-30 才开始**(事件后 12 天)→ 故障前无。
+## 9.2 结论
 
-根因:`wave_sample`(全量)整表基本为空,真正存数据的是 `wave_sample_one`;而这三段时间 `wave_sample_one` 要么为空、要么在故障之后才开始。对比气阀事件(如 8 号机 2026-03 有 535 个文件),所以出现“气阀有、活塞没有”的差异,属**采集期差异**,不是分析漏看。
+- 压力幅度归一验证了 7 号二缸盖侧 140-150° 是**形状变化而非工况**。
+- 曾试压力×振动联合,振动未产出可用特征(相关以负荷共动为主、与候选不同步),相关代码与数据已删除。
 
-## E. 总体结论
+---
 
-- 工具链已完备:稳健平台、相位对齐/等熵效率、按级通道 QC、同级/双腔/自身三种归一化、修前修后对照、波形形状、突发跳变、长窗口慢趋势、全事件扫描。
-- 方法对**缓慢/阶跃变化是敏感的**:稳定抓出了两处**传感器故障**(7 号机六缸盖侧骤降、二缸盖侧卡死 13.3 MPa)以及一处**年度配置变化**(8 号机四缸轴侧排序反转)。
-- 但在**现有功图数据**上,**没有找到可归因于气阀/活塞、且只在单缸出现的故障特征**;已有较大变化均被解释为工况、配置或传感器问题。
-- 主要制约:活塞事件故障前无 `wave_sample_one`;气阀事件多为突发或数据有长缺口。
-- 补充:对“故障前缺失”的事件已**往前追溯 1~2 个有效段**(`lookback_scan.py`,见 4.7);追溯后仍无功图故障特征,唯一在事件前两段内可见的是 7 号机六缸盖侧的传感器退化。
+# 10. 两个追问:9 号活塞看全缸 + 7 号事件前的长期演化
 
+`metric_trace.py`:把任意(测点、角度箱、指标)的**日度中位数**沿全时间轴画出,按数据间隔分段、逐段拟合趋势。
+
+## 10.1 9 号机 2026-01-18 活塞——故障缸无前段,但其它缸有特征
+
+一缸(三级)功图 2026-01-30 才起,确无前段;但**二缸(一级)、三缸(二级)有变化**(前段 2025-06~10,后段 2026-01-30~03-13,已归一):
+
+| 测点 | 集中角度箱 | 指标变化 |
+|---|---|---|
+| **三缸轴侧(二级)** | 230-240° / 240-250° / 260-270° | 斜率正负翻转,+15~18MAD |
+| **三缸盖侧(二级)** | 60-70° / 100-110° / 340-350° | 标准差/中位绝对偏差/粗糙度/四分位距/极差普遍增大 +7~15MAD |
+| 二缸盖侧(一级) | 350-360° | 标准差+114% / 四分位距+112% |
+| 二缸轴侧(一级) | 0-10° | 峰度+54% |
+
+整周期上,三缸最大压力角/上升穿越角移动 +4~5°、吸气压力 −8%、压比 +5%。
+**评价**:三缸(二级)表现为同一小角度带的波动/幅度增大,符合"上游级受三级活塞故障牵连"的形态;但前后段**跨 3~4 个月且跨年**,不能证明是事件前渐变,仍需同月基线。
+
+## 10.2 7 号机 2026-05-21 气阀——事件前的长期演化
+
+- **二缸盖侧 140-150° 标准差(候选)**:2026-02 约 0.033 → **2026-03 中位 0.030 → 2026-05 事件前中位约 0.023,约 −25% 缓慢下降**;修复后跳回约 0.033。即**事件前有小幅、非单调的长期下降,修复后恢复**。
+  - 2025-04~06 的约 0.002 低值是**二缸盖侧传感器卡死**造成的假象。
+- **整机吸气压力**:事后核对——二/三/五缸盖侧"事件前段 vs 修复后"中位数只差约 1%(如二缸盖侧 0.667→0.649、三缸 1.806→1.764),**差异可忽略**。此前依据归一趋势表说的"全机吸气压力回落"**不成立,已撤回**。
+
+**结论**:① 候选特征(二缸盖侧 140-150° 波动)在事件前约 2.5 个月内有**缓慢下降再修复后回升**,这是该事件唯一清楚的长期线索;② 吸气压力差异可忽略(已撤回);③ 2025 年因传感器与长断档不可用。
+
+## 10.3 结论
+
+- 9 号活塞:**故障缸无前段不妨碍从别的缸找特征**——三缸(二级)是主要候选,但需同月基线去伪。
+- 7 号 2026-05-21:事件前确有长期演化——**二缸盖侧 140-150° 波动缓慢下降约 −25%,修复后回升**(吸气压力差异可忽略)。
+
+---
+
+# 11. 形状特征扩展 + 7/8/9 全事件扫描汇总
+
+## 11.1 新增形状/复杂度特征(分箱 18 → 23)
+
+再增加 5 个箱内形状指标:二阶导最大(拐角尖锐度)、总变差(Σ|相邻差|,弧长/毛糙)、过均次数、移动度、复杂度。全部纳入压力幅度归一。
+
+## 11.2 全事件汇总(`summary_features.py`)
+
+把 `event_bins.py` 明细按**事件×测点×角度箱**跨指标合并、按总 |MAD| 排序,输出 `summary_final.csv`(全量 891 组)。头部(阈值 3MAD):
+
+| 事件 | 测点·角度箱 | 指标数 | 主要指标 |
+|---|---|---|---|
+| 9号 01-18 活塞 | 三缸盖侧 60-70° | 7 | 移动度↓14.9, 中位绝对偏差↑5.1, 过均次数↓5.0 |
+| 8号 12-06 活塞 | 二缸轴侧 110-120° | 10 | 标准差↑11.8, 斜率↑11.0, 极差↑9.7 |
+| 8号 03-29/04-01 气阀 | 一缸轴侧 220-230° | 16 | 导数RMS↑23.1, 最大|导数|↑19.0, 斜率↑18.7, 移动度↑16.7 |
+| 8号 03-29/04-01 气阀 | 一缸盖侧 40-50° | 14 | 最大|导数|↑21.0, 移动度↑17.3, 粗糙度↑13.5 |
+| 7号 05-21 气阀 | 二缸盖侧 140-150° | 9 | 标准差↑6.2, 四分位距↑6.0, 斜率↓5.6, 导数RMS↑3.9 |
+
+## 11.3 关键判读:看"前后段的时间距离/前段跨度"
+
+汇总含 `相隔天数`(后段起点 − 前段终点)与 `前跨度天数`(前段首末);用 `--max-pre-span` 过滤"前段回溯到一年前"的伪比较,用 `--pre-days 6` 让前段只取事件前最近的运行段。**干净短名单(前跨度≤20 天 且 相隔≤40 天)**:
+
+| 事件 | 测点·角度箱 | 指标数 | 主要指标 |
+|---|---|---|---|
+| **8号 03-29/04-01 气阀** | **一缸轴侧 220-230°** | 16 | 导数RMS↑23.1, 最大|导数|↑19.0, 斜率↑18.7, 移动度↑16.7, 粗糙度↑13.5 |
+| 8号 03-29/04-01 | 一缸轴侧 70-80° | 15 | 斜率↓27.0, 移动度↑17.2, 四分位距↓16.1 |
+| 8号 03-29/04-01 | 一缸盖侧 40-50° | 14 | 最大|导数|↑21.0, 移动度↑17.3, 粗糙度↑13.5, 斜率↑12.4 |
+| 8号 03-29/04-01 | 一缸轴侧 整周期 | 9 | 吸气压力↑24.4, 最大下降斜率↓23.7, 最大上升斜率↑21.1, 压比↓19.3 |
+| **7号 05-21 气阀** | **二缸盖侧 140-150°** | 9 | 标准差↑6.2, 四分位距↑6.0, 斜率↓5.6, 导数RMS↑3.9 |
+| 7号 05-21 | 二缸轴侧 320-330° | 8 | 峰度↑10.0, 偏度↓4.2, 移动度↑3.9 |
+| 7号 05-21 | 四缸轴侧 70-80° | 5 | 偏度↑11.6, 峰度↑5.0, 导数过零数↑4.0 |
+
+- **8 号 2026-03-29/04-01**:一缸(三级)轴侧 220-230° 波形形状大幅改变(三联图左图可见停机前在约 220° 有个深坑、开机后消失),一整套导数指标同时变;**但**该段前后有 03-10 十字头、03-29 进气滤网、04-01 阀片**多次维修**,且前段只有 5 天(03-15~20,恰逢已知工况切换日),**不能唯一归因**。
+- **8 号 230-270° 段(阈值 2MAD 后可见)**:一缸轴侧 **250-260°** 局部 12 个指标(最大|导数|↓6.3、二阶导RMS↓4.4、极差↓4.4、标准差别前 0.289→后 0.156);**230-240°** 12 个指标(中位绝对偏差↑34)。对应图 `plots/8号_2026-03-29_一缸轴侧_230-270_波动.png`(中图橙带内红线明显比蓝线高)。
+- **7 号 2026-05-21**:仍是**唯一只有一次检修、前段完整(15 天)**的干净对比,候选二缸盖侧 140-150° 稳。
+- **9 号 2026-01-18**:前段只能回到 2025-06~10(跨度大),三缸盖侧 60-70° 是跨事件台阶,需同月基线。
+
+## 11.4 结论
+
+- 特征已补齐(23 个分箱 + 13 个整周期),有中文交付 `cache/indicator/bins/results_cn/`。
+- **干净(时间近)且指标一致的候选**:7 号 2026-05-21 二缸盖侧 140-150°、8 号 2026-03-29/04-01 一缸(三级)轴侧 220-230°(后者受多次维修干扰)。
+- **跨期强候选**(需同月基线):9 号三缸(二级)60-110° 多个箱。
+- **阈值说明**:最终 `event_bins --z 2`、`summary_features --threshold 2`,即 ≥2MAD 的局部/整体变化都列出;汇总含"范围"列区分 **局部(单角度箱)** 与 **整体(全角度)**——整体变化多为负荷/工况。
+- 工具:`summary_features.py`(汇总+相隔/前跨度过滤)、`pair_plot.py`(停机前/开机后三联简图)、`make_report.py`(生成中文交付)。

File diff suppressed because it is too large
+ 0 - 304
backend/scan_pks_clean_evolution.py


+ 0 - 296
backend/scan_pks_fault_candidates.py

@@ -1,296 +0,0 @@
-#!/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
-MIN_HISTORY_HOURS = 24
-MAX_SCORE = 12.0
-
-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",
-]
-
-HOURLY_HEADERS = {
-    "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": "回流调节阀位均值",
-}
-
-CANDIDATE_FIELDS = [
-    "candidate_id", "unit", "batch", "fault_hint", "start_time", "end_time", "peak_time",
-    "score", "confidence", "history_valid_hours", "candidate_duration_hours", "repeat_count",
-    "coverage", "running_samples", "reason",
-]
-
-CANDIDATE_HEADERS = {
-    "candidate_id": "候选段标识", "unit": "机组", "batch": "批次", "fault_hint": "异常方向",
-    "start_time": "开始时间", "end_time": "结束时间", "peak_time": "峰值时间",
-    "score": "异常分数(0-12)", "confidence": "可信度", "history_valid_hours": "历史有效运行小时数",
-    "candidate_duration_hours": "候选持续小时数", "repeat_count": "异常重复次数",
-    "coverage": "最低运行覆盖率", "running_samples": "峰值小时运行样本数", "reason": "触发原因",
-}
-
-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, history_hours):
-    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(min(max(score, 0.0), MAX_SCORE), 3),
-        "confidence": "高" if history_hours >= 336 else "中" if history_hours >= 168 else "低",
-        "history_valid_hours": history_hours, "candidate_duration_hours": 1,
-        "repeat_count": 1, "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) < MIN_HISTORY_HOURS:
-                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}", len(prior))
-
-            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}", len(prior))
-                elif avg_score >= 3:
-                    add_reason(candidates, row, "气阀相关振动候选", avg_score,
-                               f"{side}平均振动{row[avg_key]:.3f},高于近期基线{avg_score:.1f}倍稳健尺度", len(prior))
-
-    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"]
-                merged[-1]["candidate_duration_hours"] = max(
-                    1, int((datetime.strptime(merged[-1]["end_time"], "%Y-%m-%d %H:%M:%S") -
-                            datetime.strptime(merged[-1]["start_time"], "%Y-%m-%d %H:%M:%S")).total_seconds() / 3600) + 1
-                )
-                merged[-1]["repeat_count"] += item["repeat_count"]
-                merged[-1]["coverage"] = min(merged[-1]["coverage"], item["coverage"])
-                merged[-1]["running_samples"] = max(merged[-1]["running_samples"], item["running_samples"])
-                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 sorted(merged, key=lambda r: (r["batch"], r["start_time"], r["fault_hint"]))
-
-
-def write_csv(path: Path, rows, fields, headers=None):
-    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.writerow(headers or {field: field for field in fields})
-        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, HOURLY_HEADERS)
-    write_csv(out / "fault_candidates.csv", candidates, CANDIDATE_FIELDS, CANDIDATE_HEADERS)
-    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()

+ 0 - 315
backend/scan_pks_pressure_v2.py

@@ -1,315 +0,0 @@
-#!/usr/bin/env python3
-"""Second PKS scanner focused on pressure usefulness.
-
-This is intentionally a new scanner.  It does not modify the first scanner or
-the database.  Pressure alarm limits come from the site_point configuration
-confirmed for YSJ_5..YSJ_9; the output keeps both absolute alarm evidence and
-relative, recent-baseline evidence so they can be reviewed separately.
-"""
-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号机"}
-EXPECTED_PER_HOUR = 720
-MIN_COVERAGE = 0.80
-MIN_HISTORY_HOURS = 24
-
-# AlarmLimit1..4 in site_point.  The same engineering limits are configured
-# for all three units, while the measured values remain unit-specific.
-PRESSURE_LIMITS = {
-    "oil": {"field": "oil", "name": "润滑油压力", "low": 0.34, "low_low": 0.31, "high": 0.68, "high_high": 0.72},
-    "inlet": {"field": "inlet", "name": "进气压力", "low": 0.45, "low_low": 0.40, "high": 0.95, "high_high": 1.00},
-    "p1": {"field": "p1", "name": "一级排气压力", "high": 2.60, "high_high": 2.75},
-    "p2": {"field": "p2", "name": "二级排气压力", "high": 4.90, "high_high": 5.00},
-    "p3": {"field": "p3", "name": "三级排气压力", "high": 6.20, "high_high": 6.30},
-}
-
-PRESSURE_KEYS = ("oil", "inlet", "p1", "p2", "p3")
-EXHAUST_KEYS = ("p1", "p2", "p3")
-
-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_6 END) AS inlet_avg,
-    MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_6 END) AS inlet_min,
-    MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_6 END) AS inlet_max,
-    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_avg,
-    MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_min,
-    MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_max,
-    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_avg,
-    MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_min,
-    MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_max,
-    AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_avg,
-    MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_min,
-    MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_max,
-    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,
-    SUM(YSJ_41 > 0 AND YSJ_5 <= 0.34) AS oil_low_count,
-    SUM(YSJ_41 > 0 AND YSJ_5 <= 0.31) AS oil_low_low_count,
-    SUM(YSJ_41 > 0 AND YSJ_5 >= 0.68) AS oil_high_count,
-    SUM(YSJ_41 > 0 AND YSJ_6 <= 0.45) AS inlet_low_count,
-    SUM(YSJ_41 > 0 AND YSJ_6 >= 0.95) AS inlet_high_count,
-    SUM(YSJ_41 > 0 AND YSJ_7 >= 2.60) AS p1_high_count,
-    SUM(YSJ_41 > 0 AND YSJ_7 >= 2.75) AS p1_high_high_count,
-    SUM(YSJ_41 > 0 AND YSJ_8 >= 4.90) AS p2_high_count,
-    SUM(YSJ_41 > 0 AND YSJ_8 >= 5.00) AS p2_high_high_count,
-    SUM(YSJ_41 > 0 AND YSJ_9 >= 6.20) AS p3_high_count,
-    SUM(YSJ_41 > 0 AND YSJ_9 >= 6.30) AS p3_high_high_count,
-    SUM(YSJ_41 > 0 AND YSJ_10 >= 7.1) AS coupling_alarm_count,
-    SUM(YSJ_41 > 0 AND YSJ_11 >= 7.1) AS chain_alarm_count
-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):
-    while start < end:
-        nxt = min(start + timedelta(days=days), end)
-        yield start, nxt
-        start = nxt
-
-
-def number(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, start, end, chunk_days):
-    rows = []
-    connection = connect()
-    try:
-        with connection.cursor() as cursor:
-            for index, (lo, hi) in enumerate(chunks(start, end, chunk_days), 1):
-                cursor.execute("EXPLAIN " + SQL, (batch, lo, hi))
-                plan = cursor.fetchone()
-                if not plan or plan.get("key") not in ("PRIMARY",):
-                    raise RuntimeError(f"安全检查失败:batch={batch} chunk={lo} EXPLAIN 未使用 PRIMARY: {plan}")
-                cursor.execute(SQL, (batch, lo, hi))
-                for raw in cursor.fetchall():
-                    row = {"batch": batch, "unit": BATCH_TO_UNIT[batch],
-                           "hour": raw["hour_start"].strftime("%Y-%m-%d %H:%M:%S"),
-                           "samples": int(raw["samples"] or 0),
-                           "running_samples": int(raw["running_samples"] or 0)}
-                    row["coverage"] = row["running_samples"] / EXPECTED_PER_HOUR
-                    for key, value in raw.items():
-                        if key not in ("hour_start", "samples", "running_samples"):
-                            row[key] = number(value)
-                    rows.append(row)
-                print(f"batch={batch} chunk={index} {lo}..{hi}: {len(rows):,} total hourly rows")
-    finally:
-        connection.close()
-    return rows
-
-
-def 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)
-    return center, max(1.4826 * mad, abs(center) * 0.01, 1e-6)
-
-
-def linear_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)
-    den = sum((x - xbar) ** 2 for x, _ in pairs)
-    return None if not den else sum((x - xbar) * (y - ybar) for x, y in pairs) / den
-
-
-def add_candidate(out, row, kind, score, reason, history):
-    out.append({"candidate_id": f"{row['unit']}-{kind}-{row['hour']}", "unit": row["unit"],
-                "batch": row["batch"], "anomaly_type": kind, "start_time": row["hour"],
-                "end_time": row["hour"], "peak_time": row["hour"],
-                "score": round(min(max(score, 0), 12), 3),
-                "history_valid_hours": history, "coverage": round(row["coverage"], 3),
-                "running_samples": row["running_samples"], "repeat_count": 1,
-                "candidate_duration_hours": 1, "reason": reason})
-
-
-def pressure_context(row, prior):
-    """Describe exhaust-pressure evidence without creating pressure-only events."""
-    changes = []
-    for key in EXHAUST_KEYS:
-        current = row.get(f"{key}_avg")
-        center, scale = baseline([x.get(f"{key}_avg") for x in prior])
-        if current is None or center is None:
-            continue
-        z = (current - center) / scale
-        if abs(z) >= 2.5:
-            direction = "上升" if z > 0 else "下降"
-            changes.append(f"{PRESSURE_LIMITS[key]['name']}{direction}{abs(z):.1f}倍尺度")
-    if len(changes) >= 2:
-        return ";压力联合证据:多级同步变化(" + ",".join(changes) + ")"
-    if changes:
-        return ";压力联合证据:" + changes[0]
-    return ";压力联合证据:排气压力无明显同步异常"
-
-
-def merge_candidates(candidates):
-    """Merge hourly triggers of the same phenomenon into reviewable segments."""
-    candidates.sort(key=lambda x: (x["batch"], x["anomaly_type"], x["start_time"]))
-    merged = []
-    for item in candidates:
-        if merged and item["batch"] == merged[-1]["batch"] and item["anomaly_type"] == merged[-1]["anomaly_type"]:
-            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"]
-                merged[-1]["candidate_duration_hours"] = int(
-                    (current - datetime.strptime(merged[-1]["start_time"], "%Y-%m-%d %H:%M:%S")).total_seconds() / 3600
-                ) + 1
-                merged[-1]["repeat_count"] += 1
-                merged[-1]["coverage"] = min(merged[-1]["coverage"], item["coverage"])
-                merged[-1]["running_samples"] = max(merged[-1]["running_samples"], item["running_samples"])
-                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))
-    for item in merged:
-        item["candidate_id"] = f"{item['unit']}-{item['anomaly_type']}-{item['start_time']}"
-    return sorted(merged, key=lambda x: (x["batch"], x["start_time"], x["anomaly_type"]))
-
-
-def detect(rows, baseline_hours):
-    result = []
-    grouped = defaultdict(list)
-    for row in rows:
-        grouped[row["batch"]].append(row)
-    for batch, series in grouped.items():
-        series.sort(key=lambda x: x["hour"])
-        valid = [x for x in series if x["coverage"] >= MIN_COVERAGE]
-        for index, row in enumerate(series):
-            if row["coverage"] < MIN_COVERAGE:
-                continue
-            prior = [x for x in valid if x["hour"] < row["hour"]][-baseline_hours:]
-            if len(prior) < MIN_HISTORY_HOURS:
-                continue
-            history = len(prior)
-
-            oil_center, oil_scale = baseline([x.get("oil_avg") for x in prior])
-            oil = row.get("oil_avg")
-            oil_slope = linear_slope([x.get("oil_avg") for x in prior[-6:]] + [oil])
-            low_ratio = (row.get("oil_low_count") or 0) / max(row["running_samples"], 1)
-            low_low_ratio = (row.get("oil_low_low_count") or 0) / max(row["running_samples"], 1)
-            if low_low_ratio >= 0.05 or low_ratio >= 0.20:
-                add_candidate(result, row, "润滑油压力异常", 5 + 5 * min(low_low_ratio * 4, 1),
-                              f"YSJ_5低报警比例{low_ratio:.1%},低低报警比例{low_low_ratio:.1%}{pressure_context(row, prior)}", history)
-            if oil_center is not None and oil is not None and oil_slope is not None:
-                deviation = (oil_center - oil) / oil_scale
-                if deviation >= 2.5 and oil_slope < 0:
-                    add_candidate(result, row, "润滑油压力异常", deviation + min(abs(oil_slope) / oil_scale, 5),
-                                  f"油压均值{oil:.3f},低于近期基线{deviation:.1f}倍尺度,近6小时斜率{oil_slope:.5f}{pressure_context(row, prior)}", history)
-
-            abnormal_exhaust = []
-            for key in EXHAUST_KEYS:
-                current = row.get(f"{key}_avg")
-                center, scale = baseline([x.get(f"{key}_avg") for x in prior])
-                if current is None or center is None:
-                    continue
-                z = abs(current - center) / scale
-                high_ratio = (row.get(f"{key}_high_count") or 0) / max(row["running_samples"], 1)
-                if z >= 3:
-                    abnormal_exhaust.append((key, current, center, z))
-
-            for side, avg_key, max_key in (("联轴器端", "coupling_avg", "coupling_max"), ("链轮端", "chain_avg", "chain_max")):
-                center, scale = baseline([x.get(avg_key) for x in prior])
-                max_center, max_scale = baseline([x.get(max_key) for x in prior])
-                if center is None or max_center is None or row.get(max_key) is None:
-                    continue
-                avg_z = (row.get(avg_key) - center) / scale if row.get(avg_key) is not None else 0
-                peak_z = (row[max_key] - max_center) / max_scale
-                alarm_ratio = (row.get(f"{('coupling' if side == '联轴器端' else 'chain')}_alarm_count") or 0) / max(row["running_samples"], 1)
-                if alarm_ratio >= 0.01 or peak_z >= 4 or avg_z >= 3:
-                    add_candidate(result, row, "振动异常(压力联合评估)", min(max(peak_z, avg_z, 0) + min(alarm_ratio * 8, 3), 12),
-                                  f"{side}均值偏离{avg_z:.1f},峰值偏离{peak_z:.1f},报警比例{alarm_ratio:.1%}{pressure_context(row, prior)}", history)
-    return merge_candidates(result)
-
-
-def write_csv(path, rows, fields, headers):
-    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.writerow(headers)
-        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)
-    parser.add_argument("--output-dir", default=str(Path(__file__).resolve().parents[1] / "cache" / "pks_scanner" / "v2_pressure"))
-    args = parser.parse_args()
-    start, end = parse_dt(args.start), parse_dt(args.end)
-    if end <= start or args.chunk_days < 1 or args.baseline_hours < MIN_HISTORY_HOURS:
-        parser.error("时间范围或参数无效")
-    print("只读模式:不修改任何数据库数据;第一版程序未使用")
-    rows = []
-    for batch in args.batch:
-        rows.extend(fetch_hourly(batch, start, end, args.chunk_days))
-    rows.sort(key=lambda x: (x["batch"], x["hour"]))
-    candidates = detect(rows, args.baseline_hours)
-    out = Path(args.output_dir)
-    hourly_fields = list(rows[0]) if rows else []
-    candidate_fields = ["candidate_id", "unit", "batch", "anomaly_type", "start_time", "end_time", "peak_time", "score", "history_valid_hours", "candidate_duration_hours", "repeat_count", "coverage", "running_samples", "reason"]
-    hourly_headers = {key: key for key in hourly_fields}
-    write_csv(out / "hourly_pressure_summary.csv", rows, hourly_fields, hourly_headers)
-    write_csv(out / "pressure_fault_candidates.csv", candidates, candidate_fields, {key: key for key in candidate_fields})
-    print(f"小时摘要:{len(rows):,} 行")
-    print(f"候选记录:{len(candidates):,} 条")
-    print(f"输出目录:{out}")
-
-
-if __name__ == "__main__":
-    main()

BIN
cache/.DS_Store


BIN
cache/pks_scanner/.DS_Store