curves.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. # -*- coding: utf-8 -*-
  2. """A翼 特性曲线多镜头件 (windscada M11; SOP §4.6c 正本 + skill curve-diagnostic).
  3. 镜头 (X 轴优先功率=直测干净量, 机舱风只作形状/相对):
  4. L1 风速-功率 功率曲线/欠发/限电平台
  5. L2 功率-桨距 控制律/削峰/per机偏离 (+三叶互比: 集距/不平衡)
  6. L3 功率-发电机转速 转速饱和点/降功率型
  7. L4 转矩-转速 T=P/ω_gen, Kω² 跟踪
  8. L5 风速-风轮转速 TSR λ=ω_r·R/v, Region-2 跟踪
  9. L6 功率-Cp 气动效率; ★Betz 0.593 硬闸: 超限=测风读低不是气动神机
  10. L7 转速比 gen/rot 联轴器/传动链/编码器 (铭牌齿比 119.752)
  11. 铁律 (§4.6c): ①列 liveness 先验(nuniq>1 ∧ 有方差 ∧ stuck_frac<0.5 ∧ 物理域) ②X轴选干净参考量
  12. ③机舱风 self-ref → 只判形状/相对, 绝对达成率须现场测风 ④per机异常必算同型机群残差(禁眼估)
  13. 前置: 限电剥离 (只用正常发电态行, SOP §4.3); 窗=2025-H2 (与曲线判别窗一致)."""
  14. import numpy as np, pandas as pd, pathlib
  15. from src.windscada.config import farm
  16. from app_ETL.app_ETL_guanlan.api import load_10min
  17. from .curtail import classify
  18. WIN = ('2025-07-01', '2026-01-01')
  19. R_ROTOR = 65.0 # SWT-4.0-130 叶轮半径 m
  20. A_ROTOR = np.pi * R_ROTOR ** 2 # 13273 m²
  21. GEAR_I = 119.752 # 铭牌齿比 (cms-tcm-swt4000)
  22. BETZ = 0.593
  23. WSB = np.arange(3.0, 15.5, 0.5) # 风速档
  24. PWB = np.arange(0, 4250, 250) # 功率档
  25. GRB = np.arange(600, 1760, 40) # 发电机转速档
  26. # 七镜头真正取用的列(slim10min 窄仓里必须有这些;缺列 ⇒ 该列 liveness 判否,不造数)
  27. CURVE_COLS = ['grd_wtc_ActPower_mean', 'tur_wtc_PowerRef_endvalue', 'tur_wtc_SecAnemo_mean',
  28. 'tur_wtc_GenRpm_mean', 'tur_wtc_MainSRpm_mean', 'tur_wtc_PitcPosA_mean',
  29. 'tur_wtc_PitcPosB_mean', 'tur_wtc_PitcPosC_mean', 'tmp_wtc_AmbieTmp_mean']
  30. def _liveness(sr, lo=None, hi=None):
  31. s = sr.dropna()
  32. if len(s) < 100: return False, '样本<100'
  33. nu = s.nunique()
  34. if nu <= 2: return False, f'恒值(nuniq={nu})'
  35. stuck = float(s.value_counts(normalize=True).iloc[0])
  36. if stuck > 0.5: return False, f'卡值{stuck:.0%}'
  37. if lo is not None and (s.median() < lo or s.median() > hi): return False, f'物理域外(中位{s.median():.1f})'
  38. return True, f'活(nuniq={nu}, 卡值{stuck:.0%})'
  39. def build_store(cfg=None, span=None, write=True):
  40. """七镜头分箱件。`span=(起, 止)` 给了就按**所选时间窗**(含两端)重算(用户令 2026-09-21)。
  41. span 模式走 `slim10min` 窄仓(同一份 `load_10min` 的列子集 ⇒ 同列同值),并且 **不写盘**:
  42. 按窗重算是服务时算给页面看的,不能覆盖正式产物 `curve_lenses.parquet`
  43. (它的窗是"限电前干净判别窗 2025-07~2026-01",另有消费者 `physics_check`)。
  44. """
  45. cfg = cfg or farm()
  46. rows, live_log = [], []
  47. for t in cfg['turbines']:
  48. if span:
  49. from app_ETL.app_ETL_guanlan.api import slim
  50. d = slim.load(t, cfg, span=span, columns=CURVE_COLS)
  51. else:
  52. d = load_10min(t, cfg, groups=['A.功率', 'A.风况', 'A.转速', 'B.变桨', 'B.温度NBM'])
  53. d = d[(d.ts >= WIN[0]) & (d.ts < WIN[1])]
  54. d['state'] = classify(d, cfg['rated_kw'])
  55. d = d[d.state == '正常发电'] # ★限电剥离前置
  56. # ① 列 liveness 先验
  57. L = {}
  58. for c, dom in (('grd_wtc_ActPower_mean', (0, 4200)), ('tur_wtc_SecAnemo_mean', (2, 20)),
  59. ('tur_wtc_GenRpm_mean', (500, 1800)), ('tur_wtc_MainSRpm_mean', (2, 20)),
  60. ('tur_wtc_PitcPosA_mean', (-5, 95)), ('tur_wtc_PitcPosB_mean', (-5, 95)),
  61. ('tur_wtc_PitcPosC_mean', (-5, 95)), ('tmp_wtc_AmbieTmp_mean', (-20, 45))):
  62. ok, why = _liveness(d[c], *dom) if c in d.columns else (False, '无此列')
  63. L[c] = ok; live_log.append(dict(turbine=t, col=c, ok=ok, why=why))
  64. P = d['grd_wtc_ActPower_mean']; V = d['tur_wtc_SecAnemo_mean']
  65. # 空气密度 (环温 + 标准气压; 海上无气压列 → 口径声明)
  66. rho = 101325.0 / (287.05 * (d['tmp_wtc_AmbieTmp_mean'] + 273.15)) if L['tmp_wtc_AmbieTmp_mean'] else pd.Series(1.225, index=d.index)
  67. d = d.assign(rho=rho,
  68. cp=(P * 1000) / (0.5 * rho * A_ROTOR * V.clip(lower=0.5) ** 3),
  69. tq=(P * 1000) / (2 * np.pi * d['tur_wtc_GenRpm_mean'].clip(lower=1) / 60) / 1000, # kNm 发电机侧
  70. lam=(2 * np.pi * d['tur_wtc_MainSRpm_mean'] / 60) * R_ROTOR / V.clip(lower=0.5),
  71. ratio=d['tur_wtc_GenRpm_mean'] / d['tur_wtc_MainSRpm_mean'].clip(lower=0.1),
  72. p3=d['tur_wtc_PitcPosA_mean'].combine(d['tur_wtc_PitcPosB_mean'], max).combine(d['tur_wtc_PitcPosC_mean'], max)
  73. - d['tur_wtc_PitcPosA_mean'].combine(d['tur_wtc_PitcPosB_mean'], min).combine(d['tur_wtc_PitcPosC_mean'], min),
  74. wsb=pd.cut(V, WSB, labels=WSB[:-1]).astype(float),
  75. pwb=pd.cut(P, PWB, labels=PWB[:-1]).astype(float),
  76. grb=pd.cut(d['tur_wtc_GenRpm_mean'], GRB, labels=GRB[:-1]).astype(float))
  77. for xcol, ycols in (('wsb', ['grd_wtc_ActPower_mean', 'cp', 'lam', 'tur_wtc_MainSRpm_mean']),
  78. ('pwb', ['tur_wtc_PitcPosA_mean', 'p3', 'tur_wtc_GenRpm_mean', 'ratio', 'cp']),
  79. ('grb', ['tq'])):
  80. g = d.groupby(xcol, observed=True)[ycols].median()
  81. n = d.groupby(xcol, observed=True).size()
  82. for x, r in g.iterrows():
  83. if n[x] < 12: continue
  84. for y in ycols:
  85. v = r[y]
  86. if v == v: rows.append(dict(turbine=t, lens=xcol, x=float(x), y=y, v=float(v), n=int(n[x])))
  87. if write:
  88. print(t, flush=True)
  89. df = pd.DataFrame(rows); st = pathlib.Path(cfg['store'])
  90. if write:
  91. df.to_parquet(st / 'curve_lenses.parquet')
  92. pd.DataFrame(live_log).to_parquet(st / 'curve_liveness.parquet')
  93. print('→ curve_lenses.parquet', df.shape)
  94. return df
  95. # 显著门 = 物理绝对锚 (z 只说"相对同类偏离", 定级必须过绝对量; MAD≈0 的镜头会造 z=200 的伪离群)
  96. ANCHOR = {'grd_wtc_ActPower_mean': (40, 'kW', '=1%额定'), 'tur_wtc_PitcPosA_mean': (0.3, '°', '=变桨分册报警门半值'),
  97. 'p3': (0.2, '°', '=三叶不平衡可辨门'), 'tur_wtc_GenRpm_mean': (5, 'rpm', '≈0.3%额定转速'),
  98. 'tq': (0.25, 'kNm', '≈1%额定转矩'), 'lam': (0.2, '—', '≈2%λ_opt'),
  99. 'cp': (0.01, '—', '≈3%Cp峰'), 'ratio': (0.6, '—', '=0.5%齿比')}
  100. def lens(xcol, ycol, cfg=None, store_df=None, win_label=None):
  101. """回 fleet 中位曲线 + 逐台残差 (§4.6c④: per机必算残差, 禁眼估).
  102. `store_df` 给了就用它(= 按**所选时间窗**重算出来的分箱件),否则读正式产物
  103. `curve_lenses.parquet`(固定判别窗)。`win_label` 只进图注,说明这张图的窗是什么。
  104. """
  105. cfg = cfg or farm()
  106. if store_df is not None:
  107. df = store_df
  108. else:
  109. df = pd.read_parquet(pathlib.Path(cfg['store']) / 'curve_lenses.parquet')
  110. g = df[(df.lens == xcol) & (df.y == ycol)]
  111. # ★工况段闸: 剔非发电段档 (0kW 档=切入/空转混合态, 转速散度极大; 低转速档同理)
  112. # 实逮: 未剔时 19#/29#/21#/31# 因单个 0kW 档 -66~-81rpm 被误判"转速跟踪偏低族",
  113. # 而真发电段 500~3500kW 差仅 ±5rpm ⇒ 单档极值经均值放大 = 伪信号 (残差改用逐档中位)
  114. if xcol == 'pwb': g = g[g.x >= 250]
  115. if xcol == 'grb': g = g[g.x >= 900]
  116. if not len(g): return None
  117. piv = g.pivot_table(index='x', columns='turbine', values='v')
  118. med = piv.median(axis=1)
  119. resid = piv.sub(med, axis=0)
  120. w = piv.notna().sum(axis=1)
  121. keep = med.index[w >= max(10, int(0.5 * piv.shape[1]))]
  122. piv, med, resid = piv.loc[keep], med.loc[keep], resid.loc[keep]
  123. rmean = resid.median() # 逐档中位: 单档极值不许主导
  124. mad = float((rmean - rmean.median()).abs().median()) or 1e-9
  125. z = (rmean - rmean.median()) / (1.4826 * mad)
  126. thr, unit, thr_note = ANCHOR.get(ycol, (0, '', ''))
  127. out_t = {t: dict(resid=round(float(rmean[t]), 4), z=round(float(z[t]), 2))
  128. for t in piv.columns if abs(z[t]) >= 3 and abs(rmean[t]) >= thr}
  129. # 样本量必须随曲线出来 (2026-08-28 审核逮): parquet 本来就有 n 列, lens() 没传出去
  130. # → 19 张图全部无样本量声明, 违反第一性原理②"Sample size declared"。
  131. npv = g.pivot_table(index='x', columns='turbine', values='n', aggfunc='sum').reindex(keep)
  132. n_by_x = [int(v) for v in npv.sum(axis=1).fillna(0)]
  133. return dict(x=[float(v) for v in med.index], fleet=[float(v) for v in med],
  134. n_total=int(sum(n_by_x)), n_by_x=n_by_x, n_min_bin=int(min(n_by_x) if n_by_x else 0),
  135. n_turbines=int(piv.shape[1]), win=(win_label or f"{WIN[0]}~{WIN[1]}"),
  136. anchor=dict(门=thr, 单位=unit, 说明=thr_note), 离群=out_t,
  137. q1=[float(v) for v in piv.quantile(0.25, axis=1)], q3=[float(v) for v in piv.quantile(0.75, axis=1)],
  138. per_t={t: [None if v != v else float(v) for v in piv[t]] for t in piv.columns},
  139. resid={t: round(float(rmean[t]), 4) for t in piv.columns},
  140. z={t: round(float(z[t]), 2) for t in piv.columns})
  141. def physics_check(cfg=None):
  142. """第一性原理硬闸: Betz + 齿比恒等 + λ 合理域."""
  143. cfg = cfg or farm()
  144. df = pd.read_parquet(pathlib.Path(cfg['store']) / 'curve_lenses.parquet')
  145. out = {}
  146. cp = df[(df.lens == 'wsb') & (df.y == 'cp')]
  147. cpmax = cp.groupby('turbine').v.max()
  148. over = cpmax[cpmax > BETZ]
  149. out['Betz'] = dict(判据=f'Cp ≤ {BETZ} (贝兹极限)', 全场最大Cp=round(float(cpmax.max()), 3),
  150. 中位峰值Cp=round(float(cpmax.median()), 3),
  151. 超限台=[dict(t=k, cp=round(float(v), 3)) for k, v in over.items()],
  152. 判=('超限=机舱风读低(NTF)或密度口径, 非气动超越' if len(over) else '未见荒谬输出 (sanity check: 本闸只防极端口径错误, 不构成功率曲线绝对准确性背书 — 机舱风自指, Cp绝对值不可信)'))
  153. ra = df[(df.lens == 'pwb') & (df.y == 'ratio')]
  154. rmed = ra.groupby('turbine').v.median()
  155. dev = (rmed - GEAR_I).abs()
  156. out['齿比恒等'] = dict(铭牌=GEAR_I, 全场中位=round(float(rmed.median()), 3),
  157. 离群台=[dict(t=k, r=round(float(rmed[k]), 2)) for k in dev[dev > 1.5].index],
  158. 判='破恒等=编码器/传感, 非联轴器打滑本身')
  159. lam = df[(df.lens == 'wsb') & (df.y == 'lam')]
  160. r2 = lam[(lam.x >= 5) & (lam.x <= 9)].groupby('turbine').v.median() # Region-2 平台
  161. floor = lam[lam.x <= 3.5].groupby('turbine').v.median()
  162. plateau = float(r2.median())
  163. out['λ叶尖速比'] = dict(Region2平台_中位=round(plateau, 2), 台间范围=[round(float(r2.min()), 2), round(float(r2.max()), 2)],
  164. 切入段_中位=round(float(floor.median()), 2),
  165. 判=('Region-2 平台 λ≈%.1f ∈ 合理域 6~10 ✔; 切入段 λ 被最小转速地板顶高 (风轮 5.5rpm 恒定) 属设计非异常, '
  166. '故 λ 判据取平台不取峰值' % plateau))
  167. return out