run.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. """5 号机缸体振动:粗筛 -> 二次细评 -> 事件验证。
  2. 用法:
  3. python3 _tmp_cylinder_vibration/run.py --stage all
  4. python3 _tmp_cylinder_vibration/run.py --stage coarse
  5. python3 _tmp_cylinder_vibration/run.py --stage all --refresh
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import json
  10. import sys
  11. from datetime import datetime
  12. from pathlib import Path
  13. import matplotlib
  14. matplotlib.use("Agg")
  15. import matplotlib.pyplot as plt
  16. import numpy as np
  17. import pandas as pd
  18. from matplotlib.patches import Patch
  19. ROOT = Path(__file__).resolve().parents[1]
  20. if str(ROOT) not in sys.path:
  21. sys.path.insert(0, str(ROOT))
  22. from _tmp_cylinder_vibration import config, data, features, labels
  23. def parse_time(value: str | None) -> datetime | None:
  24. if not value:
  25. return None
  26. return datetime.fromisoformat(value)
  27. def extract_rows(metadata: list[dict], chunk_size: int, tag: str) -> list[dict]:
  28. rows: list[dict] = []
  29. total = len(metadata)
  30. for offset in range(0, total, chunk_size):
  31. chunk = metadata[offset:offset + chunk_size]
  32. samples = data.fetch_samples([int(item["id"]) for item in chunk], chunk_size=chunk_size)
  33. for item in chunk:
  34. signal = samples.get(int(item["id"]))
  35. if signal is None:
  36. continue
  37. feature = features.extract_features(
  38. signal,
  39. float(item.get("sample_frequency_hz") or 0),
  40. float(item.get("rpm") or 0),
  41. )
  42. if feature is None:
  43. continue
  44. row = dict(item)
  45. row.update(feature)
  46. rows.append(row)
  47. done = min(offset + chunk_size, total)
  48. print(f"[{tag}] {done:,}/{total:,} files, features={len(rows):,}", flush=True)
  49. return rows
  50. def load_or_extract(
  51. metadata: list[dict],
  52. output_csv: Path,
  53. chunk_size: int,
  54. tag: str,
  55. refresh: bool,
  56. ) -> list[dict]:
  57. if output_csv.exists() and not refresh:
  58. print(f"[{tag}] reuse {output_csv}", flush=True)
  59. return pd.read_csv(output_csv).to_dict("records")
  60. rows = extract_rows(metadata, chunk_size, tag)
  61. if rows:
  62. pd.DataFrame(rows).to_csv(output_csv, index=False)
  63. print(f"[{tag}] wrote {output_csv} ({len(rows):,} rows)", flush=True)
  64. return rows
  65. def metadata_for_windows(
  66. windows: dict[str, list[tuple[datetime, datetime]]],
  67. chunk_size: int,
  68. ) -> list[dict]:
  69. rows: list[dict] = []
  70. for cylinder, intervals in windows.items():
  71. start = min(item[0] for item in intervals)
  72. end = max(item[1] for item in intervals)
  73. items = data.fetch_metadata([cylinder], start, end)
  74. for item in items:
  75. sample_time = pd.Timestamp(item["sample_time"]).to_pydatetime()
  76. if any(window_start <= sample_time <= window_end for window_start, window_end in intervals):
  77. rows.append(item)
  78. print(f"[refine] {cylinder}: windows={len(intervals)}, files={len(items):,}", flush=True)
  79. unique: dict[int, dict] = {}
  80. for item in rows:
  81. unique[int(item["id"])] = item
  82. return list(unique.values())
  83. def write_frame(frame: pd.DataFrame, path: Path) -> None:
  84. if frame.empty:
  85. frame.to_csv(path, index=False)
  86. return
  87. frame.to_csv(path, index=False, date_format="%Y-%m-%d %H:%M:%S")
  88. def _plot_gap_aware(ax, x, y, gap_minutes: int = 60, **kwargs) -> None:
  89. x = pd.Series(pd.to_datetime(x)).reset_index(drop=True)
  90. y = pd.Series(np.asarray(y, dtype=float)).reset_index(drop=True)
  91. gaps = x.diff() > pd.Timedelta(minutes=gap_minutes)
  92. y = y.mask(gaps)
  93. ax.plot(x, y, **kwargs)
  94. def plot_events(frame: pd.DataFrame, vibration_labels: pd.DataFrame, temperature: pd.DataFrame) -> None:
  95. config.PLOT_DIR.mkdir(parents=True, exist_ok=True)
  96. for event in config.EVENTS:
  97. cylinder = event["cylinder"]
  98. center = pd.Timestamp(event["date"])
  99. window_start = center - pd.Timedelta(days=10)
  100. window_end = center + pd.Timedelta(days=3)
  101. part = frame[
  102. (frame["cylinder"] == cylinder)
  103. & (frame["sample_time"] >= window_start)
  104. & (frame["sample_time"] <= window_end)
  105. ].sort_values("sample_time")
  106. if part.empty:
  107. continue
  108. fig, axes = plt.subplots(3, 1, figsize=(14, 9), sharex=True)
  109. _plot_gap_aware(axes[0], part["sample_time"], part["score"], color="#c0392b", linewidth=1.1, label="score")
  110. if "threshold" in part:
  111. _plot_gap_aware(axes[0], part["sample_time"], part["threshold"], color="#7f8c8d", linestyle="--", label="threshold")
  112. axes[0].axvline(center, color="#2c3e50", linestyle="-", linewidth=1.2, label="fault date")
  113. axes[0].set_ylabel("anomaly score")
  114. axes[0].legend(loc="upper left", fontsize=8)
  115. axes[0].grid(alpha=0.2)
  116. if not vibration_labels.empty:
  117. segments = vibration_labels[vibration_labels["cylinder"] == cylinder]
  118. for row in segments.itertuples(index=False):
  119. if pd.Timestamp(row.end_time) < window_start or pd.Timestamp(row.start_time) > window_end:
  120. continue
  121. color = {"高": "#e74c3c", "中": "#e67e22", "低": "#f1c40f"}.get(row.confidence, "#95a5a6")
  122. if pd.Timestamp(row.start_time) == pd.Timestamp(row.end_time):
  123. axes[0].axvline(row.start_time, color=color, linestyle=":", linewidth=1.5, alpha=0.8)
  124. else:
  125. axes[0].axvspan(row.start_time, row.end_time, color=color, alpha=0.15)
  126. if not temperature.empty:
  127. temp = temperature[
  128. (temperature["cylinder"] == cylinder)
  129. & (temperature["end_time"] >= window_start)
  130. & (temperature["start_time"] <= window_end)
  131. ]
  132. for row in temp.itertuples(index=False):
  133. axes[0].axvspan(row.start_time, row.end_time, color="#7f8c8d", alpha=0.12, hatch="//")
  134. for column, color in (("z_resid_rms_log", "#2980b9"), ("z_resid_kurt", "#8e44ad")):
  135. if column in part:
  136. _plot_gap_aware(axes[1], part["sample_time"], part[column], linewidth=1.0, color=color, label=column)
  137. axes[1].axhline(0, color="#bdc3c7", linewidth=0.8)
  138. axes[1].set_ylabel("robust z")
  139. axes[1].legend(loc="upper left", fontsize=8)
  140. axes[1].grid(alpha=0.2)
  141. for column, color in (("z_resid_env_1x_log", "#16a085"), ("z_resid_hf_ratio", "#d35400")):
  142. if column in part:
  143. _plot_gap_aware(axes[2], part["sample_time"], part[column], linewidth=1.0, color=color, label=column)
  144. axes[2].axhline(0, color="#bdc3c7", linewidth=0.8)
  145. axes[2].set_ylabel("robust z")
  146. axes[2].set_xlabel("time")
  147. axes[2].legend(loc="upper left", fontsize=8)
  148. axes[2].grid(alpha=0.2)
  149. index = config.CYLINDER_TO_INDEX[cylinder]
  150. handles, labels = axes[0].get_legend_handles_labels()
  151. handles.append(Patch(facecolor="#e67e22", alpha=0.25, label="vibration label"))
  152. labels.append("vibration label")
  153. handles.append(Patch(facecolor="#7f8c8d", alpha=0.2, hatch="//", label="temperature reference"))
  154. labels.append("temperature reference")
  155. axes[0].legend(handles, labels, loc="upper left", fontsize=8)
  156. axes[0].set_title(f"{event['event_id']} cylinder#{index} (gaps = no running data)")
  157. fig.tight_layout()
  158. path = config.PLOT_DIR / f"event_{event['event_id']}_cyl{index}.png"
  159. fig.savefig(path, dpi=130)
  160. plt.close(fig)
  161. print(f"[plot] {path}", flush=True)
  162. def plot_heatmap(frame: pd.DataFrame) -> None:
  163. if frame.empty:
  164. return
  165. daily = frame.assign(day=frame["sample_time"].dt.date).pivot_table(
  166. index="day", columns="cylinder", values="score", aggfunc="max"
  167. )
  168. if daily.empty:
  169. return
  170. ordered = [name for name in config.CYLINDERS if name in daily.columns]
  171. daily = daily[ordered]
  172. fig, ax = plt.subplots(figsize=(13, 5))
  173. image = ax.imshow(daily.T.to_numpy(), aspect="auto", cmap="YlOrRd", interpolation="nearest")
  174. ax.set_yticks(range(len(ordered)))
  175. ax.set_yticklabels([f"cyl{config.CYLINDER_TO_INDEX[name]}" for name in ordered])
  176. ax.set_xticks(range(0, len(daily.index), max(1, len(daily.index) // 12)))
  177. ax.set_xticklabels([str(daily.index[i]) for i in range(0, len(daily.index), max(1, len(daily.index) // 12))], rotation=45, ha="right")
  178. ax.set_title("daily max vibration anomaly score")
  179. fig.colorbar(image, ax=ax, fraction=0.025, pad=0.02)
  180. fig.tight_layout()
  181. path = config.PLOT_DIR / "score_heatmap.png"
  182. fig.savefig(path, dpi=130)
  183. plt.close(fig)
  184. print(f"[plot] {path}", flush=True)
  185. def main() -> None:
  186. parser = argparse.ArgumentParser()
  187. parser.add_argument("--stage", choices=("coarse", "refine", "all"), default="all")
  188. parser.add_argument("--coarse-minutes", type=int, default=60)
  189. parser.add_argument("--chunk-size", type=int, default=200)
  190. parser.add_argument("--limit-per-cylinder", type=int, default=0)
  191. parser.add_argument("--start", default=None)
  192. parser.add_argument("--end", default=None)
  193. parser.add_argument("--refresh", action="store_true")
  194. args = parser.parse_args()
  195. config.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  196. start = parse_time(args.start)
  197. end = parse_time(args.end)
  198. summary: dict = {
  199. "unit": config.UNIT,
  200. "coarse_minutes": args.coarse_minutes,
  201. "stage": args.stage,
  202. }
  203. coarse_features_path = config.OUTPUT_DIR / "vibration_features_coarse.csv"
  204. coarse_labels_path = config.OUTPUT_DIR / "vibration_labels_coarse.csv"
  205. refined_features_path = config.OUTPUT_DIR / "vibration_features_refined.csv"
  206. refined_labels_path = config.OUTPUT_DIR / "vibration_labels_refined.csv"
  207. validation_path = config.OUTPUT_DIR / "event_validation_refined.csv"
  208. baseline_path = config.OUTPUT_DIR / "baseline.json"
  209. summary_path = config.OUTPUT_DIR / "run_summary.json"
  210. coarse_labels = pd.DataFrame()
  211. if args.stage in ("coarse", "all"):
  212. print("[coarse] fetch metadata ...", flush=True)
  213. metadata = data.fetch_metadata(start=start, end=end)
  214. coarse_metadata = data.coarse_metadata(metadata, args.coarse_minutes)
  215. if args.limit_per_cylinder > 0:
  216. limited = []
  217. for _, group in pd.DataFrame(coarse_metadata).groupby("point_name", sort=False):
  218. limited.extend(group.head(args.limit_per_cylinder).to_dict("records"))
  219. coarse_metadata = limited
  220. print(f"[coarse] files={len(coarse_metadata):,} (all={len(metadata):,})", flush=True)
  221. rows = load_or_extract(coarse_metadata, coarse_features_path, args.chunk_size, "coarse", args.refresh)
  222. coarse_frame = labels.prepare_frame(rows, args.coarse_minutes)
  223. coarse_baseline = labels.compute_baseline(coarse_frame)
  224. baseline_path.write_text(json.dumps(coarse_baseline, ensure_ascii=False, indent=2), encoding="utf-8")
  225. coarse_frame, coarse_labels, coarse_thresholds = labels.build_labels(
  226. coarse_frame, args.coarse_minutes, baseline=coarse_baseline
  227. )
  228. write_frame(coarse_frame, coarse_features_path)
  229. write_frame(coarse_labels, coarse_labels_path)
  230. summary.update(
  231. {
  232. "coarse_files": len(coarse_metadata),
  233. "coarse_feature_rows": len(coarse_frame),
  234. "coarse_labels": len(coarse_labels),
  235. "coarse_thresholds": coarse_thresholds,
  236. "baseline": str(baseline_path),
  237. }
  238. )
  239. print(f"[coarse] labels={len(coarse_labels):,}", flush=True)
  240. if args.stage in ("refine", "all"):
  241. if coarse_labels.empty and coarse_labels_path.exists():
  242. coarse_labels = pd.read_csv(coarse_labels_path)
  243. # 二次评估固定围绕 3 次事件取窗口;粗筛候选只作为阶段一结果保留。
  244. windows = data.refine_windows(pd.DataFrame(), config.EVENTS)
  245. print(f"[refine] windows: {[(name, len(items)) for name, items in windows.items()]}", flush=True)
  246. refine_metadata = metadata_for_windows(windows, args.chunk_size)
  247. print(f"[refine] files={len(refine_metadata):,}", flush=True)
  248. rows = load_or_extract(refine_metadata, refined_features_path, args.chunk_size, "refine", args.refresh)
  249. refined_frame = labels.prepare_frame(rows, 15)
  250. baseline = None
  251. if baseline_path.exists():
  252. baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
  253. print(f"[refine] baseline {baseline_path}", flush=True)
  254. refined_frame, refined_labels, refined_thresholds = labels.build_labels(refined_frame, 15, baseline=baseline)
  255. write_frame(refined_frame, refined_features_path)
  256. write_frame(refined_labels, refined_labels_path)
  257. temperature = data.load_temperature_reference()
  258. validation = labels.validate_events(refined_labels, refined_frame, temperature)
  259. write_frame(validation, validation_path)
  260. summary.update(
  261. {
  262. "refine_files": len(refine_metadata),
  263. "refine_feature_rows": len(refined_frame),
  264. "refine_labels": len(refined_labels),
  265. "refine_thresholds": refined_thresholds,
  266. "event_hits": int(validation["vibration_hit"].sum()) if not validation.empty else 0,
  267. "false_alarms": int(validation.attrs.get("false_alarm_count", 0)),
  268. "false_alarms_per_30d": float(validation.attrs.get("false_alarm_per_30d", 0.0)),
  269. "event_validation": validation.to_dict("records"),
  270. }
  271. )
  272. plot_events(refined_frame, refined_labels, temperature)
  273. plot_heatmap(refined_frame)
  274. summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
  275. print(json.dumps(summary, ensure_ascii=False, indent=2, default=str))
  276. print(f"output: {config.OUTPUT_DIR}")
  277. if __name__ == "__main__":
  278. main()