analysis.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. """确定性筛查:命名只生成候选,计算只用明确确认且同步的测点。"""
  2. from collections import Counter
  3. from datetime import datetime
  4. import re
  5. import unicodedata
  6. from zoneinfo import ZoneInfo
  7. from algorithms.main_fan.models import Association, Calculation, Finding, Screening, ScreeningInput
  8. LOCAL_ZONE = ZoneInfo("Asia/Shanghai")
  9. UNITS = {
  10. "velocity": {"m/s": 1}, "flow": {"m3/s": 1, "m³/s": 1, "m3/min": 1/60, "m³/min": 1/60},
  11. "static_pressure": {"Pa": 1, "kPa": 1000}, "pressure": {"Pa": 1, "kPa": 1000},
  12. "shaft_power": {"kW": 1, "W": .001}, "input_power": {"kW": 1, "W": .001},
  13. "temperature": {"℃": 1, "°C": 1}, "current": {"A": 1}, "voltage": {"V": 1},
  14. "speed": {"r/min": 1, "rpm": 1}, "frequency": {"Hz": 1},
  15. }
  16. def normalized(value):
  17. return re.sub(r"\s+", "", unicodedata.normalize("NFKC", value)).lower()
  18. def fan_numbers(value):
  19. """数字必须带风机语义或号/#;不把 PLC1、一级和测点编码当风机号。"""
  20. value = normalized(value)
  21. if not any(word in value for word in ("主通", "主扇", "风机", "通风机")):
  22. return set()
  23. for cn, number in {"一": "1", "二": "2", "三": "3", "四": "4", "五": "5",
  24. "六": "6", "七": "7", "八": "8", "九": "9", "十": "10"}.items():
  25. value = value.replace(cn + "号", number + "号")
  26. matches = re.findall(r"(?:主通|主扇|风机|通风机)[-_]*([0-9]+)(?:号|#)?|([0-9]+)(?:号|#)", value)
  27. return {int(a or b) for a, b in matches}
  28. def local_time(value):
  29. return value.replace(tzinfo=LOCAL_ZONE) if value.tzinfo is None else value.astimezone(LOCAL_ZONE)
  30. def same_mine(item, data):
  31. # 任一已提供的身份冲突都拒绝;不靠去除公司后缀/模糊匹配合并矿井。
  32. if item.mine_code and item.mine_code != data.mine_code:
  33. return False
  34. if item.mine_name and normalized(item.mine_name) != normalized(data.mine_name):
  35. return False
  36. return bool(item.mine_code or item.mine_name)
  37. def screen(data: ScreeningInput, *, now=None, freshness_seconds=600, sync_seconds=120,
  38. efficiency_tolerance=.5) -> Screening:
  39. now = local_time(data.evaluation_time or now or datetime.now(LOCAL_ZONE))
  40. findings, associations, calculations, missing = [], [], [], []
  41. def note(code, severity, message, *evidence):
  42. findings.append(Finding(code=code, severity=severity, message=message, evidence=list(evidence)))
  43. scoped_devices = [d for d in data.devices if same_mine(d, data)]
  44. selected = [d for d in scoped_devices if d.device_code == data.fan_id]
  45. target_name = data.fan_name or (selected[0].name if len(selected) == 1 else data.fan_id)
  46. target_numbers = fan_numbers(target_name)
  47. if len(target_numbers) != 1:
  48. missing.append("明确的目标风机名称/编号;PLC 编号及一级、二级不是风机编号")
  49. bindings = {}
  50. for binding in data.confirmed_bindings:
  51. if binding.point_code in bindings:
  52. raise ValueError("同一测点存在重复人工确认,请消除冲突")
  53. bindings[binding.point_code] = binding
  54. duplicates = {code for code, n in Counter(p.point_code for p in data.points).items() if n > 1}
  55. usable, in_scope = {}, bool(scoped_devices)
  56. for point in data.points:
  57. state, confidence, basis = "unmatched", "none", []
  58. if not same_mine(point, data):
  59. state = "excluded"
  60. basis.append("矿井身份冲突或缺失,未参与关联和计算")
  61. note("point_identity", "blocking", f"测点 {point.point_code} 矿井身份不匹配或缺失。", point.evidence)
  62. elif point.point_code in duplicates:
  63. state = "ambiguous"
  64. basis.append("测点编码重复,未选择任意一条覆盖其他记录")
  65. note("duplicate_point", "blocking", f"测点 {point.point_code} 存在重复记录。", point.evidence)
  66. else:
  67. in_scope = True
  68. binding = bindings.get(point.point_code)
  69. numbers = fan_numbers(point.name + " " + point.device_name)
  70. if binding and binding.fan_id == data.fan_id and data.fan_id:
  71. if len(target_numbers) == 1 and numbers and numbers != target_numbers:
  72. state = "ambiguous"
  73. basis.append("人工绑定与名称中的风机编号冲突,需复核确认记录")
  74. note("binding_conflict", "blocking", f"测点 {point.point_code} 人工绑定与名称编号冲突。", point.evidence)
  75. else:
  76. state, confidence = "confirmed", "confirmed"
  77. basis.append(f"人工确认:{binding.confirmed_by};依据:{binding.reason}")
  78. elif len(numbers) > 1:
  79. state = "ambiguous"
  80. basis.append("名称含多个风机编号")
  81. elif len(target_numbers) == 1 and numbers == target_numbers:
  82. state, confidence = "candidate", "medium"
  83. basis.append("同矿井且名称风机编号一致;不能证明安装拓扑")
  84. else:
  85. basis.append("未获得可唯一指向目标风机的命名证据")
  86. if state in {"candidate", "ambiguous", "unmatched"}:
  87. missing.append(f"测点 {point.point_code} 与目标风机的人工确认")
  88. if point.value is None:
  89. note("missing_value", "review", f"测点 {point.point_code} 缺少数值。", point.evidence)
  90. unit_valid = point.unit in UNITS.get(point.metric, {})
  91. if not unit_valid:
  92. note("unknown_unit", "review", f"测点 {point.point_code} 的类型/单位尚不能用于计算:{point.metric}/{point.unit}。", point.evidence)
  93. valid_time = point.observed_at is not None
  94. if not valid_time:
  95. note("missing_time", "review", f"测点 {point.point_code} 缺少采集时间。", point.evidence)
  96. else:
  97. age = (now - local_time(point.observed_at)).total_seconds()
  98. valid_time = -sync_seconds <= age <= freshness_seconds
  99. if not valid_time:
  100. note("stale_point" if age > freshness_seconds else "future_point", "review",
  101. f"测点 {point.point_code} 采集时间不在当前筛查窗口内。", point.evidence)
  102. valid_status = point.status in {"normal", "正常", "online", "在线"}
  103. if not valid_status:
  104. note("point_status", "review", f"测点 {point.point_code} 采集状态为 {point.status},暂不用于计算。", point.evidence)
  105. nonnegative = point.value is not None and (point.value >= 0 or point.metric == "temperature")
  106. if point.value is not None and not nonnegative:
  107. note("negative_value", "review", f"测点 {point.point_code} 负值不能直接作为正向风量、风机静压增量或功率。", point.evidence)
  108. eligible = state == "confirmed" and unit_valid and valid_time and valid_status and nonnegative
  109. if eligible:
  110. usable.setdefault(point.metric, []).append(point)
  111. if point.metric == "velocity" and point.value == 0:
  112. note("zero_velocity", "review", f"测点 {point.point_code} 记录风速为 0;仅此记录不能判定风机停转或矿井停风。", point.evidence)
  113. if eligible:
  114. running = [d for d in scoped_devices if d.state == "running" and d.observed_at and
  115. (d.device_code == data.fan_id or fan_numbers(d.name) == target_numbers) and
  116. -sync_seconds <= (now - local_time(d.observed_at)).total_seconds() <= freshness_seconds and
  117. abs((local_time(d.observed_at) - local_time(point.observed_at)).total_seconds()) <= sync_seconds]
  118. if running:
  119. note("running_zero_velocity", "review", "已确认关联的零风速与同步开机记录不一致,需核查采集、测点或设备状态;不能仅据此断定停风。",
  120. point.evidence, *(d.evidence for d in running))
  121. if point.metric == "velocity":
  122. missing.append("直接风量测点,或经确认的断面面积和断面平均风速;单点风速不能直接换算风量")
  123. if point.metric == "pressure":
  124. missing.append("风压测点的物理含义与测压位置;未标定的风压不能替代风机静压增量")
  125. associations.append(Association(point_code=point.point_code, candidate_fan=target_name,
  126. state=state, confidence=confidence, basis=basis,
  127. eligible_for_calculation=bool(state == "confirmed" and point in usable.get(point.metric, []))))
  128. # 阶段开关只描述该阶段,缺少时间及主备配置时不推断整套系统停风。
  129. for device in scoped_devices:
  130. if device.observed_at is None:
  131. note("device_missing_time", "review", f"设备 {device.name} 开停记录缺少时间,不能与测点作同步冲突判断。", device.evidence)
  132. elif not -sync_seconds <= (now - local_time(device.observed_at)).total_seconds() <= freshness_seconds:
  133. note("stale_device" if local_time(device.observed_at) < now else "future_device", "review",
  134. f"设备 {device.name} 开停记录不在当前筛查窗口内。", device.evidence)
  135. for metric in ("flow", "static_pressure", "shaft_power"):
  136. if len(usable.get(metric, [])) != 1:
  137. missing.append({"flow": "唯一且已确认关联的当前风量", "static_pressure": "唯一且已确认关联的当前风机静压增量",
  138. "shaft_power": "唯一且已确认关联的当前轴功率(电输入功率不可替代)"}[metric])
  139. if all(len(usable.get(metric, [])) == 1 for metric in ("flow", "static_pressure", "shaft_power")):
  140. chosen = [usable[metric][0] for metric in ("flow", "static_pressure", "shaft_power")]
  141. times = [local_time(p.observed_at) for p in chosen]
  142. if (max(times) - min(times)).total_seconds() > sync_seconds:
  143. note("unsynchronized", "blocking", "风量、静压和轴功率不同步,未计算当前效率。", *(p.evidence for p in chosen))
  144. else:
  145. q, pressure, power = [p.value * UNITS[p.metric][p.unit] for p in chosen]
  146. if power <= 0:
  147. note("zero_shaft_power", "review", "轴功率为零,未计算效率。", chosen[2].evidence)
  148. else:
  149. eta = q * pressure / (1000 * power) * 100
  150. calculations.append(Calculation(scope="current_confirmed", label=target_name,
  151. values={"flow_m3_s": q, "static_pressure_pa": pressure, "shaft_power_kw": power},
  152. efficiency_pct=round(eta, 4), evidence=[p.evidence for p in chosen]))
  153. if eta > 100:
  154. note("impossible_efficiency", "review", "计算效率超过 100%,应核查关联、单位、压力和功率口径。", *(p.evidence for p in chosen))
  155. for report in data.reports:
  156. if not same_mine(report, data):
  157. note("report_mine_mismatch", "blocking", f"报告 {report.report_id} 属于 {report.mine_name},不能用于 {data.mine_name} 的风机研判。", report.evidence)
  158. continue
  159. if len(target_numbers) != 1 or fan_numbers(report.fan_name) != target_numbers:
  160. note("report_fan_mismatch", "review", f"报告 {report.report_id} 对应 {report.fan_name},未唯一匹配目标风机。", report.evidence)
  161. continue
  162. in_scope = True
  163. if report.recommended_next_on and now.date() > report.recommended_next_on:
  164. note("report_recommended_date_passed", "review",
  165. f"报告 {report.report_id} 所载建议下次检验日期 {report.recommended_next_on} 已过;需核查更新报告,不据此直接认定违法或当前不合格。", report.evidence)
  166. if report.inspected_on and report.inspected_on > now.date():
  167. note("report_future_date", "blocking", f"报告 {report.report_id} 检验日期晚于研判日期,未用于数值复核。", report.evidence)
  168. continue
  169. note("historical_report", "info", f"报告 {report.report_id} 的原结论“{report.original_conclusion or '未提取'}”仅为历史检测结论,不能证明当前状态。", report.evidence)
  170. for row in report.points:
  171. eta = row.flow_m3_s * row.static_pressure_pa / (1000 * row.shaft_power_kw) * 100
  172. difference = None if row.reported_efficiency_pct is None else abs(eta - row.reported_efficiency_pct)
  173. calculations.append(Calculation(scope="historical_report", label=f"{report.report_id}/{row.label}/{row.condition}",
  174. values={"flow_m3_s": row.flow_m3_s, "static_pressure_pa": row.static_pressure_pa,
  175. "shaft_power_kw": row.shaft_power_kw}, efficiency_pct=round(eta, 4),
  176. reported_efficiency_pct=row.reported_efficiency_pct,
  177. difference_percentage_points=None if difference is None else round(difference, 4), evidence=[row.evidence]))
  178. if eta > 100 or difference is not None and difference > efficiency_tolerance:
  179. note("report_formula_inconsistent", "review", f"报告 {report.report_id} 的 {row.label} 效率复核存在差异,应核验原表、识别结果及单位口径。", row.evidence)
  180. if not report.points:
  181. missing.append(f"报告 {report.report_id} 可定位到原页的性能测定数据行")
  182. for caption in report.curves:
  183. caption_eta = caption.flow_m3_s * caption.static_pressure_pa / (10 * caption.shaft_power_kw)
  184. if caption_eta > 100 or (caption.reported_efficiency_pct is not None and
  185. abs(caption_eta-caption.reported_efficiency_pct) > efficiency_tolerance):
  186. note("curve_formula_inconsistent", "review", f"报告 {report.report_id} 的 {caption.frequency_hz or 0:g}Hz 图注数字与其效率不一致,需核验图注原件及识别结果。", caption.evidence)
  187. comparable = [r for r in report.points if r.frequency_hz == caption.frequency_hz and
  188. r.condition == caption.condition and "运行工况" in r.label and
  189. abs(r.flow_m3_s-caption.flow_m3_s)<=max(.001,caption.flow_m3_s*.005) and
  190. abs(r.shaft_power_kw-caption.shaft_power_kw)<=max(.001,caption.shaft_power_kw*.005)]
  191. if len(comparable) == 1:
  192. row = comparable[0]
  193. if abs(caption.static_pressure_pa-row.static_pressure_pa) > max(1, row.static_pressure_pa*.005) or (
  194. caption.reported_efficiency_pct is not None and row.reported_efficiency_pct is not None and
  195. abs(caption.reported_efficiency_pct-row.reported_efficiency_pct) > efficiency_tolerance):
  196. condition_label = "现场" if caption.condition == "measured" else "折算"
  197. note("curve_table_disagreement", "review", f"报告 {report.report_id} 的 {caption.frequency_hz:g}Hz 图注与同频率{condition_label}运行工况表不一致,保留各自原页,需复核。",
  198. caption.evidence, row.evidence)
  199. if not any(r for r in data.reports if same_mine(r, data) and fan_numbers(r.fan_name) == target_numbers):
  200. missing.append("本矿井目标风机对应的性能测定报告")
  201. missing.append("主备风机配置、切换记录与停风处置资料;现有开停快照不足以核验连续通风能力")
  202. return Screening(engineering_status="review_required" if in_scope else "insufficient_data", has_in_scope_data=in_scope,
  203. findings=findings, associations=associations, calculations=calculations,
  204. missing_materials=list(dict.fromkeys(missing)))