|
|
@@ -0,0 +1,582 @@
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+"""
|
|
|
+通风设备与传感器运行日报生成脚本(vent-operation-daily-report skill 配套)
|
|
|
+
|
|
|
+直连通防管控平台 MCP 服务,完成:
|
|
|
+1. 拉取 7 类真实设备(isBusSup=0/NULL 且 status=1)清单
|
|
|
+2. 拉取前一天历史数据(list_ventanaly_monitor_data_days,按类型采样 skip 档位)
|
|
|
+3. 拉取前一天报警历史(get_alarm_log_history,不传 device_type)
|
|
|
+4. 历史缺失类型(主通风机/压差传感器/局扇部分参数/风窗部分参数)用实时快照补齐并注明
|
|
|
+5. 统计 max/min/avg、风门开闭次数、异常明细(时间/持续/是否解决)
|
|
|
+6. 渲染统一格式 Markdown 日报:写文件 + 打印全文(供 Agent 在对话栏转述)
|
|
|
+
|
|
|
+用法:
|
|
|
+ python vent_daily_report.py [--date 2026-08-17] [--url http://39.97.59.228:8071/mcp] [--outdir .]
|
|
|
+"""
|
|
|
+import argparse
|
|
|
+import asyncio
|
|
|
+import io
|
|
|
+import json
|
|
|
+import sys
|
|
|
+from datetime import datetime, timedelta
|
|
|
+
|
|
|
+try:
|
|
|
+ from fastmcp import Client
|
|
|
+except ImportError:
|
|
|
+ print("缺少依赖:pip install fastmcp", file=sys.stderr)
|
|
|
+ sys.exit(1)
|
|
|
+
|
|
|
+if hasattr(sys.stdout, "reconfigure"):
|
|
|
+ sys.stdout.reconfigure(encoding="utf-8")
|
|
|
+
|
|
|
+MCP_URL_DEFAULT = "http://39.97.59.228:8071/mcp"
|
|
|
+
|
|
|
+# 真实设备清单 SQL:7 类,isBusSup=0/NULL,status=1(剔除停用/模拟)
|
|
|
+SQL_DEVICES = (
|
|
|
+ "SELECT devicekind, strtype, CAST(id AS CHAR) AS deviceId, strname, strinstallpos "
|
|
|
+ "FROM deviceinfo "
|
|
|
+ "WHERE ((devicekind IN ('fanmain','fanlocal','gate','window','windrect') "
|
|
|
+ " AND (is_bus_sup = 0 OR is_bus_sup IS NULL)) "
|
|
|
+ " OR strtype IN ('modelsensor_multi','modelsensor_dp')) "
|
|
|
+ "AND status = 1 "
|
|
|
+ "ORDER BY devicekind, id"
|
|
|
+)
|
|
|
+
|
|
|
+# 各类型历史采样档位(实测:skip=5→1min/1441条,skip=6→10min/145条)
|
|
|
+SKIP_BY_KIND = {
|
|
|
+ "fanmain": 6, "fanlocal": 6, "window": 6, "windrect": 6,
|
|
|
+ "modelsensor_multi": 6, "modelsensor_dp": 6,
|
|
|
+ "gate": 5, # 风门开闭次数需更细采样(1min)
|
|
|
+}
|
|
|
+PAGE_SIZE = 100
|
|
|
+
|
|
|
+KIND_TITLE = {
|
|
|
+ "fanmain": "主通风机", "fanlocal": "局部通风机", "gate": "风门",
|
|
|
+ "window": "风窗", "windrect": "测风装置",
|
|
|
+ "modelsensor_multi": "多参数传感器", "modelsensor_dp": "压差传感器",
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+# ---------------- MCP 基础调用 ----------------
|
|
|
+
|
|
|
+async def call_json(client, tool, args, retries=2):
|
|
|
+ for i in range(retries + 1):
|
|
|
+ try:
|
|
|
+ r = await client.call_tool(tool, args)
|
|
|
+ txt = r.content[0].text
|
|
|
+ try:
|
|
|
+ return json.loads(txt)
|
|
|
+ except Exception:
|
|
|
+ return {"success": False, "message": txt[:200]}
|
|
|
+ except Exception as e:
|
|
|
+ if i == retries:
|
|
|
+ return {"success": False, "message": f"call failed: {e}"}
|
|
|
+ await asyncio.sleep(1.5 * (i + 1))
|
|
|
+
|
|
|
+
|
|
|
+async def fetch_history(client, strtype, device_id, begin, end, skip):
|
|
|
+ """分页拉取一台设备全天历史,返回 records 列表(可能为空)。"""
|
|
|
+ records, page = [], 1
|
|
|
+ while page <= 60: # 防御上限
|
|
|
+ d = await call_json(client, "list_ventanaly_monitor_data_days", {
|
|
|
+ "strtype": strtype, "gdeviceids": str(device_id), "device_num": "1",
|
|
|
+ "ttime_begin": begin, "ttime_end": end,
|
|
|
+ "skip": skip, "page_no": page, "page_size": PAGE_SIZE})
|
|
|
+ dl = ((d or {}).get("result") or {}).get("datalist") or {}
|
|
|
+ recs = dl.get("records") or []
|
|
|
+ records.extend(recs)
|
|
|
+ total = dl.get("total") or 0
|
|
|
+ if not recs or len(records) >= total:
|
|
|
+ break
|
|
|
+ page += 1
|
|
|
+ return records
|
|
|
+
|
|
|
+
|
|
|
+async def fetch_realtime(client, device_id):
|
|
|
+ d = await call_json(client, "query_device_data_by_id",
|
|
|
+ {"device_id": str(device_id), "page_size": 1})
|
|
|
+ try:
|
|
|
+ dev = ((d or {}).get("result") or {}).get("devices")[0]
|
|
|
+ return dev or {}
|
|
|
+ except Exception:
|
|
|
+ return {}
|
|
|
+
|
|
|
+
|
|
|
+async def fetch_alarms(client, begin, end):
|
|
|
+ d = await call_json(client, "get_alarm_log_history",
|
|
|
+ {"start_time": begin, "end_time": end})
|
|
|
+ r = (d or {}).get("result")
|
|
|
+ return r if isinstance(r, list) else []
|
|
|
+
|
|
|
+
|
|
|
+# ---------------- 统计工具 ----------------
|
|
|
+
|
|
|
+def to_num(v):
|
|
|
+ if v is None:
|
|
|
+ return None
|
|
|
+ s = str(v).strip()
|
|
|
+ if s in ("", "-", "null", "None"):
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ return float(s)
|
|
|
+ except ValueError:
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def mma(vals):
|
|
|
+ """max/min/avg,忽略 None。"""
|
|
|
+ xs = [v for v in vals if v is not None]
|
|
|
+ if not xs:
|
|
|
+ return None
|
|
|
+ return max(xs), min(xs), sum(xs) / len(xs)
|
|
|
+
|
|
|
+
|
|
|
+def fmt_mma3(m, dec=2):
|
|
|
+ """返回 [最大值, 最小值, 平均值] 三个显示串,无数据时全为 —。"""
|
|
|
+ if not m:
|
|
|
+ return ["—", "—", "—"]
|
|
|
+ mx, mn, av = m
|
|
|
+ return [f"{mx:.{dec}f}", f"{mn:.{dec}f}", f"{av:.{dec}f}"]
|
|
|
+
|
|
|
+
|
|
|
+def parse_dt(s):
|
|
|
+ if not s:
|
|
|
+ return None
|
|
|
+ for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
|
|
|
+ try:
|
|
|
+ return datetime.strptime(str(s)[:19], fmt)
|
|
|
+ except ValueError:
|
|
|
+ continue
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def dur_str(sec):
|
|
|
+ if sec is None:
|
|
|
+ return "—"
|
|
|
+ sec = int(sec)
|
|
|
+ h, m = sec // 3600, sec % 3600 // 60
|
|
|
+ return f"{h}小时{m:02d}分" if h else f"{m}分钟"
|
|
|
+
|
|
|
+
|
|
|
+def gate_state(v):
|
|
|
+ """风门开闭态归一化:开=1 关=0 未知=None(历史 readData 存在 文本/数字 混排)。"""
|
|
|
+ s = str(v).strip()
|
|
|
+ if s in ("1", "打开", "开"):
|
|
|
+ return 1
|
|
|
+ if s in ("0", "关闭", "关"):
|
|
|
+ return 0
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def fan_m3(v):
|
|
|
+ """主通风机风量单位归一:值过小视为 m3/s 转 m3/min(用户规则,保留整数)。"""
|
|
|
+ n = to_num(v)
|
|
|
+ if n is None:
|
|
|
+ return None, False
|
|
|
+ if n < 500: # 经验阈值:回风井风量 m3/min 通常远大于 500
|
|
|
+ return round(n * 60), True
|
|
|
+ return round(n), False
|
|
|
+
|
|
|
+
|
|
|
+# ---------------- 各类型行构建 ----------------
|
|
|
+
|
|
|
+def row_fanmain(dev, hist, rt, alarms):
|
|
|
+ rd = (rt or {}).get("realTimeData") or {}
|
|
|
+ main = rd.get("Fan1StartStatus_str") or "—"
|
|
|
+ backup = rd.get("Fan2StartStatus_str") or "—"
|
|
|
+ run = "主机运行" if main == "运行" else (f"备机运行(Fan2={backup})" if backup == "运行" else "无运行机")
|
|
|
+ m3, _converted = fan_m3(rd.get("Fan1_m3") if main == "运行" else rd.get("Fan2_m3"))
|
|
|
+ ws = to_num(rd.get("Fan1_WindSpeed") if main == "运行" else rd.get("Fan2_WindSpeed"))
|
|
|
+ pre = to_num(rd.get("Fan1_FanPre") if main == "运行" else rd.get("Fan2_FanPre"))
|
|
|
+ cur = to_num(rd.get("Fan1_MOT1_Current") if main == "运行" else rd.get("Fan2_MOT1_Current"))
|
|
|
+ vol = to_num(rd.get("Fan1_MOT1_DCVoltage") if main == "运行" else rd.get("Fan2_MOT1_DCVoltage"))
|
|
|
+ freq = to_num(rd.get("Fan1_VFD1_OutputSpeed1") or rd.get("Fan1_VFD1_OutputSpeed"))
|
|
|
+ return {
|
|
|
+ "name": dev["strname"], "run": run,
|
|
|
+ "cells": [run, ws if ws is not None else "—", m3 if m3 is not None else "—",
|
|
|
+ pre if pre is not None else "—", cur if cur is not None else "—",
|
|
|
+ vol if vol is not None else "—",
|
|
|
+ f"{freq}" if freq is not None else "—"],
|
|
|
+ "alarms": alarms, "source": "实时快照(历史明细接口无记录)",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def row_fanlocal(dev, hist, rt, alarms):
|
|
|
+ rd = (rt or {}).get("realTimeData") or {}
|
|
|
+ main = rd.get("Fan1StartStatus_str") or "—"
|
|
|
+ run = "主机运行" if main == "运行" else ("备机运行" if rd.get("Fan2StartStatus_str") == "运行" else "无运行机")
|
|
|
+ ws = to_num(rd.get("windSpeed1"))
|
|
|
+ freq = to_num(rd.get("Fan1FreqHz"))
|
|
|
+ cur = to_num(rd.get("Fan1_electric_current"))
|
|
|
+ vol = to_num(rd.get("Fan1_output_voltage"))
|
|
|
+ inlet = to_num(rd.get("inletAirVolume_merge"))
|
|
|
+ outlet = to_num(rd.get("ductOutletAirVolume_merge"))
|
|
|
+ return {
|
|
|
+ "name": dev["strname"], "run": run,
|
|
|
+ "cells": [run,
|
|
|
+ f"{inlet:.0f}†" if inlet is not None else "—",
|
|
|
+ f"{outlet:.0f}†" if outlet is not None else "—",
|
|
|
+ f"{ws}" if ws is not None else "—",
|
|
|
+ f"{freq}" if freq is not None else "—",
|
|
|
+ f"{cur}" if cur is not None else "—",
|
|
|
+ f"{vol}" if vol is not None else "—"],
|
|
|
+ "alarms": alarms,
|
|
|
+ "source": "各参数为实时快照;标†的进出口风量跨日恒定(不随频率变化),疑为静态标定值待现场核实;"
|
|
|
+ "历史仅存 windQuantity1 且该测点三重定义冲突(单位/公式错乱),已弃用",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def row_gate(dev, hist, rt, alarms):
|
|
|
+ front = back = 0
|
|
|
+ prev_f = prev_b = None
|
|
|
+ for r in hist:
|
|
|
+ rd = r.get("readData") or {}
|
|
|
+ f, b = gate_state(rd.get("frontGateOpen")), gate_state(rd.get("rearGateOpen"))
|
|
|
+ if f is not None and prev_f == 0 and f == 1:
|
|
|
+ front += 1
|
|
|
+ if b is not None and prev_b == 0 and b == 1:
|
|
|
+ back += 1
|
|
|
+ if f is not None:
|
|
|
+ prev_f = f
|
|
|
+ if b is not None:
|
|
|
+ prev_b = b
|
|
|
+ open_cnt = f"{front} / {back}"
|
|
|
+ return {
|
|
|
+ "name": dev["strname"],
|
|
|
+ "cells": [open_cnt if hist else "无数据", len(hist)],
|
|
|
+ "alarms": alarms,
|
|
|
+ "source": f"开闭次数基于{len(hist)}个采样点(1分钟粒度)上升沿统计" if hist else "前一天无历史数据",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def row_window(dev, hist, rt, alarms):
|
|
|
+ rd = (rt or {}).get("realTimeData") or {}
|
|
|
+ f3 = mma([to_num(r.get("readData", {}).get("fWindowM3")) for r in hist])
|
|
|
+ r3 = mma([to_num(r.get("readData", {}).get("rWindowM3")) for r in hist])
|
|
|
+ ang = to_num(rd.get("OpenDegree"))
|
|
|
+ dp = to_num(rd.get("frontRearDP"))
|
|
|
+ wv = to_num(rd.get("wvel1"))
|
|
|
+ return {
|
|
|
+ "name": dev["strname"],
|
|
|
+ "cells": ([f"{ang}" if ang is not None else "—"]
|
|
|
+ + fmt_mma3(f3, 1) + fmt_mma3(r3, 1)
|
|
|
+ + [f"{wv}" if wv is not None else "—",
|
|
|
+ f"{dp}" if dp is not None else "—"]),
|
|
|
+ "alarms": alarms,
|
|
|
+ "source": "过风量最大/最小/平均为历史统计;角度/风速/两侧压差为实时快照" if hist else "无历史数据,角度等为实时快照",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def row_windrect(dev, hist, rt, alarms):
|
|
|
+ rd = (rt or {}).get("realTimeData") or {}
|
|
|
+ va = mma([to_num(r.get("readData", {}).get("va")) for r in hist])
|
|
|
+ m3 = mma([to_num(r.get("readData", {}).get("m3")) for r in hist])
|
|
|
+ fwd = rd.get("forward_str") or "—"
|
|
|
+ return {
|
|
|
+ "name": dev["strname"],
|
|
|
+ "cells": fmt_mma3(va) + fmt_mma3(m3, 0) + [fwd],
|
|
|
+ "alarms": alarms,
|
|
|
+ "source": "风速/风量的最大/最小/平均为历史统计;风向为实时快照" if hist else "无历史数据",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+# 多参数传感器各参数物理合理域(越界标†:映射/量纲异常或平台冻结)
|
|
|
+MULTI_VALID = {"pa": (30000, 110000), "temperature": (-50, 80),
|
|
|
+ "humidity": (0, 100), "density": (0.3, 3.0)}
|
|
|
+
|
|
|
+
|
|
|
+def row_multi(dev, hist, rt, alarms):
|
|
|
+ fields = [("pa", 0), ("temperature", 1), ("humidity", 1), ("density", 3)]
|
|
|
+ cells, has_bad = [], False
|
|
|
+ for code, dec in fields:
|
|
|
+ m = mma([to_num(r.get("readData", {}).get(code)) for r in hist])
|
|
|
+ triple = fmt_mma3(m, dec)
|
|
|
+ lo, hi = MULTI_VALID[code]
|
|
|
+ if m and not (lo <= m[1] and m[0] <= hi):
|
|
|
+ triple = [v + "†" for v in triple]
|
|
|
+ has_bad = True
|
|
|
+ cells += triple
|
|
|
+ if not hist:
|
|
|
+ src = "无历史数据(设备新接入或未上报)"
|
|
|
+ elif has_bad:
|
|
|
+ src = "各参数为历史统计;标†数值超出物理合理域(测点映射/量纲异常或平台故障期冻结值),仅供参考"
|
|
|
+ else:
|
|
|
+ src = "各参数的最大/最小/平均为历史统计"
|
|
|
+ return {
|
|
|
+ "name": dev["strname"],
|
|
|
+ "cells": cells,
|
|
|
+ "alarms": alarms,
|
|
|
+ "source": src,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def row_dp(dev, hist, rt, alarms):
|
|
|
+ rd = (rt or {}).get("realTimeData") or {}
|
|
|
+ kpa = to_num(rd.get("kpa"))
|
|
|
+ return {
|
|
|
+ "name": dev["strname"],
|
|
|
+ "cells": [f"{kpa}" if kpa is not None else "—", len(hist)],
|
|
|
+ "alarms": alarms,
|
|
|
+ "source": "实时快照(历史明细接口无记录)",
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+TABLE_SPECS = {
|
|
|
+ "fanmain": {
|
|
|
+ "headers": ["运行机", "风速(m/s)", "风量(m³/min)", "负压", "电流(A)", "电压(V)", "频率/转速"],
|
|
|
+ "row": row_fanmain,
|
|
|
+ },
|
|
|
+ "fanlocal": {
|
|
|
+ "headers": ["运行机", "进口风量(m³/min)*", "出口风量(m³/min)*", "风速(m/s)*",
|
|
|
+ "频率(Hz)*", "电流(A)*", "电压(V)*"],
|
|
|
+ "row": row_fanlocal,
|
|
|
+ },
|
|
|
+ "gate": {
|
|
|
+ "headers": ["开闭次数(前门/后门)", "采样点数"],
|
|
|
+ "row": row_gate,
|
|
|
+ },
|
|
|
+ "window": {
|
|
|
+ "headers": ["角度(°)*",
|
|
|
+ "前窗过风量(m³/min)最大", "前窗过风量最小", "前窗过风量平均",
|
|
|
+ "后窗过风量(m³/min)最大", "后窗过风量最小", "后窗过风量平均",
|
|
|
+ "风速(m/s)*", "两侧压差(Pa)*"],
|
|
|
+ "row": row_window,
|
|
|
+ },
|
|
|
+ "windrect": {
|
|
|
+ "headers": ["风速(m/s)最大", "风速最小", "风速平均",
|
|
|
+ "风量(m³/min)最大", "风量最小", "风量平均", "风向*"],
|
|
|
+ "row": row_windrect,
|
|
|
+ },
|
|
|
+ "modelsensor_multi": {
|
|
|
+ "headers": ["大气压最大", "大气压最小", "大气压平均",
|
|
|
+ "温度(℃)最大", "温度最小", "温度平均",
|
|
|
+ "湿度(%)最大", "湿度最小", "湿度平均",
|
|
|
+ "密度最大", "密度最小", "密度平均"],
|
|
|
+ "row": row_multi,
|
|
|
+ },
|
|
|
+ "modelsensor_dp": {
|
|
|
+ "headers": ["压差(kPa)当前*", "历史记录数"],
|
|
|
+ "row": row_dp,
|
|
|
+ },
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+# ---------------- 报警处理 ----------------
|
|
|
+
|
|
|
+def summarize_alarms(alarm_rows, day_begin, day_end):
|
|
|
+ """按设备聚合报警,同设备同类型合并为一条(次数/首末时间/最长持续/最新状态)。
|
|
|
+
|
|
|
+ 时长口径:endtime 为空时截断到统计日结束(次日 00:00),避免跨天虚增;
|
|
|
+ isok=0 且无 endtime → 未解决(持续中);isok=-1 → 平台已恢复但未闭环。
|
|
|
+ """
|
|
|
+ raw = {}
|
|
|
+ for a in alarm_rows:
|
|
|
+ did = str(a.get("deviceID") or a.get("deviceId") or "")
|
|
|
+ if not did:
|
|
|
+ continue
|
|
|
+ st = parse_dt(a.get("starttime"))
|
|
|
+ if st is None:
|
|
|
+ continue
|
|
|
+ et = parse_dt(a.get("endtime"))
|
|
|
+ if et is not None:
|
|
|
+ dur = (et - st).total_seconds()
|
|
|
+ state = "已解决"
|
|
|
+ elif a.get("isok") == 0:
|
|
|
+ dur = (min(day_end, datetime.now()) - st).total_seconds()
|
|
|
+ state = "未解决(持续中)"
|
|
|
+ else:
|
|
|
+ dur = (day_end - st).total_seconds()
|
|
|
+ state = "已恢复(未闭环)"
|
|
|
+ desc = a.get("warDescrip") or a.get("valuename") or "异常"
|
|
|
+ raw.setdefault((did, desc), []).append({"st": st, "dur": dur, "state": state})
|
|
|
+
|
|
|
+ out = {}
|
|
|
+ for (did, desc), lst in raw.items():
|
|
|
+ lst.sort(key=lambda x: x["st"])
|
|
|
+ first, last = lst[0], lst[-1]
|
|
|
+ out.setdefault(did, []).append({
|
|
|
+ "desc": desc,
|
|
|
+ "count": len(lst),
|
|
|
+ "first": first["st"].strftime("%H:%M:%S"),
|
|
|
+ "last": last["st"].strftime("%H:%M:%S"),
|
|
|
+ "dur": dur_str(first["dur"]),
|
|
|
+ "max_dur": dur_str(max(l["dur"] for l in lst)),
|
|
|
+ "state": last["state"],
|
|
|
+ "full_start": first["st"].strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
+ "full_last": last["st"].strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
+ })
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def alarm_brief(items):
|
|
|
+ if not items:
|
|
|
+ return "无"
|
|
|
+ parts = []
|
|
|
+ for i in items[:3]:
|
|
|
+ if i.get("count", 1) > 1:
|
|
|
+ parts.append(f"{i['desc']}×{i['count']}次({i['first']}~{i['last']},最长{i['max_dur']},{i['state']})")
|
|
|
+ else:
|
|
|
+ parts.append(f"{i['desc']}({i['first']}起,{i['dur']},{i['state']})")
|
|
|
+ if len(items) > 3:
|
|
|
+ parts.append(f"等{len(items)}类")
|
|
|
+ return ";".join(parts)
|
|
|
+
|
|
|
+
|
|
|
+# ---------------- 主流程 ----------------
|
|
|
+
|
|
|
+async def run(args):
|
|
|
+ day = datetime.strptime(args.date, "%Y-%m-%d")
|
|
|
+ begin = day.strftime("%Y-%m-%d 00:00:00")
|
|
|
+ end = (day + timedelta(days=1)).strftime("%Y-%m-%d 00:00:00")
|
|
|
+
|
|
|
+ print(f"[1/5] 连接 MCP {args.url} ...")
|
|
|
+ async with Client(args.url) as client:
|
|
|
+ print(f"[2/5] 拉取真实设备清单(统计日 {args.date})...")
|
|
|
+ d = await call_json(client, "execute_sql_query", {"sql_query": SQL_DEVICES})
|
|
|
+ devices = d.get("result") or []
|
|
|
+ if not devices:
|
|
|
+ print("未查到真实设备,退出。原始返回:", json.dumps(d, ensure_ascii=False)[:500])
|
|
|
+ return
|
|
|
+ print(f" 共 {len(devices)} 台真实设备")
|
|
|
+
|
|
|
+ print("[3/5] 拉取前一天报警历史(全量,按设备聚合)...")
|
|
|
+ alarm_rows = await fetch_alarms(client, begin, end)
|
|
|
+ alarms_by_dev = summarize_alarms(alarm_rows, day, day + timedelta(days=1))
|
|
|
+ print(f" 报警记录 {len(alarm_rows)} 条,涉及设备 {len(alarms_by_dev)} 台")
|
|
|
+
|
|
|
+ sections = []
|
|
|
+ stats = {"total": len(devices), "no_data": 0, "abnormal": 0}
|
|
|
+ health = {"hist": False, "rt": False}
|
|
|
+ kind_order = ["fanmain", "fanlocal", "gate", "window", "windrect",
|
|
|
+ "modelsensor_multi", "modelsensor_dp"]
|
|
|
+ for kind in kind_order:
|
|
|
+ devs = [x for x in devices
|
|
|
+ if x.get("devicekind") == kind or (kind.startswith("modelsensor") and x.get("strtype") == kind)]
|
|
|
+ if not devs:
|
|
|
+ continue
|
|
|
+ spec = TABLE_SPECS[kind]
|
|
|
+ rows, notes = [], []
|
|
|
+ print(f"[4/5] {KIND_TITLE[kind]} {len(devs)} 台:拉历史+实时...")
|
|
|
+ for dev in devs:
|
|
|
+ did = dev["deviceId"]
|
|
|
+ hist = await fetch_history(client, dev["strtype"], did, begin, end,
|
|
|
+ SKIP_BY_KIND.get(dev["devicekind"], 6))
|
|
|
+ need_rt = kind in ("fanmain", "fanlocal", "window", "windrect", "modelsensor_dp")
|
|
|
+ rt = await fetch_realtime(client, did) if need_rt else {}
|
|
|
+ if hist:
|
|
|
+ health["hist"] = True
|
|
|
+ rd = (rt or {}).get("realTimeData") or {}
|
|
|
+ if rd.get("netStatus") not in (None, "") or rd.get("readTime"):
|
|
|
+ health["rt"] = True
|
|
|
+ if not hist:
|
|
|
+ stats["no_data"] += 1
|
|
|
+ alm = alarms_by_dev.get(did, [])
|
|
|
+ if alm:
|
|
|
+ stats["abnormal"] += 1
|
|
|
+ r = spec["row"](dev, hist, rt, alm)
|
|
|
+ r.setdefault("cells", [])
|
|
|
+ rows.append(r)
|
|
|
+ if not hist:
|
|
|
+ notes.append(f"{dev['strname']}:{r.get('source') or '前一天无历史数据'}")
|
|
|
+ # 渲染该类型表格
|
|
|
+ lines = [f"## {KIND_TITLE[kind]}({len(devs)} 台)", ""]
|
|
|
+ header = ["设备名称"] + spec["headers"] + ["异常摘要"]
|
|
|
+ lines.append("| " + " | ".join(header) + " |")
|
|
|
+ lines.append("|" + "---|" * len(header))
|
|
|
+ for r in rows:
|
|
|
+ cells = [str(c) for c in r["cells"]]
|
|
|
+ lines.append("| " + " | ".join([r["name"]] + cells + [alarm_brief(r["alarms"])]) + " |")
|
|
|
+ lines.append("")
|
|
|
+ src = next((r["source"] for r in rows if r.get("source")), "")
|
|
|
+ if src:
|
|
|
+ lines.append(f"> 取数说明:{src}")
|
|
|
+ if notes:
|
|
|
+ lines.append("> 无历史数据:" + ";".join(notes))
|
|
|
+ lines.append("")
|
|
|
+ sections.append("\n".join(lines))
|
|
|
+
|
|
|
+ print("[5/5] 渲染日报 ...")
|
|
|
+ md = render(args.date, stats, sections, alarms_by_dev, devices, health)
|
|
|
+ out_file = f"{args.outdir}/vent_operation_daily_report_{args.date}.md"
|
|
|
+ with io.open(out_file, "w", encoding="utf-8") as f:
|
|
|
+ f.write(md)
|
|
|
+ print(f"\n日报已保存:{out_file}\n")
|
|
|
+ print("=" * 30 + " 日报全文 " + "=" * 30)
|
|
|
+ print(md)
|
|
|
+
|
|
|
+
|
|
|
+def render(date, stats, sections, alarms_by_dev, devices, health=None):
|
|
|
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
+ cn_num = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"]
|
|
|
+ L = []
|
|
|
+ L.append(f"# 通风设备与传感器运行日报({date})")
|
|
|
+ L.append("")
|
|
|
+ L.append(f"> 数据来源:通防管控平台 MCP(vent 库 + 历史监测接口) ")
|
|
|
+ L.append(f"> 统计时段:{date} 00:00:00 ~ 23:59:59 | 真实设备口径:isBusSup=0/NULL 且 status=1 ")
|
|
|
+ L.append(f"> 历史采样:10 分钟粒度(风门开闭 1 分钟粒度)| 生成时间:{now}")
|
|
|
+ L.append("")
|
|
|
+ # 数据源健康检查:历史与实时接口均无有效返回 → 平台数据服务故障,显式警告防误读
|
|
|
+ if health is not None and not health.get("hist") and not health.get("rt"):
|
|
|
+ L.append("## ⚠️ 数据源异常警告")
|
|
|
+ L.append("")
|
|
|
+ L.append("**历史数据接口与实时数据接口均无有效返回**(全部设备历史记录为空、实时快照为空)。")
|
|
|
+ L.append("这通常是平台数据服务(历史库/实时缓存)故障或维护所致,**并非所有设备同时离线**。")
|
|
|
+ L.append("本日报的数据内容不可用,请确认平台数据服务恢复后重新导出(重新运行本命令即可)。")
|
|
|
+ L.append("")
|
|
|
+ L.append("## 一、总体概况")
|
|
|
+ L.append("")
|
|
|
+ L.append(f"- 真实设备总数:**{stats['total']}** 台")
|
|
|
+ L.append(f"- 前一天无历史数据:**{stats['no_data']}** 台(表中已标注原因)")
|
|
|
+ L.append(f"- 存在异常记录:**{stats['abnormal']}** 台(含网络断开类)")
|
|
|
+ L.append("")
|
|
|
+ cn = 1
|
|
|
+ for s in sections:
|
|
|
+ cn += 1
|
|
|
+ L.append(s.replace("## ", f"## {cn_num[cn - 1]}、", 1))
|
|
|
+ # 异常明细汇总(仅统计清单内 7 类真实设备)
|
|
|
+ cn += 1
|
|
|
+ L.append(f"## {cn_num[cn - 1]}、异常明细汇总")
|
|
|
+ L.append("")
|
|
|
+ L.append("| 设备 | 异常内容 | 次数 | 首次开始 | 最后开始 | 最长持续 | 是否解决(最新) |")
|
|
|
+ L.append("|---|---|---|---|---|---|---|")
|
|
|
+ name_by_id = {d["deviceId"]: d["strname"] for d in devices}
|
|
|
+ n = 0
|
|
|
+ for did, items in alarms_by_dev.items():
|
|
|
+ if did not in name_by_id:
|
|
|
+ continue
|
|
|
+ for i in items:
|
|
|
+ if i.get("count", 1) > 1:
|
|
|
+ L.append(f"| {name_by_id[did]} | {i['desc']} | {i['count']} | {i['full_start']} | {i['full_last']} | {i['max_dur']} | {i['state']} |")
|
|
|
+ else:
|
|
|
+ L.append(f"| {name_by_id[did]} | {i['desc']} | 1 | {i['full_start']} | — | {i['dur']} | {i['state']} |")
|
|
|
+ n += 1
|
|
|
+ if n == 0:
|
|
|
+ L.append("| — | 无异常记录 | — | — | — | — | — |")
|
|
|
+ L.append("")
|
|
|
+ cn += 1
|
|
|
+ L.append(f"## {cn_num[cn - 1]}、数据说明")
|
|
|
+ L.append("")
|
|
|
+ L.append("1. 最大值、最小值、平均值分三列显示;标注 * 的列为实时快照值(历史库无该参数)。")
|
|
|
+ L.append("2. 主通风机与压差传感器:历史明细接口当前无记录体(平台已知问题),数值列为实时快照。")
|
|
|
+ L.append("3. 主通风机风量:值 <500 时按 m³/s 换算为 m³/min(保留整数);电压为直流母线电压。")
|
|
|
+ L.append("4. 风门开闭次数:基于 1 分钟采样上升沿统计,短于采样间隔的开闭动作可能漏计。")
|
|
|
+ L.append("5. 异常口径:平台报警历史全量(含网络断开类),同设备同类型合并为一行;endtime 为空且 isok=0")
|
|
|
+ L.append(" 视为未解决,isok=-1 视为已恢复(平台未闭环),时长统一截断至统计日结束。")
|
|
|
+ L.append("6. 局部通风机/风窗的电流电压、角度压差等参数历史库未存储,以实时快照呈现当前值;")
|
|
|
+ L.append(" 标†数值为已知可疑项:局扇进出口风量疑为静态标定值(跨日恒定不随频率变化)、风窗风速")
|
|
|
+ L.append(" 测点(wvel1)量纲异常、多参数传感器部分测点数值超出物理合理域(映射/量纲错误或平台故障期冻结),均仅供参考。")
|
|
|
+ L.append("")
|
|
|
+ return "\n".join(L)
|
|
|
+
|
|
|
+
|
|
|
+def main():
|
|
|
+ ap = argparse.ArgumentParser()
|
|
|
+ ap.add_argument("--date", default=(datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d"))
|
|
|
+ ap.add_argument("--url", default=MCP_URL_DEFAULT)
|
|
|
+ ap.add_argument("--outdir", default=".")
|
|
|
+ args = ap.parse_args()
|
|
|
+ asyncio.run(run(args))
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|