| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- """第二轮图:多缸并排的实际振动特征 + 首周期波形。"""
- from __future__ import annotations
- from datetime import timedelta
- import matplotlib
- matplotlib.use("Agg")
- import matplotlib.pyplot as plt
- import numpy as np
- import pandas as pd
- from . import config, data
- LEVEL_COLORS = {"严重": "#e74c3c", "异常": "#e67e22", "轻微": "#f1c40f"}
- def _gap_aware(ax, x, y, gap_minutes: int = 60, **kwargs) -> None:
- x = pd.Series(pd.to_datetime(x)).reset_index(drop=True)
- y = pd.Series(np.asarray(y, dtype=float)).reset_index(drop=True)
- y = y.mask(x.diff() > pd.Timedelta(minutes=gap_minutes))
- ax.plot(x, y, **kwargs)
- def _ratio_features(frame: pd.DataFrame, baseline: dict) -> pd.DataFrame:
- frame = frame.copy()
- frame["rms_ratio"] = np.nan
- frame["kurt_ratio"] = np.nan
- for cylinder, group in frame.groupby("cylinder", sort=False):
- meta = baseline.get(cylinder, {})
- rms_median = float(meta.get("rms_log", [0.0, 1.0])[0])
- kurt_median = float(meta.get("kurt", [1.0, 1.0])[0])
- frame.loc[group.index, "rms_ratio"] = np.power(10.0, group["rms_log"] - rms_median)
- if kurt_median > 0:
- frame.loc[group.index, "kurt_ratio"] = group["kurt"] / kurt_median
- return frame
- def _draw_intervals(ax, intervals: pd.DataFrame, cylinder: str, start: pd.Timestamp, end: pd.Timestamp) -> None:
- if intervals.empty:
- return
- part = intervals[
- (intervals["cylinder"] == cylinder)
- & (intervals["end_time"] >= start)
- & (intervals["start_time"] <= end)
- ]
- for row in part.itertuples(index=False):
- color = LEVEL_COLORS.get(row.level, "#95a5a6")
- ax.axvspan(row.start_time, row.end_time, color=color, alpha=0.13)
- def plot_feature_grid(frame: pd.DataFrame, intervals: pd.DataFrame, baseline: dict, event: dict) -> None:
- config.PLOT_DIR.mkdir(parents=True, exist_ok=True)
- cylinder = event["cylinder"]
- center = pd.Timestamp(event["date"])
- start = center - pd.Timedelta(days=10)
- end = center + pd.Timedelta(days=3)
- part = frame[(frame["sample_time"] >= start) & (frame["sample_time"] <= end)]
- if part.empty:
- return
- part = _ratio_features(part, baseline)
- for metric, threshold, ylabel in (
- ("rms_ratio", 1.5, "RMS / baseline"),
- ("kurt_ratio", 2.0, "kurtosis / baseline"),
- ):
- fig, axes = plt.subplots(2, 3, figsize=(15, 7), sharex=True)
- for ax, name in zip(axes.ravel(), config.CYLINDERS):
- group = part[part["cylinder"] == name].sort_values("sample_time")
- ax.set_title(f"cyl{config.CYLINDER_TO_INDEX[name]}", fontsize=9)
- ax.axvline(center, color="#2c3e50", linewidth=1.0)
- if group.empty:
- ax.text(0.5, 0.5, "no data in event window", ha="center", va="center", fontsize=8, color="#7f8c8d")
- ax.set_xticks([])
- ax.set_yticks([])
- continue
- _gap_aware(ax, group["sample_time"], group[metric], color="#c0392b" if name == cylinder else "#2980b9", linewidth=1.0)
- ax.axhline(threshold, color="#7f8c8d", linestyle="--", linewidth=0.9)
- _draw_intervals(ax, intervals, name, start, end)
- ax.set_ylabel(ylabel if name == cylinder else "", fontsize=8)
- ax.grid(alpha=0.2)
- ax.tick_params(labelsize=7)
- fig.suptitle(f"{event['event_id']} cylinder#{config.CYLINDER_TO_INDEX[cylinder]} - {metric} (gaps = no running data)")
- fig.tight_layout()
- path = config.PLOT_DIR / f"features_{metric}_{event['event_id']}.png"
- fig.savefig(path, dpi=130)
- plt.close(fig)
- print(f"[plot] {path}", flush=True)
- def _nearest_row(frame: pd.DataFrame, cylinder: str, target: pd.Timestamp, tolerance: timedelta) -> pd.Series | None:
- group = frame[frame["cylinder"] == cylinder]
- if group.empty:
- return None
- distance = (group["sample_time"] - target).abs()
- index = distance.idxmin()
- if distance.loc[index] > tolerance:
- return None
- return group.loc[index]
- def plot_waveform_grid(frame: pd.DataFrame, intervals: pd.DataFrame, event: dict) -> None:
- cylinder = event["cylinder"]
- if intervals.empty:
- return
- candidates = intervals[intervals["cylinder"] == cylinder]
- if candidates.empty:
- return
- seed = candidates.loc[candidates["max_score"].idxmax()]
- seed_time = pd.Timestamp(seed["max_time"])
- baseline_time = seed_time - pd.Timedelta(hours=24)
- seed_rows: dict[str, pd.Series] = {}
- baseline_rows: dict[str, pd.Series] = {}
- for name in config.CYLINDERS:
- row = _nearest_row(frame, name, seed_time, timedelta(hours=2))
- if row is not None:
- seed_rows[name] = row
- base = _nearest_row(frame, name, baseline_time, timedelta(hours=6))
- if base is None:
- group = frame[(frame["cylinder"] == name) & (frame["sample_time"] < seed_time)]
- base = group.sort_values("sample_time").tail(1).squeeze() if not group.empty else None
- if base is not None:
- baseline_rows[name] = base
- seed_ids = [int(row["id"]) for row in seed_rows.values()]
- baseline_ids = [int(row["id"]) for row in baseline_rows.values()]
- seed_samples = data.fetch_samples(seed_ids)
- baseline_samples = data.fetch_samples(baseline_ids)
- fig, axes = plt.subplots(2, 3, figsize=(15, 7), sharex=True)
- for ax, name in zip(axes.ravel(), config.CYLINDERS):
- row = seed_rows.get(name)
- if row is None:
- ax.text(0.5, 0.5, "no data near seed time", ha="center", va="center", fontsize=9, color="#7f8c8d")
- ax.set_title(f"cyl{config.CYLINDER_TO_INDEX[name]}", fontsize=9)
- ax.set_xticks([])
- ax.set_yticks([])
- continue
- seed_signal = seed_samples.get(int(row["id"]))
- if seed_signal is None or seed_signal.size == 0:
- ax.text(0.5, 0.5, "no sample data", ha="center", va="center", fontsize=9, color="#7f8c8d")
- continue
- fs = float(row["sample_frequency_hz"] or 0) or 25600.0
- base_signal = None
- if name in baseline_rows:
- base_signal = baseline_samples.get(int(baseline_rows[name]["id"]))
- 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)))
- scale = max(scale, 1e-9)
- x = np.arange(seed_signal.size) / fs * 1000.0
- if base_signal is not None and base_signal.size:
- xb = np.arange(base_signal.size) / fs * 1000.0
- ax.plot(xb, base_signal / scale, color="#95a5a6", linewidth=0.8, label="baseline")
- ax.plot(x, seed_signal / scale, color="#c0392b" if name == cylinder else "#2980b9", linewidth=0.9, label="seed")
- ax.set_title(f"cyl{config.CYLINDER_TO_INDEX[name]}", fontsize=9)
- ax.grid(alpha=0.2)
- ax.tick_params(labelsize=7)
- if name == cylinder:
- ax.set_ylabel("normalized amplitude", fontsize=8)
- ax.legend(loc="upper right", fontsize=7)
- fig.suptitle(f"{event['event_id']} cylinder#{config.CYLINDER_TO_INDEX[cylinder]} - first-cycle waveform (baseline vs seed)")
- fig.tight_layout()
- path = config.PLOT_DIR / f"waveforms_{event['event_id']}.png"
- fig.savefig(path, dpi=130)
- plt.close(fig)
- print(f"[plot] {path}", flush=True)
|