sync_device_status.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 开机停机表合并 + 数据表 device_status 全量覆盖脚本。
  5. 职责(脚本内不含 DDL,建表/加列请先执行 schema_device_status.sql):
  6. 1. 解析 开机停机表/{7,8,9}号机.xls,将“按天逐条”记录合并成连续的开/关机时间周期;
  7. 2. 重建 machine_run_status 表(DELETE 全部 + 重插,可重复执行);
  8. 3. 全量覆盖 pks_long_sample.device_status(import_batch_id 30/31/32 匹配 7/8/9 号机);
  9. 4. 全量覆盖 wave_file.device_status(按 device_part/point_name 文本识别机组后按时间匹配)。
  10. 用法:
  11. python sync_device_status.py # 全流程
  12. python sync_device_status.py --dry-run # 只解析Excel并打印合并段,不写库
  13. python sync_device_status.py --smoke # 冒烟:pks仅 batch=30 且 2025-04-01~04-05,wave同窗
  14. python sync_device_status.py --skip-pks --skip-wave # 只重建开机停机表
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import importlib.util
  19. import math
  20. import sys
  21. from datetime import datetime, timedelta
  22. from pathlib import Path
  23. import xlrd
  24. import pymysql
  25. from pymysql.constants import CLIENT
  26. ROOT = Path(__file__).resolve().parent
  27. PROJECT_ROOT = ROOT.parents[1] if ROOT.name == "openclose" else ROOT
  28. EXCEL_DIR = PROJECT_ROOT / "开机停机表"
  29. STATUS_TABLE = "machine_run_status"
  30. # 机组 -> (import_batch_id)
  31. UNIT_BATCH = {7: 30, 8: 31, 9: 32}
  32. UNIT_FILES = {u: f"{u}号机.xls" for u in (7, 8, 9)}
  33. CHUNK_DAYS = 15 # pks 清零分块天数
  34. PKS_GLOBAL_MIN = datetime(2025, 4, 1, 0, 0, 0)
  35. # 上界取次日 00:00,保证把末行 2026-05-01 23:59:55 也纳入 [min,max) 更新范围
  36. PKS_GLOBAL_MAX = datetime(2026, 5, 2, 0, 0, 0)
  37. def load_settings():
  38. spec = importlib.util.spec_from_file_location("app_config", PROJECT_ROOT / "backend" / "app" / "config.py")
  39. mod = importlib.util.module_from_spec(spec)
  40. spec.loader.exec_module(mod)
  41. return mod.settings
  42. def connect(settings):
  43. return pymysql.connect(
  44. host=settings.db_host, port=settings.db_port, user=settings.db_user,
  45. password=settings.db_password, database=settings.db_name, charset="utf8mb4",
  46. autocommit=False, client_flag=CLIENT.FOUND_ROWS,
  47. cursorclass=pymysql.cursors.DictCursor,
  48. )
  49. # ---------------------------------------------------------------- Excel 解析
  50. def parse_xls(path: Path):
  51. """返回该机 (unit_no, [记录]),记录为 dict;同时收集问题警告。"""
  52. wb = xlrd.open_workbook(str(path))
  53. sh = wb.sheet_by_index(0)
  54. rows = [sh.row_values(r) for r in range(sh.nrows)]
  55. header = [str(x).strip() for x in rows[0]]
  56. idx = {name: i for i, name in enumerate(header)}
  57. unit_no = int(path.stem.replace("号机", ""))
  58. records = []
  59. problems = []
  60. for r_i, raw in enumerate(rows[1:], start=2):
  61. if not raw or not any(str(c).strip() for c in raw):
  62. continue
  63. status_raw = str(raw[idx["设备状态"]]).strip()
  64. if status_raw not in ("开机", "关机"):
  65. problems.append(f"行{r_i}:无法识别状态 {status_raw!r},已跳过")
  66. continue
  67. try:
  68. base = xlrd.xldate_as_datetime(raw[idx["时间"]], wb.datemode)
  69. except Exception:
  70. problems.append(f"行{r_i}:时间无法解析 {raw[idx['时间']]!r},已跳过")
  71. continue
  72. tp_raw = raw[idx["时间点"]] if idx["时间点"] is not None else 1.0
  73. try:
  74. tp = float(tp_raw)
  75. except (TypeError, ValueError):
  76. tp = 1.0
  77. problems.append(f"行{r_i}:时间点无法解析 {tp_raw!r},按 24:00 处理")
  78. if tp >= 1.0:
  79. dt = base + timedelta(days=1)
  80. else:
  81. dt = base + timedelta(seconds=int(round(tp * 86400)))
  82. start_cat = str(raw[idx["启动类别"]]).strip() if idx["启动类别"] is not None else ""
  83. stop_cat = str(raw[idx["停机类别"]]).strip() if idx["停机类别"] is not None else ""
  84. if start_cat == "nan":
  85. start_cat = ""
  86. if stop_cat == "nan":
  87. stop_cat = ""
  88. try:
  89. cum = float(raw[idx["累计运行时间"]])
  90. except (TypeError, ValueError):
  91. cum = None
  92. records.append({
  93. "unit": unit_no,
  94. "dt": dt,
  95. "status": 1 if status_raw == "开机" else 0,
  96. "start_cat": start_cat,
  97. "stop_cat": stop_cat,
  98. "cum": cum,
  99. })
  100. records.sort(key=lambda r: r["dt"])
  101. return unit_no, records, problems
  102. def build_periods(records):
  103. """把逐条记录合并为连续状态周期。
  104. 状态只在“相邻记录状态发生变化”处切段;状态相同则继续合并。
  105. 最后一个周期结束于 Excel 末行时刻(状态结束时间截到末行)。
  106. """
  107. periods = []
  108. changes = [] # 状态发生变化的记录(含首条),作为每段的开启点
  109. for rec in records:
  110. if not changes or rec["status"] != changes[-1]["status"]:
  111. changes.append(rec)
  112. if not changes:
  113. return periods
  114. last_time = records[-1]["dt"]
  115. for i, chg in enumerate(changes):
  116. start = chg["dt"]
  117. end = changes[i + 1]["dt"] if i + 1 < len(changes) else last_time
  118. if end <= start:
  119. continue
  120. status = chg["status"]
  121. cat = chg["start_cat"] if status == 1 else chg["stop_cat"]
  122. cum_vals = [r["cum"] for r in records if start <= r["dt"] < end and r["cum"] is not None]
  123. cum = max(cum_vals) if cum_vals else None
  124. run_hours = round((end - start).total_seconds() / 3600.0, 2) if status == 1 else 0.0
  125. periods.append({
  126. "unit": chg["unit"],
  127. "import_batch_id": UNIT_BATCH[chg["unit"]],
  128. "status": status,
  129. "status_start": start,
  130. "status_end": end,
  131. "start_category": cat or None,
  132. "run_hours": run_hours,
  133. "cum_run_hours": round(cum, 2) if cum is not None else None,
  134. })
  135. return periods
  136. def validate_periods(periods):
  137. warns = []
  138. per_unit = {}
  139. for p in periods:
  140. per_unit.setdefault(p["unit"], []).append(p)
  141. for u, ps in sorted(per_unit.items()):
  142. on_days = sum((p["status_end"] - p["status_start"]).total_seconds() for p in ps if p["status"] == 1) / 86400.0
  143. off_days = sum((p["status_end"] - p["status_start"]).total_seconds() for p in ps if p["status"] == 0) / 86400.0
  144. prev = None
  145. for p in ps:
  146. if prev and p["status"] == prev["status"]:
  147. warns.append(f"{u}号机:相邻两段状态未交替 {prev['status_start']}->{p['status_start']}")
  148. if prev and p["status_start"] < prev["status_end"]:
  149. warns.append(f"{u}号机:时间段重叠 {prev['status_start']}..{prev['status_end']} 与 {p['status_start']}")
  150. prev = p
  151. print(f" {u}号机:{len(ps)} 段,开机 {on_days:.1f} 天 / 关机 {off_days:.1f} 天,"
  152. f"区间 {ps[0]['status_start']:%Y-%m-%d %H:%M} ~ {ps[-1]['status_end']:%Y-%m-%d %H:%M}")
  153. return warns
  154. # ---------------------------------------------------------------- DB 更新
  155. def rebuild_status_table(conn, periods):
  156. cur = conn.cursor()
  157. cur.execute(f"DELETE FROM {STATUS_TABLE}")
  158. sql = (f"INSERT INTO {STATUS_TABLE} "
  159. f"(unit_no, import_batch_id, status, status_start, status_end, "
  160. f"start_category, run_hours, cum_run_hours) "
  161. f"VALUES (%(unit)s, %(import_batch_id)s, %(status)s, %(status_start)s, "
  162. f"%(status_end)s, %(start_category)s, %(run_hours)s, %(cum_run_hours)s)")
  163. data = [
  164. {
  165. "unit": p["unit"],
  166. "import_batch_id": p["import_batch_id"],
  167. "status": p["status"],
  168. "status_start": p["status_start"],
  169. "status_end": p["status_end"],
  170. "start_category": p["start_category"],
  171. "run_hours": p["run_hours"],
  172. "cum_run_hours": p["cum_run_hours"],
  173. }
  174. for p in periods
  175. ]
  176. cur.executemany(sql, data)
  177. conn.commit()
  178. print(f"machine_run_status 重建完成,共 {cur.rowcount} 条(已先清空)")
  179. def fetch_periods(conn):
  180. cur = conn.cursor()
  181. cur.execute(f"SELECT unit_no, status, status_start, status_end FROM {STATUS_TABLE} "
  182. f"ORDER BY unit_no, status_start")
  183. per = {}
  184. for r in cur.fetchall():
  185. per.setdefault(r["unit_no"], []).append(r)
  186. return per
  187. def _clip(s, e, lo, hi):
  188. return max(s, lo), min(e, hi)
  189. def _grid_count(a, b):
  190. """区间 [a,b) 内落在 5 秒采样网格上的点数(网格=epoch秒可被5整除)。"""
  191. a_s, b_s = int(a.timestamp()), int(b.timestamp())
  192. k0 = math.ceil(a_s / 5)
  193. k1 = math.floor((b_s - 1) / 5)
  194. return max(0, k1 - k0 + 1)
  195. def update_pks(conn, per, lo=None, hi=None, only_batch=None, chunk_days=CHUNK_DAYS):
  196. cur = conn.cursor()
  197. lo = lo or PKS_GLOBAL_MIN
  198. hi = hi or PKS_GLOBAL_MAX
  199. for unit in sorted(UNIT_BATCH):
  200. batch = UNIT_BATCH[unit]
  201. if only_batch is not None and batch != only_batch:
  202. continue
  203. on_periods = [p for p in per.get(unit, []) if p["status"] == 1]
  204. t = lo
  205. n_clear = 0
  206. while t < hi:
  207. c1 = min(t + timedelta(days=chunk_days), hi)
  208. cur.execute("UPDATE pks_long_sample SET device_status=0 "
  209. "WHERE import_batch_id=%s AND sample_time>=%s AND sample_time<%s",
  210. (batch, t, c1))
  211. n_clear += cur.rowcount
  212. conn.commit()
  213. t = c1
  214. expected = 0
  215. for p in on_periods:
  216. s, e = _clip(p["status_start"], p["status_end"], lo, hi)
  217. if e <= s:
  218. continue
  219. expected += _grid_count(s, e)
  220. cur.execute("UPDATE pks_long_sample SET device_status=1 "
  221. "WHERE import_batch_id=%s AND sample_time>=%s AND sample_time<%s",
  222. (batch, s, e))
  223. conn.commit()
  224. cur.execute("SELECT COUNT(*) n FROM pks_long_sample WHERE import_batch_id=%s AND device_status=1",
  225. (batch,))
  226. actual = cur.fetchone()["n"]
  227. print(f" pks batch{batch}({unit}号机):清零 {n_clear} 行,置1后 {actual} 行(理论 {expected},差 {actual - expected})")
  228. def _unit_expr():
  229. """由文本识别机组(7/8/9号机);识别不到返回 NULL。"""
  230. p = "CONCAT(IFNULL(device_part,''),' ',IFNULL(point_name,''),' ',IFNULL(device_code,''))"
  231. return (
  232. "CASE "
  233. f"WHEN {p} LIKE '%9号机组%' THEN 9 "
  234. f"WHEN {p} LIKE '%8号机组%' THEN 8 "
  235. f"WHEN {p} LIKE '%7号机组%' THEN 7 "
  236. "ELSE NULL END"
  237. )
  238. def update_wave(conn, per, lo=None, hi=None):
  239. cur = conn.cursor()
  240. if lo and hi:
  241. wnd = "sample_time>=%s AND sample_time<%s"
  242. args = (lo, hi)
  243. cur.execute("UPDATE wave_file SET device_status=0 WHERE " + wnd, args)
  244. else:
  245. args = ()
  246. cur.execute("UPDATE wave_file SET device_status=0")
  247. print(f" wave_file 已清零 {cur.rowcount} 行")
  248. conn.commit()
  249. # 注:SQL 内含字面 %(LIKE),不能用 %s 参数格式化,窗口条件直接内联
  250. expr = _unit_expr()
  251. sql_set = (
  252. "UPDATE wave_file w "
  253. "JOIN machine_run_status m ON m.status=1 "
  254. " AND w.sample_time>=m.status_start AND w.sample_time<m.status_end "
  255. f" AND ({expr})=m.unit_no "
  256. "SET w.device_status=1 "
  257. )
  258. if lo and hi:
  259. sql_set += (f"WHERE w.sample_time>='{lo:%Y-%m-%d %H:%M:%S}' "
  260. f"AND w.sample_time<'{hi:%Y-%m-%d %H:%M:%S}' ")
  261. cur.execute(sql_set)
  262. conn.commit()
  263. print(f" wave_file 置1完成 {cur.rowcount} 行")
  264. # ---------------------------------------------------------------- main
  265. def main():
  266. ap = argparse.ArgumentParser(description="开机停机表合并 + device_status 全量覆盖")
  267. ap.add_argument("--dry-run", action="store_true", help="只解析Excel并打印,不写库")
  268. ap.add_argument("--smoke", action="store_true", help="冒烟:pks仅batch=30,窗口2025-04-01~04-05")
  269. ap.add_argument("--skip-status-table", action="store_true", help="跳过重建 machine_run_status")
  270. ap.add_argument("--skip-pks", action="store_true", help="跳过 pks_long_sample 更新")
  271. ap.add_argument("--skip-wave", action="store_true", help="跳过 wave_file 更新")
  272. args = ap.parse_args()
  273. all_periods = []
  274. all_problems = []
  275. for u in (7, 8, 9):
  276. f = EXCEL_DIR / UNIT_FILES[u]
  277. if not f.exists():
  278. print(f"[跳过] 缺少 {f.name}")
  279. continue
  280. unit, records, problems = parse_xls(f)
  281. all_problems += [f"{unit}号机:{x}" for x in problems]
  282. periods = build_periods(records)
  283. all_periods += periods
  284. print(f"{unit}号机.xls:{len(records)} 条原始记录 -> {len(periods)} 个合并周期")
  285. for p in all_problems:
  286. print(" [警告]", p)
  287. if not all_periods:
  288. print("没有可用的合并周期,退出")
  289. sys.exit(1)
  290. print("---- 合并结果预览(每台 前2/后2 段)----")
  291. for p in all_periods[:2]:
  292. print(" ", p)
  293. print(" ...")
  294. for p in all_periods[-2:]:
  295. print(" ", p)
  296. print("---- 连续性/交替性校验 ----")
  297. for w in validate_periods(all_periods):
  298. print(" [警告]", w)
  299. if args.dry_run:
  300. print("[dry-run] 结束,未写库")
  301. return
  302. settings = load_settings()
  303. conn = connect(settings)
  304. try:
  305. if not args.skip_status_table:
  306. rebuild_status_table(conn, all_periods)
  307. per = fetch_periods(conn)
  308. smoke_lo = smoke_hi = None
  309. if args.smoke:
  310. smoke_lo = datetime(2025, 4, 29)
  311. smoke_hi = datetime(2025, 5, 4)
  312. print("---- 冒烟模式:pks batch=30,窗口 2025-04-29 ~ 2025-05-04 ----")
  313. if not args.skip_pks:
  314. print("---- 更新 pks_long_sample.device_status ----")
  315. update_pks(conn, per, lo=smoke_lo, hi=smoke_hi, only_batch=30 if args.smoke else None)
  316. if not args.skip_wave:
  317. print("---- 更新 wave_file.device_status ----")
  318. update_wave(conn, per, lo=smoke_lo, hi=smoke_hi)
  319. finally:
  320. conn.close()
  321. print("完成")
  322. if __name__ == "__main__":
  323. main()