test_main_fan_operating_mock.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. """用户指定的10分钟/10%规则:边界、独立证据、网关取数和整体报告。"""
  2. from datetime import datetime, timedelta
  3. from pathlib import Path
  4. import base64
  5. import json
  6. import re
  7. import httpx
  8. import pytest
  9. from algorithms.main_fan.equivalence import analyze_fan_switch_event, normalize_and_validate_fan_data
  10. from algorithms.main_fan.models import ScreeningInput
  11. from extensions.main_fan import data_source, service
  12. from extensions.main_fan.pipeline import load_rules
  13. from extensions.shaanxi.client import GatewayClient
  14. from extensions.shaanxi.settings import GatewaySettings
  15. from mock_gateway.main_fan import INTERNAL_TOKEN, MockState, create_app
  16. from mock_gateway.operating_data import load_operating_data
  17. EXAMPLES = Path(__file__).resolve().parents[1]/'examples/main_fan'
  18. def sample(*, gap=6, after_flow=3610):
  19. source=load_operating_data(EXAMPLES)
  20. stop=datetime.fromisoformat('2026-10-14T10:00:00+08:00')
  21. start=stop+timedelta(minutes=gap)
  22. for record in source['fan_state_records']:
  23. if record['fan_id']=='mock-report-fan-2':
  24. record['state']='running' if datetime.fromisoformat(record['observed_at'])>=start else 'stopped'
  25. for series in source['operating_series']:
  26. if series['fan_id']=='mock-report-fan-2':
  27. for point in series['samples']:
  28. at=datetime.fromisoformat(point['observed_at'])
  29. point['value']=0 if at<start else after_flow if at>=start+timedelta(minutes=2) else after_flow/2
  30. return ScreeningInput(mine_code='mock-zy',mine_name='陕西众源煤业集团有限公司',
  31. fan_pair=['mock-report-fan-1','mock-report-fan-2'],evaluation_time='2026-10-14T11:00:00+08:00',
  32. **{k:source[k] for k in ('devices','fan_state_records','operating_series')})
  33. def analyze(data):
  34. return analyze_fan_switch_event(normalize_and_validate_fan_data(data,load_rules()))
  35. @pytest.mark.parametrize('gap,flow,checks,status',[
  36. (6,3610,('pass','pass'),'pass'),
  37. (10,3420,('pass','pass'),'pass'),
  38. (10,4180,('pass','pass'),'pass'),
  39. (11,3610,('fail','pass'),'fail'),
  40. (6,3040,('pass','fail'),'fail'),
  41. (6,4560,('pass','fail'),'fail'),
  42. (11,3040,('fail','fail'),'fail'),
  43. (0,3800,('pass','pass'),'pass'),
  44. (-2,3800,('pass','pass'),'pass'),
  45. (6,0,('pass','fail'),'fail'),
  46. ])
  47. def test_switch_interval_and_flow_change_are_independent_and_inclusive(gap,flow,checks,status):
  48. result=analyze(sample(gap=gap,after_flow=flow))
  49. assert result['status']==status and len(result['events'])==1
  50. event=result['events'][0]
  51. assert event['checks']==dict(zip(('switch_interval','flow_change'),checks))
  52. assert event['timing']['interval_minutes']==max(gap,0)
  53. assert event['timing']['overlap_minutes']==max(-gap,0)
  54. assert event['metrics']['flow_deviation_percent']==pytest.approx(abs(flow-3800)/3800*100)
  55. assert event['changes']['flow']['before']*60==pytest.approx(3800)
  56. assert event['changes']['flow']['after']*60==pytest.approx(flow)
  57. assert event['before_window']['flow']['sample_count']==30
  58. assert event['after_window']['flow']['sample_count']==30
  59. assert 'pressure' not in event['assessed_metrics']
  60. @pytest.mark.parametrize('kind',['unconfirmed','duplicate_sensor','missing_samples','conflicting_samples','zero_baseline'])
  61. def test_timing_is_still_reported_when_flow_cannot_be_compared(kind):
  62. data=sample()
  63. if kind=='unconfirmed':data.operating_series[0].mapping_confirmed=False
  64. elif kind=='duplicate_sensor':data.operating_series.append(data.operating_series[0].model_copy(deep=True))
  65. elif kind=='missing_samples':data.operating_series[0].samples=[]
  66. elif kind=='conflicting_samples':
  67. series=data.operating_series[0]
  68. series.samples.extend(p.model_copy(update={'value':p.value+30}) for p in list(series.samples))
  69. elif kind=='zero_baseline':
  70. for p in data.operating_series[0].samples:p.value=0
  71. result=analyze(data)
  72. assert result['status']=='insufficient'
  73. event=result['events'][0]
  74. assert event['checks']=={'switch_interval':'pass','flow_change':'insufficient'}
  75. assert event['timing']['interval_minutes']==6
  76. assert 'flow' not in event['changes']
  77. @pytest.mark.parametrize('kind',['stage_only','unknown_state','conflict','missing_transition','foreign','future'])
  78. def test_state_chain_must_be_whole_fan_continuous_and_in_scope(kind):
  79. data=sample()
  80. if kind=='stage_only':
  81. for r in data.fan_state_records:r.complete_fan_state=False
  82. elif kind=='unknown_state':
  83. for r in data.fan_state_records:
  84. if r.observed_at.minute==3 and r.observed_at.hour==10:r.state='unknown'
  85. elif kind=='conflict':
  86. r=next(r for r in data.fan_state_records if r.fan_id.endswith('-1') and r.observed_at.hour==10 and r.observed_at.minute==0)
  87. data.fan_state_records.append(r.model_copy(update={'state':'running'}))
  88. elif kind=='missing_transition':
  89. data.fan_state_records=[r for r in data.fan_state_records if not (r.observed_at.hour==10 and r.observed_at.minute<=7)]
  90. elif kind=='foreign':
  91. for r in data.fan_state_records:r.mine_code='another-mine'
  92. else:data.evaluation_time=datetime.fromisoformat('2026-10-14T09:30:00+08:00')
  93. result=analyze(data)
  94. assert result['events']==[] and result['status']=='insufficient'
  95. def test_future_samples_cannot_fill_after_window():
  96. data=sample()
  97. data.evaluation_time=datetime.fromisoformat('2026-10-14T10:10:00+08:00')
  98. event=analyze(data)['events'][0]
  99. assert event['timing']['interval_minutes']==6
  100. assert not event['changes']
  101. @pytest.mark.asyncio
  102. async def test_mock_gateway_forwards_both_curves_and_filters_mine(tmp_path,monkeypatch):
  103. (tmp_path/'original.pdf').write_bytes(b'original mock bytes')
  104. state=MockState.from_raw_files(tmp_path,operating_data=load_operating_data(EXAMPLES))
  105. transport=httpx.ASGITransport(app=create_app(state))
  106. client=GatewayClient(GatewaySettings('http://mock',INTERNAL_TOKEN,'http://mock'),transport=transport)
  107. for key,path in {'MAIN_FAN_DEVICES_PATH':'/mock-platform/main-fan/devices','MAIN_FAN_SERIES_PATH':'/mock-platform/main-fan/series',
  108. 'MAIN_FAN_OPERATION_LOG_PATH':'/mock-platform/main-fan/states','MAIN_FAN_POINTS_PATH':'',
  109. 'MAIN_FAN_REPORTS_PATH':'','MAIN_FAN_SWITCHES_PATH':''}.items():monkeypatch.setenv(key,path)
  110. try:
  111. await client.call('POST','/internal/agents/register','mock-main-fan',json={'agentCode':'mock-main-fan','checkTypeCode':'mock-fan-screening'})
  112. incoming=await data_source.fetch(client,'mock-main-fan','mock-zy')
  113. assert len(incoming['devices'])==2 and len(incoming['operating_series'])==2 and len(incoming['fan_state_records'])==202
  114. data=ScreeningInput.model_validate({**sample().model_dump(mode='json'),
  115. **{k:incoming[k] for k in ('devices','operating_series','fan_state_records')}})
  116. assert analyze(data)['status']=='pass'
  117. unrelated=await data_source.fetch(client,'mock-main-fan','mock-ci')
  118. assert all(not rows for rows in unrelated.values())
  119. async with httpx.AsyncClient(transport=transport,base_url='http://mock') as http:
  120. raw=(await http.get('/mock/main-fan/curves/switch-states')).json()
  121. assert raw['data_kind']=='synthetic' and len(raw['series'])==2
  122. assert all(s['evidence']['method']=='synthetic' for s in incoming['operating_series'])
  123. assert 'document_facts' not in state.sample
  124. finally:await client.close()
  125. @pytest.mark.asyncio
  126. async def test_report_explains_both_rules_and_labels_mock_curves():
  127. result=await service.analyze(sample().model_dump(mode='json'),'curve-u','curve-s')
  128. text=result['result']
  129. assert result['status']=='completed'
  130. assert '6.00分钟' in text and '5.00%' in text
  131. assert '不超过10分钟' in text and '不超过10%' in text
  132. assert '3800.00' in text and '3610.00' in text
  133. assert '模拟' in text and '需复核' not in text
  134. assert '风压' not in text
  135. assert len(result['sources'])==2
  136. encoded=re.search(r'!\[主备风机开停与风量时序曲线\]\(data:image/svg\+xml;base64,([^)]*)\)',text).group(1)
  137. svg=base64.b64decode(encoded).decode()
  138. assert '主备风机开停曲线' in svg and '主备风机风量曲线' in svg
  139. assert all(t in svg for t in ('10:00:00','10:06:00','模拟运行时序'))
  140. assert result['assessment']['switch_comparison']['status']=='pass'
  141. def test_operating_fixture_identity_mismatch_is_rejected(tmp_path):
  142. for name in ('mock_switch_states.json','mock_airflow_series.json'):
  143. raw=json.loads((EXAMPLES/name).read_text())
  144. if name=='mock_airflow_series.json':raw['mine_code']='another'
  145. (tmp_path/name).write_text(json.dumps(raw))
  146. with pytest.raises(ValueError,match='矿井身份不一致'):load_operating_data(tmp_path)