history_processing.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. """固定整理切换候选及前后各单点参考风量;不替Agent作最终业务结论。"""
  2. from collections import defaultdict
  3. from datetime import timedelta
  4. from math import isfinite
  5. from extensions.main_fan import equipment, history_compaction
  6. from extensions.main_fan.gateway_queries import parse_time
  7. VERSION = 'main-fan-fixed-preprocessing-v7-open-close-timing-1h'
  8. REFERENCE_OFFSET_MINUTES = 60
  9. INTERVAL_METHOD = 'last_changed_stage_observation'
  10. def net_changes(changes):
  11. result = {}
  12. for change in changes:
  13. node = change['node_id']
  14. if node not in result:
  15. result[node] = {key: change[key] for key in
  16. ('node_id', 'fan_id', 'label', 'from_state', 'to_state', 'after', 'by')}
  17. else:
  18. result[node].update(to_state=change['to_state'], by=change['by'])
  19. return list(result.values())
  20. def switch_timing(event):
  21. """按参与净切换的各级最后完成停/启的观测估算,保留采样边界。"""
  22. timing = {'from_stopped_at': None, 'to_started_at': None, 'interval_minutes': None,
  23. 'interval_seconds': None, 'interval_status': 'unknown', 'interval_method': INTERVAL_METHOD,
  24. 'interval_bounds_minutes': None, 'interval_has_gaps': False, 'overlap_seconds': None,
  25. 'interval_sources': []}
  26. changes = event.get('state_changes', [])
  27. if not changes:
  28. return timing
  29. changes = sorted(changes, key=lambda row: parse_time(row['by']))
  30. net = net_changes(changes)
  31. stops = {row['node_id'] for row in net if row['fan_id'] == event['from_fan']
  32. and (row['from_state'], row['to_state']) == ('开', '停')}
  33. starts = {row['node_id'] for row in net if row['fan_id'] == event['to_fan']
  34. and (row['from_state'], row['to_state']) == ('停', '开')}
  35. if not stops or not starts:
  36. return timing
  37. # 净变化用于配对,真正的时间边界取该点最后一次实际变化,避免启动抖动污染时间。
  38. last = {row['node_id']: row for row in changes if row['node_id'] in stops | starts}
  39. stop_rows, start_rows = [last[node] for node in sorted(stops)], [last[node] for node in sorted(starts)]
  40. if any(parse_time(row['after']) > parse_time(row['by']) for row in stop_rows + start_rows):
  41. return timing
  42. stop_lower = max(parse_time(row['after']) for row in stop_rows)
  43. stop_at = max(parse_time(row['by']) for row in stop_rows)
  44. start_lower = max(parse_time(row['after']) for row in start_rows)
  45. start_at = max(parse_time(row['by']) for row in start_rows)
  46. signed_seconds = (start_at - stop_at).total_seconds()
  47. seconds = max(0.0, signed_seconds)
  48. sources = [{'action': action, **{key: row[key] for key in
  49. ('node_id', 'label', 'fan_id', 'from_state', 'to_state', 'after', 'by')},
  50. 'intervening_unknown_or_gap': bool(row.get('intervening_unknown_or_gap'))}
  51. for action, rows in (('stop', stop_rows), ('start', start_rows)) for row in rows]
  52. timing.update(from_stopped_at=stop_at.isoformat(), to_started_at=start_at.isoformat(),
  53. interval_minutes=seconds / 60, interval_seconds=seconds, interval_status='estimated',
  54. interval_bounds_minutes={'lower': max(0.0, (start_lower - stop_at).total_seconds()) / 60,
  55. 'upper': max(0.0, (start_at - stop_lower).total_seconds()) / 60},
  56. interval_has_gaps=any(row['intervening_unknown_or_gap'] for row in sources),
  57. overlap_seconds=max(0.0, -signed_seconds), interval_sources=sources)
  58. return timing
  59. def switch_candidates(points, maximum_minutes):
  60. """按净变化定位候选;跨组配对停启,判定阈值不限制可发现的间隔。"""
  61. changes = sorted([{'node_id': point['node_id'], 'fan_id': point.get('fan_id', ''),
  62. 'label': point.get('label', ''), **change}
  63. for point in points if point['system'] == 'safety' and point['role'] == 'fan_state'
  64. for change in point.get('state_changes', [])], key=lambda row: parse_time(row['by']))
  65. groups = []
  66. for change in changes:
  67. if not groups or parse_time(change['by']) - parse_time(groups[-1][0]['by']) > timedelta(minutes=maximum_minutes):
  68. groups.append([])
  69. groups[-1].append(change)
  70. events, pending = [], None
  71. def directions(net):
  72. stopped = {row['fan_id'] for row in net if row['fan_id'] and row['from_state'] == '开' and row['to_state'] == '停'}
  73. started = {row['fan_id'] for row in net if row['fan_id'] and row['from_state'] == '停' and row['to_state'] == '开'}
  74. return stopped, started
  75. def append(group, net, stopped, started):
  76. lower, upper = min(row['after'] for row in group), max(row['by'] for row in group)
  77. event = {'event_id': 'S' + str(len(events) + 1), 'status': 'candidate',
  78. 'from_fan': next(iter(stopped)), 'to_fan': next(iter(started)),
  79. 'after': lower, 'by': upper,
  80. 'sampling_localization_seconds': (parse_time(upper) - parse_time(lower)).total_seconds(),
  81. 'machine_switch_confirmed': False, 'state_changes': group, 'net_state_changes': net,
  82. 'limitations': ['按安监同一组相反开停变化整理候选,一级/二级仍分别保留。',
  83. '候选方向按各测点首末状态整理,组内短暂开停反复保留在原始变化中。',
  84. 'after/by是候选采样定位范围,停启间隔单独按参与净切换的各级最后完成停/启的观测估算。',
  85. '估算保留采样边界;先启后停记重叠时间,停启空档记0,不宣称已确认整机动作时刻。']}
  86. event.update(switch_timing(event))
  87. events.append(event)
  88. for group in groups:
  89. net = net_changes(group)
  90. stopped, started = directions(net)
  91. if len(stopped) == len(started) == 1 and stopped.isdisjoint(started):
  92. append(group, net, stopped, started)
  93. pending = None
  94. elif len(stopped) == 1 and not started:
  95. if pending and pending['fan'] in stopped:
  96. pending['changes'].extend(group)
  97. else:
  98. pending = {'fan': next(iter(stopped)), 'changes': list(group)}
  99. elif len(started) == 1 and not stopped:
  100. if pending and pending['fan'] not in started:
  101. combined = pending['changes'] + group
  102. combined_net = net_changes(combined)
  103. old, new = directions(combined_net)
  104. if len(old) == len(new) == 1 and old.isdisjoint(new):
  105. append(combined, combined_net, old, new)
  106. # 原机重新启动会取消之前未完成的交接,不跨过它拼接更晚的切换。
  107. pending = None
  108. elif not stopped and not started:
  109. if pending:
  110. pending['changes'].extend(group)
  111. else:
  112. pending = None
  113. return events
  114. def flow_samples(point, records, reader):
  115. timestamps = defaultdict(set)
  116. for record in records:
  117. if record['status'] != 'completed' or (record['system'], record['node_id']) != ('equipment', point['node_id']):
  118. continue
  119. document = reader.data(record)
  120. series = document.get('series')
  121. if series is None:
  122. normalized, _ = equipment._curve_data(document.get('payload'), point['node_id'])
  123. series = normalized['series']
  124. for lane in series or []:
  125. for sample in lane['samples']:
  126. try:
  127. at, value = parse_time(sample['time']), float(sample['value'])
  128. if isfinite(value) and parse_time(record['start_time']) <= at <= parse_time(record['end_time']):
  129. timestamps[at].add(value)
  130. except (KeyError, ValueError, TypeError, OverflowError):
  131. continue
  132. # 同时刻冲突不择一;零值和负值均保留,参考点另行标记有效性。
  133. return sorted((at, next(iter(values))) for at, values in timestamps.items() if len(values) == 1)
  134. def reference_sample(samples, boundary, side):
  135. """以切换前/后的指定偏移时间为目标,限对应一侧取最近的一个采样,不求均值。"""
  136. if side not in {'before', 'after'}:
  137. raise ValueError('参考风量只支持before或after')
  138. offset = timedelta(minutes=REFERENCE_OFFSET_MINUTES)
  139. target = boundary - offset if side == 'before' else boundary + offset
  140. eligible = [(at, value) for at, value in samples
  141. if (at <= boundary if side == 'before' else at >= boundary)]
  142. selected = min(eligible, key=lambda item: (abs(item[0] - target), item[0]), default=None)
  143. reasons = []
  144. if selected is None:
  145. reasons.append('missing_reference_sample')
  146. elif selected[1] < 0:
  147. reasons.append('negative_flow_values')
  148. return {'target_time': target.isoformat(), 'side': side,
  149. 'time': selected[0].isoformat() if selected else None,
  150. 'value': selected[1] if selected else None, 'valid': not reasons, 'invalid_reasons': reasons}
  151. def prepare(details, manifest, reader, rules, details_path):
  152. """返回小型Agent输入,完整候选同步写入details;原始采样仅留在证据ZIP。"""
  153. compact = history_compaction.agent_view(details, details_path)
  154. switch_rules = rules['switch']
  155. events = switch_candidates(details['points'], switch_rules['maximum_switch_interval_minutes'])
  156. flows = [point for point in details['points'] if point['system'] == 'equipment' and point['role'] == 'flow']
  157. series = {point['node_id']: flow_samples(point, manifest['segments'], reader) for point in flows} if events else {}
  158. for event in events:
  159. lower, upper = parse_time(event['after']), parse_time(event['by'])
  160. contexts = []
  161. for point in flows:
  162. samples = series[point['node_id']]
  163. contexts.append({'node_id': point['node_id'], 'fan_id': point.get('fan_id', ''),
  164. 'unit': point.get('unit', ''), 'mapping_confirmed': point.get('mapping_confirmed', False),
  165. 'before_reference': reference_sample(samples, lower, 'before'),
  166. 'after_reference': reference_sample(samples, upper, 'after')})
  167. event['flow_context'] = contexts
  168. old = [row for row in contexts if row['fan_id'] == event['from_fan']]
  169. new = [row for row in contexts if row['fan_id'] == event['to_fan']]
  170. reasons = []
  171. if len(old) != 1 or len(new) != 1:
  172. reasons.append('flow_pair_not_unique')
  173. else:
  174. before, after = old[0]['before_reference'], new[0]['after_reference']
  175. if not before['valid'] or not after['valid']:
  176. reasons.append('invalid_reference_samples')
  177. if before['value'] is None or before['value'] <= 0:
  178. reasons.append('before_value_not_positive')
  179. units = [row['unit'].replace('³', '3').replace('^', '').replace(' ', '').lower() for row in (old[0], new[0])]
  180. if units[0] not in {'m3/min', 'm3/s'} or units[0] != units[1]:
  181. reasons.append('flow_units_not_comparable')
  182. event['flow_comparison'] = {'method': f'切换前后{REFERENCE_OFFSET_MINUTES}分钟附近单点对比', 'valid': not reasons,
  183. 'relative_change_percent': abs(after['value'] - before['value']) / before['value'] * 100 if not reasons else None,
  184. 'invalid_reasons': reasons, 'judgment_scope': 'same_number_reference',
  185. 'number_mapping': '按接口原编号作参考点对照,未自动互换或确认物理绑定'}
  186. method = {'version': VERSION, 'mode': 'fixed_code', 'switch_candidate_count': len(events),
  187. 'switch_grouping_minutes': switch_rules['maximum_switch_interval_minutes'],
  188. 'interval_policy': {'method': INTERVAL_METHOD,
  189. 'pairing': '相邻可配对的停启允许跨组连接,不以正常阈值截断间隔。',
  190. 'stage_policy': '参与本次净切换的各级取最后完成停/启的新状态观测时刻。',
  191. 'uncertainty': '保留原变化采样边界形成间隔上下界;跨未知或缺测不作为间隔告警依据。',
  192. 'overlap': '先启后停时空档为0,另保留观测重叠秒数。'},
  193. 'flow_policy': {'method': 'nearest_point', 'offset_minutes': REFERENCE_OFFSET_MINUTES,
  194. 'same_side_only': True,
  195. 'note': '用户指定的单点对比;保留零值,不求均值,不使用分钟完整率、过渡区间或CV门槛。'},
  196. 'note': '固定程序已完成取数、开停区间及前后参考风量整理;保留目标时间与实际采样时间。单点参考不等同于连续稳定工况。'}
  197. details['preprocessing'] = {**method, 'switch_candidates': events}
  198. compact['preprocessing'] = {**method, 'switch_candidates': events[:20],
  199. 'switch_candidates_truncated': len(events) > 20, 'all_candidates_pointer': '/preprocessing/switch_candidates'}
  200. return compact
  201. def agent_input(summary):
  202. """默认输入只保留业务数据;类型、指纹、质量计数和计算过程留在details_path。"""
  203. local = lambda value: parse_time(value).strftime('%Y-%m-%d %H:%M:%S')
  204. cutoff = parse_time(summary['cutoff'])
  205. mine = summary.get('mine', {})
  206. result = {'mine': mine,
  207. 'range': [local((cutoff - timedelta(days=summary['lookback_days'])).isoformat()), local(summary['cutoff'])],
  208. 'states': [], 'flows': [], 'switch_candidates': []}
  209. notes = []
  210. shaft = summary.get('shaft', {})
  211. if shaft.get('identity_status') == 'confirmed':
  212. result['shaft'] = shaft['shaft_name']
  213. elif shaft or summary.get('mine'):
  214. notes.append('通风井归属待确认。')
  215. if not summary.get('transport', {}).get('coverage_complete', True):
  216. notes.append('部分历史日段取数失败或未完成,不能按全时段正常处理。')
  217. flows = [point for point in summary['points'] if point['role'] == 'flow']
  218. flow_names = {}
  219. used = set()
  220. for point in flows:
  221. name = point.get('fan_id') or point.get('label') or point['node_id']
  222. if name in used:
  223. name += ' / ' + (point.get('label') or point['node_id'])
  224. if name in used:
  225. name += ' / ' + point['node_id']
  226. used.add(name)
  227. flow_names[point['node_id']] = name
  228. values = point['values']
  229. row = {'fan': name, 'unit': point.get('unit', ''), 'samples': values.get('numeric_sample_count', 0)}
  230. if values.get('minimum') is not None:
  231. row['range'] = [values['minimum'], values['maximum']]
  232. if values.get('zero_count'):
  233. row['zero_samples'] = values['zero_count']
  234. result['flows'].append(row)
  235. if not point.get('mapping_confirmed'):
  236. notes.append('两系统风机号按原编号对照,物理对应尚未确认。')
  237. for point in summary['points']:
  238. quality = point.get('quality', {})
  239. if quality.get('unrecognized_windows'):
  240. notes.append((point.get('label') or point['node_id']) + ':部分日段结构未识别,不能当作无数据。')
  241. if point['role'] != 'fan_state':
  242. continue
  243. periods = []
  244. for period in point.get('state_periods', []):
  245. start, end = parse_time(period['first_observed_at']), parse_time(period['last_observed_at'])
  246. # 给模型的区间只保留超过5秒的观测;原始状态和切换候选保留。
  247. if end - start <= timedelta(seconds=5):
  248. continue
  249. periods.append({'state': period['state'], 'start': start.strftime('%Y-%m-%d %H:%M:%S'),
  250. 'end': end.strftime('%Y-%m-%d %H:%M:%S')})
  251. row = {'point': point.get('label') or point['node_id'], 'periods': periods}
  252. if point.get('state_periods_truncated'):
  253. row['truncated'] = True
  254. if not periods:
  255. notes.append(row['point'] + ':没有超过5秒的有效开停区间。')
  256. result['states'].append(row)
  257. preprocessing = summary['preprocessing']
  258. for event in preprocessing['switch_candidates']:
  259. row = {'id': event['event_id'], 'from_fan': event['from_fan'], 'to_fan': event['to_fan'],
  260. 'time_range': [local(event['after']), local(event['by'])], 'flow': []}
  261. nearest = lambda sample: [local(sample['time']), sample['value']] if sample.get('time') else None
  262. for flow in event['flow_context']:
  263. row['flow'].append({'fan': flow_names[flow['node_id']],
  264. 'before': nearest(flow['before_reference']), 'after': nearest(flow['after_reference'])})
  265. old = [flow for flow in event['flow_context'] if flow['fan_id'] == event['from_fan']]
  266. new = [flow for flow in event['flow_context'] if flow['fan_id'] == event['to_fan']]
  267. comparison = {'method': event['flow_comparison']['method'],
  268. 'change_percent': event['flow_comparison']['relative_change_percent']}
  269. if len(old) == len(new) == 1:
  270. before, after = old[0]['before_reference'], new[0]['after_reference']
  271. comparison.update(before_value=before['value'], after_value=after['value'])
  272. reasons = event['flow_comparison']['invalid_reasons']
  273. labels = {'flow_pair_not_unique': '风量测点无法唯一配对',
  274. 'invalid_reference_samples': '前后参考点缺失或为负风量',
  275. 'before_value_not_positive': '切换前风量不大于0或缺失,无法计算变化比例',
  276. 'flow_units_not_comparable': '风量单位不可直接比较',
  277. }
  278. if reasons:
  279. comparison['reason'] = ';'.join(labels[reason] for reason in reasons)
  280. row['comparison'] = comparison
  281. # 兼容已有原始历史详情:即使原事件曾把间隔留空,也按保留的实际变化重新派生。
  282. timing = switch_timing(event)
  283. row.update(duration_minutes=timing['interval_minutes'], duration_seconds=timing['interval_seconds'],
  284. duration_status=timing['interval_status'], duration_method=timing['interval_method'],
  285. duration_bounds_minutes=timing['interval_bounds_minutes'],
  286. duration_has_gaps=timing['interval_has_gaps'], overlap_seconds=timing['overlap_seconds'],
  287. from_stopped_at=local(timing['from_stopped_at']) if timing['from_stopped_at'] else None,
  288. to_started_at=local(timing['to_started_at']) if timing['to_started_at'] else None,
  289. duration_sources=[{'action': source['action'], 'point': source['label'] or source['node_id'],
  290. 'node_id': source['node_id'], 'previous_state_at': local(source['after']),
  291. 'new_state_at': local(source['by']),
  292. 'intervening_unknown_or_gap': source['intervening_unknown_or_gap']}
  293. for source in timing['interval_sources']])
  294. result['switch_candidates'].append(row)
  295. if result['switch_candidates']:
  296. notes.append('停启间隔已按各级最后完成停/启的观测估算,duration_bounds_minutes保留采样范围;'
  297. 'time_range仍只是整个候选的定位范围,不用它相减计算间隔。先启后停的空档为0,重叠另列。')
  298. notes.append(f'comparison按原编号比较切换前后{REFERENCE_OFFSET_MINUTES}分钟附近最近的单点;属于参考对照,未验证连续稳定工况。')
  299. if preprocessing.get('switch_candidates_truncated'):
  300. result['switch_candidates_truncated'] = True
  301. if notes:
  302. result['notes'] = list(dict.fromkeys(notes))
  303. result['details_path'] = summary['details_path']
  304. return result