vib_impact.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. # -*- coding: utf-8 -*-
  2. """冲击轴 (时域六窗层) 分型 → 六枚举定级. 库层单一实现: windcms 插件与 fusion 脚本都调这里 (落库≠接上 的教训: 生产路径必须走同一函数).
  3. 口径 (2026-08-23 如东 M5 实证, 见 skill vibration-spectral §标量指标的劣化分型 / B 类):
  4. - 基线段 = 末窗之前; 末段 = 最后一个窗 (取"末两窗"会把 8/7 阶跃稀释成间歇尖峰, 实测逮);
  5. - 分型由 sop.discriminators.vib_scalar_typology 给: 持续恶化 / 间歇尖峰 / 稳定高位 / 正常;
  6. - 定级: 持续恶化→候选·新发 (B1 阶跃); 稳定高位→候选 (B2); 间歇尖峰 且 剔共模后高值日≥3→候选 (B3 反复发作), 否则→参考; 正常→正常;
  7. - 共模日 (台风/电网等外部激励): 当日超自身阈 (max(4×自身基线, 15)) 的台数 ≥ max(6, 15% 台数) ⇒ 该日不计入个体高值日;
  8. 全部高值日都落在共模日 ⇒ 个体证据为零 → 参考 (不作设备判).
  9. ★以上阈值 (8×/2.5×/15 m/s²/共模台数) 由如东主轴承 Peak 标定, 跨测点/跨场用前须复标 [暂行 n=1 场].
  10. """
  11. import numpy as np
  12. import pandas as pd
  13. LEVEL_BY_TYPE = {'持续恶化': '候选·新发', '稳定高位': '候选', '正常': '正常'}
  14. VALID_SENSORS = ('Main_bearing_front', 'Main_bearing_rear') # 阈值标定范围; 其他测点产出但标 _caliber 未标定
  15. def impact_axis_typology(scalars, turbine, sensor, meas='Peak', event_windows=None, min_late_days=3):
  16. """scalars: 标量长表 (列 turbine, window, sensor_name, meas_name, trigger_time, scalar_value), 含全场 (fleet 参照与共模日都要全场).
  17. event_windows: 场级外部事件窗 [(start, end, label), ...] (台风等, 须有独立于 CMS 的确认: 公开气象/运行记录; 如东见 findings TYPHOON_COMMON_MODE_20260819),
  18. 窗内日与共模探测日一样不计入个体高值日 (skill: 海上先剔天气; 共模探测器靠"≥6 台超阈"对台风边缘日 (2-4 台超阈) 不敏感, 2026-08-23 实测 25#/29# 被误升 B3).
  19. min_late_days: 末窗有效天数低于此 → 末段不可判 (A1 盲区嫌疑), 只按历史 B3 定级, 禁判"已归位" (2026-08-23 实测 20# 末窗 2 天被判归位→参考, 实为带病进盲区).
  20. 返回 dict(type, level, detail, raw, late_start)."""
  21. from .discriminators import vib_scalar_typology
  22. s = scalars
  23. q = s[(s.sensor_name == sensor) & (s.meas_name == meas)].copy()
  24. if q.empty:
  25. return dict(type='INSUFFICIENT', level='INSUFFICIENT', detail='无标量', raw={}, late_start=None)
  26. q['day'] = pd.to_datetime(q.trigger_time).dt.normalize()
  27. mine = q[q.turbine == turbine]
  28. if mine.empty:
  29. return dict(type='INSUFFICIENT', level='INSUFFICIENT', detail=f'{turbine} 无 {sensor}/{meas} 标量', raw={}, late_start=None)
  30. daily = mine.groupby('day').scalar_value.agg(['max', 'median', 'count'])
  31. wins = sorted(q.window.unique())
  32. late_start = q[q.window == wins[-1]].trigger_time.min().normalize()
  33. fleet_med = float(q.groupby(['turbine', 'day']).scalar_value.median().groupby('turbine').median().median())
  34. r = vib_scalar_typology(daily, baseline_end=late_start, late_start=late_start, fleet_daily_median=fleet_med)
  35. typ = r.get('type', 'INSUFFICIENT')
  36. level = LEVEL_BY_TYPE.get(typ, 'INSUFFICIENT') # 间歇尖峰 的级别在剔共模/事件窗之后再定 (见下), 此处先占位
  37. nums = {k: (round(float(v), 3) if isinstance(v, (int, float)) and np.isfinite(v) else v) for k, v in r.items() if k != '_caliber'}
  38. base = float(r.get('baseline_max', np.nan)) if r.get('baseline_max') is not None else np.nan
  39. # 共模日: 当日超自身阈值的台数 ≥ max(6, 15% 台数)
  40. dm = q.groupby(['turbine', 'day']).scalar_value.max().unstack('turbine')
  41. bases = dm[dm.index < late_start].median() if (dm.index < late_start).any() else dm.median()
  42. thr_t = np.maximum(4 * bases, 15.0)
  43. exceed = (dm >= thr_t).sum(axis=1)
  44. n_turb = int(dm.notna().any().sum())
  45. common_days = set(exceed[exceed >= max(6, int(0.15 * n_turb))].index)
  46. ev_days = set()
  47. for w in (event_windows or []):
  48. a, b = pd.Timestamp(w[0]).normalize(), pd.Timestamp(w[1]).normalize()
  49. ev_days |= set(pd.date_range(a, b))
  50. common_days |= ev_days
  51. hi_days = daily.index[daily['max'] >= max(4 * base, 15.0)] if np.isfinite(base) else []
  52. n_high_raw = int(len(hi_days))
  53. n_high_all = int(sum(1 for d_ in hi_days if d_ not in common_days))
  54. n_common = n_high_raw - n_high_all
  55. late_days = daily[daily.index >= late_start]
  56. n_late = int(len(late_days)); n_late_ev = int(sum(1 for d_ in late_days.index if d_ in ev_days))
  57. hist = ''
  58. # 全场大风日注释 (非闸): rms_200 全场逐日中位 ≥1.1× 全期中位 的日子; 台风日 ≥1.4×, 普通大风日 1.1–1.3× (如东 07-23/24, 08-07)
  59. windy_days = set()
  60. try:
  61. r2 = scalars[(scalars.sensor_name == sensor) & (scalars.meas_name == 'rms_200')].copy()
  62. if len(r2):
  63. r2['day'] = pd.to_datetime(r2.trigger_time).dt.normalize()
  64. fr2 = r2.groupby('day').scalar_value.median(); windy_days = set(fr2[fr2 >= 1.1 * float(fr2.median())].index)
  65. except Exception:
  66. windy_days = set()
  67. n_windy = int(sum(1 for d_ in hi_days if d_ not in common_days and d_ in windy_days))
  68. if typ == '间歇尖峰':
  69. level = '候选' if n_high_all >= 3 else '参考' # B3
  70. if n_high_all >= 3 and n_windy == n_high_all:
  71. level = '参考'
  72. hist += f'; 剔窗后 {n_high_all} 个高值日**全部**为全场大风日 (rms_200 全场中位 ≥1.1×) 且逐日中位未抬 → 尖峰与风激励相关, 按 参考 记基线·下窗跟踪 (若非大风日再发 → 候选)'
  73. elif n_windy:
  74. hist += f'; 剔窗后高值日中 {n_windy} 天为全场大风日 (rms_200 全场中位 ≥1.1×), 尖峰与风激励相关, 候选须下窗复核'
  75. # B3 反复发作: ≥3 个**剔窗后**高值日 (2026-08-23 修: 原按剔窗前计数, 25#/29# 被台风边缘日误升)
  76. # ── B1 阶跃抬高 (skill B 类第一条): 逐日中位 分段比 ≥1.6 且 全场同期对照 ≈1.0; 分段点 = 各窗起始日 (末窗除外), 两段各 ≥7 天 ──
  77. step = _b1_step(q, turbine, daily, wins, ev_days=ev_days)
  78. if step and step.get('gap_shift'):
  79. g = step['gap_shift']
  80. if g.get('reason') == 'gap':
  81. hist += f"; 跨缺口抬升 {g['ratio']}× (分段 {g['split']} 前有 {g['gap_days']} 天无数据, fleet {g['fleet_ratio']}×): 时点不可定, 不作 B1 事件"
  82. else:
  83. hist += f"; 末段局部抬升 {g['ratio']}× @ {g['split']} 但剔事件窗后 {'阶跃后' if g.get('reason') == 'short_post' else '阶跃前'}仅 {g['post_days'] if g.get('reason') == 'short_post' else g['pre_days']} 天 (<{7}), 不足以判 B1 → 待下窗确认"
  84. nums.update(gap_shift_ratio=g['ratio'], gap_shift_split=g['split'], gap_days=g['gap_days'], gap_shift_reason=g.get('reason'))
  85. if step and step.get('ratio') and typ in ('正常', '间歇尖峰', '稳定高位'):
  86. # 阶跃发生在末窗之前时, "末窗 vs 之前"的基线已被阶跃后数据污染 (16# 7/15 阶跃实测漏判) → 以 B1 为准
  87. if step['still_high']:
  88. typ, level = '阶跃抬高', '候选·新发'
  89. if step.get('sparse_tail'):
  90. # 2026-08-24 用户裁: 回落未定 (末7日样本稀) 不挂报警级 → 记基线观察, 下窗再抬即候选·新发
  91. # (16# 实案: 末窗中位/基线 0.93/0.86 已在基线下, 仅因样本稀不能确认回落 — "维持候选·新发"过强)
  92. level = '候选·记基线'
  93. hist += f"; ⚠ 阶跃后末 7 个有数据日仅 {step['tail_records']} 条记录, 回落未定 → 记基线观察 (下窗再抬即候选·新发)"
  94. else:
  95. hist += f"; 曾阶跃 (分段比 {step['ratio']}× @ {step['split']}, fleet {step['fleet_ratio']}×) 但已回落 (末 7 个有数据日 {step['last7_ratio']}× < 回落阈 {1 + 0.5 * (step['ratio'] - 1):.2f}×, {step['tail_records']} 条) → 参考 (记基线, 下窗再抬即候选·新发)"
  96. level = max(level, '参考', key=['INSUFFICIENT', '正常', '参考', '候选·记基线', '候选', '候选·新发', '准定论·预警'].index)
  97. nums.update(b1_split=step['split'], b1_ratio=step['ratio'], b1_fleet_ratio=step['fleet_ratio'], b1_post_days=step['post_days'],
  98. b1_last7_ratio=step.get('last7_ratio'), b1_tail_records=step.get('tail_records'))
  99. if n_late < min_late_days:
  100. # 末窗稀疏 = A1 盲区嫌疑: 末段不可判, 禁"已归位"; 级别只看历史 B3
  101. if n_high_all >= 3:
  102. typ, level = '反复发作(末段不可判)', '候选'
  103. hist = f'; ⚠ 末窗仅 {n_late} 天有记录 (<{min_late_days}, A1 盲区嫌疑) ⇒ 末段不可判、禁判已归位; 历史剔共模后高值日 {n_high_all} 天 ≥3 ⇒ B3 反复发作记候选 + A1 (恢复采集后复判)'
  104. else:
  105. typ, level = 'INSUFFICIENT', 'INSUFFICIENT'
  106. hist = f'; ⚠ 末窗仅 {n_late} 天有记录 (<{min_late_days}, A1 盲区嫌疑) ⇒ 末段不可判; 历史剔共模后高值日 {n_high_all} 天 <3 ⇒ 不可判 (非正常)'
  107. elif typ in ('间歇尖峰', '持续恶化') and n_high_raw and n_high_all == 0:
  108. hist = f'; ⚠ {n_high_raw} 个高值日全部落在全场共模日 (台风/外部激励), 个体证据为零 → 不作设备判'
  109. typ, level = '正常', '参考'
  110. elif typ == '正常' and n_high_all >= 3:
  111. level = '参考'
  112. hist = f'; 历史反复发作 {n_high_all} 天 (末窗前, 已剔共模日 {n_common} 天), 末窗已归位 → 记基线, 查 A1 盲区与外部事件窗 (B3)'
  113. elif n_common:
  114. hist = f'; 已剔全场共模/事件窗日 {n_common} 天'
  115. if typ == '持续恶化' and n_late_ev:
  116. hist += f'; ⚠ 末窗 {n_late} 天中 {n_late_ev} 天落在外部事件窗 (台风), 末段中位倍数含外部放大, 不作损伤演进速率 (起病日须在窗外才算新发)'
  117. cal = '' if sensor in VALID_SENSORS else '; ⚠ 阈值按主轴承 Peak 标定, 本测点未标定 (只作参考)'
  118. detail = (f'末段 = 末窗 {wins[-1]} 起 {late_start.date()}, 逐日 {len(daily)} 天; ' + ', '.join(f'{k}={v}' for k, v in nums.items() if k != 'type')
  119. + f', n_high_days_all={n_high_all}' + ('; 高值日≥3 按 B3 反复发作记候选 (需剔除台风/外部事件窗后复核)' if typ == '间歇尖峰' and n_high_all >= 3 else '') + hist + cal)
  120. return dict(type=typ, level=level, detail=detail, raw=dict(nums, n_high_days_all=n_high_all, n_common_days=n_common, n_late_days=n_late, n_late_days_in_event=n_late_ev, n_windy_high_days=n_windy), late_start=str(late_start.date()))
  121. def _b1_step(q, turbine, daily, wins, ratio_min=1.6, fleet_max=1.2, min_days=7, max_gap_days=5, local_days=30, ev_days=None):
  122. """B1 阶跃 (skill B 类第一条): 逐日试分段点; 本台与全场的 pre/post 逐日中位都只用分段点前后各 local_days 天的**局部窗口**、并剔除外部事件窗日
  123. (全史 pre 会把远期低值混进基线: 如东 23# 1 月窗拉低基线造出 2.28× 假阶跃; 台风日抬 post). 成立: 比≥ratio_min ∧ 全场同分段比≤fleet_max ∧
  124. 剔窗后两侧各≥min_days 天 ∧ 分段点前相邻缺口≤max_gap_days (否则是跨缺口 level shift, 时点不可定 → 只记 gap_shift 注释).
  125. still_high = 末窗 (剔事件窗日, 不足 2 天则全用) 逐日中位 / 阶跃前局部中位 ≥ ratio_min."""
  126. ev = set(ev_days or [])
  127. dd = daily[~daily.index.isin(ev)] if ev else daily
  128. if len(dd) < 2 * min_days:
  129. return None
  130. fm = q.groupby(['turbine', 'day']).scalar_value.median().unstack('turbine')
  131. fleet_daily = fm.median(axis=1)
  132. fleet_daily = fleet_daily[~fleet_daily.index.isin(ev)] if ev else fleet_daily
  133. last_start = q[q.window == wins[-1]].trigger_time.min().normalize()
  134. last_all = daily[daily.index >= last_start]['median']
  135. last = last_all[~last_all.index.isin(ev)] if ev else last_all
  136. if len(last) < 2:
  137. last = last_all
  138. idx = daily.index
  139. best, gap_shift = None, None
  140. for k in range(1, len(idx)):
  141. split = idx[k]
  142. lo, hi = split - pd.Timedelta(days=local_days), split + pd.Timedelta(days=local_days)
  143. pre = dd['median'][(dd.index >= lo) & (dd.index < split)]
  144. post = dd['median'][(dd.index >= split) & (dd.index < hi)]
  145. p0, p1 = float(np.nanmedian(pre)) if len(pre) else np.nan, float(np.nanmedian(post)) if len(post) else np.nan
  146. if not (np.isfinite(p0) and np.isfinite(p1)) or p0 <= 1e-9:
  147. continue
  148. r = p1 / p0
  149. if r < ratio_min:
  150. continue
  151. f_pre = fleet_daily[(fleet_daily.index >= lo) & (fleet_daily.index < split)]; f_post = fleet_daily[(fleet_daily.index >= split) & (fleet_daily.index < hi)]
  152. f0, f1 = float(np.nanmedian(f_pre)) if len(f_pre) else np.nan, float(np.nanmedian(f_post)) if len(f_post) else np.nan
  153. fr = (f1 / f0) if (np.isfinite(f0) and f0 > 1e-9 and np.isfinite(f1)) else np.nan
  154. if not (np.isfinite(fr) and fr <= fleet_max):
  155. continue
  156. gap = (split - idx[k - 1]).days
  157. lr = float(np.nanmedian(last)) / p0 if len(last) else np.nan
  158. # 回落判据: 最后 7 个有数据日 (含事件窗日) 的中位 / 阶跃前局部基线; 回落 = 让回超过阶跃幅度一半 (阈 1+0.5(r-1));
  159. # 这 7 天记录 <5 条 → 样本稀, 回落未定 (16# 8/9-8/11 每日 1-2 条实测), 维持阶跃判; 36# 8/1-8/7 隆起后 8/8-8/11 回到基线 (每日多条) = 真回落
  160. tail = daily.iloc[-7:]
  161. l7 = float(np.nanmedian(tail['median'])) / p0
  162. n_tail = int(tail['count'].sum()) if 'count' in tail else 99
  163. thr_rec = 1.0 + 0.5 * (r - 1.0)
  164. sparse_tail = n_tail < 5
  165. rec = dict(split=str(split.date()), ratio=round(r, 2), fleet_ratio=round(fr, 2), pre_days=int(len(pre)), post_days=int(len(post)), gap_days=int(gap),
  166. last_ratio=(round(lr, 2) if np.isfinite(lr) else None), last7_ratio=round(l7, 2), tail_records=n_tail, sparse_tail=sparse_tail,
  167. still_high=bool(sparse_tail or l7 >= thr_rec))
  168. if gap > max_gap_days or len(pre) < min_days or len(post) < min_days:
  169. rec['reason'] = 'gap' if gap > max_gap_days else ('short_post' if len(post) < min_days else 'short_pre')
  170. if gap_shift is None or r > gap_shift['ratio']:
  171. gap_shift = rec
  172. elif best is None or r > best['ratio']:
  173. best = rec
  174. if best:
  175. best['gap_shift'] = gap_shift
  176. return best or (dict(gap_shift=gap_shift) if gap_shift else None)