trend.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. #!/usr/bin/env python3
  2. """Long-term indicator-feature deviation vs the scanner's anomaly segments.
  3. For each cylinder/side it reduces the feature CSV to daily medians, scores them
  4. as robust z against the point's own baseline (median/MAD over its valid days),
  5. and marks days that fall inside a rule anomaly segment from cache/pks_scanner/
  6. evolution (pressure 阶段段 / 振动异常段). Read-only.
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import csv
  11. from collections import defaultdict
  12. from datetime import datetime
  13. from pathlib import Path
  14. import numpy as np
  15. UNIT_BATCH = {"7": 30, "8": 31, "9": 32}
  16. TREND_FEATURES = ("p_discharge", "pressure_ratio", "n_comp", "efficiency")
  17. def parse_dt(value):
  18. return datetime.strptime(value[:19], "%Y-%m-%d %H:%M:%S")
  19. def load_features(path):
  20. rows = list(csv.DictReader(open(path, encoding="utf-8-sig")))
  21. for row in rows:
  22. row["day"] = row["sample_time"][:10]
  23. for name in TREND_FEATURES + ("indicated_work",):
  24. try:
  25. row[name] = float(row[name])
  26. except (TypeError, ValueError):
  27. row[name] = None
  28. return rows
  29. def load_segments(evolution_dir, unit):
  30. """Rule anomaly segments for a unit from the two stage CSVs."""
  31. segments = []
  32. for name, kind in (("润滑油压力小时阶段段.csv", "压力"), ("振动异常段.csv", "振动")):
  33. path = Path(evolution_dir) / name
  34. if not path.is_file():
  35. continue
  36. for row in csv.DictReader(open(path, encoding="utf-8-sig")):
  37. if row["机组"] != f"{unit}号机":
  38. continue
  39. segments.append({
  40. "kind": kind, "level": row.get("压力阶段") or row.get("振动阶段"),
  41. "start": parse_dt(row["开始小时"]), "end": parse_dt(row["结束小时"]),
  42. })
  43. return segments
  44. def mark_segments(day: str, segments):
  45. moment = datetime.strptime(day, "%Y-%m-%d")
  46. return [s["kind"] + "/" + str(s["level"]) for s in segments if s["start"] <= moment <= s["end"]]
  47. def peer_sudden_changes(rows, feature, segments):
  48. """Day-to-day jumps of the peer-relative deviation (cancels operating level)."""
  49. by_key = defaultdict(dict)
  50. for row in rows:
  51. if row[feature] is not None:
  52. by_key[(row["stage"], row["side"], row["day"])][row["cylinder"]] = row[feature]
  53. series = defaultdict(dict)
  54. for (_stage, side, day), members in by_key.items():
  55. values = [v for v in members.values() if v is not None]
  56. if len(values) < 2:
  57. continue
  58. med = float(np.median(values))
  59. if abs(med) < 1e-9:
  60. continue
  61. for cylinder, value in members.items():
  62. if value is not None:
  63. series[(cylinder, side)][day] = (value - med) / med
  64. print(f"\n== 同级缸间偏差 突发变化({feature})==")
  65. for (cylinder, side), daily in sorted(series.items()):
  66. days = sorted(daily)
  67. if len(days) < 3:
  68. continue
  69. values = np.array([daily[d] for d in days], dtype=float)
  70. scale = float(np.median(np.abs(values - np.median(values)))) * 1.4826 or float(np.std(values))
  71. if scale <= 1e-9:
  72. continue
  73. for i in range(1, len(days)):
  74. gap = (datetime.strptime(days[i], "%Y-%m-%d") - datetime.strptime(days[i - 1], "%Y-%m-%d")).days
  75. if gap > 3:
  76. continue
  77. delta = values[i] - values[i - 1]
  78. if abs(delta) / scale >= 3.0:
  79. flags = mark_segments(days[i], segments)
  80. print(f" {cylinder}缸{side}: 偏差 {values[i-1]*100:+.1f}% → {values[i]*100:+.1f}% 于 {days[i]} ({delta/scale:+.1f}×) {' '.join(flags)}")
  81. def _fit_drift(days, values):
  82. x = np.array([(datetime.strptime(d, "%Y-%m-%d") - datetime.strptime(days[0], "%Y-%m-%d")).days for d in days], dtype=float)
  83. y = np.asarray(values, dtype=float)
  84. if x[-1] <= 0 or len(y) < 8:
  85. return None
  86. slope, intercept = np.polyfit(x, y, 1)
  87. pred = slope * x + intercept
  88. ss_res = float(np.sum((y - pred) ** 2))
  89. ss_tot = float(np.sum((y - y.mean()) ** 2))
  90. r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
  91. center = float(np.median(y))
  92. rel30 = (slope * 30.0 / center) if abs(center) > 1e-9 else 0.0
  93. return rel30, r2, float(y[0]), float(y[-1])
  94. def slow_trends(rows, features=("p_discharge", "pressure_ratio", "efficiency", "n_comp", "indicated_work")):
  95. """Slow, weak drifts: linear fit per (cylinder, side) and per peer-relative series."""
  96. print("\n== 慢趋势:自基线(斜率 %/30天,要求 |%|>=3 且 R2>=0.3) ==")
  97. by_series = defaultdict(list)
  98. for row in rows:
  99. by_series[(row["cylinder"], row["side"])].append(row)
  100. for (cylinder, side), series in sorted(by_series.items()):
  101. days = sorted({r["day"] for r in series})
  102. for feature in features:
  103. daily = {d: np.median([r[feature] for r in series if r["day"] == d and r[feature] is not None]) for d in days}
  104. present = [d for d in days if daily.get(d) is not None]
  105. fit = _fit_drift(present, [daily[d] for d in present])
  106. if fit and abs(fit[0]) >= 0.03 and fit[1] >= 0.3:
  107. print(f" {cylinder}缸{side} {feature:<16} {fit[0]*100:+.1f}%/30天 R2={fit[1]:.2f} ({fit[2]:.3f}->{fit[3]:.3f})")
  108. print("\n== 慢趋势:同级 peer 相对(%/30天) ==")
  109. by_ps = defaultdict(dict)
  110. for row in rows:
  111. if row["pressure_ratio"] is not None:
  112. by_ps[(row["stage"], row["side"], row["day"])][row["cylinder"]] = row["pressure_ratio"]
  113. rel = defaultdict(dict)
  114. for (stage, side, day), members in by_ps.items():
  115. clean = {c: v for c, v in members.items() if v is not None}
  116. if len(clean) < 2:
  117. continue
  118. med = float(np.median(list(clean.values())))
  119. if abs(med) < 1e-9:
  120. continue
  121. for cylinder, value in clean.items():
  122. rel[(cylinder, side)][day] = (value - med) / med
  123. for (cylinder, side), daily in sorted(rel.items()):
  124. days = sorted(daily)
  125. fit = _fit_drift(days, [daily[d] for d in days])
  126. if fit and abs(fit[0]) >= 0.03 and fit[1] >= 0.3:
  127. print(f" {cylinder}缸{side} pressure_ratio(peer) {fit[0]*100:+.1f}%/30天 R2={fit[1]:.2f} ({fit[2]*100:+.1f}%->{fit[3]*100:+.1f}%)")
  128. def main():
  129. parser = argparse.ArgumentParser(description="功图特征长期偏离检测(对齐 evolution 异常段)")
  130. parser.add_argument("csv_path")
  131. parser.add_argument("--evolution-dir", default=str(Path(__file__).resolve().parents[2] / "cache" / "pks_scanner" / "evolution"))
  132. parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
  133. args = parser.parse_args()
  134. rows = load_features(args.csv_path)
  135. segments = load_segments(args.evolution_dir, args.unit)
  136. by_series = defaultdict(list)
  137. for row in rows:
  138. by_series[(row["cylinder"], row["side"])].append(row)
  139. print(f"文件 {args.csv_path} 行 {len(rows)} evolution段 {len(segments)}")
  140. for (cylinder, side), series in sorted(by_series.items()):
  141. days = sorted({r["day"] for r in series})
  142. print(f"\n== {cylinder}缸{side} ({len(days)} 天)")
  143. for feature in TREND_FEATURES:
  144. daily = {}
  145. for day in days:
  146. values = [r[feature] for r in series if r["day"] == day and r[feature] is not None]
  147. if values:
  148. daily[day] = float(np.median(values))
  149. if len(daily) < 5:
  150. continue
  151. values = np.array([daily[d] for d in days if d in daily], dtype=float)
  152. center = float(np.median(values))
  153. mad = float(np.median(np.abs(values - center)))
  154. scale = mad * 1.4826 or float(np.std(values))
  155. if scale <= 1e-9:
  156. continue
  157. print(f" {feature:<16} 基线 {center:.3f}±{scale:.3f}")
  158. for day in days:
  159. if day not in daily:
  160. continue
  161. z = (daily[day] - center) / scale
  162. flags = mark_segments(day, segments)
  163. if abs(z) >= 2.0 or flags:
  164. tag = " ".join(flags)
  165. print(f" {day} {daily[day]:>8.3f} z={z:+.2f} {tag}")
  166. # Abrupt day-to-day change (sudden step), independent of any trend.
  167. present = [d for d in days if d in daily]
  168. jumps = []
  169. for i in range(1, len(present)):
  170. gap = (datetime.strptime(present[i], "%Y-%m-%d") - datetime.strptime(present[i - 1], "%Y-%m-%d")).days
  171. if gap <= 3:
  172. jumps.append((present[i], daily[present[i]] - daily[present[i - 1]]))
  173. if jumps:
  174. day_jump, delta = max(jumps, key=lambda item: abs(item[1]))
  175. if abs(delta) / scale >= 3.0:
  176. flags = mark_segments(day_jump, segments)
  177. tag = " ".join(flags)
  178. print(f" [突发] {feature}: Δ={delta:+.3f} ({delta / scale:+.1f}×基线尺度) 于 {day_jump} {tag}")
  179. peer_sudden_changes(rows, "p_discharge", segments)
  180. slow_trends(rows)
  181. if __name__ == "__main__":
  182. main()