powercurve.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. # -*- coding: utf-8 -*-
  2. """A翼 功率曲线机群相对筛查 (windscada M3-A, 2026-08-24).
  3. 纪律 (skill power-curve-ntf + SOP): 机舱风=self_ref ⇒ 绝对达成率 INSUFFICIENT 不产出; 只做同机型机群相对偏差(筛查非定谳);
  4. 前置 = L1 工况门 (只取'正常发电'态: 剥限电命令面+停机); 双风速计一致性作 A 类门 (风速计故障台不进曲线横比).
  5. 自校验锚: 19# 零位 -0.87° (专项确诊, 未回正) ⇒ 部分负荷区效率损失, 相对偏差应为负; 31# 已归零 ⇒ 应近中位.
  6. 边界: 风速=机舱 SecAnemo 单计 (PriAnemo 死); 机群相对口径, 绝对达成率不产出."""
  7. import numpy as np, pandas as pd, pathlib
  8. from src.windscada.config import farm
  9. from app_ETL.app_ETL_guanlan.api import load_10min
  10. from .curtail import classify
  11. BINS = np.arange(3.0, 15.5, 0.5) # 机舱风 bin (m/s); 额定以上桨控吸收, 相对比意义降 → 截 15
  12. MIN_N_BIN = 24 # 每 bin ≥4h
  13. ANEMO_DIV_GATE = 0.08 # 双风速计中位相对差 >8% → 风速计嫌疑, 不进横比
  14. def per_turbine(cfg=None, since='2025-07-01', until='2026-01-01'):
  15. cfg = cfg or farm()
  16. rows, flags = [], {}
  17. for t in cfg['turbines']:
  18. try:
  19. d = load_10min(t, cfg, groups=['A.功率', 'A.风况', 'A.转速'])
  20. except Exception:
  21. flags[t] = '数据缺失'; continue
  22. d = d[(d.ts >= since) & (d.ts < until)]
  23. d['state'] = classify(d, cfg['rated_kw'])
  24. n_all = len(d)
  25. d = d[d['state'] == '正常发电']
  26. ws, p = d['tur_wtc_SecAnemo_mean'], d['grd_wtc_ActPower_mean'] # 风速源=SecAnemo (PriAnemo 全场恒0.01 死, 数据仲裁 2026-08-24); 单风速计口径
  27. if ws.nunique() <= 2:
  28. flags[t] = '风速计恒值 (A类, 不进横比)'; continue
  29. if len(d) < 500:
  30. flags[t] = flags.get(t, '') + f' 干净行不足({len(d)})'
  31. continue
  32. m12 = d[d['grd_wtc_ActPower_mean'].between(1000, 2000)]
  33. ws12 = float(m12['tur_wtc_SecAnemo_mean'].median()) if len(m12) >= 200 else np.nan
  34. b = pd.cut(ws, BINS)
  35. g = d.groupby(b, observed=True).agg(p=('grd_wtc_ActPower_mean', 'median'), n=('grd_wtc_ActPower_mean', 'size'))
  36. g = g[g['n'] >= MIN_N_BIN]
  37. for iv, r in g.iterrows():
  38. rows.append(dict(turbine=t, bin=float(iv.mid), p=float(r['p']), n=int(r['n']), ws12=ws12))
  39. pc = pd.DataFrame(rows)
  40. fleet = pc.groupby('bin')['p'].median().rename('fleet_p')
  41. pc = pc.join(fleet, on='bin')
  42. pc['dev'] = pc['p'] / pc['fleet_p'] - 1
  43. out = pc.groupby('turbine').apply(lambda x: float(np.average(x['dev'], weights=x['n'] * x['fleet_p'])), include_groups=False).rename('dev_w').reset_index()
  44. ws12 = pc.groupby('turbine')['ws12'].first()
  45. out = out.join(ws12.rename('ws12'), on='turbine')
  46. out['ws_bias'] = out['ws12'] - out['ws12'].median() # 同功率段(1-2MW)风速反演: 偏高→曲线假低 (03# +0.58m/s 解释其-14.7% 实逮)
  47. out['rank'] = out['dev_w'].rank(ascending=True).astype(int)
  48. def verdict(r):
  49. if r['dev_w'] < -0.03:
  50. if r['ws_bias'] == r['ws_bias'] and r['ws_bias'] > 0.3:
  51. return '风速计偏高候选(A类)'
  52. return '欠发候选(筛查级)'
  53. if r['dev_w'] > 0.05 and r['ws_bias'] == r['ws_bias'] and r['ws_bias'] < -0.3:
  54. return '风速计偏低候选(A类)'
  55. return '—'
  56. out['判别'] = out.apply(verdict, axis=1)
  57. for t, f in flags.items():
  58. out.loc[out.turbine == t, '判别'] = f'A类: {f}'
  59. return out.sort_values('dev_w'), flags, pc
  60. def store(cfg=None, **kw):
  61. cfg = cfg or farm()
  62. out, flags, pc = per_turbine(cfg, **kw)
  63. st = pathlib.Path(cfg['store']); st.mkdir(parents=True, exist_ok=True)
  64. out.to_parquet(st / 'powercurve_dev.parquet'); pc.to_parquet(st / 'powercurve_bins.parquet')
  65. return out, flags
  66. def attribution(cfg=None, since='2025-07-01', until='2026-01-01'):
  67. """欠发归因路由 (筛查级): 对曲线非常态台做机制交叉.
  68. 轴: ①桨角挂高 (1-2MW 档 PitcPosA 中位 ×fleet, 挂高=欠发经典机制) ②跨域线索 (温度/变桨/偏航登记簿由调用方并读).
  69. A类(风速计偏置)直接路由校风, 不入欠发。窗口与判别窗一致 (2025-H2)."""
  70. from src.windscada.config import farm as _farm
  71. from app_ETL.app_ETL_guanlan.api import load_10min
  72. cfg = cfg or _farm()
  73. rows = []
  74. for t in cfg['turbines']:
  75. try:
  76. d = load_10min(t, cfg, groups=['A.功率', 'B.变桨'])
  77. d = d[(d.ts >= since) & (d.ts < until)]
  78. band = d[(d['grd_wtc_ActPower_mean'] >= 1000) & (d['grd_wtc_ActPower_mean'] <= 2000)]
  79. rows.append(dict(turbine=t, pitch_mid=float(band['tur_wtc_PitcPosA_mean'].median()), n=len(band)))
  80. except Exception as e:
  81. rows.append(dict(turbine=t, err=str(e)[:50]))
  82. print(t, flush=True)
  83. df = pd.DataFrame(rows)
  84. med = df['pitch_mid'].median()
  85. df['pitch_dev'] = df['pitch_mid'] - med
  86. df.to_parquet(pathlib.Path(cfg['store']) / 'underperf_pitchmid.parquet')
  87. print('→ underperf_pitchmid.parquet', df.shape)
  88. return df