yaw.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. # -*- coding: utf-8 -*-
  2. """B翼 偏航系统 (windscada M6c v3). 判据蓝本=偏航分册四维度 + 报警族按码归维 + 真动态轴.
  3. ★2026-08-25 边界订正 (旧版错误声明"对风差无模拟通道/泵压无数据轴"): 那是只看 1min 转发层与
  4. 契约内 134 列得出的, **未扫全 10min 原生层 598 列**。原生层实有且全场活:
  5. tur_wtc_NacelPos_* 机舱位置 (0-360°) → 对风误差可重构 err=wrap(风向−机舱位置)
  6. tur_wtc_ScYawPos_mean 偏航累计位置 (-380~754) → 扭缆角轴
  7. flg_wtc_YawStat_endvalue 命令位 ('|'静止/'>'顺/'<'逆)
  8. flg_wtc_ScYawUnw_counts 解缆动作
  9. prs_wtc_YawPmPrs_* 偏航泵压 (小站泵压)
  10. 1min 转发层的 cabin_position/twisted_cable_angle/yaw_error1 确为全场 38/38 死零 (转发层死列≠信号死)。
  11. 维度:
  12. D1 偏航轴承与润滑: 润滑泵时长×fleet + 柱塞出脂反馈 + 10201
  13. D2 驱动装置: 偏航动作次数/行程 + 10100/10125/10105
  14. D3 液压制动: 卡钳压水平/带宽 + 泵压 + 10113/7122
  15. D4 对风控制: 静态偏置(符号均值) + 动态散布σ(清洗后) + 报警轴 (10400/10401 + 风传感族)
  16. A类 数据质量: 机舱位置通道归零/拖偏 (判据=corr(err,机舱位置) 负相关 ∧ |err|>45°行占比≥2%)
  17. 边界 (skill yaw-dynamic 铁律): 10min 层测不到**死区/偏航速率/滞环** (需≤30s), 该三项 INSUFFICIENT;
  18. err 为 vane 口径 → 相对/上限, 非可回收 MWh; 行程=活动量代理非真磨损量。"""
  19. import numpy as np, pandas as pd, pathlib
  20. from src.windscada.config import farm
  21. from app_ETL.app_ETL_guanlan.api import load_10min
  22. DIMS = ['偏航轴承与润滑', '驱动装置', '液压制动', '对风控制']
  23. FAM = {
  24. 'D1润滑不可能': ['10201'],
  25. 'D2过热': ['10100'], 'D2变流器警告': ['10125'], 'D2解缆': ['10105'],
  26. 'D3制动液压错误': ['10113'], 'D3油位低': ['7122'],
  27. 'D4偏航失败': ['10400', '10401'],
  28. 'D4风传感': ['8108', '8110', '8184', '8185', '8186', '8187', '8171'],
  29. }
  30. def build_store(cfg=None, span=None, write=True):
  31. """偏航面登记件。`span=(起, 止)` 给了就按**所选时间窗**(含两端)重算(用户令 2026-09-21)。
  32. span 模式下数据源换成 `slim10min` 窄仓(同一份 `load_10min` 的列子集 ⇒ 同列同值),
  33. 并 **不写盘**(`write=False`):按窗重算是"服务时算给页面看",不能覆盖正式产物
  34. `yaw_daily.parquet`(那是"数据末端往前 90 天"的口径,缺了它其它消费者会变味)。
  35. """
  36. cfg = cfg or farm()
  37. rows = []
  38. for t in cfg['turbines']:
  39. rec = dict(turbine=t)
  40. try:
  41. if span:
  42. from app_ETL.app_ETL_guanlan.api import slim
  43. cur = slim.load(t, cfg, span=span,
  44. columns=['grd_wtc_ActPower_mean', 'prs_wtc_YawPress_mean',
  45. 'prs_wtc_YawPress_max', 'prs_wtc_YawPress_min',
  46. 'dot_wtc_YawLubPu_timeon', 'din_wtc_Y1LubPis_timeon',
  47. 'flg_wtc_ScYawOpe_counts', 'cnt_wtc_WindFauS_endvalue',
  48. 'cnt_wtc_WindFauT_endvalue'])
  49. else:
  50. d = load_10min(t, cfg, groups=['A.功率', 'B.偏航', 'B.润滑液压'])
  51. cur = d[d.ts >= str(d.ts.max() - pd.Timedelta(days=90))[:10]]
  52. op = cur[cur['grd_wtc_ActPower_mean'] > 100]
  53. byday = cur.groupby(cur.ts.dt.date)
  54. rec['yawpress_med'] = float(op['prs_wtc_YawPress_mean'].median())
  55. rec['yawpress_band'] = float((op['prs_wtc_YawPress_max'] - op['prs_wtc_YawPress_min']).median())
  56. rec['yawlub_s_day'] = float(byday['dot_wtc_YawLubPu_timeon'].sum().mean())
  57. rec['y1pis_s_day'] = float(byday['din_wtc_Y1LubPis_timeon'].sum().mean())
  58. rec['scyaw_cnt_day'] = float(byday['flg_wtc_ScYawOpe_counts'].sum().mean())
  59. for c, k in (('cnt_wtc_WindFauS_endvalue', 'windfau_s'), ('cnt_wtc_WindFauT_endvalue', 'windfau_t')):
  60. v = cur[c].dropna()
  61. rec[k] = float(v.iloc[-1] - v.iloc[0]) if len(v) > 2 else np.nan # 计数器窗内增量
  62. rec['n_op'] = int(len(op))
  63. except Exception as e:
  64. rec['err10'] = str(e)[:60]
  65. rows.append(rec)
  66. if write:
  67. print(t, flush=True)
  68. df = pd.DataFrame(rows)
  69. al = pd.read_parquet(pathlib.Path(cfg['store']) / 'alarms.parquet')
  70. if span: # ★按窗:报警族计数也只看窗内(原口径 = 2026 年全年)
  71. a_w = al[(al['t_on'] >= str(span[0])) & (al['t_on'] <= str(span[1]) + ' 23:59:59')]
  72. else:
  73. a_w = al[al['t_on'].dt.year == 2026]
  74. for name, codes in FAM.items():
  75. cnt = a_w[a_w.code.isin(codes)].groupby('turbine').size()
  76. df[name] = df['turbine'].map(cnt).fillna(0).astype(int)
  77. if write:
  78. st = pathlib.Path(cfg['store']); df.to_parquet(st / 'yaw_daily.parquet')
  79. print('→ yaw_daily.parquet', df.shape)
  80. return df
  81. def registry(cfg=None, span=None):
  82. """偏航面判级。`span=(起, 止)` 给了就按**所选时间窗**重算(判据/阈值不动,只换数据切片)。"""
  83. cfg = cfg or farm()
  84. if span:
  85. df = build_store(cfg, span=span, write=False)
  86. else:
  87. df = pd.read_parquet(pathlib.Path(cfg['store']) / 'yaw_daily.parquet')
  88. ep = pathlib.Path(cfg['store']) / 'yaw_err_clean.parquet'
  89. ERR = pd.read_parquet(ep) if ep.exists() else None
  90. med = df.median(numeric_only=True)
  91. rows, detail = [], {}
  92. for _, r in df.iterrows():
  93. t = r['turbine']; d = dict(维度={}, 依据={})
  94. # D1 轴承润滑: 泵时长×fleet + 柱塞出脂反馈 + 10201
  95. lubx = r['yawlub_s_day'] / max(med['yawlub_s_day'], 1e-9) if r['yawlub_s_day'] == r['yawlub_s_day'] else np.nan
  96. pisx = r['y1pis_s_day'] / max(med['y1pis_s_day'], 1e-9) if r['y1pis_s_day'] == r['y1pis_s_day'] else np.nan
  97. st1 = '不可判'
  98. if lubx == lubx:
  99. st1 = '报警' if (lubx >= 1.8 or r['D1润滑不可能'] >= 5 or (pisx == pisx and pisx <= 0.3 and lubx >= 1.0)) else \
  100. '良好' if (lubx >= 1.4 or r['D1润滑不可能'] >= 1) else '优秀'
  101. d['依据'][DIMS[0]] = (f"润滑泵 {r['yawlub_s_day']:.0f}s/日 ({lubx:.2f}×fleet), 柱塞反馈 {r['y1pis_s_day']:.0f}s/日 ({pisx:.2f}×), "
  102. f"润滑不可能报警{int(r['D1润滑不可能'])}")
  103. # D2 驱动: 动作次数×fleet + 过热/变流器/解缆报警
  104. mvx = r['scyaw_cnt_day'] / max(med['scyaw_cnt_day'], 1e-9) if r['scyaw_cnt_day'] == r['scyaw_cnt_day'] else np.nan
  105. st2 = '不可判'
  106. if mvx == mvx:
  107. st2 = '报警' if (r['D2过热'] >= 5 or r['D2变流器警告'] >= 10 or mvx >= 1.8) else \
  108. '良好' if (r['D2过热'] >= 1 or r['D2变流器警告'] >= 3 or mvx >= 1.4 or r['D2解缆'] >= med['D2解缆'] * 2 + 5) else '优秀'
  109. d['依据'][DIMS[1]] = (f"偏航动作 {r['scyaw_cnt_day']:.0f}次/日 ({mvx:.2f}×fleet), 解缆{int(r['D2解缆'])}次, "
  110. f"过热{int(r['D2过热'])}, 变流器警告{int(r['D2变流器警告'])}")
  111. # D3 液压制动: 卡钳压水平/带宽 + 制动液压错误/油位低
  112. px = r['yawpress_med']; bandx = r['yawpress_band'] / max(med['yawpress_band'], 1e-9)
  113. st3 = '报警' if (r['D3制动液压错误'] >= 3 or r['D3油位低'] >= 3 or bandx >= 2.0) else \
  114. '良好' if (r['D3制动液压错误'] >= 1 or r['D3油位低'] >= 1 or bandx >= 1.4 or abs(px - med['yawpress_med']) > 15) else '优秀'
  115. d['依据'][DIMS[2]] = (f"卡钳压中位 {px:.0f} ({px - med['yawpress_med']:+.0f} vs fleet), 带宽 {r['yawpress_band']:.0f} ({bandx:.2f}×), "
  116. f"制动液压错误{int(r['D3制动液压错误'])}, 油位低{int(r['D3油位低'])}")
  117. # D4 对风: 真对风误差轴 (机舱位置重构, 清洗后) + 报警轴
  118. wf = (r['windfau_s'] if r['windfau_s'] == r['windfau_s'] else 0) + (r['windfau_t'] if r['windfau_t'] == r['windfau_t'] else 0)
  119. e = ERR.loc[t] if ERR is not None and t in ERR.index else None
  120. st4 = '报警' if (r['D4偏航失败'] >= 10 or r['D4风传感'] >= 30 or wf >= 100) else \
  121. '良好' if (r['D4偏航失败'] >= 2 or r['D4风传感'] >= 5 or wf >= 30) else '优秀'
  122. etxt = '对风误差轴不可用'
  123. if e is not None:
  124. if abs(e['bias_clean']) >= 2.0:
  125. st4 = '报警'; etxt = f"静态偏置 {e['bias_clean']:+.2f}° ≥2° → 零位候选(转静态偏航专项)"
  126. else:
  127. etxt = (f"静态偏置 {e['bias_clean']:+.2f}° (全场|中位|{ERR['bias_clean'].abs().median():.2f}), "
  128. f"动态散布σ {e['sd_clean']:.2f}° (全场{ERR['sd_clean'].median():.2f}), |误差|>10°占时 {e['gt10_clean']:.1%}")
  129. d['依据'][DIMS[3]] = (f"{etxt}; 偏航失败{int(r['D4偏航失败'])}, 风传感报警{int(r['D4风传感'])}, 计数增量{wf:.0f} "
  130. f"[死区/速率/滞环: 10min层不可测=INSUFFICIENT]")
  131. # A类 数据质量 (不入四维评级, 单列: 它污染一切依赖机舱位置的分析)
  132. if e is not None and e['drop'] >= 0.02 and e['corr_pos'] <= -0.15:
  133. d['数据质量'] = (f"A类·机舱位置通道归零/拖偏: |误差|>45°行占 {e['drop']:.1%}, "
  134. f"corr(误差,机舱位置)={e['corr_pos']:+.2f} (健康台≈0) → 偏航编码器/PLC读回现场核; "
  135. f"清洗前σ {e['sd_raw']:.1f}° 清洗后 {e['sd_clean']:.1f}°")
  136. for dim, st in zip(DIMS, (st1, st2, st3, st4)):
  137. d['维度'][dim] = st
  138. judged = [v for v in d['维度'].values() if v != '不可判']
  139. detail_extra = d.get('数据质量')
  140. order = {'报警': 0, '良好': 1, '优秀': 2}
  141. rows.append(dict(机组=t, 系统级=sorted(judged, key=lambda x: order[x])[0] if judged else '不可判',
  142. **d['维度'], 数据质量=detail_extra or ''))
  143. detail[t] = d
  144. return pd.DataFrame(rows).sort_values('机组').reset_index(drop=True), detail