predict.py 16 KB

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