scan_pks_fault_candidates.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  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. HOURLY_FIELDS = [
  25. "batch", "unit", "hour", "samples", "running_samples", "coverage",
  26. "oil_avg", "oil_min", "oil_max", "p1_avg", "p2_avg", "p3_avg",
  27. "coupling_avg", "coupling_max", "chain_avg", "chain_max",
  28. "oil_temp_avg", "valve_avg",
  29. ]
  30. SQL = """
  31. SELECT
  32. FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
  33. COUNT(*) AS samples,
  34. SUM(YSJ_41 > 0) AS running_samples,
  35. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
  36. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_min,
  37. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_max,
  38. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_avg,
  39. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_avg,
  40. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_avg,
  41. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
  42. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
  43. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
  44. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
  45. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_14 END) AS oil_temp_avg,
  46. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_40 END) AS valve_avg
  47. FROM pks_long_sample
  48. WHERE import_batch_id = %s
  49. AND sample_time >= %s
  50. AND sample_time < %s
  51. GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
  52. ORDER BY hour_start
  53. """
  54. def connect():
  55. return pymysql.connect(
  56. host=settings.db_host, port=settings.db_port, user=settings.db_user,
  57. password=settings.db_password, database=settings.db_name,
  58. charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
  59. connect_timeout=settings.db_connect_timeout, read_timeout=600,
  60. write_timeout=60, autocommit=True,
  61. )
  62. def parse_dt(value: str) -> datetime:
  63. return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
  64. def chunks(start: datetime, end: datetime, days: int):
  65. cursor = start
  66. step = timedelta(days=days)
  67. while cursor < end:
  68. nxt = min(cursor + step, end)
  69. yield cursor, nxt
  70. cursor = nxt
  71. def to_float(value):
  72. if value is None:
  73. return None
  74. try:
  75. value = float(value)
  76. except (TypeError, ValueError):
  77. return None
  78. return value if math.isfinite(value) else None
  79. def fetch_hourly(batch: int, start: datetime, end: datetime, chunk_days: int, limit_chunks: int | None):
  80. rows = []
  81. connection = connect()
  82. try:
  83. with connection.cursor() as cursor:
  84. for index, (chunk_start, chunk_end) in enumerate(chunks(start, end, chunk_days), 1):
  85. if limit_chunks is not None and index > limit_chunks:
  86. break
  87. cursor.execute("EXPLAIN " + SQL, (batch, chunk_start, chunk_end))
  88. plan = cursor.fetchone()
  89. if not plan or plan.get("key") not in ("PRIMARY",):
  90. raise RuntimeError(f"安全检查失败:batch={batch} chunk={chunk_start} EXPLAIN 未使用 PRIMARY: {plan}")
  91. cursor.execute(SQL, (batch, chunk_start, chunk_end))
  92. chunk_rows = cursor.fetchall()
  93. for row in chunk_rows:
  94. item = {
  95. "batch": batch, "unit": BATCH_TO_UNIT[batch],
  96. "hour": row["hour_start"].strftime("%Y-%m-%d %H:%M:%S"),
  97. "samples": int(row["samples"] or 0),
  98. "running_samples": int(row["running_samples"] or 0),
  99. }
  100. item["coverage"] = item["running_samples"] / EXPECTED_PER_HOUR
  101. for key in HOURLY_FIELDS[6:]:
  102. item[key] = to_float(row[key])
  103. rows.append(item)
  104. print(f"batch={batch} chunk={index} {chunk_start}..{chunk_end}: {len(chunk_rows):,} hourly rows")
  105. finally:
  106. connection.close()
  107. return rows
  108. def robust_baseline(values):
  109. values = [v for v in values if v is not None]
  110. if len(values) < 12:
  111. return None, None
  112. center = median(values)
  113. mad = median([abs(v - center) for v in values])
  114. scale = max(1.4826 * mad, abs(center) * 0.01, 1e-9)
  115. return center, scale
  116. def slope(values):
  117. pairs = [(i, v) for i, v in enumerate(values) if v is not None]
  118. if len(pairs) < 4:
  119. return None
  120. xbar = sum(x for x, _ in pairs) / len(pairs)
  121. ybar = sum(y for _, y in pairs) / len(pairs)
  122. denominator = sum((x - xbar) ** 2 for x, _ in pairs)
  123. if denominator == 0:
  124. return None
  125. return sum((x - xbar) * (y - ybar) for x, y in pairs) / denominator
  126. def add_reason(candidates, row, kind, score, reason):
  127. candidates.append({
  128. "candidate_id": f"{row['unit']}-{kind}-{row['hour']}",
  129. "unit": row["unit"], "batch": row["batch"], "fault_hint": kind,
  130. "start_time": row["hour"], "end_time": row["hour"],
  131. "peak_time": row["hour"], "score": round(score, 3),
  132. "coverage": round(row["coverage"], 3),
  133. "running_samples": row["running_samples"], "reason": reason,
  134. })
  135. def detect(rows, baseline_hours: int):
  136. by_batch = defaultdict(list)
  137. for row in rows:
  138. by_batch[row["batch"]].append(row)
  139. candidates = []
  140. for batch, series in by_batch.items():
  141. series.sort(key=lambda r: r["hour"])
  142. valid = [r for r in series if r["coverage"] >= MIN_COVERAGE]
  143. for i, row in enumerate(series):
  144. if row["coverage"] < MIN_COVERAGE:
  145. continue
  146. prior = [r for r in valid if r["hour"] < row["hour"]][-baseline_hours:]
  147. if len(prior) < 12:
  148. continue
  149. oil_center, oil_scale = robust_baseline([r["oil_avg"] for r in prior])
  150. oil_values = [r["oil_avg"] for r in prior[-6:]] + [row["oil_avg"]]
  151. oil_slope = slope(oil_values)
  152. if oil_center is not None and row["oil_avg"] is not None and oil_slope is not None:
  153. oil_drop = (oil_center - row["oil_avg"]) / oil_scale
  154. if oil_drop >= 2.5 and oil_slope < 0:
  155. add_reason(candidates, row, "润滑油压力下降", oil_drop + min(abs(oil_slope) / oil_scale, 5),
  156. f"YSJ_5低于近期基线{oil_drop:.1f}倍稳健尺度,近6个有效小时斜率为{oil_slope:.5f}")
  157. for side, avg_key, max_key in (("联轴器端", "coupling_avg", "coupling_max"), ("链轮端", "chain_avg", "chain_max")):
  158. avg_center, avg_scale = robust_baseline([r[avg_key] for r in prior])
  159. max_center, max_scale = robust_baseline([r[max_key] for r in prior])
  160. if max_center is None or row[max_key] is None:
  161. continue
  162. peak_score = (row[max_key] - max_center) / max_scale
  163. avg_score = ((row[avg_key] - avg_center) / avg_scale) if avg_center is not None and row[avg_key] is not None else 0
  164. if peak_score >= 4:
  165. kind = "活塞/机械冲击候选" if side == "链轮端" else "机械振动候选"
  166. add_reason(candidates, row, kind, peak_score + max(avg_score, 0),
  167. f"{side}峰值{row[max_key]:.3f},高于近期基线{peak_score:.1f}倍稳健尺度;均值偏离{avg_score:.1f}")
  168. elif avg_score >= 3:
  169. add_reason(candidates, row, "气阀相关振动候选", avg_score,
  170. f"{side}平均振动{row[avg_key]:.3f},高于近期基线{avg_score:.1f}倍稳健尺度")
  171. return merge_candidates(candidates)
  172. def merge_candidates(candidates):
  173. candidates.sort(key=lambda r: (r["batch"], r["fault_hint"], r["start_time"]))
  174. merged = []
  175. for item in candidates:
  176. if merged and item["batch"] == merged[-1]["batch"] and item["fault_hint"] == merged[-1]["fault_hint"]:
  177. previous = datetime.strptime(merged[-1]["end_time"], "%Y-%m-%d %H:%M:%S")
  178. current = datetime.strptime(item["start_time"], "%Y-%m-%d %H:%M:%S")
  179. if current - previous <= timedelta(hours=6):
  180. merged[-1]["end_time"] = item["end_time"]
  181. if item["score"] > merged[-1]["score"]:
  182. merged[-1]["score"] = item["score"]
  183. merged[-1]["peak_time"] = item["peak_time"]
  184. if item["reason"] not in merged[-1]["reason"]:
  185. merged[-1]["reason"] += "; " + item["reason"]
  186. continue
  187. merged.append(dict(item))
  188. return merged
  189. def write_csv(path: Path, rows, fields):
  190. path.parent.mkdir(parents=True, exist_ok=True)
  191. with path.open("w", encoding="utf-8-sig", newline="") as handle:
  192. writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
  193. writer.writeheader()
  194. writer.writerows(rows)
  195. def main():
  196. parser = argparse.ArgumentParser(description="只读扫描运行期 PKS,生成故障候选时间段")
  197. parser.add_argument("--batch", nargs="+", type=int, choices=sorted(BATCH_TO_UNIT), default=list(BATCH_TO_UNIT))
  198. parser.add_argument("--start", default="2025-04-01 00:00:00")
  199. parser.add_argument("--end", default="2026-05-01 00:00:00")
  200. parser.add_argument("--chunk-days", type=int, default=14)
  201. parser.add_argument("--baseline-hours", type=int, default=336, help="近期有效运行小时数,默认14天")
  202. parser.add_argument("--limit-chunks", type=int, default=None, help="仅用于安全试跑")
  203. parser.add_argument("--output-dir", default=str(Path(__file__).resolve().parents[1] / "cache" / "pks_scanner"))
  204. args = parser.parse_args()
  205. if args.chunk_days < 1 or args.baseline_hours < 12:
  206. parser.error("chunk-days 必须>=1,baseline-hours 必须>=12")
  207. start, end = parse_dt(args.start), parse_dt(args.end)
  208. if end <= start:
  209. parser.error("end 必须晚于 start")
  210. print("只读模式:不修改任何数据库数据")
  211. all_rows = []
  212. for batch in args.batch:
  213. all_rows.extend(fetch_hourly(batch, start, end, args.chunk_days, args.limit_chunks))
  214. all_rows.sort(key=lambda r: (r["batch"], r["hour"]))
  215. candidates = detect(all_rows, args.baseline_hours)
  216. out = Path(args.output_dir)
  217. write_csv(out / "hourly_summary.csv", all_rows, HOURLY_FIELDS)
  218. write_csv(out / "fault_candidates.csv", candidates,
  219. ["candidate_id", "unit", "batch", "fault_hint", "start_time", "end_time", "peak_time", "score", "coverage", "running_samples", "reason"])
  220. print(f"小时摘要:{len(all_rows):,} 行")
  221. print(f"候选段:{len(candidates):,} 段")
  222. print(f"输出:{out / 'hourly_summary.csv'}")
  223. print(f"输出:{out / 'fault_candidates.csv'}")
  224. if __name__ == "__main__":
  225. main()