verify_main_fan_batch.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. #!/usr/bin/env python3
  2. """只读取已归档数据,输出开发核验;不调用模型、网关或推送,不代替业务判识。"""
  3. import argparse
  4. from collections import Counter, defaultdict
  5. from datetime import datetime, timedelta
  6. import hashlib
  7. from html import escape
  8. import json
  9. import math
  10. from pathlib import Path
  11. import sqlite3
  12. from statistics import median
  13. from zipfile import ZipFile
  14. from zoneinfo import ZoneInfo
  15. ROOT = Path(__file__).resolve().parents[1]
  16. ZONE = ZoneInfo('Asia/Shanghai')
  17. def read(path):
  18. return json.loads(path.read_text(encoding='utf-8'))
  19. def sha(path):
  20. with path.open('rb') as stream:
  21. return hashlib.file_digest(stream, 'sha256').hexdigest()
  22. def time(value):
  23. result = datetime.fromisoformat(str(value))
  24. return result.replace(tzinfo=ZONE) if result.tzinfo is None else result.astimezone(ZONE)
  25. def local_path(base, virtual):
  26. result = (base / virtual.lstrip('/')).resolve()
  27. if not result.is_relative_to(base.resolve()):
  28. raise ValueError('归档路径超出会话目录')
  29. return result
  30. def read_segment(base, segment):
  31. if segment.get('archive'):
  32. archive = segment['archive']
  33. item = archive['members']['data']
  34. with ZipFile(local_path(base, archive['path'])) as source:
  35. content = source.read(item['name'])
  36. if hashlib.sha256(content).hexdigest() != item['sha256']:
  37. raise ValueError('压缩数据成员指纹不一致')
  38. return json.loads(content)
  39. return read(local_path(base, segment['data_path']))
  40. def point_statistics(segments, base, system, *, include_series=False):
  41. """保留原始计数;按正常标签或有限数值、窗口过滤及时间去重后计算变化。"""
  42. counts, values, examples = Counter(), defaultdict(set), []
  43. windows = []
  44. for segment in segments:
  45. if segment['status'] != 'completed':
  46. counts['failed_windows'] += 1
  47. continue
  48. document = read_segment(base, segment)
  49. if system == 'safety':
  50. samples = document['samples']
  51. else:
  52. rows = document.get('payload', {}).get('data')
  53. if not isinstance(rows, list) or any(not isinstance(row, dict)
  54. or str(row.get('nodeId')) != segment['node_id']
  55. or not isinstance(row.get('sbNodeCurve'), list) for row in rows):
  56. counts['unrecognized_windows'] += 1
  57. continue
  58. samples = [item for row in rows for item in row['sbNodeCurve']]
  59. counts['nonempty_windows' if samples else 'empty_windows'] += 1
  60. windows.append({'start_time': segment['start_time'], 'end_time': segment['end_time'],
  61. 'sample_count': len(samples), 'data_path': segment.get('data_path'),
  62. 'archive': segment.get('archive')})
  63. for sample in samples:
  64. counts['raw_sample_count'] += 1
  65. try:
  66. at = time(sample['time' if system == 'safety' else 'dataTime'])
  67. except (KeyError, ValueError, TypeError):
  68. counts['invalid_time_count'] += 1
  69. continue
  70. if not time(segment['start_time']) <= at <= time(segment['end_time']):
  71. counts['out_of_window_count'] += 1
  72. continue
  73. if system == 'safety':
  74. if sample.get('status_label') not in (None, '正常'):
  75. counts['invalid_status_count'] += 1
  76. continue
  77. value = sample.get('value_label')
  78. if value not in ('开', '停'):
  79. counts['unrecognized_value_count'] += 1
  80. continue
  81. else:
  82. try:
  83. value = float(sample['realData'])
  84. if not math.isfinite(value):
  85. raise ValueError('非有限数值')
  86. except (KeyError, ValueError, TypeError, OverflowError):
  87. counts['unrecognized_value_count'] += 1
  88. continue
  89. if values[at]:
  90. counts['repeated_timestamp_count'] += 1
  91. values[at].add(value)
  92. if len(examples) < 3:
  93. examples.append(sample)
  94. conflicts = [at.isoformat() for at, found in values.items() if len(found) > 1]
  95. ordered = sorted((at, next(iter(found))) for at, found in values.items() if len(found) == 1)
  96. gaps = [(after[0] - before[0]).total_seconds() for before, after in zip(ordered, ordered[1:])]
  97. result = {key: counts[key] for key in ('raw_sample_count', 'nonempty_windows', 'empty_windows',
  98. 'unrecognized_windows', 'failed_windows', 'invalid_time_count', 'out_of_window_count',
  99. 'invalid_status_count', 'unrecognized_value_count', 'repeated_timestamp_count')}
  100. result.update(unique_usable_sample_count=len(ordered), conflicting_timestamp_count=len(conflicts),
  101. conflicting_timestamps=conflicts, first_time=ordered[0][0].isoformat() if ordered else None,
  102. last_time=ordered[-1][0].isoformat() if ordered else None,
  103. median_gap_seconds=median(gaps) if gaps else None, largest_gap_seconds=max(gaps) if gaps else None,
  104. examples=examples, windows=windows)
  105. if system == 'safety':
  106. result['state_counts'] = dict(Counter(value for _, value in ordered))
  107. result['transitions'] = [{'previous_sample_at': before[0].isoformat(), 'observed_at': after[0].isoformat(),
  108. 'from_state': before[1], 'to_state': after[1], 'gap_seconds': (after[0] - before[0]).total_seconds()}
  109. for before, after in zip(ordered, ordered[1:]) if before[1] != after[1]]
  110. else:
  111. numeric = [value for _, value in ordered]
  112. result.update(zero_count=sum(value == 0 for value in numeric),
  113. positive_count=sum(value > 0 for value in numeric), negative_count=sum(value < 0 for value in numeric),
  114. minimum=min(numeric) if numeric else None, maximum=max(numeric) if numeric else None)
  115. if include_series:
  116. result['series'] = [{'time': at.isoformat(), 'value': value} for at, value in ordered]
  117. return result
  118. def verify_mine(connection, state, mine):
  119. base = ROOT / 'data/workspaces' / hashlib.sha256(state['user_name'].encode()).hexdigest() / hashlib.sha256(mine['session_id'].encode()).hexdigest()
  120. contexts = connection.execute('SELECT id,context_json FROM main_fan_workflows WHERE user_name=? AND session_id=? ORDER BY updated_at DESC',
  121. (state['user_name'], mine['session_id'])).fetchall()
  122. manifests = [(identifier, json.loads(context)) for identifier, context in contexts
  123. if json.loads(context).get('history_manifest')]
  124. manifests = [(identifier, context) for identifier, context in manifests
  125. if context['history_manifest']['cutoff'] == state['cutoff']
  126. and context['task_input'].get('measurements', {}).get('batch', {}).get('batch_id') == state['batch_id']]
  127. if not manifests:
  128. raise ValueError('该矿尚无归档历史数据:' + mine['dept_id'])
  129. workflow_id, context = manifests[0]
  130. manifest = context['history_manifest']
  131. checked, errors = [], []
  132. fingerprints = {virtual: expected for segment in manifest['segments'] for virtual, expected in segment['files'].items()}
  133. for virtual, expected in fingerprints.items():
  134. path = local_path(base, virtual)
  135. actual = sha(path) if path.is_file() else None
  136. entry = {'path': virtual, 'expected_sha256': expected, 'actual_sha256': actual, 'ok': actual == expected}
  137. checked.append(entry)
  138. if not entry['ok']:
  139. errors.append(entry)
  140. if errors:
  141. raise ValueError('归档原件指纹不一致,停止统计:' + str(errors[:3]))
  142. points = []
  143. for point in manifest['points']:
  144. segments = [s for s in manifest['segments'] if (s['system'], s['node_id']) == (point['system'], point['node_id'])]
  145. points.append({**point, **point_statistics(segments, base, point['system'], include_series=True)})
  146. groups = defaultdict(list)
  147. for point in points:
  148. if point['system'] == 'safety':
  149. for event in point['transitions']:
  150. groups[event['observed_at']].append({'node_id': point['node_id'], 'fan_id': point['fan_id'],
  151. 'label': point['label'], **event})
  152. candidates = []
  153. for observed, evidence in sorted(groups.items()):
  154. stopped = {event['fan_id'] for event in evidence if event['to_state'] == '停'}
  155. started = {event['fan_id'] for event in evidence if event['to_state'] == '开'}
  156. if not (stopped and started and stopped.isdisjoint(started)):
  157. continue
  158. candidates.append({'status': 'candidate', 'observed_at': observed, 'from_fans': sorted(stopped),
  159. 'to_fans': sorted(started), 'from_stopped_at': None, 'to_started_at': None, 'interval_minutes': None,
  160. 'state_changes': evidence, 'limitations': ['只按相同观测时刻分组;各级测点与整机及通风井对应尚未确认。',
  161. '实际变化落在前一采样与本次观测之间,不能把相同观测时间换算成0分钟切换。']})
  162. for point in points:
  163. series = point.pop('series', [])
  164. if point['role'] != 'flow':
  165. continue
  166. for candidate in candidates:
  167. observed = time(candidate['observed_at'])
  168. before = [sample for sample in series if time(sample['time']) <= observed]
  169. after = [sample for sample in series if time(sample['time']) > observed]
  170. candidate.setdefault('nearest_flow_samples', []).append({'node_id': point['node_id'],
  171. 'unit': point['unit'], 'before': before[-1] if before else None,
  172. 'after': after[0] if after else None,
  173. 'note': '相邻风量采样仅作证据展示,不作为稳定窗口均值;测点归属仍待确认。'})
  174. archives = []
  175. for receipt in context.get('report_archives', []):
  176. archive = read(local_path(base, receipt['archive_path']))
  177. page_checks = []
  178. for page, source in archive['facts']['page_sources'].items():
  179. proof = archive.get('page_read_evidence', {}).get(str(page), {})
  180. page_checks.append(proof.get('complete') is True and sha(local_path(base, source)) == proof.get('sha256'))
  181. pdf = local_path(base, receipt['path'])
  182. archives.append({'source_sha256': receipt['sha256'], 'source_hash_matches': sha(pdf) == receipt['sha256'],
  183. 'is_mock': receipt['is_mock'], 'binding': receipt['binding'], 'version': archive['version'],
  184. 'page_count': archive['facts']['page_count'], 'verified_page_reads': sum(page_checks),
  185. 'report_count': len(archive['facts']['reports']), 'reports': archive['facts']['reports'],
  186. 'archive_path': receipt['archive_path'], 'not_target_mine_report': True})
  187. return {'dept_id': mine['dept_id'], 'mine_name': mine['mine_name'], 'mine_id': mine['mine_id'],
  188. 'session_id': mine['session_id'], 'workflow_id': workflow_id, 'workspace_root': str(base),
  189. 'transport': {key: manifest[key] for key in ('expected_segments', 'completed_segments', 'failed_segments', 'coverage_complete')},
  190. 'original_files_checked': len(checked), 'original_file_checks': checked, 'points': points,
  191. 'switch_candidates': candidates, 'report_archives': archives,
  192. 'limitations': ['请求分段全部成功仅证明传输覆盖,不保证各段都有数据或采样连续。',
  193. '通风井身份和一级、二级测点与整机的映射待确认,切换仅为候选。',
  194. '风量测点只有1号风机,不能自动代表2号机或全井风量;0读数不等同于全矿停风。',
  195. '不同系统的开停字段不能直接替换,需核对采样、含义及设备关联。',
  196. '本地核验不产生生产判识结论;模型权限恢复后再由Agent复核。']}
  197. def table(headers, rows):
  198. return '<table><thead><tr>' + ''.join('<th>' + escape(str(h)) + '</th>' for h in headers) + '</tr></thead><tbody>' + ''.join(
  199. '<tr>' + ''.join('<td>' + escape(str(value)) + '</td>' for value in row) + '</tr>' for row in rows) + '</tbody></table>'
  200. def render(result):
  201. markdown = ['# 主通风机本地数据核验', '', '本页为已归档真实数据的独立开发核验。Agent补充复核因模型服务403待完成;不作为正式业务结论。旧AI初稿存在“没有风量历史”的错误,已单独标记为未通过复核。', '',
  202. f"查询范围:{result['start_time']} 至 {result['cutoff']},北京时间,连续{result['lookback_days']}天,每次至多24小时。", '',
  203. f"只读取本批次已保存的{len(result['mines'])}个矿,未新增网关请求、未调用模型、未推送。", '']
  204. parts = ['<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>主通风机本地数据核验</title>',
  205. '<style>body{max-width:1180px;margin:40px auto;padding:0 24px;font:16px/1.75 system-ui;color:#18323c;background:#f6f8fa}h1,h2,h3{line-height:1.4}table{width:100%;border-collapse:collapse;background:white;margin:18px 0;font-size:14px}th,td{padding:11px;text-align:left;border:1px solid #dce5e9;overflow-wrap:anywhere}.notice{padding:18px;background:#fff0d2;border-left:5px solid #b47a18;border-radius:5px}a{color:#006579}code{overflow-wrap:anywhere}</style>',
  206. '<h1>主通风机 · 本地数据核验</h1><div class="notice">已完成归档数据核验。模型服务返回403,Agent补充复核待完成。旧AI初稿存在风量遗漏,已标记为未通过复核。本页不作生产判识结论。</div>',
  207. '<p>时间范围:' + escape(result['start_time'] + ' 至 ' + result['cutoff']) + '(北京时间)。只核验已选矿井,未新增网关请求、未调用模型、未推送。</p>',
  208. '<p><a href="本地数据核验.json">完整统计与证据JSON</a> · <a href="本地数据核验.md">Markdown报告</a> · <a href="../index.html">批次首页</a></p>']
  209. for mine in result['mines']:
  210. coverage = mine['transport']
  211. parts += ['<h2>' + escape(mine['mine_name']) + ' · ' + mine['dept_id'] + '</h2>',
  212. f"<p>{len(mine['points'])}个测点,{coverage['completed_segments']}/{coverage['expected_segments']}个查询分段成功;核对原件指纹{mine['original_files_checked']}份。通风井身份待确认。</p>",
  213. '<h3>安全监控状态变化</h3>']
  214. markdown += [f"## {mine['mine_name']}(deptId={mine['dept_id']})", '', f"查询分段:{coverage['completed_segments']}/{coverage['expected_segments']}成功;归档请求、响应和数据共{mine['original_files_checked']}份,SHA256一致。分段成功不代表采样连续。", '', '### 安全监控状态变化', '']
  215. safety = [p for p in mine['points'] if p['system'] == 'safety']
  216. parts.append(table(['测点', '原始样本', '去重后正常样本', '变化次数', '最大采样间隔(秒)'],
  217. [[p['label'], p['raw_sample_count'], p['unique_usable_sample_count'], len(p['transitions']), p['largest_gap_seconds']] for p in safety]))
  218. for point in safety:
  219. markdown.append(f"- {point['label']}:原始{point['raw_sample_count']}条,去重正常{point['unique_usable_sample_count']}条,变化{len(point['transitions'])}次;nodeId={point['node_id']}。")
  220. parts.append('<p>以下为配对变化候选。表中时间是首次观察到新状态的时刻,实际变化发生在前后两个采样之间,整机切换间隔暂不确定。</p>')
  221. event_rows = []
  222. markdown += ['', '### 切换候选(实际切换时刻及间隔未确认)', '']
  223. for candidate in mine['switch_candidates']:
  224. label = '、'.join(candidate['from_fans']) + ' → ' + '、'.join(candidate['to_fans'])
  225. markdown += [f"观测时刻 {candidate['observed_at']}:{label}。", '']
  226. for event in candidate['state_changes']:
  227. event_rows.append([event['label'], event['from_state'] + '→' + event['to_state'], event['previous_sample_at'], event['observed_at'], event['gap_seconds']])
  228. markdown.append(f"- {event['label']} {event['from_state']}→{event['to_state']},采样范围({event['previous_sample_at']}, {event['observed_at']}],间隔{event['gap_seconds']}秒。")
  229. for neighbor in candidate.get('nearest_flow_samples', []):
  230. description = ';'.join(('前一条' if side == 'before' else '后一条') + ':' + (
  231. f"{neighbor[side]['time']},{neighbor[side]['value']} {neighbor['unit']}" if neighbor[side] else '无可用样本')
  232. for side in ('before', 'after'))
  233. markdown.append('- 相邻风量采样(非稳定窗口均值):' + description + '。')
  234. markdown.append('')
  235. parts.append(table(['测点', '状态变化', '前一采样', '首次观察新状态', '间隔(秒)'], event_rows))
  236. parts.append(table(['候选观测时刻', '前一条风量采样', '后一条风量采样'], [
  237. [candidate['observed_at']] + [f"{row[side]['time']} / {row[side]['value']} {row['unit']}" if row[side] else '无可用样本'
  238. for side in ('before', 'after')]
  239. for candidate in mine['switch_candidates'] for row in candidate.get('nearest_flow_samples', [])]))
  240. parts += ['<p>相邻风量采样仅作证据展示,不代表稳定窗口均值。</p>', '<h3>重大设备风量与状态样本</h3>']
  241. equipment = [p for p in mine['points'] if p['system'] == 'equipment']
  242. parts.append(table(['测点', '有数据/空日段', '原始/去重样本', '数值范围', '正值数', '最大采样间隔(秒)'],
  243. [[p['label'], f"{p['nonempty_windows']}/{p['empty_windows']}", f"{p['raw_sample_count']}/{p['unique_usable_sample_count']}",
  244. f"{p['minimum']}~{p['maximum']}", p['positive_count'], p['largest_gap_seconds']] for p in equipment]))
  245. markdown += ['### 风量与重大设备状态', '']
  246. for point in equipment:
  247. markdown.append(f"- {point['label']}:有数据{point['nonempty_windows']}段、空{point['empty_windows']}段;原始{point['raw_sample_count']}条,去重可用{point['unique_usable_sample_count']}条;数值{point['minimum']}~{point['maximum']},正值{point['positive_count']}条;nodeId={point['node_id']}。")
  248. if point['role'] == 'flow':
  249. flow_note = f"风量历史实际有{point['raw_sample_count']}条记录,去重后{point['zero_count']}条为0,正值{point['positive_count']}条。字段为data[].sbNodeCurve[].dataTime/realData,单位沿用实时列表{point['unit']}。0值属于已有数据,不能描述为没有历史。"
  250. if not point['positive_count']:
  251. flow_note += '当前无正值样本可用于可靠的切换前后稳定风量比较。'
  252. parts.append('<p class="notice">' + escape(flow_note) + '</p>')
  253. markdown += ['', flow_note, '']
  254. parts.append('<h3>模拟PDF与档案</h3>')
  255. for archive in mine['report_archives']:
  256. names = '、'.join(sorted({report.get('tested_unit', '原件未提取矿名') for report in archive['reports']}))
  257. text = f"模拟接口返回原有测试PDF:{archive['page_count']}页、{archive['report_count']}份报告,实际全页读取证据{archive['verified_page_reads']}/{archive['page_count']}页,原件SHA256核对{'一致' if archive['source_hash_matches'] else '不一致'}。原件矿名为{names},仅作测试夹具,不能作为本矿正式报告。"
  258. parts.append('<p>' + escape(text) + '</p><p>SHA256:<code>' + archive['source_sha256'] + '</code></p>')
  259. markdown += ['', '### 模拟PDF', '', text, '', 'SHA256:`' + archive['source_sha256'] + '`。', '']
  260. parts.append('<h3>核验限制与后续</h3><ul>' + ''.join('<li>' + escape(item) + '</li>' for item in mine['limitations']) + '</ul>')
  261. markdown += ['### 核验限制', ''] + ['- ' + item for item in mine['limitations']] + ['']
  262. queue = result['delivery_checks']
  263. text = f"本批次隔离数据库:business_jobs={queue['business_jobs']},business_push_targets={queue['business_push_targets']};批次publish=false。正式PDF接口、通风井和整机映射、Agent补充复核仍待完成。"
  264. parts += ['<p>' + escape(text) + '</p></html>']
  265. markdown += [text, '']
  266. return '\n'.join(markdown), '\n'.join(parts)
  267. def main():
  268. parser = argparse.ArgumentParser(description=__doc__)
  269. parser.add_argument('--batch-dir', type=Path, required=True)
  270. args = parser.parse_args()
  271. output = args.batch_dir.resolve()
  272. state = read(output / 'batch.json')
  273. if any(len(mine.get('shafts', [])) != 1 for mine in state['mines']):
  274. parser.error('本核验脚本适用于每矿一个已保存的候选系统;多井请分别按井核验,避免遗漏。')
  275. connection = sqlite3.connect((output / 'runtime/business.sqlite').as_uri() + '?mode=ro', uri=True)
  276. try:
  277. result = {'mode': 'independent_local_data_verification', 'agent_review': 'pending_model_access_403',
  278. 'production_conclusion': None, 'batch_id': state['batch_id'], 'cutoff': state['cutoff'],
  279. 'start_time': (time(state['cutoff']) - timedelta(days=state['lookback_days'])).isoformat(),
  280. 'lookback_days': state['lookback_days'],
  281. 'mines': [verify_mine(connection, state, mine) for mine in state['mines']],
  282. 'delivery_checks': {table: connection.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0]
  283. for table in ('business_jobs', 'business_push_targets')}}
  284. finally:
  285. connection.close()
  286. directory = output / 'verification'
  287. directory.mkdir(exist_ok=True)
  288. markdown, html = render(result)
  289. (directory / '本地数据核验.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
  290. (directory / '本地数据核验.md').write_text(markdown, encoding='utf-8')
  291. (directory / '本地数据核验.html').write_text(html, encoding='utf-8')
  292. print(json.dumps({'status': 'verified', 'mine_count': len(result['mines']),
  293. 'report': str(directory / '本地数据核验.html'), 'delivery_checks': result['delivery_checks']}, ensure_ascii=False))
  294. if __name__ == '__main__':
  295. main()