faults.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. # -*- coding: utf-8 -*-
  2. """A翼 故障分析件 (windscada M8; SOP 5.1/5.2/5.3 锚定).
  3. 轴:
  4. F1 停机事件化: 10min 五态序列 → 连续停机段 (≥1h) → stop_events.parquet; 调度令段另标;
  5. F2 停机归因: 段起点 ±30min 窗内首发报警码 = 归因码 (无码=无记录停机); 帕累托按"停机时长"加权 (SOP 5.2 禁 raw 计数单口径);
  6. F3 MTBF/MDT (GB/Z 178 口径族): SCADA 全停机口径 (无工单不可拆计划/故障 → 【参考级】+口径声明, SOP 5.1 定论须台账);
  7. F4 季节性: 报警族×月 (2025-01→2026-07, 19个月跨2冬1夏; 显著性未做χ²前=参考).
  8. 边界: 事件时长=状态段口径 (非报警闩锁口径); 停机含计划检修不可分; 调度令停机单列不入 MTBF 分子."""
  9. import numpy as np, pandas as pd, pathlib
  10. from src.windscada.config import farm
  11. from app_ETL.app_ETL_guanlan.api import load_10min
  12. from .curtail import classify
  13. def build_stop_events(cfg=None):
  14. cfg = cfg or farm()
  15. rows = []
  16. al = pd.read_parquet(pathlib.Path(cfg['store']) / 'alarms.parquet')
  17. for t in cfg['turbines']:
  18. try:
  19. d = load_10min(t, cfg, groups=['A.功率', 'A.风况', 'A.转速', 'A.状态']).sort_values('ts').reset_index(drop=True)
  20. d['state'] = classify(d, cfg['rated_kw'])
  21. stop = d['state'].eq('停机').astype(int)
  22. seg = (stop.diff() != 0).cumsum()
  23. alt = al[al.turbine == t]
  24. for _, g in d[stop == 1].groupby(seg[stop == 1]):
  25. if len(g) < 6: continue # ≥1h
  26. t0, t1 = g.ts.iloc[0], g.ts.iloc[-1] + pd.Timedelta(minutes=10)
  27. dur_h = (t1 - t0).total_seconds() / 3600
  28. # 两级归因 (2026-08-26 外审逮"无记录38%"→成因调查后改):
  29. # T1 紧窗 ±30min 首发码 = 跳机型停机的直接归因 (高置信)
  30. # T2 宽窗 前6h 首发码 = 长停型 (待料/待船窗口: 报警首发后进入等待, 期间无新报警)
  31. # 实测: 紧窗未命中的段里 53% 在前6h内有报警; 中位段长2.5h, 最长174h
  32. win = alt[(alt.t_on >= t0 - pd.Timedelta(minutes=30)) & (alt.t_on <= t0 + pd.Timedelta(minutes=30))]
  33. cause_code, cause_n, tier = '', 0, ''
  34. if len(win):
  35. first = win.sort_values('t_on').iloc[0]
  36. cause_code = str(first.code); cause_n = len(win); tier = 'T1紧窗'
  37. else:
  38. w2 = alt[(alt.t_on >= t0 - pd.Timedelta(hours=6)) & (alt.t_on <= t0 + pd.Timedelta(minutes=30))]
  39. if len(w2):
  40. last = w2.sort_values('t_on').iloc[-1] # 取最近一条 (最可能是触发)
  41. cause_code = str(last.code); cause_n = len(w2); tier = 'T2宽窗(前6h)'
  42. # 调度令判别: 窗内含 1007/1008
  43. disp = bool(len(win) and win.code.isin(['1007', '1008']).any())
  44. rows.append(dict(turbine=t, t_start=t0, t_end=t1, dur_h=round(dur_h, 2),
  45. cause_code=cause_code, n_alarm_win=cause_n, tier=tier, dispatch=disp))
  46. print(t, flush=True)
  47. except Exception as e:
  48. print(t, 'ERR', str(e)[:60], flush=True)
  49. df = pd.DataFrame(rows)
  50. df.to_parquet(pathlib.Path(cfg['store']) / 'stop_events.parquet')
  51. print('→ stop_events.parquet', df.shape)
  52. def _win_filter(df, months):
  53. m = df['t_start'].dt.to_period('M').astype(str)
  54. return df[m.isin(months)]
  55. def mtbf_summary(months, cfg=None):
  56. """GB/Z 178 口径族, SCADA 全停机口径【参考级】: 无工单不可拆计划/故障停机."""
  57. cfg = cfg or farm()
  58. ev = pd.read_parquet(pathlib.Path(cfg['store']) / 'stop_events.parquet')
  59. ev = _win_filter(ev, months)
  60. fault = ev[~ev.dispatch]
  61. n_t = len(cfg['turbines'])
  62. # ★分母必须用实测台时, 不能用理想日历 (2026-08-28 自审逮)。
  63. # 原式 len(months)*30.4*24*n_t 把残月当整月、把数据缺口当有数据:
  64. # 2026-01~07 理想日历 194,050 台时 vs 实测 165,475 台时 (差 17%) — 2026-07 只有 6 天,
  65. # 另有几个月覆盖 90~96%。分子虚高而分母(事件数)是实测的 ⇒ MTBO 虚高 18% (239h 实为 203h)。
  66. # 与故障统计的残月归一是同一类问题, 那里已修, 这里遗漏。
  67. cal_h = None
  68. try:
  69. lm = pd.read_parquet(pathlib.Path(cfg['store']) / 'loss_monthly.parquet')
  70. lm = lm[lm.month.astype(str).isin([str(m) for m in months])]
  71. if len(lm):
  72. cal_h = float(lm['rows_'].sum()) / 6 # 10min 行 → 台时
  73. except Exception:
  74. pass
  75. cal_src = '实测台时'
  76. if not cal_h:
  77. cal_h = len(months) * 30.4 * 24 * n_t
  78. cal_src = '理想日历(实测台时不可得)'
  79. stop_h = float(fault.dur_h.sum()); disp_h = float(ev[ev.dispatch].dur_h.sum())
  80. n_ev = int(len(fault))
  81. mtbf = (cal_h - stop_h - disp_h) / max(n_ev, 1)
  82. mdt = stop_h / max(n_ev, 1)
  83. # 剔除口径: 去 1020就地模式(检修代理) 与 1161切入边界段(判据风SecAnemo vs 控制器风的边界争议, 非故障)
  84. core = fault[~fault.cause_code.isin(['1020', '1161'])]
  85. core_h = float(core.dur_h.sum()); n_core = int(len(core))
  86. mtbf2 = (cal_h - stop_h - disp_h) / max(n_core, 1)
  87. per_t = fault.groupby('turbine').agg(次=('dur_h', 'size'), 时长h=('dur_h', 'sum')).sort_values('时长h', ascending=False)
  88. n_drop = n_ev - n_core
  89. return dict(口径=('平均停机间隔(MTBO口径): 停机=≥1h停机状态段(不含调度令/低风待机), 含计划检修不可拆 → '
  90. '**不可与行业MTBF(纯故障口径)比**; 分母=窗内实测台时(非理想日历, 残月不按整月计); 真MTBF须2025-26工单定性故障【参考级】'),
  91. 停机事件数=n_ev, 停机总时长h=round(stop_h, 0), 调度令时长h=round(disp_h, 0),
  92. 统计台时=round(cal_h, 0), 台时口径=cal_src,
  93. MTBF_h=round(mtbf, 0), MDT_h=round(mdt, 1),
  94. 剔除口径=dict(说明='剔1020就地(检修代理)+1161切入边界段', 事件数=n_core, 时长h=round(core_h, 0), MTBF_h=round(mtbf2, 0),
  95. 次数变化=f'{n_ev}→{n_core} (剔{n_drop}起, {n_drop/max(n_ev,1):.0%}) — 间隔升高主因=分母减小, 非可靠性变好'),
  96. 台级top=[dict(t=i, 次=int(r.次), 时长h=round(float(r.时长h), 0)) for i, r in per_t.head(8).iterrows()])
  97. def stop_pareto(months, cfg=None, topn=12):
  98. """停机归因帕累托 (按停机时长加权, 双口径列次数)."""
  99. cfg = cfg or farm()
  100. from src.windscada import i18n
  101. ev = pd.read_parquet(pathlib.Path(cfg['store']) / 'stop_events.parquet')
  102. ev = _win_filter(ev, months)
  103. ev = ev[~ev.dispatch]
  104. ev['cause'] = ev.cause_code.replace('', '无归因停机(前6h无报警; 实测98%段前72h内有报警=长停远窗, 非链路静默)')
  105. g = ev.groupby('cause').agg(时长h=('dur_h', 'sum'), 次=('dur_h', 'size')).sort_values('时长h', ascending=False)
  106. out = []
  107. for code, r in g.head(topn).iterrows():
  108. label = code if '无记录' in code else f"{code} {i18n.alarm_label(code)}"
  109. out.append(dict(k=label, h=round(float(r.时长h), 0), n=int(r.次)))
  110. return out
  111. def seasonal(cfg=None, fams=None):
  112. """报警族×月 季节矩阵 (全程 19 个月; 参考级)."""
  113. cfg = cfg or farm()
  114. from src.windscada import i18n
  115. al = pd.read_parquet(pathlib.Path(cfg['store']) / 'alarms.parquet')
  116. al['month'] = al.t_on.dt.to_period('M').astype(str)
  117. top_codes = al.groupby('code').size().sort_values(ascending=False).head(8).index
  118. ms = sorted(al.month.unique())
  119. out = []
  120. for c in top_codes:
  121. g = al[al.code == c].groupby('month').size().reindex(ms).fillna(0)
  122. out.append(dict(name=f"{c} {i18n.alarm_label(c)[:14]}", months=ms, vals=[int(v) for v in g]))
  123. return out