| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243 |
- """按日取数,压缩证据并整理观测状态区间;整机判识仍由Agent完成。"""
- import asyncio
- from datetime import timedelta
- import hashlib
- import json
- import time
- import config
- from obs import get_logger
- from extensions import get_service
- from extensions.registry import load_extensions
- from extensions.main_fan import equipment, exploration, gateway_queries, history_compaction, history_processing, timeseries
- from extensions.main_fan.batch_context import scope_for
- from extensions.main_fan.source_policy import source_policy, select_history_points
- from runtime.python_sandbox import digest, workspace_file
- from db import main_fan_store, db_conn
- logger = get_logger(__name__)
- def daily_windows(cutoff, days):
- end = gateway_queries.parse_time(cutoff)
- if not 1 <= days <= 40:
- raise ValueError('回查天数须为1至40天')
- return [(end - timedelta(days=index + 1), end - timedelta(days=index)) for index in range(days)]
- async def query(identifier, user, session):
- started = time.monotonic()
- scope = scope_for(user, session)
- if scope.shaft is None:
- raise ValueError('请先完成矿级测点发现并由宿主派发通风井任务')
- selected_points = select_history_points(scope.shaft.points)
- if not selected_points:
- raise ValueError('开停以安全监控为准,重大设备只取风量;当前计划没有可用于本轮分析的测点')
- points = {(point.system, point.node_id): point for point in selected_points}
- ignored_points = [point.model_dump() for point in scope.shaft.points
- if (point.system, point.node_id) not in points]
- workflow = exploration._load(identifier, user, session)
- exploration.validate_inputs(workflow, user, session)
- context = workflow['context']
- index = context.setdefault('history_index', {})
- if not index:
- # 同批次重新分析时允许宿主导入同矿、同井、同时段、同计划的原件,保持分析自主且不重拉数据。
- 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',
- (user, session, identifier)):
- previous = main_fan_store.workflow(row['id'], user, session)['context']
- batch = previous.get('task_input', {}).get('measurements', {}).get('batch', {})
- if (batch.get('batch_id') == scope.batch_id and batch.get('cutoff') == scope.cutoff
- and batch.get('lookback_days') == scope.lookback_days
- and batch.get('shaft_plan') == scope.shaft.model_dump(mode='json') and previous.get('history_index')):
- index.update(previous['history_index'])
- for record in index.values():
- context.setdefault('query_exports', {}).update(record['files'])
- context['history_reused_from_workflow'] = row['id']
- break
- gateway = get_service('shaanxi_gateway')
- spec = load_extensions().business('main_fan')
- windows = daily_windows(scope.cutoff, scope.lookback_days)
- maximum_hours = config.get_int('MAIN_FAN_SERIES_MAX_HOURS', 24)
- if maximum_hours <= 0:
- raise ValueError('历史查询窗口上限须大于0')
- # 每日窗口仍受既有接口上限约束,配置小于24小时时自动细分。
- segments = []
- for point in points.values():
- for start, end in windows:
- right = end
- while right > start:
- left = max(start, right - timedelta(hours=min(24, maximum_hours)))
- segments.append((point, left, right))
- right = left
- semaphore = asyncio.Semaphore(max(1, min(4, config.get_int('MAIN_FAN_HISTORY_CONCURRENCY', 2))))
- lock = asyncio.Lock()
- def query_key(point, start, end):
- material = [point.system, scope.mine['dept_id'], point.node_id, start.isoformat(), end.isoformat()]
- return hashlib.sha256(json.dumps(material).encode()).hexdigest()[:32]
- active_keys = {query_key(*segment) for segment in segments}
- logger.info('历史取数开始', extra={'kv': {'event': 'history.started', 'workflow_id': identifier,
- 'query_count': len(segments), 'point_count': len(points), 'lookback_days': scope.lookback_days}})
- async def fetch(point, start, end):
- key = query_key(point, start, end)
- previous = index.get(key)
- if previous and previous['status'] == 'completed':
- expected = previous['files']
- if all(workspace_file(user, session, path).is_file()
- and digest(workspace_file(user, session, path)) == sha for path, sha in expected.items()):
- return
- prefix = '/workspace/exploration/' + identifier + '/history/' + key
- record = {'key': key, 'system': point.system, 'node_id': point.node_id,
- 'role': point.role, 'fan_id': point.fan_id, 'label': point.label, 'unit': point.unit,
- 'mapping_confirmed': point.mapping_confirmed, 'start_time': start.isoformat(),
- 'end_time': end.isoformat(), 'status': 'failed', 'files': {}}
- async with semaphore:
- path = timeseries.CURVE_PATH if point.system == 'safety' else equipment.CURVE_PATH
- params, _, _ = gateway_queries.curve_parameters(point.node_id, scope.mine['dept_id'],
- start.isoformat(), end.isoformat(), node_key='nodeIds' if point.system == 'safety' else 'nodeId')
- request = {'method': 'GET', 'path': path, 'params': params}
- for attempt in range(3):
- query_started = time.monotonic()
- try:
- body = await gateway.proxy(spec.gateway.agent_code, path, params=params)
- if point.system == 'safety':
- data, summary = timeseries._normalize(body, point.node_id, start, end)
- else:
- data, summary = equipment._curve_data(body, point.node_id)
- for name, value in (('request.json', request), ('response.json', body), ('data.json', data)):
- virtual = prefix + '/' + name
- local = workspace_file(user, session, virtual)
- exploration._write(local, value)
- record['files'][virtual] = digest(local)
- record.update(status='completed', summary=summary, data_path=prefix + '/data.json',
- python_path=(prefix + '/data.json').removeprefix('/workspace/'))
- record.pop('error', None)
- logger.debug('历史日段取数完成', extra={'kv': {'event': 'history.query.completed',
- 'system': point.system, 'node_id': point.node_id, 'start': start.isoformat(),
- 'end': end.isoformat(), 'attempt': attempt + 1,
- 'duration_ms': round((time.monotonic() - query_started) * 1000)}})
- break
- except asyncio.CancelledError:
- raise
- except (ValueError, OSError, RuntimeError) as exc:
- logger.warning('历史日段取数失败', extra={'kv': {'event': 'history.query.failed',
- 'system': point.system, 'node_id': point.node_id, 'start': start.isoformat(),
- 'end': end.isoformat(), 'attempt': attempt + 1, 'error_type': type(exc).__name__}})
- record['error'] = type(exc).__name__ + ': ' + str(exc)[:300]
- if attempt == 2 or getattr(exc, 'retryable', True) is False:
- break
- await asyncio.sleep(.25 * (attempt + 1))
- async with lock:
- index[key] = record
- # 不为每个日窗口增加 Agent 操作节点;独立原件、指纹和进度仍逐段持久化。
- context.setdefault('query_exports', {}).update(record['files'])
- main_fan_store.update_workflow(identifier, context=context)
- finished = sum(key in index for key in active_keys)
- if scope.progress and (finished % 10 == 0 or finished == len(segments)):
- scope.progress({'stage': 'history', 'dept_id': scope.mine['dept_id'],
- 'shaft_id': scope.shaft.shaft_id, 'finished': finished, 'total': len(segments)})
- await asyncio.gather(*(fetch(*segment) for segment in segments))
- active = [index[key] for key in active_keys]
- logger.info('历史取数结束', extra={'kv': {'event': 'history.finished', 'workflow_id': identifier,
- 'query_count': len(active), 'failed_count': sum(row['status'] != 'completed' for row in active),
- 'duration_ms': round((time.monotonic() - started) * 1000)}})
- records = list(index.values())
- resolve = lambda path: workspace_file(user, session, path)
- archive_path = '/workspace/exploration/' + identifier + '/history-evidence.zip'
- records, archive_files, cleanup, storage = await asyncio.to_thread(
- history_compaction.compact_evidence, records, archive_path, resolve)
- for old in index.values():
- for path in old['files']:
- context.setdefault('query_exports', {}).pop(path, None)
- context['query_exports'].update(archive_files)
- context['history_index'] = {row['key']: row for row in records}
- # 先保存指向已校验ZIP的索引,再清理本工作流暂存JSON;旧批次原件不删除。
- main_fan_store.update_workflow(identifier, context=context)
- staging = '/workspace/exploration/' + identifier + '/history/'
- directories = set()
- for path in cleanup:
- if path.startswith(staging):
- local = resolve(path)
- local.unlink(missing_ok=True)
- directories.add(local.parent)
- for directory in directories:
- try:
- directory.rmdir()
- except OSError:
- pass
- # 旧档案原件和缓存索引保留,只让当前数据源计划的日段参与覆盖统计及Agent摘要。
- records = [row for row in records if row['key'] in active_keys]
- failed = [row for row in records if row['status'] != 'completed']
- manifest = {'cutoff': scope.cutoff, 'lookback_days': scope.lookback_days,
- 'source_policy': source_policy(),
- 'ignored_plan_points': ignored_points,
- 'reused_from_workflow': context.get('history_reused_from_workflow', ''),
- 'storage': storage,
- 'point_count': len(points), 'expected_segments': len(segments),
- 'completed_segments': len(records) - len(failed), 'failed_segments': len(failed),
- 'coverage_complete': bool(points) and not failed and len(records) == len(segments),
- 'points': [point.model_dump() for point in points.values()], 'segments': records}
- def derive():
- shapes, equipment_counts = [], {}
- with history_compaction.RecordReader(resolve) as reader:
- for point in points.values():
- if point.system != 'equipment':
- continue
- recognized_count, unrecognized, example = 0, 0, None
- for record in records:
- if record['system'] != point.system or record['node_id'] != point.node_id or record['status'] != 'completed':
- continue
- payload = reader.data(record)['payload']
- _, summary = equipment._curve_data(payload, point.node_id)
- if summary['sample_count'] is None:
- unrecognized += 1
- else:
- recognized_count += summary['sample_count']
- if example is None or (not example['sample_count'] and summary.get('sample_count')):
- example = {'system': point.system, 'node_id': point.node_id, 'role': point.role,
- 'sample_count': summary['sample_count'], 'archive_path': record['archive']['path'],
- 'data_member': record['archive']['members']['data']['name'],
- 'structure': summary['data_structure']}
- equipment_counts[point.node_id] = {'role': point.role, 'recognized_samples': recognized_count,
- 'unrecognized_windows': unrecognized}
- if example:
- shapes.append(example)
- return shapes, equipment_counts, history_compaction.summarize(manifest, reader)
- shapes, equipment_counts, details = await asyncio.to_thread(derive)
- details_path = '/workspace/exploration/' + identifier + '/history-state-details.json'
- agent_path = '/workspace/exploration/' + identifier + '/history-agent.json'
- details['storage'] = storage
- details['source_policy'] = source_policy()
- details['mine'] = {key: scope.mine[key] for key in ('mine_id', 'dept_id', 'mine_name')}
- details['shaft'] = {'shaft_id': scope.shaft.shaft_id, 'shaft_name': scope.shaft.shaft_name, 'identity_status': scope.shaft.identity_status}
- with history_compaction.RecordReader(resolve) as reader:
- compact = await asyncio.to_thread(history_processing.prepare, details, manifest, reader, context['policy'], details_path)
- exploration._write(resolve(details_path), details)
- context['query_exports'][details_path] = digest(resolve(details_path))
- compact = history_processing.agent_input(compact)
- exploration._write(resolve(agent_path), compact)
- context['query_exports'][agent_path] = digest(resolve(agent_path))
- manifest.update(equipment_sample_counts=equipment_counts, data_shapes=shapes, agent_data_path=agent_path)
- virtual = '/workspace/exploration/' + identifier + '/history-manifest.json'
- local = workspace_file(user, session, virtual)
- exploration._write(local, manifest)
- context.setdefault('query_exports', {})[virtual] = digest(local)
- context['history_manifest'] = manifest
- receipt = {key: manifest[key] for key in ('cutoff', 'lookback_days', 'point_count',
- 'expected_segments', 'completed_segments', 'failed_segments', 'coverage_complete')}
- receipt.update(ok=True, source_policy=source_policy(), manifest_path=virtual, python_path=virtual.removeprefix('/workspace/'),
- agent_data_path=agent_path, agent_python_path=agent_path.removeprefix('/workspace/'),
- evidence_archive_path=storage.get('archive_path'), equipment_sample_counts=equipment_counts,
- state_interval_counts={row['node_id']: len(row['state_intervals']) for row in details['points'] if row['role'] == 'fan_state'},
- note='先读精简的agent_data_path:states为开停区间,flows为风量概况,switch_candidates为切换候选及前后风量。开停区间为同状态观测合并;time_range不是切换耗时;无效风量差为null。质量和计算明细仅在需要时读取details_path。')
- context.setdefault('history_queries', []).append(receipt)
- exploration._persist(workflow, user, session, '按日回查开停与风量', receipt)
- logger.info('切换候选及前后参考风量已写入Agent输入', extra={'kv': {
- 'event': 'history.prepared', 'workflow_id': identifier,
- 'preprocessing_version': history_processing.VERSION,
- 'switch_candidate_count': details['preprocessing']['switch_candidate_count'],
- 'agent_data_path': agent_path}})
- return receipt
|