alarm_store.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. """Persist pressure / vibration stage segments into ``compressor_alarm``.
  2. Two write modes:
  3. * ``write_full`` – full scan: delete previous pressure/vibration alarms for the
  4. selected devices, then insert every segment (vibration writes one row per
  5. side: YSJ_10 and YSJ_11).
  6. * ``merge_realtime`` – realtime tick: a growing segment is merged into the
  7. existing row with the same identity (device + part + point + type + level)
  8. when their time ranges overlap or are within ``MERGE_GAP_HOURS``. Different
  9. levels are kept as separate rows, matching the stage CSV rows, so a continuous
  10. same-level fault is never split while a level transition is preserved.
  11. """
  12. from __future__ import annotations
  13. import json
  14. from datetime import datetime, timedelta
  15. from typing import Any
  16. from .db import connect
  17. OIL_ALARM_TYPE = "润滑油压力故障"
  18. VIB_ALARM_TYPE = "振动故障"
  19. OIL_PART = "润滑油压力"
  20. OIL_POINT = "YSJ_5"
  21. COUPLING_PART = "联轴器端振动"
  22. COUPLING_POINT = "YSJ_10"
  23. CHAIN_PART = "链轮端振动"
  24. CHAIN_POINT = "YSJ_11"
  25. ALARM_STATUS = 1
  26. MERGE_GAP_HOURS = 6
  27. DES_MAX = 4000
  28. INSERT_SQL = """
  29. INSERT INTO compressor_alarm
  30. (device_code, device_part, device_point, alarm_type, alarm_des,
  31. alarm_time_start, alarm_time_end, status, alarm_level,
  32. scan_start_time, scan_end_time, alarm_info_json)
  33. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  34. """
  35. def _as_dt(value) -> datetime:
  36. if isinstance(value, datetime):
  37. return value
  38. return datetime.strptime(str(value), "%Y-%m-%d %H:%M:%S")
  39. def _json(row: dict) -> str:
  40. # The explanatory note goes to alarm_des, not to alarm_info_json.
  41. data = {key: value for key, value in row.items() if not key.startswith("_") and key != "模型预测参考"}
  42. return json.dumps(data, ensure_ascii=False, separators=(",", ":"), default=str)
  43. def _targets(device_code: str, kind: str, row: dict) -> list[dict]:
  44. level = row.get("压力阶段") if kind == "pressure" else row.get("振动阶段")
  45. start, end = row.get("开始小时"), row.get("结束小时")
  46. des = str(row.get("阶段触发依据") or "")
  47. note = row.get("模型预测参考")
  48. if note:
  49. des = f"{des};{note}"
  50. des = des[:DES_MAX]
  51. info = _json(row)
  52. if kind == "pressure":
  53. parts = [(OIL_PART, OIL_POINT, OIL_ALARM_TYPE)]
  54. else:
  55. # One row per segment, at the strongest side (matches 振动异常段.csv).
  56. side = str(row.get("最强异常侧") or "")
  57. if side == "联轴器端":
  58. parts = [(COUPLING_PART, COUPLING_POINT, VIB_ALARM_TYPE)]
  59. else:
  60. parts = [(CHAIN_PART, CHAIN_POINT, VIB_ALARM_TYPE)]
  61. result = []
  62. for part, point, alarm_type in parts:
  63. result.append({
  64. "device_code": device_code,
  65. "device_part": part,
  66. "device_point": point,
  67. "alarm_type": alarm_type,
  68. "alarm_level": level,
  69. "alarm_des": des,
  70. "alarm_time_start": _as_dt(start),
  71. "alarm_time_end": _as_dt(end),
  72. "alarm_info_json": info,
  73. })
  74. return result
  75. def build_alarms(pressure_rows: list[dict], vibration_rows: list[dict], batch_to_device: dict[int, str]) -> list[dict]:
  76. alarms = []
  77. for row in pressure_rows:
  78. device = batch_to_device.get(int(row["批次"]))
  79. if device:
  80. alarms.extend(_targets(device, "pressure", row))
  81. for row in vibration_rows:
  82. device = batch_to_device.get(int(row["批次"]))
  83. if device:
  84. alarms.extend(_targets(device, "vibration", row))
  85. return alarms
  86. def _insert(cursor, alarm: dict) -> int:
  87. cursor.execute(INSERT_SQL, (
  88. alarm["device_code"], alarm["device_part"], alarm["device_point"], alarm["alarm_type"],
  89. alarm["alarm_des"], alarm["alarm_time_start"], alarm["alarm_time_end"], ALARM_STATUS,
  90. alarm["alarm_level"], alarm["scan_start_time"], alarm["scan_end_time"], alarm["alarm_info_json"],
  91. ))
  92. return int(cursor.lastrowid)
  93. def write_full(alarms: list[dict], devices: list[str], scan_start: datetime, scan_end: datetime, dry_run: bool = False) -> dict:
  94. for alarm in alarms:
  95. alarm["scan_start_time"] = scan_start
  96. alarm["scan_end_time"] = scan_end
  97. if dry_run:
  98. return {"mode": "full", "dry_run": True, "delete_scope": [devices, OIL_ALARM_TYPE, VIB_ALARM_TYPE], "inserted": len(alarms)}
  99. connection = connect()
  100. deleted = 0
  101. inserted_ids = []
  102. try:
  103. with connection.cursor() as cursor:
  104. placeholders = ",".join(["%s"] * len(devices))
  105. cursor.execute(
  106. f"DELETE FROM compressor_alarm WHERE device_code IN ({placeholders}) AND alarm_type IN (%s, %s)",
  107. (*devices, OIL_ALARM_TYPE, VIB_ALARM_TYPE),
  108. )
  109. deleted = int(cursor.rowcount)
  110. for alarm in alarms:
  111. inserted_ids.append(_insert(cursor, alarm))
  112. finally:
  113. connection.close()
  114. return {"mode": "full", "deleted": deleted, "inserted": len(inserted_ids), "ids": inserted_ids}
  115. def merge_realtime(alarms: list[dict], scan_start: datetime, scan_end: datetime, journal: list | None = None, dry_run: bool = False) -> dict:
  116. """Merge each segment into the matching existing row (same identity+level)."""
  117. inserted = updated = merged_extra = 0
  118. if dry_run:
  119. return {"mode": "realtime", "dry_run": True, "segments": len(alarms)}
  120. connection = connect()
  121. gap = timedelta(hours=MERGE_GAP_HOURS)
  122. try:
  123. with connection.cursor() as cursor:
  124. for alarm in alarms:
  125. start, end = alarm["alarm_time_start"], alarm["alarm_time_end"]
  126. cursor.execute(
  127. # Identity ignores part/point so a segment whose strongest side
  128. # flips (coupling <-> chain) still merges into one row instead
  129. # of being split. part/point are refreshed to the latest side.
  130. """
  131. SELECT id, alarm_time_start, alarm_time_end, alarm_des, alarm_info_json,
  132. scan_start_time, scan_end_time, status
  133. FROM compressor_alarm
  134. WHERE device_code = %s AND alarm_type = %s AND alarm_level <=> %s
  135. AND alarm_time_start <= %s AND alarm_time_end >= %s
  136. ORDER BY alarm_time_start ASC, id ASC
  137. """,
  138. (
  139. alarm["device_code"], alarm["alarm_type"],
  140. alarm["alarm_level"], end + gap, start - gap,
  141. ),
  142. )
  143. existing = cursor.fetchall()
  144. if not existing:
  145. new_id = _insert(cursor, {**alarm, "scan_start_time": scan_start, "scan_end_time": scan_end})
  146. inserted += 1
  147. if journal is not None:
  148. journal.append({"op": "insert", "id": new_id})
  149. continue
  150. keep = existing[0]
  151. new_start = min(start, *(row["alarm_time_start"] for row in existing))
  152. new_end = max(end, *(row["alarm_time_end"] for row in existing))
  153. new_scan_start = min([scan_start] + [row["scan_start_time"] for row in existing if row["scan_start_time"]])
  154. new_scan_end = max([scan_end] + [row["scan_end_time"] for row in existing if row["scan_end_time"]])
  155. if journal is not None:
  156. journal.append({
  157. "op": "update", "id": int(keep["id"]),
  158. "alarm_time_start": keep["alarm_time_start"], "alarm_time_end": keep["alarm_time_end"],
  159. "alarm_des": keep["alarm_des"], "alarm_info_json": keep["alarm_info_json"],
  160. "scan_start_time": keep["scan_start_time"], "scan_end_time": keep["scan_end_time"],
  161. "status": keep["status"],
  162. })
  163. if len(existing) > 1:
  164. extra_ids = [int(row["id"]) for row in existing[1:]]
  165. placeholders = ",".join(["%s"] * len(extra_ids))
  166. cursor.execute(f"DELETE FROM compressor_alarm WHERE id IN ({placeholders})", extra_ids)
  167. merged_extra += len(extra_ids)
  168. if journal is not None:
  169. for row in existing[1:]:
  170. journal.append({"op": "delete_restore", "row": dict(row)})
  171. cursor.execute(
  172. """
  173. UPDATE compressor_alarm
  174. SET device_part = %s, device_point = %s,
  175. alarm_time_start = %s, alarm_time_end = %s, alarm_des = %s,
  176. scan_start_time = %s, scan_end_time = %s, status = %s,
  177. alarm_info_json = %s
  178. WHERE id = %s
  179. """,
  180. (alarm["device_part"], alarm["device_point"],
  181. new_start, new_end, alarm["alarm_des"], new_scan_start, new_scan_end,
  182. ALARM_STATUS, alarm["alarm_info_json"], keep["id"]),
  183. )
  184. updated += 1
  185. finally:
  186. connection.close()
  187. return {"mode": "realtime", "inserted": inserted, "updated": updated, "merged_extra": merged_extra}
  188. def rollback(journal: list) -> dict:
  189. """Undo a realtime smoke run using the journal produced by ``merge_realtime``."""
  190. if not journal:
  191. return {"undone": 0}
  192. connection = connect()
  193. undone = 0
  194. try:
  195. with connection.cursor() as cursor:
  196. for entry in reversed(journal):
  197. if entry["op"] == "insert":
  198. cursor.execute("DELETE FROM compressor_alarm WHERE id = %s", (entry["id"],))
  199. undone += 1
  200. elif entry["op"] == "update":
  201. cursor.execute(
  202. """
  203. UPDATE compressor_alarm
  204. SET alarm_time_start = %s, alarm_time_end = %s, alarm_des = %s,
  205. alarm_info_json = %s, scan_start_time = %s, scan_end_time = %s, status = %s
  206. WHERE id = %s
  207. """,
  208. (entry["alarm_time_start"], entry["alarm_time_end"], entry["alarm_des"],
  209. entry["alarm_info_json"], entry["scan_start_time"], entry["scan_end_time"],
  210. entry["status"], entry["id"]),
  211. )
  212. undone += 1
  213. elif entry["op"] == "delete_restore":
  214. row = entry["row"]
  215. cursor.execute(INSERT_SQL, (
  216. row["device_code"], row["device_part"], row["device_point"], row["alarm_type"],
  217. row["alarm_des"], row["alarm_time_start"], row["alarm_time_end"], row["status"],
  218. row["alarm_level"] if "alarm_level" in row else None,
  219. row["scan_start_time"], row["scan_end_time"], row["alarm_info_json"],
  220. ))
  221. undone += 1
  222. finally:
  223. connection.close()
  224. return {"undone": undone}
  225. def fetch_alarms(devices: list[str]) -> list[dict]:
  226. connection = connect()
  227. try:
  228. with connection.cursor() as cursor:
  229. placeholders = ",".join(["%s"] * len(devices))
  230. cursor.execute(
  231. f"""
  232. SELECT id, device_code, device_part, device_point, alarm_type, alarm_level,
  233. alarm_time_start, alarm_time_end, status, alarm_des, alarm_info_json
  234. FROM compressor_alarm
  235. WHERE device_code IN ({placeholders}) AND alarm_type IN (%s, %s)
  236. ORDER BY device_code, alarm_time_start, device_part
  237. """,
  238. (*devices, OIL_ALARM_TYPE, VIB_ALARM_TYPE),
  239. )
  240. return [dict(row) for row in cursor.fetchall()]
  241. finally:
  242. connection.close()
  243. __all__ = [
  244. "OIL_ALARM_TYPE", "VIB_ALARM_TYPE", "OIL_PART", "OIL_POINT",
  245. "COUPLING_PART", "COUPLING_POINT", "CHAIN_PART", "CHAIN_POINT",
  246. "MERGE_GAP_HOURS", "build_alarms", "write_full", "merge_realtime",
  247. "rollback", "fetch_alarms",
  248. ]