|
|
@@ -0,0 +1,503 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""Same-point before/after comparison of the per-angular-bin and whole-cycle features.
|
|
|
+
|
|
|
+Reads the long CSV produced by ``angle_bins.py`` and, for each valve/piston repair
|
|
|
+event, compares the **same measurement point** between the running period before
|
|
|
+the event and the running period after it. Only same-point comparisons are made
|
|
|
+(never across cylinders).
|
|
|
+
|
|
|
+Two feature families (see ``bins_common.py``):
|
|
|
+
|
|
|
+* per-bin features : level/shape split into whole-point shift (all bins move =
|
|
|
+ load / sensor) and local deviation (only some bins move = valve / local fault);
|
|
|
+* cycle features : suction/discharge plateaus and valve-event angles/slopes.
|
|
|
+
|
|
|
+Running periods come from ``run_segments.py`` (``wave_file.rpm > 0``); the pre
|
|
|
+window is carried back over earlier running segments until it has enough days
|
|
|
+(short post windows are used as-is). Output is a ranked list per event plus a
|
|
|
+detail CSV. Read-only.
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import argparse
|
|
|
+import csv
|
|
|
+from collections import defaultdict
|
|
|
+from datetime import datetime
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+import numpy as np
|
|
|
+
|
|
|
+from bins_common import (
|
|
|
+ ANGLE_METRICS, BIN_METRICS, CYCLE_METRICS, CYCLE_VOLATILE, VOLATILE, apply_normalization,
|
|
|
+)
|
|
|
+from run_segments import build_segments, unit_segments
|
|
|
+
|
|
|
+
|
|
|
+def circ_median(values):
|
|
|
+ radians = np.radians(np.asarray(values, dtype=float))
|
|
|
+ return float(np.degrees(np.arctan2(np.mean(np.sin(radians)), np.mean(np.cos(radians)))) % 360.0)
|
|
|
+
|
|
|
+
|
|
|
+def circ_diff(a, b):
|
|
|
+ return ((b - a + 180.0) % 360.0) - 180.0
|
|
|
+
|
|
|
+Z = 3.0
|
|
|
+FLOOR_FRAC = 0.2 # per-bin scale floor = 20% of that metric's median bin scale
|
|
|
+MIN_SPAN = 0.3 # a dead/stuck channel has a near-zero per-cycle pressure span
|
|
|
+ANGLE_SCALE_FLOOR = 1.0 # degrees; a constant valve-angle must not inflate z
|
|
|
+
|
|
|
+# 故障汇总.xlsx 中的气阀/活塞事件(unit, date, kind)
|
|
|
+EVENTS = [
|
|
|
+ ("7", "2025-01-10", "活塞"),
|
|
|
+ ("7", "2025-08-08", "气阀"),
|
|
|
+ ("7", "2025-10-07", "气阀"),
|
|
|
+ ("7", "2026-05-21", "气阀"),
|
|
|
+ ("8", "2025-02-17", "气阀"),
|
|
|
+ ("8", "2025-12-06", "活塞"),
|
|
|
+ ("8", "2026-03-29", "气阀"),
|
|
|
+ ("8", "2026-04-01", "气阀"),
|
|
|
+ ("9", "2026-01-18", "活塞"),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def to_float(value):
|
|
|
+ try:
|
|
|
+ result = float(value)
|
|
|
+ except (TypeError, ValueError):
|
|
|
+ return None
|
|
|
+ return result if np.isfinite(result) else None
|
|
|
+
|
|
|
+
|
|
|
+def load(path: str) -> list[dict]:
|
|
|
+ rows = []
|
|
|
+ for row in csv.DictReader(open(path, encoding="utf-8-sig")):
|
|
|
+ try:
|
|
|
+ row["time"] = datetime.fromisoformat(row["sample_time"])
|
|
|
+ except ValueError:
|
|
|
+ continue
|
|
|
+ row["bin"] = int(row["bin"])
|
|
|
+ row["bin_start"] = float(row["bin_start"])
|
|
|
+ row["p_span"] = to_float(row.get("p_span"))
|
|
|
+ for name in BIN_METRICS + CYCLE_METRICS:
|
|
|
+ row[name] = to_float(row.get(name))
|
|
|
+ rows.append(row)
|
|
|
+ return rows
|
|
|
+
|
|
|
+
|
|
|
+def robust_scale(values: np.ndarray) -> float:
|
|
|
+ if values.size == 0:
|
|
|
+ return 0.0
|
|
|
+ center = float(np.median(values))
|
|
|
+ scale = float(np.median(np.abs(values - center))) * 1.4826
|
|
|
+ if scale <= 1e-12:
|
|
|
+ scale = float(np.std(values))
|
|
|
+ return scale
|
|
|
+
|
|
|
+
|
|
|
+def cycle_count(rows: list[dict]) -> int:
|
|
|
+ return len({r["wave_file_id"] for r in rows})
|
|
|
+
|
|
|
+
|
|
|
+def median_span(rows: list[dict]) -> float:
|
|
|
+ spans = [r["p_span"] for r in rows if r.get("p_span") is not None]
|
|
|
+ return float(np.median(spans)) if spans else 0.0
|
|
|
+
|
|
|
+
|
|
|
+def fallback_segments(rows: list[dict], merge_gap: int) -> list[tuple]:
|
|
|
+ days = sorted({r["time"].date() for r in rows})
|
|
|
+ return build_segments(days, merge_gap)
|
|
|
+
|
|
|
+
|
|
|
+def collect_before(rows, t0, segments, min_days):
|
|
|
+ segs = sorted([s for s in segments if s[0] <= t0.date()], key=lambda s: s[0], reverse=True)
|
|
|
+ chosen, days = [], set()
|
|
|
+ for start, end in segs:
|
|
|
+ part = [r for r in rows if start <= r["time"].date() <= end and r["time"] < t0]
|
|
|
+ chosen.extend(part)
|
|
|
+ days |= {r["time"].date() for r in part}
|
|
|
+ if len(days) >= min_days:
|
|
|
+ break
|
|
|
+ return sorted(chosen, key=lambda r: r["time"]), days
|
|
|
+
|
|
|
+
|
|
|
+def collect_after(rows, t0, segments, min_days, max_gap):
|
|
|
+ """First running segment after the event; extend only over small gaps."""
|
|
|
+ segs = sorted([s for s in segments if s[1] >= t0.date()], key=lambda s: s[0])
|
|
|
+ chosen, days = [], set()
|
|
|
+ prev_end = None
|
|
|
+ for start, end in segs:
|
|
|
+ if prev_end is not None and (start - prev_end).days > max_gap:
|
|
|
+ break
|
|
|
+ part = [r for r in rows if start <= r["time"].date() <= end and r["time"] >= t0]
|
|
|
+ chosen.extend(part)
|
|
|
+ days |= {r["time"].date() for r in part}
|
|
|
+ prev_end = end
|
|
|
+ if len(days) >= min_days:
|
|
|
+ break
|
|
|
+ return sorted(chosen, key=lambda r: r["time"]), days
|
|
|
+
|
|
|
+
|
|
|
+def window_rows(rows, t0, segments, pre_days, post_days, max_gap):
|
|
|
+ pre, pre_set = collect_before(rows, t0, segments, pre_days)
|
|
|
+ post, post_set = collect_after(rows, t0, segments, post_days, max_gap)
|
|
|
+ return pre, post, pre_set, post_set
|
|
|
+
|
|
|
+
|
|
|
+def compare_bins(pre, post, min_cycles):
|
|
|
+ """Per-bin level/shape comparison (BIN_METRICS)."""
|
|
|
+ pre_bins = defaultdict(lambda: defaultdict(list))
|
|
|
+ post_bins = defaultdict(lambda: defaultdict(list))
|
|
|
+ bin_start_of = {}
|
|
|
+ for row in pre:
|
|
|
+ bin_start_of[row["bin"]] = row["bin_start"]
|
|
|
+ for name in BIN_METRICS:
|
|
|
+ if row[name] is not None:
|
|
|
+ pre_bins[row["bin"]][name].append(row[name])
|
|
|
+ for row in post:
|
|
|
+ for name in BIN_METRICS:
|
|
|
+ if row[name] is not None:
|
|
|
+ post_bins[row["bin"]][name].append(row[name])
|
|
|
+
|
|
|
+ all_bins = sorted(set(pre_bins) & set(post_bins))
|
|
|
+ changes = []
|
|
|
+ for name in BIN_METRICS:
|
|
|
+ items = []
|
|
|
+ for b in all_bins:
|
|
|
+ a = np.asarray(pre_bins[b][name])
|
|
|
+ c = np.asarray(post_bins[b][name])
|
|
|
+ if a.size < min_cycles or c.size < min_cycles:
|
|
|
+ continue
|
|
|
+ pre_med = float(np.median(a))
|
|
|
+ post_med = float(np.median(c))
|
|
|
+ items.append({
|
|
|
+ "bin": b, "bin_start": bin_start_of.get(b, b * 10.0), "metric": name,
|
|
|
+ "pre_med": pre_med, "post_med": post_med, "delta": post_med - pre_med,
|
|
|
+ "scale": robust_scale(a), "pre_n": int(a.size), "post_n": int(c.size),
|
|
|
+ })
|
|
|
+ if len(items) < 4:
|
|
|
+ continue
|
|
|
+ scales = [it["scale"] for it in items if it["scale"] > 1e-12]
|
|
|
+ point_scale = float(np.median(scales)) if scales else 0.0
|
|
|
+ floor = FLOOR_FRAC * point_scale
|
|
|
+ pre_center = float(np.median([it["pre_med"] for it in items]))
|
|
|
+ global_delta = float(np.median([it["delta"] for it in items]))
|
|
|
+ global_z = global_delta / point_scale if point_scale > 1e-12 else None
|
|
|
+ for it in items:
|
|
|
+ denom = max(it["scale"], floor)
|
|
|
+ it["local_delta"] = it["delta"] - global_delta
|
|
|
+ it["local_z"] = it["local_delta"] / denom if denom > 1e-12 else None
|
|
|
+ it["rel"] = it["local_delta"] / abs(pre_center) if abs(pre_center) > 1e-9 else None
|
|
|
+ it["global_delta"] = global_delta
|
|
|
+ it["global_z"] = global_z
|
|
|
+ it["pre_center"] = pre_center
|
|
|
+ changes.extend(items)
|
|
|
+ return changes
|
|
|
+
|
|
|
+
|
|
|
+def compare_cycle(pre, post, min_cycles):
|
|
|
+ """Whole-cycle (valve timing / plateau) comparison, one record per metric."""
|
|
|
+ records = []
|
|
|
+ for name in CYCLE_METRICS:
|
|
|
+ a = np.asarray([r[name] for r in pre if r[name] is not None])
|
|
|
+ b = np.asarray([r[name] for r in post if r[name] is not None])
|
|
|
+ if a.size < min_cycles or b.size < min_cycles:
|
|
|
+ continue
|
|
|
+ if name in ANGLE_METRICS: # circular
|
|
|
+ pre_med = circ_median(a)
|
|
|
+ post_med = circ_median(b)
|
|
|
+ delta = circ_diff(pre_med, post_med)
|
|
|
+ residual = np.asarray([circ_diff(pre_med, v) for v in a])
|
|
|
+ scale = max(robust_scale(residual), ANGLE_SCALE_FLOOR)
|
|
|
+ rel = None
|
|
|
+ else:
|
|
|
+ pre_med = float(np.median(a))
|
|
|
+ post_med = float(np.median(b))
|
|
|
+ delta = post_med - pre_med
|
|
|
+ scale = robust_scale(a)
|
|
|
+ rel = delta / abs(pre_med) if abs(pre_med) > 1e-9 else None
|
|
|
+ records.append({
|
|
|
+ "metric": name, "pre_med": pre_med, "post_med": post_med, "delta": delta,
|
|
|
+ "scale": scale, "z": delta / scale if scale > 1e-12 else None,
|
|
|
+ "rel": rel, "pre_n": int(a.size), "post_n": int(b.size),
|
|
|
+ })
|
|
|
+ return records
|
|
|
+
|
|
|
+
|
|
|
+def trend_bins(pre, min_cycles):
|
|
|
+ series = defaultdict(lambda: defaultdict(list))
|
|
|
+ bin_start_of = {}
|
|
|
+ for row in pre:
|
|
|
+ bin_start_of[row["bin"]] = row["bin_start"]
|
|
|
+ for name in BIN_METRICS:
|
|
|
+ if row[name] is not None:
|
|
|
+ series[row["bin"]][name].append((row["time"], row[name]))
|
|
|
+ results = []
|
|
|
+ for name in BIN_METRICS:
|
|
|
+ items = []
|
|
|
+ for b, per_name in series.items():
|
|
|
+ points = sorted(per_name.get(name, []))
|
|
|
+ if len(points) < max(min_cycles, 8):
|
|
|
+ continue
|
|
|
+ x = np.asarray([(p[0] - points[0][0]).total_seconds() / 86400.0 for p in points])
|
|
|
+ y = np.asarray([p[1] for p in points])
|
|
|
+ if np.ptp(x) <= 0:
|
|
|
+ continue
|
|
|
+ slope = float(np.polyfit(x, y, 1)[0])
|
|
|
+ items.append({
|
|
|
+ "bin": b, "bin_start": bin_start_of.get(b, b * 10.0), "metric": name,
|
|
|
+ "total_drift": slope * float(np.ptp(x)), "scale": robust_scale(y), "n": len(points),
|
|
|
+ })
|
|
|
+ if len(items) < 4:
|
|
|
+ continue
|
|
|
+ scales = [it["scale"] for it in items if it["scale"] > 1e-12]
|
|
|
+ floor = FLOOR_FRAC * (float(np.median(scales)) if scales else 0.0)
|
|
|
+ global_drift = float(np.median([it["total_drift"] for it in items]))
|
|
|
+ for it in items:
|
|
|
+ denom = max(it["scale"], floor)
|
|
|
+ it["local_drift"] = it["total_drift"] - global_drift
|
|
|
+ it["local_mad"] = it["local_drift"] / denom if denom > 1e-12 else None
|
|
|
+ results.extend(items)
|
|
|
+ return results
|
|
|
+
|
|
|
+
|
|
|
+def trend_cycle(pre, min_cycles):
|
|
|
+ results = []
|
|
|
+ for name in CYCLE_METRICS:
|
|
|
+ if name in ANGLE_METRICS: # circular; skip linear trend
|
|
|
+ continue
|
|
|
+ points = sorted((r["time"], r[name]) for r in pre if r[name] is not None)
|
|
|
+ if len(points) < max(min_cycles, 8):
|
|
|
+ continue
|
|
|
+ x = np.asarray([(p[0] - points[0][0]).total_seconds() / 86400.0 for p in points])
|
|
|
+ y = np.asarray([p[1] for p in points])
|
|
|
+ if np.ptp(x) <= 0:
|
|
|
+ continue
|
|
|
+ slope = float(np.polyfit(x, y, 1)[0])
|
|
|
+ scale = robust_scale(y)
|
|
|
+ drift = slope * float(np.ptp(x))
|
|
|
+ results.append({"metric": name, "total_drift": drift, "scale": scale,
|
|
|
+ "mad": drift / scale if scale > 1e-12 else None, "n": len(points)})
|
|
|
+ return results
|
|
|
+
|
|
|
+
|
|
|
+def point_name(unit, cylinder, side):
|
|
|
+ return f"{unit}号机组{cylinder}缸压力{side}"
|
|
|
+
|
|
|
+
|
|
|
+def bin_label(bin_start, bin_deg):
|
|
|
+ return f"{bin_start:g}-{bin_start + bin_deg:g}°"
|
|
|
+
|
|
|
+
|
|
|
+def main() -> None:
|
|
|
+ parser = argparse.ArgumentParser(description="同点 分箱+整周期 运行段前后对比 + 趋势")
|
|
|
+ parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
|
|
|
+ parser.add_argument("--csv", nargs="+", required=True, help="angle_bins.py 长表 CSV(可多个)")
|
|
|
+ parser.add_argument("--pre-days", type=int, default=15, help="前段至少累计的运行日(不足也可)")
|
|
|
+ parser.add_argument("--post-days", type=int, default=15, help="后段目标运行日(不足也可)")
|
|
|
+ parser.add_argument("--trend-days", type=int, default=30, help="故障前趋势至少累计的运行日")
|
|
|
+ parser.add_argument("--max-gap", type=int, default=30, help="后段累加允许跨越的最大停机天数")
|
|
|
+ parser.add_argument("--merge-gap", type=int, default=2, help="运行段桥接允许的停机天数")
|
|
|
+ parser.add_argument("--min-cycles", type=int, default=3, help="每段最少周期数")
|
|
|
+ parser.add_argument("--z", type=float, default=3.0, help="变化阈值(MAD 倍数)")
|
|
|
+ parser.add_argument("--normalize", choices=("none", "span"), default="none",
|
|
|
+ help="span: 用本周期 p_span 归一再比(压工况)")
|
|
|
+ parser.add_argument("--top", type=int, default=15, help="每张表打印条数")
|
|
|
+ parser.add_argument("--out", default="", help="明细 CSV 输出路径")
|
|
|
+ args = parser.parse_args()
|
|
|
+ global Z
|
|
|
+ Z = args.z
|
|
|
+
|
|
|
+ rows = []
|
|
|
+ for path in args.csv:
|
|
|
+ part = load(path)
|
|
|
+ print(f" {path}: {len(part):,} 行")
|
|
|
+ rows.extend(part)
|
|
|
+ apply_normalization(rows, args.normalize)
|
|
|
+ if args.normalize != "none":
|
|
|
+ print(f"归一化: {args.normalize}")
|
|
|
+ starts = sorted({r["bin_start"] for r in rows})
|
|
|
+ bin_deg = round(starts[1] - starts[0], 6) if len(starts) >= 2 else 10.0
|
|
|
+ print(f"载入 {len(rows):,} 行,测点 {len({r['point_name'] for r in rows})},角度箱宽 {bin_deg:g}°")
|
|
|
+
|
|
|
+ try:
|
|
|
+ segments = unit_segments(args.unit, args.merge_gap)
|
|
|
+ print(f"运行段(rpm>0): {len(segments)} 个")
|
|
|
+ except Exception as error:
|
|
|
+ print(f"运行段取 DB 失败({error}),改用数据日期间隔")
|
|
|
+ segments = fallback_segments(rows, args.merge_gap)
|
|
|
+
|
|
|
+ detail_rows: list[dict] = []
|
|
|
+ for unit, date, kind in EVENTS:
|
|
|
+ if unit != args.unit:
|
|
|
+ continue
|
|
|
+ t0 = datetime.fromisoformat(date)
|
|
|
+ points = sorted({(r["cylinder"], r["side"]) for r in rows})
|
|
|
+ bin_changes, cycle_changes, bin_trends, cycle_trends = [], [], [], []
|
|
|
+ coverage = None
|
|
|
+ for cylinder, side in points:
|
|
|
+ point_rows = [r for r in rows if r["cylinder"] == cylinder and r["side"] == side]
|
|
|
+ pre, post, pre_set, post_set = window_rows(point_rows, t0, segments, args.pre_days, args.post_days, args.max_gap)
|
|
|
+ if cycle_count(pre) < args.min_cycles or cycle_count(post) < args.min_cycles:
|
|
|
+ continue
|
|
|
+ if median_span(pre) < MIN_SPAN or median_span(post) < MIN_SPAN:
|
|
|
+ continue
|
|
|
+ if coverage is None:
|
|
|
+ coverage = (min(r["time"] for r in pre), max(r["time"] for r in pre), pre_set,
|
|
|
+ min(r["time"] for r in post), max(r["time"] for r in post), post_set)
|
|
|
+ point = f"{cylinder}缸{side}"
|
|
|
+ label = point_name(unit, cylinder, side)
|
|
|
+ pre_dates = sorted({r["time"] for r in pre})
|
|
|
+ post_dates = sorted({r["time"] for r in post})
|
|
|
+ wmeta = {
|
|
|
+ "pre_start": str(pre_dates[0].date()), "pre_end": str(pre_dates[-1].date()),
|
|
|
+ "post_start": str(post_dates[0].date()), "post_end": str(post_dates[-1].date()),
|
|
|
+ "gap_days": (post_dates[0].date() - pre_dates[-1].date()).days,
|
|
|
+ "pre_span_days": (pre_dates[-1].date() - pre_dates[0].date()).days,
|
|
|
+ }
|
|
|
+
|
|
|
+ changes = compare_bins(pre, post, args.min_cycles)
|
|
|
+ strong_bins = defaultdict(int)
|
|
|
+ for c in changes:
|
|
|
+ if c["local_z"] is not None and abs(c["local_z"]) >= Z:
|
|
|
+ strong_bins[c["bin"]] += 1
|
|
|
+ for c in changes:
|
|
|
+ if c["local_z"] is None or abs(c["local_z"]) < Z:
|
|
|
+ continue
|
|
|
+ bin_changes.append({
|
|
|
+ "point": point, "label": label, "bin": c["bin"], "bin_start": c["bin_start"],
|
|
|
+ "type": "局部波动" if c["metric"] in VOLATILE else "局部均值",
|
|
|
+ "metric": c["metric"], "pre": c["pre_med"], "post": c["post_med"],
|
|
|
+ "delta": c["local_delta"], "mad": c["local_z"], "rel": c["rel"],
|
|
|
+ "candidate": strong_bins[c["bin"]] >= 2,
|
|
|
+ })
|
|
|
+ for name in BIN_METRICS:
|
|
|
+ items = [c for c in changes if c["metric"] == name]
|
|
|
+ if not items or items[0]["global_z"] is None or abs(items[0]["global_z"]) < Z:
|
|
|
+ continue
|
|
|
+ pre_c = items[0]["pre_center"]
|
|
|
+ bin_changes.append({
|
|
|
+ "point": point, "label": label, "bin": -1, "bin_start": -1.0,
|
|
|
+ "type": "整体波动" if name in VOLATILE else "整体水平", "metric": name,
|
|
|
+ "pre": pre_c, "post": pre_c + items[0]["global_delta"], "delta": items[0]["global_delta"],
|
|
|
+ "mad": items[0]["global_z"], "rel": None, "candidate": False,
|
|
|
+ })
|
|
|
+
|
|
|
+ for c in compare_cycle(pre, post, args.min_cycles):
|
|
|
+ if c["z"] is None or abs(c["z"]) < Z:
|
|
|
+ continue
|
|
|
+ cycle_changes.append({
|
|
|
+ "point": point, "label": label,
|
|
|
+ "type": "整周期波动" if c["metric"] in CYCLE_VOLATILE else "整周期水平",
|
|
|
+ "metric": c["metric"], "pre": c["pre_med"], "post": c["post_med"],
|
|
|
+ "delta": c["delta"], "mad": c["z"], "rel": c["rel"],
|
|
|
+ })
|
|
|
+
|
|
|
+ for t in trend_bins(pre, args.min_cycles):
|
|
|
+ if t.get("local_mad") is None or abs(t["local_mad"]) < Z:
|
|
|
+ continue
|
|
|
+ bin_trends.append({
|
|
|
+ "point": point, "label": label, "bin": t["bin"], "bin_start": t["bin_start"],
|
|
|
+ "type": "局部趋势" + ("波动" if t["metric"] in VOLATILE else "均值"),
|
|
|
+ "metric": t["metric"], "drift": t["local_drift"], "mad": t["local_mad"],
|
|
|
+ "n": t["n"],
|
|
|
+ })
|
|
|
+ for t in trend_cycle(pre, args.min_cycles):
|
|
|
+ if t["mad"] is None or abs(t["mad"]) < Z:
|
|
|
+ continue
|
|
|
+ cycle_trends.append({
|
|
|
+ "point": point, "label": label,
|
|
|
+ "type": "整周期趋势" + ("波动" if t["metric"] in CYCLE_VOLATILE else "水平"),
|
|
|
+ "metric": t["metric"], "drift": t["total_drift"], "mad": t["mad"], "n": t["n"],
|
|
|
+ })
|
|
|
+
|
|
|
+ print(f"\n{'=' * 100}")
|
|
|
+ print(f"== {unit}号机 {date} {kind}")
|
|
|
+ if coverage:
|
|
|
+ print(f" 前段 {coverage[0]:%Y-%m-%d}~{coverage[1]:%Y-%m-%d}({len(coverage[2])}运行日)"
|
|
|
+ f" 后段 {coverage[3]:%Y-%m-%d}~{coverage[4]:%Y-%m-%d}({len(coverage[5])}运行日)")
|
|
|
+ if not (bin_changes or cycle_changes or bin_trends or cycle_trends):
|
|
|
+ print(" 事件前后无足够运行数据")
|
|
|
+ continue
|
|
|
+
|
|
|
+ bin_changes.sort(key=lambda r: (r["type"].startswith("整体"), -abs(r["mad"])))
|
|
|
+ if bin_changes:
|
|
|
+ print(f" 【分箱前后变化】{len(bin_changes)} 条(★=同角度箱≥2指标一致)")
|
|
|
+ print(f" {'#':>3} {'测点':<9} {'角度箱':<10} {'类型':<8} {'指标':<11} {'前':>9} {'后':>9} {'变化':>9} {'相对':>8}")
|
|
|
+ for i, r in enumerate(bin_changes[:args.top], 1):
|
|
|
+ binst = "全箱" if r["bin"] < 0 else bin_label(r["bin_start"], bin_deg)
|
|
|
+ rel = f"{r['rel'] * 100:+.0f}%" if r["rel"] is not None else "-"
|
|
|
+ print(f" {i:>3} {r['point']:<9} {binst:<10} {r['type']:<8} {r['metric']:<11} "
|
|
|
+ f"{r['pre']:>9.4g} {r['post']:>9.4g} {r['mad']:>+8.1f}M {rel:>8} {'★' if r['candidate'] else ''}")
|
|
|
+
|
|
|
+ if cycle_changes:
|
|
|
+ cycle_changes.sort(key=lambda r: -abs(r["mad"]))
|
|
|
+ print(f" 【整周期/阀时序 前后变化】{len(cycle_changes)} 条")
|
|
|
+ print(f" {'#':>3} {'测点':<9} {'类型':<10} {'指标':<15} {'前':>10} {'后':>10} {'变化':>9} {'相对':>8}")
|
|
|
+ for i, r in enumerate(cycle_changes[:args.top], 1):
|
|
|
+ rel = f"{r['rel'] * 100:+.0f}%" if r["rel"] is not None else "-"
|
|
|
+ print(f" {i:>3} {r['point']:<9} {r['type']:<10} {r['metric']:<15} "
|
|
|
+ f"{r['pre']:>10.4g} {r['post']:>10.4g} {r['mad']:>+8.1f}M {rel:>8}")
|
|
|
+
|
|
|
+ if bin_trends:
|
|
|
+ bin_trends.sort(key=lambda r: -abs(r["mad"]))
|
|
|
+ print(f" 【故障前分箱趋势】{len(bin_trends)} 条")
|
|
|
+ print(f" {'#':>3} {'测点':<9} {'角度箱':<10} {'类型':<12} {'指标':<11} {'总变化':>11} {'变化':>9}")
|
|
|
+ for i, r in enumerate(bin_trends[:args.top], 1):
|
|
|
+ print(f" {i:>3} {r['point']:<9} {bin_label(r['bin_start'], bin_deg):<10} {r['type']:<12} "
|
|
|
+ f"{r['metric']:<11} {r['drift']:>+11.4g} {r['mad']:>+8.1f}M")
|
|
|
+
|
|
|
+ if cycle_trends:
|
|
|
+ cycle_trends.sort(key=lambda r: -abs(r["mad"]))
|
|
|
+ print(f" 【故障前整周期趋势】{len(cycle_trends)} 条")
|
|
|
+ print(f" {'#':>3} {'测点':<9} {'类型':<14} {'指标':<15} {'总变化':>12} {'变化':>9}")
|
|
|
+ for i, r in enumerate(cycle_trends[:args.top], 1):
|
|
|
+ print(f" {i:>3} {r['point']:<9} {r['type']:<14} {r['metric']:<15} "
|
|
|
+ f"{r['drift']:>+12.4g} {r['mad']:>+8.1f}M")
|
|
|
+
|
|
|
+ for r in bin_changes:
|
|
|
+ detail_rows.append({
|
|
|
+ **wmeta,
|
|
|
+ "unit": unit, "date": date, "kind": kind, "family": "bin", "point": r["point"],
|
|
|
+ "label": r["label"], "bin": r["bin"], "bin_start": r["bin_start"], "type": r["type"],
|
|
|
+ "metric": r["metric"], "pre": r["pre"], "post": r["post"], "delta": r["delta"],
|
|
|
+ "mad": r["mad"], "rel": r["rel"] if r["rel"] is not None else "",
|
|
|
+ "candidate": int(r["candidate"]),
|
|
|
+ })
|
|
|
+ for r in cycle_changes:
|
|
|
+ detail_rows.append({
|
|
|
+ **wmeta,
|
|
|
+ "unit": unit, "date": date, "kind": kind, "family": "cycle", "point": r["point"],
|
|
|
+ "label": r["label"], "bin": "", "bin_start": "", "type": r["type"],
|
|
|
+ "metric": r["metric"], "pre": r["pre"], "post": r["post"], "delta": r["delta"],
|
|
|
+ "mad": r["mad"], "rel": r["rel"] if r["rel"] is not None else "", "candidate": "",
|
|
|
+ })
|
|
|
+ for r in bin_trends:
|
|
|
+ detail_rows.append({
|
|
|
+ **wmeta,
|
|
|
+ "unit": unit, "date": date, "kind": kind, "family": "bin_trend", "point": r["point"],
|
|
|
+ "label": r["label"], "bin": r["bin"], "bin_start": r["bin_start"], "type": r["type"],
|
|
|
+ "metric": r["metric"], "pre": "", "post": "", "delta": r["drift"], "mad": r["mad"],
|
|
|
+ "rel": "", "candidate": "",
|
|
|
+ })
|
|
|
+ for r in cycle_trends:
|
|
|
+ detail_rows.append({
|
|
|
+ **wmeta,
|
|
|
+ "unit": unit, "date": date, "kind": kind, "family": "cycle_trend", "point": r["point"],
|
|
|
+ "label": r["label"], "bin": "", "bin_start": "", "type": r["type"],
|
|
|
+ "metric": r["metric"], "pre": "", "post": "", "delta": r["drift"], "mad": r["mad"],
|
|
|
+ "rel": "", "candidate": "",
|
|
|
+ })
|
|
|
+
|
|
|
+ if args.out and detail_rows:
|
|
|
+ out = Path(args.out)
|
|
|
+ out.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ with out.open("w", encoding="utf-8-sig", newline="") as handle:
|
|
|
+ writer = csv.DictWriter(handle, fieldnames=list(detail_rows[0].keys()))
|
|
|
+ writer.writeheader()
|
|
|
+ writer.writerows(detail_rows)
|
|
|
+ print(f"\n明细 -> {out}({len(detail_rows)} 行)")
|
|
|
+ print(f"时间:{datetime.now():%Y-%m-%d %H:%M:%S}")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|