| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 |
- """双机范围、额定功率、受控插值、真实切换窗口及报告项目选择。"""
- from copy import deepcopy
- from datetime import datetime, date, timedelta
- import json
- from types import SimpleNamespace
- import pytest
- from algorithms.main_fan.models import (ScreeningInput, PerformanceReport, PerformancePoint, Evidence,
- OperatingSeries, SeriesSample, SwitchRecord, Device, FanStateRecord)
- from algorithms.main_fan.equivalence import (normalize_and_validate_fan_data, compare_basic_parameters,
- analyze_fan_performance_curve, analyze_fan_switch_event, assess_fan_equivalent_capability,
- RulePolicy, rated_power, _interpolate)
- from extensions.main_fan.pipeline import load_rules
- from extensions.main_fan import service
- from extensions.main_fan.pair_reporting import render_fan_assessment_report
- from db import main_fan_store
- def sample(*, angle=35, pressure_ratio=1.02):
- reports=[]
- for number in (1,2):
- evidence=Evidence(source=f"/workspace/fan{number}.pdf",page=1,excerpt=f"合成{number}号风机测试报告",method='synthetic')
- r=PerformanceReport(report_id=f'SYN-{number}',mine_code='synthetic',mine_name='双机测试矿',fan_name=f'{number}号通风机',
- model='FBCDZ-No28',motor_model='SYN-MOTOR',serial_number=f'SYN-SERIAL-{number}',rated_power_raw='2×185 kW',
- rated_speed_rpm=740,impeller_diameter_m=2.8,inspected_on=date(2026,1,1),blade_angle_deg=angle,evidence=evidence)
- for index,q in enumerate((10,20,30,40,50,60,70)):
- pressure=500*(pressure_ratio if number==2 else 1)
- power=50
- r.points.append(PerformancePoint(label='运行工况' if index==3 else f'测点{index}',frequency_hz=50,
- flow_m3_s=q,static_pressure_pa=pressure,shaft_power_kw=power,
- reported_efficiency_pct=q*pressure/(10*power),evidence=evidence))
- reports.append(r)
- return ScreeningInput(mine_code='synthetic',mine_name='双机测试矿',fan_id='fan-1',fan_name='1号通风机',
- fan_pair=['fan-1','fan-2'],evaluation_time=datetime(2026,10,14,14),reports=reports,
- devices=[Device(device_code=f'fan-{i}',name=f'{i}号通风机',mine_code='synthetic') for i in (1,2)])
- def dataset(data):
- return normalize_and_validate_fan_data(data,load_rules())
- def test_both_fans_are_selected_and_real_nameplate_power_is_not_shaft_power():
- data=sample()
- normalized=dataset(data)
- assert [f['fan_id'] for f in normalized.fans]==['fan-1','fan-2']
- basic=compare_basic_parameters(normalized)
- power=next(r for r in basic['rows'] if r['field']=='rated_power')
- assert power['values'][0]['components_kw']==[185,185] and power['values'][0]['total_kw']==370
- assert power['status']=='match' and basic['status']=='pass'
- assert data.reports[0].points[0].shaft_power_kw==50
- @pytest.mark.parametrize('expression,expected',[('2×185 kW',370),('185+185kW',370),('370千瓦',370),('185',185),('型号185',None),('/',None),('0',None)])
- def test_rated_power_expression_is_parsed_without_guessing(expression,expected):
- report=sample().reports[0].model_copy(update={'rated_power_raw':expression})
- value=rated_power(report)
- assert (value['total_kw'] if value else None)==expected
- def test_model_or_power_mismatch_is_explicit_and_missing_values_do_not_pass():
- data=sample();data.reports[1].rated_power_raw='2×200kW'
- result=compare_basic_parameters(dataset(data))
- assert result['status']=='fail'
- data.reports[1].rated_power_raw=''
- assert compare_basic_parameters(dataset(data))['status']=='insufficient'
- data.reports[1].model='FBCDZ-No32'
- assert compare_basic_parameters(dataset(data))['status']=='fail'
- def test_curve_metrics_have_known_analytic_answers_and_never_extrapolate():
- result=analyze_fan_performance_curve(dataset(sample()))
- assert result['status']=='pass'
- row=result['results'][0]
- assert row['metrics']['mean_pressure_deviation_percent']==pytest.approx(2)
- assert row['metrics']['maximum_pressure_deviation_percent']==pytest.approx(2)
- assert row['metrics']['pressure_rmse_pa']==pytest.approx(10)
- assert row['metrics']['efficiency_difference_points']==pytest.approx(1.4)
- assert row['common_flow_range_m3_min']==[600,4200] and row['extrapolation_used'] is False
- with pytest.raises(ValueError,match='外推'):_interpolate([(10,(500,50,10)),(20,(500,50,20))],9)
- def test_abs_relative_integral_handles_crossing_curves_exactly():
- data=sample(pressure_ratio=1)
- for point in data.reports[1].points:
- point.static_pressure_pa=500+(point.flow_m3_s-40)
- row=analyze_fan_performance_curve(dataset(data))['results'][0]
- assert row['metrics']['mean_pressure_deviation_percent']==pytest.approx(3)
- assert row['metrics']['pressure_rmse_pa']==pytest.approx((300)**.5)
- def test_no_blade_angle_is_frequency_comparison_only_not_equivalence_pass():
- result=analyze_fan_performance_curve(dataset(sample(angle=None)))
- assert result['status']=='insufficient'
- assert result['results'][0]['comparability']=='frequency_only'
- assert result['results'][0]['status']=='informational'
- def test_different_angles_or_frequency_are_not_combined():
- data=sample();data.reports[1].blade_angle_deg=40
- assert analyze_fan_performance_curve(dataset(data))['results']==[]
- data.reports[1].blade_angle_deg=35
- for point in data.reports[1].points:point.frequency_hz=40
- assert analyze_fan_performance_curve(dataset(data))['results']==[]
- def test_duplicate_flow_conflict_or_insufficient_overlap_cannot_be_silently_fitted():
- data=sample()
- data.reports[0].points.append(data.reports[0].points[0].model_copy(update={'static_pressure_pa':700}))
- result=analyze_fan_performance_curve(dataset(data))
- assert result['status']=='insufficient' and not result['results'] and result['warnings']
- data=sample()
- for point in data.reports[1].points:point.flow_m3_s+=100
- assert not analyze_fan_performance_curve(dataset(data))['results']
- def with_switch(data, *, after_flow=98, pressure=True):
- at=datetime(2026,10,14,12)
- data.switch_records=[SwitchRecord(mine_code=data.mine_code,event_id='synthetic-switch',switched_at=at,
- from_fan_id='fan-1',to_fan_id='fan-2',evidence=Evidence(source='synthetic-switch-log',method='synthetic'))]
- for fan_id,begin,value in [('fan-1',-35,100),('fan-2',5,after_flow)]:
- for metric,unit,level in [('flow','m3/s',value), *([('static_pressure','Pa',500)] if pressure else [])]:
- data.operating_series.append(OperatingSeries(mine_code=data.mine_code,fan_id=fan_id,metric=metric,unit=unit,
- sensor_id=fan_id+'-'+metric,mapping_confirmed=True,evidence=Evidence(source='synthetic-series',method='synthetic'),
- samples=[SeriesSample(observed_at=at+timedelta(minutes=begin+i),value=level) for i in range(70)]))
- return data
- def test_real_switch_windows_compute_mean_changes_and_recovery():
- data=with_switch(sample())
- data.switch_records[0].from_stopped_at=data.switch_records[0].switched_at
- data.switch_records[0].to_started_at=data.switch_records[0].switched_at
- result=analyze_fan_switch_event(dataset(data))
- assert result['status']=='pass' and len(result['events'])==1
- event=result['events'][0]
- assert event['changes']['flow']['relative_change_percent']==pytest.approx(-2)
- assert event['changes']['flow']['absolute_change']==pytest.approx(-2)
- assert event['before_window']['flow']['sample_count']==30
- assert event['metrics']['recovery_time_minutes']==5
- def test_persistent_switch_change_is_reported_without_claiming_recovery():
- result=analyze_fan_switch_event(dataset(with_switch(sample(),after_flow=80)))
- assert result['status']=='fail'
- assert result['events'][0]['changes']['flow']['relative_change_percent']==pytest.approx(-20)
- assert result['events'][0]['metrics']['recovery_time_minutes'] is None
- @pytest.mark.parametrize('kind',['unconfirmed','missing','conflict','transition','foreign','future'])
- def test_bad_or_unrelated_series_cannot_make_valid_switch_assessment(kind):
- data=with_switch(sample())
- if kind=='unconfirmed':
- for series in data.operating_series:series.mapping_confirmed=False
- elif kind=='missing':
- for series in data.operating_series:series.samples=series.samples[:10]
- elif kind=='conflict':
- for series in data.operating_series:
- series.samples.extend(s.model_copy(update={'value':s.value+20}) for s in list(series.samples))
- elif kind=='transition':
- data.fan_state_records=[FanStateRecord(mine_code=data.mine_code,fan_id='fan-2',observed_at=data.switch_records[0].switched_at,
- state='running',event_type='frequency_change')]
- elif kind=='foreign':
- for series in data.operating_series:series.mine_code='another'
- elif kind=='future':data.evaluation_time=datetime(2026,10,14,11)
- assert analyze_fan_switch_event(dataset(data))['events']==[]
- def test_whole_fan_state_sequence_discovers_switch_but_stage_snapshots_do_not():
- data=with_switch(sample());data.switch_records=[]
- at=datetime(2026,10,14,12)
- for delta,states in [(-1,('running','stopped')),(0,('stopped','running'))]:
- data.fan_state_records.extend(FanStateRecord(mine_code=data.mine_code,fan_id=f'fan-{i+1}',state=state,
- observed_at=at+timedelta(minutes=delta),complete_fan_state=True,evidence=Evidence(source='synthetic-state',method='synthetic')) for i,state in enumerate(states))
- assert len(analyze_fan_switch_event(dataset(data))['events'])==1
- for record in data.fan_state_records:record.complete_fan_state=False
- assert analyze_fan_switch_event(dataset(data))['events']==[]
- def three_check_case(*, after_flow=98, gap=0, policy=None):
- data = with_switch(sample(), after_flow=after_flow)
- event = data.switch_records[0]
- event.from_stopped_at = event.switched_at
- event.to_started_at = event.switched_at + timedelta(minutes=gap)
- normalized = normalize_and_validate_fan_data(data, policy or load_rules())
- basic = compare_basic_parameters(normalized)
- curves = analyze_fan_performance_curve(normalized)
- switches = analyze_fan_switch_event(normalized)
- return normalized, basic, curves, switches
- @pytest.mark.parametrize('flow,code', [(90,'NORMAL'), (89.9,'WARNING'), (85.001,'WARNING'), (85,'ABNORMAL'), (80,'ABNORMAL')])
- def test_flow_severity_uses_agreed_fifteen_percent_inclusive_boundary(flow, code):
- normalized, basic, curves, switches = three_check_case(after_flow=flow)
- decision = assess_fan_equivalent_capability(normalized, basic, curves, switches, {})
- assert decision['result_code'] == code
- assert bool(decision['severe_findings']) == (code == 'ABNORMAL')
- def test_no_implicit_severe_timing_limit_and_explicit_configuration_is_effective():
- normalized, basic, curves, switches = three_check_case(gap=20)
- assert assess_fan_equivalent_capability(normalized, basic, curves, switches, {})['result_code'] == 'WARNING'
- normalized.policy.severity.abnormal_switch_interval_minutes = 20
- decision = assess_fan_equivalent_capability(normalized, basic, curves, switches, {})
- assert decision['result_code'] == 'ABNORMAL'
- assert decision['severe_findings'][0]['evidence_ids']
- def test_unassessable_candidate_cannot_be_hidden_by_another_passing_switch():
- normalized, basic, curves, switches = three_check_case()
- switches['candidate_event_count'] = 2
- decision = assess_fan_equivalent_capability(normalized, basic, curves, switches, {})
- assert decision['result_code'] == 'UNDETERMINED'
- assert decision['items'] == {'parameters':'pass', 'switch_interval':'insufficient', 'flow_change':'insufficient'}
- @pytest.mark.parametrize('field', ['abnormal_switch_interval_minutes', 'abnormal_flow_deviation_percent'])
- def test_serious_limits_cannot_replace_the_normal_limit(field):
- policy = load_rules().model_dump()
- policy['severity'][field] = 10
- with pytest.raises(ValueError, match='严重'):
- RulePolicy.model_validate(policy)
- @pytest.mark.asyncio
- async def test_report_only_contains_supported_projects_and_backend_preserves_missing_states():
- result=await service.analyze(sample(angle=None).model_dump(mode='json'),'pair-u','pair-s')
- assert result['judgment_scope']=='main_fan_consistency'
- assert len(result['assessment']['fan_pair'])==2 and len(result['analysis']['calculations'])==14
- assert '1号通风机' in result['result'] and '2号通风机' in result['result']
- assert '基础参数一致性' in result['result'] and '性能测定数据比较' in result['result']
- for term in ('切换','备用风机状态','技术措施','需复核','缺少','请补充','实时监测','检验日期已过'):
- assert term not in result['result']
- assert result['assessment']['decision']['overall_status']=='insufficient_evidence'
- assert result['assessment']['stages'][3]['status']=='insufficient'
- assert result['missing_materials']==[]
- assert 'data:image/svg+xml;base64,' in result['result']
- @pytest.mark.asyncio
- async def test_switch_section_only_appears_when_data_is_present_and_metric_scope_is_preserved():
- result=await service.analyze(with_switch(sample(),pressure=False).model_dump(mode='json'),'flow-u','flow-s')
- assert '切换前后稳定工况比较' in result['result']
- event=result['assessment']['switch_comparison']['events'][0]
- assert event['assessed_metrics']==['flow']
- assert result['assessment']['switch_comparison']['status']=='insufficient'
- assert '风量(m³/min)' in result['result']
- @pytest.mark.asyncio
- async def test_phase_retry_reuses_actual_snapshot_and_enforces_ownership(monkeypatch):
- result=await service.analyze(sample().model_dump(mode='json'),'phase-u','phase-s')
- async def forbidden(*args,**kwargs):raise AssertionError('计算阶段重试不应重复识别')
- monkeypatch.setattr(service,'read_document',forbidden)
- original=main_fan_store.workflow(result['workflow_id'],'phase-u','phase-s')
- retried=await service.resume_assessment(result['workflow_id'],3,'phase-u','phase-s')
- after=main_fan_store.workflow(result['workflow_id'],'phase-u','phase-s')
- assert original['stages'][:2]==after['stages'][:2]
- assert result['result']==retried['result'] and result['analysis_id']!=retried['analysis_id']
- with pytest.raises(ValueError,match='当前会话'):
- await service.resume_assessment(result['workflow_id'],3,'another-u','phase-s')
- @pytest.mark.asyncio
- async def test_nine_stage_tools_only_return_saved_results_of_owned_workflow():
- from extensions.main_fan import tools
- result=await service.analyze(sample().model_dump(mode='json'),'stage-u','stage-s')
- runtime=SimpleNamespace(config={'configurable':{'workspace_user':'stage-u','workspace_session':'stage-s'}})
- for name in ('parse_fan_evidence_documents','query_fan_operating_data','normalize_and_validate_fan_data',
- 'analyze_fan_performance_curve','analyze_fan_switch_event','match_fan_rules_and_knowledge',
- 'assess_fan_equivalent_capability','trace_fan_evidence_and_review','render_fan_assessment_report'):
- output=getattr(tools,name)(result['workflow_id'],runtime)
- assert output['workflow_id']==result['workflow_id']
- wrong=SimpleNamespace(config={'configurable':{'workspace_user':'another','workspace_session':'stage-s'}})
- with pytest.raises(ValueError):tools.analyze_fan_performance_curve(result['workflow_id'],wrong)
|