shape_analysis.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. #!/usr/bin/env python3
  2. """Shape-based indicator-diagram comparison (pre/post repair, peer, chamber).
  3. Uses the process-invariant normalised P-theta profile so load/level changes
  4. cancel and only waveform shape remains. Reports L2 shape distances and saves a
  5. profile figure. Read-only.
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import sys
  10. from collections import defaultdict
  11. from pathlib import Path
  12. import numpy as np
  13. BACKEND = Path(__file__).resolve().parents[1]
  14. sys.path.insert(0, str(BACKEND))
  15. from indicator_features.extract_from_db import ( # noqa: E402
  16. CYLINDERS,
  17. SIDES,
  18. connect,
  19. fetch_cycles,
  20. fetch_file_rows,
  21. point_name,
  22. sample_per_day,
  23. )
  24. from indicator_features.indicator_features import CYLINDER_STAGE, normalized_profile # noqa: E402
  25. def collect(connection, unit, cylinders, sides, start, end, per_day, bins):
  26. profiles = defaultdict(list)
  27. for cylinder in cylinders:
  28. for side in sides:
  29. point = point_name(unit, cylinder, side)
  30. files = fetch_file_rows(connection, point, start, end)
  31. picked = sample_per_day(files, per_day)
  32. if not picked:
  33. continue
  34. id_map = {int(r["id"]): r for r in picked}
  35. for file_id, signal in fetch_cycles(connection, list(id_map), 200).items():
  36. prof = normalized_profile(signal, cylinder, bins=bins)
  37. if prof is not None:
  38. profiles[(cylinder, side)].append(prof)
  39. return profiles
  40. def median_profile(items):
  41. return np.median(np.vstack(items), axis=0) if items else None
  42. STAGE_MEMBERS = {"一级": ("二", "四", "六"), "二级": ("三", "五"), "三级": ("一",)}
  43. def peer_median(medians, cylinder, side):
  44. peers = STAGE_MEMBERS[CYLINDER_STAGE[cylinder]]
  45. same = [m for (c, s), m in medians.items() if s == side and c in peers and m is not None]
  46. return np.median(np.vstack(same), axis=0) if same else None
  47. def main():
  48. parser = argparse.ArgumentParser(description="功图波形形状对照(修前/修后、同级、双腔)")
  49. parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
  50. parser.add_argument("--cylinders", nargs="+", default=list(CYLINDERS), choices=CYLINDERS)
  51. parser.add_argument("--sides", nargs="+", default=list(SIDES), choices=SIDES)
  52. parser.add_argument("--pre", nargs=2, required=True)
  53. parser.add_argument("--post", nargs=2, required=True)
  54. parser.add_argument("--per-day", type=int, default=6)
  55. parser.add_argument("--bins", type=int, default=180)
  56. parser.add_argument("--out-dir", default=str(BACKEND / "cache" / "indicator" / "shape"))
  57. args = parser.parse_args()
  58. connection = connect()
  59. try:
  60. pre = collect(connection, args.unit, args.cylinders, args.sides, args.pre[0], args.pre[1], args.per_day, args.bins)
  61. post = collect(connection, args.unit, args.cylinders, args.sides, args.post[0], args.post[1], args.per_day, args.bins)
  62. finally:
  63. connection.close()
  64. pre_med = {k: median_profile(v) for k, v in pre.items() if v}
  65. post_med = {k: median_profile(v) for k, v in post.items() if v}
  66. print(f"{args.unit}号机 形状对照 修前 {args.pre[0]}~{args.pre[1]} 修后 {args.post[0]}~{args.post[1]}")
  67. print(f"{'缸侧':>6}{'样本前/后':>12}{'修前/后 L2':>12}{'peer前':>9}{'peer后':>9}{'双腔前':>9}{'双腔后':>9}")
  68. for key in sorted(set(pre_med) | set(post_med)):
  69. cylinder, side = key
  70. pm, qm = pre_med.get(key), post_med.get(key)
  71. pp = peer_median(pre_med, cylinder, side)
  72. qp = peer_median(post_med, cylinder, side)
  73. peer_pre = np.linalg.norm(pm - pp) if pm is not None and pp is not None else float("nan")
  74. peer_post = np.linalg.norm(qm - qp) if qm is not None and qp is not None else float("nan")
  75. other = "轴侧" if side == "盖侧" else "盖侧"
  76. ch_pre = np.linalg.norm(pm - pre_med[(cylinder, other)]) if pm is not None and (cylinder, other) in pre_med and pre_med[(cylinder, other)] is not None else float("nan")
  77. ch_post = np.linalg.norm(qm - post_med[(cylinder, other)]) if qm is not None and (cylinder, other) in post_med and post_med[(cylinder, other)] is not None else float("nan")
  78. cross = np.linalg.norm(pm - qm) if pm is not None and qm is not None else float("nan")
  79. print(f"{cylinder+side:>6}{len(pre.get(key,[])):>6}/{len(post.get(key,[])):<5}{cross:>12.3f}{peer_pre:>9.3f}{peer_post:>9.3f}{ch_pre:>9.3f}{ch_post:>9.3f}")
  80. # Save a figure (best effort).
  81. try:
  82. import matplotlib
  83. matplotlib.use("Agg")
  84. import matplotlib.pyplot as plt
  85. out = Path(args.out_dir)
  86. out.mkdir(parents=True, exist_ok=True)
  87. keys = [k for k in sorted(set(pre_med) | set(post_med))]
  88. if keys:
  89. cols = 3
  90. rows = (len(keys) + cols - 1) // cols
  91. fig, axes = plt.subplots(rows, cols, figsize=(4 * cols, 3 * rows), squeeze=False)
  92. for ax, key in zip(axes.ravel(), keys):
  93. if pre_med.get(key) is not None:
  94. ax.plot(pre_med[key], color="crimson", label="pre")
  95. if post_med.get(key) is not None:
  96. ax.plot(post_med[key], color="royalblue", label="post")
  97. ax.set_title(f"{key[0]}缸{key[1]}", fontsize=10)
  98. ax.set_ylim(-0.2, 1.4)
  99. ax.legend(fontsize=7)
  100. for ax in axes.ravel()[len(keys):]:
  101. ax.axis("off")
  102. fig.suptitle(f"{args.unit}号机 归一化功图剖面 pre vs post")
  103. fig.tight_layout()
  104. path = out / f"{args.unit}_{args.pre[0]}_{args.post[0]}.png"
  105. fig.savefig(path, dpi=110)
  106. print("图:", path)
  107. except Exception as error:
  108. print("绘图跳过:", error)
  109. if __name__ == "__main__":
  110. main()