variants.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """配风计划计算过程的口径判定与自洽核验。
  2. 计划里同一个物理量可能有不同口径("断面"是有效断面积还是净断面积),而计划往往
  3. 不标注。这里负责两件事,都是**确定性**的:
  4. 1. 变体选择:口径标记命中时按声明走;命中不到时用计划**自己写出的结果**反推它用的是
  5. 哪个变体(`_infer_variant`),并留下痕迹供人工核实。
  6. 2. 自洽核验:拿计划写出的代入值,在规则库允许的**任一**口径下复现它写出的结果
  7. (`_reproduce_with_candidates`)。一个都复现不出来才算真的算错。
  8. 关键在于候选集**只来自规则库**,不是任意表达式——所以"逐个试算"并没有放宽判定,
  9. 只是不再用单一假设去否定一份正确的计划。
  10. """
  11. from __future__ import annotations
  12. from typing import Any, Mapping
  13. from algorithms.airflow_review.contracts import (
  14. LOC_CHAMBER,
  15. as_mapping,
  16. LOC_OTHER,
  17. LOC_STANDBY,
  18. LOC_WORKPLACE_COAL,
  19. LOC_WORKPLACE_HEADING,
  20. TARGET_RETURN_SYSTEM,
  21. )
  22. from algorithms.airflow_review.engine.expression import try_evaluate
  23. from algorithms.airflow_review.rules.loader import RuleSet
  24. STATUS_OK = "ok"
  25. STATUS_INSUFFICIENT = "insufficient"
  26. STATUS_INCONSISTENT = "inconsistent"
  27. def _candidate_expressions(
  28. formula: Mapping[str, Any],
  29. facts: Mapping[str, Any],
  30. rules: RuleSet,
  31. ) -> list[tuple[str, Any]]:
  32. """按优先顺序给出该公式的全部候选表达式:显式命中的变体优先,其余变体次之,基础表达式最后。
  33. 候选集**只来自规则库**,不是任意表达式——所以"逐个试算"并没有放宽判定。
  34. """
  35. selected = rules.select_formula(dict(formula), facts)
  36. if selected.get("expression") is None:
  37. return []
  38. ordered: list[tuple[str, Any]] = []
  39. if selected.get("variant"):
  40. ordered.append((str(selected["expression"]), selected["variant"]))
  41. for variant in formula.get("variants") or []:
  42. expression = variant.get("expression")
  43. if expression and all(expression != existing for existing, _ in ordered):
  44. ordered.append((str(expression), variant.get("when")))
  45. base = formula.get("expression")
  46. if base and all(base != existing for existing, _ in ordered):
  47. ordered.append((str(base), None))
  48. return ordered
  49. def _infer_variant(
  50. formula: Mapping[str, Any],
  51. params: Mapping[str, Any],
  52. stated_result: float | None,
  53. ) -> dict[str, Any] | None:
  54. """用计划写出的结果反推变体;返回命中的表达式与候选清单,命中不到返回 None。
  55. 容差按计划结果的 0.5% 与 1 m³/min 取大者,避免因计划取整而判不中。
  56. """
  57. variants = formula.get("variants") or []
  58. if not variants or not isinstance(stated_result, (int, float)):
  59. return None
  60. limit = max(abs(float(stated_result)) * 0.005, 1.0)
  61. candidates: list[dict[str, Any]] = []
  62. for variant in variants:
  63. expression = variant.get("expression")
  64. if not expression:
  65. continue
  66. value, _ = try_evaluate(str(expression), params)
  67. entry = {"expression": expression, "when": variant.get("when"),
  68. "value_m3_min": None if value is None else round(value, 4)}
  69. candidates.append(entry)
  70. if value is not None and abs(value - float(stated_result)) <= limit:
  71. return {"expression": expression, "variant": variant.get("when"),
  72. "candidates": candidates}
  73. return None
  74. def _reproduce_with_candidates(
  75. formula: Mapping[str, Any],
  76. merged: Mapping[str, Any],
  77. values: Mapping[str, Any],
  78. stated: Mapping[str, Any],
  79. resolved: Mapping[str, Any] | None,
  80. rules: RuleSet,
  81. ) -> dict[str, Any] | None:
  82. """自洽核验:计划写出的代入值,能否在规则库允许的某个口径下复现它写出的结果。
  83. 计划往往不标注参数口径(如"断面"到底是有效断面积还是净断面积),这时逐个候选变体
  84. 试算:**某一个**能复现即认为计划自洽;一个都复现不出来才是真的算错。这既避免把正确
  85. 计划误判成计算错误,也没有放宽判定——候选集来自规则库。计划没写结果时返回 None,
  86. 交由调用方按基础表达式走常规路径。
  87. """
  88. stated_result = stated.get("stated_result_m3_min")
  89. if not isinstance(stated_result, (int, float)):
  90. return None
  91. candidates = _candidate_expressions(formula, merged, rules)
  92. if not candidates:
  93. return None
  94. params = {**merged, **values}
  95. if resolved is not None:
  96. params.setdefault("component_required", list(((resolved or {}).get("planned") or {}).values()))
  97. params.setdefault("reserve_coefficient",
  98. values.get("reserve_coefficient", rules.constant("reserve_coefficient")))
  99. limit = max(abs(float(stated_result)) * 0.005, 1.0)
  100. evaluated: list[dict[str, Any]] = []
  101. for expression, variant in candidates:
  102. value, reason = try_evaluate(expression, params)
  103. evaluated.append({"expression": expression, "variant": variant,
  104. "value_m3_min": None if value is None else round(value, 4),
  105. "reason": reason})
  106. if value is not None and abs(value - float(stated_result)) <= limit:
  107. return {
  108. "status": STATUS_OK, "expression": expression, "source": "rule_library",
  109. "variant": variant,
  110. "variant_selection": ("declared" if variant else "base"),
  111. "recomputed_m3_min": round(value, 4),
  112. "substituted_values": dict(values),
  113. "stated_result_m3_min": stated_result,
  114. "difference_m3_min": round(float(stated_result) - value, 4),
  115. "candidates": evaluated,
  116. }
  117. return {
  118. "status": STATUS_INCONSISTENT,
  119. "reason": ("计划写出的代入值在规则库允许的任何口径下都得不到它写出的结果:"
  120. + ";".join(f"{entry['expression']} → {entry['value_m3_min']}" for entry in evaluated)),
  121. "expression": candidates[0][0],
  122. "candidates": evaluated,
  123. "substituted_values": dict(values),
  124. "stated_result_m3_min": stated_result,
  125. }
  126. def _reproduce_stated(
  127. item: Mapping[str, Any],
  128. facts: Mapping[str, Any],
  129. rules: RuleSet,
  130. resolved: Mapping[str, Any] | None = None,
  131. ) -> dict[str, Any] | None:
  132. """自洽核验:计划写的代入值,按它自己引用的公式应该得到什么结果。"""
  133. stated = item.get("stated_calculation")
  134. if not isinstance(stated, Mapping):
  135. return None
  136. values = as_mapping(stated.get("substituted_values"))
  137. # 汇总类对象的"代入值"就是它列出的各子项计划值
  138. if "component_required" in str(stated.get("expression") or "") or "sum(" in str(stated.get("expression") or ""):
  139. planned = (resolved or {}).get("planned") or {}
  140. if planned:
  141. values.setdefault("component_required", list(planned.values()))
  142. if not values and not stated.get("expression"):
  143. return None
  144. expression = None
  145. source = "plan_stated"
  146. formula_id = stated.get("formula_id") or item.get("planned_formula")
  147. if formula_id:
  148. formula = _find_formula(rules, str(formula_id), str(facts.get("location_type") or ""))
  149. if formula is not None:
  150. merged = {**facts, **values}
  151. outcome = _reproduce_with_candidates(formula, merged, values, stated, resolved, rules)
  152. if outcome is not None:
  153. return outcome
  154. selected = rules.select_formula(dict(formula), merged)
  155. if selected.get("expression"):
  156. expression = str(selected["expression"])
  157. source = "rule_library"
  158. if expression is None:
  159. expression = stated.get("expression")
  160. if not expression:
  161. return {"status": STATUS_INSUFFICIENT, "reason": "计划未给出可复算的计算式",
  162. "stated_result_m3_min": stated.get("stated_result_m3_min")}
  163. merged_params = {**facts, **values}
  164. if resolved is not None:
  165. merged_params.setdefault("component_required", list(((resolved or {}).get("planned") or {}).values()))
  166. merged_params.setdefault("reserve_coefficient", facts.get("reserve_coefficient",
  167. rules.constant("reserve_coefficient")))
  168. value, reason = try_evaluate(str(expression), merged_params)
  169. if value is None:
  170. return {"status": STATUS_INSUFFICIENT, "reason": reason, "expression": expression,
  171. "stated_result_m3_min": stated.get("stated_result_m3_min")}
  172. stated_result = stated.get("stated_result_m3_min")
  173. payload = {"status": STATUS_OK, "expression": expression, "source": source,
  174. "recomputed_m3_min": round(value, 4),
  175. "substituted_values": values,
  176. "stated_result_m3_min": stated_result}
  177. if isinstance(stated_result, (int, float)):
  178. payload["difference_m3_min"] = round(float(stated_result) - value, 4)
  179. return payload
  180. def _find_formula(rules: RuleSet, formula_id: str, location_type: str) -> dict[str, Any] | None:
  181. for bucket in (location_type, LOC_WORKPLACE_COAL, LOC_WORKPLACE_HEADING, LOC_CHAMBER,
  182. LOC_OTHER, LOC_STANDBY, TARGET_RETURN_SYSTEM, "mine_total"):
  183. for formula in rules.formulas(str(bucket)) or rules.formulas_for_target(str(bucket)):
  184. if formula.get("id") == formula_id:
  185. return formula
  186. return None