detect_cycle_index.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. """Backfill the first detected cycle's sample_index range into wave_file.
  2. The original full re-run encoded the first complete cycle of every file but
  3. never wrote back where that cycle starts and ends. This script re-detects the
  4. first complete cycle for every file that still has **no stored cycle bounds**
  5. (``cycle_start IS NULL``) and records the real ``sample_index`` bounds into two
  6. columns:
  7. * ``cycle_start`` / ``cycle_end`` >= 0 : first complete cycle, half-open
  8. ``[cycle_start, cycle_end)`` in actual sample_index values;
  9. * ``-1`` : no complete cycle found (matches the
  10. ``tspluse_status = -1`` convention).
  11. Any row already carrying a real first cycle (``cycle_start >= 0``) is never
  12. re-selected. No ``rpm`` / ``device_status`` filter is applied: every file that
  13. was previously skipped (mostly stopped machines, but also running files with
  14. unreliable ``rpm = 0``) gets re-detected, across all measurement types by
  15. default.
  16. Because most pending files never contain a trigger pulse, a cheap screening
  17. pass runs before detection: one grouped query per batch looks for any
  18. trigger-high sample (``second_value >= TRIGGER_THRESHOLD``) inside the
  19. reachable window ``STAGE_LIMITS[-1]``, and only files that pass enter the numpy
  20. detector. This matches the staged detector's reach exactly — a window with no
  21. high trigger can never yield a cycle — so the expensive multi-stage reads are
  22. spent only on the small minority of files that actually contain pulses.
  23. The detection itself reuses predict.py's adaptive multi-stage batch reading
  24. (STAGE_LIMITS = 3500 -> 7000 -> 14000, parallel reader connections, tuple
  25. watermark) but needs no model: only the trigger channel is used, so it runs on
  26. CPU only. The fetch also keeps the real ``sample_index`` per row because
  27. ``detect_cycles`` returns array offsets, which differ from database indices for
  28. sparse columns.
  29. Only rows holding ``cycle_start IS NULL`` are selected, so the ``(sample_time,
  30. id)`` watermark keeps the run position and any file that gained a cycle during
  31. the run is not re-read. Writes are flushed in one ``CASE id`` UPDATE per
  32. ``--write-batch`` rows.
  33. Usage:
  34. conda activate tspulse
  35. python detect_cycle_index.py # all types
  36. python detect_cycle_index.py --measurement-type 位移
  37. python detect_cycle_index.py --measurement-type 位移,加速度
  38. python detect_cycle_index.py [--measurement-type 压力] [--start-time ""]
  39. [--start-id 0] [--batch 100] [--read-workers 4]
  40. [--limit 0] [--report-every 100]
  41. [--write-batch 100] [--dry-run]
  42. """
  43. import argparse
  44. import sys
  45. from concurrent.futures import ThreadPoolExecutor
  46. from pathlib import Path
  47. import numpy as np
  48. BACKEND = Path(__file__).resolve().parents[1]
  49. sys.path.insert(0, str(BACKEND))
  50. from app.algorithms.cycles import TRIGGER_THRESHOLD, detect_cycles # noqa: E402
  51. import predict # noqa: E402
  52. from predict import STAGE_LIMITS # noqa: E402
  53. def _fetch_chunk(connection, file_ids: list[int], limit: int) -> dict[int, np.ndarray]:
  54. """Fetch the first ``limit`` rows of a chunk of files in one query.
  55. Returns {file_id: (n, 4) array} with columns
  56. [array_offset(0..n-1), signal_value, second_value, sample_index].
  57. """
  58. if not file_ids:
  59. return {}
  60. placeholders = ",".join(["%s"] * len(file_ids))
  61. with connection.cursor() as cursor:
  62. cursor.execute(
  63. f"""
  64. SELECT wave_file_id,
  65. CAST(signal_value AS FLOAT) AS sig,
  66. CAST(second_value AS FLOAT) AS sec,
  67. sample_index
  68. FROM wave_sample
  69. WHERE wave_file_id IN ({placeholders}) AND sample_index < %s
  70. ORDER BY wave_file_id, sample_index
  71. """,
  72. (*file_ids, limit),
  73. )
  74. rows = cursor.fetchall()
  75. grouped: dict[int, tuple[list[float], list[float], list[int]]] = {}
  76. for row in rows:
  77. signal, second, index = grouped.setdefault(row["wave_file_id"], ([], [], []))
  78. signal.append(float(row["sig"]))
  79. second.append(float(row["sec"]) if row["sec"] is not None else np.nan)
  80. index.append(int(row["sample_index"]))
  81. out: dict[int, np.ndarray] = {}
  82. for file_id, (signal, second, index) in grouped.items():
  83. count = len(signal)
  84. out[file_id] = np.column_stack(
  85. [
  86. np.arange(count, dtype=float),
  87. np.asarray(signal),
  88. np.asarray(second),
  89. np.asarray(index, dtype=float),
  90. ]
  91. )
  92. return out
  93. def read_batch_samples(
  94. reader_connections: list,
  95. file_ids: list[int],
  96. workers: int,
  97. limit: int,
  98. ) -> dict[int, np.ndarray]:
  99. """Fetch the first ``limit`` rows of every file in one round-trip."""
  100. if not file_ids:
  101. return {}
  102. if workers <= 1 or len(file_ids) <= workers:
  103. return _fetch_chunk(reader_connections[0], file_ids, limit)
  104. chunks = np.array_split(file_ids, min(workers, len(file_ids)))
  105. with ThreadPoolExecutor(max_workers=len(chunks)) as executor:
  106. futures = [
  107. executor.submit(_fetch_chunk, reader_connections[i], chunk.tolist(), limit)
  108. for i, chunk in enumerate(chunks)
  109. ]
  110. merged: dict[int, np.ndarray] = {}
  111. for future in futures:
  112. merged.update(future.result())
  113. return merged
  114. SCREEN_LIMIT = STAGE_LIMITS[-1]
  115. def _fetch_trigger_chunk(connection, file_ids: list[int], limit: int) -> set[int]:
  116. """Return file_ids that have at least one trigger-high sample in the window."""
  117. if not file_ids:
  118. return set()
  119. placeholders = ",".join(["%s"] * len(file_ids))
  120. with connection.cursor() as cursor:
  121. cursor.execute(
  122. f"""
  123. SELECT DISTINCT wave_file_id
  124. FROM wave_sample
  125. WHERE wave_file_id IN ({placeholders})
  126. AND sample_index < %s
  127. AND second_value >= %s
  128. """,
  129. (*file_ids, limit, TRIGGER_THRESHOLD),
  130. )
  131. return {int(row["wave_file_id"]) for row in cursor.fetchall()}
  132. def screen_trigger_files(
  133. reader_connections: list,
  134. file_ids: list[int],
  135. workers: int,
  136. ) -> set[int]:
  137. """Return the subset of ``file_ids`` that contain any trigger-high sample.
  138. A single grouped query reads only the trigger channel (``second_value``)
  139. within the reachable detection window (``SCREEN_LIMIT``). Files without a
  140. single ``second_value >= TRIGGER_THRESHOLD`` sample can never produce a
  141. complete cycle, so they are decided (-1) without the numpy detector.
  142. """
  143. if not file_ids:
  144. return set()
  145. if workers <= 1 or len(file_ids) <= workers:
  146. return _fetch_trigger_chunk(reader_connections[0], file_ids, SCREEN_LIMIT)
  147. chunks = np.array_split(file_ids, min(workers, len(file_ids)))
  148. with ThreadPoolExecutor(max_workers=len(chunks)) as executor:
  149. futures = [
  150. executor.submit(_fetch_trigger_chunk, reader_connections[i], chunk.tolist(), SCREEN_LIMIT)
  151. for i, chunk in enumerate(chunks)
  152. ]
  153. merged: set[int] = set()
  154. for future in futures:
  155. merged |= future.result()
  156. return merged
  157. def first_cycle_bounds(samples: np.ndarray) -> tuple[int, int] | None:
  158. """Return ``(cycle_start, cycle_end)`` in real sample_index, half-open."""
  159. cycles, _ = detect_cycles(samples)
  160. if not cycles:
  161. return None
  162. cycle = cycles[0]
  163. return (int(samples[cycle.start_offset, 3]), int(samples[cycle.end_offset, 3]))
  164. def resolve_batch_bounds(
  165. rows: list[dict],
  166. reader_connections: list,
  167. workers: int,
  168. first_stage: int = 0,
  169. ) -> tuple[dict[int, tuple[int, int] | None], int]:
  170. """Adaptively detect the first cycle per file, escalating window sizes.
  171. Returns (``{file_id: (start, end) | None}``, error_count). None means no
  172. complete cycle was found at any window (-> -1).
  173. """
  174. errors = 0
  175. resolved: dict[int, tuple[int, int] | None] = {}
  176. stage_files: dict[int, dict] = {int(row["id"]): row for row in rows}
  177. for stage in range(first_stage, len(STAGE_LIMITS)):
  178. if not stage_files:
  179. break
  180. limit = STAGE_LIMITS[stage]
  181. samples_map = read_batch_samples(reader_connections, list(stage_files), workers, limit)
  182. next_pending: dict[int, dict] = {}
  183. for file_id, row in stage_files.items():
  184. samples = samples_map.get(file_id)
  185. if samples is None or len(samples) == 0:
  186. resolved[file_id] = None
  187. continue
  188. try:
  189. bounds = first_cycle_bounds(samples)
  190. except Exception:
  191. errors += 1
  192. resolved[file_id] = None
  193. continue
  194. if bounds is None and int(row.get("sample_count") or 0) > limit:
  195. next_pending[file_id] = row
  196. else:
  197. resolved[file_id] = bounds
  198. stage_files = next_pending
  199. for file_id in stage_files:
  200. resolved[file_id] = None
  201. return resolved, errors
  202. def flush_bounds(connection, buffer: list[tuple[int, int, int]]) -> None:
  203. """Write pending (file_id, cycle_start, cycle_end) rows in one UPDATE."""
  204. if not buffer:
  205. return
  206. case_start = " ".join("WHEN %s THEN %s" for _ in buffer)
  207. case_end = " ".join("WHEN %s THEN %s" for _ in buffer)
  208. placeholders = ",".join(["%s"] * len(buffer))
  209. params_start: list[int] = []
  210. params_end: list[int] = []
  211. for file_id, start, end in buffer:
  212. params_start.extend([file_id, start])
  213. params_end.extend([file_id, end])
  214. with connection.cursor() as cursor:
  215. cursor.execute(
  216. f"UPDATE wave_file SET cycle_start = CASE id {case_start} END, "
  217. f"cycle_end = CASE id {case_end} END "
  218. f"WHERE id IN ({placeholders})",
  219. tuple(params_start + params_end + [file_id for file_id, _, _ in buffer]),
  220. )
  221. buffer.clear()
  222. def fetch_batch(
  223. connection,
  224. measurement_type: str,
  225. watermark_time: object | None,
  226. watermark_id: int,
  227. batch: int,
  228. ) -> list[dict]:
  229. with connection.cursor() as cursor:
  230. if watermark_time is None:
  231. cursor.execute(
  232. """
  233. SELECT id, point_name, measurement_type, sample_time, sample_count
  234. FROM wave_file
  235. WHERE measurement_type = %s AND cycle_start IS NULL
  236. ORDER BY sample_time ASC, id ASC
  237. LIMIT %s
  238. """,
  239. (measurement_type, batch),
  240. )
  241. else:
  242. cursor.execute(
  243. """
  244. SELECT id, point_name, measurement_type, sample_time, sample_count
  245. FROM wave_file
  246. WHERE measurement_type = %s AND cycle_start IS NULL
  247. AND (sample_time > %s OR (sample_time = %s AND id > %s))
  248. ORDER BY sample_time ASC, id ASC
  249. LIMIT %s
  250. """,
  251. (measurement_type, watermark_time, watermark_time, watermark_id, batch),
  252. )
  253. return cursor.fetchall()
  254. def list_pending_types(connection) -> list[str]:
  255. """Return every measurement_type that still has files without cycle bounds."""
  256. with connection.cursor() as cursor:
  257. cursor.execute(
  258. """
  259. SELECT DISTINCT measurement_type AS mt
  260. FROM wave_file
  261. WHERE cycle_start IS NULL
  262. ORDER BY mt
  263. """
  264. )
  265. return [str(row["mt"]) for row in cursor.fetchall() if row["mt"] is not None]
  266. def main() -> int:
  267. parser = argparse.ArgumentParser(description="回写首个完整周期的 sample_index 边界")
  268. parser.add_argument("--measurement-type", action="append", default=[],
  269. help="要回写的测量类型,可多次指定或用逗号分隔(默认自动取全部测量类型)")
  270. parser.add_argument("--start-time", type=str, default="",
  271. help="起始水位时间(含),留空从头开始")
  272. parser.add_argument("--start-id", type=int, default=0,
  273. help="起始水位 id(含)")
  274. parser.add_argument("--batch", type=int, default=100, help="每轮读取的文件数")
  275. parser.add_argument("--read-workers", type=int, default=4, help="并行读样本的连接数")
  276. parser.add_argument("--limit", type=int, default=0, help="最多处理文件数,0 表示不限")
  277. parser.add_argument("--report-every", type=int, default=100, help="每 N 个文件打印一次汇总")
  278. parser.add_argument("--write-batch", type=int, default=100,
  279. help="回写攒满 N 条才批量写一次")
  280. parser.add_argument("--dry-run", action="store_true", help="只检测不回写数据库")
  281. args = parser.parse_args()
  282. measurement_types: list[str] = []
  283. for raw in args.measurement_type:
  284. for part in str(raw).split(","):
  285. part = part.strip()
  286. if part and part not in measurement_types:
  287. measurement_types.append(part)
  288. if not measurement_types:
  289. with predict.get_connection() as connection:
  290. measurement_types = list_pending_types(connection)
  291. if not measurement_types:
  292. print("没有待回写的测量类型(无 device_status = 1 且周期缺失/为 -1 的文件),退出")
  293. return 0
  294. start_time = args.start_time.strip()
  295. read_workers = max(1, min(args.read_workers, args.batch))
  296. reader_connections = [predict.get_connection() for _ in range(read_workers)]
  297. current_type = measurement_types[0]
  298. watermark_time: object | None = start_time or None
  299. watermark_id = args.start_id
  300. total = 0
  301. stats = {"有周期": 0, "无周期": 0, "屏掉(无触发)": 0}
  302. errors = 0
  303. last_reported = 0
  304. write_buffer: list[tuple[int, int, int]] = []
  305. print(f"测量类型: {', '.join(measurement_types)}, 水位起点: "
  306. f"sample_time={start_time or '从头'}, id={args.start_id}, 批大小: {args.batch}, "
  307. f"读并行: {read_workers} 连接, 窗口: {STAGE_LIMITS}"
  308. + (", 干跑(不回写)" if args.dry_run else ""))
  309. try:
  310. for type_index, measurement_type in enumerate(measurement_types, start=1):
  311. current_type = measurement_type
  312. watermark_time = start_time or None
  313. watermark_id = args.start_id
  314. total = 0
  315. stats = {"有周期": 0, "无周期": 0, "屏掉(无触发)": 0}
  316. errors = 0
  317. last_reported = 0
  318. write_buffer = []
  319. def flush(connection) -> None:
  320. if args.dry_run:
  321. write_buffer.clear()
  322. else:
  323. flush_bounds(connection, write_buffer)
  324. def summary_text() -> str:
  325. return (
  326. f"{measurement_type}: 已处理 {total}, 有周期 {stats['有周期']}, "
  327. f"无周期 {stats['无周期']} (其中屏掉无触发 {stats['屏掉(无触发)']}), 错误 {errors}, "
  328. f"水位 sample_time={watermark_time} id={watermark_id}"
  329. )
  330. print(f"\n开始处理类型 {type_index}/{len(measurement_types)}: {measurement_type}")
  331. while True:
  332. connection = predict.get_connection()
  333. try:
  334. rows = fetch_batch(connection, measurement_type, watermark_time, watermark_id, args.batch)
  335. if not rows:
  336. flush(connection)
  337. connection.close()
  338. print(f"无待处理文件,完成 [{summary_text()}]")
  339. break
  340. file_ids = [int(row["id"]) for row in rows]
  341. trigger_ids = screen_trigger_files(reader_connections, file_ids, read_workers)
  342. detect_rows = [row for row in rows if int(row["id"]) in trigger_ids]
  343. resolved: dict[int, tuple[int, int] | None] = {
  344. int(row["id"]): None for row in rows if int(row["id"]) not in trigger_ids
  345. }
  346. batch_resolved, batch_errors = resolve_batch_bounds(
  347. detect_rows, reader_connections, read_workers,
  348. )
  349. resolved.update(batch_resolved)
  350. errors += batch_errors
  351. for row in rows:
  352. file_id = int(row["id"])
  353. total += 1
  354. bounds = resolved[file_id]
  355. if bounds is None:
  356. stats["无周期"] += 1
  357. if file_id not in trigger_ids:
  358. stats["屏掉(无触发)"] = stats.get("屏掉(无触发)", 0) + 1
  359. write_buffer.append((file_id, -1, -1))
  360. else:
  361. stats["有周期"] += 1
  362. write_buffer.append((file_id, bounds[0], bounds[1]))
  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. break
  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 predict.get_connection() as connection:
  388. flush_bounds(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 detect_cycle_index.py --measurement-type {current_type} "
  399. f"--start-time \"{watermark_time}\" --start-id {watermark_id}")
  400. return 0
  401. for connection in reader_connections:
  402. try:
  403. connection.close()
  404. except Exception:
  405. pass
  406. return 0
  407. if __name__ == "__main__":
  408. sys.exit(main())