scan_pks_clean_evolution.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. #!/usr/bin/env python3
  2. """Simple PKS evolution report.
  3. This scanner intentionally avoids the previous state-machine implementation.
  4. It reports smoothed hourly features first, then derives oil-pressure stages and
  5. vibration segments from those features. It is read-only and uses the actual
  6. site_point alarm limits for each machine.
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import csv
  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. EXPECTED_PER_HOUR = 720
  22. MIN_COVERAGE = 0.80
  23. SMOOTH_HOURS = 6
  24. CONFIRM_HOURS = 3
  25. MERGE_GAP_HOURS = 6
  26. SQL = """
  27. SELECT
  28. FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600) AS hour_start,
  29. COUNT(*) AS samples,
  30. SUM(YSJ_41 > 0) AS running_samples,
  31. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_avg,
  32. MIN(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_min,
  33. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_5 END) AS oil_max,
  34. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_avg,
  35. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_10 END) AS coupling_max,
  36. AVG(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_avg,
  37. MAX(CASE WHEN YSJ_41 > 0 THEN YSJ_11 END) AS chain_max,
  38. SUM(YSJ_41 > 0 AND YSJ_5 <= %s) AS oil_low_count,
  39. SUM(YSJ_41 > 0 AND YSJ_5 <= %s) AS oil_low_low_count,
  40. SUM(YSJ_41 > 0 AND YSJ_10 >= %s) AS coupling_high_count,
  41. SUM(YSJ_41 > 0 AND YSJ_10 >= %s) AS coupling_high_high_count,
  42. SUM(YSJ_41 > 0 AND YSJ_11 >= %s) AS chain_high_count,
  43. SUM(YSJ_41 > 0 AND YSJ_11 >= %s) AS chain_high_high_count
  44. FROM pks_long_sample
  45. WHERE import_batch_id = %s AND sample_time >= %s AND sample_time < %s
  46. GROUP BY FROM_UNIXTIME((UNIX_TIMESTAMP(sample_time) DIV 3600) * 3600)
  47. ORDER BY hour_start
  48. """
  49. def connect():
  50. return pymysql.connect(
  51. host=settings.db_host, port=settings.db_port, user=settings.db_user,
  52. password=settings.db_password, database=settings.db_name,
  53. charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
  54. connect_timeout=settings.db_connect_timeout, read_timeout=600,
  55. write_timeout=60, autocommit=True,
  56. )
  57. def alarm_value(row, alarm_type):
  58. for i in range(1, 5):
  59. if str(row.get(f"AlarmType{i}") or "").strip() == alarm_type:
  60. return float(row[f"AlarmLimit{i}"])
  61. raise RuntimeError(f"site_point 缺少 {alarm_type}")
  62. def load_limits():
  63. names = [f"YSJ{unit}_{point}" for unit in (7, 8, 9) for point in (5, 10, 11)]
  64. conn = connect()
  65. try:
  66. with conn.cursor() as cur:
  67. marks = ",".join(["%s"] * len(names))
  68. cur.execute(
  69. "SELECT ItemName, ItemDescription, Units, AlarmType1, AlarmType2, AlarmType3, AlarmType4, "
  70. "AlarmLimit1, AlarmLimit2, AlarmLimit3, AlarmLimit4 "
  71. f"FROM site_point WHERE ItemName IN ({marks})", names,
  72. )
  73. rows = {r["ItemName"]: r for r in cur.fetchall()}
  74. finally:
  75. conn.close()
  76. limits = {}
  77. for batch, unit in BATCH_TO_UNIT.items():
  78. n = unit[0]
  79. oil = rows[f"YSJ{n}_5"]
  80. coupling = rows[f"YSJ{n}_10"]
  81. chain = rows[f"YSJ{n}_11"]
  82. limits[batch] = {
  83. "unit": unit,
  84. "oil_low": alarm_value(oil, "PVLow"),
  85. "oil_low_low": alarm_value(oil, "PVLowLow"),
  86. "coupling_high": alarm_value(coupling, "PVHigh"),
  87. "coupling_high_high": alarm_value(coupling, "PVHighHigh"),
  88. "chain_high": alarm_value(chain, "PVHigh"),
  89. "chain_high_high": alarm_value(chain, "PVHighHigh"),
  90. }
  91. return limits
  92. def fetch(batch, start, end, chunk_days, limit):
  93. result = []
  94. conn = connect()
  95. try:
  96. with conn.cursor() as cur:
  97. cursor = start
  98. while cursor < end:
  99. nxt = min(cursor + timedelta(days=chunk_days), end)
  100. params = (limit["oil_low"], limit["oil_low_low"], limit["coupling_high"],
  101. limit["coupling_high_high"], limit["chain_high"], limit["chain_high_high"],
  102. batch, cursor, nxt)
  103. cur.execute("EXPLAIN " + SQL, params)
  104. plan = cur.fetchone()
  105. if not plan or plan.get("key") != "PRIMARY":
  106. raise RuntimeError(f"EXPLAIN 未使用 PRIMARY: batch={batch}, chunk={cursor}")
  107. cur.execute(SQL, params)
  108. for raw in cur.fetchall():
  109. row = {"机组": limit["unit"], "批次": batch,
  110. "小时": raw["hour_start"].strftime("%Y-%m-%d %H:%M:%S"),
  111. "_dt": raw["hour_start"], "样本数": int(raw["samples"] or 0),
  112. "运行样本数": int(raw["running_samples"] or 0)}
  113. row["运行覆盖率"] = row["运行样本数"] / EXPECTED_PER_HOUR
  114. for key in ("oil_avg", "oil_min", "oil_max", "coupling_avg", "coupling_max",
  115. "chain_avg", "chain_max", "oil_low_count", "oil_low_low_count",
  116. "coupling_high_count", "coupling_high_high_count",
  117. "chain_high_count", "chain_high_high_count"):
  118. row[key] = float(raw[key]) if raw[key] is not None else None
  119. result.append(row)
  120. cursor = nxt
  121. finally:
  122. conn.close()
  123. return result
  124. def rolling_median(values):
  125. values = [v for v in values if v is not None]
  126. return median(values) if values else None
  127. def slope(values):
  128. pairs = [(i, v) for i, v in enumerate(values) if v is not None]
  129. if len(pairs) < 3:
  130. return None
  131. xm = sum(i for i, _ in pairs) / len(pairs)
  132. ym = sum(v for _, v in pairs) / len(pairs)
  133. den = sum((i - xm) ** 2 for i, _ in pairs)
  134. return sum((i - xm) * (v - ym) for i, v in pairs) / den if den else None
  135. def prepare(rows, limits):
  136. by_batch = defaultdict(list)
  137. for row in rows:
  138. if row["运行覆盖率"] >= MIN_COVERAGE and row["oil_avg"] is not None:
  139. by_batch[row["批次"]].append(row)
  140. output = []
  141. for batch, series in by_batch.items():
  142. series.sort(key=lambda r: r["_dt"])
  143. limit = limits[batch]
  144. history = []
  145. cycle = 0
  146. previous = None
  147. for row in series:
  148. if previous is None or row["_dt"] - previous > timedelta(hours=12):
  149. cycle += 1
  150. cycle_rows = []
  151. cycle_rows.append(row)
  152. baseline = rolling_median([r["oil_avg"] for r in cycle_rows[:24]])
  153. smooth = rolling_median([r["oil_avg"] for r in history[-(SMOOTH_HOURS - 1):]] + [row["oil_avg"]])
  154. trend = slope([r["oil_avg"] for r in history[-5:]] + [row["oil_avg"]])
  155. stable_values = [r["oil_avg"] for r in cycle_rows[:24]]
  156. stable_mad = median(abs(value - baseline) for value in stable_values) if baseline is not None else 0
  157. fluctuation_band = max(3 * stable_mad, 0.0015)
  158. low = smooth is not None and smooth <= limit["oil_low"]
  159. low_low = smooth is not None and smooth <= limit["oil_low_low"]
  160. mild_zone = baseline is not None and smooth <= baseline - fluctuation_band
  161. abnormal_zone = baseline is not None and smooth <= baseline - 2 * fluctuation_band
  162. current = (
  163. "严重异常" if low_low
  164. else "异常" if low or (abnormal_zone and trend is not None and trend < 0)
  165. else "轻微" if mild_zone and trend is not None and trend < 0
  166. else "正常"
  167. )
  168. row.update({"周期编号": f"{limit['unit']}-P{cycle:02d}", "周期基线油压": baseline,
  169. "6小时油压中位数": smooth, "6小时油压趋势": trend,
  170. "正常波动带": fluctuation_band,
  171. "低报警阈值": limit["oil_low"], "低低报警阈值": limit["oil_low_low"],
  172. "原始压力等级": current,
  173. "低报警样本比例": (row["oil_low_count"] or 0) / max(row["运行样本数"], 1),
  174. "低低报警样本比例": (row["oil_low_low_count"] or 0) / max(row["运行样本数"], 1)})
  175. output.append(row)
  176. history.append(row)
  177. previous = row["_dt"]
  178. return sorted(output, key=lambda r: (r["批次"], r["_dt"]))
  179. def build_oil_segments(rows):
  180. result = []
  181. for batch, series in defaultdict(list).items():
  182. pass
  183. grouped = defaultdict(list)
  184. for row in rows:
  185. if row["原始压力等级"] != "正常":
  186. grouped[(row["批次"], row["周期编号"], row["原始压力等级"])].append(row)
  187. for (_batch, _cycle, level), series in grouped.items():
  188. series.sort(key=lambda r: r["_dt"])
  189. current = []
  190. for row in series:
  191. if not current or row["_dt"] - current[-1]["_dt"] <= timedelta(hours=MERGE_GAP_HOURS):
  192. current.append(row)
  193. else:
  194. result.append(oil_segment(current, level)); current = [row]
  195. if current:
  196. result.append(oil_segment(current, level))
  197. return sorted(result, key=lambda r: (r["机组"], r["开始小时"]))
  198. def oil_segment(series, level):
  199. first, last = series[0], series[-1]
  200. confirmed = len(series) >= CONFIRM_HOURS or level == "严重异常"
  201. return {"机组": first["机组"], "批次": first["批次"], "周期编号": first["周期编号"],
  202. "压力阶段": level if confirmed else "轻微", "开始小时": first["小时"], "结束小时": last["小时"],
  203. "持续小时数": len(series), "周期基线油压": first["周期基线油压"],
  204. "阶段开始油压": first["6小时油压中位数"], "阶段结束油压": last["6小时油压中位数"],
  205. "阶段最低油压": min(r["6小时油压中位数"] for r in series),
  206. "低报警小时数": sum(r["6小时油压中位数"] <= r["低报警阈值"] for r in series),
  207. "低低报警小时数": sum(r["6小时油压中位数"] <= r["低低报警阈值"] for r in series),
  208. "是否形成确认等级": "是" if confirmed else "否",
  209. "阶段判定依据": f"6小时中位数{first['6小时油压中位数']:.3f}~{last['6小时油压中位数']:.3f}MPa,"
  210. f"连续{len(series)}个有效运行小时;基线波动带{first.get('正常波动带', 0):.3f}MPa;"
  211. f"{'达到低低报警阈值' if level == '严重异常' else '超过正常波动带并持续下降'}"}
  212. def build_vibration_segments(rows, limits):
  213. signals = []
  214. by_batch = defaultdict(list)
  215. for row in rows:
  216. by_batch[row["批次"]].append(row)
  217. for batch, series in by_batch.items():
  218. series.sort(key=lambda r: r["_dt"])
  219. history = []
  220. limit = limits[batch]
  221. for row in series:
  222. for side in ("coupling", "chain"):
  223. avg = rolling_median([r[f"{side}_avg"] for r in history[-5:]] + [row[f"{side}_avg"]])
  224. peak = row[f"{side}_max"]
  225. base = rolling_median([r[f"{side}_avg"] for r in history[-24:]])
  226. if avg is None or peak is None or base is None:
  227. continue
  228. if peak >= limit[f"{side}_high_high"] or avg >= base * 1.20:
  229. level = "严重异常"
  230. elif peak >= limit[f"{side}_high"] or avg >= base * 1.10:
  231. level = "异常"
  232. elif avg > base * 1.05:
  233. level = "轻微"
  234. else:
  235. continue
  236. signals.append({"机组": row["机组"], "批次": batch, "_dt": row["_dt"], "侧": side,
  237. "等级": level, "均值": avg, "峰值": peak})
  238. history.append(row)
  239. result = []
  240. grouped = defaultdict(list)
  241. for signal in signals: grouped[(signal["批次"], signal["侧"])].append(signal)
  242. for (batch, side), series in grouped.items():
  243. series.sort(key=lambda r: r["_dt"]); current=[]
  244. for signal in series:
  245. if not current or signal["_dt"] - current[-1]["_dt"] <= timedelta(hours=MERGE_GAP_HOURS): current.append(signal)
  246. else: result.append(vibration_segment(current, limits[batch])); current=[signal]
  247. if current: result.append(vibration_segment(current, limits[batch]))
  248. return sorted(result, key=lambda r: (r["机组"], r["开始时间"]))
  249. def vibration_segment(series, limit):
  250. level_order={"轻微":1,"异常":2,"严重异常":3}; first=series[0]; last=series[-1]
  251. strongest=max(series,key=lambda r:(level_order[r["等级"]],r["峰值"]))
  252. side_name="联轴器端" if first["侧"]=="coupling" else "链轮端"
  253. return {"机组":first["机组"],"批次":first["批次"],"开始时间":first["_dt"].strftime("%Y-%m-%d %H:%M:%S"),"结束时间":last["_dt"].strftime("%Y-%m-%d %H:%M:%S"),"峰值时间":strongest["_dt"].strftime("%Y-%m-%d %H:%M:%S"),"振动等级":max(series,key=lambda r:level_order[r["等级"]])["等级"],"主要振动侧":side_name,"持续小时数":int((last["_dt"]-first["_dt"]).total_seconds()/3600)+1,"触发小时数":len(series),"最大振动值":max(r["峰值"] for r in series),"高报警阈值":limit[f"{first['侧']}_high"],"高高报警阈值":limit[f"{first['侧']}_high_high"],"阶段判定依据":f"{side_name}6小时中位数/小时峰值触发,连续{len(series)}个有效运行小时"}
  254. def write(path, rows, fields):
  255. path.parent.mkdir(parents=True, exist_ok=True)
  256. with path.open("w", encoding="utf-8-sig", newline="") as f:
  257. w=csv.DictWriter(f,fieldnames=fields,extrasaction="ignore");w.writeheader()
  258. for row in rows:
  259. w.writerow({k: "" if row.get(k) is None else round(row[k],6) if isinstance(row.get(k),float) else row.get(k) for k in fields})
  260. def main():
  261. p=argparse.ArgumentParser();p.add_argument("--batch",nargs="+",type=int,choices=sorted(BATCH_TO_UNIT),default=list(BATCH_TO_UNIT));p.add_argument("--start",default="2025-04-01 00:00:00");p.add_argument("--end",default="2026-05-01 00:00:00");p.add_argument("--chunk-days",type=int,default=14);p.add_argument("--output-dir",default="cache/pks_scanner/clean_evolution");a=p.parse_args();start=datetime.strptime(a.start,"%Y-%m-%d %H:%M:%S");end=datetime.strptime(a.end,"%Y-%m-%d %H:%M:%S")
  262. limits=load_limits(); rows=[]
  263. for batch in a.batch: rows.extend(fetch(batch,start,end,a.chunk_days,limits[batch]))
  264. rows=prepare(rows,limits); oil=build_oil_segments(rows); vib=build_vibration_segments(rows,limits); out=Path(a.output_dir)
  265. write(out/"润滑油压力小时特征.csv",rows,["机组","批次","周期编号","小时","运行样本数","运行覆盖率","油压均值","油压最小值","油压最大值","6小时油压中位数","6小时油压趋势","周期基线油压","正常波动带","低报警阈值","低低报警阈值","原始压力等级"])
  266. for r in rows:r["油压均值"]=r.pop("oil_avg");r["油压最小值"]=r.pop("oil_min");r["油压最大值"]=r.pop("oil_max")
  267. write(out/"润滑油压力小时特征.csv",rows,["机组","批次","周期编号","小时","运行样本数","运行覆盖率","油压均值","油压最小值","油压最大值","6小时油压中位数","6小时油压趋势","周期基线油压","低报警阈值","低低报警阈值","原始压力等级"])
  268. write(out/"润滑油压力小时阶段段.csv",oil,list(oil[0]) if oil else ["机组","压力阶段","开始小时","结束小时"]);write(out/"振动异常段.csv",vib,list(vib[0]) if vib else ["机组","振动等级","开始时间","结束时间"])
  269. print(f"小时特征:{len(rows):,} 行");print(f"油压阶段:{len(oil):,} 段");print(f"振动阶段:{len(vib):,} 段");print(f"输出:{out}")
  270. if __name__ == "__main__": main()