watch.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. #!/usr/bin/env python3
  2. """单个角度箱看板:波动"幅度"与"次数"各两条曲线 + 自适应正常带。
  3. - 波动幅度:① 极差 range/span ② 粗糙度 roughness/span(去趋势残差)
  4. - 波动次数:① 导数过零数 zero_cross ② 极值数 n_extrema
  5. 每个量用过去 `--window` 天的中位数 ± k×MAD 作正常带;越界点标红。
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import csv
  10. import sys
  11. from datetime import datetime, timedelta
  12. from pathlib import Path
  13. import numpy as np
  14. BACKEND = Path(__file__).resolve().parents[1]
  15. sys.path.insert(0, str(BACKEND))
  16. import matplotlib
  17. matplotlib.use("Agg")
  18. import matplotlib.pyplot as plt # noqa: E402
  19. matplotlib.rcParams["font.sans-serif"] = ["Arial Unicode MS", "Heiti TC", "Hiragino Sans GB", "DejaVu Sans"]
  20. matplotlib.rcParams["axes.unicode_minus"] = False
  21. from event_bins import EVENTS, load # noqa: E402
  22. def band_series(times, values, window_days, k, min_base=10):
  23. center = np.full(len(values), np.nan)
  24. lo = np.full(len(values), np.nan)
  25. hi = np.full(len(values), np.nan)
  26. out = np.zeros(len(values), dtype=bool)
  27. for i in range(len(values)):
  28. t0 = times[i] - timedelta(days=window_days)
  29. idx = [j for j in range(i) if times[j] >= t0 and np.isfinite(values[j])]
  30. if len(idx) < min_base:
  31. continue
  32. hist = values[idx]
  33. med = float(np.median(hist))
  34. mad = float(np.median(np.abs(hist - med))) * 1.4826
  35. if mad <= 1e-12:
  36. mad = float(np.std(hist))
  37. center[i], lo[i], hi[i] = med, med - k * mad, med + k * mad
  38. if np.isfinite(values[i]):
  39. out[i] = values[i] > hi[i] or values[i] < lo[i]
  40. return center, lo, hi, out
  41. def plot_panel(ax, times, values, center, lo, hi, out, ylabel, events, plot_start, plot_end):
  42. ax.fill_between(times, lo, hi, color="0.85", label="正常带(中位±3MAD)")
  43. ax.plot(times, center, color="0.5", lw=1, label="带中位")
  44. ax.plot(times, values, "-", color="tab:blue", lw=1, alpha=0.6)
  45. ax.plot(times, values, "o", ms=3, color="tab:blue")
  46. if out.any():
  47. ax.plot(np.asarray(times)[out], np.asarray(values)[out], "o", ms=5, color="red", label="越界")
  48. for ev in events:
  49. ax.axvline(datetime.fromisoformat(ev), color="k", ls="--", lw=0.7)
  50. ax.set_ylabel(ylabel)
  51. ax.grid(alpha=0.25)
  52. ax.legend(fontsize=7)
  53. if plot_start or plot_end:
  54. mask = np.ones(len(times), dtype=bool)
  55. if plot_start:
  56. mask &= np.asarray(times) >= plot_start
  57. if plot_end:
  58. mask &= np.asarray(times) < plot_end
  59. val = np.asarray(values)[mask]
  60. band = np.r_[np.asarray(lo)[mask], np.asarray(hi)[mask]]
  61. allv = np.r_[val[np.isfinite(val)], band[np.isfinite(band)]]
  62. if allv.size:
  63. pad = 0.1 * (np.nanmax(allv) - np.nanmin(allv) + 1e-9)
  64. ax.set_ylim(np.nanmin(allv) - pad, np.nanmax(allv) + pad)
  65. if plot_start and plot_end:
  66. ax.set_xlim(plot_start, plot_end)
  67. def main() -> None:
  68. parser = argparse.ArgumentParser(description="单箱 波动幅度/次数 看板")
  69. parser.add_argument("--csv", required=True)
  70. parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
  71. parser.add_argument("--cylinder", required=True)
  72. parser.add_argument("--side", required=True)
  73. parser.add_argument("--bin-start", type=float, required=True)
  74. parser.add_argument("--window", type=int, default=30)
  75. parser.add_argument("--k", type=float, default=3.0)
  76. parser.add_argument("--plot-start", default="", help="只显示该日期之后(含);基线仍用全部历史")
  77. parser.add_argument("--plot-end", default="")
  78. parser.add_argument("--out", required=True)
  79. args = parser.parse_args()
  80. rows = load(args.csv)
  81. sel = [r for r in rows if r["cylinder"] == args.cylinder and r["side"] == args.side
  82. and abs(r["bin_start"] - args.bin_start) < 1e-6]
  83. sel.sort(key=lambda r: r["time"])
  84. if not sel:
  85. print("没有匹配数据")
  86. return
  87. times = [r["time"] for r in sel]
  88. span = np.asarray([r["p_span"] or np.nan for r in sel], dtype=float)
  89. def col(name):
  90. return np.asarray([r[name] if r[name] is not None else np.nan for r in sel], dtype=float)
  91. series = [
  92. (col("range") / span, "波动幅度·极差\nrange/span"),
  93. (col("roughness") / span, "波动幅度·粗糙度\nroughness/span"),
  94. (col("zero_cross"), "波动次数·导数过零数"),
  95. (col("n_extrema"), "波动次数·极值数"),
  96. ]
  97. events = [e[1] for e in EVENTS if e[0] == args.unit]
  98. plot_start = datetime.fromisoformat(args.plot_start) if args.plot_start else None
  99. plot_end = datetime.fromisoformat(args.plot_end) if args.plot_end else None
  100. fig, axes = plt.subplots(4, 1, figsize=(13, 10), sharex=True)
  101. bands = []
  102. for ax, (values, label) in zip(axes, series):
  103. c, lo, hi, out = band_series(times, values, args.window, args.k)
  104. bands.append((values, c, lo, hi, out))
  105. plot_panel(ax, times, values, c, lo, hi, out, label, events, plot_start, plot_end)
  106. fig.suptitle(f"{args.unit}号机 {args.cylinder}缸{args.side} 箱{args.bin_start:g}°"
  107. f"(基线窗口 {args.window} 天,±{args.k:g}MAD)")
  108. fig.tight_layout()
  109. out_path = Path(args.out)
  110. out_path.parent.mkdir(parents=True, exist_ok=True)
  111. fig.savefig(out_path, dpi=120)
  112. plt.close(fig)
  113. header = ["时间", "极差/span", "粗糙度/span", "导数过零数", "极值数",
  114. "极差越界", "粗糙度越界", "过零越界", "极值越界"]
  115. with out_path.with_suffix(".csv").open("w", encoding="utf-8-sig", newline="") as fh:
  116. wr = csv.writer(fh)
  117. wr.writerow(header)
  118. for i, t in enumerate(times):
  119. wr.writerow([t.isoformat()] + [f"{bands[j][0][i]:.4g}" for j in range(4)]
  120. + [int(bands[j][4][i]) for j in range(4)])
  121. print("图:", out_path)
  122. print("表:", out_path.with_suffix(".csv"))
  123. for label, (values, c, lo, hi, out) in zip(
  124. ["极差", "粗糙度", "过零数", "极值数"], bands):
  125. print(f" {label}: 越界 {int(out.sum())} 次")
  126. if __name__ == "__main__":
  127. main()