verify.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. """配风计划核验(工具 4 的确定性内核)。
  2. 按「用风地点 → 回风系统 → 矿井总需风量」自下而上复算,比对计划值、复算值、
  3. 层级汇总关系与最低风量要求,产出 verified_items / blocked_items 与安全隐患问题。
  4. """
  5. from __future__ import annotations
  6. from typing import Any, Mapping
  7. from algorithms.airflow_review.contracts import (
  8. FINDING_DATA_INSUFFICIENT,
  9. as_window,
  10. mappings,
  11. FINDING_PLAN_MISMATCH,
  12. SEV_ATTENTION,
  13. SEV_MAJOR,
  14. SEV_MANUAL,
  15. VERIFY_INVALID,
  16. VERIFY_MANUAL,
  17. VERIFY_VALID,
  18. WORKPLACE_LOCATION_TYPES,
  19. unique,
  20. )
  21. from algorithms.airflow_review.engine.findings import FindingFactory
  22. from algorithms.airflow_review.engine.hierarchy import ancestors_of, build_hierarchy
  23. from algorithms.airflow_review.engine.recalc import (
  24. STATUS_OK,
  25. recalc_all,
  26. within_tolerance,
  27. )
  28. from algorithms.airflow_review.rules.loader import RuleSet
  29. STATUS_VALID = "valid"
  30. STATUS_INVALID = "invalid"
  31. STATUS_MANUAL = "manual_review"
  32. def verify_plan(
  33. plan: Mapping[str, Any],
  34. rules: RuleSet,
  35. tolerance: Mapping[str, Any] | None = None,
  36. finding_factory: FindingFactory | None = None,
  37. ) -> dict[str, Any]:
  38. """核验配风计划,返回逐项结论、权威需风量与问题清单。
  39. 阻断规则:只有**计算错误**沿层级向上传播(错误会污染父级汇总值);依据不足的
  40. 对象只是自身不进权威集,不牵连父级——父级的汇总正确性可以独立用子项计划值校验。
  41. """
  42. items = mappings(plan.get("items"))
  43. factory = finding_factory or FindingFactory()
  44. effective_tolerance = rules.tolerance(tolerance)
  45. hierarchy = build_hierarchy(items)
  46. nodes = hierarchy["nodes"]
  47. recalc = recalc_all(items, rules)
  48. mine_id = str(plan.get("mine_id") or "")
  49. plan_id = str(plan.get("plan_id") or "")
  50. plan_version = str(plan.get("plan_version") or "")
  51. effective_window = as_window(plan.get("effective_window"))
  52. findings: list[dict[str, Any]] = []
  53. results: list[dict[str, Any]] = []
  54. blocked: list[str] = []
  55. verified: list[dict[str, Any]] = []
  56. manual_entries: list[dict[str, Any]] = []
  57. for issue in hierarchy["issues"]:
  58. if issue["severity"] != "major":
  59. continue
  60. findings.append(factory.make(
  61. finding_type=FINDING_DATA_INSUFFICIENT,
  62. conclusion=f"计划层级关系存在问题:{issue['message']}",
  63. target_id=issue["target_id"],
  64. mine_id=mine_id,
  65. plan_id=plan_id,
  66. plan_version=plan_version,
  67. severity=SEV_MANUAL,
  68. rule_version=rules.rule_version,
  69. manual_review_required=True,
  70. extra={"hierarchy_issue": issue["code"]},
  71. ))
  72. for item in items:
  73. target_id = str(item.get("target_id") or "")
  74. trace = recalc.get(target_id) or {}
  75. status, reason = _decide_status(item, trace, tolerance=effective_tolerance)
  76. reuse_key = _reuse_key(target_id, hierarchy)
  77. entry = {
  78. "target_id": target_id,
  79. "target_type": item.get("target_type"),
  80. "location_type": trace.get("location_type"),
  81. "target_name": trace.get("target_name"),
  82. "verification_status": status,
  83. "verification_reason": reason,
  84. "planned_required_airflow_m3_min": trace.get("planned_required_airflow_m3_min"),
  85. "calculated_required_airflow_m3_min": trace.get("calculated_required_airflow_m3_min"),
  86. "difference_m3_min": None,
  87. "difference_percent": None,
  88. "min_airflow_m3_min": trace.get("min_airflow_m3_min"),
  89. "max_airflow_m3_min": trace.get("max_airflow_m3_min"),
  90. "governing_method_id": trace.get("governing_method_id"),
  91. "aggregation_basis": trace.get("aggregation_basis") or "",
  92. "calculation_trace": trace.get("methods") or [],
  93. "verification_checks": trace.get("checks") or [],
  94. "stated_reproduction": trace.get("stated_reproduction"),
  95. "recalc_warnings": trace.get("warnings") or [],
  96. "rule_version": rules.rule_version,
  97. "is_authoritative": status == VERIFY_VALID,
  98. "required_reuse_key": reuse_key,
  99. "is_single_return_system_equivalent": bool(
  100. hierarchy["single_return_equivalence"].get("equivalent")) and reuse_key != "",
  101. "effective_window": effective_window,
  102. "evidence_ids": trace.get("evidence_ids") or [],
  103. }
  104. planned = entry["planned_required_airflow_m3_min"]
  105. calculated = entry["calculated_required_airflow_m3_min"]
  106. if isinstance(planned, (int, float)) and isinstance(calculated, (int, float)):
  107. _, difference, _ = within_tolerance(planned, calculated, effective_tolerance)
  108. entry["difference_m3_min"] = difference
  109. if planned:
  110. entry["difference_percent"] = round(difference / float(planned) * 100.0, 4)
  111. results.append(entry)
  112. if status == VERIFY_VALID:
  113. verified.append(_verified_entry(entry, plan, rules, effective_window))
  114. continue
  115. if status == VERIFY_INVALID:
  116. blocked.extend([target_id, *ancestors_of(target_id, nodes)])
  117. findings.append(_item_finding(item, entry, status, reason, plan, rules, factory, mine_id))
  118. continue
  119. # 依据不足:工作面类逐条列问题(承载 75% 红线,必须可逐项复核),
  120. # 硐室与其他巷道数量可达数十条,汇总为一条计划级问题并附明细。
  121. manual_entries.append({"target_id": target_id,
  122. "target_name": entry.get("target_name"),
  123. "location_type": entry.get("location_type"),
  124. "reason": reason})
  125. if _is_workplace(entry):
  126. findings.append(_item_finding(item, entry, status, reason, plan, rules, factory, mine_id))
  127. blocked = unique(blocked)
  128. manual_items = unique([entry["target_id"] for entry in manual_entries])
  129. # 层级归属问题单独影响计划级结论:归属不明时整个计划不能算"核验通过"
  130. hierarchy_major_targets = unique([issue["target_id"] for issue in hierarchy["issues"]
  131. if issue["severity"] == "major" and issue["target_id"]])
  132. invalid = [entry["target_id"] for entry in results if entry["verification_status"] == VERIFY_INVALID]
  133. if invalid:
  134. plan_status = STATUS_INVALID
  135. elif manual_items or hierarchy_major_targets:
  136. plan_status = STATUS_MANUAL
  137. else:
  138. plan_status = STATUS_VALID
  139. plan_authoritative = plan_status != STATUS_INVALID
  140. if plan_status != STATUS_VALID or blocked:
  141. findings.append(_plan_level_finding(plan_status, blocked, manual_entries, results,
  142. plan, rules, factory, mine_id,
  143. hierarchy_targets=hierarchy_major_targets))
  144. return {
  145. "plan_id": plan_id,
  146. "plan_version": plan_version,
  147. "plan_calculation_status": plan_status,
  148. "plan_authoritative": plan_authoritative,
  149. "rule_version": rules.rule_version,
  150. "tolerance": effective_tolerance,
  151. "items": results,
  152. "verified_items": verified,
  153. "blocked_items": blocked,
  154. "manual_review_items": manual_items,
  155. "manual_review_details": manual_entries,
  156. "findings": findings,
  157. "hierarchy": {
  158. "levels": hierarchy["levels"],
  159. "issues": hierarchy["issues"],
  160. "blocking_targets": hierarchy["blocking_targets"],
  161. "single_return_system": hierarchy["single_return_system"],
  162. "single_return_equivalence": hierarchy["single_return_equivalence"],
  163. },
  164. "effective_window": effective_window,
  165. "evidence_ids": unique([eid for entry in results for eid in entry["evidence_ids"]]),
  166. }
  167. def _decide_status(
  168. item: Mapping[str, Any],
  169. trace: Mapping[str, Any],
  170. tolerance: Mapping[str, float],
  171. ) -> tuple[str, str]:
  172. """判定单项核验状态;先看依据是否充分,再看是否算错。"""
  173. if not trace:
  174. return VERIFY_MANUAL, "该对象未参与复算"
  175. if trace.get("calculation_status") != STATUS_OK:
  176. return VERIFY_MANUAL, "缺少必要参数或所有计算方法均不适用,无法复算需风量"
  177. planned = trace.get("planned_required_airflow_m3_min")
  178. calculated = trace.get("calculated_required_airflow_m3_min")
  179. if not isinstance(planned, (int, float)) or not isinstance(calculated, (int, float)):
  180. return VERIFY_MANUAL, "计划值或复算值缺失"
  181. within, difference, limit = within_tolerance(planned, calculated, tolerance)
  182. if not within:
  183. return VERIFY_INVALID, (
  184. f"计划需风量 {planned} m³/min 与复算值 {calculated} m³/min 相差 {difference} m³/min,"
  185. f"超出容差 {limit} m³/min"
  186. )
  187. stated = trace.get("stated_reproduction") or {}
  188. if stated.get("status") == "inconsistent":
  189. return VERIFY_INVALID, f"计划写出的代入值与结果不自洽:{stated.get('reason', '')}"
  190. stated_result = stated.get("stated_result_m3_min")
  191. recomputed = stated.get("recomputed_m3_min")
  192. if isinstance(stated_result, (int, float)) and isinstance(recomputed, (int, float)):
  193. stated_ok, stated_diff, _ = within_tolerance(stated_result, recomputed, tolerance)
  194. if not stated_ok:
  195. return VERIFY_INVALID, (
  196. f"计划写出的代入值与结果不自洽:按 {stated.get('expression')} 应为 {recomputed} m³/min,"
  197. f"计划写为 {stated_result} m³/min(差 {stated_diff})"
  198. )
  199. for check in trace.get("verification_checks") or []:
  200. if check.get("status") == "fail":
  201. return VERIFY_INVALID, f"{check.get('name')}不满足:{check.get('left_value')} {check.get('operator')} {check.get('limit_value')}"
  202. min_required = trace.get("min_airflow_m3_min")
  203. if isinstance(min_required, (int, float)) and isinstance(calculated, (int, float)):
  204. if calculated < min_required and abs(calculated - min_required) > max(
  205. float(tolerance.get("absolute_m3_min", 1.0)), abs(min_required) * 0.005
  206. ):
  207. return VERIFY_INVALID, f"复算需风量 {calculated} m³/min 低于法定最低风量 {min_required} m³/min"
  208. for check in trace.get("checks") or []:
  209. if check.get("status") == "insufficient" and check.get("check_id") in ("VF-CFI-MIN", "VF-HFI-MIN-COAL", "VF-HFI-MIN-ROCK"):
  210. return VERIFY_MANUAL, f"{check.get('name')}缺少参数:{check.get('reason')}"
  211. return VERIFY_VALID, "计划值、复算值、层级汇总关系与验算项均一致"
  212. def _item_finding(
  213. item: Mapping[str, Any],
  214. entry: Mapping[str, Any],
  215. status: str,
  216. reason: str,
  217. plan: Mapping[str, Any],
  218. rules: RuleSet,
  219. factory: FindingFactory,
  220. mine_id: str,
  221. ) -> dict[str, Any]:
  222. name = entry.get("target_name") or entry.get("target_id")
  223. if status == VERIFY_INVALID:
  224. conclusion = f"配风计划中{name}需风量计算不正确:{reason}"
  225. severity = SEV_MAJOR
  226. else:
  227. conclusion = f"{name}的需风量无法核验,暂不作为正式比对基准:{reason}"
  228. severity = SEV_MANUAL
  229. return factory.make(
  230. finding_type=FINDING_PLAN_MISMATCH,
  231. conclusion=conclusion,
  232. target_id=str(entry.get("target_id") or ""),
  233. target_type=str(entry.get("target_type") or ""),
  234. target_name=str(name),
  235. severity=severity,
  236. mine_id=mine_id,
  237. required_value=entry.get("planned_required_airflow_m3_min"),
  238. actual_value=entry.get("calculated_required_airflow_m3_min"),
  239. difference=entry.get("difference_m3_min"),
  240. plan_id=str(plan.get("plan_id") or ""),
  241. plan_version=str(plan.get("plan_version") or ""),
  242. plan_verification_status=status,
  243. required_effective_window=entry.get("effective_window"),
  244. rule_version=rules.rule_version,
  245. evidence_ids=entry.get("evidence_ids") or [],
  246. calculation_trace=[entry.get("calculation_trace"), entry.get("verification_checks"),
  247. entry.get("stated_reproduction")],
  248. manual_review_required=status == VERIFY_MANUAL,
  249. extra={"is_hazard": status == VERIFY_INVALID,
  250. "verification_reason": reason,
  251. "governing_method_id": entry.get("governing_method_id")},
  252. )
  253. def _plan_level_finding(
  254. plan_status: str,
  255. blocked: list[str],
  256. manual_entries: list[dict[str, Any]],
  257. results: list[dict[str, Any]],
  258. plan: Mapping[str, Any],
  259. rules: RuleSet,
  260. factory: FindingFactory,
  261. mine_id: str,
  262. hierarchy_targets: list[str] | None = None,
  263. ) -> dict[str, Any]:
  264. invalid = [entry["target_id"] for entry in results if entry["verification_status"] == VERIFY_INVALID]
  265. manual_items = unique([entry["target_id"] for entry in manual_entries])
  266. by_type: dict[str, int] = {}
  267. for entry in manual_entries:
  268. key = str(entry.get("location_type") or "未分类")
  269. by_type[key] = by_type.get(key, 0) + 1
  270. version = plan.get("plan_version") or ""
  271. if plan_status == STATUS_INVALID:
  272. conclusion = (f"配风计划 {version} 存在计算错误,受影响对象"
  273. f"({('、'.join(blocked)) or '无'})暂不作为正式比对基准")
  274. severity = SEV_MAJOR
  275. manual_review = False
  276. elif plan_status == STATUS_MANUAL:
  277. breakdown = "、".join(f"{name} {count} 项" for name, count in sorted(by_type.items()))
  278. conclusion = (f"配风计划 {version} 计划依据不足:{len(manual_items)} 个对象缺少计算过程或参数"
  279. f"({breakdown}),未列入权威需风量;其余对象核验通过")
  280. severity = SEV_MANUAL
  281. manual_review = True
  282. else:
  283. conclusion = f"配风计划 {version} 整体核验通过"
  284. severity = SEV_ATTENTION
  285. manual_review = False
  286. if plan_status == STATUS_MANUAL and not manual_items and hierarchy_targets:
  287. conclusion = (f"配风计划 {version} 的层级归属关系不完整(涉及 "
  288. f"{('、'.join(hierarchy_targets))}),无法确认汇总基准,需人工复核")
  289. manual_review = True
  290. return factory.make(
  291. finding_type=FINDING_DATA_INSUFFICIENT if plan_status == STATUS_MANUAL else FINDING_PLAN_MISMATCH,
  292. conclusion=conclusion,
  293. target_id="MINE_TOTAL" if plan_status != STATUS_VALID else "",
  294. target_type="mine_total",
  295. severity=severity,
  296. mine_id=mine_id,
  297. plan_id=str(plan.get("plan_id") or ""),
  298. plan_version=str(version),
  299. plan_verification_status=plan_status,
  300. required_effective_window=as_window(plan.get("effective_window")),
  301. rule_version=rules.rule_version,
  302. evidence_ids=unique([eid for entry in results for eid in entry.get("evidence_ids") or []]),
  303. manual_review_required=manual_review,
  304. extra={"is_hazard": plan_status == STATUS_INVALID,
  305. "blocked_items": blocked, "invalid_items": invalid,
  306. "manual_review_items": manual_items,
  307. "manual_review_breakdown": by_type,
  308. "manual_review_details": manual_entries,
  309. "hierarchy_issue_targets": list(hierarchy_targets or [])},
  310. )
  311. def _is_workplace(entry: Mapping[str, Any]) -> bool:
  312. return str(entry.get("location_type") or "") in WORKPLACE_LOCATION_TYPES
  313. def _verified_entry(
  314. entry: Mapping[str, Any],
  315. plan: Mapping[str, Any],
  316. rules: RuleSet,
  317. effective_window: Mapping[str, Any],
  318. ) -> dict[str, Any]:
  319. """核验通过的权威需风量;后续比对只能引用这里的值。"""
  320. return {
  321. "target_id": entry.get("target_id"),
  322. "target_type": entry.get("target_type"),
  323. "location_type": entry.get("location_type"),
  324. "target_name": entry.get("target_name"),
  325. "required_airflow_m3_min": entry.get("calculated_required_airflow_m3_min"),
  326. "planned_required_airflow_m3_min": entry.get("planned_required_airflow_m3_min"),
  327. "required_source": "verified_monthly_air_distribution_plan",
  328. "plan_id": plan.get("plan_id"),
  329. "plan_version": plan.get("plan_version"),
  330. "required_effective_window": dict(effective_window),
  331. "approved_at": plan.get("approved_at"),
  332. "required_reuse_key": entry.get("required_reuse_key") or "",
  333. "is_single_return_system_equivalent": entry.get("is_single_return_system_equivalent", False),
  334. "limit_floor_m3_min": entry.get("min_airflow_m3_min"),
  335. "limit_ceiling_m3_min": entry.get("max_airflow_m3_min"),
  336. "rule_version": rules.rule_version,
  337. "evidence_ids": entry.get("evidence_ids") or [],
  338. }
  339. def _reuse_key(target_id: str, hierarchy: Mapping[str, Any]) -> str:
  340. equivalence = hierarchy.get("single_return_equivalence") or {}
  341. if not equivalence.get("equivalent"):
  342. return ""
  343. if target_id in (equivalence.get("return_system_id"), equivalence.get("mine_total_id")):
  344. return str(equivalence.get("reuse_key") or "")
  345. return ""
  346. __all__ = ["verify_plan", "STATUS_VALID", "STATUS_INVALID", "STATUS_MANUAL"]