| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568 |
- """风量研判引擎的离线单测:规则库、表达式、复算、层级、核验、比对、校核、去重、证据链。
- 基准样例覆盖需求 §2.11.7 要求的九类场景:正常、计划计算错误、层级归属错误、单一回风
- 系统、总风量不足、回风系统风量不足、工作面低于 75%、数据冲突、时间无法对齐。
- """
- from __future__ import annotations
- import pytest
- from algorithms.airflow_review.contracts import to_m3_min
- from algorithms.airflow_review.engine.compare import compare_targets
- from algorithms.airflow_review.engine.dedupe import dedupe_findings
- from algorithms.airflow_review.engine.expression import ExpressionError, evaluate, missing_params
- from algorithms.airflow_review.engine.findings import summarize
- from algorithms.airflow_review.engine.hierarchy import build_hierarchy
- from algorithms.airflow_review.engine.recalc import recalc_all, recalc_plan_item, within_tolerance
- from algorithms.airflow_review.engine.reconcile import reconcile_sources
- from algorithms.airflow_review.engine.trace import trace_evidence
- from algorithms.airflow_review.engine.verify import verify_plan
- from algorithms.airflow_review.rules.loader import (
- RuleError,
- available_versions,
- classify_location,
- derive_params,
- load_rules,
- )
- PLAN_WINDOW = {"start": "2026-05-01T00:00:00+08:00", "end": "2026-05-31T23:59:59+08:00"}
- MAY_POINT = {"start": "2026-05-15T09:00:00+08:00", "end": "2026-05-15T09:00:00+08:00"}
- @pytest.fixture(scope="module")
- def rules():
- return load_rules()
- # ---- 规则库与表达式 -------------------------------------------------------
- def test_rule_library_is_versioned_and_loaded(rules):
- assert rules.rule_version in available_versions()
- assert rules.thresholds()["compare"]["workplace"]["threshold_ratio"] == 0.75
- assert rules.compare_policy("mine_total_intake")["finding_type"] == "total_airflow_shortage"
- assert rules.tolerance()["absolute_m3_min"] == 1.0
- def test_unknown_rule_version_is_rejected():
- with pytest.raises(RuleError, match="规则版本不可用"):
- load_rules("AIR-RULE-1900.1")
- def test_expression_evaluator_is_whitelisted():
- assert evaluate("60 * 0.7 * 1.5 * 11.3 * 1.2 * 1.5", {}) == pytest.approx(1281.42, rel=1e-6)
- assert evaluate("sum(values) * factor", {"values": [1, 2, 3], "factor": 1.2}) == pytest.approx(7.2)
- with pytest.raises(ExpressionError):
- evaluate("__import__('os').system('echo x')", {})
- with pytest.raises(ExpressionError):
- evaluate("a ** 99", {"a": 2})
- assert missing_params("a + b + c", {"a": 1}) == ["b", "c"]
- def test_table_lookup_interpolates_and_clamps(rules):
- value, row = rules.lookup_table("v_cfi_by_temp", "max_temperature", 24.5, ["min_speed", "max_speed"])
- assert 1.5 < value < 1.8
- assert row["max_temp"] == 26
- height, _ = rules.lookup_table("k_ch_by_height", "cutting_height", 3.4, "k")
- assert height == 1.2
- eta, _ = rules.lookup_table("leakage_eta_by_distance", "distance", 1500, "max_eta")
- assert eta == 2
- def test_derive_params_records_provenance(rules):
- formula = rules.formulas("采煤工作面")[0]
- params, missing, trace = derive_params(formula, {"max_temperature": 22, "cutting_height": 2.3,
- "face_length": 180, "max_support_width": 4.8,
- "min_support_width": 4.2}, rules)
- assert not missing
- assert params["k_chi"] == 1.1
- assert any(entry.get("table") == "k_ch_by_height" for entry in trace)
- def test_unit_conversion_and_unknown_unit():
- value, warning = to_m3_min(1.5, "m3/s")
- assert value == 90.0 and warning is None
- value, warning = to_m3_min("120", "m3/h")
- assert value == 2.0 and warning is None
- value, warning = to_m3_min(100, "方/分钟")
- assert value is None and "未识别" in warning
- def test_location_classification_and_unknown_falls_through():
- assert classify_location("22210 综采工作面")[0] == "采煤工作面"
- assert classify_location("12 上305 辅运顺槽工作面局扇安设点")[0] == "掘进工作面"
- assert classify_location("一水平消防材料库")[0] == "硐室"
- assert classify_location("二水平北边界巷")[0] == "其他巷道"
- location_type, reason = classify_location("某个没见过的东西")
- assert location_type == "" and "未命中" in reason
- # ---- 复算 -----------------------------------------------------------------
- def test_recalc_uses_section_area_variant(rules):
- """真实计划里 S_cfi 已含 70% 有效断面系数,变体必须命中,不能重复相乘。"""
- item = _workplace_item()
- result = recalc_plan_item(item, rules)
- assert result["calculated_required_airflow_m3_min"] == pytest.approx(1220.4)
- assert result["governing_method_id"] == "WF-CFI-01"
- assert result["methods"][0]["variant"] == {"section_area_basis": "有效断面积"}
- reproduced = result["stated_reproduction"]
- assert reproduced["source"] == "rule_library"
- assert reproduced["recomputed_m3_min"] == pytest.approx(1220.4)
- def test_recalc_marks_insufficient_when_params_missing(rules):
- item = {"target_id": "L9", "target_type": "workplace", "location_type": "采煤工作面",
- "target_name": "缺参数面", "planned_required_airflow_m3_min": 900, "planned_parameters": {}}
- result = recalc_plan_item(item, rules)
- assert result["calculation_status"] == "insufficient"
- assert result["calculated_required_airflow_m3_min"] is None
- assert any(method["status"] == "insufficient" for method in result["methods"])
- def test_tolerance_absorbs_rounding_but_not_errors():
- within, difference, limit = within_tolerance(1221, 1220.4, {"absolute_m3_min": 1.0, "relative_percent": 0.5})
- assert within and difference == pytest.approx(-0.6) and limit == pytest.approx(6.105)
- within, _, _ = within_tolerance(900, 850, {"absolute_m3_min": 1.0, "relative_percent": 0.5})
- assert not within
- def test_aggregation_uses_component_planned_values(rules):
- items = [_workplace_item(), _return_system_item()]
- results = recalc_all(items, rules)
- aggregate = results["R1"]
- assert aggregate["aggregation_basis"] == "planned_components"
- assert aggregate["calculated_required_airflow_m3_min"] == pytest.approx(1465.2)
- # ---- 层级 -----------------------------------------------------------------
- def test_hierarchy_detects_orphan_duplicate_and_cycle():
- items = [
- {"target_id": "L1", "target_type": "workplace", "target_name": "A", "parent_return_system_id": "R1",
- "planned_required_airflow_m3_min": 100},
- {"target_id": "L2", "target_type": "workplace", "target_name": "B",
- "planned_required_airflow_m3_min": 100},
- {"target_id": "R1", "target_type": "return_air_system", "target_name": "系统一",
- "component_workplace_ids": ["L1", "L1"], "parent_target_id": "MINE"},
- {"target_id": "MINE", "target_type": "mine_total", "target_name": "总",
- "component_return_system_ids": ["R1"]},
- ]
- result = build_hierarchy(items)
- codes = {issue["code"] for issue in result["issues"]}
- assert "orphan_target" in codes
- assert "duplicate_in_aggregation" in codes
- def test_hierarchy_detects_cycle():
- items = [
- {"target_id": "MINE", "target_type": "mine_total", "target_name": "总",
- "component_return_system_ids": ["R1"], "parent_target_id": "R2"},
- {"target_id": "R1", "target_type": "return_air_system", "target_name": "系统",
- "component_workplace_ids": ["L1"], "parent_target_id": "MINE"},
- {"target_id": "R2", "target_type": "return_air_system", "target_name": "系统2",
- "component_workplace_ids": ["L1"], "parent_target_id": "R1"},
- {"target_id": "L1", "target_type": "workplace", "target_name": "A", "parent_return_system_id": "R1",
- "planned_required_airflow_m3_min": 100},
- ]
- result = build_hierarchy(items)
- assert any(issue["code"] == "cyclic_reference" for issue in result["issues"])
- def test_single_return_system_equivalence_and_reuse_key(rules):
- items = [_workplace_item(), _return_system_item()]
- items[1]["component_workplace_ids"] = ["L1"]
- items[1]["planned_required_airflow_m3_min"] = 1465.2
- items.append({"target_id": "MINE_TOTAL", "target_type": "mine_total", "target_name": "矿井总需风量",
- "component_return_system_ids": ["R1"], "planned_required_airflow_m3_min": 1465.2})
- result = verify_plan({"plan_id": "P", "plan_version": "V1", "effective_window": PLAN_WINDOW,
- "items": items}, rules)
- equivalence = result["hierarchy"]["single_return_equivalence"]
- assert equivalence["applicable"] and equivalence["equivalent"]
- reuse_keys = {item["target_id"]: item["required_reuse_key"] for item in result["verified_items"]}
- assert reuse_keys["R1"] == reuse_keys["MINE_TOTAL"] != ""
- # ---- 基准样例:九类场景 ---------------------------------------------------
- def test_baseline_normal_plan_and_comparison(rules):
- result = verify_plan(_normal_plan(), rules)
- assert result["plan_calculation_status"] == "valid"
- assert result["plan_authoritative"] is True
- assert not result["blocked_items"]
- comparison = compare_targets([{
- "target_id": "MINE_TOTAL_INTAKE", "target_type": "mine_total_intake", "target_name": "矿井总进风量",
- "actual_airflow_m3_min": 7300, "required_airflow_m3_min": 6800,
- "plan_verification_status": "valid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- record = comparison["comparisons"][0]
- assert record["comparison_status"] == "normal"
- assert not comparison["findings"]
- def test_baseline_plan_calculation_error_blocks_ancestors(rules):
- plan = _normal_plan()
- plan["items"][0]["planned_required_airflow_m3_min"] = 900 # 复算值 1220.4,超出容差
- result = verify_plan(plan, rules)
- assert result["plan_calculation_status"] == "invalid"
- assert result["plan_authoritative"] is False
- assert "L1" in result["blocked_items"]
- assert "R1" in result["blocked_items"] # 错误沿层级向上传播
- assert "MINE_TOTAL" in result["blocked_items"]
- finding = next(item for item in result["findings"] if item["target_id"] == "L1")
- assert finding["finding_type"] == "plan_calculation_mismatch"
- assert finding["is_hazard"] is True and finding["severity"] == "major"
- def test_baseline_hierarchy_ownership_error(rules):
- plan = _normal_plan()
- plan["items"][0].pop("parent_return_system_id")
- result = verify_plan(plan, rules)
- issues = {issue["code"] for issue in result["hierarchy"]["issues"]}
- assert "orphan_target" in issues
- assert result["plan_calculation_status"] == "manual_review"
- def test_baseline_total_airflow_shortage(rules):
- comparison = compare_targets([{
- "target_id": "MINE_TOTAL_RETURN", "target_type": "mine_total_return", "target_name": "矿井总回风量",
- "actual_airflow_m3_min": 6000, "required_airflow_m3_min": 6800,
- "plan_verification_status": "valid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- finding = comparison["findings"][0]
- assert finding["finding_type"] == "total_airflow_shortage"
- assert finding["severity"] == "major"
- assert finding["shortfall_m3_min"] == pytest.approx(800)
- def test_baseline_return_system_shortage(rules):
- comparison = compare_targets([{
- "target_id": "R1", "target_type": "return_air_system", "target_name": "南翼回风系统",
- "actual_airflow_m3_min": 3000, "required_airflow_m3_min": 3200,
- "plan_verification_status": "valid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- finding = comparison["findings"][0]
- assert finding["finding_type"] == "return_system_airflow_shortage"
- assert finding["severity"] == "major"
- def test_baseline_workplace_below_75_percent(rules):
- comparison = compare_targets([{
- "target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
- "target_name": "1301采煤工作面",
- "actual_airflow_m3_min": 640, "required_airflow_m3_min": 900,
- "plan_verification_status": "valid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- finding = comparison["findings"][0]
- assert finding["finding_type"] == "workplace_airflow_below_75_percent"
- assert finding["is_major_hazard"] is True
- assert finding["ratio"] == pytest.approx(0.7111)
- assert finding["disposal_note"]
- def test_baseline_workplace_between_75_and_100_is_attention(rules):
- comparison = compare_targets([{
- "target_id": "L1", "target_type": "workplace", "location_type": "掘进工作面",
- "target_name": "12202辅运顺槽", "actual_airflow_m3_min": 800, "required_airflow_m3_min": 900,
- "plan_verification_status": "valid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- finding = comparison["findings"][0]
- assert finding["severity"] == "attention"
- assert finding["is_major_hazard"] is False
- def test_baseline_time_not_aligned_is_manual_review(rules):
- comparison = compare_targets([{
- "target_id": "MINE_TOTAL_INTAKE", "target_type": "mine_total_intake", "target_name": "矿井总进风量",
- "actual_airflow_m3_min": 35181, "required_airflow_m3_min": 32497.2,
- "plan_verification_status": "valid",
- "actual_window": {"start": "2026-05-15T09:00:00+08:00", "end": "2026-05-15T09:00:00+08:00"},
- "required_effective_window": {"start": "2024-12-01T00:00:00+08:00", "end": "2024-12-31T23:59:59+08:00"},
- }], rules)
- record = comparison["comparisons"][0]
- assert record["time_alignment_status"] == "failed"
- assert record["comparison_status"] == "insufficient"
- assert comparison["findings"][0]["finding_type"] == "time_alignment_failed"
- def test_baseline_plan_not_verified_is_not_comparable(rules):
- comparison = compare_targets([{
- "target_id": "L1", "target_type": "workplace", "target_name": "1301采煤工作面",
- "actual_airflow_m3_min": 640, "required_airflow_m3_min": 900,
- "plan_verification_status": "invalid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- record = comparison["comparisons"][0]
- assert record["comparison_status"] == "not_comparable"
- assert "未取得权威需风量" in record["finding"]["conclusion"]
- def test_baseline_missing_actual_is_insufficient_not_normal(rules):
- comparison = compare_targets([{
- "target_id": "L1", "target_type": "workplace", "target_name": "1301采煤工作面",
- "actual_airflow_m3_min": None, "required_airflow_m3_min": 900,
- "plan_verification_status": "valid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- record = comparison["comparisons"][0]
- assert record["comparison_status"] == "insufficient"
- assert comparison["findings"][0]["manual_review_required"] is True
- def test_baseline_data_conflict_is_reported_without_averaging(rules):
- result = reconcile_sources({
- "target_id": "L1", "target_type": "workplace", "target_name": "1301采煤工作面",
- "comparison_window": MAY_POINT,
- "sources": [
- {"source_type": "measurement_report", "airflow_m3_min": 860,
- "observed_at": "2026-05-15T09:00:00+08:00"},
- {"source_type": "monitoring", "airflow_m3_min": 600,
- "observed_at": "2026-05-15T09:05:00+08:00"},
- ],
- }, rules)
- assert result["consistency"] == "conflict"
- assert result["requires_manual_review"] is True
- assert result["finding"]["finding_type"] == "data_conflict"
- assert result["recommended_airflow_m3_min"] == 860 # 采用高优先级来源,不做平均
- def test_reconcile_accepts_consistent_sources_and_flags_time_offset(rules):
- ok = reconcile_sources({
- "target_id": "L1", "target_type": "workplace", "target_name": "面",
- "comparison_window": MAY_POINT,
- "sources": [
- {"source_type": "measurement_report", "airflow_m3_min": 860, "observed_at": "2026-05-15T09:00:00+08:00"},
- {"source_type": "monitoring", "airflow_m3_min": 820, "observed_at": "2026-05-15T09:30:00+08:00"},
- ],
- }, rules)
- assert ok["consistency"] == "acceptable"
- assert ok["deviation_percent"] == pytest.approx(4.878, rel=1e-3)
- assert ok["recommended_source"] == "measurement_report"
- offset = reconcile_sources({
- "target_id": "L1", "target_type": "workplace", "target_name": "面",
- "comparison_window": MAY_POINT,
- "sources": [
- {"source_type": "measurement_report", "airflow_m3_min": 860, "observed_at": "2026-05-15T09:00:00+08:00"},
- {"source_type": "monitoring", "airflow_m3_min": 860, "observed_at": "2026-05-15T15:00:00+08:00"},
- ],
- }, rules)
- assert offset["time_alignment_status"] == "failed"
- assert offset["finding"]["finding_type"] == "time_alignment_failed"
- # ---- 去重与证据链 ---------------------------------------------------------
- def test_dedupe_merges_same_target_and_dedups_reuse(rules):
- comparison = compare_targets([
- {"target_id": "MINE_TOTAL", "target_type": "mine_total_intake", "target_name": "矿井总进风量",
- "actual_airflow_m3_min": 6000, "required_airflow_m3_min": 6800, "plan_verification_status": "valid",
- "actual_window": MAY_POINT, "required_effective_window": PLAN_WINDOW},
- {"target_id": "R1", "target_type": "return_air_system", "target_name": "唯一回风系统",
- "actual_airflow_m3_min": 6000, "required_airflow_m3_min": 6800, "plan_verification_status": "valid",
- "actual_window": MAY_POINT, "required_effective_window": PLAN_WINDOW,
- "required_reuse_key": "reuse:MINE_TOTAL"},
- {"target_id": "MINE_TOTAL", "target_type": "mine_total_return", "target_name": "矿井总回风量",
- "actual_airflow_m3_min": 6100, "required_airflow_m3_min": 6800, "plan_verification_status": "valid",
- "actual_window": MAY_POINT, "required_effective_window": PLAN_WINDOW,
- "required_reuse_key": "reuse:MINE_TOTAL"},
- ], rules)
- deduped = dedupe_findings(comparison["findings"])
- assert deduped["merged_count"] == 1 # 同一复用基准不重复计数
- assert "reuse:MINE_TOTAL" in deduped["reuse_groups"]
- merged = dedupe_findings(comparison["findings"] + [dict(comparison["findings"][0],
- finding_id="F999")])
- assert merged["merged_count"] >= 1
- def test_trace_evidence_reports_missing_ids():
- result = trace_evidence(
- evidence={"e001": {"evidence_id": "e001", "source_file": "报表.xlsx", "source_type": "measurement_report",
- "normalized_value": 860, "locator": {"sheet": "测风报表", "cell": "N20"},
- "unit": "m3/min", "confidence": 0.98}},
- evidence_ids=["e001", "e404"], finding_id="F001",
- )
- assert result["is_complete"] is False
- assert result["missing_evidence_ids"] == ["e404"]
- assert result["manual_review_required"] is True
- assert result["evidence_chain"][0]["cell"] == "N20"
- def test_summary_counts_by_severity_and_type(rules):
- comparison = compare_targets([
- {"target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
- "target_name": "面", "actual_airflow_m3_min": 500, "required_airflow_m3_min": 900,
- "plan_verification_status": "valid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW},
- ], rules)
- stats = summarize(comparison["findings"])
- assert stats["major_count"] == 1
- assert stats["by_finding_type"]["workplace_airflow_below_75_percent"] == 1
- # ---- 构造辅助 -------------------------------------------------------------
- def _workplace_item() -> dict:
- """布尔台矿 22210 综采工作面的真实计划参数(计划值 1221,复算 1220.4)。"""
- return {
- "target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
- "target_name": "22210 综采工作面", "parent_return_system_id": "R1",
- "planned_required_airflow_m3_min": 1221, "planned_formula": "WF-CFI-01",
- "mining_method": "综采",
- "planned_parameters": {"v_cfi": 1.0, "S_cfi": 11.3, "k_chi": 1.2, "k_cli": 1.5,
- "section_area_basis": "有效断面积",
- "q_cgi": 3.4, "k_cgi": 1.6, "q_cci": 0.5, "k_cci": 1.5,
- # 验算项需要控顶距与采高,缺了就判"依据不足"(引擎的严格性
- # 是刻意的:没有这些参数就无法完成最小/最大风速验算)
- "l_cbi": 5.0, "l_csi": 4.4, "h_cfi": 3.0},
- "stated_calculation": {"formula_id": "WF-CFI-01", "stated_result_m3_min": 1221,
- "substituted_values": {"v_cfi": 1.0, "S_cfi": 11.3, "k_chi": 1.2,
- "k_cli": 1.5, "section_area_basis": "有效断面积"}},
- "evidence_ids": ["e101"],
- }
- def _return_system_item() -> dict:
- return {"target_id": "R1", "target_type": "return_air_system", "target_name": "南翼回风系统",
- "parent_target_id": "MINE_TOTAL", "component_workplace_ids": ["L1", "L2"],
- "planned_required_airflow_m3_min": 1465.2,
- "planned_parameters": {"reserve_coefficient": 1.2},
- "evidence_ids": ["e102"]}
- def _normal_plan() -> dict:
- return {
- "plan_id": "PLAN-TEST", "plan_version": "V1", "mine_id": "M1",
- "approved_at": "2026-04-28", "effective_window": PLAN_WINDOW,
- "items": [
- _workplace_item(),
- {"target_id": "L2", "target_type": "workplace", "location_type": "掘进工作面",
- "target_name": "12202 辅运顺槽", "parent_return_system_id": "R1",
- "planned_required_airflow_m3_min": 367.3, "planned_formula": "WF-HFI-04",
- "roadway_type": "煤巷",
- "planned_parameters": {"q_hgi": 0.83, "k_hgi": 1.6, "S_hfi": 17.82,
- "S_hdi": 17.82, "fan_intake_sum": 100,
- "outlet_airflow": 90, "distance": 800}},
- {"target_id": "R1", "target_type": "return_air_system", "target_name": "南翼回风系统",
- "parent_target_id": "MINE_TOTAL", "component_workplace_ids": ["L1", "L2"],
- "planned_required_airflow_m3_min": 1905.24,
- "planned_parameters": {"reserve_coefficient": 1.2}},
- {"target_id": "MINE_TOTAL", "target_type": "mine_total", "target_name": "矿井总需风量",
- "component_return_system_ids": ["R1"], "planned_required_airflow_m3_min": 1905.24},
- ],
- }
- # ---- 参数口径的变体选择与自洽核验 -----------------------------------------
- def test_variant_inferred_from_plan_stated_result(rules):
- """计划未标注参数口径时,按计划写出的结果反推变体,不能把正确计划算成 854.28。"""
- item = _workplace_item()
- item["planned_parameters"]["section_area_basis"] = "平均有效净断面积" # 模型曾写出的同义说法
- result = recalc_plan_item(item, rules)
- assert result["calculated_required_airflow_m3_min"] == pytest.approx(1220.4)
- method = result["methods"][0]
- assert method["variant_selection"] == "inferred_from_stated_calculation"
- assert method["variant"] == {"section_area_basis": "有效断面积"}
- assert any("反推" in warning for warning in result["warnings"])
- def test_variant_declared_is_not_overridden(rules):
- """口径标记写对时按声明走,不做反推。"""
- item = _workplace_item()
- result = recalc_plan_item(item, rules)
- assert result["methods"][0]["variant_selection"] == "declared"
- assert not result["warnings"]
- def test_self_consistency_accepts_any_allowed_convention(rules):
- """自洽核验:计划写出的数值在某个允许口径下能复现即算自洽。"""
- item = _workplace_item()
- item["planned_parameters"]["section_area_basis"] = "平均有效净断面积"
- item["stated_calculation"]["expression"] = "60x70%x1.0x11.3x1.2x1.5"
- stated = recalc_plan_item(item, rules)["stated_reproduction"]
- assert stated["status"] == "ok"
- assert stated["recomputed_m3_min"] == pytest.approx(1220.4)
- # 命中即返回,但试算过的表达式都留在计算链里,人能看出是哪个口径对上的
- assert stated["candidates"][0]["expression"] == "60 * v_cfi * S_cfi * k_chi * k_cli"
- assert stated["candidates"][0]["variant"] == {"section_area_basis": "有效断面积"}
- def test_self_consistency_flags_when_no_convention_reproduces(rules):
- """任何口径都复现不出来,才是真的算错(这里让计划值与基础口径一致,只留自洽性问题)。"""
- item = _workplace_item()
- item["planned_parameters"]["section_area_basis"] = "平均有效净断面积"
- item["planned_required_airflow_m3_min"] = 854.28 # 与基础口径复算值一致
- item["stated_calculation"]["stated_result_m3_min"] = 2000 # 但与代入值无关
- stated = recalc_plan_item(item, rules)["stated_reproduction"]
- assert stated["status"] == "inconsistent"
- assert "任何口径" in stated["reason"]
- plan = {"plan_id": "P", "plan_version": "V1", "effective_window": PLAN_WINDOW, "items": [item]}
- result = verify_plan(plan, rules)
- assert result["items"][0]["verification_status"] == "invalid"
- assert "不自洽" in result["items"][0]["verification_reason"]
- def test_parameter_markers_expose_allowed_values(rules):
- """口径标记的允许取值必须来自规则库,供提取模板约束模型写法。"""
- markers = rules.parameter_markers()
- assert set(markers["section_area_basis"]["allowed"]) == {"有效断面积", "净断面积"}
- assert "WF-CFI-01" in markers["section_area_basis"]["affects"]
- assert markers["roadway_type"]["allowed"]
- assert "煤巷" in markers["roadway_type"]["allowed"]
- assert markers["room_type"]["allowed"]
- def test_workplace_without_location_type_uses_workplace_policy(rules):
- """地点类型缺失/判不出时按工作面策略(0.75)走,不能回落到回风系统的 1.0——
- 那等于把工作面按 100% 判定,用错了规则。"""
- comparison = compare_targets([{
- "target_id": "L1", "target_type": "workplace", "target_name": "某采面",
- "actual_airflow_m3_min": 800, "required_airflow_m3_min": 900,
- "plan_verification_status": "valid", "actual_window": MAY_POINT,
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- record = comparison["comparisons"][0]
- assert record["policy_key"] == "workplace"
- assert record["threshold_ratio"] == 0.75
- # 800/900 = 88.9%,未破 75% 红线 → 关注项而不是重大风险
- assert record["comparison_status"] == "insufficient"
- assert comparison["findings"][0]["severity"] == "attention"
- def test_unaligned_or_unparsable_time_stops_auto_determination(rules):
- """实测窗口解析不出时不得定性——不知道实测是什么时候的,比了也没意义。"""
- comparison = compare_targets([{
- "target_id": "L1", "target_type": "workplace", "location_type": "采煤工作面",
- "target_name": "某采面", "actual_airflow_m3_min": 800, "required_airflow_m3_min": 900,
- "plan_verification_status": "valid",
- "actual_window": "本月中旬", # 认不出 → 归一化后为空
- "required_effective_window": PLAN_WINDOW,
- }], rules)
- record = comparison["comparisons"][0]
- assert record["time_alignment_status"] == "manual_review"
- assert record["comparison_status"] == "insufficient"
- finding = comparison["findings"][0]
- assert finding["finding_type"] == "time_alignment_failed"
- assert finding["manual_review_required"] is True
- assert "不判定风量是否满足" in finding["conclusion"]
- def test_tolerant_time_parsing_for_non_iso_forms():
- """材料里常见的中文/斜杠写法要能认出来,否则会被误判成"时间无法对齐"。"""
- from algorithms.airflow_review.contracts import as_window, parse_time
- assert parse_time("2026年05月29日") is not None
- assert parse_time("2024年12月1日") is not None
- assert parse_time("2024/12/01") is not None
- assert parse_time("2024-12-01 9:00") is not None
- assert parse_time("不是时间") is None
- window = as_window("自2024年12月1日至2024年12月31日")
- assert parse_time(window["start"]) < parse_time(window["end"])
- assert window["normalized_from"] == "string"
- assert as_window("本月中旬") == {}
|