| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147 |
- #!/usr/bin/env python3
- """单个角度箱看板:波动"幅度"与"次数"各两条曲线 + 自适应正常带。
- - 波动幅度:① 极差 range/span ② 粗糙度 roughness/span(去趋势残差)
- - 波动次数:① 导数过零数 zero_cross ② 极值数 n_extrema
- 每个量用过去 `--window` 天的中位数 ± k×MAD 作正常带;越界点标红。
- """
- from __future__ import annotations
- import argparse
- import csv
- import sys
- from datetime import datetime, timedelta
- from pathlib import Path
- import numpy as np
- BACKEND = Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(BACKEND))
- import matplotlib
- matplotlib.use("Agg")
- import matplotlib.pyplot as plt # noqa: E402
- matplotlib.rcParams["font.sans-serif"] = ["Arial Unicode MS", "Heiti TC", "Hiragino Sans GB", "DejaVu Sans"]
- matplotlib.rcParams["axes.unicode_minus"] = False
- from event_bins import EVENTS, load # noqa: E402
- def band_series(times, values, window_days, k, min_base=10):
- center = np.full(len(values), np.nan)
- lo = np.full(len(values), np.nan)
- hi = np.full(len(values), np.nan)
- out = np.zeros(len(values), dtype=bool)
- for i in range(len(values)):
- t0 = times[i] - timedelta(days=window_days)
- idx = [j for j in range(i) if times[j] >= t0 and np.isfinite(values[j])]
- if len(idx) < min_base:
- continue
- hist = values[idx]
- med = float(np.median(hist))
- mad = float(np.median(np.abs(hist - med))) * 1.4826
- if mad <= 1e-12:
- mad = float(np.std(hist))
- center[i], lo[i], hi[i] = med, med - k * mad, med + k * mad
- if np.isfinite(values[i]):
- out[i] = values[i] > hi[i] or values[i] < lo[i]
- return center, lo, hi, out
- def plot_panel(ax, times, values, center, lo, hi, out, ylabel, events, plot_start, plot_end):
- ax.fill_between(times, lo, hi, color="0.85", label="正常带(中位±3MAD)")
- ax.plot(times, center, color="0.5", lw=1, label="带中位")
- ax.plot(times, values, "-", color="tab:blue", lw=1, alpha=0.6)
- ax.plot(times, values, "o", ms=3, color="tab:blue")
- if out.any():
- ax.plot(np.asarray(times)[out], np.asarray(values)[out], "o", ms=5, color="red", label="越界")
- for ev in events:
- ax.axvline(datetime.fromisoformat(ev), color="k", ls="--", lw=0.7)
- ax.set_ylabel(ylabel)
- ax.grid(alpha=0.25)
- ax.legend(fontsize=7)
- if plot_start or plot_end:
- mask = np.ones(len(times), dtype=bool)
- if plot_start:
- mask &= np.asarray(times) >= plot_start
- if plot_end:
- mask &= np.asarray(times) < plot_end
- val = np.asarray(values)[mask]
- band = np.r_[np.asarray(lo)[mask], np.asarray(hi)[mask]]
- allv = np.r_[val[np.isfinite(val)], band[np.isfinite(band)]]
- if allv.size:
- pad = 0.1 * (np.nanmax(allv) - np.nanmin(allv) + 1e-9)
- ax.set_ylim(np.nanmin(allv) - pad, np.nanmax(allv) + pad)
- if plot_start and plot_end:
- ax.set_xlim(plot_start, plot_end)
- def main() -> None:
- parser = argparse.ArgumentParser(description="单箱 波动幅度/次数 看板")
- parser.add_argument("--csv", required=True)
- parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
- parser.add_argument("--cylinder", required=True)
- parser.add_argument("--side", required=True)
- parser.add_argument("--bin-start", type=float, required=True)
- parser.add_argument("--window", type=int, default=30)
- parser.add_argument("--k", type=float, default=3.0)
- parser.add_argument("--plot-start", default="", help="只显示该日期之后(含);基线仍用全部历史")
- parser.add_argument("--plot-end", default="")
- parser.add_argument("--out", required=True)
- args = parser.parse_args()
- rows = load(args.csv)
- sel = [r for r in rows if r["cylinder"] == args.cylinder and r["side"] == args.side
- and abs(r["bin_start"] - args.bin_start) < 1e-6]
- sel.sort(key=lambda r: r["time"])
- if not sel:
- print("没有匹配数据")
- return
- times = [r["time"] for r in sel]
- span = np.asarray([r["p_span"] or np.nan for r in sel], dtype=float)
- def col(name):
- return np.asarray([r[name] if r[name] is not None else np.nan for r in sel], dtype=float)
- series = [
- (col("range") / span, "波动幅度·极差\nrange/span"),
- (col("roughness") / span, "波动幅度·粗糙度\nroughness/span"),
- (col("zero_cross"), "波动次数·导数过零数"),
- (col("n_extrema"), "波动次数·极值数"),
- ]
- events = [e[1] for e in EVENTS if e[0] == args.unit]
- plot_start = datetime.fromisoformat(args.plot_start) if args.plot_start else None
- plot_end = datetime.fromisoformat(args.plot_end) if args.plot_end else None
- fig, axes = plt.subplots(4, 1, figsize=(13, 10), sharex=True)
- bands = []
- for ax, (values, label) in zip(axes, series):
- c, lo, hi, out = band_series(times, values, args.window, args.k)
- bands.append((values, c, lo, hi, out))
- plot_panel(ax, times, values, c, lo, hi, out, label, events, plot_start, plot_end)
- fig.suptitle(f"{args.unit}号机 {args.cylinder}缸{args.side} 箱{args.bin_start:g}°"
- f"(基线窗口 {args.window} 天,±{args.k:g}MAD)")
- fig.tight_layout()
- out_path = Path(args.out)
- out_path.parent.mkdir(parents=True, exist_ok=True)
- fig.savefig(out_path, dpi=120)
- plt.close(fig)
- header = ["时间", "极差/span", "粗糙度/span", "导数过零数", "极值数",
- "极差越界", "粗糙度越界", "过零越界", "极值越界"]
- with out_path.with_suffix(".csv").open("w", encoding="utf-8-sig", newline="") as fh:
- wr = csv.writer(fh)
- wr.writerow(header)
- for i, t in enumerate(times):
- wr.writerow([t.isoformat()] + [f"{bands[j][0][i]:.4g}" for j in range(4)]
- + [int(bands[j][4][i]) for j in range(4)])
- print("图:", out_path)
- print("表:", out_path.with_suffix(".csv"))
- for label, (values, c, lo, hi, out) in zip(
- ["极差", "粗糙度", "过零数", "极值数"], bands):
- print(f" {label}: 越界 {int(out.sum())} 次")
- if __name__ == "__main__":
- main()
|