pitch_validation.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. # -*- coding: utf-8 -*-
  2. """pitch_validation.py — 变桨校核: 三叶片桨距不平衡 (单叶静态零位偏 / frozen) 检出 + 定位.
  3. 裁决对象 = per 机组 **三叶片桨距一致性** — 真三叶 IPC (独立变桨) 场的冗余三元组奇偶
  4. (同转子三叶 below-rated 应同 fine pitch; 单叶零位偏/卡死破对称)。区别 yaw (对风) / sensor (仪表)。
  5. 来源: gym P 赛道 three_blade champion (EXP-P1-01 立 / P1-03 跨场 n=2 海勒斯+敖都稳健, 远景 EN182-6250
  6. 1min IPC 场)。gym 分数≠交付, 本件 = 走设计 §7 四闸 (冻结suite/3原则审/RV-1独立审/labeled DP test) 的生产实现。
  7. ★物理 (冗余三元组奇偶, 同齿比/三相电流族): 健康三叶 below-rated → 三桨中位近相等 (fine pitch β≈0);
  8. 单叶零位偏 δ → 该叶中位偏 δ → 三桨极差 spread=max−min 抬 → flag + 定位偏离中位最大的叶 (独有定位轴)。
  9. **intra-turbine 相对互比 (三桨自比) → 天生无 fleet 批次盲** (对照臂 fleet_z 跨机 z 过敏 INV fp 20/64;
  10. region2_abs 绝对锚自指盲 fp 13/64 = Cp 自参照陷阱族); 迁移 IPC 场阈稳。
  11. ★产化硬化 (送审前真盘 probe 逮):
  12. - **gen 下界 (filter gap 修)**: gym _br 仅 P<0.7·rated **无下界** → 停机/停运台 (P≡0, 三叶顺桨 89-95°) 混入
  13. → spread 94° 假阳 (海勒斯 9ZlypWTP 整窗 P≡0 实证)。修: below-rated = **gen_power_min < P < br·rated**
  14. (真发电 fine-pitch 段); 健康台加下界 spread 逐位不变 (4wpykJt9 0.765→0.765), 停机台 → INSUFFICIENT。
  15. - **第五角: 三叶独立性守卫**: 集控广播机型 / 早期单叶 = 三桨为同一测量写三列 (非真 IPC 面) → spread≡0 =
  16. 伪"平衡" (EXP-P1-00/06 王家湾 negative: '清点≠可用'); pair-diff < derived_tol → INSUFFICIENT note
  17. (承传感器 _phase_derived_guard, sensor 五角教训第五角)。
  18. - **顺桨/非fine-pitch 守卫**: 任一叶中位 > feather_deg → 疑停机/顺桨混入 (gen 下界应已滤, belt-and-suspenders) → note。
  19. - **fine-pitch gate (RV-1 逮, 角C)**: br_ws 上沿 (如 9 m/s) 够到**变桨调节区** (海勒斯 ws8-9 三桨中位 5.08° 非 β≈0) →
  20. collective-pitch 瞬态污染静态极差 → per样本**三桨中位 < fine_pitch_max(3°)** 过闸, 只留 Region-2 静态段
  21. (单叶偏 row-median 仍≈0° 不受影响, 调节区三叶同偏被剔); 海勒斯健康台 spread 略降 (JXYvbOK2 0.957→0.941, margin↑),
  22. windy 场调节区占比大时 confound 更大 → farm-robust。
  23. ★诚实界 (gym known_limitations + 3原则审):
  24. - **共模盲 (headline)**: 单台 collective 集距同偏 (三叶同 δ) → 极差不变 → 本臂**天然盲** (EXP-P1-02 自咬);
  25. 集距零位偏须绝对轴 (region2_abs, 但自指盲) 或现场独立量 → 本臂只判"叶间不平衡"非"整体零位对不对"。
  26. - **定位歧义 (RV-1 逮, 角A)**: loc_blade = odd-one-out (偏离另两叶最大者); **单叶坏 与 双叶同偏不可辨**
  27. (后者 loc 反指健康叶, 同三相电气双相同偏反指) → 两非loc叶彼此近时 note 提示, 需现场三叶对照/绝对量定坏叶。
  28. - **razor-thin margin (RV-1 逮, 角B)**: 海勒斯健康 max spread 0.957 vs 阈 1.0 = 4% margin (JXYvbOK2 子窗越阈)
  29. → spread∈[0.7×阈,阈] 报 borderline=True (监视非定论, 亚阈值轻度不平衡); 阈 1° 是 gym a-priori 非 tune, 窗选敏感。
  30. - **半广播守卫 (RV-1 逮, 角D)**: 一对桨距近同 (n_copy==1) = 仅 2 独立通道 → note "定位不可靠" (spread 仍可检出);
  31. ≥2 对近同 = 全广播 → INSUFFICIENT。
  32. - **相对≠绝对**: 三桨互比给不出绝对桨距零位; 绝对零位须现场独立量 (pitch-error skill [A])。
  33. - **数据面**: 限**真三叶 IPC 场** (远景 EN182 类, 三叶独立可观测); 集控广播/单叶机型三叶面不存在 → 守卫弃权。
  34. - **检出下限 ~1-1.5°** (gym 海勒斯 δ=0.5/1.0/1.5 → recall 0/0.67/0.83); 定位命中 ≥0.9。
  35. - **spread 幅值语境**: spread ≫10° ≠ 零位偏 (那是卡叶/停机/顺桨) → 调用方按幅值分诊 (零位偏 O(1°) vs 卡叶 O(10°+))。
  36. - **INSUFFICIENT 归因二分 (RV-1 逮, 角C-b)**: fine-pitch gate 会把"**发电但全坐高集距** (~10°, 降额/集距offset)"的台
  37. 推过 min_n → INSUFFICIENT; 此类**非停机** (海勒斯 j1eytYUu/zdR8V4b1 实证: P>50 数百~数千样本坐 10°) → note 须区分
  38. "发电但高集距(走绝对轴)" vs "真停机/无数据", **别把活着的高集距异常台静默归入停机桶** (承 sensor 五角"信号别静默埋")。
  39. DP 验收 = tests/sop/test_pitch_three_blade_dp.py。
  40. """
  41. from __future__ import annotations
  42. import numpy as np
  43. import pandas as pd
  44. def pitch_three_blade_verdict(
  45. df: pd.DataFrame, *, blade_pitch_cols: list, power_col: str, ws_col: str, turbine_col: str,
  46. rated_power: float, gen_power_min: float = 50.0, br_power_frac: float = 0.7,
  47. br_ws: tuple = (4.0, 9.0), imb_thresh: float = 1.0, min_n: int = 200,
  48. derived_tol: float = 0.02, feather_deg: float = 20.0, fine_pitch_max: float = 3.0,
  49. ) -> dict:
  50. """per-机组 **三叶片桨距不平衡** 裁决 (真三叶 IPC 场).
  51. 返回 {tid: {"spread": 三桨中位极差(°), "flag": bool, "blade_meds": [3], "loc_blade": 1-3 (坏叶),
  52. "n": below-rated 发电样本, "note"?: 弃权/低置信}}。
  53. 机制: below-rated **发电 fine-pitch** 段 (gen_power_min < P < br_power_frac·rated ∧ ws∈br_ws ∧
  54. 三桨中位<fine_pitch_max=纯Region-2静态, 剔调节区) per台三桨中位;
  55. spread = max−min; flag = spread > imb_thresh (1° a-priori, 海勒斯健康 p90=0.68/max=0.95 → margin);
  56. 定位 = 偏离三桨中位最大的叶 (高或低侧)。
  57. """
  58. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  59. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  60. NULL = {"spread": 0.0, "flag": False, "blade_meds": None, "loc_blade": None, "n": 0}
  61. bc = [c for c in blade_pitch_cols if c in df.columns]
  62. if len(bc) != 3 or power_col not in df.columns or ws_col not in df.columns:
  63. return {t: {**NULL, "note": "需恰 3 桨距列 + power + ws; abstain"} for t in tids}
  64. P = pd.to_numeric(df[power_col], errors="coerce").to_numpy(float)
  65. W = pd.to_numeric(df[ws_col], errors="coerce").to_numpy(float)
  66. B = np.column_stack([pd.to_numeric(df[c], errors="coerce").to_numpy(float) for c in bc])
  67. finite = np.isfinite(P) & np.isfinite(W) & np.isfinite(B).all(axis=1)
  68. # fine-pitch gate (RV-1 逮 角C: br_ws 上沿够到变桨调节区 → 桨距非 β≈0 静态): per样本三桨中位 < fine_pitch_max
  69. # → 只留 Region-2 fine-pitch 静态段, 剔除调节区 collective-pitch 瞬态污染 (海勒斯 ws8-9 三桨中位 5.08° 实证)。
  70. # 单叶偏不受影响 (一叶+8°其余0° → row median 仍≈0° 过闸); collective 调节 (三叶同~5°) → 被剔。
  71. row_med = np.median(B, axis=1)
  72. br_gen = finite & (P > gen_power_min) & (P < br_power_frac * rated_power) & (W >= br_ws[0]) & (W <= br_ws[1])
  73. br = br_gen & (row_med < fine_pitch_max) # + fine-pitch gate
  74. tv = df[turbine_col].astype(str).to_numpy()
  75. out = {}
  76. for t in tids:
  77. sel = br & (tv == t)
  78. if sel.sum() < min_n:
  79. # INSUFFICIENT 归因二分 (RV-1 逮 角C-b): 分"真停机/无发电"vs"发电但全在调节区/高集距" — 后者别静默埋成停机
  80. gen_pre = br_gen & (tv == t) # gate 前的 below-rated 发电样本
  81. if gen_pre.sum() >= min_n: # 有足量发电样本, 却被 fine-pitch gate 剔光
  82. hi_med = float(np.median(row_med[gen_pre]))
  83. out[t] = {**NULL, "n": int(sel.sum()),
  84. "note": f"有 below-rated 发电样本 (n={int(gen_pre.sum())}) 但全在变桨调节区/高集距 "
  85. f"(三桨中位≈{hi_med:.1f}°≥fine_pitch_max) → 非 Region-2 静态, 本臂弃权; "
  86. f"旁注: 集距偏高疑降额/集距offset, 走绝对轴 (region2_abs / 现场量) — 非停机"}
  87. else:
  88. out[t] = {**NULL, "n": int(sel.sum()),
  89. "note": "below-rated 发电样本不足 (疑停机/停运/无本工况数据); INSUFFICIENT"}
  90. continue
  91. Bt = B[sel]
  92. # 第五角: 三叶独立性 — 计数近同桨距对 (RV-1 逮全∧半广播)
  93. d = [float(np.median(np.abs(Bt[:, i] - Bt[:, j]))) for i, j in ((0, 1), (0, 2), (1, 2))]
  94. n_copy = sum(1 for x in d if x < derived_tol)
  95. if n_copy >= 2: # ≥2 对近同 = ≤1 独立通道 → 全广播不可测
  96. out[t] = {**NULL, "n": int(sel.sum()),
  97. "note": f"三叶桨距近同 (pair-diff<{derived_tol}°): 疑集控广播/单叶非真IPC面 → INSUFFICIENT"}
  98. continue
  99. meds = [float(np.median(Bt[:, b])) for b in range(3)]
  100. spread = max(meds) - min(meds)
  101. med_m = float(np.median(meds))
  102. loc = (int(np.argmax(meds)) + 1 if (max(meds) - med_m) >= (med_m - min(meds))
  103. else int(np.argmin(meds)) + 1) # 偏离另两叶最大的叶 (odd-one-out; 高/低侧)
  104. borderline = bool(imb_thresh * 0.7 < spread <= imb_thresh)
  105. rec = {"spread": round(spread, 3), "flag": bool(spread > imb_thresh), "borderline": borderline,
  106. "blade_meds": [round(m, 3) for m in meds], "loc_blade": loc, "n": int(sel.sum())}
  107. notes = []
  108. if n_copy == 1: # 角D: 半广播 (一对复制) → 定位不可靠
  109. notes.append("疑半广播/两叶复制 (一对 pair 近同): 仅 2 独立通道, 定位不可靠, spread 仍可检出")
  110. others = sorted(m for k, m in enumerate(meds) if k != loc - 1) # 角A: 两非loc叶
  111. if spread > imb_thresh and len(others) == 2 and abs(others[1] - others[0]) < 0.3 * spread:
  112. notes.append("两非loc叶彼此近: 单叶坏∨**双叶同偏**不可辨 (后者 loc 反指健康叶) → 现场三叶对照/绝对量")
  113. if borderline: # 角B: 亚阈值 razor-thin
  114. notes.append(f"borderline (spread {spread:.2f}∈[{imb_thresh*0.7:.2f},{imb_thresh:.2f}]): 疑轻度不平衡, 监视非定论")
  115. if any(abs(m) > feather_deg for m in meds): # 顺桨/非fine-pitch 混入 (gen下界应已滤)
  116. notes.append("某叶中位非fine-pitch (>feather_deg): 疑停机/顺桨混入, spread非零位偏语境")
  117. elif spread > 10.0: # 幅值语境: 零位偏 O(1°) vs 卡叶 O(10°+)
  118. notes.append("spread≫零位偏量级: 疑卡叶/机械故障非静态零位偏, 按幅值分诊")
  119. if notes:
  120. rec["note"] = "; ".join(notes)
  121. out[t] = rec
  122. return out
  123. # ==================== 变桨电机三元组 parity 校核 (与三叶桨距角正交) ====================
  124. def pitch_motor_parity_verdict(
  125. df, *, motor_cur_cols: list, turbine_col: str, motor_temp_cols: list | None = None,
  126. cur_active_min: float = 3.0, imb_thresh: float | None = None,
  127. k_mad: float = 3.5, uniform_cv_floor: float = 0.05, derived_tol: float = 0.1,
  128. min_active_n: int = 200, temp_confirm_deg: float = 5.0, low_duty_frac: float = 0.05,
  129. ) -> dict:
  130. """per-机组 **变桨电机三元组不平衡** 校核 (电机/轴承健康) — 与 pitch_three_blade_verdict
  131. (叶间**桨距角**不平衡, 标定轴) **正交**: 本轴 = 三**电机电流** imbalance (单电机摩擦/轴承退化)。
  132. 返回 {tid: {"m": active段中位 imbalance, "flag": bool, "z": fleet稳健z, "loc_motor": 1-3 (偏离电机),
  133. "n_active": active样本, "note"?: 弃权/低置信}}。
  134. 机制 (no black box, parity 族同三相电流/齿比): m = max_i|I_i/Ibar−1| (Ibar=三电机均值); **标度不变**
  135. → 三电机共缩 (真整体变桨负载) → m 逐位不变 (王牌, 实测 Δm 2e-16); 单电机 gain/摩擦 → 该电机偏 → m 抬。
  136. **active 条件化** (max(I_i)>cur_active_min = 主动变桨; 消 idle 电流≈0 的比值噪声) + **per台中位** (over
  137. 多方位角 = 消 azimuth 1P 载荷调制)。flag = fleet 稳健守卫 (hi_thresh 地板 scale, 承 yaw §48 F2) 高离群。
  138. ★confound 五角 checklist 前置 (§48):
  139. - **派生守卫**: 任两电机 pair-diff 中位 < derived_tol → 疑复制列 (三电机同一测量) → INSUFFICIENT。
  140. - **id 列守卫**: turbine_col 全空 → INSUFFICIENT (承 yaw turbine_hash 坏列)。
  141. - **active 条件化**: idle 电流≈0 → 比值噪声; 必 active (max>cur_active_min); active<min_active_n → 弃权。
  142. - **azimuth 1P**: 电机电流随方位角 (重力/气动相位) 调制 → per台中位平均掉 1P。
  143. ★诚实界:
  144. - **候选/筛查级非确诊**: 相对非绝对 (电机效率标定未知); 单电机高电流 = 摩擦/轴承退化**候选**, 现场核电机/减速/轴承。
  145. - **温度交叉判别 (C1, RV-1 逮 + 真盘证)**: 传入 motor_temp_cols → loc 电机温度共升 (loc_temp_dev>temp_confirm_deg)
  146. = I²R/摩擦发热 → **升置信排电流传感器 gain artifact** (传感器 gain 错抬读数**不产热**); 温度未共升 → 疑测量侧
  147. (采集/传感器) 非发热退化。真盘: 台51 电机2 电流35A+温度54.7°C(+28°C双坐实真退化)/台63 电机1(+19.5°C)/台58(+6°C弱)。
  148. - **电流瞬态** (active 才有信号) vs 温度 sustained; 温度亦可作主轴 (motor_temp_cols 换 motor_cur_cols 位跑)。
  149. - **窗界 (C3)**: 短窗 (真盘 10 天) 下"恒偏 (传感器/电机个体差) vs 渐变 (退化)"不可解, flag=窗内持续偏; 须更长窗看 onset 趋势定退化。
  150. - **t0 起单电机恒偏** (无 onset) 本臂靠绝对 m (非差分) 可检, 但"恒偏是真退化还是电机个体差"无铭牌不可辨 (同齿比静态恒偏)。
  151. - **双电机同偏反指健康电机** (loc odd-one-out 结构性反指, 承桨距角A/电气双相) → 两非loc电机近时 note, 需现场三电机对照。
  152. - **fleet-relative → 批次盲** (全场一致高不平衡 → 无flag); 批次嫌疑场须绝对基准。
  153. 来源: gym EXP-PM1-01 (TRUE: H2检出76-86 / H3可分性Δm 2e-16 / 真盘候选台58·63) → §7四闸并回 src/ 2026-07-16。
  154. DP = tests/sop/test_pitch_motor_parity_dp.py。
  155. """
  156. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  157. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  158. NULL = {"m": None, "flag": False, "z": None, "loc_motor": None, "n_active": 0}
  159. mc = [c for c in motor_cur_cols if c in df.columns]
  160. if len(mc) != 3:
  161. return {t: {**NULL, "note": "需恰 3 电机电流列; abstain"} for t in tids}
  162. if df[turbine_col].notna().sum() == 0 or (len(tids) == 1 and tids[0] in ("nan", "None", "")):
  163. return {t: {**NULL, "note": "turbine id 列全空/坏列; INSUFFICIENT"} for t in tids}
  164. I = np.column_stack([pd.to_numeric(df[c], errors="coerce").to_numpy(float) for c in mc])
  165. tv = df[turbine_col].astype(str).to_numpy()
  166. import warnings
  167. with warnings.catch_warnings(), np.errstate(invalid="ignore", divide="ignore"):
  168. warnings.simplefilter("ignore", RuntimeWarning) # 全NaN行由 active/finite mask 滤, 抑良性空切片告警
  169. active = (np.nanmax(I, axis=1) > cur_active_min) & np.isfinite(I).all(axis=1)
  170. Ibar = np.nanmean(I, axis=1)
  171. dev = I / Ibar[:, None] - 1.0
  172. m = np.nanmax(np.abs(dev), axis=1)
  173. tc = [c for c in (motor_temp_cols or []) if c in df.columns] # C1: 温度交叉判别 (可选, RV-1 逮)
  174. T = (np.column_stack([pd.to_numeric(df[c], errors="coerce").to_numpy(float) for c in tc])
  175. if len(tc) == 3 else None)
  176. out = {}
  177. per_m = {}
  178. n_total = {t: int((tv == t).sum()) for t in tids}
  179. for t in tids:
  180. s = (tv == t) & active & np.isfinite(m)
  181. if s.sum() < min_active_n:
  182. out[t] = {**NULL, "n_active": int(s.sum()), "note": "active(主动变桨)样本不足; INSUFFICIENT"}
  183. continue
  184. # 派生守卫: 三电机是否复制列 (第五角)
  185. d = [float(np.nanmedian(np.abs(I[s, i] - I[s, j]))) for i, j in ((0, 1), (0, 2), (1, 2))]
  186. if min(d) < derived_tol:
  187. out[t] = {**NULL, "n_active": int(s.sum()),
  188. "note": f"疑派生/复制电机电流 (pair-diff<{derived_tol}A): parity 不可证; INSUFFICIENT"}
  189. continue
  190. per_m[t] = float(np.median(m[s]))
  191. out[t] = {"m": round(per_m[t], 4), "flag": False, "z": None, "loc_motor": None,
  192. "n_active": int(s.sum())}
  193. md = np.median(dev[s], axis=0) # 定位: 偏离最大电机 (odd-one-out)
  194. j = int(np.nanargmax(np.abs(md)))
  195. out[t]["loc_motor"] = j + 1
  196. notes = []
  197. # C1 温度交叉判别 (RV-1 逮): loc 电机温度共升 → I²R/摩擦发热 = 真电机退化, 排电流传感器 gain artifact (只抬读不产热)
  198. if T is not None:
  199. with np.errstate(invalid="ignore"):
  200. tmed = [float(np.nanmedian(T[s, i])) for i in range(3)]
  201. if all(np.isfinite(tmed)):
  202. loc_tdev = tmed[j] - float(np.median([tmed[k] for k in range(3) if k != j]))
  203. out[t]["loc_temp_dev_deg"] = round(loc_tdev, 1)
  204. out[t]["temp_confirms"] = bool(loc_tdev > temp_confirm_deg)
  205. notes.append("loc电机温度共升 → 电机侧真退化 (排电流传感器gain artifact)" if loc_tdev > temp_confirm_deg
  206. else "loc电机温度未共升 → 电流偏疑传感器/采集侧 (非发热退化), 现场核采集链")
  207. others = sorted(md[k] for k in range(3) if k != j)
  208. if len(others) == 2 and abs(others[1] - others[0]) < 0.3 * abs(md[j]):
  209. notes.append("两非loc电机近: 单电机坏∨双电机同偏不可辨 (后者loc反指健康电机) → 现场三电机对照")
  210. if n_total[t] and s.sum() / n_total[t] < low_duty_frac: # C2: 低 active 占空 = 疑停机/降额
  211. notes.append(f"active 占空低 ({s.sum()/n_total[t]:.1%}<{low_duty_frac:.0%}): 疑停机/降额少变桨, m 基于少数机动")
  212. if notes:
  213. out[t]["note"] = "; ".join(notes)
  214. if len(per_m) < 5:
  215. return out # 不足 fleet z (已报 per台 m)
  216. vals = np.array(list(per_m.values())); med = float(np.median(vals))
  217. mad = float(np.median(np.abs(vals - med)))
  218. scale = max(1.4826 * mad, uniform_cv_floor * abs(med)) if med != 0 else (1.4826 * mad or float(np.std(vals)))
  219. thr = (imb_thresh if imb_thresh is not None else med + k_mad * scale)
  220. for t in per_m:
  221. z = (per_m[t] - med) / scale if scale > 0 else 0.0
  222. out[t]["z"] = round(float(z), 2)
  223. out[t]["flag"] = bool(per_m[t] > thr)
  224. return out