hierarchy.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. """层级关系校验:用风地点 → 回风系统 → 矿井总需风量(§2.6 第 4、5 条)。
  2. 只做结构与归属判断,不算风量:遗漏归属、重复归属、循环引用、重复汇总、
  3. 汇总关系缺失,以及单一回风系统与矿井总需风量的等值关系识别。
  4. """
  5. from __future__ import annotations
  6. from typing import Any, Mapping
  7. from algorithms.airflow_review.contracts import (
  8. TARGET_RETURN_SYSTEM,
  9. TARGET_WORKPLACE,
  10. as_list,
  11. text,
  12. unique,
  13. )
  14. LEVEL_WORKPLACE = "workplace"
  15. LEVEL_RETURN = "return_air_system"
  16. LEVEL_MINE = "mine_total"
  17. _LEVEL_BY_TYPE = {
  18. TARGET_WORKPLACE: LEVEL_WORKPLACE,
  19. TARGET_RETURN_SYSTEM: LEVEL_RETURN,
  20. "mine_total": LEVEL_MINE,
  21. }
  22. def build_hierarchy(items: list[Mapping[str, Any]]) -> dict[str, Any]:
  23. """建立层级模型并返回结构问题清单。"""
  24. nodes: dict[str, dict[str, Any]] = {}
  25. duplicates: set[str] = set()
  26. for item in items:
  27. target_id = text(item.get("target_id"))
  28. if not target_id:
  29. continue
  30. if target_id in nodes:
  31. duplicates.add(target_id)
  32. continue
  33. target_type = text(item.get("target_type"))
  34. nodes[target_id] = {
  35. "target_id": target_id,
  36. "target_name": item.get("target_name") or target_id,
  37. "target_type": target_type,
  38. "level": _LEVEL_BY_TYPE.get(target_type, ""),
  39. "parent_id": text(item.get("parent_return_system_id") or item.get("parent_mine_total_id")
  40. or item.get("parent_target_id")),
  41. "component_ids": [text(cid) for cid in _component_ids(item)],
  42. "planned_required_airflow_m3_min": item.get("planned_required_airflow_m3_min"),
  43. }
  44. issues: list[dict[str, Any]] = []
  45. for target_id in sorted(duplicates):
  46. issues.append(_issue("duplicate_target_id", "major", target_id,
  47. f"同一对象在计划中出现多次:{target_id},无法确定唯一需风量"))
  48. # 归属校验:每个子对象必须且只能归属一个父对象
  49. parent_claims: dict[str, set[str]] = {}
  50. for node in nodes.values():
  51. if not node["parent_id"]:
  52. continue
  53. parent_claims.setdefault(node["parent_id"], set()).add(node["target_id"])
  54. for node in nodes.values():
  55. if node["level"] == LEVEL_MINE:
  56. continue
  57. parent_id = node["parent_id"]
  58. if not parent_id:
  59. issues.append(_issue("orphan_target", "major", node["target_id"],
  60. f"{node['target_name']} 未标明所属"
  61. f"{'回风系统' if node['level'] == LEVEL_WORKPLACE else '矿井总需风量'},无法归属"))
  62. continue
  63. parent = nodes.get(parent_id)
  64. if parent is None:
  65. issues.append(_issue("missing_parent", "major", node["target_id"],
  66. f"{node['target_name']} 的上级对象 {parent_id} 在计划中不存在"))
  67. continue
  68. if parent["level"] == node["level"]:
  69. issues.append(_issue("level_conflict", "major", node["target_id"],
  70. f"{node['target_name']} 与上级 {parent_id} 处于同一层级"))
  71. if node["target_id"] not in parent["component_ids"]:
  72. issues.append(_issue("missing_membership", "general", node["target_id"],
  73. f"{node['target_name']} 声明归属 {parent_id},但该上级未将其列入组成对象"))
  74. for parent_id, children in parent_claims.items():
  75. owner = nodes.get(parent_id)
  76. if owner is None:
  77. continue
  78. for child_id in sorted(children):
  79. claims = [pid for pid, cids in parent_claims.items() if child_id in cids]
  80. if len(claims) > 1:
  81. issues.append(_issue("duplicate_membership", "major", child_id,
  82. f"对象 {child_id} 被 {('/'.join(sorted(claims)))} 重复归属"))
  83. # 汇总关系校验
  84. for node in nodes.values():
  85. if node["level"] == LEVEL_WORKPLACE:
  86. continue
  87. component_ids = node["component_ids"]
  88. if not component_ids:
  89. issues.append(_issue("missing_aggregation", "major", node["target_id"],
  90. f"{node['target_name']} 未列出组成对象,无法校验汇总关系"))
  91. seen: dict[str, int] = {}
  92. for child_id in component_ids:
  93. seen[child_id] = seen.get(child_id, 0) + 1
  94. for child_id, count in sorted(seen.items()):
  95. if count > 1:
  96. issues.append(_issue("duplicate_in_aggregation", "major", node["target_id"],
  97. f"{node['target_name']} 重复列入 {child_id} {count} 次,汇总会重复计算"))
  98. if child_id not in nodes:
  99. issues.append(_issue("unknown_component", "general", node["target_id"],
  100. f"{node['target_name']} 列出的组成对象 {child_id} 未在计划中定义"))
  101. cycles = _find_cycles(nodes)
  102. for cycle in cycles:
  103. issues.append(_issue("cyclic_reference", "major", cycle[0],
  104. "层级存在循环引用:" + " → ".join(cycle)))
  105. return_systems = [node for node in nodes.values() if node["level"] == LEVEL_RETURN]
  106. mine_totals = [node for node in nodes.values() if node["level"] == LEVEL_MINE]
  107. if not return_systems:
  108. issues.append(_issue("no_return_system", "major", "",
  109. "计划未列出任何回风系统,层级关系不成立"))
  110. if not mine_totals:
  111. issues.append(_issue("no_mine_total", "major", "", "计划未列出矿井总需风量"))
  112. equivalence = _single_return_equivalence(return_systems, mine_totals)
  113. return {
  114. "nodes": nodes,
  115. "roots": [node["target_id"] for node in mine_totals],
  116. "levels": {
  117. LEVEL_WORKPLACE: sorted(node["target_id"] for node in nodes.values() if node["level"] == LEVEL_WORKPLACE),
  118. LEVEL_RETURN: sorted(node["target_id"] for node in return_systems),
  119. LEVEL_MINE: sorted(node["target_id"] for node in mine_totals),
  120. },
  121. "issues": issues,
  122. "single_return_system": len(return_systems) == 1,
  123. "single_return_equivalence": equivalence,
  124. "blocking_targets": unique([issue["target_id"] for issue in issues
  125. if issue["severity"] == "major" and issue["target_id"]]),
  126. }
  127. def ancestors_of(target_id: str, nodes: Mapping[str, Mapping[str, Any]]) -> list[str]:
  128. """向上追溯到矿井总需风量,用于"计算错误影响上层对象"的阻断传播。"""
  129. chain: list[str] = []
  130. seen: set[str] = set()
  131. current = target_id
  132. while current and current not in seen:
  133. seen.add(current)
  134. node = nodes.get(current)
  135. if node is None:
  136. break
  137. parent = text(node.get("parent_id"))
  138. if not parent or parent in seen:
  139. break
  140. chain.append(parent)
  141. current = parent
  142. return chain
  143. def _single_return_equivalence(
  144. return_systems: list[dict[str, Any]],
  145. mine_totals: list[dict[str, Any]],
  146. ) -> dict[str, Any]:
  147. """单一回风系统时,回风系统需风量与矿井总需风量应等值并可复用同一基准。"""
  148. if len(return_systems) != 1 or len(mine_totals) != 1:
  149. return {"applicable": False, "equivalent": False, "reason": "非单一回风系统,不适用等值关系"}
  150. system_value = return_systems[0].get("planned_required_airflow_m3_min")
  151. mine_value = mine_totals[0].get("planned_required_airflow_m3_min")
  152. tail = return_systems[0].get("component_ids") or []
  153. if not tail:
  154. return {"applicable": True, "equivalent": False,
  155. "return_system_id": return_systems[0]["target_id"],
  156. "mine_total_id": mine_totals[0]["target_id"],
  157. "system_value": system_value, "mine_value": mine_value,
  158. "reason": "单一回风系统,但矿井总需风量未将回风系统列为组成对象"}
  159. if isinstance(system_value, (int, float)) and isinstance(mine_value, (int, float)):
  160. equivalent = abs(float(system_value) - float(mine_value)) <= max(1.0, abs(float(mine_value)) * 0.005)
  161. else:
  162. equivalent = False
  163. return {
  164. "applicable": True,
  165. "equivalent": equivalent,
  166. "return_system_id": return_systems[0]["target_id"],
  167. "mine_total_id": mine_totals[0]["target_id"],
  168. "system_value": system_value,
  169. "mine_value": mine_value,
  170. "reuse_key": f"reuse:{mine_totals[0]['target_id']}" if equivalent else "",
  171. "reason": "单一回风系统,需风量与矿井总需风量等值,可复用同一基准"
  172. if equivalent else "单一回风系统,但回风系统需风量与矿井总需风量的计划值不一致",
  173. }
  174. def _component_ids(item: Mapping[str, Any]) -> list[str]:
  175. """原样保留组成对象列表:这里不能去重,否则"重复汇总"永远检测不出来。"""
  176. return [text(cid) for cid in as_list(
  177. item.get("component_workplace_ids") or item.get("component_return_system_ids")
  178. or item.get("component_target_ids"))]
  179. def _find_cycles(nodes: Mapping[str, Mapping[str, Any]]) -> list[list[str]]:
  180. cycles: list[list[str]] = []
  181. reported: set[frozenset[str]] = set()
  182. for start in nodes:
  183. path: list[str] = []
  184. current = start
  185. while current and current in nodes:
  186. if current in path:
  187. cycle = path[path.index(current):] + [current]
  188. key = frozenset(cycle)
  189. if key not in reported:
  190. reported.add(key)
  191. cycles.append(cycle)
  192. break
  193. path.append(current)
  194. current = text(nodes[current].get("parent_id"))
  195. return cycles
  196. def _issue(code: str, severity: str, target_id: str, message: str) -> dict[str, Any]:
  197. return {"code": code, "severity": severity, "target_id": target_id, "message": message}