angle_bins.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. #!/usr/bin/env python3
  2. """Per-10-degree local statistics from every stored indicator cycle (read-only).
  3. Each ``wave_sample_one`` row set is exactly one crankshaft revolution of chamber
  4. pressure. Map every raw sample to a crank angle (0-360, linear in sample index,
  5. 0 = keyphasor marker) and split it into 36 ten-degree bins. On the **raw**
  6. samples of each bin compute six simple numbers:
  7. mean 箱内平均压力
  8. range 箱内 max - min
  9. std 箱内标准差(波动)
  10. mad 箱内中位绝对偏差(稳健波动)
  11. slope 箱内压力对角度的线性斜率
  12. roughness 去掉箱内线性趋势后的残差 RMS(平滑 / 毛糙)
  13. No resampling. One long CSV row per (cycle, bin), so the file is large by
  14. design and the caller should keep ``--per-day`` small. Only running cycles
  15. (``wave_file.rpm > 0``) are used. Same-point before/after comparison is done by
  16. ``event_bins.py``.
  17. """
  18. from __future__ import annotations
  19. import argparse
  20. import csv
  21. import sys
  22. from collections import defaultdict
  23. from datetime import datetime
  24. from pathlib import Path
  25. import numpy as np
  26. import pymysql
  27. BACKEND = Path(__file__).resolve().parents[1]
  28. sys.path.insert(0, str(BACKEND))
  29. from app.config import settings # noqa: E402
  30. from indicator_features.bins_common import BIN_METRICS, CYCLE_METRICS, FIELDNAMES # noqa: E402
  31. from indicator_features.indicator_features import CYLINDER_STAGE, otsu_threshold, smooth # noqa: E402
  32. CYLINDERS = ("一", "二", "三", "四", "五", "六")
  33. SIDES = ("盖侧", "轴侧")
  34. DEFAULT_BIN_DEG = 10.0
  35. def connect() -> pymysql.connections.Connection:
  36. return pymysql.connect(
  37. host=settings.db_host, port=settings.db_port, user=settings.db_user,
  38. password=settings.db_password, database=settings.db_name, charset="utf8mb4",
  39. cursorclass=pymysql.cursors.DictCursor, connect_timeout=8, read_timeout=600,
  40. )
  41. def point_name(unit: str, cylinder: str, side: str) -> str:
  42. return f"{unit}号机组{cylinder}缸压力{side}"
  43. def fetch_file_rows(connection, point: str, start: str, end: str) -> list[dict]:
  44. """Running pressure files for one point in [start, end)."""
  45. with connection.cursor() as cursor:
  46. cursor.execute(
  47. """
  48. SELECT id, sample_time, rpm
  49. FROM wave_file
  50. WHERE point_name = %s AND measurement_type = '压力'
  51. AND rpm > 0
  52. AND sample_time >= %s AND sample_time < %s
  53. ORDER BY sample_time
  54. """,
  55. (point, start, end),
  56. )
  57. return list(cursor.fetchall())
  58. def sample_per_day(rows: list[dict], per_day: int) -> list[dict]:
  59. if per_day <= 0:
  60. return rows
  61. by_day: dict = defaultdict(list)
  62. for row in rows:
  63. by_day[row["sample_time"].date()].append(row)
  64. picked: list[dict] = []
  65. for day in sorted(by_day):
  66. day_rows = by_day[day]
  67. if len(day_rows) <= per_day:
  68. picked.extend(day_rows)
  69. continue
  70. idx = np.linspace(0, len(day_rows) - 1, per_day).round().astype(int)
  71. picked.extend(day_rows[int(i)] for i in idx)
  72. return picked
  73. def fetch_cycles(connection, file_ids: list[int], batch: int) -> dict[int, np.ndarray]:
  74. cycles: dict[int, list[tuple[int, float]]] = defaultdict(list)
  75. with connection.cursor() as cursor:
  76. for start in range(0, len(file_ids), batch):
  77. chunk = file_ids[start:start + batch]
  78. placeholders = ", ".join(["%s"] * len(chunk))
  79. cursor.execute(
  80. f"""
  81. SELECT wave_file_id, sample_index, CAST(signal_value AS FLOAT) AS sig
  82. FROM wave_sample_one
  83. WHERE wave_file_id IN ({placeholders})
  84. ORDER BY wave_file_id, sample_index
  85. """,
  86. tuple(chunk),
  87. )
  88. for row in cursor.fetchall():
  89. if row["sig"] is None:
  90. continue
  91. cycles[int(row["wave_file_id"])].append((int(row["sample_index"]), float(row["sig"])))
  92. result: dict[int, np.ndarray] = {}
  93. for file_id, rows in cycles.items():
  94. rows.sort()
  95. result[file_id] = np.asarray([value for _, value in rows], dtype=float)
  96. return result
  97. def _safe_ratio(numerator: float, denominator: float) -> float | None:
  98. return numerator / denominator if abs(denominator) > 1e-12 else None
  99. def bin_features(signal: np.ndarray, bin_deg: float = DEFAULT_BIN_DEG) -> list[dict]:
  100. """Distribution / derivative / oscillation numbers for each angular bin (raw samples)."""
  101. p = np.asarray(signal, dtype=float)
  102. p = p[np.isfinite(p)]
  103. count = len(p)
  104. if count < 60:
  105. return []
  106. nbins = int(round(360.0 / bin_deg))
  107. angle = 360.0 * np.arange(count) / count
  108. features = []
  109. for b in range(nbins):
  110. low = b * bin_deg
  111. high = low + bin_deg
  112. mask = (angle >= low) & (angle < high)
  113. y = p[mask]
  114. x = angle[mask]
  115. if y.size < 3:
  116. continue
  117. center = float(np.mean(y))
  118. std = float(np.std(y))
  119. span_x = float(np.ptp(x))
  120. if span_x > 0:
  121. slope, intercept = np.polyfit(x, y, 1)
  122. residual = y - (slope * x + intercept)
  123. else:
  124. slope, residual = 0.0, y - center
  125. p10, p50, p90 = (float(v) for v in np.percentile(y, [10, 50, 90]))
  126. if std > 1e-12:
  127. z = (y - center) / std
  128. skew = float(np.mean(z ** 3))
  129. kurt = float(np.mean(z ** 4))
  130. else:
  131. skew = kurt = 0.0
  132. dy = np.diff(y)
  133. d2 = np.diff(dy) if dy.size > 1 else np.empty(0)
  134. if dy.size:
  135. abs_dy = np.abs(dy)
  136. d_abs_mean = float(np.mean(abs_dy))
  137. d_abs_max = float(np.max(abs_dy))
  138. d_rms = float(np.sqrt(np.mean(dy ** 2)))
  139. tv = float(np.sum(abs_dy))
  140. if d2.size:
  141. d2_rms = float(np.sqrt(np.mean(d2 ** 2)))
  142. d2_abs_max = float(np.max(np.abs(d2)))
  143. sign = np.sign(dy)
  144. sign[sign == 0] = 1
  145. zero_cross = int(np.count_nonzero(np.diff(sign) != 0))
  146. else:
  147. d2_rms = d2_abs_max = 0.0
  148. zero_cross = 0
  149. else:
  150. d_abs_mean = d_abs_max = d_rms = d2_rms = d2_abs_max = tv = 0.0
  151. zero_cross = 0
  152. n_extrema = int(np.count_nonzero(np.diff(np.sign(np.diff(y))) != 0)) if y.size > 2 else 0
  153. centered = y - center
  154. mc = np.sign(centered)
  155. mc[mc == 0] = 1
  156. mean_cross = int(np.count_nonzero(np.diff(mc) != 0)) if y.size > 1 else 0
  157. # Hjorth mobility / complexity (shape/irregularity)
  158. var_y = float(np.var(y))
  159. var_dy = float(np.var(dy)) if dy.size else 0.0
  160. var_d2 = float(np.var(d2)) if d2.size else 0.0
  161. mobility = float(np.sqrt(var_dy / var_y)) if var_y > 1e-18 else 0.0
  162. complexity = (float(np.sqrt(var_d2 / var_dy)) / mobility) if var_dy > 1e-18 and mobility > 1e-18 else 0.0
  163. features.append({
  164. "bin": b, "bin_start": low, "n_raw": int(y.size),
  165. "mean": center, "range": float(np.max(y) - np.min(y)), "std": std,
  166. "mad": float(np.median(np.abs(y - np.median(y)))), "slope": float(slope),
  167. "roughness": float(np.sqrt(np.mean(residual ** 2))),
  168. "p10": p10, "p50": p50, "p90": p90, "iqr": p90 - p10,
  169. "skew": skew, "kurt": kurt,
  170. "d_abs_mean": d_abs_mean, "d_abs_max": d_abs_max, "d_rms": d_rms,
  171. "d2_rms": d2_rms, "zero_cross": zero_cross, "n_extrema": n_extrema,
  172. "d2_abs_max": d2_abs_max, "tv": tv, "mean_cross": mean_cross,
  173. "mobility": mobility, "complexity": complexity,
  174. })
  175. return features
  176. def cycle_timing(signal: np.ndarray) -> dict:
  177. """Whole-cycle plateau / valve-event features (Otsu plateaus + derivative corners)."""
  178. p = np.asarray(signal, dtype=float)
  179. p = p[np.isfinite(p)]
  180. count = len(p)
  181. out = {name: None for name in CYCLE_METRICS}
  182. if count < 60:
  183. return out
  184. ps = smooth(p, 21)
  185. angle = 360.0 * np.arange(count) / count
  186. threshold = otsu_threshold(ps)
  187. low = ps[ps <= threshold]
  188. high = ps[ps > threshold]
  189. suction = float(np.median(low)) if low.size else float(np.min(ps))
  190. discharge = float(np.median(high)) if high.size else float(np.max(ps))
  191. high_mask = ps >= threshold
  192. out["p_suction"] = suction
  193. out["p_discharge"] = discharge
  194. out["pressure_ratio"] = _safe_ratio(discharge, suction)
  195. out["suction_frac"] = float(np.mean(~high_mask))
  196. out["discharge_frac"] = float(np.mean(high_mask))
  197. out["angle_min"] = float(angle[int(np.argmin(ps))])
  198. out["angle_max"] = float(angle[int(np.argmax(ps))])
  199. signed = np.sign(ps - threshold)
  200. signed[signed == 0] = 1
  201. rising = np.flatnonzero(np.diff(signed) > 0)
  202. falling = np.flatnonzero(np.diff(signed) < 0)
  203. if rising.size:
  204. out["cross_up"] = float(angle[rising[0]])
  205. if falling.size:
  206. out["cross_down"] = float(angle[falling[0]])
  207. dp = np.gradient(ps)
  208. out["max_rise"] = float(np.max(dp))
  209. out["max_fall"] = float(np.min(dp))
  210. out["max_rise_angle"] = float(angle[int(np.argmax(dp))])
  211. out["max_fall_angle"] = float(angle[int(np.argmin(dp))])
  212. return out
  213. def main() -> None:
  214. parser = argparse.ArgumentParser(description="功图每10°分箱局部统计(只读,抽样)")
  215. parser.add_argument("--unit", required=True, choices=("7", "8", "9"))
  216. parser.add_argument("--cylinders", nargs="+", default=list(CYLINDERS), choices=CYLINDERS)
  217. parser.add_argument("--sides", nargs="+", default=list(SIDES), choices=SIDES)
  218. parser.add_argument("--start", required=True, help="YYYY-MM-DD")
  219. parser.add_argument("--end", required=True, help="YYYY-MM-DD(不含)")
  220. parser.add_argument("--per-day", type=int, default=4, help="每测点每天最多取几个运行文件")
  221. parser.add_argument("--bin-deg", type=float, default=DEFAULT_BIN_DEG, help="角度箱宽度(默认10°,可选5/15/20)")
  222. parser.add_argument("--batch", type=int, default=200, help="IN 查询批大小")
  223. parser.add_argument("--output", required=True)
  224. args = parser.parse_args()
  225. if not (0 < args.bin_deg <= 90):
  226. parser.error("--bin-deg 必须在 (0, 90] 之间")
  227. connection = connect()
  228. rows_out: list[dict] = []
  229. try:
  230. for cylinder in args.cylinders:
  231. for side in args.sides:
  232. point = point_name(args.unit, cylinder, side)
  233. files = fetch_file_rows(connection, point, args.start, args.end)
  234. if not files:
  235. print(f"{point}: 无运行文件", flush=True)
  236. continue
  237. picked = sample_per_day(files, args.per_day)
  238. cycles = fetch_cycles(connection, [int(r["id"]) for r in picked], args.batch)
  239. id_to_time = {int(r["id"]): r for r in picked}
  240. kept = 0
  241. for file_id, signal in cycles.items():
  242. bins = bin_features(signal, args.bin_deg)
  243. if not bins:
  244. continue
  245. meta = id_to_time[file_id]
  246. finite = signal[np.isfinite(signal)]
  247. base = {
  248. "unit": args.unit,
  249. "cylinder": cylinder,
  250. "stage": CYLINDER_STAGE[cylinder],
  251. "side": side,
  252. "point_name": point,
  253. "wave_file_id": file_id,
  254. "sample_time": str(meta["sample_time"]),
  255. "rpm": float(meta["rpm"] or 0.0),
  256. "n_samples": int(finite.size),
  257. "p_min": float(finite.min()),
  258. "p_max": float(finite.max()),
  259. "p_span": float(finite.max() - finite.min()),
  260. }
  261. timing = cycle_timing(signal)
  262. for item in bins:
  263. rows_out.append({**base, **item, **timing})
  264. kept += 1
  265. days = len({r["sample_time"].date() for r in picked})
  266. print(f"{point}: 运行文件 {len(files)},抽样 {len(picked)}({days}天),出周期 {kept}", flush=True)
  267. finally:
  268. connection.close()
  269. if not rows_out:
  270. print("没有特征行,退出")
  271. return
  272. rows_out.sort(key=lambda r: (r["cylinder"], r["side"], r["sample_time"], r["bin"]))
  273. output = Path(args.output)
  274. output.parent.mkdir(parents=True, exist_ok=True)
  275. with output.open("w", encoding="utf-8-sig", newline="") as handle:
  276. writer = csv.DictWriter(handle, fieldnames=FIELDNAMES)
  277. writer.writeheader()
  278. writer.writerows(rows_out)
  279. periods = len({(r["point_name"], r["wave_file_id"]) for r in rows_out})
  280. print(f"周期 {periods:,},特征行 {len(rows_out):,} -> {output}")
  281. print(f"时间:{datetime.now():%Y-%m-%d %H:%M:%S}")
  282. if __name__ == "__main__":
  283. main()