scan_pks_pressure_v2.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. #!/usr/bin/env python3
  2. """Second PKS scanner focused on pressure usefulness.
  3. This is intentionally a new scanner. It does not modify the first scanner or
  4. the database. Pressure alarm limits come from the site_point configuration
  5. confirmed for YSJ_5..YSJ_9; the output keeps both absolute alarm evidence and
  6. relative, recent-baseline evidence so they can be reviewed separately.
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import csv
  11. import math
  12. import os
  13. import sys
  14. from collections import defaultdict
  15. from datetime import datetime, timedelta
  16. from pathlib import Path
  17. from statistics import median
  18. import pymysql
  19. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  20. from app.config import settings # noqa: E402
  21. BATCH_TO_UNIT = {30: "7号机", 31: "8号机", 32: "9号机"}
  22. EXPECTED_PER_HOUR = 720
  23. MIN_COVERAGE = 0.80
  24. MIN_HISTORY_HOURS = 24
  25. # AlarmLimit1..4 in site_point. The same engineering limits are configured
  26. # for all three units, while the measured values remain unit-specific.
  27. PRESSURE_LIMITS = {
  28. "oil": {"field": "oil", "name": "润滑油压力", "low": 0.34, "low_low": 0.31, "high": 0.68, "high_high": 0.72},
  29. "inlet": {"field": "inlet", "name": "进气压力", "low": 0.45, "low_low": 0.40, "high": 0.95, "high_high": 1.00},
  30. "p1": {"field": "p1", "name": "一级排气压力", "high": 2.60, "high_high": 2.75},
  31. "p2": {"field": "p2", "name": "二级排气压力", "high": 4.90, "high_high": 5.00},
  32. "p3": {"field": "p3", "name": "三级排气压力", "high": 6.20, "high_high": 6.30},
  33. }
  34. PRESSURE_KEYS = ("oil", "inlet", "p1", "p2", "p3")
  35. EXHAUST_KEYS = ("p1", "p2", "p3")
  36. SQL = """
  37. SELECT
  38. FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
  39. COUNT(*) AS samples,
  40. SUM(YSJ_41 > 0) AS running_samples,
  41. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
  42. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_min,
  43. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_max,
  44. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_6 END) AS inlet_avg,
  45. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_6 END) AS inlet_min,
  46. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_6 END) AS inlet_max,
  47. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_avg,
  48. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_min,
  49. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_7 END) AS p1_max,
  50. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_avg,
  51. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_min,
  52. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_8 END) AS p2_max,
  53. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_avg,
  54. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_min,
  55. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_9 END) AS p3_max,
  56. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
  57. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
  58. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
  59. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
  60. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_14 END) AS oil_temp_avg,
  61. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_40 END) AS valve_avg,
  62. SUM(YSJ_41 > 0 AND YSJ_5 <= 0.34) AS oil_low_count,
  63. SUM(YSJ_41 > 0 AND YSJ_5 <= 0.31) AS oil_low_low_count,
  64. SUM(YSJ_41 > 0 AND YSJ_5 >= 0.68) AS oil_high_count,
  65. SUM(YSJ_41 > 0 AND YSJ_6 <= 0.45) AS inlet_low_count,
  66. SUM(YSJ_41 > 0 AND YSJ_6 >= 0.95) AS inlet_high_count,
  67. SUM(YSJ_41 > 0 AND YSJ_7 >= 2.60) AS p1_high_count,
  68. SUM(YSJ_41 > 0 AND YSJ_7 >= 2.75) AS p1_high_high_count,
  69. SUM(YSJ_41 > 0 AND YSJ_8 >= 4.90) AS p2_high_count,
  70. SUM(YSJ_41 > 0 AND YSJ_8 >= 5.00) AS p2_high_high_count,
  71. SUM(YSJ_41 > 0 AND YSJ_9 >= 6.20) AS p3_high_count,
  72. SUM(YSJ_41 > 0 AND YSJ_9 >= 6.30) AS p3_high_high_count,
  73. SUM(YSJ_41 > 0 AND YSJ_10 >= 7.1) AS coupling_alarm_count,
  74. SUM(YSJ_41 > 0 AND YSJ_11 >= 7.1) AS chain_alarm_count
  75. FROM pks_long_sample
  76. WHERE import_batch_id = %s AND sample_time >= %s AND sample_time < %s
  77. GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
  78. ORDER BY hour_start
  79. """
  80. def connect():
  81. return pymysql.connect(host=settings.db_host, port=settings.db_port, user=settings.db_user,
  82. password=settings.db_password, database=settings.db_name,
  83. charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
  84. connect_timeout=settings.db_connect_timeout, read_timeout=600,
  85. write_timeout=60, autocommit=True)
  86. def parse_dt(value: str) -> datetime:
  87. return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
  88. def chunks(start: datetime, end: datetime, days: int):
  89. while start < end:
  90. nxt = min(start + timedelta(days=days), end)
  91. yield start, nxt
  92. start = nxt
  93. def number(value):
  94. if value is None:
  95. return None
  96. try:
  97. value = float(value)
  98. except (TypeError, ValueError):
  99. return None
  100. return value if math.isfinite(value) else None
  101. def fetch_hourly(batch, start, end, chunk_days):
  102. rows = []
  103. connection = connect()
  104. try:
  105. with connection.cursor() as cursor:
  106. for index, (lo, hi) in enumerate(chunks(start, end, chunk_days), 1):
  107. cursor.execute("EXPLAIN " + SQL, (batch, lo, hi))
  108. plan = cursor.fetchone()
  109. if not plan or plan.get("key") not in ("PRIMARY",):
  110. raise RuntimeError(f"安全检查失败:batch={batch} chunk={lo} EXPLAIN 未使用 PRIMARY: {plan}")
  111. cursor.execute(SQL, (batch, lo, hi))
  112. for raw in cursor.fetchall():
  113. row = {"batch": batch, "unit": BATCH_TO_UNIT[batch],
  114. "hour": raw["hour_start"].strftime("%Y-%m-%d %H:%M:%S"),
  115. "samples": int(raw["samples"] or 0),
  116. "running_samples": int(raw["running_samples"] or 0)}
  117. row["coverage"] = row["running_samples"] / EXPECTED_PER_HOUR
  118. for key, value in raw.items():
  119. if key not in ("hour_start", "samples", "running_samples"):
  120. row[key] = number(value)
  121. rows.append(row)
  122. print(f"batch={batch} chunk={index} {lo}..{hi}: {len(rows):,} total hourly rows")
  123. finally:
  124. connection.close()
  125. return rows
  126. def baseline(values):
  127. values = [v for v in values if v is not None]
  128. if len(values) < 12:
  129. return None, None
  130. center = median(values)
  131. mad = median(abs(v - center) for v in values)
  132. return center, max(1.4826 * mad, abs(center) * 0.01, 1e-6)
  133. def linear_slope(values):
  134. pairs = [(i, v) for i, v in enumerate(values) if v is not None]
  135. if len(pairs) < 4:
  136. return None
  137. xbar = sum(x for x, _ in pairs) / len(pairs)
  138. ybar = sum(y for _, y in pairs) / len(pairs)
  139. den = sum((x - xbar) ** 2 for x, _ in pairs)
  140. return None if not den else sum((x - xbar) * (y - ybar) for x, y in pairs) / den
  141. def add_candidate(out, row, kind, score, reason, history):
  142. out.append({"candidate_id": f"{row['unit']}-{kind}-{row['hour']}", "unit": row["unit"],
  143. "batch": row["batch"], "anomaly_type": kind, "start_time": row["hour"],
  144. "end_time": row["hour"], "peak_time": row["hour"],
  145. "score": round(min(max(score, 0), 12), 3),
  146. "history_valid_hours": history, "coverage": round(row["coverage"], 3),
  147. "running_samples": row["running_samples"], "repeat_count": 1,
  148. "candidate_duration_hours": 1, "reason": reason})
  149. def pressure_context(row, prior):
  150. """Describe exhaust-pressure evidence without creating pressure-only events."""
  151. changes = []
  152. for key in EXHAUST_KEYS:
  153. current = row.get(f"{key}_avg")
  154. center, scale = baseline([x.get(f"{key}_avg") for x in prior])
  155. if current is None or center is None:
  156. continue
  157. z = (current - center) / scale
  158. if abs(z) >= 2.5:
  159. direction = "上升" if z > 0 else "下降"
  160. changes.append(f"{PRESSURE_LIMITS[key]['name']}{direction}{abs(z):.1f}倍尺度")
  161. if len(changes) >= 2:
  162. return ";压力联合证据:多级同步变化(" + ",".join(changes) + ")"
  163. if changes:
  164. return ";压力联合证据:" + changes[0]
  165. return ";压力联合证据:排气压力无明显同步异常"
  166. def merge_candidates(candidates):
  167. """Merge hourly triggers of the same phenomenon into reviewable segments."""
  168. candidates.sort(key=lambda x: (x["batch"], x["anomaly_type"], x["start_time"]))
  169. merged = []
  170. for item in candidates:
  171. if merged and item["batch"] == merged[-1]["batch"] and item["anomaly_type"] == merged[-1]["anomaly_type"]:
  172. previous = datetime.strptime(merged[-1]["end_time"], "%Y-%m-%d %H:%M:%S")
  173. current = datetime.strptime(item["start_time"], "%Y-%m-%d %H:%M:%S")
  174. if current - previous <= timedelta(hours=6):
  175. merged[-1]["end_time"] = item["end_time"]
  176. merged[-1]["candidate_duration_hours"] = int(
  177. (current - datetime.strptime(merged[-1]["start_time"], "%Y-%m-%d %H:%M:%S")).total_seconds() / 3600
  178. ) + 1
  179. merged[-1]["repeat_count"] += 1
  180. merged[-1]["coverage"] = min(merged[-1]["coverage"], item["coverage"])
  181. merged[-1]["running_samples"] = max(merged[-1]["running_samples"], item["running_samples"])
  182. if item["score"] > merged[-1]["score"]:
  183. merged[-1]["score"] = item["score"]
  184. merged[-1]["peak_time"] = item["peak_time"]
  185. if item["reason"] not in merged[-1]["reason"]:
  186. merged[-1]["reason"] += "; " + item["reason"]
  187. continue
  188. merged.append(dict(item))
  189. for item in merged:
  190. item["candidate_id"] = f"{item['unit']}-{item['anomaly_type']}-{item['start_time']}"
  191. return sorted(merged, key=lambda x: (x["batch"], x["start_time"], x["anomaly_type"]))
  192. def detect(rows, baseline_hours):
  193. result = []
  194. grouped = defaultdict(list)
  195. for row in rows:
  196. grouped[row["batch"]].append(row)
  197. for batch, series in grouped.items():
  198. series.sort(key=lambda x: x["hour"])
  199. valid = [x for x in series if x["coverage"] >= MIN_COVERAGE]
  200. for index, row in enumerate(series):
  201. if row["coverage"] < MIN_COVERAGE:
  202. continue
  203. prior = [x for x in valid if x["hour"] < row["hour"]][-baseline_hours:]
  204. if len(prior) < MIN_HISTORY_HOURS:
  205. continue
  206. history = len(prior)
  207. oil_center, oil_scale = baseline([x.get("oil_avg") for x in prior])
  208. oil = row.get("oil_avg")
  209. oil_slope = linear_slope([x.get("oil_avg") for x in prior[-6:]] + [oil])
  210. low_ratio = (row.get("oil_low_count") or 0) / max(row["running_samples"], 1)
  211. low_low_ratio = (row.get("oil_low_low_count") or 0) / max(row["running_samples"], 1)
  212. if low_low_ratio >= 0.05 or low_ratio >= 0.20:
  213. add_candidate(result, row, "润滑油压力异常", 5 + 5 * min(low_low_ratio * 4, 1),
  214. f"YSJ_5低报警比例{low_ratio:.1%},低低报警比例{low_low_ratio:.1%}{pressure_context(row, prior)}", history)
  215. if oil_center is not None and oil is not None and oil_slope is not None:
  216. deviation = (oil_center - oil) / oil_scale
  217. if deviation >= 2.5 and oil_slope < 0:
  218. add_candidate(result, row, "润滑油压力异常", deviation + min(abs(oil_slope) / oil_scale, 5),
  219. f"油压均值{oil:.3f},低于近期基线{deviation:.1f}倍尺度,近6小时斜率{oil_slope:.5f}{pressure_context(row, prior)}", history)
  220. abnormal_exhaust = []
  221. for key in EXHAUST_KEYS:
  222. current = row.get(f"{key}_avg")
  223. center, scale = baseline([x.get(f"{key}_avg") for x in prior])
  224. if current is None or center is None:
  225. continue
  226. z = abs(current - center) / scale
  227. high_ratio = (row.get(f"{key}_high_count") or 0) / max(row["running_samples"], 1)
  228. if z >= 3:
  229. abnormal_exhaust.append((key, current, center, z))
  230. for side, avg_key, max_key in (("联轴器端", "coupling_avg", "coupling_max"), ("链轮端", "chain_avg", "chain_max")):
  231. center, scale = baseline([x.get(avg_key) for x in prior])
  232. max_center, max_scale = baseline([x.get(max_key) for x in prior])
  233. if center is None or max_center is None or row.get(max_key) is None:
  234. continue
  235. avg_z = (row.get(avg_key) - center) / scale if row.get(avg_key) is not None else 0
  236. peak_z = (row[max_key] - max_center) / max_scale
  237. alarm_ratio = (row.get(f"{('coupling' if side == '联轴器端' else 'chain')}_alarm_count") or 0) / max(row["running_samples"], 1)
  238. if alarm_ratio >= 0.01 or peak_z >= 4 or avg_z >= 3:
  239. add_candidate(result, row, "振动异常(压力联合评估)", min(max(peak_z, avg_z, 0) + min(alarm_ratio * 8, 3), 12),
  240. f"{side}均值偏离{avg_z:.1f},峰值偏离{peak_z:.1f},报警比例{alarm_ratio:.1%}{pressure_context(row, prior)}", history)
  241. return merge_candidates(result)
  242. def write_csv(path, rows, fields, headers):
  243. path.parent.mkdir(parents=True, exist_ok=True)
  244. with path.open("w", encoding="utf-8-sig", newline="") as handle:
  245. writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
  246. writer.writerow(headers)
  247. writer.writerows(rows)
  248. def main():
  249. parser = argparse.ArgumentParser(description="只读扫描 PKS 压力与报警特征(第二版)")
  250. parser.add_argument("--batch", nargs="+", type=int, choices=sorted(BATCH_TO_UNIT), default=list(BATCH_TO_UNIT))
  251. parser.add_argument("--start", default="2025-04-01 00:00:00")
  252. parser.add_argument("--end", default="2026-05-01 00:00:00")
  253. parser.add_argument("--chunk-days", type=int, default=14)
  254. parser.add_argument("--baseline-hours", type=int, default=336)
  255. parser.add_argument("--output-dir", default=str(Path(__file__).resolve().parents[1] / "cache" / "pks_scanner" / "v2_pressure"))
  256. args = parser.parse_args()
  257. start, end = parse_dt(args.start), parse_dt(args.end)
  258. if end <= start or args.chunk_days < 1 or args.baseline_hours < MIN_HISTORY_HOURS:
  259. parser.error("时间范围或参数无效")
  260. print("只读模式:不修改任何数据库数据;第一版程序未使用")
  261. rows = []
  262. for batch in args.batch:
  263. rows.extend(fetch_hourly(batch, start, end, args.chunk_days))
  264. rows.sort(key=lambda x: (x["batch"], x["hour"]))
  265. candidates = detect(rows, args.baseline_hours)
  266. out = Path(args.output_dir)
  267. hourly_fields = list(rows[0]) if rows else []
  268. 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"]
  269. hourly_headers = {key: key for key in hourly_fields}
  270. write_csv(out / "hourly_pressure_summary.csv", rows, hourly_fields, hourly_headers)
  271. write_csv(out / "pressure_fault_candidates.csv", candidates, candidate_fields, {key: key for key in candidate_fields})
  272. print(f"小时摘要:{len(rows):,} 行")
  273. print(f"候选记录:{len(candidates):,} 条")
  274. print(f"输出目录:{out}")
  275. if __name__ == "__main__":
  276. main()