common_residual_analysis.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. #!/usr/bin/env python3
  2. """Separate common operating-condition changes from local diagram changes."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. import sys
  7. from collections import defaultdict
  8. from datetime import datetime, timedelta
  9. from pathlib import Path
  10. import numpy as np
  11. BACKEND = Path(__file__).resolve().parents[1]
  12. sys.path.insert(0, str(BACKEND))
  13. from event_bins import EVENTS # noqa: E402
  14. BAD = {("7", "四", "盖侧"), ("7", "六", "盖侧"), ("8", "六", "盖侧")}
  15. SCALARS = {
  16. "p_suction": "吸气压力", "p_discharge": "排气压力", "p_span": "压力幅度",
  17. "suction_frac": "吸气占比", "discharge_frac": "排气占比",
  18. "compression_slope": "压缩斜率", "expansion_slope": "膨胀斜率",
  19. "corner_sharpness": "拐角变化",
  20. }
  21. def load(path: str) -> list[dict]:
  22. with open(path, encoding="utf-8-sig", newline="") as handle:
  23. rows = list(csv.DictReader(handle))
  24. for row in rows:
  25. row["time"] = datetime.fromisoformat(row["sample_time"])
  26. row["span"] = float(row.get("p_span") or 0)
  27. for key in list(SCALARS) + [k for k in row if k.startswith("prof_")]:
  28. try:
  29. row[key] = float(row[key])
  30. except (TypeError, ValueError):
  31. row[key] = np.nan
  32. return rows
  33. def median(rows: list[dict], key: str) -> float | None:
  34. values = [r[key] for r in rows if np.isfinite(r[key])]
  35. return float(np.median(values)) if values else None
  36. def profile_median(rows: list[dict], names: list[str]) -> np.ndarray | None:
  37. if not rows:
  38. return None
  39. values = np.asarray([[r[n] for n in names] for r in rows], dtype=float)
  40. values = values[np.all(np.isfinite(values), axis=1)]
  41. return np.median(values, axis=0) if len(values) else None
  42. def pct(value: float | None) -> str:
  43. return "" if value is None else f"{value:.5f}"
  44. def main() -> None:
  45. parser = argparse.ArgumentParser(description="公共工况变化与局部功图残差")
  46. parser.add_argument("--csv", nargs="+", required=True)
  47. parser.add_argument("--unit", nargs="+", required=True)
  48. parser.add_argument("--pre-days", type=int, default=30)
  49. parser.add_argument("--post-days", type=int, default=30)
  50. parser.add_argument("--outdir", required=True)
  51. parser.add_argument("--event", nargs=3, action="append", metavar=("UNIT", "DATE", "KIND"),
  52. help="只分析指定事件;可重复传入")
  53. args = parser.parse_args()
  54. by_unit = {unit: load(path) for unit, path in zip(args.unit, args.csv)}
  55. outdir = Path(args.outdir)
  56. outdir.mkdir(parents=True, exist_ok=True)
  57. all_rows: list[dict] = []
  58. summary: list[dict] = []
  59. normal: list[dict] = []
  60. events = [tuple(item) for item in args.event] if args.event else EVENTS
  61. for unit, date_text, kind in events:
  62. source = [r for r in by_unit.get(unit, []) if r["span"] >= 0.3]
  63. t0 = datetime.fromisoformat(date_text)
  64. pre_start = t0 - timedelta(days=args.pre_days)
  65. post_end = t0 + timedelta(days=args.post_days)
  66. points = sorted({(r["cylinder"], r["side"]) for r in source if (unit, r["cylinder"], r["side"]) not in BAD})
  67. profiles = sorted(k for k in source[0] if k.startswith("prof_")) if source else []
  68. deltas: dict[tuple[str, str], np.ndarray] = {}
  69. profile_pairs = {}
  70. scalar_changes: dict[tuple[str, str], dict[str, float]] = {}
  71. for cylinder, side in points:
  72. rows = [r for r in source if r["cylinder"] == cylinder and r["side"] == side]
  73. pre = [r for r in rows if pre_start <= r["time"] < t0]
  74. post = [r for r in rows if t0 <= r["time"] <= post_end]
  75. if not pre or not post:
  76. continue
  77. before = profile_median(pre, profiles)
  78. after = profile_median(post, profiles)
  79. if before is None or after is None:
  80. continue
  81. delta = after - before
  82. deltas[(cylinder, side)] = delta
  83. profile_pairs[(cylinder, side)] = (before, after, len(pre), len(post))
  84. changes = {}
  85. for key in SCALARS:
  86. a, b = median(pre, key), median(post, key)
  87. changes[key] = np.nan if a is None or b is None else b - a
  88. scalar_changes[(cylinder, side)] = changes
  89. if not deltas:
  90. summary.append({"机组": unit, "事件": date_text, "故障类型": kind, "可比较测点": 0,
  91. "公共形状变化": "", "局部残差最大": "", "判断": "修前无有效功图"})
  92. continue
  93. matrix = np.vstack(list(deltas.values()))
  94. common = np.median(matrix, axis=0)
  95. common_norm = float(np.linalg.norm(common))
  96. local_scores = {}
  97. for point, delta in deltas.items():
  98. residual = delta - common
  99. local = float(np.linalg.norm(residual))
  100. total = float(np.linalg.norm(delta))
  101. local_scores[point] = (local, total, residual)
  102. cylinder, side = point
  103. before, after, pre_n, post_n = profile_pairs[point]
  104. for idx, name in enumerate(profiles):
  105. all_rows.append({
  106. "机组": unit, "事件": date_text, "故障类型": kind,
  107. "测点": f"{cylinder}缸{side}", "角度": f"{idx * 360 / len(profiles):.1f}°",
  108. "前后变化": round(float(after[idx] - before[idx]), 6),
  109. "公共变化": round(float(common[idx]), 6),
  110. "局部残差": round(float(after[idx] - before[idx] - common[idx]), 6),
  111. "修前周期": pre_n, "修后周期": post_n,
  112. })
  113. ranked = sorted(local_scores.items(), key=lambda item: item[1][0], reverse=True)
  114. top = ranked[0]
  115. ratio = top[1][0] / max(common_norm, 1e-12)
  116. judgment = "局部差异优先" if ratio >= 1.2 else "公共工况变化优先"
  117. summary.append({
  118. "机组": unit, "事件": date_text, "故障类型": kind,
  119. "可比较测点": len(deltas), "公共形状变化": round(common_norm, 4),
  120. "局部残差最大": f"{top[0][0]}缸{top[0][1]}",
  121. "最大局部残差": round(top[1][0], 4), "该点总变化": round(top[1][1], 4),
  122. "局部/公共": round(ratio, 2), "判断": judgment,
  123. })
  124. # Normal candidates: closest to the pre-event median profile for each point.
  125. for point, (before, after, pre_n, post_n) in profile_pairs.items():
  126. cylinder, side = point
  127. rows = [r for r in source if r["cylinder"] == cylinder and r["side"] == side]
  128. pre_rows = [r for r in rows if pre_start <= r["time"] < t0]
  129. distances = []
  130. for row in pre_rows:
  131. vector = np.asarray([row[n] for n in profiles], dtype=float)
  132. if np.all(np.isfinite(vector)):
  133. distances.append((float(np.linalg.norm(vector - before)), row))
  134. if distances:
  135. distance, row = min(distances, key=lambda item: item[0])
  136. local, total, _ = local_scores[point]
  137. normal.append({
  138. "机组": unit, "事件": date_text, "测点": f"{cylinder}缸{side}",
  139. "类别": "正常候选", "日期": row["sample_time"],
  140. "距修前模板": round(distance, 4), "事件后该点变化": round(total, 4),
  141. "局部残差": round(local, 4),
  142. })
  143. # One compact scalar table per event and point.
  144. for point, changes in scalar_changes.items():
  145. for key, label in SCALARS.items():
  146. all_rows.append({
  147. "机组": unit, "事件": date_text, "故障类型": kind,
  148. "测点": f"{point[0]}缸{point[1]}", "角度": label,
  149. "前后变化": "" if np.isnan(changes[key]) else round(changes[key], 6),
  150. "公共变化": "", "局部残差": "", "修前周期": "", "修后周期": "",
  151. })
  152. def write(path: Path, rows: list[dict]) -> None:
  153. fields = []
  154. for row in rows:
  155. for field in row:
  156. if field not in fields:
  157. fields.append(field)
  158. with path.open("w", encoding="utf-8-sig", newline="") as handle:
  159. writer = csv.DictWriter(handle, fieldnames=fields)
  160. writer.writeheader()
  161. writer.writerows(rows)
  162. write(outdir / "三事件_公共与局部变化.csv", all_rows)
  163. write(outdir / "三事件_公共工况判断.csv", summary)
  164. write(outdir / "三事件_正常功图候选.csv", normal)
  165. print(f"公共/局部变化 -> {outdir / '三事件_公共与局部变化.csv'}")
  166. print(f"事件判断 -> {outdir / '三事件_公共工况判断.csv'}")
  167. print(f"正常候选 -> {outdir / '三事件_正常功图候选.csv'}")
  168. for row in summary:
  169. print(row)
  170. if __name__ == "__main__":
  171. main()