scan_pks_fault_candidates.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. #!/usr/bin/env python3
  2. """Scan running PKS data for interpretable fault-candidate segments.
  3. Read-only, bounded scanner. MySQL performs hourly aggregation inside each
  4. (batch, time-chunk) range; Python only receives the small hourly summary.
  5. The output is a candidate list for human review, not a confirmed diagnosis.
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import csv
  10. import math
  11. import os
  12. import sys
  13. from collections import defaultdict
  14. from datetime import datetime, timedelta
  15. from pathlib import Path
  16. from statistics import median
  17. import pymysql
  18. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  19. from app.config import settings # noqa: E402
  20. BATCH_TO_UNIT = {30: "7号机", 31: "8号机", 32: "9号机"}
  21. DEFAULT_COLUMNS = ("YSJ_5", "YSJ_7", "YSJ_8", "YSJ_9", "YSJ_10", "YSJ_11", "YSJ_14", "YSJ_40", "YSJ_41")
  22. EXPECTED_PER_HOUR = 720
  23. MIN_COVERAGE = 0.80
  24. MIN_HISTORY_HOURS = 24
  25. MAX_SCORE = 12.0
  26. HOURLY_FIELDS = [
  27. "batch", "unit", "hour", "samples", "running_samples", "coverage",
  28. "oil_avg", "oil_min", "oil_max", "p1_avg", "p2_avg", "p3_avg",
  29. "coupling_avg", "coupling_max", "chain_avg", "chain_max",
  30. "oil_temp_avg", "valve_avg",
  31. ]
  32. HOURLY_HEADERS = {
  33. "batch": "批次", "unit": "机组", "hour": "小时",
  34. "samples": "样本数", "running_samples": "运行样本数", "coverage": "运行覆盖率",
  35. "oil_avg": "润滑油压力均值", "oil_min": "润滑油压力最小值", "oil_max": "润滑油压力最大值",
  36. "p1_avg": "一级排气压力均值", "p2_avg": "二级排气压力均值", "p3_avg": "三级排气压力均值",
  37. "coupling_avg": "联轴器端振动均值", "coupling_max": "联轴器端振动最大值",
  38. "chain_avg": "链轮端振动均值", "chain_max": "链轮端振动最大值",
  39. "oil_temp_avg": "主油道润滑油温度均值", "valve_avg": "回流调节阀位均值",
  40. }
  41. CANDIDATE_FIELDS = [
  42. "candidate_id", "unit", "batch", "fault_hint", "start_time", "end_time", "peak_time",
  43. "score", "confidence", "history_valid_hours", "candidate_duration_hours", "repeat_count",
  44. "coverage", "running_samples", "reason",
  45. ]
  46. CANDIDATE_HEADERS = {
  47. "candidate_id": "候选段标识", "unit": "机组", "batch": "批次", "fault_hint": "异常方向",
  48. "start_time": "开始时间", "end_time": "结束时间", "peak_time": "峰值时间",
  49. "score": "异常分数(0-12)", "confidence": "可信度", "history_valid_hours": "历史有效运行小时数",
  50. "candidate_duration_hours": "候选持续小时数", "repeat_count": "异常重复次数",
  51. "coverage": "最低运行覆盖率", "running_samples": "峰值小时运行样本数", "reason": "触发原因",
  52. }
  53. SQL = """
  54. SELECT
  55. FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
  56. COUNT(*) AS samples,
  57. SUM(YSJ_41 > 0) AS running_samples,
  58. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
  59. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_min,
  60. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_max,
  61. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_avg,
  62. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_avg,
  63. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_avg,
  64. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
  65. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
  66. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
  67. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
  68. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_14 END) AS oil_temp_avg,
  69. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_40 END) AS valve_avg
  70. FROM pks_long_sample
  71. WHERE import_batch_id = %s
  72. AND sample_time >= %s
  73. AND sample_time < %s
  74. GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
  75. ORDER BY hour_start
  76. """
  77. def connect():
  78. return pymysql.connect(
  79. host=settings.db_host, port=settings.db_port, user=settings.db_user,
  80. password=settings.db_password, database=settings.db_name,
  81. charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
  82. connect_timeout=settings.db_connect_timeout, read_timeout=600,
  83. write_timeout=60, autocommit=True,
  84. )
  85. def parse_dt(value: str) -> datetime:
  86. return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
  87. def chunks(start: datetime, end: datetime, days: int):
  88. cursor = start
  89. step = timedelta(days=days)
  90. while cursor < end:
  91. nxt = min(cursor + step, end)
  92. yield cursor, nxt
  93. cursor = nxt
  94. def to_float(value):
  95. if value is None:
  96. return None
  97. try:
  98. value = float(value)
  99. except (TypeError, ValueError):
  100. return None
  101. return value if math.isfinite(value) else None
  102. def fetch_hourly(batch: int, start: datetime, end: datetime, chunk_days: int, limit_chunks: int | None):
  103. rows = []
  104. connection = connect()
  105. try:
  106. with connection.cursor() as cursor:
  107. for index, (chunk_start, chunk_end) in enumerate(chunks(start, end, chunk_days), 1):
  108. if limit_chunks is not None and index > limit_chunks:
  109. break
  110. cursor.execute("EXPLAIN " + SQL, (batch, chunk_start, chunk_end))
  111. plan = cursor.fetchone()
  112. if not plan or plan.get("key") not in ("PRIMARY",):
  113. raise RuntimeError(f"安全检查失败:batch={batch} chunk={chunk_start} EXPLAIN 未使用 PRIMARY: {plan}")
  114. cursor.execute(SQL, (batch, chunk_start, chunk_end))
  115. chunk_rows = cursor.fetchall()
  116. for row in chunk_rows:
  117. item = {
  118. "batch": batch, "unit": BATCH_TO_UNIT[batch],
  119. "hour": row["hour_start"].strftime("%Y-%m-%d %H:%M:%S"),
  120. "samples": int(row["samples"] or 0),
  121. "running_samples": int(row["running_samples"] or 0),
  122. }
  123. item["coverage"] = item["running_samples"] / EXPECTED_PER_HOUR
  124. for key in HOURLY_FIELDS[6:]:
  125. item[key] = to_float(row[key])
  126. rows.append(item)
  127. print(f"batch={batch} chunk={index} {chunk_start}..{chunk_end}: {len(chunk_rows):,} hourly rows")
  128. finally:
  129. connection.close()
  130. return rows
  131. def robust_baseline(values):
  132. values = [v for v in values if v is not None]
  133. if len(values) < 12:
  134. return None, None
  135. center = median(values)
  136. mad = median([abs(v - center) for v in values])
  137. scale = max(1.4826 * mad, abs(center) * 0.01, 1e-9)
  138. return center, scale
  139. def slope(values):
  140. pairs = [(i, v) for i, v in enumerate(values) if v is not None]
  141. if len(pairs) < 4:
  142. return None
  143. xbar = sum(x for x, _ in pairs) / len(pairs)
  144. ybar = sum(y for _, y in pairs) / len(pairs)
  145. denominator = sum((x - xbar) ** 2 for x, _ in pairs)
  146. if denominator == 0:
  147. return None
  148. return sum((x - xbar) * (y - ybar) for x, y in pairs) / denominator
  149. def add_reason(candidates, row, kind, score, reason, history_hours):
  150. candidates.append({
  151. "candidate_id": f"{row['unit']}-{kind}-{row['hour']}",
  152. "unit": row["unit"], "batch": row["batch"], "fault_hint": kind,
  153. "start_time": row["hour"], "end_time": row["hour"],
  154. "peak_time": row["hour"], "score": round(min(max(score, 0.0), MAX_SCORE), 3),
  155. "confidence": "高" if history_hours >= 336 else "中" if history_hours >= 168 else "低",
  156. "history_valid_hours": history_hours, "candidate_duration_hours": 1,
  157. "repeat_count": 1, "coverage": round(row["coverage"], 3),
  158. "running_samples": row["running_samples"], "reason": reason,
  159. })
  160. def detect(rows, baseline_hours: int):
  161. by_batch = defaultdict(list)
  162. for row in rows:
  163. by_batch[row["batch"]].append(row)
  164. candidates = []
  165. for batch, series in by_batch.items():
  166. series.sort(key=lambda r: r["hour"])
  167. valid = [r for r in series if r["coverage"] >= MIN_COVERAGE]
  168. for i, row in enumerate(series):
  169. if row["coverage"] < MIN_COVERAGE:
  170. continue
  171. prior = [r for r in valid if r["hour"] < row["hour"]][-baseline_hours:]
  172. if len(prior) < MIN_HISTORY_HOURS:
  173. continue
  174. oil_center, oil_scale = robust_baseline([r["oil_avg"] for r in prior])
  175. oil_values = [r["oil_avg"] for r in prior[-6:]] + [row["oil_avg"]]
  176. oil_slope = slope(oil_values)
  177. if oil_center is not None and row["oil_avg"] is not None and oil_slope is not None:
  178. oil_drop = (oil_center - row["oil_avg"]) / oil_scale
  179. if oil_drop >= 2.5 and oil_slope < 0:
  180. add_reason(candidates, row, "润滑油压力下降", oil_drop + min(abs(oil_slope) / oil_scale, 5),
  181. f"YSJ_5低于近期基线{oil_drop:.1f}倍稳健尺度,近6个有效小时斜率为{oil_slope:.5f}", len(prior))
  182. for side, avg_key, max_key in (("联轴器端", "coupling_avg", "coupling_max"), ("链轮端", "chain_avg", "chain_max")):
  183. avg_center, avg_scale = robust_baseline([r[avg_key] for r in prior])
  184. max_center, max_scale = robust_baseline([r[max_key] for r in prior])
  185. if max_center is None or row[max_key] is None:
  186. continue
  187. peak_score = (row[max_key] - max_center) / max_scale
  188. avg_score = ((row[avg_key] - avg_center) / avg_scale) if avg_center is not None and row[avg_key] is not None else 0
  189. if peak_score >= 4:
  190. kind = "活塞/机械冲击候选" if side == "链轮端" else "机械振动候选"
  191. add_reason(candidates, row, kind, peak_score + max(avg_score, 0),
  192. f"{side}峰值{row[max_key]:.3f},高于近期基线{peak_score:.1f}倍稳健尺度;均值偏离{avg_score:.1f}", len(prior))
  193. elif avg_score >= 3:
  194. add_reason(candidates, row, "气阀相关振动候选", avg_score,
  195. f"{side}平均振动{row[avg_key]:.3f},高于近期基线{avg_score:.1f}倍稳健尺度", len(prior))
  196. return merge_candidates(candidates)
  197. def merge_candidates(candidates):
  198. candidates.sort(key=lambda r: (r["batch"], r["fault_hint"], r["start_time"]))
  199. merged = []
  200. for item in candidates:
  201. if merged and item["batch"] == merged[-1]["batch"] and item["fault_hint"] == merged[-1]["fault_hint"]:
  202. previous = datetime.strptime(merged[-1]["end_time"], "%Y-%m-%d %H:%M:%S")
  203. current = datetime.strptime(item["start_time"], "%Y-%m-%d %H:%M:%S")
  204. if current - previous <= timedelta(hours=6):
  205. merged[-1]["end_time"] = item["end_time"]
  206. merged[-1]["candidate_duration_hours"] = max(
  207. 1, int((datetime.strptime(merged[-1]["end_time"], "%Y-%m-%d %H:%M:%S") -
  208. datetime.strptime(merged[-1]["start_time"], "%Y-%m-%d %H:%M:%S")).total_seconds() / 3600) + 1
  209. )
  210. merged[-1]["repeat_count"] += item["repeat_count"]
  211. merged[-1]["coverage"] = min(merged[-1]["coverage"], item["coverage"])
  212. merged[-1]["running_samples"] = max(merged[-1]["running_samples"], item["running_samples"])
  213. if item["score"] > merged[-1]["score"]:
  214. merged[-1]["score"] = item["score"]
  215. merged[-1]["peak_time"] = item["peak_time"]
  216. if item["reason"] not in merged[-1]["reason"]:
  217. merged[-1]["reason"] += "; " + item["reason"]
  218. continue
  219. merged.append(dict(item))
  220. return sorted(merged, key=lambda r: (r["batch"], r["start_time"], r["fault_hint"]))
  221. def write_csv(path: Path, rows, fields, headers=None):
  222. path.parent.mkdir(parents=True, exist_ok=True)
  223. with path.open("w", encoding="utf-8-sig", newline="") as handle:
  224. writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
  225. writer.writerow(headers or {field: field for field in fields})
  226. writer.writerows(rows)
  227. def main():
  228. parser = argparse.ArgumentParser(description="只读扫描运行期 PKS,生成故障候选时间段")
  229. parser.add_argument("--batch", nargs="+", type=int, choices=sorted(BATCH_TO_UNIT), default=list(BATCH_TO_UNIT))
  230. parser.add_argument("--start", default="2025-04-01 00:00:00")
  231. parser.add_argument("--end", default="2026-05-01 00:00:00")
  232. parser.add_argument("--chunk-days", type=int, default=14)
  233. parser.add_argument("--baseline-hours", type=int, default=336, help="近期有效运行小时数,默认14天")
  234. parser.add_argument("--limit-chunks", type=int, default=None, help="仅用于安全试跑")
  235. parser.add_argument("--output-dir", default=str(Path(__file__).resolve().parents[1] / "cache" / "pks_scanner"))
  236. args = parser.parse_args()
  237. if args.chunk_days < 1 or args.baseline_hours < 12:
  238. parser.error("chunk-days 必须>=1,baseline-hours 必须>=12")
  239. start, end = parse_dt(args.start), parse_dt(args.end)
  240. if end <= start:
  241. parser.error("end 必须晚于 start")
  242. print("只读模式:不修改任何数据库数据")
  243. all_rows = []
  244. for batch in args.batch:
  245. all_rows.extend(fetch_hourly(batch, start, end, args.chunk_days, args.limit_chunks))
  246. all_rows.sort(key=lambda r: (r["batch"], r["hour"]))
  247. candidates = detect(all_rows, args.baseline_hours)
  248. out = Path(args.output_dir)
  249. write_csv(out / "hourly_summary.csv", all_rows, HOURLY_FIELDS, HOURLY_HEADERS)
  250. write_csv(out / "fault_candidates.csv", candidates, CANDIDATE_FIELDS, CANDIDATE_HEADERS)
  251. print(f"小时摘要:{len(all_rows):,} 行")
  252. print(f"候选段:{len(candidates):,} 段")
  253. print(f"输出:{out / 'hourly_summary.csv'}")
  254. print(f"输出:{out / 'fault_candidates.csv'}")
  255. if __name__ == "__main__":
  256. main()