test_main_fan_history_compaction.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. from copy import deepcopy
  2. import hashlib
  3. import json
  4. import pytest
  5. from extensions.main_fan.history_compaction import RecordReader, agent_view, compact_evidence, summarize_point
  6. def point(system='safety', role='fan_state'):
  7. return {'node_id': 'node1', 'system': system, 'role': role, 'fan_id': '1号', 'label': '主通1一级', 'unit': '', 'mapping_confirmed': False}
  8. class Reader:
  9. def __init__(self, samples):
  10. self.samples = samples
  11. def data(self, record):
  12. return {'samples': self.samples[record['key']]}
  13. def segment(key='a', start='2026-10-01 00:00:00', end='2026-10-03 00:00:00'):
  14. return {'key': key, 'status': 'completed', 'node_id': 'node1', 'start_time': start, 'end_time': end}
  15. def sample(at, value='开', **extras):
  16. return {'time': at, 'value_label': value, 'status_code': '0', 'status_label': '正常', **extras}
  17. @pytest.mark.parametrize('stopped_label', ['停', '关'])
  18. def test_sort_merge_cross_day_and_deduplicate_boundary_without_extending_edges(stopped_label):
  19. reader = Reader({'a': [sample('2026-10-01 23:59:00'), sample('2026-10-02 00:00:00')],
  20. 'b': [sample('2026-10-02 00:02:00', stopped_label), sample('2026-10-02 00:00:00'),
  21. sample('2026-10-02 00:01:00', stopped_label)]})
  22. result = summarize_point(point(), [segment('b'), segment('a')], reader)
  23. intervals = result['state_intervals']
  24. assert len(intervals) == 2 and intervals[0]['state'] == '开' and intervals[1]['state'] == '停'
  25. assert intervals[0]['observed_from'] == '2026-10-01T23:59:00+08:00'
  26. assert intervals[-1]['observed_until'] == '2026-10-02T00:02:00+08:00'
  27. assert result['quality']['duplicate_time_count'] == 1
  28. assert result['state_changes'][0]['uncertainty_seconds'] == 60
  29. assert result['state_changes'][0]['machine_switch_confirmed'] is False
  30. assert result['state_changes'][0]['to_state'] == '停'
  31. assert reader.samples['b'][0]['value_label'] == stopped_label
  32. def test_fault_conflicting_timestamp_and_long_gap_break_observed_intervals():
  33. samples = [sample('2026-10-01 00:00:00'), sample('2026-10-01 00:01:00'),
  34. sample('2026-10-01 00:02:00', status_label='故障', status_code='2'),
  35. sample('2026-10-01 00:03:00'), sample('2026-10-01 00:04:00'),
  36. sample('2026-10-01 00:04:00', '停'), sample('2026-10-01 00:05:00'), sample('2026-10-01 00:30:00')]
  37. result = summarize_point(point(), [segment()], Reader({'a': samples}))
  38. assert result['quality']['conflicting_timestamp_count'] == 1 and result['quality']['invalid_value_count'] == 1
  39. assert result['quality']['gap_count'] == 1 and result['state_changes'] == []
  40. assert sum(row['state'] == '未知' for row in result['state_intervals']) == 2
  41. view = agent_view({'points': [result]}, '/workspace/details.json')['points'][0]
  42. assert len(view['state_periods']) == 1 and view['state_periods'][0]['observed_parts'] == 4
  43. assert view['unknown_interval_count'] == 2
  44. def test_state_change_across_unknown_preserves_sampling_uncertainty():
  45. result = summarize_point(point(), [segment()], Reader({'a': [sample('2026-10-01 01:00:00'),
  46. sample('2026-10-01 01:01:00', status_label='故障'), sample('2026-10-01 01:05:00', '停')]}))
  47. change = result['state_changes'][0]
  48. assert change['after'] == '2026-10-01T01:00:00+08:00' and change['by'] == '2026-10-01T01:05:00+08:00'
  49. assert change['uncertainty_seconds'] == 300 and change['intervening_unknown_or_gap']
  50. @pytest.mark.parametrize('role', ['fan_state', 'flow'])
  51. def test_equipment_zeros_remain_data_and_state_codes_are_not_assumed(role):
  52. class EquipmentReader:
  53. def data(self, record):
  54. return {'payload': {'code': 200, 'data': [{'nodeId': 'node1', 'sbNodeCurve': [
  55. {'dataTime': '2026-10-01 01:00:00', 'realData': '0'},
  56. {'dataTime': '2026-10-01 01:01:00', 'realData': '0'}]}]}}
  57. result = summarize_point(point('equipment', role), [segment()], EquipmentReader())
  58. assert result['quality']['raw_sample_count'] == 2
  59. if role == 'fan_state':
  60. assert len(result['state_intervals']) == 1 and result['state_intervals'][0]['state'] == '原始值0'
  61. else:
  62. assert result['values']['zero_count'] == 2 and result['values']['numeric_sample_count'] == 2
  63. def test_zip_evidence_roundtrip_retry_and_tampered_source(tmp_path):
  64. resolve = lambda virtual: tmp_path / virtual.removeprefix('/workspace/')
  65. record = segment(); record['files'] = {}
  66. for name, value in [('request', {'nodeId': 'node1'}), ('response', {'code': 200, 'data': []}), ('data', {'samples': []})]:
  67. virtual = '/workspace/daily/a/' + name + '.json'
  68. local = resolve(virtual); local.parent.mkdir(parents=True, exist_ok=True)
  69. local.write_text(json.dumps(value)); record['files'][virtual] = hashlib.sha256(local.read_bytes()).hexdigest()
  70. bad = deepcopy(record)
  71. raw = resolve('/workspace/daily/a/data.json'); raw.write_text('tampered')
  72. with pytest.raises(ValueError, match='指纹'):
  73. compact_evidence([bad], '/workspace/failed.zip', resolve)
  74. raw.write_text(json.dumps({'samples': []}))
  75. records, files, cleanup, _ = compact_evidence([record], '/workspace/packed.zip', resolve)
  76. assert len(files) == 1 and len(cleanup) == 3
  77. with RecordReader(resolve) as reader:
  78. assert reader.data(records[0]) == {'samples': []}
  79. stamp = resolve('/workspace/packed.zip').stat().st_mtime_ns
  80. again, _, cleanup, _ = compact_evidence(records, '/workspace/packed.zip', resolve)
  81. assert not cleanup and again == records and resolve('/workspace/packed.zip').stat().st_mtime_ns == stamp
  82. def test_independent_verifier_reads_compacted_evidence_without_daily_files(tmp_path):
  83. from scripts.verify_main_fan_batch import read_segment
  84. resolve = lambda virtual: tmp_path / virtual.lstrip('/')
  85. record = segment(); record['files'] = {}
  86. for kind, value in [('request', {}), ('response', {'code': 200}), ('data', {'samples': []})]:
  87. virtual = '/workspace/staging/' + kind + '.json'
  88. path = resolve(virtual); path.parent.mkdir(parents=True, exist_ok=True)
  89. path.write_text(json.dumps(value)); record['files'][virtual] = hashlib.sha256(path.read_bytes()).hexdigest()
  90. records, _, cleanup, _ = compact_evidence([record], '/workspace/packed.zip', resolve)
  91. for virtual in cleanup:
  92. resolve(virtual).unlink()
  93. assert read_segment(tmp_path, records[0]) == {'samples': []}
  94. def test_noisy_state_overview_is_bounded_and_points_to_complete_details():
  95. samples = [sample(f'2026-10-01 01:{minute:02d}:00', '开' if minute % 2 else '停') for minute in range(60)]
  96. result = summarize_point(point(), [segment()], Reader({'a': samples}))
  97. view = agent_view({'points': [result]}, '/workspace/details.json')['points'][0]
  98. assert len(result['state_intervals']) == 60 and len(view['state_periods']) == 20
  99. assert view['state_periods_truncated'] and view['state_change_count'] == 59