backtest.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. #!/usr/bin/env python3
  2. """Backtest the cylinder-temperature scan against labelled 4#/5#/6# faults.
  3. Reads ``_tmp_temperature/out/温度小时特征.csv`` and ``温度异常段.csv`` and
  4. reports, for every temperature-related fault:
  5. * the maximum same-stage spread change / peer deviation in the 7 days before;
  6. * whether a mild/abnormal/severe segment hit the pre-fault window;
  7. * the lead time.
  8. It also prints normal-period percentiles and a small threshold sweep so the
  9. "how many degC is a fault" question can be answered from data instead of a
  10. hand-picked 3 degC rule.
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import sys
  15. from datetime import timedelta
  16. from pathlib import Path
  17. import pandas as pd
  18. ROOT = Path(__file__).resolve().parents[1]
  19. if str(ROOT) not in sys.path:
  20. sys.path.insert(0, str(ROOT))
  21. from models.common.db import connect
  22. OUT = Path(__file__).resolve().parent / "out"
  23. LOOKBACK_DAYS = 7
  24. def load_events() -> pd.DataFrame:
  25. connection = connect()
  26. try:
  27. with connection.cursor() as cursor:
  28. cursor.execute(
  29. "SELECT id, unit_name, fault_date, fault_category, fault_description "
  30. "FROM compressor_fault "
  31. "WHERE unit_name IN ('4号机组','5号机组','6号机组') "
  32. "AND (fault_description LIKE %s OR fault_description LIKE %s "
  33. " OR fault_description LIKE %s OR fault_description LIKE %s) "
  34. "ORDER BY unit_name, fault_date",
  35. ("%缸温度%", "%排气温度%", "%排温%", "%温度异常%"),
  36. )
  37. rows = cursor.fetchall()
  38. finally:
  39. connection.close()
  40. frame = pd.DataFrame(rows)
  41. frame["event_date"] = pd.to_datetime(frame["fault_date"])
  42. return frame
  43. def main() -> None:
  44. parser = argparse.ArgumentParser()
  45. parser.add_argument("--dir", default=str(OUT))
  46. args = parser.parse_args()
  47. directory = Path(args.dir)
  48. features = pd.read_csv(directory / "温度小时特征.csv", low_memory=False)
  49. features["dt"] = pd.to_datetime(features["小时"])
  50. features["unit"] = features["机组"].str.replace("号机", "号机组", regex=False)
  51. for column in ("级差变化", "最大同级偏离", "最大同级偏低", "最大稳健z", "级差", "最高温度"):
  52. features[column] = pd.to_numeric(features[column], errors="coerce")
  53. segments = pd.read_csv(directory / "温度异常段.csv")
  54. segments = segments[segments["特征类型"] != "高频波动"].copy()
  55. segments["start"] = pd.to_datetime(segments["开始小时"])
  56. segments["end"] = pd.to_datetime(segments["结束小时"])
  57. segments["unit"] = segments["机组"].str.replace("号机", "号机组", regex=False)
  58. channel_path = directory / "温度通道异常段.csv"
  59. if channel_path.exists():
  60. channels = pd.read_csv(channel_path)
  61. channels["start"] = pd.to_datetime(channels["开始小时"])
  62. channels["end"] = pd.to_datetime(channels["结束小时"])
  63. channels["unit"] = channels["机组"].str.replace("号机", "号机组", regex=False)
  64. else:
  65. channels = pd.DataFrame(columns=["unit", "start", "end", "异常类型", "点位描述"])
  66. events = load_events()
  67. print(f"温度类故障事件:{len(events)} 起\n")
  68. def _closest_start(frame, moment):
  69. if frame.empty:
  70. return ""
  71. values = frame["start"].tolist()
  72. before = [value for value in values if value <= moment]
  73. chosen = max(before) if before else min(values)
  74. return chosen.strftime("%Y-%m-%d %H:%M")
  75. rows = []
  76. for _, event in events.iterrows():
  77. unit = event["unit_name"]
  78. date = event["event_date"]
  79. start = date - timedelta(days=LOOKBACK_DAYS)
  80. end = date + timedelta(days=1)
  81. window = features[(features["unit"] == unit) & (features["dt"] >= start) & (features["dt"] <= end)]
  82. hits = segments[(segments["unit"] == unit) & (segments["start"] <= end) & (segments["end"] >= start)]
  83. channel_hits = channels[(channels["unit"] == unit) & (channels["start"] <= end) & (channels["end"] >= start)]
  84. starts = []
  85. if not hits.empty:
  86. starts.extend(hits["start"].tolist())
  87. if not channel_hits.empty:
  88. starts.extend(channel_hits["start"].tolist())
  89. if starts:
  90. before = [value for value in starts if value <= end]
  91. first = max(before) if before else min(starts)
  92. else:
  93. first = pd.NaT
  94. lead = (date - first).total_seconds() / 3600 if starts else None
  95. def peak(stage: str, column: str):
  96. part = window[window["级"] == stage][column].dropna()
  97. return round(float(part.max()), 2) if not part.empty else None
  98. def peak_row(stage: str, column: str):
  99. part = window[(window["级"] == stage) & window[column].notna()]
  100. if part.empty:
  101. return None
  102. row = part.loc[part[column].idxmax()]
  103. return row
  104. stage = "一级" if "一级" in str(event["fault_description"]) else "二级" if "二级" in str(event["fault_description"]) else ""
  105. stages = [stage] if stage else ["一级", "二级"]
  106. best = None
  107. for candidate in stages:
  108. peak_peer = peak(candidate, "最大同级偏离")
  109. peak_spread = peak(candidate, "级差变化")
  110. score = max(value for value in (peak_peer, peak_spread) if value is not None) if (peak_peer is not None or peak_spread is not None) else -1
  111. if best is None or score > best[0]:
  112. best = (score, candidate, peak_peer, peak_spread)
  113. hit = not hits.empty or not channel_hits.empty
  114. status = "数据不足" if not window.empty and window["最高温度"].dropna().empty else ("命中" if hit else "未命中")
  115. rows.append({
  116. "机组": unit,
  117. "故障日期": date.date().isoformat(),
  118. "描述": event["fault_description"],
  119. "关注级": best[1] if best else "",
  120. "最大同级偏离℃": best[2] if best else None,
  121. "最大级差变化℃": best[3] if best else None,
  122. "命中": status,
  123. "首个事件开始": first.strftime("%Y-%m-%d %H:%M") if not hits.empty else "",
  124. "提前小时": round(lead, 1) if lead is not None else "",
  125. "命中等级": "、".join(sorted(set(pd.concat([hits["温度阶段"]]).astype(str)))) if not hits.empty else ("通道异常" if not channel_hits.empty else ""),
  126. "通道异常命中": "是" if not channel_hits.empty else "否",
  127. "通道异常类型": "、".join(sorted(set(channel_hits["异常类型"].astype(str)))) if not channel_hits.empty else "",
  128. "通道异常开始": _closest_start(channel_hits, end),
  129. "同日命中": "是" if starts and first.date() == date.date() else "否",
  130. })
  131. report = pd.DataFrame(rows)
  132. print(report.to_string(index=False))
  133. report.to_csv(directory / "回测_温度事件.csv", index=False, encoding="utf-8-sig")
  134. # ---- normal-period distribution ----
  135. event_mask = pd.Series(False, index=features.index)
  136. for _, event in events.iterrows():
  137. start = event["event_date"] - timedelta(days=3)
  138. end = event["event_date"] + timedelta(days=3)
  139. event_mask |= (features["unit"] == event["unit_name"]) & (features["dt"] >= start) & (features["dt"] <= end)
  140. normal = features[~event_mask]
  141. print("\n正常运行期分位数(排除故障日±3天):")
  142. for column in ("级差变化", "最大同级偏离", "最大同级偏低", "最大稳健z", "最高温度"):
  143. series = normal[column].dropna()
  144. if series.empty:
  145. continue
  146. print(f" {column:>6}: "
  147. f"P50={series.quantile(0.5):6.2f} P90={series.quantile(0.9):6.2f} "
  148. f"P99={series.quantile(0.99):6.2f} P99.5={series.quantile(0.995):6.2f} "
  149. f"P99.9={series.quantile(0.999):6.2f} max={series.max():6.2f}")
  150. # ---- threshold sweep ----
  151. print("\n阈值扫描(事件召回 vs 正常期小时触发率):")
  152. pre = []
  153. for _, event in events.iterrows():
  154. start = event["event_date"] - timedelta(days=LOOKBACK_DAYS)
  155. end = event["event_date"] + timedelta(days=1)
  156. part = features[(features["unit"] == event["unit_name"]) & (features["dt"] >= start) & (features["dt"] <= end)]
  157. pre.append(part)
  158. print(" peer\\spread | " + " ".join(f"{spread:>6}" for spread in (2, 3, 4, 5, 6, 8)))
  159. for peer in (2, 3, 4, 5, 6, 8, 10):
  160. cells = []
  161. for spread in (2, 3, 4, 5, 6, 8):
  162. hit_count = 0
  163. for part in pre:
  164. mask = (part["最大同级偏离"] >= peer) | (part["级差变化"] >= spread)
  165. if mask.any():
  166. hit_count += 1
  167. recall = hit_count / len(pre) if pre else 0
  168. normal_mask = (normal["最大同级偏离"] >= peer) | (normal["级差变化"] >= spread)
  169. normal_rate = normal_mask.mean() if not normal.empty else 0
  170. cells.append(f"{recall:.2f}/{normal_rate*100:.2f}%")
  171. print(f" {peer:>4} | " + " ".join(f"{cell:>7}" for cell in cells))
  172. print(" 单元格 = 事件召回 / 正常小时触发率(百分比)")
  173. if __name__ == "__main__":
  174. main()