test_airflow_engine.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. """风量研判引擎的离线单测:规则库、表达式、复算、层级、核验、比对、校核、去重、证据链。
  2. 基准样例覆盖需求 §2.11.7 要求的九类场景:正常、计划计算错误、层级归属错误、单一回风
  3. 系统、总风量不足、回风系统风量不足、工作面低于 75%、数据冲突、时间无法对齐。
  4. """
  5. from __future__ import annotations
  6. import pytest
  7. from algorithms.airflow_review.contracts import to_m3_min
  8. from algorithms.airflow_review.engine.compare import compare_targets
  9. from algorithms.airflow_review.engine.dedupe import dedupe_findings
  10. from algorithms.airflow_review.engine.expression import ExpressionError, evaluate, missing_params
  11. from algorithms.airflow_review.engine.findings import summarize
  12. from algorithms.airflow_review.engine.hierarchy import build_hierarchy
  13. from algorithms.airflow_review.engine.recalc import recalc_all, recalc_plan_item, within_tolerance
  14. from algorithms.airflow_review.engine.reconcile import reconcile_sources
  15. from algorithms.airflow_review.engine.trace import trace_evidence
  16. from algorithms.airflow_review.engine.verify import verify_plan
  17. from algorithms.airflow_review.rules.loader import (
  18. RuleError,
  19. available_versions,
  20. classify_location,
  21. derive_params,
  22. load_rules,
  23. )
  24. PLAN_WINDOW = {"start": "2026-05-01T00:00:00+08:00", "end": "2026-05-31T23:59:59+08:00"}
  25. MAY_POINT = {"start": "2026-05-15T09:00:00+08:00", "end": "2026-05-15T09:00:00+08:00"}
  26. @pytest.fixture(scope="module")
  27. def rules():
  28. return load_rules()
  29. # ---- 规则库与表达式 -------------------------------------------------------
  30. def test_rule_library_is_versioned_and_loaded(rules):
  31. assert rules.rule_version in available_versions()
  32. assert rules.thresholds()["compare"]["workplace"]["threshold_ratio"] == 0.75
  33. assert rules.compare_policy("mine_total_intake")["finding_type"] == "total_airflow_shortage"
  34. assert rules.tolerance()["absolute_m3_min"] == 1.0
  35. def test_unknown_rule_version_is_rejected():
  36. with pytest.raises(RuleError, match="规则版本不可用"):
  37. load_rules("AIR-RULE-1900.1")
  38. def test_expression_evaluator_is_whitelisted():
  39. assert evaluate("60 * 0.7 * 1.5 * 11.3 * 1.2 * 1.5", {}) == pytest.approx(1281.42, rel=1e-6)
  40. assert evaluate("sum(values) * factor", {"values": [1, 2, 3], "factor": 1.2}) == pytest.approx(7.2)
  41. with pytest.raises(ExpressionError):
  42. evaluate("__import__('os').system('echo x')", {})
  43. with pytest.raises(ExpressionError):
  44. evaluate("a ** 99", {"a": 2})
  45. assert missing_params("a + b + c", {"a": 1}) == ["b", "c"]
  46. def test_table_lookup_interpolates_and_clamps(rules):
  47. value, row = rules.lookup_table("v_cfi_by_temp", "max_temperature", 24.5, ["min_speed", "max_speed"])
  48. assert 1.5 < value < 1.8
  49. assert row["max_temp"] == 26
  50. height, _ = rules.lookup_table("k_ch_by_height", "cutting_height", 3.4, "k")
  51. assert height == 1.2
  52. eta, _ = rules.lookup_table("leakage_eta_by_distance", "distance", 1500, "max_eta")
  53. assert eta == 2
  54. def test_derive_params_records_provenance(rules):
  55. formula = rules.formulas("采煤工作面")[0]
  56. params, missing, trace = derive_params(formula, {"max_temperature": 22, "cutting_height": 2.3,
  57. "face_length": 180, "max_support_width": 4.8,
  58. "min_support_width": 4.2}, rules)
  59. assert not missing
  60. assert params["k_chi"] == 1.1
  61. assert any(entry.get("table") == "k_ch_by_height" for entry in trace)
  62. def test_unit_conversion_and_unknown_unit():
  63. value, warning = to_m3_min(1.5, "m3/s")
  64. assert value == 90.0 and warning is None
  65. value, warning = to_m3_min("120", "m3/h")
  66. assert value == 2.0 and warning is None
  67. value, warning = to_m3_min(100, "方/分钟")
  68. assert value is None and "未识别" in warning
  69. def test_location_classification_and_unknown_falls_through():
  70. assert classify_location("22210 综采工作面")[0] == "采煤工作面"
  71. assert classify_location("12 上305 辅运顺槽工作面局扇安设点")[0] == "掘进工作面"
  72. assert classify_location("一水平消防材料库")[0] == "硐室"
  73. assert classify_location("二水平北边界巷")[0] == "其他巷道"
  74. location_type, reason = classify_location("某个没见过的东西")
  75. assert location_type == "" and "未命中" in reason
  76. # ---- 复算 -----------------------------------------------------------------
  77. def test_recalc_uses_section_area_variant(rules):
  78. """真实计划里 S_cfi 已含 70% 有效断面系数,变体必须命中,不能重复相乘。"""
  79. item = _workplace_item()
  80. result = recalc_plan_item(item, rules)
  81. assert result["calculated_required_airflow_m3_min"] == pytest.approx(1220.4)
  82. assert result["governing_method_id"] == "WF-CFI-01"
  83. assert result["methods"][0]["variant"] == {"section_area_basis": "有效断面积"}
  84. reproduced = result["stated_reproduction"]
  85. assert reproduced["source"] == "rule_library"
  86. assert reproduced["recomputed_m3_min"] == pytest.approx(1220.4)
  87. def test_recalc_marks_insufficient_when_params_missing(rules):
  88. item = {"target_id": "L9", "target_type": "workplace", "location_type": "采煤工作面",
  89. "target_name": "缺参数面", "planned_required_airflow_m3_min": 900, "planned_parameters": {}}
  90. result = recalc_plan_item(item, rules)
  91. assert result["calculation_status"] == "insufficient"
  92. assert result["calculated_required_airflow_m3_min"] is None
  93. assert any(method["status"] == "insufficient" for method in result["methods"])
  94. def test_tolerance_absorbs_rounding_but_not_errors():
  95. within, difference, limit = within_tolerance(1221, 1220.4, {"absolute_m3_min": 1.0, "relative_percent": 0.5})
  96. assert within and difference == pytest.approx(-0.6) and limit == pytest.approx(6.105)
  97. within, _, _ = within_tolerance(900, 850, {"absolute_m3_min": 1.0, "relative_percent": 0.5})
  98. assert not within
  99. def test_aggregation_uses_component_planned_values(rules):
  100. items = [_workplace_item(), _return_system_item()]
  101. results = recalc_all(items, rules)
  102. aggregate = results["R1"]
  103. assert aggregate["aggregation_basis"] == "planned_components"
  104. assert aggregate["calculated_required_airflow_m3_min"] == pytest.approx(1465.2)
  105. # ---- 层级 -----------------------------------------------------------------
  106. def test_hierarchy_detects_orphan_duplicate_and_cycle():
  107. items = [
  108. {"target_id": "L1", "target_type": "workplace", "target_name": "A", "parent_return_system_id": "R1",
  109. "planned_required_airflow_m3_min": 100},
  110. {"target_id": "L2", "target_type": "workplace", "target_name": "B",
  111. "planned_required_airflow_m3_min": 100},
  112. {"target_id": "R1", "target_type": "return_air_system", "target_name": "系统一",
  113. "component_workplace_ids": ["L1", "L1"], "parent_target_id": "MINE"},
  114. {"target_id": "MINE", "target_type": "mine_total", "target_name": "总",
  115. "component_return_system_ids": ["R1"]},
  116. ]
  117. result = build_hierarchy(items)
  118. codes = {issue["code"] for issue in result["issues"]}
  119. assert "orphan_target" in codes
  120. assert "duplicate_in_aggregation" in codes
  121. def test_hierarchy_detects_cycle():
  122. items = [
  123. {"target_id": "MINE", "target_type": "mine_total", "target_name": "总",
  124. "component_return_system_ids": ["R1"], "parent_target_id": "R2"},
  125. {"target_id": "R1", "target_type": "return_air_system", "target_name": "系统",
  126. "component_workplace_ids": ["L1"], "parent_target_id": "MINE"},
  127. {"target_id": "R2", "target_type": "return_air_system", "target_name": "系统2",
  128. "component_workplace_ids": ["L1"], "parent_target_id": "R1"},
  129. {"target_id": "L1", "target_type": "workplace", "target_name": "A", "parent_return_system_id": "R1",
  130. "planned_required_airflow_m3_min": 100},
  131. ]
  132. result = build_hierarchy(items)
  133. assert any(issue["code"] == "cyclic_reference" for issue in result["issues"])
  134. def test_single_return_system_equivalence_and_reuse_key(rules):
  135. items = [_workplace_item(), _return_system_item()]
  136. items[1]["component_workplace_ids"] = ["L1"]
  137. items[1]["planned_required_airflow_m3_min"] = 1465.2
  138. items.append({"target_id": "MINE_TOTAL", "target_type": "mine_total", "target_name": "矿井总需风量",
  139. "component_return_system_ids": ["R1"], "planned_required_airflow_m3_min": 1465.2})
  140. result = verify_plan({"plan_id": "P", "plan_version": "V1", "effective_window": PLAN_WINDOW,
  141. "items": items}, rules)
  142. equivalence = result["hierarchy"]["single_return_equivalence"]
  143. assert equivalence["applicable"] and equivalence["equivalent"]
  144. reuse_keys = {item["target_id"]: item["required_reuse_key"] for item in result["verified_items"]}
  145. assert reuse_keys["R1"] == reuse_keys["MINE_TOTAL"] != ""
  146. # ---- 基准样例:九类场景 ---------------------------------------------------
  147. def test_baseline_normal_plan_and_comparison(rules):
  148. result = verify_plan(_normal_plan(), rules)
  149. assert result["plan_calculation_status"] == "valid"
  150. assert result["plan_authoritative"] is True
  151. assert not result["blocked_items"]
  152. comparison = compare_targets([{
  153. "target_id": "MINE_TOTAL_INTAKE", "target_type": "mine_total_intake", "target_name": "矿井总进风量",
  154. "actual_airflow_m3_min": 7300, "required_airflow_m3_min": 6800,
  155. "plan_verification_status": "valid", "actual_window": MAY_POINT,
  156. "required_effective_window": PLAN_WINDOW,
  157. }], rules)
  158. record = comparison["comparisons"][0]
  159. assert record["comparison_status"] == "normal"
  160. assert not comparison["findings"]
  161. def test_baseline_plan_calculation_error_blocks_ancestors(rules):
  162. plan = _normal_plan()
  163. plan["items"][0]["planned_required_airflow_m3_min"] = 900 # 复算值 1220.4,超出容差
  164. result = verify_plan(plan, rules)
  165. assert result["plan_calculation_status"] == "invalid"
  166. assert result["plan_authoritative"] is False
  167. assert "L1" in result["blocked_items"]
  168. assert "R1" in result["blocked_items"] # 错误沿层级向上传播
  169. assert "MINE_TOTAL" in result["blocked_items"]
  170. finding = next(item for item in result["findings"] if item["target_id"] == "L1")
  171. assert finding["finding_type"] == "plan_calculation_mismatch"
  172. assert finding["is_hazard"] is True and finding["severity"] == "major"
  173. def test_baseline_hierarchy_ownership_error(rules):
  174. plan = _normal_plan()
  175. plan["items"][0].pop("parent_return_system_id")
  176. result = verify_plan(plan, rules)
  177. issues = {issue["code"] for issue in result["hierarchy"]["issues"]}
  178. assert "orphan_target" in issues
  179. assert result["plan_calculation_status"] == "manual_review"
  180. def test_baseline_total_airflow_shortage(rules):
  181. comparison = compare_targets([{
  182. "target_id": "MINE_TOTAL_RETURN", "target_type": "mine_total_return", "target_name": "矿井总回风量",
  183. "actual_airflow_m3_min": 6000, "required_airflow_m3_min": 6800,
  184. "plan_verification_status": "valid", "actual_window": MAY_POINT,
  185. "required_effective_window": PLAN_WINDOW,
  186. }], rules)
  187. finding = comparison["findings"][0]
  188. assert finding["finding_type"] == "total_airflow_shortage"
  189. assert finding["severity"] == "major"
  190. assert finding["shortfall_m3_min"] == pytest.approx(800)
  191. def test_baseline_return_system_shortage(rules):
  192. comparison = compare_targets([{
  193. "target_id": "R1", "target_type": "return_air_system", "target_name": "南翼回风系统",
  194. "actual_airflow_m3_min": 3000, "required_airflow_m3_min": 3200,
  195. "plan_verification_status": "valid", "actual_window": MAY_POINT,
  196. "required_effective_window": PLAN_WINDOW,
  197. }], rules)
  198. finding = comparison["findings"][0]
  199. assert finding["finding_type"] == "return_system_airflow_shortage"
  200. assert finding["severity"] == "major"
  201. def test_baseline_workplace_below_75_percent(rules):
  202. comparison = compare_targets([{
  203. "target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
  204. "target_name": "1301采煤工作面",
  205. "actual_airflow_m3_min": 640, "required_airflow_m3_min": 900,
  206. "plan_verification_status": "valid", "actual_window": MAY_POINT,
  207. "required_effective_window": PLAN_WINDOW,
  208. }], rules)
  209. finding = comparison["findings"][0]
  210. assert finding["finding_type"] == "workplace_airflow_below_75_percent"
  211. assert finding["is_major_hazard"] is True
  212. assert finding["ratio"] == pytest.approx(0.7111)
  213. assert finding["disposal_note"]
  214. def test_baseline_workplace_between_75_and_100_is_attention(rules):
  215. comparison = compare_targets([{
  216. "target_id": "L1", "target_type": "workplace", "location_type": "掘进工作面",
  217. "target_name": "12202辅运顺槽", "actual_airflow_m3_min": 800, "required_airflow_m3_min": 900,
  218. "plan_verification_status": "valid", "actual_window": MAY_POINT,
  219. "required_effective_window": PLAN_WINDOW,
  220. }], rules)
  221. finding = comparison["findings"][0]
  222. assert finding["severity"] == "attention"
  223. assert finding["is_major_hazard"] is False
  224. def test_baseline_time_not_aligned_is_manual_review(rules):
  225. comparison = compare_targets([{
  226. "target_id": "MINE_TOTAL_INTAKE", "target_type": "mine_total_intake", "target_name": "矿井总进风量",
  227. "actual_airflow_m3_min": 35181, "required_airflow_m3_min": 32497.2,
  228. "plan_verification_status": "valid",
  229. "actual_window": {"start": "2026-05-15T09:00:00+08:00", "end": "2026-05-15T09:00:00+08:00"},
  230. "required_effective_window": {"start": "2024-12-01T00:00:00+08:00", "end": "2024-12-31T23:59:59+08:00"},
  231. }], rules)
  232. record = comparison["comparisons"][0]
  233. assert record["time_alignment_status"] == "failed"
  234. assert record["comparison_status"] == "insufficient"
  235. assert comparison["findings"][0]["finding_type"] == "time_alignment_failed"
  236. def test_baseline_plan_not_verified_is_not_comparable(rules):
  237. comparison = compare_targets([{
  238. "target_id": "L1", "target_type": "workplace", "target_name": "1301采煤工作面",
  239. "actual_airflow_m3_min": 640, "required_airflow_m3_min": 900,
  240. "plan_verification_status": "invalid", "actual_window": MAY_POINT,
  241. "required_effective_window": PLAN_WINDOW,
  242. }], rules)
  243. record = comparison["comparisons"][0]
  244. assert record["comparison_status"] == "not_comparable"
  245. assert "未取得权威需风量" in record["finding"]["conclusion"]
  246. def test_baseline_missing_actual_is_insufficient_not_normal(rules):
  247. comparison = compare_targets([{
  248. "target_id": "L1", "target_type": "workplace", "target_name": "1301采煤工作面",
  249. "actual_airflow_m3_min": None, "required_airflow_m3_min": 900,
  250. "plan_verification_status": "valid", "actual_window": MAY_POINT,
  251. "required_effective_window": PLAN_WINDOW,
  252. }], rules)
  253. record = comparison["comparisons"][0]
  254. assert record["comparison_status"] == "insufficient"
  255. assert comparison["findings"][0]["manual_review_required"] is True
  256. def test_baseline_data_conflict_is_reported_without_averaging(rules):
  257. result = reconcile_sources({
  258. "target_id": "L1", "target_type": "workplace", "target_name": "1301采煤工作面",
  259. "comparison_window": MAY_POINT,
  260. "sources": [
  261. {"source_type": "measurement_report", "airflow_m3_min": 860,
  262. "observed_at": "2026-05-15T09:00:00+08:00"},
  263. {"source_type": "monitoring", "airflow_m3_min": 600,
  264. "observed_at": "2026-05-15T09:05:00+08:00"},
  265. ],
  266. }, rules)
  267. assert result["consistency"] == "conflict"
  268. assert result["requires_manual_review"] is True
  269. assert result["finding"]["finding_type"] == "data_conflict"
  270. assert result["recommended_airflow_m3_min"] == 860 # 采用高优先级来源,不做平均
  271. def test_reconcile_accepts_consistent_sources_and_flags_time_offset(rules):
  272. ok = reconcile_sources({
  273. "target_id": "L1", "target_type": "workplace", "target_name": "面",
  274. "comparison_window": MAY_POINT,
  275. "sources": [
  276. {"source_type": "measurement_report", "airflow_m3_min": 860, "observed_at": "2026-05-15T09:00:00+08:00"},
  277. {"source_type": "monitoring", "airflow_m3_min": 820, "observed_at": "2026-05-15T09:30:00+08:00"},
  278. ],
  279. }, rules)
  280. assert ok["consistency"] == "acceptable"
  281. assert ok["deviation_percent"] == pytest.approx(4.878, rel=1e-3)
  282. assert ok["recommended_source"] == "measurement_report"
  283. offset = reconcile_sources({
  284. "target_id": "L1", "target_type": "workplace", "target_name": "面",
  285. "comparison_window": MAY_POINT,
  286. "sources": [
  287. {"source_type": "measurement_report", "airflow_m3_min": 860, "observed_at": "2026-05-15T09:00:00+08:00"},
  288. {"source_type": "monitoring", "airflow_m3_min": 860, "observed_at": "2026-05-15T15:00:00+08:00"},
  289. ],
  290. }, rules)
  291. assert offset["time_alignment_status"] == "failed"
  292. assert offset["finding"]["finding_type"] == "time_alignment_failed"
  293. # ---- 去重与证据链 ---------------------------------------------------------
  294. def test_dedupe_merges_same_target_and_dedups_reuse(rules):
  295. comparison = compare_targets([
  296. {"target_id": "MINE_TOTAL", "target_type": "mine_total_intake", "target_name": "矿井总进风量",
  297. "actual_airflow_m3_min": 6000, "required_airflow_m3_min": 6800, "plan_verification_status": "valid",
  298. "actual_window": MAY_POINT, "required_effective_window": PLAN_WINDOW},
  299. {"target_id": "R1", "target_type": "return_air_system", "target_name": "唯一回风系统",
  300. "actual_airflow_m3_min": 6000, "required_airflow_m3_min": 6800, "plan_verification_status": "valid",
  301. "actual_window": MAY_POINT, "required_effective_window": PLAN_WINDOW,
  302. "required_reuse_key": "reuse:MINE_TOTAL"},
  303. {"target_id": "MINE_TOTAL", "target_type": "mine_total_return", "target_name": "矿井总回风量",
  304. "actual_airflow_m3_min": 6100, "required_airflow_m3_min": 6800, "plan_verification_status": "valid",
  305. "actual_window": MAY_POINT, "required_effective_window": PLAN_WINDOW,
  306. "required_reuse_key": "reuse:MINE_TOTAL"},
  307. ], rules)
  308. deduped = dedupe_findings(comparison["findings"])
  309. assert deduped["merged_count"] == 1 # 同一复用基准不重复计数
  310. assert "reuse:MINE_TOTAL" in deduped["reuse_groups"]
  311. merged = dedupe_findings(comparison["findings"] + [dict(comparison["findings"][0],
  312. finding_id="F999")])
  313. assert merged["merged_count"] >= 1
  314. def test_trace_evidence_reports_missing_ids():
  315. result = trace_evidence(
  316. evidence={"e001": {"evidence_id": "e001", "source_file": "报表.xlsx", "source_type": "measurement_report",
  317. "normalized_value": 860, "locator": {"sheet": "测风报表", "cell": "N20"},
  318. "unit": "m3/min", "confidence": 0.98}},
  319. evidence_ids=["e001", "e404"], finding_id="F001",
  320. )
  321. assert result["is_complete"] is False
  322. assert result["missing_evidence_ids"] == ["e404"]
  323. assert result["manual_review_required"] is True
  324. assert result["evidence_chain"][0]["cell"] == "N20"
  325. def test_summary_counts_by_severity_and_type(rules):
  326. comparison = compare_targets([
  327. {"target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
  328. "target_name": "面", "actual_airflow_m3_min": 500, "required_airflow_m3_min": 900,
  329. "plan_verification_status": "valid", "actual_window": MAY_POINT,
  330. "required_effective_window": PLAN_WINDOW},
  331. ], rules)
  332. stats = summarize(comparison["findings"])
  333. assert stats["major_count"] == 1
  334. assert stats["by_finding_type"]["workplace_airflow_below_75_percent"] == 1
  335. # ---- 构造辅助 -------------------------------------------------------------
  336. def _workplace_item() -> dict:
  337. """布尔台矿 22210 综采工作面的真实计划参数(计划值 1221,复算 1220.4)。"""
  338. return {
  339. "target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
  340. "target_name": "22210 综采工作面", "parent_return_system_id": "R1",
  341. "planned_required_airflow_m3_min": 1221, "planned_formula": "WF-CFI-01",
  342. "mining_method": "综采",
  343. "planned_parameters": {"v_cfi": 1.0, "S_cfi": 11.3, "k_chi": 1.2, "k_cli": 1.5,
  344. "section_area_basis": "有效断面积",
  345. "q_cgi": 3.4, "k_cgi": 1.6, "q_cci": 0.5, "k_cci": 1.5,
  346. # 验算项需要控顶距与采高,缺了就判"依据不足"(引擎的严格性
  347. # 是刻意的:没有这些参数就无法完成最小/最大风速验算)
  348. "l_cbi": 5.0, "l_csi": 4.4, "h_cfi": 3.0},
  349. "stated_calculation": {"formula_id": "WF-CFI-01", "stated_result_m3_min": 1221,
  350. "substituted_values": {"v_cfi": 1.0, "S_cfi": 11.3, "k_chi": 1.2,
  351. "k_cli": 1.5, "section_area_basis": "有效断面积"}},
  352. "evidence_ids": ["e101"],
  353. }
  354. def _return_system_item() -> dict:
  355. return {"target_id": "R1", "target_type": "return_air_system", "target_name": "南翼回风系统",
  356. "parent_target_id": "MINE_TOTAL", "component_workplace_ids": ["L1", "L2"],
  357. "planned_required_airflow_m3_min": 1465.2,
  358. "planned_parameters": {"reserve_coefficient": 1.2},
  359. "evidence_ids": ["e102"]}
  360. def _normal_plan() -> dict:
  361. return {
  362. "plan_id": "PLAN-TEST", "plan_version": "V1", "mine_id": "M1",
  363. "approved_at": "2026-04-28", "effective_window": PLAN_WINDOW,
  364. "items": [
  365. _workplace_item(),
  366. {"target_id": "L2", "target_type": "workplace", "location_type": "掘进工作面",
  367. "target_name": "12202 辅运顺槽", "parent_return_system_id": "R1",
  368. "planned_required_airflow_m3_min": 367.3, "planned_formula": "WF-HFI-04",
  369. "roadway_type": "煤巷",
  370. "planned_parameters": {"q_hgi": 0.83, "k_hgi": 1.6, "S_hfi": 17.82,
  371. "S_hdi": 17.82, "fan_intake_sum": 100,
  372. "outlet_airflow": 90, "distance": 800}},
  373. {"target_id": "R1", "target_type": "return_air_system", "target_name": "南翼回风系统",
  374. "parent_target_id": "MINE_TOTAL", "component_workplace_ids": ["L1", "L2"],
  375. "planned_required_airflow_m3_min": 1905.24,
  376. "planned_parameters": {"reserve_coefficient": 1.2}},
  377. {"target_id": "MINE_TOTAL", "target_type": "mine_total", "target_name": "矿井总需风量",
  378. "component_return_system_ids": ["R1"], "planned_required_airflow_m3_min": 1905.24},
  379. ],
  380. }
  381. # ---- 参数口径的变体选择与自洽核验 -----------------------------------------
  382. def test_variant_inferred_from_plan_stated_result(rules):
  383. """计划未标注参数口径时,按计划写出的结果反推变体,不能把正确计划算成 854.28。"""
  384. item = _workplace_item()
  385. item["planned_parameters"]["section_area_basis"] = "平均有效净断面积" # 模型曾写出的同义说法
  386. result = recalc_plan_item(item, rules)
  387. assert result["calculated_required_airflow_m3_min"] == pytest.approx(1220.4)
  388. method = result["methods"][0]
  389. assert method["variant_selection"] == "inferred_from_stated_calculation"
  390. assert method["variant"] == {"section_area_basis": "有效断面积"}
  391. assert any("反推" in warning for warning in result["warnings"])
  392. def test_variant_declared_is_not_overridden(rules):
  393. """口径标记写对时按声明走,不做反推。"""
  394. item = _workplace_item()
  395. result = recalc_plan_item(item, rules)
  396. assert result["methods"][0]["variant_selection"] == "declared"
  397. assert not result["warnings"]
  398. def test_self_consistency_accepts_any_allowed_convention(rules):
  399. """自洽核验:计划写出的数值在某个允许口径下能复现即算自洽。"""
  400. item = _workplace_item()
  401. item["planned_parameters"]["section_area_basis"] = "平均有效净断面积"
  402. item["stated_calculation"]["expression"] = "60x70%x1.0x11.3x1.2x1.5"
  403. stated = recalc_plan_item(item, rules)["stated_reproduction"]
  404. assert stated["status"] == "ok"
  405. assert stated["recomputed_m3_min"] == pytest.approx(1220.4)
  406. # 命中即返回,但试算过的表达式都留在计算链里,人能看出是哪个口径对上的
  407. assert stated["candidates"][0]["expression"] == "60 * v_cfi * S_cfi * k_chi * k_cli"
  408. assert stated["candidates"][0]["variant"] == {"section_area_basis": "有效断面积"}
  409. def test_self_consistency_flags_when_no_convention_reproduces(rules):
  410. """任何口径都复现不出来,才是真的算错(这里让计划值与基础口径一致,只留自洽性问题)。"""
  411. item = _workplace_item()
  412. item["planned_parameters"]["section_area_basis"] = "平均有效净断面积"
  413. item["planned_required_airflow_m3_min"] = 854.28 # 与基础口径复算值一致
  414. item["stated_calculation"]["stated_result_m3_min"] = 2000 # 但与代入值无关
  415. stated = recalc_plan_item(item, rules)["stated_reproduction"]
  416. assert stated["status"] == "inconsistent"
  417. assert "任何口径" in stated["reason"]
  418. plan = {"plan_id": "P", "plan_version": "V1", "effective_window": PLAN_WINDOW, "items": [item]}
  419. result = verify_plan(plan, rules)
  420. assert result["items"][0]["verification_status"] == "invalid"
  421. assert "不自洽" in result["items"][0]["verification_reason"]
  422. def test_parameter_markers_expose_allowed_values(rules):
  423. """口径标记的允许取值必须来自规则库,供提取模板约束模型写法。"""
  424. markers = rules.parameter_markers()
  425. assert set(markers["section_area_basis"]["allowed"]) == {"有效断面积", "净断面积"}
  426. assert "WF-CFI-01" in markers["section_area_basis"]["affects"]
  427. assert markers["roadway_type"]["allowed"]
  428. assert "煤巷" in markers["roadway_type"]["allowed"]
  429. assert markers["room_type"]["allowed"]
  430. def test_workplace_without_location_type_uses_workplace_policy(rules):
  431. """地点类型缺失/判不出时按工作面策略(0.75)走,不能回落到回风系统的 1.0——
  432. 那等于把工作面按 100% 判定,用错了规则。"""
  433. comparison = compare_targets([{
  434. "target_id": "L1", "target_type": "workplace", "target_name": "某采面",
  435. "actual_airflow_m3_min": 800, "required_airflow_m3_min": 900,
  436. "plan_verification_status": "valid", "actual_window": MAY_POINT,
  437. "required_effective_window": PLAN_WINDOW,
  438. }], rules)
  439. record = comparison["comparisons"][0]
  440. assert record["policy_key"] == "workplace"
  441. assert record["threshold_ratio"] == 0.75
  442. # 800/900 = 88.9%,未破 75% 红线 → 关注项而不是重大风险
  443. assert record["comparison_status"] == "insufficient"
  444. assert comparison["findings"][0]["severity"] == "attention"
  445. def test_unaligned_or_unparsable_time_stops_auto_determination(rules):
  446. """实测窗口解析不出时不得定性——不知道实测是什么时候的,比了也没意义。"""
  447. comparison = compare_targets([{
  448. "target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
  449. "target_name": "某采面", "actual_airflow_m3_min": 800, "required_airflow_m3_min": 900,
  450. "plan_verification_status": "valid",
  451. "actual_window": "本月中旬", # 认不出 → 归一化后为空
  452. "required_effective_window": PLAN_WINDOW,
  453. }], rules)
  454. record = comparison["comparisons"][0]
  455. assert record["time_alignment_status"] == "manual_review"
  456. assert record["comparison_status"] == "insufficient"
  457. finding = comparison["findings"][0]
  458. assert finding["finding_type"] == "time_alignment_failed"
  459. assert finding["manual_review_required"] is True
  460. assert "不判定风量是否满足" in finding["conclusion"]
  461. def test_tolerant_time_parsing_for_non_iso_forms():
  462. """材料里常见的中文/斜杠写法要能认出来,否则会被误判成"时间无法对齐"。"""
  463. from algorithms.airflow_review.contracts import as_window, parse_time
  464. assert parse_time("2026年05月29日") is not None
  465. assert parse_time("2024年12月1日") is not None
  466. assert parse_time("2024/12/01") is not None
  467. assert parse_time("2024-12-01 9:00") is not None
  468. assert parse_time("不是时间") is None
  469. window = as_window("自2024年12月1日至2024年12月31日")
  470. assert parse_time(window["start"]) < parse_time(window["end"])
  471. assert window["normalized_from"] == "string"
  472. assert as_window("本月中旬") == {}