loader.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. """规则库加载与查询:公式、参数派生、验算项、阈值、表中取值。
  2. 规则以 JSON 文件承载并版本化;加载器只做「取规则」和「按事实选变体」,
  3. 不做业务判断,保证同一 (规则版本, 参数事实) 必然得到同一公式与系数。
  4. """
  5. from __future__ import annotations
  6. import json
  7. from functools import lru_cache
  8. from pathlib import Path
  9. from typing import Any, Mapping
  10. from algorithms.airflow_review.contracts import (
  11. RULE_VERSION_DEFAULT,
  12. parse_time,
  13. )
  14. from algorithms.airflow_review.engine.expression import try_evaluate
  15. RULES_DIR = Path(__file__).resolve().parent
  16. class RuleError(ValueError):
  17. """规则库缺失、结构不合法或规则版本不可用。"""
  18. class RuleSet:
  19. def __init__(self, data: dict[str, Any], source: str = "") -> None:
  20. if not data.get("rule_version"):
  21. raise RuleError("规则库缺少 rule_version")
  22. self._data = data
  23. self._source = source
  24. # ---- 基本信息 ---------------------------------------------------------
  25. @property
  26. def rule_version(self) -> str:
  27. return self._data["rule_version"]
  28. @property
  29. def source(self) -> str:
  30. return self._source
  31. @property
  32. def title(self) -> str:
  33. return self._data.get("title", "")
  34. def effective_window(self) -> dict[str, Any]:
  35. return {"start": self._data.get("effective_from"), "end": self._data.get("effective_to")}
  36. def is_effective_at(self, moment: Any) -> bool:
  37. parsed = parse_time(moment)
  38. if parsed is None:
  39. return False
  40. start = parse_time(self._data.get("effective_from"))
  41. end = parse_time(self._data.get("effective_to"))
  42. if start and parsed < start:
  43. return False
  44. if end and parsed > end:
  45. return False
  46. return True
  47. # ---- 常数与表 ---------------------------------------------------------
  48. def constant(self, name: str) -> Any:
  49. entry = (self._data.get("constants") or {}).get(name)
  50. if entry is None:
  51. raise RuleError(f"规则库缺少常数:{name}")
  52. return entry.get("value") if isinstance(entry, dict) else entry
  53. def constants(self) -> dict[str, Any]:
  54. return {name: (entry.get("value") if isinstance(entry, dict) else entry)
  55. for name, entry in (self._data.get("constants") or {}).items()}
  56. def table(self, name: str) -> list[dict[str, Any]]:
  57. table = (self._data.get("tables") or {}).get(name)
  58. if not table:
  59. raise RuleError(f"规则库缺少参数表:{name}")
  60. return list(table.get("rows") or [])
  61. def table_bounds(self, name: str, by_field: str) -> tuple[str, str]:
  62. """取区间字段名。表中字段名不必与 by 字段同名(如 min_temp vs max_temperature),
  63. 因此允许在表定义里显式声明 bounds,未声明时回落 min_/max_ + by 字段名。"""
  64. table = (self._data.get("tables") or {}).get(name) or {}
  65. bounds = table.get("bounds") or {}
  66. return (str(bounds.get("low") or f"min_{by_field}"),
  67. str(bounds.get("high") or f"max_{by_field}"))
  68. def table_title(self, name: str) -> str:
  69. return str(((self._data.get("tables") or {}).get(name) or {}).get("title") or name)
  70. def lookup_table(
  71. self,
  72. name: str,
  73. by_field: str,
  74. value: Any,
  75. pick: Any,
  76. ) -> tuple[float | None, dict[str, Any] | None]:
  77. """按 by_field 命中行并取系数;pick 为字段名或 [低值字段, 高值字段](区间内线性内插)。
  78. value 为字符串时按等值匹配(如硐室类型 matching room_type),为数值时按区间匹配。
  79. """
  80. low_field, high_field = self.table_bounds(name, by_field)
  81. row = self._match_row(self.table(name), by_field, low_field, high_field, value)
  82. if row is None:
  83. return None, None
  84. if isinstance(pick, (list, tuple)) and len(pick) == 2:
  85. low, high = row.get(pick[0]), row.get(pick[1])
  86. if low is None or high is None:
  87. return None, row
  88. if not isinstance(value, (int, float)):
  89. return float(low), row
  90. span_low, span_high = row.get(low_field), row.get(high_field)
  91. if None in (span_low, span_high) or span_high == span_low:
  92. return float(low), row
  93. weight = min(max((float(value) - float(span_low)) / (float(span_high) - float(span_low)), 0.0), 1.0)
  94. return round(float(low) + (float(high) - float(low)) * weight, 4), row
  95. picked = row.get(str(pick))
  96. if picked is None:
  97. return None, row
  98. return float(picked), row
  99. @staticmethod
  100. def _match_row(
  101. rows: list[dict[str, Any]],
  102. by_field: str,
  103. low_field: str,
  104. high_field: str,
  105. value: Any,
  106. ) -> dict[str, Any] | None:
  107. if not isinstance(value, (int, float)):
  108. wanted = str(value)
  109. for row in rows:
  110. if str(row.get(by_field)) == wanted:
  111. return row
  112. for row in rows:
  113. if wanted and wanted in str(row.get(by_field) or ""):
  114. return row
  115. return None
  116. numeric = float(value)
  117. for row in rows:
  118. low, high = row.get(low_field), row.get(high_field)
  119. if low is not None and numeric < float(low):
  120. continue
  121. if high is not None and numeric >= float(high):
  122. continue
  123. return row
  124. # 落在最后一档之外(例如超过最高温度),按边界档处理并留痕
  125. for row in reversed(rows):
  126. low = row.get(low_field)
  127. if low is None or numeric >= float(low):
  128. return row
  129. return rows[0] if rows else None
  130. # ---- 公式 -------------------------------------------------------------
  131. def formulas(self, location_type: str) -> list[dict[str, Any]]:
  132. return list((self._data.get("formulas") or {}).get(location_type) or [])
  133. def formulas_for_target(self, target_type: str) -> list[dict[str, Any]]:
  134. return list((self._data.get("formulas") or {}).get(target_type) or [])
  135. @staticmethod
  136. def select_formula(formula: dict[str, Any], facts: Mapping[str, Any]) -> dict[str, Any]:
  137. """按事实选择公式变体;返回 {expression, variant, note}。"""
  138. if formula.get("applies_to") and not _when_matches(formula["applies_to"], facts):
  139. return {"expression": None, "variant": None, "reason": "公式适用范围不匹配"}
  140. selected = {"expression": formula.get("expression"), "variant": None, "note": formula.get("note", "")}
  141. for variant in formula.get("variants") or []:
  142. if _when_matches(variant.get("when") or {}, facts):
  143. selected = {"expression": variant.get("expression"), "variant": variant.get("when"),
  144. "note": variant.get("note", formula.get("note", ""))}
  145. break
  146. return selected
  147. def verification(self, location_type: str) -> list[dict[str, Any]]:
  148. return list((self._data.get("verification") or {}).get(location_type) or [])
  149. # ---- 最低风量与最大风速 ----------------------------------------------
  150. def min_airflow(self, location_type: str, facts: Mapping[str, Any]) -> dict[str, Any] | None:
  151. for entry in (self._data.get("min_airflow") or {}).get(location_type) or []:
  152. if _when_matches(entry.get("when") or {}, facts):
  153. return entry
  154. return None
  155. def max_air_velocity(self, location_type: str, tag: str | None = None) -> dict[str, Any] | None:
  156. table = self._data.get("max_air_velocity") or {}
  157. if tag and f"{location_type}:{tag}" in table:
  158. return table[f"{location_type}:{tag}"]
  159. return table.get(location_type)
  160. # ---- 阈值 -------------------------------------------------------------
  161. def thresholds(self) -> dict[str, Any]:
  162. return dict(self._data.get("thresholds") or {})
  163. def parameter_markers(self) -> dict[str, dict[str, Any]]:
  164. """列出会影响公式选择的"口径标记"及其允许取值。
  165. 这些标记(如 section_area_basis、roadway_type、explosive_grade)决定用哪个公式
  166. 变体,取错值会静默算出错误结果,因此必须把允许取值明确告诉提取方——否则模型会
  167. 写一个同义但不同的说法(实测遇到过把"有效断面积"写成"平均有效净断面积"),
  168. 导致变体不命中、复算出与计划不符的值,把正确计划误判成计算错误。
  169. """
  170. markers: dict[str, dict[str, Any]] = {}
  171. for bucket in (self._data.get("formulas") or {}):
  172. for formula in self.formulas(bucket):
  173. for key, values in (formula.get("marker_values") or {}).items():
  174. entry = markers.setdefault(key, {"allowed": [], "affects": []})
  175. for value in values:
  176. if value not in entry["allowed"]:
  177. entry["allowed"].append(value)
  178. if formula["id"] not in entry["affects"]:
  179. entry["affects"].append(formula["id"])
  180. for variant in formula.get("variants") or []:
  181. for key, expected in (variant.get("when") or {}).items():
  182. entry = markers.setdefault(key, {"allowed": [], "affects": []})
  183. for value in (expected if isinstance(expected, (list, tuple)) else [expected]):
  184. if value not in entry["allowed"]:
  185. entry["allowed"].append(value)
  186. if formula["id"] not in entry["affects"]:
  187. entry["affects"].append(formula["id"])
  188. # 表驱动的标记:允许取值就是该表那一列的取值集合
  189. for spec in (formula.get("params") or {}).values():
  190. derive = (spec or {}).get("derive") or {}
  191. if "table" not in derive or not derive.get("by"):
  192. continue
  193. try:
  194. rows = self.table(derive["table"])
  195. except RuleError:
  196. continue
  197. values = [row.get(derive["by"]) for row in rows]
  198. values = [value for value in values if value is not None]
  199. if not values:
  200. continue
  201. entry = markers.setdefault(derive["by"], {"allowed": [], "affects": []})
  202. for value in values:
  203. if value not in entry["allowed"]:
  204. entry["allowed"].append(value)
  205. entry["table"] = derive["table"]
  206. for entry in markers.values():
  207. entry["note"] = ("取值必须严格来自 allowed;确实无法判断时留 null——"
  208. "工具会用计划自己写出的结果反推它采用的是哪个变体")
  209. return markers
  210. def tolerance(self, override: Mapping[str, Any] | None = None) -> dict[str, float]:
  211. base = dict((self.thresholds().get("tolerance") or {}))
  212. base.pop("note", None)
  213. for key, value in (override or {}).items():
  214. if isinstance(value, (int, float)):
  215. base[key] = float(value)
  216. base.setdefault("absolute_m3_min", 1.0)
  217. base.setdefault("relative_percent", 0.5)
  218. return base
  219. def compare_policy(self, target_type: str) -> dict[str, Any]:
  220. policies = self.thresholds().get("compare") or {}
  221. if target_type not in policies:
  222. raise RuleError(f"规则库缺少比对阈值策略:{target_type}")
  223. return dict(policies[target_type])
  224. def reconcile_policy(self) -> dict[str, Any]:
  225. return dict(self.thresholds().get("reconcile") or {})
  226. def _when_matches(when: Mapping[str, Any], facts: Mapping[str, Any]) -> bool:
  227. for key, expected in when.items():
  228. actual = facts.get(key)
  229. if isinstance(expected, (list, tuple)):
  230. if actual not in expected:
  231. return False
  232. elif actual != expected:
  233. return False
  234. return True
  235. def _rules_path(rule_version: str) -> Path:
  236. return RULES_DIR / f"airflow-rules-{rule_version.replace('AIR-RULE-', '')}.json"
  237. MASTER_DATA_FILE = RULES_DIR / "master_data.json"
  238. @lru_cache(maxsize=1)
  239. def load_master_data() -> dict[str, Any]:
  240. """加载主数据(地点分类、别名、回风系统标识);缺失时返回空壳而非报错。"""
  241. if not MASTER_DATA_FILE.is_file():
  242. return {"version": "MASTER-未配置", "location_classification": [], "aliases": {}}
  243. return json.loads(MASTER_DATA_FILE.read_text(encoding="utf-8"))
  244. def classify_location(name: str, master: Mapping[str, Any] | None = None) -> tuple[str, str]:
  245. """按主数据把地点名判成地点类型;命中不到返回 ("", 原因),由调用方转待复核。"""
  246. data = master if master is not None else load_master_data()
  247. text = (name or "").strip()
  248. if not text:
  249. return "", "地点名为空"
  250. rules = sorted(data.get("location_classification") or [], key=lambda item: item.get("priority", 99))
  251. for rule in rules:
  252. for keyword in rule.get("match") or []:
  253. if keyword and keyword in text:
  254. return str(rule.get("location_type") or ""), f"命中关键词「{keyword}」({rule.get('id')})"
  255. return "", "未命中任何地点分类规则,需人工确认"
  256. def normalize_location_name(name: str, master: Mapping[str, Any] | None = None) -> str:
  257. """别名归一:去掉空白与全角空格,保留原文以便回溯。"""
  258. text = (name or "").replace("\u3000", " ").strip()
  259. for _ in range(4):
  260. text = text.replace(" ", " ")
  261. return text
  262. def available_versions() -> list[str]:
  263. return sorted(path.stem.replace("airflow-rules-", "AIR-RULE-") for path in RULES_DIR.glob("airflow-rules-*.json"))
  264. @lru_cache(maxsize=8)
  265. def load_rules(rule_version: str | None = None, effective_date: Any = None) -> RuleSet:
  266. """加载规则库;effective_date 用于校验规则在目标时间窗内有效。"""
  267. version = rule_version if rule_version and rule_version != "auto" else RULE_VERSION_DEFAULT
  268. path = _rules_path(version)
  269. if not path.is_file():
  270. raise RuleError(f"规则版本不可用:{version};可选 {available_versions()}")
  271. data = json.loads(path.read_text(encoding="utf-8"))
  272. rules = RuleSet(data, source=path.name)
  273. if effective_date and not rules.is_effective_at(effective_date):
  274. raise RuleError(f"规则版本 {version} 在 {effective_date} 不生效,需人工确认适用版本")
  275. return rules
  276. def derive_params(
  277. formula: dict[str, Any],
  278. facts: Mapping[str, Any],
  279. rules: RuleSet,
  280. ) -> tuple[dict[str, Any], list[str], list[dict[str, Any]]]:
  281. """按公式的参数声明补齐派生参数。
  282. 返回 (可用参数, 缺失参数名, 派生过程)。派生过程要写进计算链,让人能看出
  283. 每个系数是从哪张表、按哪个事实取的——这是"结论可追溯"的一半。
  284. """
  285. params = dict(facts)
  286. missing: list[str] = []
  287. trace: list[dict[str, Any]] = []
  288. for name, spec in (formula.get("params") or {}).items():
  289. if params.get(name) is not None:
  290. continue
  291. spec = spec or {}
  292. default_from = spec.get("default_from")
  293. if default_from and default_from.startswith("constants."):
  294. params[name] = rules.constant(default_from.split(".", 1)[1])
  295. trace.append({"param": name, "from": default_from, "value": params[name]})
  296. continue
  297. derive = spec.get("derive") or {}
  298. if "table" in derive:
  299. by_field = derive.get("by")
  300. driver = params.get(by_field)
  301. if driver is None:
  302. missing.append(name)
  303. continue
  304. value, row = rules.lookup_table(derive["table"], by_field, driver, derive.get("pick"))
  305. if value is None:
  306. missing.append(name)
  307. continue
  308. params[name] = value
  309. trace.append({"param": name, "table": derive["table"],
  310. "table_title": rules.table_title(derive["table"]),
  311. "by": by_field, "driver": driver, "value": value, "row": row})
  312. continue
  313. if "expression" in derive:
  314. value, reason = try_evaluate(derive["expression"], params)
  315. if value is None:
  316. missing.append(name)
  317. continue
  318. params[name] = round(value, 4)
  319. trace.append({"param": name, "expression": derive["expression"], "value": params[name]})
  320. continue
  321. if name in (formula.get("requires") or []):
  322. missing.append(name)
  323. for name in formula.get("requires") or []:
  324. if params.get(name) is None and name not in missing:
  325. missing.append(name)
  326. return params, missing, trace