predict.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. """Step 3: long-running TSPulse anomaly detection poller.
  2. Polls the wave_file table for rows matching ``rpm > 0 AND measurement_type =
  3. '压力'``, reads each file's samples with an adaptive multi-stage window
  4. (``STAGE_LIMITS`` = 3500 -> 7000 -> 14000 rows), escalating until a complete
  5. crankshaft cycle is found or the file's data is exhausted, and encodes the
  6. first complete cycle with the frozen TSPulse model into a 128-dim fingerprint.
  7. The fingerprint is compared against the part's centroid and radius from the
  8. baseline table and ``tspluse_status`` is written back as a proportional
  9. integer of the centroid distance:
  10. * 无周期 (no complete cycle found) -> ``-1``
  11. * 正常 (distance <= 2 x radius) -> ``0``
  12. * 异常 (distance > 2 x radius) -> ``min(127, round(10 x distance / radius))``
  13. Every processed file is written back, so a full re-run from an empty watermark
  14. cleanly replaces any previously stored levels.
  15. The samples of a whole batch of files are read with one ``IN (...) AND
  16. sample_index < {stage_limit}`` query per stage and all candidate cycles are
  17. encoded in a single forward pass, so the network round trips and MPS kernel
  18. launches are amortised across the batch. Only files that still report no cycle
  19. at the current stage are re-fetched at the next, larger window; a file is
  20. finally marked 无周期 only when no window yields a cycle. Files are processed
  21. in ``(sample_time, id)`` ascending order and a tuple watermark on those two
  22. columns prevents re-processing files that have already been seen, so the
  23. poller first drains the backlog and then keeps watching for newly inserted
  24. rows.
  25. Usage:
  26. conda activate tspulse
  27. python predict.py [--start-time ""] [--start-id 0] [--interval 60]
  28. [--batch 100] [--limit 0] [--report-every 100]
  29. [--write-batch 100] [--dry-run]
  30. """
  31. import argparse
  32. import sys
  33. import time
  34. from concurrent.futures import ThreadPoolExecutor
  35. from pathlib import Path
  36. import numpy as np
  37. import torch
  38. BACKEND = Path(__file__).resolve().parents[1]
  39. sys.path.insert(0, str(BACKEND))
  40. from app.algorithms.cycles import detect_cycles # noqa: E402
  41. from app.db import get_connection # noqa: E402
  42. from tspulse import TSPulse, describe, get_device # noqa: E402
  43. from tspulse.dataset import N_CHANNELS, SEQ_LEN # noqa: E402
  44. HERE = Path(__file__).resolve().parent
  45. CHECKPOINT_PATH = HERE / "checkpoints" / "tspulse_frozen.pt"
  46. BASELINE_PATH = HERE / "baseline" / "baseline.npz"
  47. STAGE_LIMITS = (3500, 7000, 14000)
  48. MEASUREMENT_TYPE = "压力"
  49. ANOMALY_FACTOR = 2.0
  50. SCALE = 10.0
  51. STATUS_MAX = 127
  52. def load_model(device: torch.device) -> tuple[TSPulse, np.ndarray, np.ndarray]:
  53. checkpoint = torch.load(CHECKPOINT_PATH, map_location="cpu", weights_only=False)
  54. config = checkpoint["config"]
  55. model = TSPulse(dim=config["dim"], depth=config["depth"], heads=config["heads"]).to(device)
  56. model.load_state_dict(checkpoint["state_dict"])
  57. model.eval()
  58. return model, checkpoint["mean"], checkpoint["std"]
  59. def load_baseline() -> tuple[dict[str, int], np.ndarray, np.ndarray]:
  60. data = np.load(BASELINE_PATH)
  61. part_names = data["part_names"].astype(str).tolist()
  62. part_index = {part: i for i, part in enumerate(part_names)}
  63. return part_index, data["centroids"], data["radii"]
  64. def _fetch_chunk(connection, file_ids: list[int], limit: int) -> dict[int, np.ndarray]:
  65. """Fetch the first ``limit`` rows of a chunk of files in one query."""
  66. if not file_ids:
  67. return {}
  68. placeholders = ",".join(["%s"] * len(file_ids))
  69. with connection.cursor() as cursor:
  70. cursor.execute(
  71. f"""
  72. SELECT wave_file_id,
  73. CAST(signal_value AS FLOAT) AS sig,
  74. CAST(second_value AS FLOAT) AS sec
  75. FROM wave_sample
  76. WHERE wave_file_id IN ({placeholders}) AND sample_index < %s
  77. ORDER BY wave_file_id, sample_index
  78. """,
  79. (*file_ids, limit),
  80. )
  81. rows = cursor.fetchall()
  82. grouped: dict[int, tuple[list[float], list[float]]] = {}
  83. for row in rows:
  84. signal, second = grouped.setdefault(row["wave_file_id"], ([], []))
  85. signal.append(float(row["sig"]))
  86. second.append(float(row["sec"]) if row["sec"] is not None else np.nan)
  87. out: dict[int, np.ndarray] = {}
  88. for file_id, (signal, second) in grouped.items():
  89. count = len(signal)
  90. out[file_id] = np.column_stack(
  91. [np.arange(count, dtype=float), np.asarray(signal), np.asarray(second)]
  92. )
  93. return out
  94. def read_batch_samples(
  95. reader_connections: list,
  96. file_ids: list[int],
  97. workers: int,
  98. limit: int,
  99. ) -> dict[int, np.ndarray]:
  100. """Fetch the first ``limit`` rows of every file in one round-trip.
  101. The batch is split into ``workers`` chunks fetched on parallel persistent
  102. connections. Returns {file_id: (n, 3) array} with columns
  103. [sample_index(0..n-1), signal_value, second_value].
  104. """
  105. if not file_ids:
  106. return {}
  107. if workers <= 1 or len(file_ids) <= workers:
  108. return _fetch_chunk(reader_connections[0], file_ids, limit)
  109. chunks = np.array_split(file_ids, min(workers, len(file_ids)))
  110. with ThreadPoolExecutor(max_workers=len(chunks)) as executor:
  111. futures = [
  112. executor.submit(_fetch_chunk, reader_connections[i], chunk.tolist(), limit)
  113. for i, chunk in enumerate(chunks)
  114. ]
  115. merged: dict[int, np.ndarray] = {}
  116. for future in futures:
  117. merged.update(future.result())
  118. return merged
  119. def build_cycle_matrix(samples: np.ndarray, start: int, end: int) -> np.ndarray:
  120. signal = samples[start:end, 1]
  121. second = samples[start:end, 2]
  122. count = end - start
  123. return np.column_stack([signal, second, np.ones(count), np.ones(count)])
  124. def flush_status(connection, buffer: list[tuple[int, int]]) -> None:
  125. """Write pending (file_id, status) pairs in a single UPDATE statement."""
  126. if not buffer:
  127. return
  128. case_sql = " ".join("WHEN %s THEN %s" for _ in buffer)
  129. placeholders = ",".join(["%s"] * len(buffer))
  130. params: list[int] = []
  131. for file_id, level in buffer:
  132. params.extend([file_id, level])
  133. with connection.cursor() as cursor:
  134. cursor.execute(
  135. f"UPDATE wave_file SET tspluse_status = CASE id {case_sql} END "
  136. f"WHERE id IN ({placeholders})",
  137. tuple(params + [file_id for file_id, _ in buffer]),
  138. )
  139. buffer.clear()
  140. def fetch_batch(
  141. connection,
  142. watermark_time: object | None,
  143. watermark_id: int,
  144. batch: int,
  145. ) -> list[dict]:
  146. with connection.cursor() as cursor:
  147. if watermark_time is None:
  148. cursor.execute(
  149. """
  150. SELECT id, point_name, measurement_type, sample_time, sample_count
  151. FROM wave_file
  152. WHERE rpm > 0 AND measurement_type = %s
  153. ORDER BY sample_time ASC, id ASC
  154. LIMIT %s
  155. """,
  156. (MEASUREMENT_TYPE, batch),
  157. )
  158. else:
  159. cursor.execute(
  160. """
  161. SELECT id, point_name, measurement_type, sample_time, sample_count
  162. FROM wave_file
  163. WHERE rpm > 0 AND measurement_type = %s
  164. AND (sample_time > %s OR (sample_time = %s AND id > %s))
  165. ORDER BY sample_time ASC, id ASC
  166. LIMIT %s
  167. """,
  168. (MEASUREMENT_TYPE, watermark_time, watermark_time, watermark_id, batch),
  169. )
  170. return cursor.fetchall()
  171. def resolve_batch_cycles(
  172. rows: list[dict],
  173. part_of: dict[int, str],
  174. part_index: dict[str, int],
  175. reader_connections: list,
  176. workers: int,
  177. first_stage: int = 0,
  178. ) -> tuple[dict[int, tuple[str, np.ndarray | None]], int]:
  179. """Adaptively detect one cycle per file with progressively larger windows.
  180. The whole batch is first fetched at ``STAGE_LIMITS[first_stage]``. Files
  181. that still report 无周期 and have more rows than the current window are
  182. re-fetched at the next stage, up to ``STAGE_LIMITS[-1]``; a file is only
  183. finally 无周期 when no window yields a cycle. ``first_stage`` lets callers
  184. (predict_re) skip cheap windows for files already known to need more data.
  185. Returns (``{file_id: (status, matrix)}``, error_count). Status is "ok" or a
  186. skip reason, mirroring ``prepare_cycle``.
  187. """
  188. errors = 0
  189. resolved: dict[int, tuple[str, np.ndarray | None]] = {}
  190. stage_files: dict[int, dict] = {int(row["id"]): row for row in rows}
  191. for stage in range(first_stage, len(STAGE_LIMITS)):
  192. if not stage_files:
  193. break
  194. limit = STAGE_LIMITS[stage]
  195. samples_map = read_batch_samples(reader_connections, list(stage_files), workers, limit)
  196. next_pending: dict[int, dict] = {}
  197. for file_id, row in stage_files.items():
  198. samples = samples_map.get(file_id)
  199. if samples is None or len(samples) == 0:
  200. resolved[file_id] = ("无样本", None)
  201. continue
  202. try:
  203. status, matrix = prepare_cycle(samples, part_of[file_id], part_index)
  204. except Exception:
  205. errors += 1
  206. resolved[file_id] = ("错误", None)
  207. continue
  208. if status == "无周期" and int(row.get("sample_count") or 0) > limit:
  209. next_pending[file_id] = row
  210. else:
  211. resolved[file_id] = (status, matrix)
  212. stage_files = next_pending
  213. for file_id in stage_files:
  214. resolved[file_id] = ("无周期", None)
  215. return resolved, errors
  216. def compute_status(distance: float, radius: float) -> int:
  217. """Map a fingerprint distance to a proportional integer status.
  218. Normal (distance <= ANOMALY_FACTOR x radius) returns 0; anomalies are
  219. recorded as ``min(STATUS_MAX, round(SCALE x distance / radius))`` so the
  220. stored tinyint is proportional to the centroid distance and can be queried
  221. with ``tspluse_status >= N``. 无周期 files are handled separately as -1.
  222. """
  223. ratio = distance / radius
  224. if ratio <= ANOMALY_FACTOR:
  225. return 0
  226. return min(STATUS_MAX, int(round(SCALE * ratio)))
  227. def prepare_cycle(
  228. samples: np.ndarray,
  229. part: str,
  230. part_index: dict[str, int],
  231. ) -> tuple[str, np.ndarray | None]:
  232. """Locate the first complete cycle and build its [signal, second, 1, 1] matrix.
  233. Returns (status, matrix); status is "ok" or a skip reason.
  234. """
  235. cycles, _ = detect_cycles(samples)
  236. if not cycles:
  237. return "无周期", None
  238. cycle = cycles[0]
  239. matrix = build_cycle_matrix(samples, cycle.start_offset, cycle.end_offset)
  240. if len(matrix) > SEQ_LEN:
  241. return "周期过长", None
  242. if not np.all(np.isfinite(matrix[:, :2])):
  243. return "坏数据", None
  244. if part not in part_index:
  245. return "无基准", None
  246. return "ok", matrix
  247. def encode_batch(
  248. model: TSPulse,
  249. mean: np.ndarray,
  250. std: np.ndarray,
  251. device: torch.device,
  252. matrices: list[np.ndarray],
  253. ) -> np.ndarray:
  254. """Encode many cycle matrices in a single forward pass -> (B, dim)."""
  255. if not matrices:
  256. return np.empty((0, model.dim), dtype=np.float32)
  257. batch = np.zeros((len(matrices), SEQ_LEN, N_CHANNELS), dtype=np.float32)
  258. for index, matrix in enumerate(matrices):
  259. normalised = (matrix - mean) / std
  260. batch[index, : len(normalised)] = normalised
  261. tensor = torch.from_numpy(batch).to(device)
  262. with torch.no_grad():
  263. fingerprints, _ = model(tensor)
  264. return fingerprints.cpu().numpy()
  265. def main() -> int:
  266. parser = argparse.ArgumentParser(description="TSPulse 长期异常预测轮询脚本")
  267. parser.add_argument("--start-time", type=str, default="",
  268. help="起始水位时间(含),如 2026-01-01 00:00:00,留空从头开始")
  269. parser.add_argument("--start-id", type=int, default=0,
  270. help="起始水位 id(含),同一 sample_time 时作为次序")
  271. parser.add_argument("--interval", type=int, default=60, help="无新数据时的轮询间隔秒数")
  272. parser.add_argument("--batch", type=int, default=100, help="每轮读取的文件数")
  273. parser.add_argument("--read-workers", type=int, default=4, help="并行读样本的连接数(0=单连接)")
  274. parser.add_argument("--limit", type=int, default=0, help="最多处理文件数,0 表示不限")
  275. parser.add_argument("--report-every", type=int, default=100, help="每 N 个文件打印一次汇总")
  276. parser.add_argument("--write-batch", type=int, default=100,
  277. help="回写攒满 N 条才批量写一次(每个文件都会回写)")
  278. parser.add_argument("--dry-run", action="store_true", help="只预测不回写数据库")
  279. args = parser.parse_args()
  280. if not CHECKPOINT_PATH.exists():
  281. print(f"未找到模型 {CHECKPOINT_PATH}, 请先运行 tspulse/train.py")
  282. return 1
  283. if not BASELINE_PATH.exists():
  284. print(f"未找到基准表 {BASELINE_PATH}, 请先运行 build_baseline.py")
  285. return 1
  286. device = get_device()
  287. print(f"设备: {describe(device)}")
  288. model, mean, std = load_model(device)
  289. part_index, centroids, radii = load_baseline()
  290. print(f"基准表: {len(part_index)} 个部位, 异常判定: 距离 > {ANOMALY_FACTOR:g}×半径, "
  291. f"回写 tspluse_status = min({STATUS_MAX}, round({SCALE:g} × 距离/半径)), 无周期=-1")
  292. start_time = args.start_time.strip()
  293. watermark_time: object | None = start_time or None
  294. watermark_id = args.start_id
  295. total = 0
  296. stats = {"正常": 0, "异常": 0, "无周期": 0}
  297. skipped = {"无基准": 0, "坏数据": 0, "周期过长": 0, "无样本": 0}
  298. errors = 0
  299. last_reported = 0
  300. write_buffer: list[tuple[int, int]] = []
  301. read_workers = max(1, min(args.read_workers, args.batch))
  302. reader_connections = [get_connection() for _ in range(read_workers)]
  303. def flush(connection) -> None:
  304. if args.dry_run:
  305. write_buffer.clear()
  306. else:
  307. flush_status(connection, write_buffer)
  308. def summary_text() -> str:
  309. return (
  310. f"已处理 {total}, 正常 {stats['正常']}, 异常 {stats['异常']}, "
  311. f"无周期 {stats['无周期']}, 跳过 {sum(skipped.values())}({dict(skipped)}), "
  312. f"错误 {errors}, 水位 sample_time={watermark_time} id={watermark_id}"
  313. )
  314. print(f"水位起点: sample_time={start_time or '从头'}, id={args.start_id}, "
  315. f"批大小: {args.batch}, 读并行: {read_workers} 连接, 间隔: {args.interval}s"
  316. + (", 干跑(不回写)" if args.dry_run else ""))
  317. try:
  318. while True:
  319. connection = get_connection()
  320. try:
  321. rows = fetch_batch(connection, watermark_time, watermark_id, args.batch)
  322. if not rows:
  323. flush(connection)
  324. connection.close()
  325. print(
  326. f"无新数据(sample_time>{watermark_time}),等待 {args.interval}s 后继续... "
  327. f"[{summary_text()}]",
  328. flush=True,
  329. )
  330. time.sleep(args.interval)
  331. continue
  332. file_ids = [int(row["id"]) for row in rows]
  333. part_of = {int(row["id"]): f"{row['point_name']}_{row['measurement_type']}" for row in rows}
  334. resolved, batch_errors = resolve_batch_cycles(
  335. rows, part_of, part_index, reader_connections, read_workers,
  336. )
  337. errors += batch_errors
  338. candidates: list[tuple[int, str, np.ndarray]] = []
  339. for row in rows:
  340. file_id = int(row["id"])
  341. total += 1
  342. status, matrix = resolved[file_id]
  343. if status == "ok":
  344. candidates.append((file_id, part_of[file_id], matrix))
  345. elif status == "无周期":
  346. stats["无周期"] += 1
  347. write_buffer.append((file_id, -1))
  348. else:
  349. if status in skipped:
  350. skipped[status] += 1
  351. write_buffer.append((file_id, 0))
  352. if candidates:
  353. fingerprints = encode_batch(
  354. model, mean, std, device, [item[2] for item in candidates]
  355. )
  356. for (file_id, part, _matrix), fingerprint in zip(candidates, fingerprints):
  357. index = part_index[part]
  358. distance = float(np.linalg.norm(fingerprint - centroids[index]))
  359. radius = float(radii[index])
  360. status = compute_status(distance, radius)
  361. stats["异常" if status > 0 else "正常"] += 1
  362. write_buffer.append((file_id, status))
  363. if len(write_buffer) >= args.write_batch:
  364. flush(connection)
  365. if total // args.report_every > last_reported:
  366. last_reported = total // args.report_every
  367. print(f" 汇总: {summary_text()}", flush=True)
  368. if args.limit and total >= args.limit:
  369. flush(connection)
  370. watermark_time = rows[-1]["sample_time"]
  371. watermark_id = int(rows[-1]["id"])
  372. connection.close()
  373. print(f"已达 --limit={args.limit},退出")
  374. print(f" 汇总: {summary_text()}")
  375. return 0
  376. flush(connection)
  377. watermark_time = rows[-1]["sample_time"]
  378. watermark_id = int(rows[-1]["id"])
  379. finally:
  380. try:
  381. connection.close()
  382. except Exception:
  383. pass
  384. except KeyboardInterrupt:
  385. if write_buffer and not args.dry_run:
  386. try:
  387. with get_connection() as connection:
  388. flush_status(connection, write_buffer)
  389. except Exception:
  390. pass
  391. for connection in reader_connections:
  392. try:
  393. connection.close()
  394. except Exception:
  395. pass
  396. print(f"\n已中断。{summary_text()}")
  397. if watermark_time is not None:
  398. print(f"续跑命令: python predict.py --start-time \"{watermark_time}\" --start-id {watermark_id}")
  399. return 0
  400. for connection in reader_connections:
  401. try:
  402. connection.close()
  403. except Exception:
  404. pass
  405. return 0
  406. if __name__ == "__main__":
  407. sys.exit(main())