recalc.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. """需风量复算:按「用风地点 → 回风系统 → 矿井总需风量」自下而上逐层计算。
  2. 两种核验同时进行,二者的失败含义不同:
  3. 1. 独立复算(standard):用规则库公式 + 计划给定参数独立算一遍,与计划值比对。
  4. 差异说明公式适用性、参数取值或口径有问题。
  5. 2. 自洽核验(stated):用计划自己写出的代入数值,按规则库公式重算,
  6. 与计划写出的结果比对。差异说明计划本身算错了。
  7. 两种结果都写进计算链,报告里逐项展示。
  8. """
  9. from __future__ import annotations
  10. from typing import Any, Mapping
  11. from algorithms.airflow_review.contracts import (
  12. LOC_CHAMBER,
  13. as_mapping,
  14. LOC_OTHER,
  15. LOC_STANDBY,
  16. LOC_WORKPLACE_COAL,
  17. LOC_WORKPLACE_HEADING,
  18. PLAN_TARGET_TYPES,
  19. TARGET_MINE_INTAKE,
  20. TARGET_MINE_RETURN,
  21. TARGET_RETURN_SYSTEM,
  22. unique,
  23. )
  24. from algorithms.airflow_review.engine.expression import try_evaluate
  25. from algorithms.airflow_review.engine.variants import (
  26. STATUS_INCONSISTENT,
  27. _infer_variant,
  28. _reproduce_stated,
  29. )
  30. from algorithms.airflow_review.rules.loader import RuleSet, derive_params
  31. STATUS_OK = "ok"
  32. STATUS_INSUFFICIENT = "insufficient"
  33. STATUS_NOT_APPLICABLE = "not_applicable"
  34. # 复算顺序:自下而上(§2.6 第 4 条)
  35. _ORDER = {LOC_WORKPLACE_COAL: 0, LOC_WORKPLACE_HEADING: 0, LOC_STANDBY: 0,
  36. LOC_CHAMBER: 0, LOC_OTHER: 0, TARGET_RETURN_SYSTEM: 1, "mine_total": 2}
  37. def recalc_plan_item(
  38. item: Mapping[str, Any],
  39. rules: RuleSet,
  40. resolved: Mapping[str, Any] | None = None,
  41. ) -> dict[str, Any]:
  42. """复算单个计划对象。resolved 为子项汇总所需的两路需风量(见 _resolve_components)。"""
  43. target_type = str(item.get("target_type") or "")
  44. location_type = item.get("location_type") or _location_type_for(target_type, item)
  45. facts = as_mapping(item.get("planned_parameters"))
  46. facts.setdefault("target_id", item.get("target_id"))
  47. facts.setdefault("location_type", location_type)
  48. if item.get("chamber_type"):
  49. facts.setdefault("chamber_type", item["chamber_type"])
  50. if item.get("roadway_type"):
  51. facts.setdefault("roadway_type", item["roadway_type"])
  52. if item.get("mining_method"):
  53. facts.setdefault("mining_method", item["mining_method"])
  54. result: dict[str, Any] = {
  55. "target_id": item.get("target_id"),
  56. "target_type": target_type,
  57. "location_type": location_type,
  58. "target_name": item.get("target_name") or item.get("target_id"),
  59. "planned_required_airflow_m3_min": item.get("planned_required_airflow_m3_min"),
  60. "aggregation_basis": "planned_components" if target_type in (TARGET_RETURN_SYSTEM, "mine_total") else "",
  61. "methods": [],
  62. "checks": [],
  63. "warnings": [],
  64. "evidence_ids": unique(list(item.get("evidence_ids") or []) + [item.get("evidence_id")]),
  65. }
  66. if location_type == LOC_STANDBY:
  67. result["methods"].append(_standby_method(item, rules, resolved))
  68. elif target_type in (TARGET_RETURN_SYSTEM, "mine_total"):
  69. result["methods"].append(_aggregate_method(item, rules, target_type, resolved))
  70. else:
  71. stated_result = (item.get("stated_calculation") or {}).get("stated_result_m3_min")
  72. stated_result = stated_result if isinstance(stated_result, (int, float)) else None
  73. for formula in rules.formulas(location_type):
  74. result["methods"].append(_evaluate_formula(formula, facts, rules, stated_result))
  75. inferred = [method["method_id"] for method in result["methods"]
  76. if method.get("variant_selection") == "inferred_from_stated_calculation"]
  77. if inferred:
  78. result["warnings"].append(
  79. "参数口径未标注或无法识别,以下计算方法的公式变体是按计划写出的结果反推的,"
  80. "建议核实计划采用的参数口径:" + "、".join(inferred))
  81. candidates = [method["value_m3_min"] for method in result["methods"]
  82. if method["status"] == STATUS_OK and method.get("value_m3_min") is not None]
  83. if candidates:
  84. result["calculated_required_airflow_m3_min"] = round(max(candidates), 4)
  85. result["calculation_status"] = STATUS_OK
  86. result["governing_method_id"] = next(
  87. method["method_id"] for method in result["methods"]
  88. if method.get("value_m3_min") == max(candidates))
  89. else:
  90. result["calculated_required_airflow_m3_min"] = None
  91. result["calculation_status"] = STATUS_INSUFFICIENT
  92. result["governing_method_id"] = None
  93. result["warnings"].append("所有计算方法均缺少参数,无法复算需风量")
  94. result["checks"] = _run_checks(item, facts, rules, location_type, result)
  95. result["min_airflow_m3_min"], result["max_airflow_m3_min"] = _bounds(result)
  96. result["stated_reproduction"] = _reproduce_stated(item, facts, rules, resolved)
  97. return result
  98. def recalc_all(items: list[Mapping[str, Any]], rules: RuleSet) -> dict[str, dict[str, Any]]:
  99. """按层级顺序复算全部对象。
  100. 父级汇总以子项的**计划值**为基准:父级的正确性 = 它的声称值是否等于其子项
  101. 声称值的汇总(这正是"汇总错误/漏算/重复计算"的检验)。子项各自的复算值另外
  102. 汇总一路,用于观察误差是否向上传播。
  103. """
  104. ordered = sorted(items, key=lambda item: (
  105. _ORDER.get(str(item.get("target_type")), 9),
  106. _ORDER.get(str(item.get("location_type") or ""), 0),
  107. str(item.get("target_id")),
  108. ))
  109. results: dict[str, dict[str, Any]] = {}
  110. for item in ordered:
  111. resolved = _resolve_components(item, items, results)
  112. results[str(item.get("target_id"))] = recalc_plan_item(item, rules, resolved)
  113. return results
  114. def _resolve_components(
  115. item: Mapping[str, Any],
  116. items: list[Mapping[str, Any]],
  117. results: dict[str, dict[str, Any]],
  118. ) -> dict[str, Any]:
  119. """收集组成对象的两路需风量:计划值(汇总核验基准)与复算值(传播观察)。"""
  120. if str(item.get("target_type")) not in (TARGET_RETURN_SYSTEM, "mine_total"):
  121. return {}
  122. by_id = {str(entry.get("target_id")): entry for entry in items}
  123. child_ids = list(item.get("component_workplace_ids") or item.get("component_return_system_ids")
  124. or item.get("component_target_ids") or [])
  125. planned: dict[str, float] = {}
  126. recalculated: dict[str, float] = {}
  127. missing_planned: list[str] = []
  128. for child_id in child_ids:
  129. child_id = str(child_id)
  130. child = by_id.get(child_id) or {}
  131. value = child.get("planned_required_airflow_m3_min")
  132. if isinstance(value, (int, float)):
  133. planned[child_id] = float(value)
  134. else:
  135. missing_planned.append(child_id)
  136. child_result = results.get(child_id)
  137. if child_result and child_result.get("calculated_required_airflow_m3_min") is not None:
  138. recalculated[child_id] = child_result["calculated_required_airflow_m3_min"]
  139. return {
  140. "planned": planned,
  141. "recalculated": recalculated,
  142. "recalculated_complete": len(recalculated) == len([cid for cid in child_ids if cid]),
  143. "missing_planned": missing_planned,
  144. "child_count": len(child_ids),
  145. }
  146. def _location_type_for(target_type: str, item: Mapping[str, Any]) -> str:
  147. if target_type in (TARGET_MINE_INTAKE, TARGET_MINE_RETURN, "mine_total"):
  148. return "矿井"
  149. if target_type == TARGET_RETURN_SYSTEM:
  150. return TARGET_RETURN_SYSTEM
  151. return str(item.get("location_type") or "")
  152. def _evaluate_formula(
  153. formula: Mapping[str, Any],
  154. facts: Mapping[str, Any],
  155. rules: RuleSet,
  156. stated_result: float | None = None,
  157. ) -> dict[str, Any]:
  158. """按公式(含变体)计算,并记录变体是怎么选出来的。
  159. 口径标记(如 section_area_basis)写错或没写时,如果直接退回基础表达式,会把一份
  160. 正确的计划算成"与计划不符"从而整盘阻断。因此这里做一次**确定性反推**:拿计划自己
  161. 写出的结果去对每个候选表达式,命中哪个就采用哪个,并把"这个变体是反推来的"记在
  162. 计算链里、同时给出提示,让人能复核。计划没写结果时才退回基础表达式。
  163. """
  164. selected = rules.select_formula(dict(formula), facts)
  165. method: dict[str, Any] = {
  166. "method_id": formula.get("id"),
  167. "name": formula.get("name"),
  168. "citation": formula.get("citation"),
  169. "aggregation": formula.get("aggregation"),
  170. "expression": selected.get("expression"),
  171. "variant": selected.get("variant"),
  172. "variant_selection": "declared" if selected.get("variant") else "default",
  173. "note": selected.get("note", ""),
  174. }
  175. if selected.get("expression") is None:
  176. method.update({"status": STATUS_NOT_APPLICABLE, "value_m3_min": None,
  177. "reason": selected.get("reason", "公式不适用")})
  178. return method
  179. params, missing, derived = derive_params(dict(formula), facts, rules)
  180. method["params"] = {name: params.get(name) for name in (formula.get("params") or {})}
  181. method["derived"] = derived
  182. if missing:
  183. method.update({"status": STATUS_INSUFFICIENT, "value_m3_min": None,
  184. "missing_params": missing,
  185. "reason": "缺少参数:" + "、".join(missing)})
  186. return method
  187. if not selected.get("variant"):
  188. inferred = _infer_variant(formula, params, stated_result)
  189. if inferred is not None:
  190. method.update({"expression": inferred["expression"], "variant": inferred["variant"],
  191. "variant_selection": "inferred_from_stated_calculation",
  192. "variant_candidates": inferred["candidates"],
  193. "note": ("参数口径未标注或无法识别,已按计划写出的结果反推采用该变体;"
  194. "建议核实计划采用的参数口径")})
  195. value, reason = try_evaluate(str(method["expression"]), params)
  196. if value is None:
  197. method.update({"status": STATUS_INSUFFICIENT, "value_m3_min": None, "reason": reason})
  198. return method
  199. method.update({"status": STATUS_OK, "value_m3_min": round(value, 4)})
  200. return method
  201. def _standby_method(
  202. item: Mapping[str, Any],
  203. rules: RuleSet,
  204. resolved: Mapping[str, Any] | None,
  205. ) -> dict[str, Any]:
  206. """备用工作面:规则给出的是下限,因此用「参照采面需风量 × 50%」作为核验线。"""
  207. formula = (rules.formulas(LOC_STANDBY) or [{}])[0]
  208. facts = as_mapping(item.get("planned_parameters"))
  209. ref_id = item.get("referenced_workplace_id")
  210. referenced = None
  211. if ref_id and resolved:
  212. referenced = (resolved.get("planned") or {}).get(str(ref_id))
  213. if referenced is None:
  214. referenced = facts.get("referenced_required_airflow")
  215. method = {
  216. "method_id": formula.get("id"),
  217. "name": formula.get("name"),
  218. "citation": formula.get("citation"),
  219. "aggregation": "min_bound",
  220. "expression": formula.get("expression"),
  221. "params": {"referenced_required_airflow": referenced},
  222. "reference_workplace_id": ref_id,
  223. }
  224. if referenced is None:
  225. method.update({"status": STATUS_INSUFFICIENT, "value_m3_min": None,
  226. "reason": "缺少参照采煤工作面的需风量"})
  227. return method
  228. value, reason = try_evaluate(str(formula.get("expression")), {"referenced_required_airflow": float(referenced)})
  229. if value is None:
  230. method.update({"status": STATUS_INSUFFICIENT, "value_m3_min": None, "reason": reason})
  231. return method
  232. method.update({"status": STATUS_OK, "value_m3_min": round(value, 4)})
  233. return method
  234. def _aggregate_method(
  235. item: Mapping[str, Any],
  236. rules: RuleSet,
  237. target_type: str,
  238. resolved: Mapping[str, Any] | None,
  239. ) -> dict[str, Any]:
  240. """回风系统与矿井总需风量的汇总公式。
  241. 汇总核验以子项计划值为基准(检验漏算、重复计算与算错);子项复算值另走一路,
  242. 完整时给出 propagation_value_m3_min,用于观察复算误差是否向上传播。
  243. """
  244. formula = (rules.formulas_for_target(str(target_type)) or [{}])[0]
  245. facts = as_mapping(item.get("planned_parameters"))
  246. planned = dict((resolved or {}).get("planned") or {})
  247. recalculated = dict((resolved or {}).get("recalculated") or {})
  248. child_count = (resolved or {}).get("child_count") or 0
  249. method: dict[str, Any] = {
  250. "method_id": formula.get("id"),
  251. "name": formula.get("name"),
  252. "citation": formula.get("citation"),
  253. "aggregation": formula.get("aggregation"),
  254. "expression": formula.get("expression"),
  255. "basis": "planned_components",
  256. "component_count": child_count,
  257. "component_values": planned,
  258. "missing_component_ids": list((resolved or {}).get("missing_planned") or []),
  259. "params": {"reserve_coefficient": facts.get("reserve_coefficient",
  260. rules.constant("reserve_coefficient"))},
  261. }
  262. if not planned:
  263. method.update({"status": STATUS_INSUFFICIENT, "value_m3_min": None,
  264. "reason": "缺少组成对象的计划需风量,无法校验汇总关系"})
  265. return method
  266. factor = method["params"]["reserve_coefficient"]
  267. value, reason = try_evaluate(str(formula.get("expression")),
  268. {"component_required": list(planned.values()),
  269. "reserve_coefficient": factor})
  270. if value is None:
  271. method.update({"status": STATUS_INSUFFICIENT, "value_m3_min": None, "reason": reason})
  272. return method
  273. method.update({"status": STATUS_OK, "value_m3_min": round(value, 4)})
  274. if len(recalculated) == child_count and child_count:
  275. propagated, propagated_reason = try_evaluate(
  276. str(formula.get("expression")),
  277. {"component_required": list(recalculated.values()), "reserve_coefficient": factor},
  278. )
  279. if propagated is None:
  280. method["propagation_note"] = f"复算值汇总失败:{propagated_reason}"
  281. else:
  282. method["propagation_value_m3_min"] = round(propagated, 4)
  283. method["recalculated_component_values"] = recalculated
  284. else:
  285. method["propagation_note"] = (
  286. f"仅 {len(recalculated)}/{child_count} 个组成对象完成复算,未给出复算传播值"
  287. )
  288. return method
  289. def _run_checks(
  290. item: Mapping[str, Any],
  291. facts: Mapping[str, Any],
  292. rules: RuleSet,
  293. location_type: str,
  294. result: Mapping[str, Any],
  295. ) -> list[dict[str, Any]]:
  296. """验算:把规则里的不等式取右边的计算值,与左侧主体值比较。"""
  297. subject = {
  298. "required_airflow_m3_min": result.get("calculated_required_airflow_m3_min")
  299. if result.get("calculated_required_airflow_m3_min") is not None
  300. else item.get("planned_required_airflow_m3_min"),
  301. "planned_required_airflow_m3_min": item.get("planned_required_airflow_m3_min"),
  302. }
  303. subject.update({key: value for key, value in facts.items() if value is not None})
  304. checks: list[dict[str, Any]] = []
  305. for spec in rules.verification(location_type):
  306. check: dict[str, Any] = {
  307. "check_id": spec.get("id"),
  308. "name": spec.get("name"),
  309. "operator": spec.get("operator"),
  310. "citation": spec.get("citation"),
  311. "expression": spec.get("expression"),
  312. }
  313. if spec.get("applicable_when") and not all(
  314. facts.get(key) == value for key, value in spec["applicable_when"].items()
  315. ):
  316. check.update({"status": STATUS_NOT_APPLICABLE, "reason": "验算条件不适用"})
  317. checks.append(check)
  318. continue
  319. left_value = subject.get(str(spec.get("left")))
  320. params, missing, derived = derive_params(dict(spec), facts, rules)
  321. check["derived"] = derived
  322. if missing:
  323. check.update({"status": STATUS_INSUFFICIENT, "reason": "缺少参数:" + "、".join(missing)})
  324. checks.append(check)
  325. continue
  326. right_value, reason = try_evaluate(str(spec.get("expression")), params)
  327. if right_value is None:
  328. check.update({"status": STATUS_INSUFFICIENT, "reason": reason})
  329. checks.append(check)
  330. continue
  331. check["left_value"] = left_value
  332. check["limit_value"] = round(right_value, 4)
  333. check["params"] = {name: params.get(name) for name in (spec.get("params") or {})}
  334. if not isinstance(left_value, (int, float)):
  335. check.update({"status": STATUS_INSUFFICIENT, "reason": "左侧主体值缺失"})
  336. checks.append(check)
  337. continue
  338. check["status"] = "pass" if _compare(spec.get("operator"), float(left_value), right_value) else "fail"
  339. checks.append(check)
  340. return checks
  341. def _compare(operator: Any, left: float, right: float) -> bool:
  342. tolerance = abs(right) * 1e-9 + 1e-9
  343. if operator == ">=":
  344. return left >= right - tolerance
  345. if operator == "<=":
  346. return left <= right + tolerance
  347. if operator == ">":
  348. return left > right
  349. if operator == "<":
  350. return left < right
  351. return False
  352. def _bounds(result: Mapping[str, Any]) -> tuple[float | None, float | None]:
  353. """从验算项里抽出需风量的上下限,供比对与报告引用。"""
  354. low: float | None = None
  355. high: float | None = None
  356. for check in result.get("checks") or []:
  357. if check.get("status") != STATUS_OK and check.get("limit_value") is None:
  358. continue
  359. limit = check.get("limit_value")
  360. if limit is None:
  361. continue
  362. if check.get("operator") in (">=", ">"):
  363. low = max(low, limit) if low is not None else limit
  364. elif check.get("operator") in ("<=", "<"):
  365. high = min(high, limit) if high is not None else limit
  366. return low, high
  367. def within_tolerance(
  368. planned: float | None,
  369. calculated: float | None,
  370. tolerance: Mapping[str, float],
  371. ) -> tuple[bool, float | None, float | None]:
  372. """绝对与相对容差需同时满足;用于吸收计划取整(如 1220.4 → 1221)。"""
  373. if planned is None or calculated is None:
  374. return False, None, None
  375. difference = round(float(calculated) - float(planned), 4)
  376. absolute_limit = float(tolerance.get("absolute_m3_min", 1.0))
  377. relative_limit = abs(float(planned)) * float(tolerance.get("relative_percent", 0.5)) / 100.0
  378. limit = max(absolute_limit, relative_limit)
  379. return abs(difference) <= limit, difference, round(limit, 4)
  380. __all__ = ["STATUS_OK", "STATUS_INSUFFICIENT", "STATUS_NOT_APPLICABLE", "STATUS_INCONSISTENT",
  381. "recalc_plan_item", "recalc_all", "within_tolerance", "PLAN_TARGET_TYPES"]