device_online_report.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. # -*- coding: utf-8 -*-
  2. """
  3. 设备设施在线率统计日报生成器(通防管控平台)
  4. 功能:按设备类型(主通风机/局部通风机/风门/风窗/各类传感器/其他)统计在线情况,
  5. 生成当天设备在线日报(Markdown + JSON),并执行告警规则:
  6. - 主通风机:有 1 套不在线即红色报警
  7. - 其余类型:在线率 < 90% 橙色报警
  8. 输入(JSON 文件,数据来源为 tf_mcp 的 execute_sql_query 查询结果):
  9. --devices 设备明细(含分站连接状态),字段:ID/strName/deviceKind/strType/
  10. nSubStationID/StationName/monitorFlag/testFlag/linkStatus/isUse
  11. --fan 可选,主通风机开机状态(fan 表),字段:nFanID/strName/bOn
  12. --aigate 可选,智能风门状态(ai_gate 表),字段:name/status(1正常0离线2故障)
  13. 输出:
  14. report.md / report.json (写入 --outdir,默认当前目录)
  15. 用法示例:
  16. python device_online_report.py --devices devices.json --outdir ./report
  17. python device_online_report.py --devices devices.json --fan fan.json --aigate aigate.json --outdir ./report
  18. 本脚本仅依赖 Python 标准库,无第三方依赖。
  19. """
  20. import argparse
  21. import json
  22. import os
  23. from datetime import datetime
  24. # ============================================================
  25. # 一、类型分组配置(可覆盖:通过 --groups 传入自定义 JSON 文件)
  26. # 键为日报分组名,值为该组包含的 deviceKind 编码列表
  27. # ============================================================
  28. DEFAULT_GROUPS = {
  29. "主通风机": ["fanmain"],
  30. "局部通风机": ["fanlocal"],
  31. "风门": ["gate"],
  32. "风窗": ["window"],
  33. "传感器": [
  34. "modelsensor", "safetymonitor", "gasmonitor", "gas",
  35. "gaspatrol", "dusting", "fiber", "bundletube", "windrect",
  36. "atomizing", "duaSpeCamera", "imgFireDet",
  37. ],
  38. # 其余 deviceKind 自动归入"其他"
  39. }
  40. # 告警阈值(可覆盖)
  41. DEFAULT_THRESHOLD = 0.90 # 其余类型在线率下限
  42. DEFAULT_FANMAIN_OFFLINE_TOL = 0 # 主通风机允许离线台数(0 = 1台离线即报警)
  43. def load_json(path):
  44. """读取 JSON 文件;支持 tf_mcp 返回的 {result: [...]} 包装结构。"""
  45. if not path:
  46. return []
  47. with open(path, "r", encoding="utf-8") as f:
  48. data = json.load(f)
  49. if isinstance(data, dict):
  50. # 兼容 {success/code/result:[...]} 或 {result:[...]}
  51. for key in ("result", "data"):
  52. if key in data and isinstance(data[key], list):
  53. return data[key]
  54. return []
  55. return data
  56. def norm(s):
  57. """规范化字符串用于匹配(去空白)。"""
  58. return "" if s is None else str(s).strip()
  59. def get(d, key, default=None):
  60. v = d.get(key, default)
  61. return v
  62. # ============================================================
  63. # 二、在线判定(分类型混合判定,优先级链,命中即止)
  64. # ============================================================
  65. def judge_device(dev, fan_map, aigate_map, judge_mode="strict"):
  66. """
  67. 判定单台设备在线状态,返回 (state, reason, detail)
  68. state: "online" / "offline" / "unknown"
  69. """
  70. kind = norm(dev.get("deviceKind"))
  71. name = norm(dev.get("strName"))
  72. monitor_flag = get(dev, "monitorFlag")
  73. test_flag = get(dev, "testFlag")
  74. link_status = get(dev, "linkStatus")
  75. is_use = get(dev, "isUse")
  76. station_id = get(dev, "nSubStationID")
  77. # 规则 1:主通风机 —— fan 表开机状态(bOn=1 在线 / 0 离线 / NULL 继续)
  78. if kind == "fanmain" and fan_map:
  79. b_on = fan_map.get(name)
  80. if b_on is not None:
  81. if int(b_on) == 1:
  82. return "online", "fan表bOn=1(开机)", "fan.bOn"
  83. return "offline", f"fan表bOn={b_on}(停机)", "fan.bOn"
  84. # 规则 2:风门 —— ai_gate 表状态(按名称匹配,status=1 在线 / 0/2 离线 / 无匹配继续)
  85. if kind == "gate" and aigate_map:
  86. st = aigate_map.get(name)
  87. if st is not None:
  88. if int(st) == 1:
  89. return "online", "ai_gate状态正常", "ai_gate.status"
  90. return "offline", f"ai_gate状态={st}(离线/故障)", "ai_gate.status"
  91. # 规则 3:通用主判定 —— 关联分站连接状态
  92. # 分站在线 = linkStatus=1 且 isUse=1(开启监控)
  93. if station_id is not None and str(station_id) != "":
  94. if link_status is None and is_use is None:
  95. # 分站 ID 在 sub_station 中不存在
  96. return "unknown", "关联分站不存在于sub_station", "station"
  97. if int(link_status or 0) == 1 and int(is_use or 0) == 1:
  98. return "online", "关联分站在线(linkStatus=1)", "station"
  99. return "offline", f"关联分站离线(linkStatus={link_status},isUse={is_use})", "station"
  100. # 设备未关联分站
  101. return "unknown", "未关联分站", "station"
  102. # ============================================================
  103. # 三、统计与告警
  104. # ============================================================
  105. def build_groups(custom_groups=None):
  106. groups = dict(DEFAULT_GROUPS)
  107. if custom_groups:
  108. groups.update(custom_groups)
  109. return groups
  110. def group_of(kind, groups):
  111. for gname, kinds in groups.items():
  112. if kind in kinds:
  113. return gname
  114. return "其他"
  115. def aggregate(devices, fan_map, aigate_map, groups, threshold, fan_tol):
  116. """统计各分组:总数/在线/离线/未知/在线率/明细。"""
  117. results = {}
  118. for gname in list(groups.keys()) + ["其他"]:
  119. results[gname] = {
  120. "total": 0, "online": 0, "offline": 0, "unknown": 0,
  121. "sim_cnt": 0, "online_rate": None,
  122. "online_list": [], "offline_list": [], "unknown_list": [],
  123. }
  124. for dev in devices:
  125. kind = norm(dev.get("deviceKind"))
  126. gname = group_of(kind, groups)
  127. r = results[gname]
  128. state, reason, detail = judge_device(dev, fan_map, aigate_map)
  129. item = {
  130. "id": get(dev, "ID"),
  131. "name": norm(dev.get("strName")),
  132. "deviceKind": kind,
  133. "strType": norm(dev.get("strType")),
  134. "stationName": norm(dev.get("StationName")),
  135. "monitorFlag": get(dev, "monitorFlag"),
  136. "testFlag": get(dev, "testFlag"),
  137. "state": state,
  138. "reason": reason,
  139. "judge_source": detail,
  140. }
  141. r["total"] += 1
  142. if int(get(dev, "testFlag") or 0) == 1:
  143. r["sim_cnt"] += 1
  144. if state == "online":
  145. r["online"] += 1
  146. r["online_list"].append(item)
  147. elif state == "offline":
  148. r["offline"] += 1
  149. r["offline_list"].append(item)
  150. else:
  151. r["unknown"] += 1
  152. r["unknown_list"].append(item)
  153. # 在线率:分子=在线数;分母=正常设备(total),unknown 不计入分母并单独说明
  154. for r in results.values():
  155. denom = r["total"] - r["unknown"]
  156. if denom > 0:
  157. r["online_rate"] = round(r["online"] / denom, 4)
  158. return results
  159. def judge_alarms(results, threshold, fan_tol):
  160. """告警规则:
  161. - 主通风机:offline/unknown 超过容忍台数 -> 红色报警
  162. - 其他分组:在线率 < threshold -> 橙色报警
  163. """
  164. alarms = []
  165. fan = results.get("主通风机", {})
  166. bad = (fan.get("offline", 0) or 0) + (fan.get("unknown", 0) or 0)
  167. if bad > fan_tol:
  168. alarms.append({
  169. "level": "RED",
  170. "group": "主通风机",
  171. "message": f"主通风机有 {bad} 套不在线(在线 {fan.get('online',0)}/{fan.get('total',0)}),须立即排查!",
  172. })
  173. for gname, r in results.items():
  174. if gname == "主通风机":
  175. continue
  176. rate = r.get("online_rate")
  177. if rate is not None and rate < threshold:
  178. alarms.append({
  179. "level": "ORANGE",
  180. "group": gname,
  181. "message": f"{gname}在线率 {rate*100:.1f}% 低于 {threshold*100:.0f}%(在线 {r['online']}/{r['total']},离线 {r['offline']},未知 {r['unknown']})",
  182. })
  183. if not alarms:
  184. alarms.append({"level": "GREEN", "group": "-", "message": "全部设备在线率达标"})
  185. return alarms
  186. # ============================================================
  187. # 四、输出渲染
  188. # ============================================================
  189. def render_markdown(report_date, results, alarms, threshold, fan_tol, total_summary):
  190. lines = []
  191. lines.append(f"# 设备设施在线情况日报({report_date})")
  192. lines.append("")
  193. lines.append("## 一、总体概况")
  194. lines.append("")
  195. lines.append(f"- 正常接入设备:**{total_summary['total']}** 台")
  196. lines.append(f"- 在线:**{total_summary['online']}** 台({total_summary['online_rate']*100:.1f}%)")
  197. lines.append(f"- 离线:**{total_summary['offline']}** 台")
  198. lines.append(f"- 状态未知(未关联分站/无法判定):**{total_summary['unknown']}** 台(不计入在线率分母)")
  199. lines.append(f"- 模拟数据设备:**{total_summary['sim']}** 台(testFlag=1,仅供测试参考)")
  200. lines.append("")
  201. lines.append("## 二、分类型在线统计")
  202. lines.append("")
  203. lines.append("| 设备类型 | 总数 | 在线 | 离线 | 未知 | 在线率 | 告警 |")
  204. lines.append("|---|---|---|---|---|---|---|")
  205. order = list(results.keys())
  206. for gname in order:
  207. r = results[gname]
  208. rate = f"{r['online_rate']*100:.1f}%" if r["online_rate"] is not None else "-"
  209. lvl = next((a["level"] for a in alarms if a["group"] == gname), "")
  210. flag = {"RED": "🔴", "ORANGE": "🟠", "GREEN": "🟢"}.get(lvl, "")
  211. lines.append(f"| {gname} | {r['total']} | {r['online']} | {r['offline']} | {r['unknown']} | {rate} | {flag} |")
  212. lines.append("")
  213. lines.append("## 三、告警信息")
  214. lines.append("")
  215. for a in alarms:
  216. icon = {"RED": "🔴", "ORANGE": "🟠", "GREEN": "🟢"}[a["level"]]
  217. lines.append(f"- {icon} **[{a['level']}]** {a['message']}")
  218. lines.append("")
  219. lines.append("## 四、离线/异常设备明细")
  220. lines.append("")
  221. for gname in order:
  222. r = results[gname]
  223. if not r["offline_list"] and not r["unknown_list"]:
  224. continue
  225. lines.append(f"### {gname}(离线 {r['offline']} / 未知 {r['unknown']})")
  226. lines.append("")
  227. lines.append("| 设备名称 | 类型 | 分站 | 状态 | 原因 | 模拟 |")
  228. lines.append("|---|---|---|---|---|---|")
  229. for item in r["offline_list"] + r["unknown_list"]:
  230. state_cn = "离线" if item["state"] == "offline" else "未知"
  231. sim = "是" if item["testFlag"] == 1 else "否"
  232. lines.append(f"| {item['name']} | {item['strType']} | {item['stationName'] or '-'} | {state_cn} | {item['reason']} | {sim} |")
  233. lines.append("")
  234. lines.append("---")
  235. lines.append(f"*统计口径:分母为正常接入设备(status=1);在线率 = 在线 / (总数 - 未知);"
  236. f"其余类型在线率阈值 {threshold*100:.0f}%;主通风机离线 {fan_tol} 台即报警*")
  237. return "\n".join(lines)
  238. def main():
  239. parser = argparse.ArgumentParser(description="设备设施在线率统计日报生成器")
  240. parser.add_argument("--devices", required=True, help="设备明细 JSON(tf_mcp execute_sql_query 结果)")
  241. parser.add_argument("--fan", default=None, help="可选:fan 表开机状态 JSON")
  242. parser.add_argument("--aigate", default=None, help="可选:ai_gate 风门状态 JSON")
  243. parser.add_argument("--groups", default=None, help="可选:自定义分组 JSON {分组名:[deviceKind,...]}")
  244. parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="在线率阈值,默认0.90")
  245. parser.add_argument("--fan-tol", type=int, default=DEFAULT_FANMAIN_OFFLINE_TOL, help="主通风机容忍离线台数,默认0")
  246. parser.add_argument("--outdir", default=".", help="输出目录,默认当前目录")
  247. parser.add_argument("--date", default=None, help="日报日期,默认当天")
  248. args = parser.parse_args()
  249. devices = load_json(args.devices)
  250. fan_map = {}
  251. for row in load_json(args.fan):
  252. fan_map.setdefault(norm(row.get("strName")), row.get("bOn"))
  253. aigate_map = {}
  254. for row in load_json(args.aigate):
  255. aigate_map.setdefault(norm(row.get("name")), row.get("status"))
  256. groups = build_groups(load_json(args.groups) if args.groups else None)
  257. results = aggregate(devices, fan_map, aigate_map, groups, args.threshold, args.fan_tol)
  258. alarms = judge_alarms(results, args.threshold, args.fan_tol)
  259. total = {"total": 0, "online": 0, "offline": 0, "unknown": 0, "sim": 0}
  260. for r in results.values():
  261. total["total"] += r["total"]
  262. total["online"] += r["online"]
  263. total["offline"] += r["offline"]
  264. total["unknown"] += r["unknown"]
  265. total["sim"] += r["sim_cnt"]
  266. total["online_rate"] = round(total["online"] / (total["total"] - total["unknown"]), 4) \
  267. if (total["total"] - total["unknown"]) > 0 else None
  268. report_date = args.date or datetime.now().strftime("%Y-%m-%d")
  269. os.makedirs(args.outdir, exist_ok=True)
  270. md = render_markdown(report_date, results, alarms, args.threshold, args.fan_tol, total)
  271. with open(os.path.join(args.outdir, "report.md"), "w", encoding="utf-8") as f:
  272. f.write(md)
  273. report = {
  274. "report_date": report_date,
  275. "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  276. "summary": total,
  277. "alarms": alarms,
  278. "threshold": args.threshold,
  279. "fanmain_offline_tolerance": args.fan_tol,
  280. "groups": groups,
  281. "detail": results,
  282. }
  283. with open(os.path.join(args.outdir, "report.json"), "w", encoding="utf-8") as f:
  284. json.dump(report, f, ensure_ascii=False, indent=2)
  285. print(f"✅ 已生成日报:{os.path.join(args.outdir, 'report.md')} / report.json")
  286. for a in alarms:
  287. print(f" [{a['level']}] {a['message']}")
  288. if __name__ == "__main__":
  289. main()