detect_cycle_index.py 15 KB

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