workflow_monitoring.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. """按冻结阈值判定现成监测数值;模型不能降低已确认的告警等级。"""
  2. from copy import deepcopy
  3. import math
  4. VERSION = 'main-fan-monitoring-rules-v1'
  5. RANK = {'NORMAL': 0, 'WARNING': 1, 'ABNORMAL': 2}
  6. def finite(value):
  7. return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
  8. def number(value):
  9. return format(value, '.8g')
  10. def classify(value, normal, severe):
  11. if finite(severe) and value >= severe:
  12. return 'ABNORMAL'
  13. return 'WARNING' if value > normal else 'NORMAL'
  14. def assess(monitoring, rules):
  15. """仅判定预处理已确认可比的比例和停启间隔,不重选点或重新计算比例。"""
  16. normal_flow = rules['switch']['flow_deviation_percent']
  17. normal_interval = rules['switch']['maximum_switch_interval_minutes']
  18. severe_flow = rules.get('severity', {}).get('abnormal_flow_deviation_percent')
  19. severe_interval = rules.get('severity', {}).get('abnormal_switch_interval_minutes')
  20. for threshold in (normal_flow, normal_interval):
  21. if not finite(threshold) or threshold < 0:
  22. raise ValueError('监测正常阈值必须为非负有限数值')
  23. for threshold in (severe_flow, severe_interval):
  24. if threshold is not None and (not finite(threshold) or threshold < 0):
  25. raise ValueError('已配置的监测严重阈值必须为非负有限数值')
  26. events, lines, minimum = [], [], 'NORMAL'
  27. units = {row['fan']: row.get('unit', '') for row in monitoring.get('flows', [])}
  28. for candidate in monitoring.get('switch_candidates', []):
  29. comparison = candidate.get('comparison', {})
  30. before, after, change = (comparison.get(key) for key in
  31. ('before_value', 'after_value', 'change_percent'))
  32. event = {key: candidate.get(key) for key in ('id', 'from_fan', 'to_fan', 'time_range')}
  33. period = '~'.join(candidate.get('time_range', []))
  34. label = f'{period}({candidate["from_fan"]}→{candidate["to_fan"]})'
  35. flow = {'result_code': None, 'before_value': before, 'after_value': after,
  36. 'change_percent': change, 'reason': comparison.get('reason', '')}
  37. if (all(finite(value) for value in (before, after, change)) and before > 0
  38. and after >= 0 and change >= 0 and not comparison.get('reason')):
  39. flow['result_code'] = classify(change, normal_flow, severe_flow)
  40. if flow['result_code'] == 'ABNORMAL':
  41. verdict = f'达到{number(severe_flow)}%严重超限阈值'
  42. elif flow['result_code'] == 'WARNING':
  43. verdict = f'超过{number(normal_flow)}%一般超限阈值'
  44. else:
  45. verdict = f'未超过{number(normal_flow)}%一般超限阈值'
  46. lines.append(f'{label}:切换前后参考风量{number(before)}→{number(after)} '
  47. f'{units.get(candidate["from_fan"], "")},变化率{number(change)}%,{verdict}。')
  48. else:
  49. lines.append(f'{label}:本次没有可计算变化率的前后风量对照,不据此告警。')
  50. duration = candidate.get('duration_minutes')
  51. bounds = candidate.get('duration_bounds_minutes') or {}
  52. status = candidate.get('duration_status')
  53. has_gaps = candidate.get('duration_has_gaps', False)
  54. interval = {'result_code': None, 'duration_minutes': duration,
  55. 'duration_bounds_minutes': bounds, 'duration_has_gaps': has_gaps,
  56. 'duration_status': status, 'comparison_value_minutes': None}
  57. if finite(duration) and duration >= 0:
  58. # 估算仅以采样下界作为告警依据,缺测/未知及跨阈值范围不能变成告警。
  59. confirmed = duration if status == 'provided' else bounds.get('lower') if status == 'estimated' else None
  60. if (not has_gaps and finite(confirmed) and confirmed >= 0
  61. and (status != 'estimated' or confirmed <= duration)):
  62. interval['comparison_value_minutes'] = confirmed
  63. interval['result_code'] = classify(confirmed, normal_interval, severe_interval)
  64. text = f'{label}:按开停观测估算停启间隔约{number(duration * 60)}秒'
  65. overlap = candidate.get('overlap_seconds')
  66. if finite(overlap) and overlap > 0:
  67. text += f',先启后停的观测重叠约{number(overlap)}秒'
  68. if interval['result_code'] == 'ABNORMAL':
  69. text += f',可靠间隔下界达到{number(severe_interval)}分钟严重超限阈值'
  70. elif interval['result_code'] == 'WARNING':
  71. text += f',可靠间隔下界超过{number(normal_interval)}分钟一般超限阈值'
  72. elif interval['result_code'] == 'NORMAL' and duration <= normal_interval:
  73. text += f',该估算值未超过{number(normal_interval)}分钟阈值'
  74. else:
  75. text += ',采样范围或缺测限制使其不能作为间隔超限依据'
  76. lines.append(text + '。')
  77. else:
  78. lines.append(f'{label}:未取得可配对的停启间隔,不据此告警。')
  79. event.update(flow=flow, interval=interval)
  80. events.append(event)
  81. for item in (flow, interval):
  82. if item['result_code'] and RANK[item['result_code']] > RANK[minimum]:
  83. minimum = item['result_code']
  84. summary = {'ABNORMAL': '监测数据达到严重超限阈值,研判为异常。',
  85. 'WARNING': '监测数据超过一般阈值,研判为预警。',
  86. 'NORMAL': '现有监测数据未形成明确超限告警。'}[minimum]
  87. limitations = ('风量为切换前后各1小时附近最近单点对照,零值按原样保留;'
  88. '开停间隔为采样观测估算,均不代表已核实连续稳定工况。')
  89. if any('物理对应尚未确认' in note for note in monitoring.get('notes', [])):
  90. limitations += '两系统风机按原编号对照,物理对应尚未确认。'
  91. date_range = '至'.join(monitoring.get('range', []))
  92. return {'version': VERSION, 'minimum_result_code': minimum,
  93. 'thresholds': {'normal_flow_percent': normal_flow, 'severe_flow_percent': severe_flow,
  94. 'normal_interval_minutes': normal_interval, 'severe_interval_minutes': severe_interval},
  95. 'events': events, 'summary': summary,
  96. 'reason': '\n'.join([summary, *lines, limitations,
  97. '性能报告作为辅助核对,不抵消以上已确认的监测超限。'] if minimum != 'NORMAL'
  98. else [summary, *lines, limitations]),
  99. 'data_detail': f'监测时段:{date_range}。开停和风量来自本轮固定预处理,共{len(events)}个切换候选,'
  100. f'{sum(row["flow"]["result_code"] is not None for row in events)}组有效风量变化率对照。'}
  101. def resolve(model_decision, assessment):
  102. """保留模型原件;明确监测告警的编码、摘要和正文由固定证据生成。"""
  103. final = deepcopy(model_decision)
  104. minimum = assessment['minimum_result_code']
  105. model_code = model_decision['judgment']['resultCode']
  106. if minimum != 'NORMAL':
  107. if RANK.get(model_code, -1) <= RANK[minimum]:
  108. # 同时换掉矛盾摘要/正文,不能仅改编码仍保留“运行正常”的旧解释。
  109. final['dataDetail'] = assessment['data_detail'] + '性能报告及模型辅助分析随本轮原始输入留档。'
  110. final['judgment'].update(resultCode=minimum, result=assessment['summary'], reason=assessment['reason'])
  111. else:
  112. # 性能有清晰证据时仍可提高等级,不能被较低的监测等级覆盖。
  113. final['judgment']['result'] = assessment['summary'] + model_decision['judgment']['result']
  114. final['judgment']['reason'] = assessment['reason'] + '\n\n模型辅助分析:\n' + model_decision['judgment']['reason']
  115. return final, {'version': VERSION, 'minimum_result_code': minimum,
  116. 'model_result_code': model_code, 'final_result_code': final['judgment']['resultCode'],
  117. 'monitoring_enforced': minimum != 'NORMAL',
  118. 'model_decision': model_decision, 'final_decision': final}