detect_cycle_index.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  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(
  54. connection, file_ids: list[int], limit: int, lo: int = 0
  55. ) -> dict[int, np.ndarray]:
  56. """Fetch rows in ``[lo, limit)`` of a chunk of files in one query.
  57. Returns {file_id: (n, 4) array} with columns
  58. [array_offset(0..n-1), signal_value, second_value, sample_index].
  59. """
  60. if not file_ids:
  61. return {}
  62. placeholders = ",".join(["%s"] * len(file_ids))
  63. with connection.cursor() as cursor:
  64. cursor.execute(
  65. f"""
  66. SELECT wave_file_id,
  67. CAST(signal_value AS FLOAT) AS sig,
  68. CAST(second_value AS FLOAT) AS sec,
  69. sample_index
  70. FROM wave_sample
  71. WHERE wave_file_id IN ({placeholders})
  72. AND sample_index >= %s AND sample_index < %s
  73. ORDER BY wave_file_id, sample_index
  74. """,
  75. (*file_ids, lo, limit),
  76. )
  77. rows = cursor.fetchall()
  78. grouped: dict[int, tuple[list[float], list[float], list[int]]] = {}
  79. for row in rows:
  80. signal, second, index = grouped.setdefault(row["wave_file_id"], ([], [], []))
  81. signal.append(float(row["sig"]))
  82. second.append(float(row["sec"]) if row["sec"] is not None else np.nan)
  83. index.append(int(row["sample_index"]))
  84. out: dict[int, np.ndarray] = {}
  85. for file_id, (signal, second, index) in grouped.items():
  86. count = len(signal)
  87. out[file_id] = np.column_stack(
  88. [
  89. np.arange(count, dtype=float),
  90. np.asarray(signal),
  91. np.asarray(second),
  92. np.asarray(index, dtype=float),
  93. ]
  94. )
  95. return out
  96. def read_batch_samples(
  97. reader_connections: list,
  98. file_ids: list[int],
  99. workers: int,
  100. limit: int,
  101. lo: int = 0,
  102. ) -> dict[int, np.ndarray]:
  103. """Fetch rows in ``[lo, limit)`` of every file in one round-trip."""
  104. if not file_ids:
  105. return {}
  106. if workers <= 1 or len(file_ids) <= workers:
  107. return _fetch_chunk(reader_connections[0], file_ids, limit, lo)
  108. chunks = np.array_split(file_ids, min(workers, len(file_ids)))
  109. with ThreadPoolExecutor(max_workers=len(chunks)) as executor:
  110. futures = [
  111. executor.submit(_fetch_chunk, reader_connections[i], chunk.tolist(), limit, lo)
  112. for i, chunk in enumerate(chunks)
  113. ]
  114. merged: dict[int, np.ndarray] = {}
  115. for future in futures:
  116. merged.update(future.result())
  117. return merged
  118. SCREEN_LIMIT = STAGE_LIMITS[-1]
  119. def _fetch_trigger_chunk(connection, file_ids: list[int], limit: int) -> set[int]:
  120. """Return file_ids that have at least one trigger-high sample in the window."""
  121. if not file_ids:
  122. return set()
  123. placeholders = ",".join(["%s"] * len(file_ids))
  124. with connection.cursor() as cursor:
  125. cursor.execute(
  126. f"""
  127. SELECT DISTINCT wave_file_id
  128. FROM wave_sample
  129. WHERE wave_file_id IN ({placeholders})
  130. AND sample_index < %s
  131. AND second_value >= %s
  132. """,
  133. (*file_ids, limit, TRIGGER_THRESHOLD),
  134. )
  135. return {int(row["wave_file_id"]) for row in cursor.fetchall()}
  136. def screen_trigger_files(
  137. reader_connections: list,
  138. file_ids: list[int],
  139. workers: int,
  140. ) -> set[int]:
  141. """Return the subset of ``file_ids`` that contain any trigger-high sample.
  142. A single grouped query reads only the trigger channel (``second_value``)
  143. within the reachable detection window (``SCREEN_LIMIT``). Files without a
  144. single ``second_value >= TRIGGER_THRESHOLD`` sample can never produce a
  145. complete cycle, so they are decided (-1) without the numpy detector.
  146. """
  147. if not file_ids:
  148. return set()
  149. if workers <= 1 or len(file_ids) <= workers:
  150. return _fetch_trigger_chunk(reader_connections[0], file_ids, SCREEN_LIMIT)
  151. chunks = np.array_split(file_ids, min(workers, len(file_ids)))
  152. with ThreadPoolExecutor(max_workers=len(chunks)) as executor:
  153. futures = [
  154. executor.submit(_fetch_trigger_chunk, reader_connections[i], chunk.tolist(), SCREEN_LIMIT)
  155. for i, chunk in enumerate(chunks)
  156. ]
  157. merged: set[int] = set()
  158. for future in futures:
  159. merged |= future.result()
  160. return merged
  161. def first_cycle_bounds(samples: np.ndarray) -> tuple[int, int] | None:
  162. """Return ``(cycle_start, cycle_end)`` in real sample_index, half-open."""
  163. cycles, _ = detect_cycles(samples)
  164. if not cycles:
  165. return None
  166. cycle = cycles[0]
  167. return (int(samples[cycle.start_offset, 3]), int(samples[cycle.end_offset, 3]))
  168. def resolve_batch_bounds(
  169. rows: list[dict],
  170. reader_connections: list,
  171. workers: int,
  172. first_stage: int = 0,
  173. ) -> tuple[dict[int, tuple[int, int] | None], int]:
  174. """Adaptively detect the first cycle per file, escalating window sizes.
  175. Each escalation re-feeds the same growing prefix to ``detect_cycles`` as the
  176. original implementation (the zero-marker threshold is computed over the runs
  177. visible in that prefix), but only the rows that were not seen before are
  178. downloaded again: stage deltas are appended to an in-memory prefix instead of
  179. re-fetching ``[0, limit)`` on every stage. Detection itself runs in parallel
  180. across the worker connections.
  181. Returns (``{file_id: (start, end) | None}``, error_count). None means no
  182. complete cycle was found at any window (-> -1).
  183. """
  184. errors = 0
  185. resolved: dict[int, tuple[int, int] | None] = {}
  186. pending: dict[int, dict] = {int(row["id"]): row for row in rows}
  187. prefixes: dict[int, np.ndarray] = {}
  188. for stage in range(first_stage, len(STAGE_LIMITS)):
  189. if not pending:
  190. break
  191. limit = STAGE_LIMITS[stage]
  192. lo = 0 if stage == first_stage else STAGE_LIMITS[stage - 1]
  193. deltas = read_batch_samples(reader_connections, list(pending), workers, limit, lo=lo)
  194. detect_workers = min(workers, len(pending))
  195. def work(item: tuple[int, dict]) -> tuple[int, str | tuple[int, int]]:
  196. file_id, row = item
  197. delta = deltas.get(file_id)
  198. if delta is None or len(delta) == 0:
  199. return file_id, "empty"
  200. prefix = prefixes.get(file_id)
  201. prefix = delta if prefix is None else np.vstack([prefix, delta])
  202. prefixes[file_id] = prefix
  203. try:
  204. bounds = first_cycle_bounds(prefix)
  205. except Exception:
  206. return file_id, "error"
  207. if bounds is not None:
  208. return file_id, bounds
  209. if int(row.get("sample_count") or 0) > limit:
  210. return file_id, "pending"
  211. return file_id, "none"
  212. results: dict[int, str | tuple[int, int]] = {}
  213. with ThreadPoolExecutor(max_workers=detect_workers) as executor:
  214. for file_id, result in executor.map(work, pending.items()):
  215. results[file_id] = result
  216. next_pending: dict[int, dict] = {}
  217. for file_id, row in pending.items():
  218. result = results[file_id]
  219. if isinstance(result, tuple):
  220. resolved[file_id] = result
  221. elif result == "pending":
  222. next_pending[file_id] = row
  223. continue
  224. elif result == "error":
  225. errors += 1
  226. resolved[file_id] = None
  227. else:
  228. resolved[file_id] = None
  229. prefixes.pop(file_id, None)
  230. pending = next_pending
  231. for file_id in pending:
  232. resolved[file_id] = None
  233. return resolved, errors
  234. def flush_bounds(connection, buffer: list[tuple[int, int, int]]) -> None:
  235. """Write pending (file_id, cycle_start, cycle_end) rows in one UPDATE."""
  236. if not buffer:
  237. return
  238. case_start = " ".join("WHEN %s THEN %s" for _ in buffer)
  239. case_end = " ".join("WHEN %s THEN %s" for _ in buffer)
  240. placeholders = ",".join(["%s"] * len(buffer))
  241. params_start: list[int] = []
  242. params_end: list[int] = []
  243. for file_id, start, end in buffer:
  244. params_start.extend([file_id, start])
  245. params_end.extend([file_id, end])
  246. with connection.cursor() as cursor:
  247. cursor.execute(
  248. f"UPDATE wave_file SET cycle_start = CASE id {case_start} END, "
  249. f"cycle_end = CASE id {case_end} END "
  250. f"WHERE id IN ({placeholders})",
  251. tuple(params_start + params_end + [file_id for file_id, _, _ in buffer]),
  252. )
  253. buffer.clear()
  254. def fetch_batch(
  255. connection,
  256. measurement_type: str,
  257. watermark_time: object | None,
  258. watermark_id: int,
  259. batch: int,
  260. ) -> list[dict]:
  261. with connection.cursor() as cursor:
  262. if watermark_time is None:
  263. cursor.execute(
  264. """
  265. SELECT id, point_name, measurement_type, sample_time, sample_count
  266. FROM wave_file
  267. WHERE measurement_type = %s AND cycle_start IS NULL
  268. ORDER BY sample_time ASC, id ASC
  269. LIMIT %s
  270. """,
  271. (measurement_type, batch),
  272. )
  273. else:
  274. cursor.execute(
  275. """
  276. SELECT id, point_name, measurement_type, sample_time, sample_count
  277. FROM wave_file
  278. WHERE measurement_type = %s AND cycle_start IS NULL
  279. AND (sample_time > %s OR (sample_time = %s AND id > %s))
  280. ORDER BY sample_time ASC, id ASC
  281. LIMIT %s
  282. """,
  283. (measurement_type, watermark_time, watermark_time, watermark_id, batch),
  284. )
  285. return cursor.fetchall()
  286. def list_pending_types(connection) -> list[str]:
  287. """Return every measurement_type that still has files without cycle bounds."""
  288. with connection.cursor() as cursor:
  289. cursor.execute(
  290. """
  291. SELECT DISTINCT measurement_type AS mt
  292. FROM wave_file
  293. WHERE cycle_start IS NULL
  294. ORDER BY mt
  295. """
  296. )
  297. return [str(row["mt"]) for row in cursor.fetchall() if row["mt"] is not None]
  298. def main() -> int:
  299. parser = argparse.ArgumentParser(description="回写首个完整周期的 sample_index 边界")
  300. parser.add_argument("--measurement-type", action="append", default=[],
  301. help="要回写的测量类型,可多次指定或用逗号分隔(默认自动取全部测量类型)")
  302. parser.add_argument("--start-time", type=str, default="",
  303. help="起始水位时间(含),留空从头开始")
  304. parser.add_argument("--start-id", type=int, default=0,
  305. help="起始水位 id(含)")
  306. parser.add_argument("--batch", type=int, default=100, help="每轮读取的文件数")
  307. parser.add_argument("--read-workers", type=int, default=4, help="并行读样本的连接数")
  308. parser.add_argument("--limit", type=int, default=0, help="最多处理文件数,0 表示不限")
  309. parser.add_argument("--report-every", type=int, default=100, help="每 N 个文件打印一次汇总")
  310. parser.add_argument("--write-batch", type=int, default=100,
  311. help="回写攒满 N 条才批量写一次")
  312. parser.add_argument("--dry-run", action="store_true", help="只检测不回写数据库")
  313. args = parser.parse_args()
  314. measurement_types: list[str] = []
  315. for raw in args.measurement_type:
  316. for part in str(raw).split(","):
  317. part = part.strip()
  318. if part and part not in measurement_types:
  319. measurement_types.append(part)
  320. if not measurement_types:
  321. with predict.get_connection() as connection:
  322. measurement_types = list_pending_types(connection)
  323. if not measurement_types:
  324. print("没有待回写的测量类型(无 device_status = 1 且周期缺失/为 -1 的文件),退出")
  325. return 0
  326. start_time = args.start_time.strip()
  327. read_workers = max(1, min(args.read_workers, args.batch))
  328. reader_connections = [predict.get_connection() for _ in range(read_workers)]
  329. current_type = measurement_types[0]
  330. watermark_time: object | None = start_time or None
  331. watermark_id = args.start_id
  332. total = 0
  333. stats = {"有周期": 0, "无周期": 0, "屏掉(无触发)": 0}
  334. errors = 0
  335. last_reported = 0
  336. write_buffer: list[tuple[int, int, int]] = []
  337. print(f"测量类型: {', '.join(measurement_types)}, 水位起点: "
  338. f"sample_time={start_time or '从头'}, id={args.start_id}, 批大小: {args.batch}, "
  339. f"读并行: {read_workers} 连接, 窗口: {STAGE_LIMITS}"
  340. + (", 干跑(不回写)" if args.dry_run else ""))
  341. try:
  342. for type_index, measurement_type in enumerate(measurement_types, start=1):
  343. current_type = measurement_type
  344. watermark_time = start_time or None
  345. watermark_id = args.start_id
  346. total = 0
  347. stats = {"有周期": 0, "无周期": 0, "屏掉(无触发)": 0}
  348. errors = 0
  349. last_reported = 0
  350. write_buffer = []
  351. def flush(connection) -> None:
  352. if args.dry_run:
  353. write_buffer.clear()
  354. else:
  355. flush_bounds(connection, write_buffer)
  356. def summary_text() -> str:
  357. return (
  358. f"{measurement_type}: 已处理 {total}, 有周期 {stats['有周期']}, "
  359. f"无周期 {stats['无周期']} (其中屏掉无触发 {stats['屏掉(无触发)']}), 错误 {errors}, "
  360. f"水位 sample_time={watermark_time} id={watermark_id}"
  361. )
  362. print(f"\n开始处理类型 {type_index}/{len(measurement_types)}: {measurement_type}")
  363. while True:
  364. connection = predict.get_connection()
  365. try:
  366. rows = fetch_batch(connection, measurement_type, watermark_time, watermark_id, args.batch)
  367. if not rows:
  368. flush(connection)
  369. connection.close()
  370. print(f"无待处理文件,完成 [{summary_text()}]")
  371. break
  372. file_ids = [int(row["id"]) for row in rows]
  373. trigger_ids = screen_trigger_files(reader_connections, file_ids, read_workers)
  374. detect_rows = [row for row in rows if int(row["id"]) in trigger_ids]
  375. resolved: dict[int, tuple[int, int] | None] = {
  376. int(row["id"]): None for row in rows if int(row["id"]) not in trigger_ids
  377. }
  378. batch_resolved, batch_errors = resolve_batch_bounds(
  379. detect_rows, reader_connections, read_workers,
  380. )
  381. resolved.update(batch_resolved)
  382. errors += batch_errors
  383. for row in rows:
  384. file_id = int(row["id"])
  385. total += 1
  386. bounds = resolved[file_id]
  387. if bounds is None:
  388. stats["无周期"] += 1
  389. if file_id not in trigger_ids:
  390. stats["屏掉(无触发)"] = stats.get("屏掉(无触发)", 0) + 1
  391. write_buffer.append((file_id, -1, -1))
  392. else:
  393. stats["有周期"] += 1
  394. write_buffer.append((file_id, bounds[0], bounds[1]))
  395. if len(write_buffer) >= args.write_batch:
  396. flush(connection)
  397. if total // args.report_every > last_reported:
  398. last_reported = total // args.report_every
  399. print(f" 汇总: {summary_text()}", flush=True)
  400. if args.limit and total >= args.limit:
  401. flush(connection)
  402. watermark_time = rows[-1]["sample_time"]
  403. watermark_id = int(rows[-1]["id"])
  404. connection.close()
  405. print(f"已达 --limit={args.limit},退出")
  406. print(f" 汇总: {summary_text()}")
  407. break
  408. flush(connection)
  409. watermark_time = rows[-1]["sample_time"]
  410. watermark_id = int(rows[-1]["id"])
  411. finally:
  412. try:
  413. connection.close()
  414. except Exception:
  415. pass
  416. except KeyboardInterrupt:
  417. if write_buffer and not args.dry_run:
  418. try:
  419. with predict.get_connection() as connection:
  420. flush_bounds(connection, write_buffer)
  421. except Exception:
  422. pass
  423. for connection in reader_connections:
  424. try:
  425. connection.close()
  426. except Exception:
  427. pass
  428. print(f"\n已中断。{summary_text()}")
  429. if watermark_time is not None:
  430. print(f"续跑命令: python detect_cycle_index.py --measurement-type {current_type} "
  431. f"--start-time \"{watermark_time}\" --start-id {watermark_id}")
  432. return 0
  433. for connection in reader_connections:
  434. try:
  435. connection.close()
  436. except Exception:
  437. pass
  438. return 0
  439. if __name__ == "__main__":
  440. sys.exit(main())