history.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. """按日取数,压缩证据并整理观测状态区间;整机判识仍由Agent完成。"""
  2. import asyncio
  3. from datetime import timedelta
  4. import hashlib
  5. import json
  6. import time
  7. import config
  8. from obs import get_logger
  9. from extensions import get_service
  10. from extensions.registry import load_extensions
  11. from extensions.main_fan import equipment, exploration, gateway_queries, history_compaction, history_processing, timeseries
  12. from extensions.main_fan.batch_context import scope_for
  13. from extensions.main_fan.source_policy import source_policy, select_history_points
  14. from runtime.python_sandbox import digest, workspace_file
  15. from db import main_fan_store, db_conn
  16. logger = get_logger(__name__)
  17. def daily_windows(cutoff, days):
  18. end = gateway_queries.parse_time(cutoff)
  19. if not 1 <= days <= 40:
  20. raise ValueError('回查天数须为1至40天')
  21. return [(end - timedelta(days=index + 1), end - timedelta(days=index)) for index in range(days)]
  22. async def query(identifier, user, session):
  23. started = time.monotonic()
  24. scope = scope_for(user, session)
  25. if scope.shaft is None:
  26. raise ValueError('请先完成矿级测点发现并由宿主派发通风井任务')
  27. selected_points = select_history_points(scope.shaft.points)
  28. if not selected_points:
  29. raise ValueError('开停以安全监控为准,重大设备只取风量;当前计划没有可用于本轮分析的测点')
  30. points = {(point.system, point.node_id): point for point in selected_points}
  31. ignored_points = [point.model_dump() for point in scope.shaft.points
  32. if (point.system, point.node_id) not in points]
  33. workflow = exploration._load(identifier, user, session)
  34. exploration.validate_inputs(workflow, user, session)
  35. context = workflow['context']
  36. index = context.setdefault('history_index', {})
  37. if not index:
  38. # 同批次重新分析时允许宿主导入同矿、同井、同时段、同计划的原件,保持分析自主且不重拉数据。
  39. for row in db_conn.query('SELECT id FROM main_fan_workflows WHERE user_name=? AND session_id=? AND id<>? ORDER BY created_at DESC LIMIT 20',
  40. (user, session, identifier)):
  41. previous = main_fan_store.workflow(row['id'], user, session)['context']
  42. batch = previous.get('task_input', {}).get('measurements', {}).get('batch', {})
  43. if (batch.get('batch_id') == scope.batch_id and batch.get('cutoff') == scope.cutoff
  44. and batch.get('lookback_days') == scope.lookback_days
  45. and batch.get('shaft_plan') == scope.shaft.model_dump(mode='json') and previous.get('history_index')):
  46. index.update(previous['history_index'])
  47. for record in index.values():
  48. context.setdefault('query_exports', {}).update(record['files'])
  49. context['history_reused_from_workflow'] = row['id']
  50. break
  51. gateway = get_service('shaanxi_gateway')
  52. spec = load_extensions().business('main_fan')
  53. windows = daily_windows(scope.cutoff, scope.lookback_days)
  54. maximum_hours = config.get_int('MAIN_FAN_SERIES_MAX_HOURS', 24)
  55. if maximum_hours <= 0:
  56. raise ValueError('历史查询窗口上限须大于0')
  57. # 每日窗口仍受既有接口上限约束,配置小于24小时时自动细分。
  58. segments = []
  59. for point in points.values():
  60. for start, end in windows:
  61. right = end
  62. while right > start:
  63. left = max(start, right - timedelta(hours=min(24, maximum_hours)))
  64. segments.append((point, left, right))
  65. right = left
  66. semaphore = asyncio.Semaphore(max(1, min(4, config.get_int('MAIN_FAN_HISTORY_CONCURRENCY', 2))))
  67. lock = asyncio.Lock()
  68. def query_key(point, start, end):
  69. material = [point.system, scope.mine['dept_id'], point.node_id, start.isoformat(), end.isoformat()]
  70. return hashlib.sha256(json.dumps(material).encode()).hexdigest()[:32]
  71. active_keys = {query_key(*segment) for segment in segments}
  72. logger.info('历史取数开始', extra={'kv': {'event': 'history.started', 'workflow_id': identifier,
  73. 'query_count': len(segments), 'point_count': len(points), 'lookback_days': scope.lookback_days}})
  74. async def fetch(point, start, end):
  75. key = query_key(point, start, end)
  76. previous = index.get(key)
  77. if previous and previous['status'] == 'completed':
  78. expected = previous['files']
  79. if all(workspace_file(user, session, path).is_file()
  80. and digest(workspace_file(user, session, path)) == sha for path, sha in expected.items()):
  81. return
  82. prefix = '/workspace/exploration/' + identifier + '/history/' + key
  83. record = {'key': key, 'system': point.system, 'node_id': point.node_id,
  84. 'role': point.role, 'fan_id': point.fan_id, 'label': point.label, 'unit': point.unit,
  85. 'mapping_confirmed': point.mapping_confirmed, 'start_time': start.isoformat(),
  86. 'end_time': end.isoformat(), 'status': 'failed', 'files': {}}
  87. async with semaphore:
  88. path = timeseries.CURVE_PATH if point.system == 'safety' else equipment.CURVE_PATH
  89. params, _, _ = gateway_queries.curve_parameters(point.node_id, scope.mine['dept_id'],
  90. start.isoformat(), end.isoformat(), node_key='nodeIds' if point.system == 'safety' else 'nodeId')
  91. request = {'method': 'GET', 'path': path, 'params': params}
  92. for attempt in range(3):
  93. query_started = time.monotonic()
  94. try:
  95. body = await gateway.proxy(spec.gateway.agent_code, path, params=params)
  96. if point.system == 'safety':
  97. data, summary = timeseries._normalize(body, point.node_id, start, end)
  98. else:
  99. data, summary = equipment._curve_data(body, point.node_id)
  100. for name, value in (('request.json', request), ('response.json', body), ('data.json', data)):
  101. virtual = prefix + '/' + name
  102. local = workspace_file(user, session, virtual)
  103. exploration._write(local, value)
  104. record['files'][virtual] = digest(local)
  105. record.update(status='completed', summary=summary, data_path=prefix + '/data.json',
  106. python_path=(prefix + '/data.json').removeprefix('/workspace/'))
  107. record.pop('error', None)
  108. logger.debug('历史日段取数完成', extra={'kv': {'event': 'history.query.completed',
  109. 'system': point.system, 'node_id': point.node_id, 'start': start.isoformat(),
  110. 'end': end.isoformat(), 'attempt': attempt + 1,
  111. 'duration_ms': round((time.monotonic() - query_started) * 1000)}})
  112. break
  113. except asyncio.CancelledError:
  114. raise
  115. except (ValueError, OSError, RuntimeError) as exc:
  116. logger.warning('历史日段取数失败', extra={'kv': {'event': 'history.query.failed',
  117. 'system': point.system, 'node_id': point.node_id, 'start': start.isoformat(),
  118. 'end': end.isoformat(), 'attempt': attempt + 1, 'error_type': type(exc).__name__}})
  119. record['error'] = type(exc).__name__ + ': ' + str(exc)[:300]
  120. if attempt == 2 or getattr(exc, 'retryable', True) is False:
  121. break
  122. await asyncio.sleep(.25 * (attempt + 1))
  123. async with lock:
  124. index[key] = record
  125. # 不为每个日窗口增加 Agent 操作节点;独立原件、指纹和进度仍逐段持久化。
  126. context.setdefault('query_exports', {}).update(record['files'])
  127. main_fan_store.update_workflow(identifier, context=context)
  128. finished = sum(key in index for key in active_keys)
  129. if scope.progress and (finished % 10 == 0 or finished == len(segments)):
  130. scope.progress({'stage': 'history', 'dept_id': scope.mine['dept_id'],
  131. 'shaft_id': scope.shaft.shaft_id, 'finished': finished, 'total': len(segments)})
  132. await asyncio.gather(*(fetch(*segment) for segment in segments))
  133. active = [index[key] for key in active_keys]
  134. logger.info('历史取数结束', extra={'kv': {'event': 'history.finished', 'workflow_id': identifier,
  135. 'query_count': len(active), 'failed_count': sum(row['status'] != 'completed' for row in active),
  136. 'duration_ms': round((time.monotonic() - started) * 1000)}})
  137. records = list(index.values())
  138. resolve = lambda path: workspace_file(user, session, path)
  139. archive_path = '/workspace/exploration/' + identifier + '/history-evidence.zip'
  140. records, archive_files, cleanup, storage = await asyncio.to_thread(
  141. history_compaction.compact_evidence, records, archive_path, resolve)
  142. for old in index.values():
  143. for path in old['files']:
  144. context.setdefault('query_exports', {}).pop(path, None)
  145. context['query_exports'].update(archive_files)
  146. context['history_index'] = {row['key']: row for row in records}
  147. # 先保存指向已校验ZIP的索引,再清理本工作流暂存JSON;旧批次原件不删除。
  148. main_fan_store.update_workflow(identifier, context=context)
  149. staging = '/workspace/exploration/' + identifier + '/history/'
  150. directories = set()
  151. for path in cleanup:
  152. if path.startswith(staging):
  153. local = resolve(path)
  154. local.unlink(missing_ok=True)
  155. directories.add(local.parent)
  156. for directory in directories:
  157. try:
  158. directory.rmdir()
  159. except OSError:
  160. pass
  161. # 旧档案原件和缓存索引保留,只让当前数据源计划的日段参与覆盖统计及Agent摘要。
  162. records = [row for row in records if row['key'] in active_keys]
  163. failed = [row for row in records if row['status'] != 'completed']
  164. manifest = {'cutoff': scope.cutoff, 'lookback_days': scope.lookback_days,
  165. 'source_policy': source_policy(),
  166. 'ignored_plan_points': ignored_points,
  167. 'reused_from_workflow': context.get('history_reused_from_workflow', ''),
  168. 'storage': storage,
  169. 'point_count': len(points), 'expected_segments': len(segments),
  170. 'completed_segments': len(records) - len(failed), 'failed_segments': len(failed),
  171. 'coverage_complete': bool(points) and not failed and len(records) == len(segments),
  172. 'points': [point.model_dump() for point in points.values()], 'segments': records}
  173. def derive():
  174. shapes, equipment_counts = [], {}
  175. with history_compaction.RecordReader(resolve) as reader:
  176. for point in points.values():
  177. if point.system != 'equipment':
  178. continue
  179. recognized_count, unrecognized, example = 0, 0, None
  180. for record in records:
  181. if record['system'] != point.system or record['node_id'] != point.node_id or record['status'] != 'completed':
  182. continue
  183. payload = reader.data(record)['payload']
  184. _, summary = equipment._curve_data(payload, point.node_id)
  185. if summary['sample_count'] is None:
  186. unrecognized += 1
  187. else:
  188. recognized_count += summary['sample_count']
  189. if example is None or (not example['sample_count'] and summary.get('sample_count')):
  190. example = {'system': point.system, 'node_id': point.node_id, 'role': point.role,
  191. 'sample_count': summary['sample_count'], 'archive_path': record['archive']['path'],
  192. 'data_member': record['archive']['members']['data']['name'],
  193. 'structure': summary['data_structure']}
  194. equipment_counts[point.node_id] = {'role': point.role, 'recognized_samples': recognized_count,
  195. 'unrecognized_windows': unrecognized}
  196. if example:
  197. shapes.append(example)
  198. return shapes, equipment_counts, history_compaction.summarize(manifest, reader)
  199. shapes, equipment_counts, details = await asyncio.to_thread(derive)
  200. details_path = '/workspace/exploration/' + identifier + '/history-state-details.json'
  201. agent_path = '/workspace/exploration/' + identifier + '/history-agent.json'
  202. details['storage'] = storage
  203. details['source_policy'] = source_policy()
  204. details['mine'] = {key: scope.mine[key] for key in ('mine_id', 'dept_id', 'mine_name')}
  205. details['shaft'] = {'shaft_id': scope.shaft.shaft_id, 'shaft_name': scope.shaft.shaft_name, 'identity_status': scope.shaft.identity_status}
  206. with history_compaction.RecordReader(resolve) as reader:
  207. compact = await asyncio.to_thread(history_processing.prepare, details, manifest, reader, context['policy'], details_path)
  208. exploration._write(resolve(details_path), details)
  209. context['query_exports'][details_path] = digest(resolve(details_path))
  210. compact = history_processing.agent_input(compact)
  211. exploration._write(resolve(agent_path), compact)
  212. context['query_exports'][agent_path] = digest(resolve(agent_path))
  213. manifest.update(equipment_sample_counts=equipment_counts, data_shapes=shapes, agent_data_path=agent_path)
  214. virtual = '/workspace/exploration/' + identifier + '/history-manifest.json'
  215. local = workspace_file(user, session, virtual)
  216. exploration._write(local, manifest)
  217. context.setdefault('query_exports', {})[virtual] = digest(local)
  218. context['history_manifest'] = manifest
  219. receipt = {key: manifest[key] for key in ('cutoff', 'lookback_days', 'point_count',
  220. 'expected_segments', 'completed_segments', 'failed_segments', 'coverage_complete')}
  221. receipt.update(ok=True, source_policy=source_policy(), manifest_path=virtual, python_path=virtual.removeprefix('/workspace/'),
  222. agent_data_path=agent_path, agent_python_path=agent_path.removeprefix('/workspace/'),
  223. evidence_archive_path=storage.get('archive_path'), equipment_sample_counts=equipment_counts,
  224. state_interval_counts={row['node_id']: len(row['state_intervals']) for row in details['points'] if row['role'] == 'fan_state'},
  225. note='先读精简的agent_data_path:states为开停区间,flows为风量概况,switch_candidates为切换候选及前后风量。开停区间为同状态观测合并;time_range不是切换耗时;无效风量差为null。质量和计算明细仅在需要时读取details_path。')
  226. context.setdefault('history_queries', []).append(receipt)
  227. exploration._persist(workflow, user, session, '按日回查开停与风量', receipt)
  228. logger.info('切换候选及前后参考风量已写入Agent输入', extra={'kv': {
  229. 'event': 'history.prepared', 'workflow_id': identifier,
  230. 'preprocessing_version': history_processing.VERSION,
  231. 'switch_candidate_count': details['preprocessing']['switch_candidate_count'],
  232. 'agent_data_path': agent_path}})
  233. return receipt