discovery.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. """宿主固定发现测点:取齐分页、校验矿井、按真实类型选开停和风量,不调用模型。"""
  2. from collections import defaultdict
  3. import hashlib
  4. import json
  5. import re
  6. import unicodedata
  7. import config
  8. from extensions.main_fan import exploration, realtime
  9. from extensions.main_fan.batch_models import HistoryPoint, MinePlan, ShaftPlan
  10. from extensions.main_fan.batch_tools import checked_plan
  11. from extensions.main_fan.batch_context import scope_for
  12. from runtime.python_sandbox import workspace_file
  13. def fan_name(row):
  14. """只识别名称明确写出的风机号;不从nodeId或一级/二级推断编号。"""
  15. numbers = '一二三四五六七八九十'
  16. number = rf'([0-9]+(?![0-9])|[{numbers}]+(?![{numbers}]))'
  17. found = set()
  18. for key in ('devName', 'nodePlace', 'nodeTypeName'):
  19. text = unicodedata.normalize('NFKC', str(row.get(key) or ''))
  20. # 前置“1号主扇”和后置“主通1”都可识别;编号后紧跟“级”时是电机级数。
  21. patterns = (rf'主(?:通风机|通|扇)(?:风机)?\s*{number}(?!\s*级)',
  22. rf'{number}\s*(?:号|#)\s*(?:主扇|主要通风机|主通风机|通风机|主风机|风机|风量)')
  23. for pattern in patterns:
  24. for value in re.findall(pattern, text):
  25. if len(value) == 1 and value in numbers:
  26. value = str(numbers.index(value) + 1)
  27. if value.isdigit():
  28. found.add(str(int(value)) + '号风机')
  29. return next(iter(found)) if len(found) == 1 else ''
  30. def shaft_name(row):
  31. for key in ('shaftName', 'wellName'):
  32. if row.get(key):
  33. return str(row[key]).strip()
  34. # 只匹配明确的井名片段;单独“通风机”或风机号均不构成通风井身份。
  35. names = set()
  36. for key in ('devFixPos', 'nodePlace', 'devName'):
  37. text = str(row.get(key) or '')
  38. names.update(re.findall(r'(?:[东西南北中](?:翼)?[一二三四五0-9]*|[一二三四五0-9]+号)(?:回风立井|回风斜井|回风井|通风井|风井)', text))
  39. return next(iter(names)) if len(names) == 1 else ''
  40. def plan_from_rows(mine, safety_rows, equipment_rows):
  41. groups, seen = defaultdict(list), {}
  42. for system, rows in (('safety', safety_rows), ('equipment', equipment_rows)):
  43. for row in rows:
  44. if not isinstance(row, dict):
  45. raise ValueError('实时列表包含非对象记录')
  46. selected = (str(row.get('sensorTypeId', '')) == '1010' if system == 'safety'
  47. else str(row.get('nodeTypeNo', '')) == '30101')
  48. if not selected:
  49. continue
  50. for field, expected in (('deptId', mine['dept_id']), ('mineId', mine['mine_id'])):
  51. if row.get(field) is not None and str(row[field]) != str(expected):
  52. raise ValueError('实时测点的' + field + '与本矿不一致')
  53. if not row.get('nodeId'):
  54. raise ValueError('目标类型测点缺少nodeId')
  55. point = HistoryPoint(system=system, node_id=str(row['nodeId']),
  56. role='fan_state' if system == 'safety' else 'flow', fan_id=fan_name(row),
  57. label=str((row.get('nodePlace') if system == 'safety' else row.get('nodeTypeName')) or ''),
  58. unit=str(row.get('sensorUnit') or ''), mapping_confirmed=False,
  59. mapping_reason='按接口原名称保留编号;一级/二级分别保留,跨系统物理绑定未确认')
  60. name = shaft_name(row)
  61. key = (system, point.node_id)
  62. signature = (name, point.model_dump())
  63. if key in seen:
  64. if seen[key] != signature:
  65. raise ValueError('分页中同一测点的类型、名称或归属冲突:' + point.node_id)
  66. continue
  67. seen[key] = signature
  68. groups[name].append(point)
  69. if not groups:
  70. raise ValueError('本矿未取得安全监控1010开停点或重大设备30101风量点')
  71. shafts, limitations = [], []
  72. for name, points in sorted(groups.items()):
  73. fans = sorted({point.fan_id for point in points if point.fan_id})
  74. notes = ['风机号来自测点名称,跨系统仅按原编号作时间对照,未证明物理绑定。']
  75. if not name:
  76. notes.append('接口缺少明确井名,保留矿级待确认测点集合;不将1号、2号当作两个通风井。')
  77. if len(fans) != 2 or any(not point.fan_id for point in points):
  78. notes.append('部分风机编号或主备配对不足,不能据此宣称已覆盖全部主备系统。')
  79. shafts.append(ShaftPlan(shaft_id='shaft-' + hashlib.sha256(name.encode()).hexdigest()[:12] if name else 'unresolved-main',
  80. shaft_name=name or '主通风机测点集合(通风井归属待确认)',
  81. identity_status='confirmed' if name else 'candidate',
  82. identity_reason='实时列表明确井名原文:' + name if name else '两个实时列表未提供明确通风井归属',
  83. fan_pair=fans if len(fans) == 2 else [], points=points, limitations=notes))
  84. limitations.extend(notes)
  85. return MinePlan(coverage='partial', shafts=shafts, limitations=list(dict.fromkeys(limitations)))
  86. async def discover(identifier, user, session):
  87. scope = scope_for(user, session)
  88. pages = {}
  89. size = min(100, config.get_int('MAIN_FAN_REALTIME_MAX_PAGE_SIZE', 1000))
  90. for system in ('safety', 'equipment'):
  91. rows, page, visited = [], 1, set()
  92. while page:
  93. if page in visited:
  94. raise ValueError('实时列表分页循环,不能声称已取齐')
  95. visited.add(page)
  96. if system == 'safety':
  97. receipt = await realtime.query_sensors(identifier, scope.mine['dept_id'], page, size, ['1010'], user, session)
  98. else:
  99. receipt = await realtime.query_equipment(identifier, scope.mine['dept_id'], page, size, user, session)
  100. body = json.loads(workspace_file(user, session, receipt['data_path']).read_text())['payload']
  101. if not isinstance(body, dict) or str(body.get('code')) not in {'0', '200'} or body.get('success') is False:
  102. raise ValueError('实时列表未返回成功业务响应:' + system)
  103. if not receipt['list_recognized'] or (receipt['has_more'] and not receipt['next_page']):
  104. raise ValueError('实时列表结构或分页不完整:' + system)
  105. container = body['rows'] if 'rows' in body else body.get('data')
  106. rows.extend(container['rows'] if isinstance(container, dict) else container)
  107. page = receipt['next_page']
  108. pages[system] = rows
  109. workflow = exploration._load(identifier, user, session)
  110. plan = checked_plan(workflow, user, session, plan_from_rows(scope.mine, pages['safety'], pages['equipment']))
  111. exploration._persist(workflow, user, session, '固定代码发现本矿测点', {'mode': 'fixed_code', 'plan': plan.model_dump(mode='json')})
  112. return plan