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