test_main_fan_equivalence.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. """双机范围、额定功率、受控插值、真实切换窗口及报告项目选择。"""
  2. from copy import deepcopy
  3. from datetime import datetime, date, timedelta
  4. import json
  5. from types import SimpleNamespace
  6. import pytest
  7. from algorithms.main_fan.models import (ScreeningInput, PerformanceReport, PerformancePoint, Evidence,
  8. OperatingSeries, SeriesSample, SwitchRecord, Device, FanStateRecord)
  9. from algorithms.main_fan.equivalence import (normalize_and_validate_fan_data, compare_basic_parameters,
  10. analyze_fan_performance_curve, analyze_fan_switch_event, assess_fan_equivalent_capability,
  11. RulePolicy, rated_power, _interpolate)
  12. from extensions.main_fan.pipeline import load_rules
  13. from extensions.main_fan import service
  14. from extensions.main_fan.pair_reporting import render_fan_assessment_report
  15. from db import main_fan_store
  16. def sample(*, angle=35, pressure_ratio=1.02):
  17. reports=[]
  18. for number in (1,2):
  19. evidence=Evidence(source=f"/workspace/fan{number}.pdf",page=1,excerpt=f"合成{number}号风机测试报告",method='synthetic')
  20. r=PerformanceReport(report_id=f'SYN-{number}',mine_code='synthetic',mine_name='双机测试矿',fan_name=f'{number}号通风机',
  21. model='FBCDZ-No28',motor_model='SYN-MOTOR',serial_number=f'SYN-SERIAL-{number}',rated_power_raw='2×185 kW',
  22. rated_speed_rpm=740,impeller_diameter_m=2.8,inspected_on=date(2026,1,1),blade_angle_deg=angle,evidence=evidence)
  23. for index,q in enumerate((10,20,30,40,50,60,70)):
  24. pressure=500*(pressure_ratio if number==2 else 1)
  25. power=50
  26. r.points.append(PerformancePoint(label='运行工况' if index==3 else f'测点{index}',frequency_hz=50,
  27. flow_m3_s=q,static_pressure_pa=pressure,shaft_power_kw=power,
  28. reported_efficiency_pct=q*pressure/(10*power),evidence=evidence))
  29. reports.append(r)
  30. return ScreeningInput(mine_code='synthetic',mine_name='双机测试矿',fan_id='fan-1',fan_name='1号通风机',
  31. fan_pair=['fan-1','fan-2'],evaluation_time=datetime(2026,10,14,14),reports=reports,
  32. devices=[Device(device_code=f'fan-{i}',name=f'{i}号通风机',mine_code='synthetic') for i in (1,2)])
  33. def dataset(data):
  34. return normalize_and_validate_fan_data(data,load_rules())
  35. def test_both_fans_are_selected_and_real_nameplate_power_is_not_shaft_power():
  36. data=sample()
  37. normalized=dataset(data)
  38. assert [f['fan_id'] for f in normalized.fans]==['fan-1','fan-2']
  39. basic=compare_basic_parameters(normalized)
  40. power=next(r for r in basic['rows'] if r['field']=='rated_power')
  41. assert power['values'][0]['components_kw']==[185,185] and power['values'][0]['total_kw']==370
  42. assert power['status']=='match' and basic['status']=='pass'
  43. assert data.reports[0].points[0].shaft_power_kw==50
  44. @pytest.mark.parametrize('expression,expected',[('2×185 kW',370),('185+185kW',370),('370千瓦',370),('185',185),('型号185',None),('/',None),('0',None)])
  45. def test_rated_power_expression_is_parsed_without_guessing(expression,expected):
  46. report=sample().reports[0].model_copy(update={'rated_power_raw':expression})
  47. value=rated_power(report)
  48. assert (value['total_kw'] if value else None)==expected
  49. def test_model_or_power_mismatch_is_explicit_and_missing_values_do_not_pass():
  50. data=sample();data.reports[1].rated_power_raw='2×200kW'
  51. result=compare_basic_parameters(dataset(data))
  52. assert result['status']=='fail'
  53. data.reports[1].rated_power_raw=''
  54. assert compare_basic_parameters(dataset(data))['status']=='insufficient'
  55. data.reports[1].model='FBCDZ-No32'
  56. assert compare_basic_parameters(dataset(data))['status']=='fail'
  57. def test_curve_metrics_have_known_analytic_answers_and_never_extrapolate():
  58. result=analyze_fan_performance_curve(dataset(sample()))
  59. assert result['status']=='pass'
  60. row=result['results'][0]
  61. assert row['metrics']['mean_pressure_deviation_percent']==pytest.approx(2)
  62. assert row['metrics']['maximum_pressure_deviation_percent']==pytest.approx(2)
  63. assert row['metrics']['pressure_rmse_pa']==pytest.approx(10)
  64. assert row['metrics']['efficiency_difference_points']==pytest.approx(1.4)
  65. assert row['common_flow_range_m3_min']==[600,4200] and row['extrapolation_used'] is False
  66. with pytest.raises(ValueError,match='外推'):_interpolate([(10,(500,50,10)),(20,(500,50,20))],9)
  67. def test_abs_relative_integral_handles_crossing_curves_exactly():
  68. data=sample(pressure_ratio=1)
  69. for point in data.reports[1].points:
  70. point.static_pressure_pa=500+(point.flow_m3_s-40)
  71. row=analyze_fan_performance_curve(dataset(data))['results'][0]
  72. assert row['metrics']['mean_pressure_deviation_percent']==pytest.approx(3)
  73. assert row['metrics']['pressure_rmse_pa']==pytest.approx((300)**.5)
  74. def test_no_blade_angle_is_frequency_comparison_only_not_equivalence_pass():
  75. result=analyze_fan_performance_curve(dataset(sample(angle=None)))
  76. assert result['status']=='insufficient'
  77. assert result['results'][0]['comparability']=='frequency_only'
  78. assert result['results'][0]['status']=='informational'
  79. def test_different_angles_or_frequency_are_not_combined():
  80. data=sample();data.reports[1].blade_angle_deg=40
  81. assert analyze_fan_performance_curve(dataset(data))['results']==[]
  82. data.reports[1].blade_angle_deg=35
  83. for point in data.reports[1].points:point.frequency_hz=40
  84. assert analyze_fan_performance_curve(dataset(data))['results']==[]
  85. def test_duplicate_flow_conflict_or_insufficient_overlap_cannot_be_silently_fitted():
  86. data=sample()
  87. data.reports[0].points.append(data.reports[0].points[0].model_copy(update={'static_pressure_pa':700}))
  88. result=analyze_fan_performance_curve(dataset(data))
  89. assert result['status']=='insufficient' and not result['results'] and result['warnings']
  90. data=sample()
  91. for point in data.reports[1].points:point.flow_m3_s+=100
  92. assert not analyze_fan_performance_curve(dataset(data))['results']
  93. def with_switch(data, *, after_flow=98, pressure=True):
  94. at=datetime(2026,10,14,12)
  95. data.switch_records=[SwitchRecord(mine_code=data.mine_code,event_id='synthetic-switch',switched_at=at,
  96. from_fan_id='fan-1',to_fan_id='fan-2',evidence=Evidence(source='synthetic-switch-log',method='synthetic'))]
  97. for fan_id,begin,value in [('fan-1',-35,100),('fan-2',5,after_flow)]:
  98. for metric,unit,level in [('flow','m3/s',value), *([('static_pressure','Pa',500)] if pressure else [])]:
  99. data.operating_series.append(OperatingSeries(mine_code=data.mine_code,fan_id=fan_id,metric=metric,unit=unit,
  100. sensor_id=fan_id+'-'+metric,mapping_confirmed=True,evidence=Evidence(source='synthetic-series',method='synthetic'),
  101. samples=[SeriesSample(observed_at=at+timedelta(minutes=begin+i),value=level) for i in range(70)]))
  102. return data
  103. def test_real_switch_windows_compute_mean_changes_and_recovery():
  104. data=with_switch(sample())
  105. data.switch_records[0].from_stopped_at=data.switch_records[0].switched_at
  106. data.switch_records[0].to_started_at=data.switch_records[0].switched_at
  107. result=analyze_fan_switch_event(dataset(data))
  108. assert result['status']=='pass' and len(result['events'])==1
  109. event=result['events'][0]
  110. assert event['changes']['flow']['relative_change_percent']==pytest.approx(-2)
  111. assert event['changes']['flow']['absolute_change']==pytest.approx(-2)
  112. assert event['before_window']['flow']['sample_count']==30
  113. assert event['metrics']['recovery_time_minutes']==5
  114. def test_persistent_switch_change_is_reported_without_claiming_recovery():
  115. result=analyze_fan_switch_event(dataset(with_switch(sample(),after_flow=80)))
  116. assert result['status']=='fail'
  117. assert result['events'][0]['changes']['flow']['relative_change_percent']==pytest.approx(-20)
  118. assert result['events'][0]['metrics']['recovery_time_minutes'] is None
  119. @pytest.mark.parametrize('kind',['unconfirmed','missing','conflict','transition','foreign','future'])
  120. def test_bad_or_unrelated_series_cannot_make_valid_switch_assessment(kind):
  121. data=with_switch(sample())
  122. if kind=='unconfirmed':
  123. for series in data.operating_series:series.mapping_confirmed=False
  124. elif kind=='missing':
  125. for series in data.operating_series:series.samples=series.samples[:10]
  126. elif kind=='conflict':
  127. for series in data.operating_series:
  128. series.samples.extend(s.model_copy(update={'value':s.value+20}) for s in list(series.samples))
  129. elif kind=='transition':
  130. data.fan_state_records=[FanStateRecord(mine_code=data.mine_code,fan_id='fan-2',observed_at=data.switch_records[0].switched_at,
  131. state='running',event_type='frequency_change')]
  132. elif kind=='foreign':
  133. for series in data.operating_series:series.mine_code='another'
  134. elif kind=='future':data.evaluation_time=datetime(2026,10,14,11)
  135. assert analyze_fan_switch_event(dataset(data))['events']==[]
  136. def test_whole_fan_state_sequence_discovers_switch_but_stage_snapshots_do_not():
  137. data=with_switch(sample());data.switch_records=[]
  138. at=datetime(2026,10,14,12)
  139. for delta,states in [(-1,('running','stopped')),(0,('stopped','running'))]:
  140. data.fan_state_records.extend(FanStateRecord(mine_code=data.mine_code,fan_id=f'fan-{i+1}',state=state,
  141. observed_at=at+timedelta(minutes=delta),complete_fan_state=True,evidence=Evidence(source='synthetic-state',method='synthetic')) for i,state in enumerate(states))
  142. assert len(analyze_fan_switch_event(dataset(data))['events'])==1
  143. for record in data.fan_state_records:record.complete_fan_state=False
  144. assert analyze_fan_switch_event(dataset(data))['events']==[]
  145. def three_check_case(*, after_flow=98, gap=0, policy=None):
  146. data = with_switch(sample(), after_flow=after_flow)
  147. event = data.switch_records[0]
  148. event.from_stopped_at = event.switched_at
  149. event.to_started_at = event.switched_at + timedelta(minutes=gap)
  150. normalized = normalize_and_validate_fan_data(data, policy or load_rules())
  151. basic = compare_basic_parameters(normalized)
  152. curves = analyze_fan_performance_curve(normalized)
  153. switches = analyze_fan_switch_event(normalized)
  154. return normalized, basic, curves, switches
  155. @pytest.mark.parametrize('flow,code', [(90,'NORMAL'), (89.9,'WARNING'), (85.001,'WARNING'), (85,'ABNORMAL'), (80,'ABNORMAL')])
  156. def test_flow_severity_uses_agreed_fifteen_percent_inclusive_boundary(flow, code):
  157. normalized, basic, curves, switches = three_check_case(after_flow=flow)
  158. decision = assess_fan_equivalent_capability(normalized, basic, curves, switches, {})
  159. assert decision['result_code'] == code
  160. assert bool(decision['severe_findings']) == (code == 'ABNORMAL')
  161. def test_no_implicit_severe_timing_limit_and_explicit_configuration_is_effective():
  162. normalized, basic, curves, switches = three_check_case(gap=20)
  163. assert assess_fan_equivalent_capability(normalized, basic, curves, switches, {})['result_code'] == 'WARNING'
  164. normalized.policy.severity.abnormal_switch_interval_minutes = 20
  165. decision = assess_fan_equivalent_capability(normalized, basic, curves, switches, {})
  166. assert decision['result_code'] == 'ABNORMAL'
  167. assert decision['severe_findings'][0]['evidence_ids']
  168. def test_unassessable_candidate_cannot_be_hidden_by_another_passing_switch():
  169. normalized, basic, curves, switches = three_check_case()
  170. switches['candidate_event_count'] = 2
  171. decision = assess_fan_equivalent_capability(normalized, basic, curves, switches, {})
  172. assert decision['result_code'] == 'UNDETERMINED'
  173. assert decision['items'] == {'parameters':'pass', 'switch_interval':'insufficient', 'flow_change':'insufficient'}
  174. @pytest.mark.parametrize('field', ['abnormal_switch_interval_minutes', 'abnormal_flow_deviation_percent'])
  175. def test_serious_limits_cannot_replace_the_normal_limit(field):
  176. policy = load_rules().model_dump()
  177. policy['severity'][field] = 10
  178. with pytest.raises(ValueError, match='严重'):
  179. RulePolicy.model_validate(policy)
  180. @pytest.mark.asyncio
  181. async def test_report_only_contains_supported_projects_and_backend_preserves_missing_states():
  182. result=await service.analyze(sample(angle=None).model_dump(mode='json'),'pair-u','pair-s')
  183. assert result['judgment_scope']=='main_fan_consistency'
  184. assert len(result['assessment']['fan_pair'])==2 and len(result['analysis']['calculations'])==14
  185. assert '1号通风机' in result['result'] and '2号通风机' in result['result']
  186. assert '基础参数一致性' in result['result'] and '性能测定数据比较' in result['result']
  187. for term in ('切换','备用风机状态','技术措施','需复核','缺少','请补充','实时监测','检验日期已过'):
  188. assert term not in result['result']
  189. assert result['assessment']['decision']['overall_status']=='insufficient_evidence'
  190. assert result['assessment']['stages'][3]['status']=='insufficient'
  191. assert result['missing_materials']==[]
  192. assert 'data:image/svg+xml;base64,' in result['result']
  193. @pytest.mark.asyncio
  194. async def test_switch_section_only_appears_when_data_is_present_and_metric_scope_is_preserved():
  195. result=await service.analyze(with_switch(sample(),pressure=False).model_dump(mode='json'),'flow-u','flow-s')
  196. assert '切换前后稳定工况比较' in result['result']
  197. event=result['assessment']['switch_comparison']['events'][0]
  198. assert event['assessed_metrics']==['flow']
  199. assert result['assessment']['switch_comparison']['status']=='insufficient'
  200. assert '风量(m³/min)' in result['result']
  201. @pytest.mark.asyncio
  202. async def test_phase_retry_reuses_actual_snapshot_and_enforces_ownership(monkeypatch):
  203. result=await service.analyze(sample().model_dump(mode='json'),'phase-u','phase-s')
  204. async def forbidden(*args,**kwargs):raise AssertionError('计算阶段重试不应重复识别')
  205. monkeypatch.setattr(service,'read_document',forbidden)
  206. original=main_fan_store.workflow(result['workflow_id'],'phase-u','phase-s')
  207. retried=await service.resume_assessment(result['workflow_id'],3,'phase-u','phase-s')
  208. after=main_fan_store.workflow(result['workflow_id'],'phase-u','phase-s')
  209. assert original['stages'][:2]==after['stages'][:2]
  210. assert result['result']==retried['result'] and result['analysis_id']!=retried['analysis_id']
  211. with pytest.raises(ValueError,match='当前会话'):
  212. await service.resume_assessment(result['workflow_id'],3,'another-u','phase-s')
  213. @pytest.mark.asyncio
  214. async def test_nine_stage_tools_only_return_saved_results_of_owned_workflow():
  215. from extensions.main_fan import tools
  216. result=await service.analyze(sample().model_dump(mode='json'),'stage-u','stage-s')
  217. runtime=SimpleNamespace(config={'configurable':{'workspace_user':'stage-u','workspace_session':'stage-s'}})
  218. for name in ('parse_fan_evidence_documents','query_fan_operating_data','normalize_and_validate_fan_data',
  219. 'analyze_fan_performance_curve','analyze_fan_switch_event','match_fan_rules_and_knowledge',
  220. 'assess_fan_equivalent_capability','trace_fan_evidence_and_review','render_fan_assessment_report'):
  221. output=getattr(tools,name)(result['workflow_id'],runtime)
  222. assert output['workflow_id']==result['workflow_id']
  223. wrong=SimpleNamespace(config={'configurable':{'workspace_user':'another','workspace_session':'stage-s'}})
  224. with pytest.raises(ValueError):tools.analyze_fan_performance_curve(result['workflow_id'],wrong)