labels.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. """缸体振动标签:只用振动特征,不看温度、不看故障表。"""
  2. from __future__ import annotations
  3. from datetime import timedelta
  4. import numpy as np
  5. import pandas as pd
  6. from . import config
  7. def prepare_frame(rows: list[dict], interval_minutes: int) -> pd.DataFrame:
  8. frame = pd.DataFrame(rows)
  9. if frame.empty:
  10. return frame
  11. frame["sample_time"] = pd.to_datetime(frame["sample_time"])
  12. frame["cylinder"] = frame["point_name"].map(config.POINT_TO_CYLINDER)
  13. frame["stage"] = frame["cylinder"].map(lambda name: config.CYLINDERS[name]["stage"])
  14. frame["slot"] = frame["sample_time"].dt.floor(f"{interval_minutes}min")
  15. frame = frame.dropna(subset=["cylinder"]).sort_values(["cylinder", "sample_time"]).reset_index(drop=True)
  16. return frame
  17. def event_window_mask(times: pd.Series) -> pd.Series:
  18. mask = pd.Series(False, index=times.index)
  19. for event in config.EVENTS:
  20. center = pd.Timestamp(event["date"])
  21. start = center - pd.Timedelta(days=config.BASELINE_EXCLUDE_BEFORE_DAYS)
  22. end = center + pd.Timedelta(days=config.BASELINE_EXCLUDE_AFTER_DAYS)
  23. mask |= (times >= start) & (times <= end)
  24. return mask
  25. def _add_residuals(frame: pd.DataFrame) -> pd.DataFrame:
  26. residuals: pd.DataFrame | None = None
  27. for feature in config.RESIDUAL_FEATURES:
  28. pivot = frame.pivot_table(
  29. index="slot", columns="cylinder", values=feature, aggfunc="median"
  30. )
  31. if pivot.empty:
  32. continue
  33. pieces: list[pd.DataFrame] = []
  34. for stage, peers in config.STAGE_PEERS.items():
  35. available = [name for name in peers if name in pivot.columns]
  36. if len(available) < 2:
  37. continue
  38. for cylinder in available:
  39. peer = pivot[available].drop(columns=[cylinder]).median(axis=1)
  40. residual = pivot[cylinder] - peer
  41. part = residual.rename(f"resid_{feature}").reset_index()
  42. part["cylinder"] = cylinder
  43. pieces.append(part)
  44. if not pieces:
  45. continue
  46. feature_frame = pd.concat(pieces, ignore_index=True)
  47. if residuals is None:
  48. residuals = feature_frame
  49. else:
  50. residuals = residuals.merge(feature_frame, on=["slot", "cylinder"], how="outer")
  51. if residuals is None:
  52. return frame
  53. return frame.merge(residuals, on=["slot", "cylinder"], how="left")
  54. def _normal_mask_for(normal_mask: pd.Series, index: pd.Index) -> pd.Series:
  55. mask = normal_mask.reindex(index)
  56. return mask.where(mask.notna(), False).astype(bool)
  57. def _prepare_raw(frame: pd.DataFrame) -> pd.DataFrame:
  58. frame = frame.copy().sort_values(["cylinder", "sample_time"]).reset_index(drop=True)
  59. derived = [
  60. column for column in frame.columns
  61. if column.startswith("resid_") or column.startswith("z_")
  62. or column in {"score", "driver", "threshold", "contrast", "hit"}
  63. ]
  64. frame = frame.drop(columns=derived, errors="ignore")
  65. frame = _add_residuals(frame)
  66. return frame.sort_values(["cylinder", "sample_time"]).reset_index(drop=True)
  67. def _feature_columns() -> list[str]:
  68. return list(config.SCORE_FEATURES) + [f"resid_{name}" for name in config.RESIDUAL_FEATURES]
  69. def compute_baseline(frame: pd.DataFrame) -> dict[str, dict[str, list[float]]]:
  70. """按缸、按特征计算正常时段的中位数/MAD,供二次评估复用。"""
  71. prepared = _prepare_raw(frame)
  72. normal_mask = ~event_window_mask(prepared["sample_time"])
  73. columns = list(dict.fromkeys(_feature_columns() + config.PLOT_FEATURES))
  74. baseline: dict[str, dict[str, list[float]]] = {}
  75. for cylinder, group in prepared.groupby("cylinder", sort=False):
  76. normal = group.loc[_normal_mask_for(normal_mask, group.index)]
  77. if normal.empty:
  78. normal = group
  79. baseline[cylinder] = {}
  80. for column in columns:
  81. values = normal[column].dropna() if column in normal else pd.Series(dtype=float)
  82. if values.empty:
  83. values = group[column].dropna() if column in group else pd.Series(dtype=float)
  84. if values.empty:
  85. baseline[cylinder][column] = [0.0, 1.0]
  86. continue
  87. median = float(values.median())
  88. mad = float((values - median).abs().median())
  89. scale = 1.4826 * mad
  90. if not np.isfinite(scale) or scale <= 1e-9:
  91. scale = float(values.std())
  92. if not np.isfinite(scale) or scale <= 1e-9:
  93. scale = 1.0
  94. baseline[cylinder][column] = [median, scale]
  95. return baseline
  96. def _apply_baseline_z(
  97. frame: pd.DataFrame,
  98. columns: list[str],
  99. baseline: dict[str, dict[str, list[float]]],
  100. ) -> None:
  101. for column in columns:
  102. if column not in frame.columns:
  103. frame[f"z_{column}"] = np.nan
  104. continue
  105. frame[f"z_{column}"] = np.nan
  106. for _, group in frame.groupby("cylinder", sort=False):
  107. median, scale = baseline.get(group["cylinder"].iloc[0], {}).get(column, [0.0, 1.0])
  108. scale = float(scale) if np.isfinite(scale) and scale > 0 else 1.0
  109. z = (group[column] - float(median)) / scale
  110. frame.loc[group.index, f"z_{column}"] = z.clip(-20.0, 20.0)
  111. def _add_rolling_z(frame: pd.DataFrame, columns: list[str], interval_minutes: int) -> None:
  112. window = max(4, int(round(config.ROLLING_WINDOW_HOURS * 60 / max(interval_minutes, 1))))
  113. min_periods = max(2, int(round(config.ROLLING_MIN_HOURS * 60 / max(interval_minutes, 1))))
  114. for column in columns:
  115. if column not in frame.columns:
  116. frame[f"z_{column}"] = np.nan
  117. continue
  118. frame[f"z_{column}"] = np.nan
  119. for _, group in frame.groupby("cylinder", sort=False):
  120. series = group[column]
  121. median = series.rolling(window, min_periods=min_periods).median().shift(1)
  122. std = series.rolling(window, min_periods=min_periods).std().shift(1)
  123. floor = max(float(series.std()), 1e-6) * 0.2
  124. z = (series - median) / std.clip(lower=floor)
  125. frame.loc[group.index, f"z_{column}"] = z.clip(-20.0, 20.0)
  126. def build_labels(frame: pd.DataFrame, interval_minutes: int, baseline: dict | None = None) -> tuple[pd.DataFrame, pd.DataFrame, dict[str, float]]:
  127. """返回带分数的明细、标签段、每缸阈值。"""
  128. if frame.empty:
  129. return frame, pd.DataFrame(), {}
  130. frame = _prepare_raw(frame)
  131. frame["score"] = 0.0
  132. frame["driver"] = "none"
  133. frame["threshold"] = config.MIN_SCORE_Z
  134. normal_mask = ~event_window_mask(frame["sample_time"])
  135. columns = _feature_columns()
  136. z_columns = [f"z_{name}" for name in columns]
  137. if baseline is None:
  138. _add_rolling_z(frame, columns, interval_minutes)
  139. else:
  140. _apply_baseline_z(frame, columns, baseline)
  141. available = [column for column in z_columns if column in frame.columns]
  142. if not available:
  143. return frame, pd.DataFrame(), {}
  144. z_values = frame[available].replace([np.inf, -np.inf], np.nan).abs()
  145. max_scores = z_values.max(axis=1).fillna(0.0)
  146. frame["score"] = max_scores
  147. drivers = z_values.fillna(0.0).idxmax(axis=1).str.replace("z_", "", regex=False)
  148. frame["driver"] = drivers.where(max_scores > 0, "none")
  149. contrast_window = max(2, int(round(config.CONTRAST_WINDOW_HOURS * 60 / max(interval_minutes, 1))))
  150. frame["contrast"] = 0.0
  151. for _, group in frame.groupby("cylinder", sort=False):
  152. score = group["score"]
  153. local = score.rolling(contrast_window, min_periods=max(2, contrast_window // 4)).median().shift(1)
  154. frame.loc[group.index, "contrast"] = (score - local).fillna(0.0)
  155. thresholds: dict[str, float] = {}
  156. for cylinder, group in frame.groupby("cylinder", sort=False):
  157. normal_scores = group.loc[_normal_mask_for(normal_mask, group.index), "score"].dropna()
  158. quantile = float(normal_scores.quantile(config.SCORE_QUANTILE)) if len(normal_scores) else config.MIN_SCORE_Z
  159. thresholds[cylinder] = max(config.MIN_SCORE_Z, quantile)
  160. frame["threshold"] = frame["cylinder"].map(thresholds)
  161. frame["hit"] = (frame["score"] >= frame["threshold"]) & (frame["contrast"] >= config.MIN_CONTRAST)
  162. labels = _extract_segments(frame, interval_minutes)
  163. return frame, labels, thresholds
  164. def _extract_segments(frame: pd.DataFrame, interval_minutes: int) -> pd.DataFrame:
  165. merge_gap = max(1, int(round(120 / max(interval_minutes, 1))))
  166. rows: list[dict] = []
  167. for cylinder, group in frame.groupby("cylinder", sort=False):
  168. group = group.sort_values("sample_time").reset_index(drop=True)
  169. threshold = float(group["threshold"].iloc[0])
  170. stage = config.CYLINDERS[cylinder]["stage"]
  171. peers = [name for name in config.STAGE_PEERS[stage] if name != cylinder]
  172. hit_positions = np.flatnonzero(group["hit"].fillna(False).to_numpy())
  173. if hit_positions.size == 0:
  174. continue
  175. groups: list[tuple[int, int]] = []
  176. start = previous = int(hit_positions[0])
  177. for position in hit_positions[1:]:
  178. position = int(position)
  179. if position - previous <= merge_gap + 1:
  180. previous = position
  181. else:
  182. groups.append((start, previous))
  183. start = previous = position
  184. groups.append((start, previous))
  185. for start, end in groups:
  186. segment = group.iloc[start:end + 1]
  187. hit_segment = segment[segment["hit"]]
  188. if hit_segment.empty:
  189. continue
  190. max_index = segment["score"].idxmax()
  191. max_row = segment.loc[max_index]
  192. start_time = pd.Timestamp(segment["sample_time"].iloc[0])
  193. end_time = pd.Timestamp(segment["sample_time"].iloc[-1])
  194. max_score = float(max_row["score"])
  195. max_time = pd.Timestamp(max_row["sample_time"])
  196. peer_rows = frame[
  197. (frame["cylinder"].isin(peers))
  198. & (frame["sample_time"] >= max_time - pd.Timedelta(minutes=interval_minutes))
  199. & (frame["sample_time"] <= max_time + pd.Timedelta(minutes=interval_minutes))
  200. ]
  201. peer_score = float(peer_rows["score"].max()) if not peer_rows.empty else None
  202. specificity = (max_score / peer_score) if peer_score and peer_score > 0 else None
  203. hit_points = int(len(hit_segment))
  204. strong_single = (
  205. hit_points == 1
  206. and max_score >= config.STRONG_SINGLE_SCORE
  207. and specificity is not None
  208. and specificity >= config.STRONG_SINGLE_SPECIFICITY
  209. )
  210. if hit_points < config.MIN_CONSECUTIVE_HITS and not strong_single:
  211. continue
  212. lookback = start_time - timedelta(days=config.ONSET_LOOKBACK_DAYS)
  213. onset_candidates = group[
  214. (group["sample_time"] >= lookback)
  215. & (group["sample_time"] <= start_time)
  216. & (group["score"] >= threshold * config.ONSET_FACTOR)
  217. ]
  218. onset_time = pd.Timestamp(onset_candidates["sample_time"].iloc[0]) if not onset_candidates.empty else start_time
  219. lead_days = max(0.0, (start_time - onset_time).total_seconds() / 86400.0)
  220. if max_score >= max(threshold + 2.0, 6.0) and hit_points >= 4:
  221. confidence = "高"
  222. elif hit_points >= 2 or strong_single:
  223. confidence = "中"
  224. else:
  225. confidence = "低"
  226. rows.append(
  227. {
  228. "cylinder": cylinder,
  229. "stage": config.CYLINDERS[cylinder]["stage"],
  230. "start_time": start_time,
  231. "end_time": end_time,
  232. "duration_hours": round((end_time - start_time).total_seconds() / 3600.0, 2),
  233. "points": int(len(segment)),
  234. "hit_points": hit_points,
  235. "max_score": round(max_score, 3),
  236. "max_time": max_time,
  237. "peer_max_score": None if peer_score is None else round(peer_score, 3),
  238. "specificity": None if specificity is None else round(specificity, 2),
  239. "driver": str(max_row["driver"]),
  240. "onset_time": onset_time,
  241. "lead_days": round(lead_days, 2),
  242. "threshold": round(threshold, 3),
  243. "confidence": confidence,
  244. },
  245. )
  246. if not rows:
  247. return pd.DataFrame()
  248. return pd.DataFrame(rows).sort_values(["start_time", "cylinder"]).reset_index(drop=True)
  249. def validate_events(labels: pd.DataFrame, frame: pd.DataFrame, temperature: pd.DataFrame) -> pd.DataFrame:
  250. rows: list[dict] = []
  251. event_windows: list[tuple[pd.Timestamp, pd.Timestamp]] = []
  252. for event in config.EVENTS:
  253. event_time = pd.Timestamp(event["date"])
  254. window_start = event_time - pd.Timedelta(days=config.VALIDATION_BEFORE_DAYS)
  255. window_end = event_time + pd.Timedelta(days=config.VALIDATION_AFTER_DAYS)
  256. event_windows.append((window_start, window_end))
  257. cylinder = event["cylinder"]
  258. candidate = labels[
  259. (labels["cylinder"] == cylinder)
  260. & (labels["start_time"] <= window_end)
  261. & (labels["end_time"] >= window_start)
  262. ] if not labels.empty else pd.DataFrame()
  263. if candidate.empty:
  264. hit = False
  265. segment = None
  266. lead_days = None
  267. else:
  268. segment = candidate.loc[candidate["max_score"].idxmax()]
  269. hit = True
  270. if pd.Timestamp(segment["start_time"]) <= event_time:
  271. lead_days = round((event_time - pd.Timestamp(segment["start_time"])).total_seconds() / 86400.0, 2)
  272. else:
  273. lead_days = -round((pd.Timestamp(segment["start_time"]) - event_time).total_seconds() / 86400.0, 2)
  274. peer_score = None
  275. specificity = None
  276. if segment is not None and not frame.empty:
  277. stage = config.CYLINDERS[cylinder]["stage"]
  278. peers = [name for name in config.STAGE_PEERS[stage] if name != cylinder]
  279. max_time = pd.Timestamp(segment["max_time"])
  280. nearby = frame[
  281. (frame["cylinder"].isin(peers))
  282. & (frame["sample_time"] >= max_time - pd.Timedelta(minutes=15))
  283. & (frame["sample_time"] <= max_time + pd.Timedelta(minutes=15))
  284. ]
  285. if not nearby.empty:
  286. peer_score = round(float(nearby["score"].max()), 3)
  287. if peer_score > 0:
  288. specificity = round(float(segment["max_score"]) / peer_score, 2)
  289. temp_row = pd.DataFrame()
  290. if not temperature.empty:
  291. temp_row = temperature[
  292. (temperature["cylinder"] == cylinder)
  293. & (temperature["start_time"] <= window_end)
  294. & (temperature["end_time"] >= window_start)
  295. ].sort_values("start_time")
  296. rows.append(
  297. {
  298. "event_id": event["event_id"],
  299. "event_date": event["date"],
  300. "cylinder": cylinder,
  301. "description": event["description"],
  302. "vibration_hit": hit,
  303. "segment_start": None if segment is None else segment["start_time"],
  304. "segment_end": None if segment is None else segment["end_time"],
  305. "segment_max_score": None if segment is None else segment["max_score"],
  306. "segment_driver": None if segment is None else segment["driver"],
  307. "segment_confidence": None if segment is None else segment["confidence"],
  308. "lead_days": lead_days,
  309. "peer_max_score": peer_score,
  310. "specificity": specificity,
  311. "temp_reference_start": None if temp_row.empty else temp_row["start_time"].iloc[0],
  312. "temp_reference_end": None if temp_row.empty else temp_row["end_time"].iloc[0],
  313. "temp_reference_stage": None if temp_row.empty else " / ".join(temp_row["温度阶段"].astype(str).unique()),
  314. "temp_reference_confirmed": None if temp_row.empty else " / ".join(temp_row["是否形成确认等级"].astype(str).unique()),
  315. },
  316. )
  317. total_days = max((frame["sample_time"].max() - frame["sample_time"].min()).total_seconds() / 86400.0, 1.0) if not frame.empty else 1.0
  318. if not labels.empty:
  319. related = labels.apply(
  320. lambda row: any(
  321. pd.Timestamp(row["start_time"]) <= end and pd.Timestamp(row["end_time"]) >= start
  322. for start, end in event_windows
  323. ),
  324. axis=1,
  325. )
  326. false_alarms = labels[~related]
  327. else:
  328. false_alarms = pd.DataFrame()
  329. result = pd.DataFrame(rows)
  330. result.attrs["false_alarm_count"] = int(len(false_alarms))
  331. result.attrs["false_alarm_per_30d"] = round(len(false_alarms) / total_days * 30.0, 2)
  332. result.attrs["total_days"] = round(total_days, 1)
  333. return result