matching.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """地点名称匹配:把测风报表的实测地点与配风计划的用风地点对应起来。
  2. 这是"参数一致性规则"(§2.6 第 10 条)的落地点:计划值、实测值、复算值必须指向
  3. 同一对象。逐字符相等几乎不可能(计划写"42 上208 主运顺槽工作面局扇安设点",
  4. 报表写"42上208主运顺槽风桥段回风"),因此按三级策略逐步放宽:
  5. 1. 归一化后完全相等;
  6. 2. 去掉测点后缀(回风/入风/上风侧/下风侧/测点/站)后的核心名相等;
  7. 3. 一方包含另一方核心名,且核心名长度达到门槛。
  8. 匹配不上的**不许猜**:进 unmatched 并转人工复核(§2.8 第 1 条)。
  9. """
  10. from __future__ import annotations
  11. import re
  12. from typing import Any, Mapping
  13. from algorithms.airflow_review.rules.loader import load_master_data
  14. # 测点位置与口径后缀:报表常在名称后加这些词,计划里换一种写法
  15. # (如报表"明安木独回风立井总回风" vs 计划"明安木独回风立井回风系统")。
  16. _MEASUREMENT_SUFFIX = ("回风上风侧", "回风下风侧", "风桥段回风上风侧", "风桥段回风", "回风系统",
  17. "总回风", "回风", "主要通风机", "主扇",
  18. "入风", "进风", "上风侧", "下风侧", "测点", "测风站", "站")
  19. _MIN_CONTAINMENT_LEN = 6
  20. _NOISE = re.compile(r"[\s\u3000()()、,,.。\-—_/]")
  21. def normalize_name(name: Any) -> str:
  22. """归一化:去空白与标点、统一全角数字、压缩"综采(放)"之类写法差异。"""
  23. text = _NOISE.sub("", str(name or "").lower())
  24. text = text.replace("(", "").replace(")", "")
  25. return text
  26. def core_name(name: Any) -> str:
  27. """核心名:去掉测点位置后缀,用于"同一地点的不同测点"归并。"""
  28. text = normalize_name(name)
  29. changed = True
  30. while changed:
  31. changed = False
  32. for suffix in _MEASUREMENT_SUFFIX:
  33. if text.endswith(suffix) and len(text) > len(suffix) + 2:
  34. text = text[: -len(suffix)]
  35. changed = True
  36. return text
  37. def match_locations(
  38. dataset_locations: list[Mapping[str, Any]],
  39. verified_items: list[Mapping[str, Any]],
  40. *,
  41. aliases: Mapping[str, list[str]] | None = None,
  42. ) -> dict[str, Any]:
  43. """返回匹配对与未匹配清单;每个实测地点最多匹配一个计划对象。"""
  44. alias_map = _alias_index(aliases or (load_master_data().get("aliases") or {}))
  45. plan_index: dict[str, list[dict[str, Any]]] = {}
  46. core_index: dict[str, list[dict[str, Any]]] = {}
  47. for item in verified_items:
  48. target_id = str(item.get("target_id") or "")
  49. if not target_id:
  50. continue
  51. entry = dict(item)
  52. for key in _plan_keys(entry, alias_map):
  53. plan_index.setdefault(key, []).append(entry)
  54. core = core_name(entry.get("target_name") or target_id)
  55. if core:
  56. core_index.setdefault(core, []).append(entry)
  57. pairs: list[dict[str, Any]] = []
  58. unmatched_dataset: list[dict[str, Any]] = []
  59. used: set[str] = set()
  60. for location in dataset_locations:
  61. name = location.get("location_name")
  62. found, strategy = _lookup(name, plan_index, core_index, alias_map, used)
  63. if found is None:
  64. unmatched_dataset.append({
  65. "location_id": location.get("location_id"),
  66. "location_name": name,
  67. "airflow_m3_min": location.get("airflow_m3_min"),
  68. "reason": "在核验通过的配风计划对象中找不到对应地点",
  69. "action": "请人工确认该测点对应计划中的哪个用风地点",
  70. })
  71. continue
  72. used.add(str(found.get("target_id")))
  73. pairs.append({
  74. "location_id": location.get("location_id"),
  75. "location_name": name,
  76. "location_type": location.get("location_type"),
  77. "measurement_point": location.get("measurement_point"),
  78. "actual_airflow_m3_min": location.get("airflow_m3_min"),
  79. "target_id": found.get("target_id"),
  80. "target_name": found.get("target_name"),
  81. "target_type": found.get("target_type"),
  82. "matched_by": strategy,
  83. "match_confidence": 1.0 if strategy == "exact" else (0.9 if strategy == "core" else 0.75),
  84. })
  85. unmatched_plan = [dict(item) for item in verified_items
  86. if str(item.get("target_id")) not in used
  87. and str(item.get("target_type")) == "workplace"]
  88. return {
  89. "pairs": pairs,
  90. "unmatched_dataset": unmatched_dataset,
  91. "unmatched_plan_workplaces": unmatched_plan,
  92. "matched_count": len(pairs),
  93. "match_rate": round(len(pairs) / len(dataset_locations), 4) if dataset_locations else 0.0,
  94. }
  95. def match_return_systems(
  96. dataset_systems: list[Mapping[str, Any]],
  97. verified_items: list[Mapping[str, Any]],
  98. ) -> dict[str, Any]:
  99. """回风井 ↔ 回风系统匹配:回风系统以回风井为单位,按核心名包含关系对应。"""
  100. plan_systems = [dict(item) for item in verified_items
  101. if str(item.get("target_type")) == "return_air_system"]
  102. pairs: list[dict[str, Any]] = []
  103. unmatched: list[dict[str, Any]] = []
  104. used: set[str] = set()
  105. for system in dataset_systems:
  106. name = core_name(system.get("location_name"))
  107. found = None
  108. for item in plan_systems:
  109. candidate = core_name(item.get("target_name"))
  110. if not candidate or not name:
  111. continue
  112. if candidate == name or candidate in name or name in candidate:
  113. found = item
  114. break
  115. if found is None:
  116. unmatched.append({"location_name": system.get("location_name"),
  117. "reason": "该回风井在核验通过的回风系统中找不到对应对象",
  118. "action": "请人工确认回风井与回风系统的对应关系"})
  119. continue
  120. used.add(str(found.get("target_id")))
  121. pairs.append({
  122. "location_name": system.get("location_name"),
  123. "actual_airflow_m3_min": system.get("airflow_m3_min"),
  124. "target_id": found.get("target_id"),
  125. "target_name": found.get("target_name"),
  126. "target_type": "return_air_system",
  127. })
  128. return {
  129. "pairs": pairs,
  130. "unmatched_dataset": unmatched,
  131. "unmatched_plan_systems": [item for item in plan_systems
  132. if str(item.get("target_id")) not in used],
  133. }
  134. def _lookup(
  135. name: Any,
  136. plan_index: Mapping[str, list[dict[str, Any]]],
  137. core_index: Mapping[str, list[dict[str, Any]]],
  138. alias_map: Mapping[str, list[str]],
  139. used: set[str],
  140. ) -> tuple[dict[str, Any] | None, str]:
  141. keys = _plan_keys({"target_name": name}, alias_map)
  142. for key in keys:
  143. for candidate in plan_index.get(key, []):
  144. if str(candidate.get("target_id")) not in used:
  145. return candidate, "exact"
  146. core = core_name(name)
  147. if not core:
  148. return None, ""
  149. for candidate in core_index.get(core, []):
  150. if str(candidate.get("target_id")) not in used:
  151. return candidate, "core"
  152. if len(core) >= _MIN_CONTAINMENT_LEN:
  153. for candidate_core, candidates in core_index.items():
  154. if not candidate_core or len(candidate_core) < _MIN_CONTAINMENT_LEN:
  155. continue
  156. if core in candidate_core or candidate_core in core:
  157. for candidate in candidates:
  158. if str(candidate.get("target_id")) not in used:
  159. return candidate, "containment"
  160. return None, ""
  161. def _plan_keys(item: Mapping[str, Any], alias_map: Mapping[str, list[str]]) -> list[str]:
  162. names = [item.get("target_name") or ""]
  163. for name in list(names):
  164. names.extend(alias_map.get(normalize_name(name), []))
  165. return [normalize_name(name) for name in names if name]
  166. def _alias_index(aliases: Mapping[str, Any]) -> dict[str, list[str]]:
  167. """把别名表展开成 归一化名 → 等价写法 的索引,两个方向都建。"""
  168. index: dict[str, list[str]] = {}
  169. for canonical, variants in aliases.items():
  170. if canonical in ("note", "示例") or not isinstance(variants, list):
  171. continue
  172. group = [canonical, *[str(item) for item in variants]]
  173. for name in group:
  174. key = normalize_name(name)
  175. bucket = index.setdefault(key, [])
  176. for other in group:
  177. if other != name and other not in bucket:
  178. bucket.append(other)
  179. return index