compare.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """实际风量与需风量比对(工具 6 的确定性内核)。
  2. 同一套比较逻辑服务矿井总进风/总回风、回风系统与工作面,只靠规则库里的
  3. threshold_policy 区分对象与阈值(需求 §2.3 工具 6 要求 2)。
  4. 三条硬约束:
  5. 1. 只有 plan_verification_status = valid 才允许把计划需风量当正式基准;
  6. 2. 比对前先校验时间对齐,对不上不得判定;
  7. 3. 缺实测值或缺有效需风值时只输出数据不足,不得判正常(§2.6 第 13 条)。
  8. """
  9. from __future__ import annotations
  10. from typing import Any, Mapping
  11. from algorithms.airflow_review.contracts import (
  12. ALIGN_ALIGNED,
  13. as_window,
  14. ALIGN_FAILED,
  15. ALIGN_MANUAL,
  16. ALIGN_PARTIAL,
  17. COMPARE_INSUFFICIENT,
  18. COMPARE_MAJOR,
  19. COMPARE_NORMAL,
  20. COMPARE_NOT_COMPARABLE,
  21. FINDING_DATA_INSUFFICIENT,
  22. FINDING_TIME_ALIGNMENT,
  23. LOC_OTHER,
  24. LOC_STANDBY,
  25. LOC_WORKPLACE_COAL,
  26. LOC_WORKPLACE_HEADING,
  27. SEV_ATTENTION,
  28. SEV_MAJOR,
  29. SEV_MANUAL,
  30. TARGET_MINE_INTAKE,
  31. TARGET_MINE_RETURN,
  32. TARGET_RETURN_SYSTEM,
  33. TARGET_WORKPLACE,
  34. parse_time,
  35. ratio as compute_ratio,
  36. window_contains,
  37. window_overlaps,
  38. )
  39. from algorithms.airflow_review.engine.findings import FindingFactory
  40. from algorithms.airflow_review.rules.loader import RuleSet
  41. # location_type 到比对阈值策略的映射:阈值策略按目标类型区分,不重复建比较逻辑
  42. _POLICY_KEY_BY_LOCATION = {
  43. LOC_WORKPLACE_COAL: TARGET_WORKPLACE,
  44. LOC_WORKPLACE_HEADING: TARGET_WORKPLACE,
  45. LOC_STANDBY: TARGET_WORKPLACE,
  46. LOC_OTHER: TARGET_RETURN_SYSTEM,
  47. }
  48. def policy_key(target: Mapping[str, Any]) -> str:
  49. """决定该对象用哪条阈值策略:目标类型优先,工作面按地点类型细分。"""
  50. target_type = str(target.get("target_type") or "")
  51. if target_type in (TARGET_MINE_INTAKE, TARGET_MINE_RETURN):
  52. return TARGET_MINE_INTAKE if target_type == TARGET_MINE_INTAKE else TARGET_MINE_RETURN
  53. if target_type == TARGET_RETURN_SYSTEM:
  54. return TARGET_RETURN_SYSTEM
  55. if target_type == TARGET_WORKPLACE:
  56. # 地点类型缺失/无法判定时按工作面策略(0.75)走,**不能回落到回风系统的 1.0**:
  57. # 那等于把工作面按 100% 判定,用错了规则;具体地点类型只影响是否 75% 红线。
  58. return _POLICY_KEY_BY_LOCATION.get(str(target.get("location_type") or ""), TARGET_WORKPLACE)
  59. return TARGET_RETURN_SYSTEM
  60. def compare_one(
  61. target: Mapping[str, Any],
  62. rules: RuleSet,
  63. factory: FindingFactory,
  64. *,
  65. mine_id: str = "",
  66. ) -> dict[str, Any]:
  67. """比对单个对象,返回比对记录与(必要时)问题记录。"""
  68. key = policy_key(target)
  69. policy = rules.compare_policy(key)
  70. threshold_ratio = float(target.get("threshold_policy", {}).get("threshold_ratio", policy["threshold_ratio"]))
  71. actual = _number(target.get("actual_airflow_m3_min"))
  72. required = _number(target.get("required_airflow_m3_min"))
  73. plan_status = str(target.get("plan_verification_status") or "")
  74. # 模型会把时间窗写成字符串(实测传过 "2024-12-01 ~ 2024-12-31"),
  75. # 归一化后仍不可解析才按"时间无法对齐"转人工复核——不能直接 dict() 崩掉整轮。
  76. actual_window = as_window(target.get("actual_window"))
  77. required_window = as_window(target.get("required_effective_window"))
  78. alignment, alignment_reason = align_windows(actual_window, required_window)
  79. record: dict[str, Any] = {
  80. "comparison_id": f"C{target.get('target_id')}",
  81. "mine_id": mine_id,
  82. "target_id": target.get("target_id"),
  83. "target_type": target.get("target_type"),
  84. "location_type": target.get("location_type"),
  85. "target_name": target.get("target_name"),
  86. "actual_value_m3_min": actual,
  87. "actual_source": target.get("actual_source"),
  88. "actual_window": actual_window,
  89. "required_value_m3_min": required,
  90. "required_source": target.get("required_source"),
  91. "required_reuse_key": target.get("required_reuse_key", ""),
  92. "required_plan_version": target.get("plan_version", ""),
  93. "required_effective_window": required_window,
  94. "time_alignment_status": alignment,
  95. "time_alignment_reason": alignment_reason,
  96. "threshold_ratio": threshold_ratio,
  97. "policy_key": key,
  98. "difference_m3_min": None,
  99. "shortfall_m3_min": None,
  100. "ratio": None,
  101. "comparison_status": COMPARE_NOT_COMPARABLE,
  102. "plan_verification_status": plan_status,
  103. "severity": "",
  104. "finding": None,
  105. "evidence_ids": list(target.get("evidence_ids") or []),
  106. }
  107. if plan_status != "valid":
  108. record["comparison_status"] = COMPARE_NOT_COMPARABLE
  109. record["finding"] = factory.make(
  110. finding_type=FINDING_DATA_INSUFFICIENT,
  111. conclusion=f"{target.get('target_name') or target.get('target_id')} 未取得权威需风量,暂不比对:"
  112. f"配风计划核验状态为 {plan_status or '未知'}",
  113. target_id=str(target.get("target_id") or ""),
  114. target_type=str(target.get("target_type") or ""),
  115. target_name=str(target.get("target_name") or ""),
  116. severity=SEV_MANUAL,
  117. mine_id=mine_id,
  118. plan_version=str(target.get("plan_version") or ""),
  119. plan_verification_status=plan_status,
  120. required_effective_window=required_window,
  121. rule_version=rules.rule_version,
  122. evidence_ids=record["evidence_ids"],
  123. manual_review_required=True,
  124. )
  125. return record
  126. if actual is None or required is None:
  127. record["comparison_status"] = COMPARE_INSUFFICIENT
  128. missing = "有效实测风量" if actual is None else "有效需风量"
  129. record["finding"] = factory.make(
  130. finding_type=FINDING_DATA_INSUFFICIENT,
  131. conclusion=f"{target.get('target_name') or target.get('target_id')} 缺少{missing},无法比对",
  132. target_id=str(target.get("target_id") or ""),
  133. target_type=str(target.get("target_type") or ""),
  134. target_name=str(target.get("target_name") or ""),
  135. severity=SEV_MANUAL,
  136. mine_id=mine_id,
  137. actual_value=actual,
  138. required_value=required,
  139. plan_version=str(target.get("plan_version") or ""),
  140. plan_verification_status=plan_status,
  141. actual_window=actual_window,
  142. required_effective_window=required_window,
  143. rule_version=rules.rule_version,
  144. evidence_ids=record["evidence_ids"],
  145. manual_review_required=True,
  146. )
  147. return record
  148. if alignment in (ALIGN_FAILED, ALIGN_MANUAL):
  149. # 时间窗缺失或解析不出,与"窗口无交集"一样不能定性:不知道实测是什么时候的,
  150. # 拿它跟计划窗口比毫无意义(§2.8 第 6 条:实测时间无法匹配有效窗口必须转人工复核)。
  151. record["comparison_status"] = COMPARE_INSUFFICIENT
  152. record["finding"] = factory.make(
  153. finding_type=FINDING_TIME_ALIGNMENT,
  154. conclusion=f"{target.get('target_name') or target.get('target_id')} 实测时间与计划生效窗口"
  155. f"无法对齐({alignment_reason}),不判定风量是否满足",
  156. target_id=str(target.get("target_id") or ""),
  157. target_type=str(target.get("target_type") or ""),
  158. target_name=str(target.get("target_name") or ""),
  159. severity=SEV_MANUAL,
  160. mine_id=mine_id,
  161. actual_value=actual,
  162. required_value=required,
  163. plan_version=str(target.get("plan_version") or ""),
  164. plan_verification_status=plan_status,
  165. actual_window=actual_window,
  166. required_effective_window=required_window,
  167. time_alignment_status=alignment,
  168. rule_version=rules.rule_version,
  169. evidence_ids=record["evidence_ids"],
  170. manual_review_required=True,
  171. )
  172. return record
  173. ratio_value = compute_ratio(actual, required)
  174. record["ratio"] = ratio_value
  175. record["difference_m3_min"] = round(actual - required, 4)
  176. record["shortfall_m3_min"] = round(required - actual, 4) if actual < required else 0.0
  177. if ratio_value is None:
  178. record["comparison_status"] = COMPARE_INSUFFICIENT
  179. return record
  180. if ratio_value < threshold_ratio:
  181. severity = str(policy.get("below_threshold_severity") or SEV_MAJOR)
  182. status = COMPARE_MAJOR
  183. percent = round(ratio_value * 100.0, 2)
  184. threshold_percent = round(threshold_ratio * 100.0, 2)
  185. if key == TARGET_WORKPLACE:
  186. conclusion = (f"{target.get('target_name')} 实际供风量为需风量的 {percent}%,"
  187. f"低于 {threshold_percent}% 红线,缺 {record['shortfall_m3_min']} m³/min")
  188. elif key == TARGET_RETURN_SYSTEM:
  189. conclusion = (f"{target.get('target_name')} 实际风量为需风量的 {percent}%,"
  190. f"低于需风量,缺 {record['shortfall_m3_min']} m³/min")
  191. else:
  192. conclusion = (f"{target.get('target_name')} 实际风量为需风量的 {percent}%,"
  193. f"低于需风量,缺 {record['shortfall_m3_min']} m³/min")
  194. elif ratio_value < 1.0:
  195. severity = str(policy.get("between_threshold_and_required_severity") or SEV_ATTENTION)
  196. status = COMPARE_INSUFFICIENT
  197. conclusion = (f"{target.get('target_name')} 实际风量为需风量的 {round(ratio_value * 100.0, 2)}%,"
  198. f"低于需风量但高于 {round(threshold_ratio * 100.0, 2)}% 红线,需关注")
  199. else:
  200. record["comparison_status"] = COMPARE_NORMAL
  201. record["severity"] = ""
  202. return record
  203. record["comparison_status"] = status
  204. record["severity"] = severity
  205. record["finding"] = factory.make(
  206. finding_type=str(policy.get("finding_type")),
  207. conclusion=conclusion,
  208. target_id=str(target.get("target_id") or ""),
  209. target_type=str(target.get("target_type") or ""),
  210. target_name=str(target.get("target_name") or ""),
  211. severity=severity,
  212. mine_id=mine_id,
  213. actual_value=actual,
  214. required_value=required,
  215. difference=record["difference_m3_min"],
  216. ratio=ratio_value,
  217. plan_id=str(target.get("plan_id") or ""),
  218. plan_version=str(target.get("plan_version") or ""),
  219. plan_verification_status=plan_status,
  220. actual_window=actual_window,
  221. required_effective_window=required_window,
  222. time_alignment_status=alignment,
  223. rule_version=rules.rule_version,
  224. evidence_ids=record["evidence_ids"],
  225. extra={
  226. "is_abnormal": True,
  227. "is_major_hazard": status == COMPARE_MAJOR and key == TARGET_WORKPLACE,
  228. "shortfall_m3_min": record["shortfall_m3_min"],
  229. "actual_required_ratio": ratio_value,
  230. "threshold_ratio": threshold_ratio,
  231. "required_reuse_key": record["required_reuse_key"],
  232. "disposal_note": "请核实是否已停止作业、撤人、断电并处理" if status == COMPARE_MAJOR else "",
  233. },
  234. )
  235. return record
  236. def compare_targets(
  237. targets: list[Mapping[str, Any]],
  238. rules: RuleSet,
  239. *,
  240. mine_id: str = "",
  241. factory: FindingFactory | None = None,
  242. ) -> dict[str, Any]:
  243. """批量比对;一次工具调用处理全部对象,避免逐个调用往返回模型。"""
  244. finder = factory or FindingFactory()
  245. comparisons: list[dict[str, Any]] = []
  246. findings: list[dict[str, Any]] = []
  247. for target in targets:
  248. record = compare_one(target, rules, finder, mine_id=mine_id)
  249. comparisons.append(record)
  250. if record.get("finding"):
  251. findings.append(record["finding"])
  252. return {
  253. "mine_id": mine_id,
  254. "comparisons": comparisons,
  255. "findings": findings,
  256. "rule_version": rules.rule_version,
  257. "summary": _summarize(comparisons),
  258. }
  259. def align_windows(
  260. actual_window: Mapping[str, Any] | None,
  261. required_window: Mapping[str, Any] | None,
  262. ) -> tuple[str, str]:
  263. """时间对齐判定:实测窗口落在计划生效窗口内为 aligned。
  264. 实测时间窗长于计划窗口(跨月旬报之类)只算 partial,需要人工确认按哪个
  265. 版本取值;完全不相交判 failed。任一侧缺时间一律 manual_review。
  266. """
  267. if not actual_window or not required_window:
  268. return ALIGN_MANUAL, "缺少实测窗口或计划生效窗口"
  269. if parse_time(actual_window.get("start")) is None or parse_time(actual_window.get("end")) is None:
  270. return ALIGN_MANUAL, "实测窗口时间无法解析"
  271. if parse_time(required_window.get("start")) is None or parse_time(required_window.get("end")) is None:
  272. return ALIGN_MANUAL, "计划生效窗口时间无法解析"
  273. if window_contains(required_window, actual_window):
  274. return ALIGN_ALIGNED, "实测窗口落在计划生效窗口内"
  275. if not window_overlaps(actual_window, required_window):
  276. return ALIGN_FAILED, "实测窗口与计划生效窗口无交集"
  277. return ALIGN_PARTIAL, "实测窗口与计划生效窗口部分重叠,需确认取值口径"
  278. def _summarize(comparisons: list[Mapping[str, Any]]) -> dict[str, Any]:
  279. counts: dict[str, int] = {}
  280. for record in comparisons:
  281. status = str(record.get("comparison_status") or "")
  282. counts[status] = counts.get(status, 0) + 1
  283. return {
  284. "total": len(comparisons),
  285. "by_status": counts,
  286. "abnormal_count": sum(1 for record in comparisons
  287. if record.get("comparison_status") in (COMPARE_MAJOR, COMPARE_INSUFFICIENT)),
  288. "major_risk_count": counts.get(COMPARE_MAJOR, 0),
  289. "not_comparable_count": counts.get(COMPARE_NOT_COMPARABLE, 0),
  290. }
  291. def _number(value: Any) -> float | None:
  292. if isinstance(value, bool):
  293. return None
  294. if isinstance(value, (int, float)):
  295. return float(value)
  296. if isinstance(value, str):
  297. try:
  298. return float(value.strip())
  299. except ValueError:
  300. return None
  301. return None