vibration_algorithm.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. #!/usr/bin/env python3
  2. """PKS vibration anomaly-segment algorithm (vibration half of the scanner).
  3. Vibration-only reconstruction of ``backend/scan_pks_fault_evolution.py``. The
  4. ported functions are byte-for-byte equivalent to the original so the exported
  5. ``振动异常段.csv`` stays identical. Shared query/IO/statistics helpers live in
  6. :mod:`models.common`.
  7. CLI
  8. ---
  9. ::
  10. python models/vibration/vibration_algorithm.py \
  11. --hourly-csv models/datas/hourly/pks_hourly_full.csv \
  12. --output-dir models/results/vibration
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import sys
  17. from collections import defaultdict
  18. from datetime import timedelta
  19. from pathlib import Path
  20. from statistics import median
  21. if __package__ in (None, ""):
  22. sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
  23. from models.common import db, devices, io, paths, queries
  24. from models.common.constants import (
  25. MIN_HOUR_COVERAGE,
  26. VIBRATION_BASELINE_DAYS,
  27. VIBRATION_MERGE_GAP_HOURS,
  28. VIBRATION_RESTART_GAP_HOURS,
  29. )
  30. from models.common.stats import median_value, robust_baseline
  31. def vibration_hour_signals(rows: list[dict], configs: dict[int, dict]):
  32. signals = []
  33. grouped = defaultdict(list)
  34. for row in rows:
  35. if row["运行覆盖率"] >= MIN_HOUR_COVERAGE:
  36. grouped[row["批次"]].append(row)
  37. for batch, series in grouped.items():
  38. series.sort(key=lambda row: row["_dt"])
  39. config = configs[batch]
  40. valid = []
  41. last_valid_dt = None
  42. for row in series:
  43. if row["运行覆盖率"] < MIN_HOUR_COVERAGE:
  44. continue
  45. restart = last_valid_dt is None or row["_dt"] - last_valid_dt > timedelta(hours=VIBRATION_RESTART_GAP_HOURS)
  46. if restart:
  47. # The first complete running hour after a restart is a
  48. # transition hour. Keep older valid hours for the long-term
  49. # robust reference, but do not evaluate this transition hour.
  50. last_valid_dt = row["_dt"]
  51. continue
  52. baseline_start = row["_dt"] - timedelta(days=VIBRATION_BASELINE_DAYS)
  53. prior = [item for item in valid if item["_dt"] >= baseline_start]
  54. side_signals = []
  55. for side in ("coupling", "chain"):
  56. avg_center, avg_scale = robust_baseline([item[f"{side}_avg"] for item in prior])
  57. max_center, max_scale = robust_baseline([item[f"{side}_max"] for item in prior])
  58. current_avg = row[f"{side}_avg"]
  59. current_max = row[f"{side}_max"]
  60. if current_max is None:
  61. continue
  62. avg_z = ((current_avg - avg_center) / avg_scale) if avg_center is not None and current_avg is not None else 0.0
  63. peak_z = (current_max - max_center) / max_scale if max_center is not None and max_scale else 0.0
  64. running = max(row["运行样本数"], 1)
  65. high_ratio = (row[f"{side}_high_count"] or 0) / running
  66. high_high_ratio = (row[f"{side}_high_high_count"] or 0) / running
  67. limits = config[side]
  68. local_prior = valid[:6] if len(valid) >= 6 else []
  69. local_avg = median_value([item[f"{side}_avg"] for item in local_prior])
  70. local_max = median_value([item[f"{side}_max"] for item in local_prior])
  71. local_rise = False
  72. severe = (
  73. high_high_ratio >= 0.01
  74. or current_max >= limits["high_high"]
  75. or peak_z >= 8
  76. )
  77. abnormal = (
  78. high_ratio >= 0.01
  79. or current_max >= limits["high"]
  80. or peak_z >= 5
  81. or avg_z >= 4
  82. or local_rise
  83. )
  84. mild = peak_z >= 4 or avg_z >= 3 or local_rise
  85. if severe or abnormal or mild:
  86. side_signals.append({
  87. "side": side,
  88. "avg_z": avg_z,
  89. "peak_z": peak_z,
  90. "high_ratio": high_ratio,
  91. "high_high_ratio": high_high_ratio,
  92. "severe": severe,
  93. "abnormal": abnormal,
  94. "value": current_max,
  95. "avg": current_avg,
  96. "prior_max_center": max_center,
  97. "prior_max_scale": max_scale,
  98. "prior_avg_center": avg_center,
  99. })
  100. if side_signals:
  101. strongest = max(side_signals, key=lambda item: (item["severe"], item["peak_z"], item["avg_z"]))
  102. signal_level = 3 if any(item["severe"] for item in side_signals) else 2 if any(item["abnormal"] for item in side_signals) else 1
  103. reasons = []
  104. for item in side_signals:
  105. label = "联轴器端" if item["side"] == "coupling" else "链轮端"
  106. reasons.append(
  107. f"{label}均值偏离{item['avg_z']:.1f}倍、峰值偏离{item['peak_z']:.1f}倍、"
  108. f"高报警比例{item['high_ratio']:.1%}"
  109. )
  110. signals.append({
  111. "批次": batch,
  112. "机组": config["unit"],
  113. "_dt": row["_dt"],
  114. "_level": signal_level,
  115. "_severe": signal_level == 3,
  116. "_side_signals": side_signals,
  117. "_strongest": strongest,
  118. "_reason": ";".join(reasons),
  119. })
  120. valid.append(row)
  121. last_valid_dt = row["_dt"]
  122. return signals
  123. def merge_vibration_signals(signals: list[dict], configs: dict[int, dict]):
  124. grouped = defaultdict(list)
  125. for signal in signals:
  126. grouped[signal["批次"]].append(signal)
  127. events = []
  128. for batch, series in grouped.items():
  129. series.sort(key=lambda item: item["_dt"])
  130. current = []
  131. for signal in series:
  132. if not current or signal["_dt"] - current[-1]["_dt"] <= timedelta(hours=VIBRATION_MERGE_GAP_HOURS):
  133. current.append(signal)
  134. else:
  135. events.append(build_vibration_event(current, configs[batch]))
  136. current = [signal]
  137. if current:
  138. events.append(build_vibration_event(current, configs[batch]))
  139. events.sort(key=lambda row: (queries.unit_sort_key(row["机组"]), row["开始小时"], row["批次"]))
  140. unit_indexes = defaultdict(int)
  141. for event in events:
  142. unit_indexes[event["机组"]] += 1
  143. event["周期编号"] = f'{event["机组"]}-V{unit_indexes[event["机组"]]:02d}'
  144. filtered = []
  145. for event in events:
  146. # A single post-start relative-rise signal is not sufficient evidence;
  147. # absolute severe signals remain eligible on their own.
  148. if event["_严重信号小时数"] > 0 or event["_触发小时数"] >= 2:
  149. filtered.append(event)
  150. return sorted(filtered, key=io.output_sort_key)
  151. def vibration_score(duration_hours: int, valid_hours: int, avg_change: float, max_value: float, peak_z: float, high_hours: int, high_high_hours: int, config: dict, side: str):
  152. duration_score = min(40.0, max(0.0, (duration_hours - 1) / 12 * 40))
  153. avg_score = min(20.0, max(0.0, (avg_change - 0.10) / 0.40 * 20)) if avg_change >= 0.10 else 0.0
  154. high = config[side]["high"]
  155. high_high = config[side]["high_high"]
  156. if max_value < high:
  157. max_score = min(10.0, max(0.0, (max_value / max(high, 1e-6) - 0.8) / 0.2 * 10))
  158. elif max_value < high_high:
  159. max_score = 10.0 + (max_value - high) / max(high_high - high, 1e-6) * 10.0
  160. else:
  161. max_score = 20.0
  162. peak_score = min(20.0, max(0.0, (peak_z - 4.0) / 8.0 * 20.0)) if peak_z >= 4.0 else 0.0
  163. total = duration_score + avg_score + max_score + peak_score
  164. hard_severe = max_value >= high_high or high_high_hours > 0 or (peak_z >= 8.0 and valid_hours >= 2)
  165. if hard_severe or total >= 60.0:
  166. level = "严重异常"
  167. elif total >= 30.0:
  168. level = "异常"
  169. else:
  170. level = "轻微"
  171. return total, level, duration_score, avg_score, max_score, peak_score
  172. def build_vibration_event(signals: list[dict], config: dict):
  173. start = signals[0]["_dt"]
  174. end = signals[-1]["_dt"]
  175. all_side = [item for signal in signals for item in signal["_side_signals"]]
  176. strongest = max(all_side, key=lambda item: (item["severe"], item["peak_z"], item["value"]))
  177. main_side = "联轴器端" if strongest["side"] == "coupling" else "链轮端"
  178. max_value = max(item["value"] for item in all_side if item["value"] is not None)
  179. max_peak_z = max(item["peak_z"] for item in all_side)
  180. high_hours = sum(
  181. 1 for signal in signals
  182. if any(item["high_ratio"] > 0 for item in signal["_side_signals"])
  183. )
  184. high_high_hours = sum(
  185. 1 for signal in signals
  186. if any(item["high_high_ratio"] > 0 for item in signal["_side_signals"])
  187. )
  188. severe_hours = sum(1 for signal in signals if signal["_severe"])
  189. duration_hours = int((end - start).total_seconds() / 3600) + 1
  190. running_days = len({signal["_dt"].date() for signal in signals})
  191. side_avg_changes = []
  192. for side in ("coupling", "chain"):
  193. values = [item["avg"] for item in all_side if item["side"] == side and item["avg"] is not None]
  194. prior_values = [item["prior_avg_center"] for item in all_side if item["side"] == side and item["prior_avg_center"] is not None]
  195. if values and prior_values and median(prior_values) != 0:
  196. side_avg_changes.append((median(values) / median(prior_values) - 1, side))
  197. avg_change, _ = max(side_avg_changes, key=lambda item: item[0], default=(0.0, strongest["side"]))
  198. peak_ratio = max_value / max(strongest["prior_max_center"], 1e-6)
  199. if max_value >= config[strongest["side"]]["high_high"] or severe_hours > 0 and (high_high_hours > 0 or max_peak_z >= 8):
  200. level = "严重异常"
  201. elif max_value >= config[strongest["side"]]["high"] and high_hours >= 2:
  202. level = "严重异常"
  203. elif max_value >= config[strongest["side"]]["high"] or max_peak_z >= 5 or avg_change >= 0.10:
  204. level = "异常"
  205. else:
  206. level = "轻微"
  207. if peak_ratio >= 1.5 and avg_change < 0.25:
  208. shape = "间歇冲击型"
  209. elif avg_change >= 0.10 and len(signals) >= 3:
  210. shape = "持续抬升型"
  211. else:
  212. shape = "混合型"
  213. peak_signal = max(signals, key=lambda signal: signal["_strongest"]["peak_z"])
  214. first_signal = signals[0]
  215. last_signal = signals[-1]
  216. first_value = first_signal["_strongest"]["value"]
  217. last_value = last_signal["_strongest"]["value"]
  218. baseline = strongest["prior_max_center"]
  219. scale = max(strongest.get("prior_max_scale", 0.0), 0.0)
  220. normal_band = f"{baseline - 2 * scale:.3f}~{baseline + 2 * scale:.3f} mm/s" if baseline is not None else ""
  221. consecutive = 0
  222. longest_consecutive = 0
  223. for signal in signals:
  224. if signal["_level"] >= 2:
  225. consecutive += 1
  226. longest_consecutive = max(longest_consecutive, consecutive)
  227. else:
  228. consecutive = 0
  229. score, level, duration_score, avg_score, max_score, peak_score = vibration_score(
  230. duration_hours, len(signals), avg_change, max_value, max_peak_z,
  231. high_hours, high_high_hours, config, strongest["side"]
  232. )
  233. confirmed = "是" if level != "轻微" else "否"
  234. if shape == "持续抬升型":
  235. basis = f"{main_side}振动均值持续抬升,阶段内{longest_consecutive or len(signals)}个有效运行小时触发"
  236. elif shape == "间歇冲击型":
  237. basis = f"{main_side}重复出现振动冲击,阶段内{len(signals)}个有效运行小时触发"
  238. else:
  239. basis = f"{main_side}振动峰值或均值偏离近期基线,阶段内{len(signals)}个有效运行小时触发"
  240. if high_high_hours:
  241. basis += f";高高报警小时{high_high_hours}个"
  242. elif high_hours:
  243. basis += f";高报警小时{high_hours}个"
  244. basis += f";最大振动值{max_value:.3f}mm/s,峰值高于近期基线{max_peak_z:.2f}个稳健尺度(峰值/基线{peak_ratio:.2f}倍);综合评分{score:.1f}分"
  245. return {
  246. "机组": config["unit"],
  247. "批次": config["batch"],
  248. "周期编号": "",
  249. "振动阶段": level,
  250. "开始小时": start.strftime("%Y-%m-%d %H:%M:%S"),
  251. "结束小时": end.strftime("%Y-%m-%d %H:%M:%S"),
  252. "持续自然小时数": duration_hours,
  253. "有效运行小时数": len(signals),
  254. "近期基线振动": baseline,
  255. "正常波动带": normal_band,
  256. "阶段开始小时振动": first_value,
  257. "阶段结束小时振动": last_value,
  258. "阶段最大振动值": max_value,
  259. "阶段开始相对近期基线变化": (first_value / baseline - 1) if baseline else None,
  260. "阶段结束相对近期基线变化": (last_value / baseline - 1) if baseline else None,
  261. "阶段最大连续异常有效小时数": longest_consecutive,
  262. "阶段综合评分": score,
  263. "持续时间得分": duration_score,
  264. "平均振动得分": avg_score,
  265. "最大振动值得分": max_score,
  266. "峰值偏离得分": peak_score,
  267. "最强异常侧": main_side,
  268. "最强侧近期峰值基线": baseline,
  269. "最强侧近期稳健尺度": scale,
  270. "阶段最大峰值/基线比例": peak_ratio,
  271. "阶段高报警小时数": high_hours,
  272. "阶段高高报警小时数": high_high_hours,
  273. "_严重信号小时数": severe_hours,
  274. "_触发小时数": len(signals),
  275. "是否形成确认等级": confirmed,
  276. "阶段触发依据": basis,
  277. }
  278. VIBRATION_FIELDS = [
  279. "机组", "批次", "周期编号", "振动阶段", "开始小时", "结束小时", "持续自然小时数", "有效运行小时数",
  280. "近期基线振动", "正常波动带", "阶段开始小时振动", "阶段结束小时振动", "阶段最大振动值",
  281. "阶段开始相对近期基线变化", "阶段结束相对近期基线变化", "阶段最大连续异常有效小时数",
  282. "阶段高报警小时数", "阶段高高报警小时数", "是否形成确认等级", "阶段触发依据",
  283. "阶段综合评分", "持续时间得分", "平均振动得分", "最大振动值得分", "峰值偏离得分",
  284. "最强异常侧", "最强侧近期峰值基线", "最强侧近期稳健尺度", "阶段最大峰值/基线比例",
  285. ]
  286. def run(rows: list[dict], configs: dict[int, dict]):
  287. """Return vibration anomaly events."""
  288. signals = vibration_hour_signals(rows, configs)
  289. return merge_vibration_signals(signals, configs)
  290. def write_results(output_dir: Path, events, extra_fields=()):
  291. output_dir = Path(output_dir)
  292. fields = [field for field in VIBRATION_FIELDS if field not in extra_fields] + list(extra_fields)
  293. io.write_csv(output_dir / "振动异常段.csv", io.clean_rows(events), fields, {field: field for field in fields})
  294. def load_hourly(args, selected_batches, unit_by_batch, configs):
  295. if args.hourly_csv:
  296. selected_set = set(selected_batches)
  297. return [row for row in queries.load_hourly_csv(Path(args.hourly_csv)) if row["批次"] in selected_set]
  298. start, end = queries.parse_dt(args.start), queries.parse_dt(args.end)
  299. return queries.fetch_all_hourly(start, end, args.chunk_days, unit_by_batch, configs, selected_batches)
  300. def main() -> None:
  301. parser = argparse.ArgumentParser(description="PKS 振动异常段算法(独立于压力)")
  302. parser.add_argument("--device", nargs="+", default=list(devices.DEVICE_CODES), help="压缩机编号,如 7# 8# 9#")
  303. parser.add_argument("--start", default="2025-01-01 00:00:00")
  304. parser.add_argument("--end", default="2026-09-01 00:00:00")
  305. parser.add_argument("--chunk-days", type=int, default=14)
  306. parser.add_argument("--hourly-csv", default=None)
  307. parser.add_argument("--output-dir", default=str(paths.VIBRATION_RESULTS))
  308. args = parser.parse_args()
  309. devices_selected = devices.normalize_devices(args.device)
  310. device_batches = devices.load_device_batches(devices_selected)
  311. unit_by_batch = devices.batch_to_unit(device_batches)
  312. configs = db.load_alarm_config(unit_by_batch)
  313. selected_batches = sorted(unit_by_batch)
  314. rows = load_hourly(args, selected_batches, unit_by_batch, configs)
  315. events = run(rows, configs)
  316. write_results(Path(args.output_dir), events)
  317. print(f"设备:{devices_selected}")
  318. print(f"小时特征:{len(rows):,} 行")
  319. print(f"振动异常段:{len(events):,} 段")
  320. print(f"输出目录:{args.output_dir}")
  321. if __name__ == "__main__":
  322. main()