reconcile.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """多源风量交叉校核(工具 5 的确定性内核)。
  2. 默认以正式测风报表为周期审查主依据、安全监控为连续校核依据;先校验时间与
  3. 统计口径,再算偏差。偏差超限或时间不对齐时**不得**给出单一确定结论,也不得
  4. 用平均值掩盖冲突(需求 §2.3 工具 5 要求 3、5)。
  5. """
  6. from __future__ import annotations
  7. from typing import Any, Mapping
  8. from algorithms.airflow_review.contracts import (
  9. ALIGN_ALIGNED,
  10. as_mapping,
  11. as_window,
  12. ALIGN_FAILED,
  13. ALIGN_MANUAL,
  14. FINDING_DATA_CONFLICT,
  15. FINDING_DATA_INSUFFICIENT,
  16. FINDING_TIME_ALIGNMENT,
  17. SEV_ATTENTION,
  18. SEV_MANUAL,
  19. SOURCE_MEASUREMENT,
  20. SOURCE_MONITORING,
  21. parse_time,
  22. )
  23. from algorithms.airflow_review.engine.findings import FindingFactory
  24. from algorithms.airflow_review.rules.loader import RuleSet
  25. CONSISTENCY_ACCEPTABLE = "acceptable"
  26. CONSISTENCY_CONFLICT = "conflict"
  27. CONSISTENCY_SINGLE = "single_source"
  28. CONSISTENCY_UNKNOWN = "unknown"
  29. def reconcile_sources(
  30. request: Mapping[str, Any],
  31. rules: RuleSet,
  32. factory: FindingFactory | None = None,
  33. *,
  34. mine_id: str = "",
  35. ) -> dict[str, Any]:
  36. """对同一对象、同一时间窗的多个来源做对齐与偏差判定。"""
  37. finder = factory or FindingFactory()
  38. policy = rules.reconcile_policy()
  39. deviation_limit = float(policy.get("deviation_percent", 10.0))
  40. tolerance_minutes = float(policy.get("time_tolerance_minutes", 60))
  41. priority = list(policy.get("source_priority") or [SOURCE_MEASUREMENT, SOURCE_MONITORING])
  42. target_id = str(request.get("target_id") or "")
  43. target_type = str(request.get("target_type") or "")
  44. target_name = str(request.get("target_name") or target_id)
  45. window = as_window(request.get("comparison_window"))
  46. sources = [as_mapping(item) for item in (request.get("sources") or [])]
  47. sources = [item for item in sources if _number(item.get("airflow_m3_min")) is not None]
  48. sources = sorted(sources, key=lambda item: _priority_index(str(item.get("source_type") or ""), priority))
  49. result: dict[str, Any] = {
  50. "target_id": target_id,
  51. "target_type": target_type,
  52. "target_name": target_name,
  53. "period": request.get("period", ""),
  54. "comparison_window": window,
  55. "recommended_airflow_m3_min": None,
  56. "recommended_source": "",
  57. "deviation_percent": None,
  58. "consistency": CONSISTENCY_UNKNOWN,
  59. "time_alignment_status": ALIGN_MANUAL,
  60. "requires_manual_review": True,
  61. "sources": sources,
  62. "reason": "",
  63. "rule_version": rules.rule_version,
  64. "evidence_ids": list(request.get("evidence_ids") or []),
  65. "finding": None,
  66. }
  67. if not sources:
  68. result["reason"] = "该对象在本窗口内没有任何有效来源数据"
  69. result["finding"] = finder.make(
  70. finding_type=FINDING_DATA_INSUFFICIENT,
  71. conclusion=f"{target_name} 缺少有效风量数据,无法交叉校核",
  72. target_id=target_id, target_type=target_type, target_name=target_name,
  73. severity=SEV_MANUAL, mine_id=mine_id,
  74. actual_window=window, rule_version=rules.rule_version,
  75. evidence_ids=result["evidence_ids"], manual_review_required=True,
  76. )
  77. return result
  78. primary = sources[0]
  79. result["recommended_airflow_m3_min"] = _number(primary.get("airflow_m3_min"))
  80. result["recommended_source"] = str(primary.get("source_type") or "")
  81. if len(sources) == 1:
  82. result["consistency"] = CONSISTENCY_SINGLE
  83. result["time_alignment_status"] = ALIGN_ALIGNED
  84. result["requires_manual_review"] = False
  85. result["reason"] = (f"仅有 {result['recommended_source']} 单一来源,按来源优先级直接采用;"
  86. f"缺少第二来源,无法交叉验证")
  87. return result
  88. secondary = sources[1]
  89. align_status, align_reason = _align_times(primary, secondary, tolerance_minutes, window)
  90. result["time_alignment_status"] = align_status
  91. if align_status == ALIGN_FAILED:
  92. result["consistency"] = CONSISTENCY_UNKNOWN
  93. result["reason"] = f"两来源时间不对齐({align_reason}),不形成正式一致性结论"
  94. result["finding"] = finder.make(
  95. finding_type=FINDING_TIME_ALIGNMENT,
  96. conclusion=f"{target_name} 测风报表与安全监控数据时间不对齐({align_reason}),需人工复核",
  97. target_id=target_id, target_type=target_type, target_name=target_name,
  98. severity=SEV_MANUAL, mine_id=mine_id,
  99. actual_value=_number(primary.get("airflow_m3_min")),
  100. required_value=_number(secondary.get("airflow_m3_min")),
  101. actual_window=window, time_alignment_status=ALIGN_FAILED,
  102. rule_version=rules.rule_version, evidence_ids=result["evidence_ids"],
  103. manual_review_required=True,
  104. )
  105. return result
  106. first, second = _number(primary.get("airflow_m3_min")), _number(secondary.get("airflow_m3_min"))
  107. deviation = _deviation_percent(first, second)
  108. result["deviation_percent"] = deviation
  109. if deviation <= deviation_limit:
  110. result["consistency"] = CONSISTENCY_ACCEPTABLE
  111. result["requires_manual_review"] = align_status != ALIGN_ALIGNED
  112. result["reason"] = (f"测风报表与安全监控数据处于允许偏差范围内({deviation}% ≤ {deviation_limit}%),"
  113. f"按来源优先级采用 {result['recommended_source']}")
  114. return result
  115. result["consistency"] = CONSISTENCY_CONFLICT
  116. result["reason"] = (f"两来源偏差 {deviation}% 超出允许阈值 {deviation_limit}%,"
  117. f"不采用平均值掩盖冲突,转人工复核")
  118. result["finding"] = finder.make(
  119. finding_type=FINDING_DATA_CONFLICT,
  120. conclusion=(f"{target_name} 测风报表({first} m³/min)与安全监控({second} m³/min)"
  121. f"偏差 {deviation}%,超过阈值 {deviation_limit}%,需人工复核确认采信值"),
  122. target_id=target_id, target_type=target_type, target_name=target_name,
  123. severity=SEV_ATTENTION, mine_id=mine_id,
  124. actual_value=first, required_value=second, ratio=deviation,
  125. actual_window=window, time_alignment_status=align_status,
  126. rule_version=rules.rule_version, evidence_ids=result["evidence_ids"],
  127. manual_review_required=True,
  128. extra={"deviation_limit_percent": deviation_limit,
  129. "candidate_values": [first, second],
  130. "disposal_note": "请核实传感器状态与测风时点后确认采信值"},
  131. )
  132. return result
  133. def _align_times(
  134. primary: Mapping[str, Any],
  135. secondary: Mapping[str, Any],
  136. tolerance_minutes: float,
  137. window: Mapping[str, Any],
  138. ) -> tuple[str, str]:
  139. first = parse_time(primary.get("observed_at"))
  140. second = parse_time(secondary.get("observed_at"))
  141. if first is None or second is None:
  142. return ALIGN_MANUAL, "来源观测时间无法解析"
  143. delta_minutes = abs((first - second).total_seconds()) / 60.0
  144. if delta_minutes > tolerance_minutes:
  145. return ALIGN_FAILED, f"观测时点相差 {round(delta_minutes, 1)} 分钟,超过容差 {tolerance_minutes} 分钟"
  146. for moment in (first, second):
  147. if window and not _in_window(moment, window):
  148. return ALIGN_MANUAL, "观测时点不在声明的时间窗内"
  149. return ALIGN_ALIGNED, f"观测时点相差 {round(delta_minutes, 1)} 分钟,在容差内"
  150. def _in_window(moment: Any, window: Mapping[str, Any]) -> bool:
  151. start, end = parse_time(window.get("start")), parse_time(window.get("end"))
  152. if start is None or end is None:
  153. return True
  154. return start <= moment <= end
  155. def _deviation_percent(first: float | None, second: float | None) -> float:
  156. """偏差按两值中的较小者计算:偏保守,避免用大值稀释冲突。"""
  157. if not first or not second:
  158. return 100.0
  159. return round(abs(first - second) / min(first, second) * 100.0, 4)
  160. def _priority_index(source_type: str, priority: list[str]) -> int:
  161. try:
  162. return priority.index(source_type)
  163. except ValueError:
  164. return len(priority)
  165. def _number(value: Any) -> float | None:
  166. if isinstance(value, bool):
  167. return None
  168. if isinstance(value, (int, float)):
  169. return float(value)
  170. if isinstance(value, str):
  171. try:
  172. return float(value.strip())
  173. except ValueError:
  174. return None
  175. return None