test_main_fan_preparation.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. """固定取数与预处理的少量回归:类型、分页、窗口和首次Agent调用前的数据。"""
  2. from datetime import datetime, timedelta
  3. import json
  4. from types import SimpleNamespace
  5. import pytest
  6. from db import main_fan_store
  7. from extensions.main_fan import discovery, gateway_queries, history, history_processing
  8. from extensions.main_fan.batch import BatchRunner
  9. from extensions.main_fan.batch_context import current_scope
  10. from runtime.python_sandbox import workspace_file
  11. from test_main_fan_batch import batch_workspace, bound
  12. def realtime_rows(mine):
  13. identity = {'deptId': mine['dept_id'], 'mineId': mine['mine_id']}
  14. safety = [{**identity, 'nodeId': f'state{fan}{index}', 'nodePlace': f'主通{fan}{stage}级',
  15. 'sensorTypeId': '1010', 'sensorName': '主通风机'} for fan in (1, 2) for index, stage in enumerate(('一', '二'), 1)]
  16. equipment = [{**identity, 'nodeId': f'flow{fan}', 'nodeTypeNo': '30101',
  17. 'nodeTypeName': name + '号风量', 'sensorUnit': 'm3/min'} for fan, name in ((1, '一'), (2, '二'))]
  18. equipment += [{**identity, 'nodeId': 'speed2', 'nodeTypeNo': '30102', 'nodeTypeName': '二号风速'},
  19. {**identity, 'nodeId': 'equipment-state2', 'nodeTypeNo': '30007', 'nodeTypeName': '二号风机运行状态'}]
  20. return safety, equipment
  21. def test_fixed_selection_keeps_stages_and_excludes_equipment_state_and_speed():
  22. mine = {'dept_id': '1237', 'mine_id': '610831018590'}
  23. safety, equipment = realtime_rows(mine)
  24. safety.append({'nodeId': 'vibration', 'sensorTypeId': '1022', 'nodePlace': '主通1振动'})
  25. plan = discovery.plan_from_rows(mine, safety, equipment)
  26. assert len(plan.shafts) == 1 and plan.shafts[0].shaft_id == 'unresolved-main'
  27. points = plan.shafts[0].points
  28. assert len(points) == 6
  29. assert {p.fan_id for p in points} == {'1号风机', '2号风机'}
  30. assert {p.node_id for p in points} == {'state11', 'state12', 'state21', 'state22', 'flow1', 'flow2'}
  31. assert all(not p.mapping_confirmed for p in points)
  32. @pytest.mark.parametrize('label,fan', [
  33. ('风机房1号主扇一级电机&其它&其它', '1号风机'),
  34. ('风机房1号主扇二级电机&其它&其它', '1号风机'),
  35. ('风机房2号主扇一级电机&其它&其它', '2号风机'),
  36. ('风机房2号主扇二级电机&其它&其它', '2号风机'),
  37. ('主通1一级', '1号风机'), ('主通1二级', '1号风机'),
  38. ('主通2一级', '2号风机'), ('主通12二级', '12号风机'),
  39. ('主扇一级电机', ''), ('主扇二级电机', ''),
  40. ])
  41. def test_fan_number_is_not_motor_stage(label, fan):
  42. assert discovery.fan_name({'nodePlace': label}) == fan
  43. def test_fixed_selection_rejects_other_mine_and_conflicting_duplicate():
  44. mine = {'dept_id': '1237', 'mine_id': '610831018590'}
  45. safety, equipment = realtime_rows(mine)
  46. safety[0]['deptId'] = '1045'
  47. with pytest.raises(ValueError, match='deptId'):
  48. discovery.plan_from_rows(mine, safety, equipment)
  49. safety[0]['deptId'] = '1237'
  50. safety.append({**safety[0], 'nodePlace': '主通2一级'})
  51. with pytest.raises(ValueError, match='冲突'):
  52. discovery.plan_from_rows(mine, safety, equipment)
  53. class FixedGateway:
  54. def __init__(self, mine):
  55. self.safety, self.equipment = realtime_rows(mine)
  56. # 测点ID本身不含中文,名称保留一级/二级。
  57. for index, row in enumerate(self.safety):
  58. row['nodeId'] = 'state' + str(index + 1)
  59. self.calls = []
  60. async def proxy(self, agent, path, *, params):
  61. params = dict(params)
  62. self.calls.append((path, params))
  63. if path.endswith('getNodeRealtimeDetails') or path.endswith('selectSbNodeList'):
  64. rows = self.safety if path.endswith('getNodeRealtimeDetails') else self.equipment
  65. page, size = int(params['pageNum']), int(params['pageSize'])
  66. return {'code': 200, 'total': len(rows), 'rows': rows[(page - 1) * size:page * size]}
  67. if 'nodeIds' in params:
  68. return {'code': 200, 'data': [[{'NodeID': params['nodeIds'], 'DataTime': params['startTime'],
  69. 'RealData': 1, 'KgDesc': '开', 'StateID': 0, 'stateName': '正常'}], []]}
  70. return {'code': 200, 'data': [{'nodeId': params['nodeId'], 'sbNodeCurve': [
  71. {'dataTime': params['startTime'], 'realData': '0.00'}]}]}
  72. @pytest.mark.asyncio
  73. async def test_fixed_discovery_gets_all_pages_without_model(batch_workspace, monkeypatch):
  74. gateway = FixedGateway(batch_workspace.mine)
  75. monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '1')
  76. monkeypatch.setenv('MAIN_FAN_REALTIME_MAX_PAGE_SIZE', '2')
  77. monkeypatch.setattr(gateway_queries, 'get_service', lambda _: gateway)
  78. with bound(batch_workspace, days=1) as (scope, identifier):
  79. plan = await discovery.discover(identifier, scope.user, scope.session)
  80. assert len(plan.shafts[0].points) == 6 and len(gateway.calls) == 4
  81. assert [params['sensorTypeList[0]'] for path, params in gateway.calls if '/aqjk/' in path] == ['1010', '1010']
  82. assert [params['devOfSystem'] for path, params in gateway.calls if '/zdsb/' in path] == ['30', '30']
  83. def test_explicit_two_shaft_names_are_grouped_without_splitting_by_fan_number():
  84. mine = {'dept_id': '1237', 'mine_id': '610831018590'}
  85. safety, equipment = realtime_rows(mine)
  86. for row in safety[:2] + equipment[:1]:
  87. row['shaftName'] = '北风井'
  88. for row in safety[2:] + equipment[1:2]:
  89. row['shaftName'] = '南风井'
  90. plan = discovery.plan_from_rows(mine, safety, equipment)
  91. assert {shaft.shaft_name for shaft in plan.shafts} == {'北风井', '南风井'}
  92. def test_preprocessing_keeps_zero_and_sampling_bounds_for_single_point_comparison():
  93. def state(fan, old, new, by):
  94. return {'node_id': 'state' + fan, 'system': 'safety', 'role': 'fan_state', 'fan_id': fan + '号风机',
  95. 'state_intervals': [], 'state_value_semantics': '接口标签', 'state_changes': [
  96. {'from_state': old, 'to_state': new, 'after': '2026-10-03T15:00:00+08:00',
  97. 'by': by, 'intervening_unknown_or_gap': False, 'evidence': []}]}
  98. points = [state('2', '开', '停', '2026-10-03T15:00:10+08:00'),
  99. state('1', '停', '开', '2026-10-03T15:01:00+08:00')]
  100. points += [{'node_id': 'flow' + fan, 'system': 'equipment', 'role': 'flow',
  101. 'fan_id': fan + '号风机', 'unit': 'm3/min', 'values': {}} for fan in ('1', '2')]
  102. details = {'points': points}
  103. segments = [{'key': point['node_id'], 'system': 'equipment', 'node_id': point['node_id'], 'status': 'completed',
  104. 'start_time': '2026-10-03T00:00:00+08:00', 'end_time': '2026-10-04T00:00:00+08:00'} for point in points[2:]]
  105. class Reader:
  106. def data(self, record):
  107. if record['node_id'] == 'flow1':
  108. samples = [{'time': '2026-10-03T15:10:00+08:00', 'value': 0}]
  109. else:
  110. start = datetime.fromisoformat('2026-10-03T14:40:00+08:00')
  111. samples = [{'time': (start + timedelta(seconds=i)).isoformat(), 'value': 5000} for i in range(30)]
  112. return {'series': [{'samples': samples}]}
  113. rules = {'switch': {'maximum_switch_interval_minutes': 10, 'transition_minutes': 5, 'window_minutes': 30,
  114. 'sample_interval_seconds': 60, 'minimum_completeness': .9, 'maximum_cv': .05}}
  115. compact = history_processing.prepare(details, {'segments': segments}, Reader(), rules, '/workspace/details.json')
  116. event = compact['preprocessing']['switch_candidates'][0]
  117. assert event['from_fan'] == '2号风机' and event['to_fan'] == '1号风机'
  118. assert event['sampling_localization_seconds'] == 60 and event['interval_seconds'] == 50
  119. assert event['interval_minutes'] == pytest.approx(50 / 60)
  120. assert event['interval_status'] == 'estimated'
  121. assert event['interval_bounds_minutes'] == {'lower': 0, 'upper': 1}
  122. old = next(row for row in event['flow_context'] if row['fan_id'] == '2号风机')
  123. assert old['before_reference']['value'] == 5000 and old['before_reference']['valid']
  124. new = next(row for row in event['flow_context'] if row['fan_id'] == '1号风机')
  125. assert new['after_reference']['value'] == 0 and new['after_reference']['valid']
  126. assert event['flow_comparison']['relative_change_percent'] == 100
  127. # 默认输入省略缺口字段,原观测信息保留;零值、查询失败和比较口径不变。
  128. compact.update(cutoff='2026-10-21T09:42:02+08:00', lookback_days=40,
  129. transport={'coverage_complete': False})
  130. compact['points'][0]['state_periods'] = [{'state': '开', 'first_observed_at': '2026-10-03T14:00:00+08:00',
  131. 'last_observed_at': '2026-10-03T15:00:00+08:00', 'observed_parts': 2}]
  132. slim = history_processing.agent_input(compact)
  133. assert 'gaps' not in slim['states'][0]['periods'][0]
  134. assert compact['points'][0]['state_periods'][0]['observed_parts'] == 2
  135. slim_event = slim['switch_candidates'][0]
  136. assert slim_event['comparison']['change_percent'] == 100
  137. assert slim_event['duration_minutes'] == pytest.approx(50 / 60)
  138. assert slim_event['duration_status'] == 'estimated'
  139. assert slim_event['from_stopped_at'] == '2026-10-03 15:00:10'
  140. assert slim_event['to_started_at'] == '2026-10-03 15:01:00'
  141. assert slim_event['comparison']['before_value'] == 5000 and slim_event['comparison']['after_value'] == 0
  142. assert not {'before_mean', 'after_mean', 'before_samples', 'after_samples'} & slim_event['comparison'].keys()
  143. assert next(row for row in slim_event['flow'] if row['fan'] == '1号风机')['after'][1] == 0
  144. assert any('日段取数失败' in note for note in slim['notes'])
  145. def timing_point(node, fan, changes):
  146. return {'node_id': node, 'label': node, 'system': 'safety', 'role': 'fan_state',
  147. 'fan_id': fan, 'state_changes': [{'from_state': old, 'to_state': new,
  148. 'after': '2026-10-03T' + after + '+08:00', 'by': '2026-10-03T' + by + '+08:00',
  149. 'intervening_unknown_or_gap': False} for old, new, after, by in changes]}
  150. def test_switch_interval_longer_than_normal_limit_is_not_lost_by_grouping():
  151. points = [timing_point('old', '2号风机', [('开', '停', '14:59:55', '15:00:00')]),
  152. timing_point('new', '1号风机', [('停', '开', '15:14:55', '15:15:00')])]
  153. events = history_processing.switch_candidates(points, 10)
  154. assert len(events) == 1
  155. event = events[0]
  156. assert event['from_fan'] == '2号风机' and event['to_fan'] == '1号风机'
  157. assert event['interval_minutes'] == 15 and event['interval_status'] == 'estimated'
  158. assert event['interval_bounds_minutes']['lower'] == pytest.approx(895 / 60)
  159. assert event['interval_bounds_minutes']['upper'] == pytest.approx(905 / 60)
  160. assert not event['interval_has_gaps']
  161. def test_switch_interval_uses_final_start_after_jitter_and_last_participating_stage():
  162. points = [timing_point('old1', '2号风机', [('开', '停', '15:00:10', '15:00:15')]),
  163. timing_point('old2', '2号风机', [('开', '停', '15:00:15', '15:00:20')]),
  164. timing_point('new1', '1号风机', [('停', '开', '15:00:00', '15:00:05')]),
  165. timing_point('new2', '1号风机', [('停', '开', '15:00:05', '15:00:10'),
  166. ('开', '停', '15:00:10', '15:00:12'), ('停', '开', '15:00:45', '15:00:50')])]
  167. event = history_processing.switch_candidates(points, 10)[0]
  168. assert event['from_stopped_at'] == '2026-10-03T15:00:20+08:00'
  169. assert event['to_started_at'] == '2026-10-03T15:00:50+08:00'
  170. assert event['interval_seconds'] == 30 and event['overlap_seconds'] == 0
  171. assert event['interval_bounds_minutes'] == {'lower': 25 / 60, 'upper': 35 / 60}
  172. assert len(event['interval_sources']) == 4
  173. def test_start_before_stop_retains_overlap_instead_of_negative_gap():
  174. points = [timing_point('old', '2号风机', [('开', '停', '15:00:25', '15:00:30')]),
  175. timing_point('new', '1号风机', [('停', '开', '15:00:05', '15:00:10')])]
  176. event = history_processing.switch_candidates(points, 10)[0]
  177. assert event['interval_seconds'] == 0 and event['interval_minutes'] == 0
  178. assert event['overlap_seconds'] == 20
  179. assert event['interval_bounds_minutes'] == {'lower': 0, 'upper': 0}
  180. def test_original_fan_restart_cancels_pending_handover():
  181. points = [timing_point('old', '2号风机', [('开', '停', '14:59:55', '15:00:00'),
  182. ('停', '开', '15:14:55', '15:15:00')]),
  183. timing_point('new', '1号风机', [('停', '开', '15:29:55', '15:30:00')])]
  184. assert not history_processing.switch_candidates(points, 10)
  185. def test_timing_across_unknown_samples_is_computed_but_marked_as_limited():
  186. points = [timing_point('old', '2号风机', [('开', '停', '14:59:55', '15:00:00')]),
  187. timing_point('new', '1号风机', [('停', '开', '15:14:55', '15:15:00')])]
  188. points[1]['state_changes'][0]['intervening_unknown_or_gap'] = True
  189. event = history_processing.switch_candidates(points, 10)[0]
  190. assert event['interval_minutes'] == 15 and event['interval_has_gaps']
  191. def test_nearest_point_uses_one_hour_target_and_never_crosses_switch():
  192. boundary = datetime.fromisoformat('2026-10-03T15:00:00+08:00')
  193. samples = [(boundary + timedelta(minutes=offset), value) for offset, value in
  194. [(-120, 100), (-70, 200), (-61, 300), (-45, 400), (-1, 500), (1, 9999)]]
  195. result = history_processing.reference_sample(samples, boundary, 'before')
  196. assert result['value'] == 300
  197. assert result['time'] == '2026-10-03T13:59:00+08:00'
  198. assert result['target_time'] == '2026-10-03T14:00:00+08:00'
  199. after = history_processing.reference_sample(
  200. [(boundary + timedelta(minutes=offset), 0) for offset in (-1, 2, 28, 59, 80)], boundary, 'after')
  201. assert after['valid'] and after['value'] == 0
  202. assert after['time'] == (boundary + timedelta(minutes=59)).isoformat()
  203. missing = history_processing.reference_sample([(boundary + timedelta(minutes=1), 9000)], boundary, 'before')
  204. assert not missing['valid'] and missing['time'] is None and missing['value'] is None
  205. @pytest.mark.asyncio
  206. async def test_runner_supplies_processed_data_before_workflow_call(batch_workspace, monkeypatch):
  207. gateway = FixedGateway(batch_workspace.mine)
  208. monkeypatch.setenv('SHANXI_GATEWAY_ENABLED', '1')
  209. monkeypatch.setattr(gateway_queries, 'get_service', lambda _: gateway)
  210. monkeypatch.setattr(history, 'get_service', lambda _: gateway)
  211. calls = []
  212. async def executor(**kwargs):
  213. scope = current_scope.get()
  214. workflow = main_fan_store.workflow(scope.active_workflow_id, scope.user, scope.session)
  215. prepared = workflow['context']['prepared_data']
  216. data = json.loads(workspace_file(scope.user, scope.session, prepared['agent_data_path']).read_text())
  217. assert prepared['mode'] == 'fixed_code'
  218. assert len(data['states']) == 4 and len(data['flows']) == 2
  219. assert not {'points', 'preprocessing', 'transport', 'source_policy', 'data_preparation'} & data.keys()
  220. assert data['reports'][0]['mode'] == 'analyze'
  221. assert kwargs['workflow_id'] == scope.active_workflow_id
  222. assert 'message' not in kwargs and 'graph_config' not in kwargs
  223. calls.append(workflow['context']['task_input']['measurements']['batch']['phase'])
  224. return SimpleNamespace(status='success', run_id='test', error='', output='', usage={}, structured_output={
  225. 'result': '# 测试报告', 'analysis_id': 'test', 'workflow_id': scope.active_workflow_id,
  226. 'platform_payload': {'taskId': 'test-task', 'judgment': {'resultCode': 'UNDETERMINED'}}})
  227. output = batch_workspace.root / 'prepared-output'
  228. result = await BatchRunner(output, batch_workspace.pdf, executor=executor).run(
  229. {'dept_ids': ['1219'], 'cutoff': '2026-10-20 15:00:00', 'lookback_days': 1},
  230. snapshot={'snapshot_id': 'c' * 32, 'mines': [batch_workspace.mine]})
  231. assert result['status'] == 'completed', result
  232. assert calls == ['assessment']
  233. assert len(gateway.calls) == 8, '两个实时列表和六个日段全部先由固定代码获取'
  234. assert (output / '1219/unresolved-main/给Agent的数据.json').is_file()