diagnose_drift_running.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. from __future__ import annotations
  2. import os
  3. import sys
  4. from time import monotonic
  5. import numpy as np
  6. import pandas as pd
  7. import pymysql
  8. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  9. from app.config import settings # noqa: E402
  10. BATCHES = (30, 31, 32)
  11. CACHE = os.path.join(
  12. os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "cache", "pks_minute_levels.csv"
  13. )
  14. SPEED_THRESHOLD = 300.0 # rpm: running gate
  15. def connect() -> pymysql.connections.Connection:
  16. return pymysql.connect(
  17. host=settings.db_host,
  18. port=settings.db_port,
  19. user=settings.db_user,
  20. password=settings.db_password,
  21. database=settings.db_name,
  22. charset="utf8mb4",
  23. cursorclass=pymysql.cursors.SSCursor,
  24. connect_timeout=settings.db_connect_timeout,
  25. read_timeout=3600,
  26. write_timeout=120,
  27. )
  28. def wall_offset() -> int:
  29. connection = connect()
  30. try:
  31. with connection.cursor() as cursor:
  32. cursor.execute("SELECT sample_time, UNIX_TIMESTAMP(sample_time) AS u "
  33. "FROM pks_long_sample LIMIT 1")
  34. wall, uni = cursor.fetchone()
  35. finally:
  36. connection.close()
  37. return int(pd.Timestamp(wall).value // 10 ** 9) - int(uni)
  38. def load_minute_levels(offset: int) -> pd.DataFrame:
  39. if os.path.exists(CACHE):
  40. f = pd.read_csv(CACHE, parse_dates=["time"])
  41. print(f" loaded minute levels from cache: {len(f):,} rows")
  42. return f
  43. t0 = monotonic()
  44. connection = connect()
  45. rows = []
  46. try:
  47. with connection.cursor() as cursor:
  48. cursor.execute(
  49. "SELECT import_batch_id AS b, UNIX_TIMESTAMP(sample_time) DIV 60 AS bk, "
  50. "COUNT(*) AS cnt, AVG(YSJ_1) AS lev, AVG(YSJ_41) AS rpm "
  51. "FROM pks_long_sample GROUP BY import_batch_id, bk "
  52. "ORDER BY import_batch_id, bk"
  53. )
  54. while True:
  55. chunk = cursor.fetchmany(200_000)
  56. if not chunk:
  57. break
  58. rows.extend(chunk)
  59. finally:
  60. connection.close()
  61. f = pd.DataFrame(rows, columns=["b", "bk", "cnt", "lev", "rpm"])
  62. f["time"] = pd.to_datetime(f["bk"] * 60 + offset, unit="s")
  63. f[["b", "bk", "cnt", "lev", "rpm", "time"]].to_csv(CACHE, index=False)
  64. print(f" aggregated minute levels: {len(f):,} rows in {monotonic()-t0:.0f}s")
  65. return f
  66. def mad_std(x: np.ndarray) -> float:
  67. med = float(np.median(x))
  68. return 1.4826 * float(np.median(np.abs(x - med))), med
  69. def flag_summary(values: np.ndarray, tau: float, minute_step: int = 1) -> dict:
  70. mask = np.abs(values) > tau
  71. if mask.size == 0:
  72. return {"count": 0, "frac": 0.0, "top": []}
  73. diff = np.diff(mask.astype(np.int8))
  74. starts = np.flatnonzero(diff == 1) + 1
  75. ends = np.flatnonzero(diff == -1) + 1
  76. if mask[0]:
  77. starts = np.concatenate(([0], starts))
  78. if mask[-1]:
  79. ends = np.concatenate((ends, [len(mask)]))
  80. if len(starts) == 0 or len(ends) == 0:
  81. return {"count": 0, "frac": round(float(mask.mean()), 4), "top": []}
  82. ends = ends[: len(starts)]
  83. durs = (ends - starts) * minute_step
  84. peaks = [float(np.max(np.abs(values[s:e]))) for s, e in zip(starts, ends)]
  85. order = np.argsort(peaks)[::-1][:5]
  86. return {
  87. "count": int(len(durs)),
  88. "durs_min": [int(x) for x in durs],
  89. "frac": round(float(mask.mean()), 4),
  90. "top": [{"peak": round(peaks[i], 2), "dur_min": int(durs[i])} for i in order],
  91. }
  92. def build_run_blocks(times: pd.DatetimeIndex, running: np.ndarray) -> np.ndarray:
  93. """block id per minute; merge running minutes with gaps <= 6 min."""
  94. idx_run = np.flatnonzero(running)
  95. if len(idx_run) == 0:
  96. return np.full(len(times), -1, dtype=np.int64)
  97. block = np.full(len(times), -1, dtype=np.int64)
  98. bid = 0
  99. prev_t = None
  100. for i in idx_run:
  101. if prev_t is None or (times[i] - prev_t).total_seconds() > 360:
  102. bid += 1
  103. block[i] = bid
  104. prev_t = times[i]
  105. return block
  106. def main() -> None:
  107. offset = wall_offset()
  108. f = load_minute_levels(offset)
  109. per: dict[int, pd.DataFrame] = {b: g.sort_values("time").set_index("time") for b, g in f.groupby("b")}
  110. result: dict = {}
  111. for b in BATCHES:
  112. df = per[b].copy()
  113. df["running"] = (df["rpm"] > SPEED_THRESHOLD).to_numpy()
  114. df["block"] = build_run_blocks(df.index, df["running"].to_numpy())
  115. dur = df.groupby("block")["lev"].transform("size")
  116. df["warmup"] = df.groupby("block").cumcount() < 120 # first 2h of each run block
  117. valid = df["running"] & ~df["warmup"] & df["lev"].notna()
  118. n_run = int(df["running"].sum())
  119. n_block = int((df["block"] >= 0).groupby(df["block"]).ngroups)
  120. run_levels = df.loc[df["running"], "lev"]
  121. rpm_run = df.loc[df["running"], "rpm"]
  122. # self-reference on valid (running, non-warmup) minutes
  123. ser = df["lev"].where(df["running"])
  124. local = ser.rolling("6h", min_periods=20).median()
  125. ref = ser.rolling("20d", min_periods=200).median()
  126. e_series = (local - ref).loc[valid].dropna()
  127. e = e_series.to_numpy()
  128. if len(e) == 0:
  129. self_info = None
  130. else:
  131. sig, med = mad_std(e)
  132. self_info = {
  133. "n_min": int(len(e)), "med": round(med, 3), "sigma": round(sig, 3),
  134. "tau4": round(4 * sig, 3), "max_abs": round(float(np.abs(e).max()), 3),
  135. "flag": {f"{k}s": flag_summary(e, k * sig) for k in (3, 4, 5)},
  136. }
  137. # plateau (steady level) per run block and drift of plateau across blocks
  138. plateau = (df.loc[valid, "lev"].groupby(df.loc[valid, "block"]).median())
  139. pstart = df.loc[df["running"]].groupby("block")["rpm"].apply(lambda s: s.index[0])
  140. prev_ref = plateau.rolling(30, min_periods=8).median().shift(1)
  141. dev = (plateau - prev_ref).dropna()
  142. drift = None
  143. if len(dev) > 3:
  144. sig_d, med_d = mad_std(dev.to_numpy())
  145. flagged = dev[np.abs(dev) > 4 * sig_d]
  146. drift = {
  147. "n_blocks": int(len(plateau)),
  148. "blocks_per_month": round(len(plateau) / 13.0, 1),
  149. "plateau_p50": round(float(plateau.median()), 2),
  150. "plateau_minmax": [round(float(plateau.min()), 2), round(float(plateau.max()), 2)],
  151. "dev_sigma": round(sig_d, 3),
  152. "dev_mad_med": round(med_d, 3),
  153. "blocks_over_4sigma": int(len(flagged)),
  154. "top_blocks": [
  155. {"start": str(idx)[:16], "plateau": round(float(pl), 2),
  156. "dev": round(float(dv), 2)}
  157. for idx, (pl, dv) in flagged.head(5).items()
  158. ],
  159. }
  160. stats = {
  161. "n_run_minutes": n_run, "run_minutes_share": round(n_run / len(df), 4),
  162. "n_run_blocks": n_block, "blocks_over_24h": int((dur >= 1440).sum()),
  163. "rpm_run_p50": round(float(rpm_run.median()), 0),
  164. "run_level_p50": round(float(run_levels.median()), 2),
  165. "run_level_minmax": [round(float(run_levels.min()), 1), round(float(run_levels.max()), 1)],
  166. }
  167. result[str(b)] = {"gating": stats, "self_within_run": self_info, "plateau_drift": drift}
  168. print(f"[{b}] run_min={n_run:,} ({stats['run_minutes_share']:.1%}) blocks={n_block} "
  169. f"rpm_p50={stats['rpm_run_p50']:.0f} run_level_p50={stats['run_level_p50']}")
  170. if self_info:
  171. print(f" within-run self: sigma={self_info['sigma']} tau4={self_info['tau4']} "
  172. f"max={self_info['max_abs']} frac@4s={self_info['flag']['4s']['frac']} "
  173. f"runs@4s={self_info['flag']['4s']['count']}")
  174. if drift:
  175. print(f" plateau drift: blocks={drift['n_blocks']} dev_sigma={drift['dev_sigma']} "
  176. f"over_4s={drift['blocks_over_4sigma']}")
  177. # cross-machine on common running minutes (all three machines running)
  178. frame = pd.DataFrame({str(b): per[b]["lev"] for b in BATCHES})
  179. frame["rpm"] = per[30]["rpm"]
  180. common = (frame["30"] > 0) & (frame["31"] > 0) & (frame["32"] > 0) & (frame["rpm"] > SPEED_THRESHOLD)
  181. sub = frame.loc[common, [str(b) for b in BATCHES]]
  182. cross = {}
  183. if len(sub) > 1000:
  184. for u in BATCHES:
  185. others = [str(o) for o in BATCHES if o != u]
  186. ref = sub[others].median(axis=1)
  187. d = (sub[str(u)] - ref).to_numpy()
  188. sig, med = mad_std(d)
  189. cross[str(u)] = {
  190. "n_min": int(len(d)), "med_bias": round(med, 3), "sigma": round(sig, 3),
  191. "tau4": round(4 * sig, 3), "max_abs": round(float(np.abs(d).max()), 3),
  192. "flag": {f"{k}s": flag_summary(d, k * sig) for k in (3, 4, 5)},
  193. }
  194. print(f"[cross u={u}] common_run_min={len(d):,} bias={med:.2f} sigma={sig:.3f} "
  195. f"frac@4s={cross[str(u)]['flag']['4s']['frac']} runs@4s={cross[str(u)]['flag']['4s']['count']}")
  196. result["cross_common_running"] = cross
  197. print("\n===== JSON SUMMARY =====")
  198. print(__import__("json").dumps(result, indent=2, ensure_ascii=False))
  199. if __name__ == "__main__":
  200. main()