machine_events.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. """整机多缸振动事件标签。
  2. 最终标签不再按单缸输出,而是按稳定运行段内的整机多缸聚集事件输出:
  3. - 排除开机后 6 小时、停机前 6 小时;
  4. - 基线至少成熟 7 天;
  5. - 24 小时窗口内至少 4 个不同缸有候选标签;
  6. - 其中至少 2 条达到“异常”或“严重异常”;
  7. - 相邻候选窗口合并为同一个整机事件;
  8. - 事件只描述“整机可能异常”,不指定故障缸。
  9. """
  10. from __future__ import annotations
  11. from datetime import timedelta
  12. from pathlib import Path
  13. import numpy as np
  14. import pandas as pd
  15. from . import config, unified_labels
  16. SEVERITY = {"轻微": 1, "异常": 2, "严重异常": 3}
  17. LEVELS = ("轻微", "异常", "严重异常")
  18. BAD_LEVELS = {"异常", "严重异常"}
  19. MACHINE_FIELDS = unified_labels.CORE_FIELDS + [
  20. "参与缸数", "参与缸", "异常缸数", "严重异常缸数",
  21. "异常标签数", "严重异常标签数", "候选标签数",
  22. "最大异常分数", "最大RMS", "最大峭度",
  23. "转速范围", "运行段开始", "运行段结束", "整机聚集窗口小时",
  24. ]
  25. def build_runs(frame: pd.DataFrame, gap_hours: float = 2.0) -> pd.DataFrame:
  26. frame = frame.copy()
  27. frame["sample_time"] = pd.to_datetime(frame["sample_time"])
  28. running = frame.loc[frame["rpm"].astype(float) > 0, ["sample_time"]].drop_duplicates()
  29. running = running.sort_values("sample_time").reset_index(drop=True)
  30. if running.empty:
  31. return pd.DataFrame(columns=["run_id", "start", "end", "hours"])
  32. running["gap_hours"] = running["sample_time"].diff().dt.total_seconds() / 3600.0
  33. running["run_id"] = (running["gap_hours"] > gap_hours).cumsum().astype(int)
  34. rows = []
  35. for run_id, group in running.groupby("run_id", sort=True):
  36. start = pd.Timestamp(group["sample_time"].min())
  37. end = pd.Timestamp(group["sample_time"].max())
  38. rows.append({
  39. "run_id": int(run_id),
  40. "start": start,
  41. "end": end,
  42. "hours": round((end - start).total_seconds() / 3600.0, 2),
  43. })
  44. return pd.DataFrame(rows)
  45. def _run_index(timestamp: pd.Timestamp, runs: pd.DataFrame, tolerance_hours: float = 1.0) -> int | None:
  46. tolerance = pd.Timedelta(hours=tolerance_hours)
  47. for row in runs.itertuples(index=False):
  48. if row.start - tolerance <= timestamp <= row.end + tolerance:
  49. return int(row.run_id)
  50. return None
  51. def prepare_candidates(
  52. labels: pd.DataFrame,
  53. frame: pd.DataFrame,
  54. runs: pd.DataFrame,
  55. guard_start_hours: int = 6,
  56. guard_end_hours: int = 6,
  57. ) -> pd.DataFrame:
  58. labels = labels.copy()
  59. labels["开始小时"] = pd.to_datetime(labels["开始小时"])
  60. labels["结束小时"] = pd.to_datetime(labels["结束小时"])
  61. labels["cylinder"] = labels["阶段部位"].astype(str).str.replace("5号机", "", regex=False)
  62. run_by_id = runs.set_index("run_id")
  63. rows = []
  64. for row in labels.itertuples(index=False):
  65. run_id = _run_index(pd.Timestamp(row.开始小时), runs)
  66. if run_id is None:
  67. continue
  68. run = run_by_id.loc[run_id]
  69. start = pd.Timestamp(row.开始小时)
  70. end = pd.Timestamp(row.结束小时)
  71. stable = bool(start >= run["start"] + pd.Timedelta(hours=guard_start_hours)
  72. and end <= run["end"] - pd.Timedelta(hours=guard_end_hours))
  73. rows.append({
  74. **row._asdict(),
  75. "run_id": run_id,
  76. "run_start": run["start"],
  77. "run_end": run["end"],
  78. "stable": stable,
  79. })
  80. result = pd.DataFrame(rows)
  81. if result.empty:
  82. return result
  83. return result
  84. def _merge_candidate_windows(candidates: list[dict], merge_gap_hours: int = 6) -> list[dict]:
  85. if not candidates:
  86. return []
  87. candidates = sorted(candidates, key=lambda item: item["window_start"])
  88. merged = [candidates[0].copy()]
  89. for candidate in candidates[1:]:
  90. current = merged[-1]
  91. if candidate["window_start"] <= current["window_end"] + pd.Timedelta(hours=merge_gap_hours):
  92. current["window_end"] = max(current["window_end"], candidate["window_end"])
  93. current["indices"] = sorted(set(current["indices"]) | set(candidate["indices"]))
  94. else:
  95. merged.append(candidate.copy())
  96. return merged
  97. def _ordered_cylinders(values: list[str]) -> list[str]:
  98. present = set(values)
  99. return [name for name in config.CYLINDERS if name in present]
  100. def build_machine_events(
  101. labels: pd.DataFrame,
  102. frame: pd.DataFrame,
  103. hourly: pd.DataFrame,
  104. runs: pd.DataFrame,
  105. guard_start_hours: int = 6,
  106. guard_end_hours: int = 6,
  107. window_hours: int = 24,
  108. min_cylinders: int = 4,
  109. min_bad_labels: int = 2,
  110. merge_gap_hours: int = 6,
  111. ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
  112. candidates = prepare_candidates(
  113. labels, frame, runs,
  114. guard_start_hours=guard_start_hours,
  115. guard_end_hours=guard_end_hours,
  116. )
  117. if candidates.empty:
  118. return pd.DataFrame(columns=MACHINE_FIELDS), candidates, pd.DataFrame()
  119. candidates = candidates[candidates["stable"]].copy()
  120. candidates = candidates.sort_values(["开始小时", "cylinder"]).reset_index(drop=True)
  121. candidates["severity_num"] = candidates["阶段"].map(SEVERITY).fillna(0).astype(int)
  122. score_map = {
  123. (row.cylinder, pd.Timestamp(row.start_hour)): float(row.peak_score)
  124. for row in hourly.itertuples(index=False)
  125. }
  126. windows = []
  127. for index, row in candidates.iterrows():
  128. start = pd.Timestamp(row["开始小时"])
  129. end = start + pd.Timedelta(hours=window_hours)
  130. part = candidates[(candidates["结束小时"] >= start) & (candidates["开始小时"] <= end)]
  131. if part["cylinder"].nunique() < min_cylinders:
  132. continue
  133. if int(part["阶段"].isin(BAD_LEVELS).sum()) < min_bad_labels:
  134. continue
  135. windows.append({
  136. "window_start": start,
  137. "window_end": end,
  138. "indices": part.index.tolist(),
  139. })
  140. windows = _merge_candidate_windows(windows, merge_gap_hours=merge_gap_hours)
  141. frame = frame.copy()
  142. frame["sample_time"] = pd.to_datetime(frame["sample_time"])
  143. rows = []
  144. for index, window in enumerate(windows, start=1):
  145. part = candidates.loc[window["indices"]].copy()
  146. start = pd.Timestamp(part["开始小时"].min())
  147. end = pd.Timestamp(part["结束小时"].max())
  148. cylinders = _ordered_cylinders(part["cylinder"].tolist())
  149. bad = part[part["阶段"].isin(BAD_LEVELS)]
  150. severe = part[part["阶段"] == "严重异常"]
  151. bad_cylinders = _ordered_cylinders(bad["cylinder"].tolist())
  152. severe_cylinders = _ordered_cylinders(severe["cylinder"].tolist())
  153. max_level = max(part["阶段"], key=lambda value: SEVERITY.get(value, 0))
  154. peak_score = max(
  155. (score_map.get((row.cylinder, pd.Timestamp(row.开始小时)), np.nan) for row in part.itertuples(index=False)),
  156. default=np.nan,
  157. )
  158. rpm_part = frame[
  159. (frame["sample_time"] >= start)
  160. & (frame["sample_time"] <= end)
  161. & (frame["cylinder"].isin(cylinders))
  162. ]
  163. rpm_min = float(rpm_part["rpm"].min()) if not rpm_part.empty else np.nan
  164. rpm_max = float(rpm_part["rpm"].max()) if not rpm_part.empty else np.nan
  165. rms_col = pd.to_numeric(part.get("阶段最大RMS"), errors="coerce")
  166. kurt_col = pd.to_numeric(part.get("阶段最大峭度"), errors="coerce")
  167. rms_max = float(rms_col.max()) if rms_col.notna().any() else np.nan
  168. kurt_max = float(kurt_col.max()) if kurt_col.notna().any() else np.nan
  169. run_start = pd.Timestamp(part["run_start"].min())
  170. run_end = pd.Timestamp(part["run_end"].max())
  171. hours = round((end - start).total_seconds() / 3600.0, 2)
  172. bad_hours = int(bad["持续自然小时数"].sum()) if not bad.empty else 0
  173. severe_hours = int(severe["持续自然小时数"].sum()) if not severe.empty else 0
  174. trigger = (
  175. f"5号机整机稳定运行段:{start:%Y-%m-%d %H:%M}~{end:%Y-%m-%d %H:%M},"
  176. f"24小时窗口内{len(cylinders)}个缸出现候选标签({'、'.join(cylinders)}),"
  177. f"其中{len(bad)}条达到异常/严重异常({'、'.join(bad_cylinders)});"
  178. f"最大等级{max_level},最大异常分数{peak_score:.2f};"
  179. f"参与缸数={len(cylinders)},异常缸数={len(bad_cylinders)}"
  180. )
  181. rows.append({
  182. "标签ID": f"5号机-MACH-{index:04d}",
  183. "标签类型": "振动",
  184. "机组": "5号机",
  185. "批次": "全量",
  186. "周期编号": f"5号机-MACH-{index:04d}",
  187. "阶段": max_level,
  188. "阶段部位": "5号机整机",
  189. "阶段点位": "5号机一缸~六缸缸体振动",
  190. "开始小时": start,
  191. "结束小时": end,
  192. "持续自然小时数": hours,
  193. "有效运行小时数": int(part["持续自然小时数"].sum()),
  194. "基线值": np.nan,
  195. "正常波动带": "",
  196. "阶段开始值": np.nan,
  197. "阶段结束值": np.nan,
  198. "阶段最大值": rms_max,
  199. "相对基线变化": np.nan,
  200. "阶段最大连续异常有效小时数": bad_hours,
  201. "阶段高报警小时数": bad_hours,
  202. "阶段高高报警小时数": severe_hours,
  203. "方向": "整机多缸",
  204. "特征类型": "整机振动",
  205. "置信度": "高" if severe_cylinders else "中",
  206. "是否形成确认等级": "是",
  207. "阶段触发依据": trigger,
  208. "事件关联": "",
  209. "启动过渡比例": 0.0,
  210. "局部基线可用比例": 1.0,
  211. "数据来源": "wave_file 首周期波形",
  212. "算法版本": "cylinder_vibration_machine_v2",
  213. "参与缸数": len(cylinders),
  214. "参与缸": " / ".join(cylinders),
  215. "异常缸数": len(bad_cylinders),
  216. "严重异常缸数": len(severe_cylinders),
  217. "异常标签数": int(len(bad)),
  218. "严重异常标签数": int(len(severe)),
  219. "候选标签数": int(len(part)),
  220. "最大异常分数": round(peak_score, 3) if np.isfinite(peak_score) else np.nan,
  221. "最大RMS": round(rms_max, 4) if np.isfinite(rms_max) else np.nan,
  222. "最大峭度": round(kurt_max, 3) if np.isfinite(kurt_max) else np.nan,
  223. "转速范围": f"{rpm_min:.1f}~{rpm_max:.1f}" if np.isfinite(rpm_min) and np.isfinite(rpm_max) else "",
  224. "运行段开始": run_start,
  225. "运行段结束": run_end,
  226. "整机聚集窗口小时": window_hours,
  227. })
  228. result = pd.DataFrame(rows, columns=MACHINE_FIELDS)
  229. return result, candidates, pd.DataFrame(windows)
  230. def validate_events(events: pd.DataFrame, events_config: list[dict] | None = None) -> pd.DataFrame:
  231. rows = []
  232. for event in events_config or config.EVENTS:
  233. event_id = event["event_id"]
  234. event_date = pd.Timestamp(event["date"])
  235. window_start = event_date - pd.Timedelta(days=7)
  236. window_end = event_date + pd.Timedelta(days=1)
  237. matched = events[
  238. (events["开始小时"] <= window_end)
  239. & (events["结束小时"] >= window_start)
  240. ].sort_values("开始小时") if not events.empty else events
  241. if matched.empty:
  242. rows.append({
  243. "event_id": event_id,
  244. "event_date": event["date"],
  245. "fault_cylinder": event["cylinder"],
  246. "hit": False,
  247. "machine_label_id": "",
  248. "start_time": pd.NaT,
  249. "end_time": pd.NaT,
  250. "duration_hours": np.nan,
  251. "lead_days": np.nan,
  252. "max_level": "",
  253. "participating_cylinders": "",
  254. "abnormal_cylinders": "",
  255. })
  256. continue
  257. matched_event = matched.iloc[0]
  258. start = pd.Timestamp(matched_event["开始小时"])
  259. rows.append({
  260. "event_id": event_id,
  261. "event_date": event["date"],
  262. "fault_cylinder": event["cylinder"],
  263. "hit": True,
  264. "machine_label_id": matched_event["标签ID"],
  265. "start_time": start,
  266. "end_time": pd.Timestamp(matched_event["结束小时"]),
  267. "duration_hours": float(matched_event["持续自然小时数"]),
  268. "lead_days": round((event_date - start).total_seconds() / 86400.0, 2),
  269. "max_level": matched_event["阶段"],
  270. "participating_cylinders": matched_event["参与缸"],
  271. "abnormal_cylinders": matched_event["异常缸数"],
  272. })
  273. return pd.DataFrame(rows)
  274. def build_summary(events: pd.DataFrame, candidates: pd.DataFrame, validation: pd.DataFrame, runs: pd.DataFrame) -> dict:
  275. event_run_ids = sorted(set(events["运行段开始"].dropna().tolist()))
  276. all_run_ids = set(runs["run_id"].tolist())
  277. return {
  278. "rule": {
  279. "stable_start_hours": 6,
  280. "stable_end_hours": 6,
  281. "window_hours": 24,
  282. "min_cylinders": 4,
  283. "min_bad_labels": 2,
  284. "merge_gap_hours": 6,
  285. },
  286. "candidate_labels": int(len(candidates)),
  287. "events": int(len(events)),
  288. "runs": int(len(runs)),
  289. "event_run_ids": event_run_ids,
  290. "event_runs": int(len(event_run_ids)),
  291. "normal_runs": int(len(all_run_ids) - len(event_run_ids)),
  292. "validation": {
  293. "total": int(len(validation)),
  294. "hit": int(validation["hit"].sum()) if not validation.empty else 0,
  295. },
  296. }