diagnose_drift.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  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. COLUMN = "YSJ_1"
  12. RNG = np.random.default_rng(7)
  13. def connect() -> pymysql.connections.Connection:
  14. return pymysql.connect(
  15. host=settings.db_host,
  16. port=settings.db_port,
  17. user=settings.db_user,
  18. password=settings.db_password,
  19. database=settings.db_name,
  20. charset="utf8mb4",
  21. cursorclass=pymysql.cursors.SSCursor,
  22. connect_timeout=settings.db_connect_timeout,
  23. read_timeout=3600,
  24. write_timeout=120,
  25. )
  26. LEVEL_CACHE = os.path.join(
  27. os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "cache", "pks_levels.csv"
  28. )
  29. def wall_offset() -> int:
  30. """Return seconds to add to UNIX epoch so naive pandas shows local wall-clock times."""
  31. connection = connect()
  32. try:
  33. with connection.cursor() as cursor:
  34. cursor.execute("SELECT sample_time, UNIX_TIMESTAMP(sample_time) AS u "
  35. "FROM pks_long_sample LIMIT 1")
  36. wall, uni = cursor.fetchone()
  37. finally:
  38. connection.close()
  39. wall_epoch = int(pd.Timestamp(wall).tz_localize(None).value // 10 ** 9)
  40. return wall_epoch - int(uni)
  41. def load_levels(offset: int) -> pd.DataFrame:
  42. """1-min bucket AVERAGE level per batch, from a single server-side aggregation (cached)."""
  43. if os.path.exists(LEVEL_CACHE):
  44. frame = pd.read_csv(LEVEL_CACHE)
  45. frame["time"] = pd.to_datetime(frame["time"])
  46. print(f" level buckets loaded from cache: {len(frame):,} rows")
  47. return frame
  48. t0 = monotonic()
  49. connection = connect()
  50. rows = []
  51. try:
  52. with connection.cursor() as cursor:
  53. cursor.execute(
  54. "SELECT import_batch_id AS b, UNIX_TIMESTAMP(sample_time) DIV 60 AS bk, "
  55. f"COUNT(*) AS cnt, AVG(`{COLUMN}`) AS lvl "
  56. "FROM pks_long_sample GROUP BY import_batch_id, bk ORDER BY import_batch_id, bk"
  57. )
  58. while True:
  59. chunk = cursor.fetchmany(200_000)
  60. if not chunk:
  61. break
  62. rows.extend(chunk)
  63. finally:
  64. connection.close()
  65. frame = pd.DataFrame(rows, columns=["b", "bk", "cnt", "lvl"])
  66. frame["time"] = pd.to_datetime(frame["bk"] * 60 + offset, unit="s")
  67. bad = frame[frame["cnt"] != 60]
  68. if len(bad):
  69. print(f" WARN: {len(bad)} buckets with cnt!=60\n{bad.head(10).to_string()}")
  70. frame[["b", "bk", "cnt", "lvl", "time"]].to_csv(LEVEL_CACHE, index=False)
  71. print(f" level buckets loaded: {len(frame):,} rows ({frame['b'].nunique()} batches) "
  72. f"{monotonic()-t0:.0f}s")
  73. return frame
  74. def sample_raw_days() -> pd.DataFrame:
  75. """Fetch raw 5s rows of ~24 random days per batch to gauge noise/quantization/gaps."""
  76. t0 = monotonic()
  77. days = pd.date_range("2025-04-01", "2026-04-30", freq="D")
  78. picks = days[RNG.choice(len(days), size=24, replace=False)].sort_values()
  79. connection = connect()
  80. parts = []
  81. try:
  82. with connection.cursor() as cursor:
  83. for d in picks:
  84. start = d.strftime("%Y-%m-%d 00:00:00")
  85. end = d.strftime("%Y-%m-%d 23:59:55")
  86. cursor.execute(
  87. "SELECT import_batch_id AS b, "
  88. f"UNIX_TIMESTAMP(sample_time) AS t, `{COLUMN}` AS v "
  89. "FROM pks_long_sample WHERE sample_time BETWEEN %s AND %s",
  90. (start, end),
  91. )
  92. while True:
  93. chunk = cursor.fetchmany(100_000)
  94. if not chunk:
  95. break
  96. parts.extend(chunk)
  97. finally:
  98. connection.close()
  99. frame = pd.DataFrame(parts, columns=["b", "t", "v"])
  100. frame = frame.dropna(subset=["v"]).sort_values(["b", "t"]).reset_index(drop=True)
  101. print(f" raw sampled days: {len(frame):,} rows over {len(picks)} days "
  102. f"({monotonic()-t0:.0f}s)")
  103. return frame
  104. def noise_stats(raw: pd.DataFrame) -> dict:
  105. out = {}
  106. for b, g in raw.groupby("b"):
  107. dv = np.abs(np.diff(g["v"].to_numpy()))
  108. pos = dv[dv > 1e-12]
  109. med = float(np.median(dv))
  110. mad0 = float(np.median(np.abs(dv - med)))
  111. out[int(b)] = {
  112. "n_pairs": int(len(dv)),
  113. "pct_eq0": round(float((dv <= 1e-12).mean()), 4),
  114. "adj_diff": {
  115. "median": med,
  116. "mad": mad0,
  117. "delta(med+3*mad)": round(med + 3 * mad0, 4),
  118. "min_pos": float(pos.min()) if pos.size else 0.0,
  119. "p1_pos": round(float(np.percentile(pos, 1)), 5) if pos.size else 0.0,
  120. "p50_pos": round(float(np.percentile(pos, 50)), 5) if pos.size else 0.0,
  121. "p99_pos": round(float(np.percentile(pos, 99)), 5) if pos.size else 0.0,
  122. },
  123. "value": {
  124. "min": round(float(g["v"].min()), 3),
  125. "p1": round(float(g["v"].quantile(0.01)), 3),
  126. "p50": round(float(g["v"].median()), 3),
  127. "p99": round(float(g["v"].quantile(0.99)), 3),
  128. "max": round(float(g["v"].max()), 3),
  129. },
  130. }
  131. return out
  132. def series_per_batch(levels: pd.DataFrame) -> dict[int, pd.Series]:
  133. out = {}
  134. for b, g in levels.groupby("b"):
  135. s = g.set_index("time")["lvl"]
  136. s = s[~s.index.duplicated(keep="first")].sort_index()
  137. out[int(b)] = s
  138. return out
  139. def run_stats(values: np.ndarray, tau: float, minutes: int = 5) -> dict:
  140. mask = np.abs(values) > tau
  141. if mask.size == 0:
  142. return {"count": 0, "frac": 0.0, "top": []}
  143. diff = np.diff(mask.astype(np.int8))
  144. starts = np.flatnonzero(diff == 1) + 1
  145. ends = np.flatnonzero(diff == -1) + 1
  146. if mask[0]:
  147. starts = np.concatenate(([0], starts))
  148. if mask[-1]:
  149. ends = np.concatenate((ends, [len(mask)]))
  150. if len(starts) == 0 or len(ends) == 0:
  151. return {"count": 0, "frac": float(mask.mean()), "top": []}
  152. ends = ends[: len(starts)]
  153. durs = (ends - starts) * minutes
  154. peaks = [float(np.max(np.abs(values[s:e]))) for s, e in zip(starts, ends)]
  155. order = np.argsort(peaks)[::-1][:6]
  156. top = [{"peak": round(peaks[i], 3), "dur_min": int(durs[i])} for i in order]
  157. return {
  158. "count": int(len(durs)),
  159. "durs_min": [int(x) for x in durs],
  160. "frac": round(float(mask.mean()), 4),
  161. "top": top,
  162. }
  163. def main() -> None:
  164. out: dict = {}
  165. coverage = {}
  166. connection = connect()
  167. try:
  168. with connection.cursor() as cursor:
  169. cursor.execute(
  170. "SELECT import_batch_id AS b, COUNT(*) AS n, COUNT(YSJ_1) AS nn, "
  171. "MIN(sample_time) AS mn, MAX(sample_time) AS mx, "
  172. "COUNT(DISTINCT sample_time) AS dd FROM pks_long_sample GROUP BY import_batch_id"
  173. )
  174. for row in cursor.fetchall():
  175. b, n, nn, mn, mx, dd = row
  176. span = int((mx - mn).total_seconds() // 5) + 1
  177. coverage[int(b)] = {
  178. "rows": int(n), "non_null": int(nn), "distinct_t": int(dd),
  179. "span_5s_slots": span, "gap_free": n == dd == span,
  180. }
  181. finally:
  182. connection.close()
  183. out["coverage"] = coverage
  184. print("coverage:", out["coverage"], flush=True)
  185. offset = wall_offset()
  186. print(f"wall offset = {offset}s (UTC -> local)")
  187. levels = load_levels(offset)
  188. per_batch = series_per_batch(levels)
  189. raw = sample_raw_days()
  190. out["noise_and_quantization"] = noise_stats(raw)
  191. self_stats: dict[int, dict] = {}
  192. for b in BATCHES:
  193. s5 = per_batch[b].resample("5min").median().dropna()
  194. local6h = s5.rolling(145, center=True, min_periods=20).median()
  195. ref10d = s5.rolling(5761, center=True, min_periods=100).median()
  196. e = (local6h - ref10d).dropna().to_numpy()
  197. med_e = float(np.median(e))
  198. sigma_e = 1.4826 * float(np.median(np.abs(e - med_e)))
  199. self_stats[b] = {
  200. "med": round(med_e, 4), "sigma": round(sigma_e, 4), "tau4": round(4 * sigma_e, 4),
  201. "max_abs": round(float(np.abs(e).max()), 4),
  202. "flag": {f"{k}s": run_stats(e, k * sigma_e) for k in (3, 4, 5, 6)},
  203. "daily_level_p50": round(float(per_batch[b].resample("1D").median().median()), 4),
  204. "daily_level_minmax": [
  205. round(float(per_batch[b].resample("1D").median().min()), 3),
  206. round(float(per_batch[b].resample("1D").median().max()), 3),
  207. ],
  208. }
  209. print(f"self b{b}: med={self_stats[b]['med']} sigma={self_stats[b]['sigma']} "
  210. f"tau4={self_stats[b]['tau4']} max={self_stats[b]['max_abs']} "
  211. f"frac@4s={self_stats[b]['flag']['4s']['frac']} runs@4s={self_stats[b]['flag']['4s']['count']}")
  212. out["self_ref"] = self_stats
  213. s5_all = {b: per_batch[b].resample("5min").median() for b in BATCHES}
  214. frame = pd.DataFrame(s5_all).dropna()
  215. cross = {}
  216. for u in BATCHES:
  217. others = [o for o in BATCHES if o != u]
  218. ref = frame[others].median(axis=1)
  219. d = (frame[u] - ref).to_numpy()
  220. med_d = float(np.median(d))
  221. sigma_d = 1.4826 * float(np.median(np.abs(d - med_d)))
  222. cross[int(u)] = {
  223. "med": round(med_d, 4), "sigma": round(sigma_d, 4), "tau4": round(4 * sigma_d, 4),
  224. "max_abs": round(float(np.abs(d).max()), 4),
  225. "flag": {f"{k}s": run_stats(d, k * sigma_d) for k in (3, 4, 5, 6)},
  226. }
  227. print(f"cross b{u}: med={cross[u]['med']} sigma={cross[u]['sigma']} "
  228. f"tau4={cross[u]['tau4']} max={cross[u]['max_abs']} "
  229. f"frac@4s={cross[u]['flag']['4s']['frac']} runs@4s={cross[u]['flag']['4s']['count']}")
  230. out["cross_ref"] = cross
  231. out["cross_grid"] = {"common_5min": int(len(frame)),
  232. "start": str(frame.index.min()), "end": str(frame.index.max())}
  233. print("\n===== JSON SUMMARY =====")
  234. print(__import__("json").dumps(out, indent=2, ensure_ascii=False))
  235. if __name__ == "__main__":
  236. main()