monitoring.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. """安全监控风量数据源适配器(工具 2 的数据侧)。
  2. 本轮没有真实监控系统对接,因此做成**可插拔适配器**:默认实现读本地文件
  3. (`<AIRFLOW_MONITORING_DIR>/<mine_id>.json`),接真实系统时只需替换
  4. `get_source()` 返回的实现,工具与 Skill 都不用改。
  5. 无数据源时返回 `data_insufficient` 并转人工复核——绝不用测风报表顶替监控数据
  6. (需求 §2.3 工具 2 要求 4)。
  7. """
  8. from __future__ import annotations
  9. import json
  10. from bisect import bisect_left
  11. from pathlib import Path
  12. from typing import Any, Iterable, Mapping, Protocol
  13. import config
  14. from algorithms.airflow_review.contracts import parse_time
  15. # 采样与异常判定阈值:恒值、突跳、量程,都可配而不写死在逻辑里
  16. _STUCK_REPEATS = 6
  17. _JUMP_PERCENT = 30.0
  18. _JUMP_ABSOLUTE = 100.0
  19. _RANGE_MIN = 0.0
  20. _RANGE_MAX = 20000.0
  21. _GAP_FACTOR = 3.0
  22. class MonitoringSource(Protocol):
  23. """监控数据源协议:接真实系统时实现 query 即可。"""
  24. def query(
  25. self,
  26. *,
  27. mine_id: str,
  28. target_ids: Iterable[str],
  29. sensor_ids: Iterable[str],
  30. start_time: str,
  31. end_time: str,
  32. aggregation: str,
  33. ) -> dict[str, Any]: ...
  34. class LocalFileMonitoringSource:
  35. """本地文件数据源:每个矿一个 JSON,字段见 data/monitoring/README.md。"""
  36. def __init__(self, directory: str | Path | None = None) -> None:
  37. self._directory = Path(directory) if directory else config.resolve_path(config.AIRFLOW_MONITORING_DIR)
  38. def path_for(self, mine_id: str) -> Path:
  39. return self._directory / f"{mine_id}.json"
  40. def query(
  41. self,
  42. *,
  43. mine_id: str,
  44. target_ids: Iterable[str] = (),
  45. sensor_ids: Iterable[str] = (),
  46. start_time: str = "",
  47. end_time: str = "",
  48. aggregation: str = "day",
  49. ) -> dict[str, Any]:
  50. path = self.path_for(mine_id)
  51. if not path.is_file():
  52. return {
  53. "status": "data_insufficient",
  54. "reason": f"未接入 {mine_id} 的安全监控数据源(缺少 {path.name})",
  55. "series": [],
  56. "data_quality": {"missing_rate": None, "offline_periods": []},
  57. "manual_review_required": True,
  58. "note": "安全监控数据不可用;连续监测与交叉校核结论必须转人工复核,"
  59. "不得用测风报表代替监控数据得出结论",
  60. }
  61. try:
  62. payload = json.loads(path.read_text(encoding="utf-8"))
  63. except json.JSONDecodeError as exc:
  64. return {"status": "error", "error": f"监控数据源不是合法 JSON:{exc.msg}", "series": []}
  65. wanted_targets = {str(item) for item in target_ids if item}
  66. wanted_sensors = {str(item) for item in sensor_ids if item}
  67. start, end = parse_time(start_time), parse_time(end_time)
  68. series: list[dict[str, Any]] = []
  69. missing_total = 0
  70. online_total = 0
  71. offline: list[dict[str, Any]] = []
  72. for raw in payload.get("series") or []:
  73. if wanted_targets and str(raw.get("target_id")) not in wanted_targets:
  74. continue
  75. if wanted_sensors and str(raw.get("sensor_id")) not in wanted_sensors:
  76. continue
  77. samples = [item for item in (raw.get("samples") or []) if _in_range(item, start, end)]
  78. stats, quality = _aggregate(samples, aggregation, float(raw.get("interval_minutes") or 1.0))
  79. missing_total += quality["missing_slots"]
  80. online_total += quality["expected_slots"]
  81. offline.extend(quality["offline_periods"])
  82. series.append({
  83. "sensor_id": raw.get("sensor_id"),
  84. "target_id": raw.get("target_id"),
  85. "target_type": raw.get("target_type", ""),
  86. "aggregation": aggregation,
  87. "statistics": stats,
  88. "sensor_status": quality["sensor_status"],
  89. "anomalies": quality["anomalies"],
  90. "evidence_ids": list(raw.get("evidence_ids") or []),
  91. })
  92. data_quality = {
  93. "series_count": len(series),
  94. "missing_rate": round(missing_total / online_total, 4) if online_total else None,
  95. "offline_periods": offline,
  96. "source_path": str(path),
  97. }
  98. status = "ok" if series else "data_insufficient"
  99. result: dict[str, Any] = {
  100. "status": status,
  101. "mine_id": mine_id,
  102. "window": {"start": start_time, "end": end_time, "aggregation": aggregation},
  103. "series": series,
  104. "data_quality": data_quality,
  105. }
  106. if status == "data_insufficient":
  107. result["reason"] = "查询窗口内没有匹配的监控序列"
  108. result["manual_review_required"] = True
  109. return result
  110. def get_source() -> MonitoringSource:
  111. """返回当前配置的监控数据源;接真实系统时在这里换实现。"""
  112. return LocalFileMonitoringSource()
  113. def _in_range(sample: Mapping[str, Any], start: Any, end: Any) -> bool:
  114. moment = parse_time(sample.get("at"))
  115. if moment is None:
  116. return False
  117. if start and moment < start:
  118. return False
  119. if end and moment > end:
  120. return False
  121. return True
  122. def _aggregate(
  123. samples: list[Mapping[str, Any]],
  124. aggregation: str,
  125. interval_minutes: float,
  126. ) -> tuple[dict[str, Any], dict[str, Any]]:
  127. """按 point/hour/shift/day/month 聚合,并标记断线、恒值、突跳与量程异常。"""
  128. points: list[tuple[Any, float]] = []
  129. out_of_range: list[dict[str, Any]] = []
  130. for sample in samples:
  131. moment = parse_time(sample.get("at"))
  132. value = sample.get("airflow_m3_min")
  133. if moment is None or not isinstance(value, (int, float)):
  134. continue
  135. numeric = float(value)
  136. if numeric < _RANGE_MIN or numeric > _RANGE_MAX:
  137. out_of_range.append({"at": sample.get("at"), "value": numeric})
  138. continue
  139. points.append((moment, numeric))
  140. points.sort(key=lambda item: item[0])
  141. buckets = _bucketize(points, aggregation)
  142. statistics: dict[str, Any] = {"sample_count": len(points), "bucket_count": len(buckets)}
  143. # 统计量按**全体样本**给出(最小/最大是全局极值),分桶明细另列,避免把
  144. # "各桶最小值的平均"当成最小值——这种口径会让断线低谷被平均掉。
  145. if points:
  146. values = sorted(value for _, value in points)
  147. statistics.update({
  148. "mean_m3_min": round(sum(values) / len(values), 4),
  149. "min_m3_min": round(values[0], 4),
  150. "max_m3_min": round(values[-1], 4),
  151. "p50_m3_min": round(_percentile(values, 0.5), 4),
  152. "p95_m3_min": round(_percentile(values, 0.95), 4),
  153. })
  154. if buckets:
  155. statistics["buckets"] = buckets
  156. statistics["first_bucket"] = {key: buckets[0][key] for key in ("start", "end", "mean_m3_min")}
  157. statistics["last_bucket"] = {key: buckets[-1][key] for key in ("start", "end", "mean_m3_min")}
  158. if points:
  159. span_minutes = (points[-1][0] - points[0][0]).total_seconds() / 60.0
  160. expected_slots = int(span_minutes / interval_minutes) + 1 if interval_minutes > 0 else len(points)
  161. else:
  162. expected_slots = 0
  163. missing_slots = max(expected_slots - len(points), 0)
  164. statistics["valid_sample_rate"] = round(len(points) / expected_slots, 4) if expected_slots else 0.0
  165. anomalies: list[dict[str, Any]] = []
  166. offline = _offline_periods(points, interval_minutes)
  167. for period in offline:
  168. anomalies.append({"kind": "断线", **period})
  169. anomalies.extend(_stuck_periods(points))
  170. anomalies.extend(_jumps(points))
  171. for sample in out_of_range:
  172. anomalies.append({"kind": "量程异常", **sample})
  173. kinds = {anomaly["kind"] for anomaly in anomalies}
  174. if points and "断线" in kinds and len(offline) >= 2:
  175. sensor_status = "offline"
  176. elif kinds & {"恒值", "突跳", "量程异常"}:
  177. sensor_status = "abnormal"
  178. elif not points:
  179. sensor_status = "no_data"
  180. else:
  181. sensor_status = "normal"
  182. return statistics, {
  183. "expected_slots": expected_slots,
  184. "missing_slots": missing_slots,
  185. "offline_periods": offline,
  186. "anomalies": anomalies,
  187. "sensor_status": sensor_status,
  188. }
  189. def _bucketize(points: list[tuple[Any, float]], aggregation: str) -> list[dict[str, Any]]:
  190. if not points:
  191. return []
  192. key_func = {
  193. "point": lambda moment: moment.isoformat(),
  194. "hour": lambda moment: moment.strftime("%Y-%m-%dT%H"),
  195. "shift": lambda moment: moment.strftime("%Y-%m-%d-") + _shift_name(moment.hour),
  196. "day": lambda moment: moment.strftime("%Y-%m-%d"),
  197. "month": lambda moment: moment.strftime("%Y-%m"),
  198. }.get(aggregation, lambda moment: moment.strftime("%Y-%m-%d"))
  199. buckets: dict[str, list[tuple[Any, float]]] = {}
  200. for moment, value in points:
  201. buckets.setdefault(key_func(moment), []).append((moment, value))
  202. result: list[dict[str, Any]] = []
  203. for key in sorted(buckets):
  204. group = sorted(buckets[key], key=lambda item: item[0])
  205. values = sorted(value for _, value in group)
  206. result.append({
  207. "key": key,
  208. "start": group[0][0].isoformat(),
  209. "end": group[-1][0].isoformat(),
  210. "mean_m3_min": round(sum(values) / len(values), 4),
  211. "min_m3_min": round(values[0], 4),
  212. "max_m3_min": round(values[-1], 4),
  213. "p50_m3_min": round(_percentile(values, 0.5), 4),
  214. "p95_m3_min": round(_percentile(values, 0.95), 4),
  215. "sample_count": len(values),
  216. })
  217. return result
  218. def _shift_name(hour: int) -> str:
  219. if 0 <= hour < 8:
  220. return "夜班"
  221. if 8 <= hour < 16:
  222. return "早班"
  223. return "中班"
  224. def _percentile(values: list[float], fraction: float) -> float:
  225. if not values:
  226. return 0.0
  227. index = bisect_left(values, values[min(int(len(values) * fraction), len(values) - 1)])
  228. return values[min(index, len(values) - 1)]
  229. def _offline_periods(points: list[tuple[Any, float]], interval_minutes: float) -> list[dict[str, Any]]:
  230. limit = interval_minutes * _GAP_FACTOR
  231. periods: list[dict[str, Any]] = []
  232. for (previous, _), (current, _) in zip(points, points[1:]):
  233. gap = (current - previous).total_seconds() / 60.0
  234. if gap > limit:
  235. periods.append({"start": previous.isoformat(), "end": current.isoformat(),
  236. "gap_minutes": round(gap, 2)})
  237. return periods
  238. def _stuck_periods(points: list[tuple[Any, float]]) -> list[dict[str, Any]]:
  239. anomalies: list[dict[str, Any]] = []
  240. run_start = 0
  241. for index in range(1, len(points) + 1):
  242. same = index < len(points) and points[index][1] == points[run_start][1]
  243. if same:
  244. continue
  245. if index - run_start >= _STUCK_REPEATS:
  246. anomalies.append({"kind": "恒值", "start": points[run_start][0].isoformat(),
  247. "end": points[index - 1][0].isoformat(),
  248. "value": points[run_start][1], "repeat": index - run_start})
  249. run_start = index
  250. return anomalies
  251. def _jumps(points: list[tuple[Any, float]]) -> list[dict[str, Any]]:
  252. anomalies: list[dict[str, Any]] = []
  253. for (previous_at, previous_value), (current_at, current_value) in zip(points, points[1:]):
  254. delta = abs(current_value - previous_value)
  255. baseline = max(abs(previous_value), 1.0)
  256. if delta > _JUMP_ABSOLUTE and delta / baseline * 100.0 > _JUMP_PERCENT:
  257. anomalies.append({"kind": "突跳", "at": current_at.isoformat(),
  258. "from": previous_value, "to": current_value,
  259. "change_percent": round(delta / baseline * 100.0, 2)})
  260. return anomalies