plots_actual.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. """第二轮图:多缸并排的实际振动特征 + 首周期波形。"""
  2. from __future__ import annotations
  3. from datetime import timedelta
  4. import matplotlib
  5. matplotlib.use("Agg")
  6. import matplotlib.pyplot as plt
  7. import numpy as np
  8. import pandas as pd
  9. from . import config, data
  10. LEVEL_COLORS = {"严重": "#e74c3c", "异常": "#e67e22", "轻微": "#f1c40f"}
  11. def _gap_aware(ax, x, y, gap_minutes: int = 60, **kwargs) -> None:
  12. x = pd.Series(pd.to_datetime(x)).reset_index(drop=True)
  13. y = pd.Series(np.asarray(y, dtype=float)).reset_index(drop=True)
  14. y = y.mask(x.diff() > pd.Timedelta(minutes=gap_minutes))
  15. ax.plot(x, y, **kwargs)
  16. def _ratio_features(frame: pd.DataFrame, baseline: dict) -> pd.DataFrame:
  17. frame = frame.copy()
  18. frame["rms_ratio"] = np.nan
  19. frame["kurt_ratio"] = np.nan
  20. for cylinder, group in frame.groupby("cylinder", sort=False):
  21. meta = baseline.get(cylinder, {})
  22. rms_median = float(meta.get("rms_log", [0.0, 1.0])[0])
  23. kurt_median = float(meta.get("kurt", [1.0, 1.0])[0])
  24. frame.loc[group.index, "rms_ratio"] = np.power(10.0, group["rms_log"] - rms_median)
  25. if kurt_median > 0:
  26. frame.loc[group.index, "kurt_ratio"] = group["kurt"] / kurt_median
  27. return frame
  28. def _draw_intervals(ax, intervals: pd.DataFrame, cylinder: str, start: pd.Timestamp, end: pd.Timestamp) -> None:
  29. if intervals.empty:
  30. return
  31. part = intervals[
  32. (intervals["cylinder"] == cylinder)
  33. & (intervals["end_time"] >= start)
  34. & (intervals["start_time"] <= end)
  35. ]
  36. for row in part.itertuples(index=False):
  37. color = LEVEL_COLORS.get(row.level, "#95a5a6")
  38. ax.axvspan(row.start_time, row.end_time, color=color, alpha=0.13)
  39. def plot_feature_grid(frame: pd.DataFrame, intervals: pd.DataFrame, baseline: dict, event: dict) -> None:
  40. config.PLOT_DIR.mkdir(parents=True, exist_ok=True)
  41. cylinder = event["cylinder"]
  42. center = pd.Timestamp(event["date"])
  43. start = center - pd.Timedelta(days=10)
  44. end = center + pd.Timedelta(days=3)
  45. part = frame[(frame["sample_time"] >= start) & (frame["sample_time"] <= end)]
  46. if part.empty:
  47. return
  48. part = _ratio_features(part, baseline)
  49. for metric, threshold, ylabel in (
  50. ("rms_ratio", 1.5, "RMS / baseline"),
  51. ("kurt_ratio", 2.0, "kurtosis / baseline"),
  52. ):
  53. fig, axes = plt.subplots(2, 3, figsize=(15, 7), sharex=True)
  54. for ax, name in zip(axes.ravel(), config.CYLINDERS):
  55. group = part[part["cylinder"] == name].sort_values("sample_time")
  56. ax.set_title(f"cyl{config.CYLINDER_TO_INDEX[name]}", fontsize=9)
  57. ax.axvline(center, color="#2c3e50", linewidth=1.0)
  58. if group.empty:
  59. ax.text(0.5, 0.5, "no data in event window", ha="center", va="center", fontsize=8, color="#7f8c8d")
  60. ax.set_xticks([])
  61. ax.set_yticks([])
  62. continue
  63. _gap_aware(ax, group["sample_time"], group[metric], color="#c0392b" if name == cylinder else "#2980b9", linewidth=1.0)
  64. ax.axhline(threshold, color="#7f8c8d", linestyle="--", linewidth=0.9)
  65. _draw_intervals(ax, intervals, name, start, end)
  66. ax.set_ylabel(ylabel if name == cylinder else "", fontsize=8)
  67. ax.grid(alpha=0.2)
  68. ax.tick_params(labelsize=7)
  69. fig.suptitle(f"{event['event_id']} cylinder#{config.CYLINDER_TO_INDEX[cylinder]} - {metric} (gaps = no running data)")
  70. fig.tight_layout()
  71. path = config.PLOT_DIR / f"features_{metric}_{event['event_id']}.png"
  72. fig.savefig(path, dpi=130)
  73. plt.close(fig)
  74. print(f"[plot] {path}", flush=True)
  75. def _nearest_row(frame: pd.DataFrame, cylinder: str, target: pd.Timestamp, tolerance: timedelta) -> pd.Series | None:
  76. group = frame[frame["cylinder"] == cylinder]
  77. if group.empty:
  78. return None
  79. distance = (group["sample_time"] - target).abs()
  80. index = distance.idxmin()
  81. if distance.loc[index] > tolerance:
  82. return None
  83. return group.loc[index]
  84. def plot_waveform_grid(frame: pd.DataFrame, intervals: pd.DataFrame, event: dict) -> None:
  85. cylinder = event["cylinder"]
  86. if intervals.empty:
  87. return
  88. candidates = intervals[intervals["cylinder"] == cylinder]
  89. if candidates.empty:
  90. return
  91. seed = candidates.loc[candidates["max_score"].idxmax()]
  92. seed_time = pd.Timestamp(seed["max_time"])
  93. baseline_time = seed_time - pd.Timedelta(hours=24)
  94. seed_rows: dict[str, pd.Series] = {}
  95. baseline_rows: dict[str, pd.Series] = {}
  96. for name in config.CYLINDERS:
  97. row = _nearest_row(frame, name, seed_time, timedelta(hours=2))
  98. if row is not None:
  99. seed_rows[name] = row
  100. base = _nearest_row(frame, name, baseline_time, timedelta(hours=6))
  101. if base is None:
  102. group = frame[(frame["cylinder"] == name) & (frame["sample_time"] < seed_time)]
  103. base = group.sort_values("sample_time").tail(1).squeeze() if not group.empty else None
  104. if base is not None:
  105. baseline_rows[name] = base
  106. seed_ids = [int(row["id"]) for row in seed_rows.values()]
  107. baseline_ids = [int(row["id"]) for row in baseline_rows.values()]
  108. seed_samples = data.fetch_samples(seed_ids)
  109. baseline_samples = data.fetch_samples(baseline_ids)
  110. fig, axes = plt.subplots(2, 3, figsize=(15, 7), sharex=True)
  111. for ax, name in zip(axes.ravel(), config.CYLINDERS):
  112. row = seed_rows.get(name)
  113. if row is None:
  114. ax.text(0.5, 0.5, "no data near seed time", ha="center", va="center", fontsize=9, color="#7f8c8d")
  115. ax.set_title(f"cyl{config.CYLINDER_TO_INDEX[name]}", fontsize=9)
  116. ax.set_xticks([])
  117. ax.set_yticks([])
  118. continue
  119. seed_signal = seed_samples.get(int(row["id"]))
  120. if seed_signal is None or seed_signal.size == 0:
  121. ax.text(0.5, 0.5, "no sample data", ha="center", va="center", fontsize=9, color="#7f8c8d")
  122. continue
  123. fs = float(row["sample_frequency_hz"] or 0) or 25600.0
  124. base_signal = None
  125. if name in baseline_rows:
  126. base_signal = baseline_samples.get(int(baseline_rows[name]["id"]))
  127. scale = float(np.sqrt(np.mean(base_signal ** 2))) if base_signal is not None and base_signal.size else float(np.sqrt(np.mean(seed_signal ** 2)))
  128. scale = max(scale, 1e-9)
  129. x = np.arange(seed_signal.size) / fs * 1000.0
  130. if base_signal is not None and base_signal.size:
  131. xb = np.arange(base_signal.size) / fs * 1000.0
  132. ax.plot(xb, base_signal / scale, color="#95a5a6", linewidth=0.8, label="baseline")
  133. ax.plot(x, seed_signal / scale, color="#c0392b" if name == cylinder else "#2980b9", linewidth=0.9, label="seed")
  134. ax.set_title(f"cyl{config.CYLINDER_TO_INDEX[name]}", fontsize=9)
  135. ax.grid(alpha=0.2)
  136. ax.tick_params(labelsize=7)
  137. if name == cylinder:
  138. ax.set_ylabel("normalized amplitude", fontsize=8)
  139. ax.legend(loc="upper right", fontsize=7)
  140. fig.suptitle(f"{event['event_id']} cylinder#{config.CYLINDER_TO_INDEX[cylinder]} - first-cycle waveform (baseline vs seed)")
  141. fig.tight_layout()
  142. path = config.PLOT_DIR / f"waveforms_{event['event_id']}.png"
  143. fig.savefig(path, dpi=130)
  144. plt.close(fig)
  145. print(f"[plot] {path}", flush=True)