history_compaction.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. """历史数据的无损证据压缩与观测状态区间;不判定整机切换或合规性。"""
  2. from collections import Counter, defaultdict
  3. from contextlib import ExitStack
  4. from copy import deepcopy
  5. import hashlib
  6. import json
  7. import math
  8. from statistics import median
  9. from zipfile import ZIP_DEFLATED, ZipFile
  10. from extensions.main_fan import equipment, gateway_queries
  11. VERSION = 'main-fan-observed-intervals-v2-open-close-labels'
  12. class RecordReader:
  13. """兼容原逐段JSON和单个ZIP中的原件,不解压出数百个文件。"""
  14. def __init__(self, resolve):
  15. self.resolve = resolve
  16. self.stack = ExitStack()
  17. self.archives = {}
  18. def __enter__(self):
  19. return self
  20. def __exit__(self, *args):
  21. return self.stack.__exit__(*args)
  22. def bytes(self, record, kind='data'):
  23. if record.get('archive'):
  24. archive = record['archive']
  25. if archive['path'] not in self.archives:
  26. self.archives[archive['path']] = self.stack.enter_context(ZipFile(self.resolve(archive['path'])))
  27. item = archive['members'][kind]
  28. content = self.archives[archive['path']].read(item['name'])
  29. expected = item['sha256']
  30. else:
  31. virtual = next(path for path in record['files'] if path.endswith('/' + kind + '.json'))
  32. content = self.resolve(virtual).read_bytes()
  33. expected = record['files'][virtual]
  34. if hashlib.sha256(content).hexdigest() != expected:
  35. raise ValueError('历史证据指纹不一致:' + record['key'] + '/' + kind)
  36. return content
  37. def data(self, record):
  38. return json.loads(self.bytes(record))
  39. def compact_evidence(records, archive_path, resolve):
  40. """原件逐项校验后归入一个ZIP;调用者先持久化新索引,再清理本轮暂存文件。"""
  41. completed = [row for row in records if row['status'] == 'completed']
  42. if not completed:
  43. return records, {}, [], {}
  44. if all(row.get('archive') for row in completed) and len({row['archive']['path'] for row in completed}) == 1:
  45. files = {path: sha for row in completed for path, sha in row['files'].items()}
  46. with ZipFile(resolve(completed[0]['archive']['path'])) as archive:
  47. original_bytes = sum(item.file_size for item in archive.infolist())
  48. # 同一批次重试不改写已经完成的压缩证据。
  49. return records, files, [], {'archive_path': completed[0]['archive']['path'],
  50. 'compressed_bytes': resolve(completed[0]['archive']['path']).stat().st_size,
  51. 'original_file_count': len(completed) * 3, 'original_bytes': original_bytes}
  52. destination = resolve(archive_path)
  53. destination.parent.mkdir(parents=True, exist_ok=True)
  54. temporary = destination.with_name(destination.name + '.writing')
  55. result, cleanup, size = deepcopy(records), [], 0
  56. try:
  57. with RecordReader(resolve) as reader, ZipFile(temporary, 'w', ZIP_DEFLATED, compresslevel=6) as archive:
  58. for record in result:
  59. if record['status'] != 'completed':
  60. continue
  61. members = {}
  62. for kind in ('request', 'response', 'data'):
  63. content = reader.bytes(record, kind)
  64. name = record['key'] + '/' + kind + '.json'
  65. archive.writestr(name, content)
  66. size += len(content)
  67. members[kind] = {'name': name, 'sha256': hashlib.sha256(content).hexdigest()}
  68. cleanup.extend(record['files'] if not record.get('archive') else [])
  69. record['archive'] = {'path': archive_path, 'members': members}
  70. record.pop('data_path', None)
  71. record.pop('python_path', None)
  72. with ZipFile(temporary) as archive:
  73. if archive.testzip() is not None:
  74. raise ValueError('历史压缩证据校验失败')
  75. temporary.replace(destination)
  76. finally:
  77. temporary.unlink(missing_ok=True)
  78. sha = hashlib.sha256(destination.read_bytes()).hexdigest()
  79. for record in result:
  80. if record['status'] == 'completed':
  81. record['files'] = {archive_path: sha}
  82. return result, {archive_path: sha}, cleanup, {'archive_path': archive_path,
  83. 'original_file_count': len(completed) * 3, 'original_bytes': size,
  84. 'compressed_bytes': destination.stat().st_size}
  85. def _state(sample, system):
  86. if system == 'safety':
  87. if sample.get('status_label') not in (None, '正常') or str(sample.get('status_code')) not in ('None', '', '0'):
  88. return None, 'abnormal_status'
  89. label = sample.get('value_label')
  90. if label in ('停', '关'):
  91. return '停', ''
  92. return ('开', '') if label == '开' else (None, 'unrecognized_state')
  93. try:
  94. value = float(sample['value'])
  95. if value in (0, 1):
  96. return '原始值' + str(int(value)), ''
  97. except (KeyError, TypeError, ValueError):
  98. pass
  99. return None, 'unrecognized_state'
  100. def summarize_point(point, records, reader):
  101. counts, timestamps = Counter(), defaultdict(list)
  102. for record in records:
  103. if record['status'] != 'completed':
  104. counts['failed_windows'] += 1
  105. continue
  106. document = reader.data(record)
  107. if point['system'] == 'safety':
  108. samples = document['samples']
  109. else:
  110. document, shape = equipment._curve_data(document['payload'], point['node_id'])
  111. if shape['sample_count'] is None:
  112. counts['unrecognized_windows'] += 1
  113. continue
  114. samples = [sample for series in document['series'] for sample in series['samples']]
  115. counts['nonempty_windows' if samples else 'empty_windows'] += 1
  116. for index, sample in enumerate(samples):
  117. counts['raw_sample_count'] += 1
  118. try:
  119. at = gateway_queries.parse_time(sample['time'])
  120. except (KeyError, TypeError, ValueError):
  121. counts['invalid_time_count'] += 1
  122. continue
  123. if not gateway_queries.parse_time(record['start_time']) <= at <= gateway_queries.parse_time(record['end_time']):
  124. counts['out_of_window_count'] += 1
  125. continue
  126. if point['role'] == 'fan_state':
  127. value, reason = _state(sample, point['system'])
  128. else:
  129. try:
  130. value = float(sample['value'])
  131. if not math.isfinite(value):
  132. raise ValueError('非有限数值')
  133. reason = ''
  134. except (KeyError, ValueError, TypeError, OverflowError):
  135. value, reason = None, 'invalid_numeric_value'
  136. if reason:
  137. counts['invalid_value_count'] += 1
  138. timestamps[at].append((value, reason, {'segment': record['key'], 'sample_index': index}))
  139. timeline = []
  140. for at, items in sorted(timestamps.items()):
  141. counts['duplicate_time_count'] += len(items) - 1
  142. variants = {(value, reason) for value, reason, _ in items}
  143. if len(variants) > 1:
  144. counts['conflicting_timestamp_count'] += 1
  145. value, reason = None, 'conflicting_samples'
  146. else:
  147. value, reason = next(iter(variants))
  148. timeline.append((at, value, reason, items[0][2]))
  149. deltas = [(after[0] - before[0]).total_seconds() for before, after in zip(timeline, timeline[1:])]
  150. cadence = median(deltas) if deltas else None
  151. gap_limit = 5 * (cadence if len(deltas) >= 3 else (60 if point['system'] == 'safety' else 3000))
  152. gaps = [{'after': before[0].isoformat(), 'before': after[0].isoformat(), 'seconds': delta}
  153. for before, after, delta in zip(timeline, timeline[1:], deltas) if gap_limit and delta > gap_limit]
  154. quality = {key: counts[key] for key in ('raw_sample_count', 'nonempty_windows', 'empty_windows',
  155. 'failed_windows', 'unrecognized_windows', 'invalid_time_count', 'out_of_window_count',
  156. 'invalid_value_count', 'duplicate_time_count', 'conflicting_timestamp_count')}
  157. quality.update(unique_sample_count=len(timeline), median_sample_interval_seconds=cadence,
  158. gap_threshold_seconds=gap_limit, largest_sample_interval_seconds=max(deltas) if deltas else None,
  159. gap_count=len(gaps))
  160. result = {**point, 'quality': quality, 'first_observed_at': timeline[0][0].isoformat() if timeline else None,
  161. 'last_observed_at': timeline[-1][0].isoformat() if timeline else None, 'sampling_gaps': gaps}
  162. if point['role'] == 'flow':
  163. numeric = [value for _, value, reason, _ in timeline if not reason]
  164. result['values'] = {'numeric_sample_count': len(numeric), 'zero_count': numeric.count(0),
  165. 'positive_count': sum(value > 0 for value in numeric), 'negative_count': sum(value < 0 for value in numeric),
  166. 'minimum': min(numeric) if numeric else None, 'maximum': max(numeric) if numeric else None,
  167. 'stable_window_comparison': 'not_computed'}
  168. return result
  169. intervals, changes = [], []
  170. previous, last_valid, interrupted = None, None, False
  171. for at, value, reason, source in timeline:
  172. gap = bool(previous and gap_limit and (at - previous[0]).total_seconds() > gap_limit)
  173. if (intervals and previous and (value, reason) == (previous[1], previous[2]) and not gap):
  174. intervals[-1].update(observed_until=at.isoformat(), last_source=source)
  175. intervals[-1]['sample_count'] += 1
  176. else:
  177. intervals.append({'state': value or '未知', 'observed_from': at.isoformat(), 'observed_until': at.isoformat(),
  178. 'sample_count': 1, 'reason': reason, 'first_source': source, 'last_source': source})
  179. if not reason:
  180. if last_valid and value != last_valid[1]:
  181. changes.append({'from_state': last_valid[1], 'to_state': value,
  182. 'after': last_valid[0].isoformat(), 'by': at.isoformat(),
  183. 'uncertainty_seconds': (at - last_valid[0]).total_seconds(),
  184. 'intervening_unknown_or_gap': interrupted or gap, 'machine_switch_confirmed': False,
  185. 'evidence': [last_valid[3], source]})
  186. last_valid, interrupted = (at, value, reason, source), False
  187. else:
  188. interrupted = True
  189. previous = (at, value, reason, source)
  190. result.update(state_intervals=intervals, state_changes=changes,
  191. state_value_semantics='开停标签来自接口;“关”归一为“停”,原标签保留在采样证据中'
  192. if point['system'] == 'safety' else '0/1保留原值,开停含义待核对')
  193. return result
  194. def summarize(manifest, reader):
  195. points = [summarize_point(point, [row for row in manifest['segments']
  196. if (row['system'], row['node_id']) == (point['system'], point['node_id'])], reader)
  197. for point in manifest['points']]
  198. return {'schema_version': VERSION, 'cutoff': manifest['cutoff'], 'lookback_days': manifest['lookback_days'],
  199. 'transport': {key: manifest[key] for key in ('expected_segments', 'completed_segments', 'failed_segments', 'coverage_complete')},
  200. 'interval_semantics': 'observed_from/until是首末采样时刻;不外推到查询边界或长缺测段。after/by表示变化发生在(after,by]内,不是精确切换时刻。',
  201. 'gap_policy': '大于各测点中位采样间隔5倍时分段;此阈值仅用于缺测提示,不是切换合规标准。',
  202. 'machine_mapping': '按测点和系统保留,不把一级/二级自动合成为整机,不合并跨系统状态。',
  203. 'points': points}
  204. def agent_view(details, details_path, *, full=False):
  205. """整理状态观测区间;full=True 为单次工作流展开全部已有区间和变化。"""
  206. result = {key: value for key, value in details.items() if key != 'points'}
  207. result['details_path'] = details_path
  208. result['state_period_semantics'] = ('state_periods仅归并同状态的有效观测。observed_parts>1表示中间含未知状态或长缺测,'
  209. '不是整段持续开/停;具体分段和未知区间见details_path。state_changes只给变化的采样边界。')
  210. result['points'] = []
  211. for index, point in enumerate(details['points']):
  212. view = {key: point[key] for key in ('system', 'node_id', 'label', 'fan_id', 'role', 'unit', 'mapping_confirmed',
  213. 'quality', 'first_observed_at', 'last_observed_at') if key in point}
  214. view['details_pointer'] = '/points/' + str(index)
  215. if point['role'] == 'flow':
  216. view['values'] = point['values']
  217. else:
  218. periods = []
  219. for interval in point['state_intervals']:
  220. if interval['state'] == '未知':
  221. continue
  222. if periods and periods[-1]['state'] == interval['state']:
  223. periods[-1]['last_observed_at'] = interval['observed_until']
  224. periods[-1]['sample_count'] += interval['sample_count']
  225. periods[-1]['observed_parts'] += 1
  226. else:
  227. periods.append({'state': interval['state'], 'first_observed_at': interval['observed_from'],
  228. 'last_observed_at': interval['observed_until'], 'sample_count': interval['sample_count'], 'observed_parts': 1})
  229. view['state_period_count'] = len(periods)
  230. view['state_periods'] = periods if full or len(periods) <= 20 else periods[:10] + periods[-10:]
  231. view['state_periods_truncated'] = not full and len(periods) > 20
  232. view['state_interval_count'] = len(point['state_intervals'])
  233. view['unknown_interval_count'] = sum(row['state'] == '未知' for row in point['state_intervals'])
  234. view['state_change_count'] = len(point['state_changes'])
  235. changes = point['state_changes']
  236. view['state_changes'] = [{key: value for key, value in change.items() if key != 'evidence'}
  237. for change in (changes if full or len(changes) <= 20 else changes[:10] + changes[-10:])]
  238. view['state_changes_truncated'] = not full and len(changes) > 20
  239. view['state_value_semantics'] = point['state_value_semantics']
  240. result['points'].append(view)
  241. return result