wind_coupling.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. # -*- coding: utf-8 -*-
  2. """wind_coupling.py — 风资源→设备 耦合映射 框架 (farm-agnostic, SOP §0.9 应用范式).
  3. 把风资源从"去混基线"升级为贯穿透镜: 分解设备信号里哪些风况/载荷驱动 vs 机器内禀,
  4. 并建 风况↔故障↔可靠性 关联矩阵。三张映射 = 纯函数, 吃标准接口(cleaned df + fault df + 列名参数),
  5. OEM/场无关。四条护栏焊进代码(均承第一性原理②confounder排):
  6. ① 带符号 z (非 |z|) — map_wind_health: 内禀残差方向不能丢(平陆13#低速轴承 |z|误叙热=冷 blood lesson)
  7. ② 状态混杂检验(gen_frac) — map_wind_fault: 无则停机态触发的子系统误报"低风驱动"假映射
  8. ③ 运行时长归一 + 排除活跃度 — map_wind_reliability: 防"暴露=活跃度"logging伪相关
  9. ④ 前提门在调用方(限电/投产剥离) — 本模块只吃已剥离的发电态数据(gen_mask), 不自剥
  10. 结论强度上限 = 关联/趋势级(机舱风 self_ref + 相关≠因果); 【定论】须现场取证 + 独立风况量。
  11. 校准 = scripts/wind_coupling_benchmark.py (纯合成注入-回收已知真值卡)。
  12. 设计正本 = outputs/sop_review/风资源设备映射_分析域设计草案.md。
  13. """
  14. from __future__ import annotations
  15. import numpy as np
  16. import pandas as pd
  17. __all__ = ['map_wind_health', 'map_wind_fault', 'map_wind_reliability', 'map_wind_load',
  18. 'map_device_to_wind', 'rainflow_del', 'default_subsystem', 'spearman', 'ks_stat']
  19. # ----------------------------- 数值 helpers (无 scipy) -----------------------------
  20. def _r2(y, yhat):
  21. ss_res = np.nansum((y - yhat) ** 2)
  22. ss_tot = np.nansum((y - np.nanmean(y)) ** 2)
  23. return float(1 - ss_res / ss_tot) if ss_tot > 0 else np.nan
  24. def _ols_pred(X, y):
  25. beta, *_ = np.linalg.lstsq(X, y, rcond=None)
  26. return X @ beta
  27. def spearman(x, y):
  28. """Spearman r + 正态近似 p + n (无 scipy). 返回 (r, p, n)."""
  29. from math import erf
  30. x = np.asarray(x, float); y = np.asarray(y, float)
  31. m = ~(np.isnan(x) | np.isnan(y))
  32. x, y = x[m], y[m]
  33. n = len(x)
  34. if n < 5:
  35. return (np.nan, np.nan, n)
  36. rx = pd.Series(x).rank().to_numpy(); ry = pd.Series(y).rank().to_numpy()
  37. if np.std(rx) == 0 or np.std(ry) == 0:
  38. return (0.0, 1.0, n)
  39. r = float(np.corrcoef(rx, ry)[0, 1])
  40. t = r * np.sqrt((n - 2) / max(1e-12, 1 - r * r))
  41. p = float(2 * (1 - 0.5 * (1 + erf(abs(t) / np.sqrt(2)))))
  42. return (r, p, n)
  43. def ks_stat(a, b):
  44. """两样本 KS 统计量 (无 scipy)."""
  45. a = np.sort(np.asarray(a, float)); a = a[~np.isnan(a)]
  46. b = np.sort(np.asarray(b, float)); b = b[~np.isnan(b)]
  47. if len(a) < 20 or len(b) < 20:
  48. return np.nan
  49. allv = np.concatenate([a, b])
  50. ca = np.searchsorted(a, allv, 'right') / len(a)
  51. cb = np.searchsorted(b, allv, 'right') / len(b)
  52. return float(np.max(np.abs(ca - cb)))
  53. def default_subsystem(desc: str) -> str:
  54. """状态码描述 → 子系统 (行业约定关键词; 可被调用方自定 subsystem_fn 覆盖)."""
  55. d = str(desc)
  56. if any(k in d for k in ['桨叶', '变桨', '桨距', '91°', 'pitch']): return '变桨/桨叶'
  57. if '主轴承' in d or 'main bearing' in d.lower(): return '主轴承'
  58. if any(k in d for k in ['变流器', '变频器', 'converter', 'inverter']): return '变流器'
  59. if any(k in d for k in ['偏航', '解缆', '扭缆', 'yaw']): return '偏航'
  60. if any(k in d for k in ['齿轮', '齿箱', 'gearbox']): return '齿轮箱'
  61. if any(k in d for k in ['发电机', '定子', '转子', 'generator', 'stator']): return '发电机'
  62. if any(k in d for k in ['安全', '紧停', '急停', 'safety', 'estop']): return '安全链'
  63. if any(k in d for k in ['暴风', '大风', 'storm', 'high wind']): return '暴风'
  64. if any(k in d for k in ['限功率', 'SCADA', '停止按钮', 'curtail']): return '控制/限功率'
  65. return '其他'
  66. # ----------------------------- A. 风况 → 健康 -----------------------------
  67. def map_wind_health(df, *, comp_cols, load_cols, wind_col, turbine_col,
  68. sector_col=None, month_col=None, gen_mask=None,
  69. min_n=5000, min_per_turbine=1000):
  70. """A映射: 每健康信号 H ~ load + W(风速[+扇区+季节]) 分层回归.
  71. 产出 per 信号: r2_load / r2_full / dr2_wind(风况在载荷之外边际) +
  72. per台 **带符号z** 内禀残差(护栏①: +热/−冷, 非|z|) + 条件性(台内扇区残差极差z).
  73. load_cols = 载荷协变量列表(如 [功率,转速,舱外温]); wind_col=风速; sector_col=机舱位置(可选);
  74. gen_mask = 发电态布尔(护栏④: 前提门在调用方, 传已剥离的发电态). 返回 dict{signals:{...}}.
  75. """
  76. d = df if gen_mask is None else df[gen_mask]
  77. d = d.copy()
  78. out = {}
  79. for H in comp_cols:
  80. need = [H] + list(load_cols) + [wind_col] + ([sector_col] if sector_col else []) \
  81. + ([month_col] if month_col else [])
  82. sub = d[[c for c in need if c in d] + [turbine_col]].dropna(
  83. subset=[c for c in need if c in d])
  84. if len(sub) < min_n:
  85. out[H] = {'n': int(len(sub)), 'verdict': 'INSUFFICIENT'}; continue
  86. y = sub[H].to_numpy(float)
  87. ones = np.ones(len(sub))
  88. # load-only 设计
  89. Xl_parts = [ones] + [sub[c].to_numpy(float) for c in load_cols]
  90. Xl = np.column_stack(Xl_parts)
  91. # full = load + 风速 [+ sin/cos扇区] [+ sin/cos季节]
  92. Xf_parts = list(Xl_parts) + [sub[wind_col].to_numpy(float)]
  93. if sector_col:
  94. th = np.deg2rad(sub[sector_col].to_numpy(float))
  95. Xf_parts += [np.sin(th), np.cos(th)]
  96. if month_col:
  97. mth = np.deg2rad((sub[month_col].to_numpy(float) - 1) / 12 * 360)
  98. Xf_parts += [np.sin(mth), np.cos(mth)]
  99. Xf = np.column_stack(Xf_parts)
  100. yl = _ols_pred(Xl, y); yf = _ols_pred(Xf, y)
  101. r2l, r2f = _r2(y, yl), _r2(y, yf)
  102. # (b) per台 带符号z 内禀残差 (护栏①)
  103. sub = sub.assign(_rl=y - yl)
  104. pm = sub.groupby(turbine_col)['_rl'].agg(['mean', 'size'])
  105. pm = pm[pm['size'] >= min_per_turbine]
  106. mu, sd = pm['mean'].mean(), pm['mean'].std()
  107. pm['z'] = (pm['mean'] - mu) / sd if sd and sd > 0 else np.nan
  108. pm_sorted = pm.reindex(pm['z'].abs().sort_values(ascending=False).index)
  109. intrinsic = [{'tid': str(t), 'z': round(float(pm.loc[t, 'z']), 2), # ★带符号
  110. 'resid_degC': round(float(pm.loc[t, 'mean']), 2),
  111. 'dir': ('热' if pm.loc[t, 'z'] > 0 else '冷')}
  112. for t in pm_sorted.index[:5]]
  113. # (c) 条件性缺陷 (台内扇区残差极差 z)
  114. conditional = []
  115. if sector_col:
  116. sub = sub.assign(_rf=y - yf, _sec=np.clip(np.floor(sub[sector_col].to_numpy(float) / 45), 0, 7))
  117. ms = sub.groupby([turbine_col, '_sec'])['_rf'].mean().unstack()
  118. span = (ms.max(axis=1) - ms.min(axis=1))
  119. smu, ssd = span.mean(), span.std()
  120. if ssd and ssd > 0:
  121. sz = ((span - smu) / ssd).sort_values(ascending=False)
  122. conditional = [{'tid': str(t), 'span_z': round(float(sz[t]), 2),
  123. 'span_degC': round(float(span[t]), 2)} for t in sz.index[:3]]
  124. out[H] = {
  125. 'n': int(len(sub)),
  126. 'r2_load': round(r2l, 3), 'r2_full': round(r2f, 3),
  127. 'dr2_wind_marginal': round(r2f - r2l, 4),
  128. 'intrinsic_signed_z': intrinsic, # 护栏①: 带符号
  129. 'conditional_defect': conditional,
  130. }
  131. return {'mapping': 'A_wind_health', 'signals': out,
  132. 'guards': ['signed_z(非|z|)', 'gen_mask前提门在调用方']}
  133. # ----------------------------- B. 风况 → 故障 -----------------------------
  134. def map_wind_fault(df, faults, *, time_col, turbine_col, wind_col, gen_col,
  135. fault_time_col, fault_turbine_col, fault_desc_col,
  136. subsystem_fn=None, tol_min=30, min_events=30,
  137. episode_gap_s=3600, wsbins=(0, 3, 5, 7, 9, 11, 13, 25)):
  138. """B映射: 故障激活时刻回查风况 → P(W|F) vs 基线P(W) + KS + **状态混杂检验(护栏②)**.
  139. 护栏②硬核: 每子系统报发电态占比(gen_frac); gen_frac≪基线 → 负风偏移=停机态触发伪相关,
  140. 非"低风驱动"(不控制会误报). 返回 dict{subsystems:{sys:{wind_shift,ks,gen_frac,class,...}}}.
  141. class ∈ {wind_driven, wind_independent, state_confounded, weak}.
  142. """
  143. subsystem_fn = subsystem_fn or default_subsystem
  144. sc = df[[turbine_col, time_col, wind_col, gen_col]].copy()
  145. sc[time_col] = pd.to_datetime(sc[time_col], errors='coerce')
  146. sc = sc.dropna(subset=[time_col]).sort_values(time_col)
  147. w_base = sc[wind_col].dropna().to_numpy(float)
  148. base_mean = float(np.nanmean(w_base))
  149. base_gen = float(sc[gen_col].mean())
  150. fa = faults[[fault_turbine_col, fault_time_col, fault_desc_col]].copy()
  151. fa[fault_time_col] = pd.to_datetime(fa[fault_time_col], errors='coerce')
  152. fa = fa.dropna(subset=[fault_time_col, fault_turbine_col])
  153. lo, hi = sc[time_col].min(), sc[time_col].max()
  154. fa = fa[(fa[fault_time_col] >= lo) & (fa[fault_time_col] <= hi)]
  155. fa['_sys'] = fa[fault_desc_col].map(subsystem_fn)
  156. # per台 merge_asof 回查风况+态
  157. merged = []
  158. for m, g in fa.groupby(fault_turbine_col):
  159. scm = sc[sc[turbine_col] == m][[time_col, wind_col, gen_col]].sort_values(time_col)
  160. if len(scm) == 0:
  161. continue
  162. j = pd.merge_asof(g.sort_values(fault_time_col), scm,
  163. left_on=fault_time_col, right_on=time_col,
  164. direction='nearest', tolerance=pd.Timedelta(f'{tol_min}min'))
  165. merged.append(j)
  166. if not merged:
  167. return {'mapping': 'B_wind_fault', 'subsystems': {}, 'note': '无匹配'}
  168. fa2 = pd.concat(merged, ignore_index=True)
  169. bins = np.asarray(wsbins, float)
  170. subs = {}
  171. for sysn, g in fa2.groupby('_sys'):
  172. gg = g.dropna(subset=[wind_col])
  173. if len(gg) < min_events:
  174. subs[sysn] = {'n_matched': int(len(gg)), 'class': 'INSUFFICIENT_n'}; continue
  175. # episode 去重 (同台>gap 算新)
  176. gg = gg.sort_values([fault_turbine_col, fault_time_col])
  177. gap = gg.groupby(fault_turbine_col)[fault_time_col].diff().dt.total_seconds()
  178. ep = gg[(gap.isna()) | (gap > episode_gap_s)]
  179. w_f = ep[wind_col].to_numpy(float)
  180. gen_frac = float(ep[gen_col].mean()) if gen_col in ep else np.nan
  181. shift = float(np.nanmean(w_f) - base_mean)
  182. ks = ks_stat(w_f, w_base)
  183. # 分类 (护栏②): 状态混杂优先 — 故障在停机态触发(gen_frac≪基线)本身即混杂,
  184. # 其任何风况关联(不论正负偏移)都被运行态污染, 不可归"风驱动"。此判据不依赖偏移符号。
  185. ks_ok = ks is not None and ks == ks
  186. if gen_frac == gen_frac and gen_frac < base_gen - 0.2:
  187. cls = 'state_confounded'
  188. elif ks_ok and abs(ks) < 0.15 and abs(shift) < 1.0:
  189. cls = 'wind_independent' # 风况≈基线 = 调度/时间驱动
  190. elif shift > 1.0 or (ks_ok and ks > 0.3):
  191. cls = 'wind_driven' # 风况显著偏离 (且非停机态混杂)
  192. else:
  193. cls = 'weak'
  194. subs[sysn] = {
  195. 'n_episodes': int(len(ep)), 'wind_mean_at_fault': round(float(np.nanmean(w_f)), 2),
  196. 'wind_shift': round(shift, 2), 'ks_vs_baseline': round(ks, 3) if ks == ks else None,
  197. 'gen_frac': round(gen_frac, 2), 'class': cls,
  198. }
  199. return {'mapping': 'B_wind_fault', 'baseline_wind_mean': round(base_mean, 2),
  200. 'baseline_gen_frac': round(base_gen, 2), 'subsystems': subs,
  201. 'guards': ['state_confound(gen_frac)', 'episode去重(削re-fire)']}
  202. # ----------------------------- C. 风况 → 可靠性 -----------------------------
  203. def map_wind_reliability(df, faults, *, time_col, turbine_col, wind_col, gen_col, stop_col,
  204. fault_time_col, fault_turbine_col, fault_type_col=None,
  205. fault_kind_generating=None, cutin=3.5, episode_gap_s=3600,
  206. dt_minutes=10):
  207. """C映射: 台间 可用率/故障率/MTBF vs 风况暴露 回归 + **运行时长归一(护栏③)**.
  208. 护栏③硬核: 故障率按 gen_hours 归一(每千运行时) + 报 暴露vs运行时长 相关
  209. (若强正 → 暴露=活跃度, 故障相关是logging伪). 返回 dict{tests, per_turbine, confound}.
  210. """
  211. sc = df[[turbine_col, time_col, wind_col, gen_col, stop_col]].copy()
  212. sc[time_col] = pd.to_datetime(sc[time_col], errors='coerce')
  213. sc = sc.dropna(subset=[time_col])
  214. rows = []
  215. for m, g in sc.groupby(turbine_col):
  216. windy = g[wind_col] >= cutin
  217. nw = int(windy.sum())
  218. gen_h = float(g[gen_col].sum()) * dt_minutes / 60.0
  219. avail = float((g.loc[windy, gen_col] == 1).mean()) if nw > 0 else np.nan
  220. fdown = float(((g[stop_col] == 1) & windy).sum()) / max(nw, 1)
  221. rows.append({'tid': str(m), 'ws_exposure': round(float(g[wind_col].mean()), 3),
  222. 'availability': round(avail, 4), 'forced_down_frac': round(fdown, 4),
  223. 'gen_hours': round(gen_h, 1)})
  224. rel = pd.DataFrame(rows)
  225. # 故障 episode (可选只故障型) per台
  226. fa = faults[[fault_turbine_col, fault_time_col] + ([fault_type_col] if fault_type_col else [])].copy()
  227. fa[fault_time_col] = pd.to_datetime(fa[fault_time_col], errors='coerce')
  228. fa = fa.dropna(subset=[fault_time_col, fault_turbine_col])
  229. if fault_type_col and fault_kind_generating:
  230. fa = fa[fa[fault_type_col] == fault_kind_generating]
  231. fa = fa.sort_values([fault_turbine_col, fault_time_col])
  232. gap = fa.groupby(fault_turbine_col)[fault_time_col].diff().dt.total_seconds()
  233. ep = fa[(gap.isna()) | (gap > episode_gap_s)]
  234. span_days = (fa[fault_time_col].max() - fa[fault_time_col].min()).total_seconds() / 86400 if len(fa) else np.nan
  235. ec = ep.groupby(fault_turbine_col).size()
  236. rel['fault_episodes'] = rel['tid'].map(lambda t: int(ec.get(t, 0)))
  237. rel['fault_per_1kh'] = rel['fault_episodes'] / rel['gen_hours'].replace(0, np.nan) * 1000
  238. rel['mtbf_days'] = (span_days / rel['fault_episodes'].replace(0, np.nan)).round(1)
  239. # 暴露 vs 可靠性 台间 Spearman
  240. tests = {}
  241. for metric in ['availability', 'forced_down_frac', 'fault_episodes', 'fault_per_1kh', 'mtbf_days']:
  242. r, p, n = spearman(rel['ws_exposure'], rel[metric])
  243. tests[metric] = {'spearman_r': round(r, 3) if r == r else None,
  244. 'p': round(p, 3) if p == p else None, 'n': n,
  245. 'sig': bool(p < 0.05) if p == p else False}
  246. # 护栏③: 暴露 vs 运行时长 (排除"暴露=活跃度")
  247. er, ep_, en = spearman(rel['ws_exposure'], rel['gen_hours'])
  248. confound = {'exposure_vs_genhours_r': round(er, 3) if er == er else None,
  249. 'p': round(ep_, 3) if ep_ == ep_ else None,
  250. 'is_activity_proxy': bool(ep_ < 0.05 and abs(er) > 0.4) if ep_ == ep_ else False}
  251. return {'mapping': 'C_wind_reliability', 'n_turbines': len(rel),
  252. 'exposure_range': [round(float(rel['ws_exposure'].min()), 2), round(float(rel['ws_exposure'].max()), 2)],
  253. 'tests': tests, 'confound_activity': confound,
  254. 'per_turbine': rel.sort_values('availability').to_dict('records'),
  255. 'guards': ['fault率按gen_hours归一', '暴露vs运行时长排除活跃度伪']}
  256. # ----------------------------- E. 风况 → 载荷 → 疲劳/DEL -----------------------------
  257. def rainflow_del(x, m=4.0):
  258. """雨流计数(ASTM 4点) → 相对 DEL = (Σ rangeᵢ^m)^(1/m). x=去趋势载荷代理序列(塔振/应变).
  259. m=Wöhler S-N 斜率(焊接钢塔≈4). 相对量(无气弹标定→只台内/相对比)."""
  260. x = np.asarray(x, float); x = x[np.isfinite(x)]
  261. if len(x) < 8:
  262. return np.nan
  263. d = np.diff(x)
  264. tp = np.concatenate([[x[0]], x[np.where(np.diff(np.sign(d)) != 0)[0] + 1], [x[-1]]])
  265. # tp 恒 ≥2 (首末); 平/单调信号 → 残余半循环处理(平=0, 斜坡=range), 非 nan
  266. ranges, stack = [], []
  267. for p in tp:
  268. stack.append(p)
  269. while len(stack) >= 4:
  270. s1, s2, s3 = stack[-3], stack[-2], stack[-1]
  271. if abs(s2 - s3) >= abs(s1 - s2):
  272. ranges.append(abs(s1 - s2)); del stack[-3]; del stack[-2]
  273. else:
  274. break
  275. ranges += [abs(stack[i] - stack[i + 1]) for i in range(len(stack) - 1)]
  276. return float(np.sum(np.asarray(ranges) ** m) ** (1.0 / m)) if ranges else 0.0
  277. def map_wind_load(windows, *, del_col, ti_col, load_cols, turbine_col,
  278. shear_col=None, min_n=2000, min_per_turbine=50):
  279. """E映射: 载荷剂量(DEL) ~ load + W(TI[,切变]). 台内自比消传感器增益污染(护栏⑤).
  280. windows = per-窗 DataFrame (每台每10min: del_col=雨流DEL, ti_col=湍流强度, load_cols=风速/转速/功率,
  281. shear_col=切变α可选). **数据门硬**: 需秒级塔振/应变→DEL(SCADA-10min无, 仅有高频载荷通道的场可跑, 如阜山)。
  282. 护栏⑤(本映射新增): 加速度计/应变无标定 → 跨台绝对DEL不可比 → **台内自比(z-within)是唯一可信轴**;
  283. 台间报但标 gain_confounded=INSUFFICIENT。返回 dict{within_turbine(主), pooled(增益污染), inter_turbine}.
  284. """
  285. d = windows.dropna(subset=[del_col, ti_col] + list(load_cols)).copy()
  286. d = d[d[del_col] > 0]
  287. if len(d) < min_n:
  288. return {'mapping': 'E_wind_load_DEL', 'n': int(len(d)), 'verdict': 'INSUFFICIENT'}
  289. d['_y'] = np.log(d[del_col].to_numpy(float)) # DEL 长尾 → log
  290. ones = np.ones(len(d))
  291. def z(col):
  292. return (pd.to_numeric(d[col], errors='coerce') - pd.to_numeric(d[col], errors='coerce').mean()) \
  293. / pd.to_numeric(d[col], errors='coerce').std()
  294. def zwin(col):
  295. g = d.groupby(turbine_col)[col]
  296. return ((d[col] - g.transform('mean')) / g.transform('std')).fillna(0).to_numpy()
  297. def _r2(y, X):
  298. b, *_ = np.linalg.lstsq(X, y, rcond=None); yh = X @ b
  299. ss = np.nansum((y - yh) ** 2); st = np.nansum((y - np.nanmean(y)) ** 2)
  300. return (float(1 - ss / st) if st > 0 else np.nan), b
  301. y = d['_y'].to_numpy(float)
  302. load_pool = np.column_stack([ones] + [z(c).fillna(0).to_numpy() for c in load_cols])
  303. full_pool = np.column_stack([load_pool, z(ti_col).fillna(0).to_numpy()])
  304. r2lp, _ = _r2(y, load_pool); r2fp, _ = _r2(y, full_pool)
  305. # ★护栏⑤ 台内自比 (消增益) = 主轴
  306. yw = zwin('_y')
  307. load_w = np.column_stack([ones] + [zwin(c) for c in load_cols])
  308. full_w = np.column_stack([load_w, zwin(ti_col)])
  309. r2lw, _ = _r2(yw, load_w); r2fw, bw = _r2(yw, full_w)
  310. within = {'R2_load': round(r2lw, 3), 'R2_plus_TI': round(r2fw, 3),
  311. 'dR2_TI_marginal': round(r2fw - r2lw, 4),
  312. 'TI_coef': round(float(bw[-1]), 3), 'load_coefs': [round(float(bw[i + 1]), 3) for i in range(len(load_cols))]}
  313. if shear_col and shear_col in d:
  314. dm = d.dropna(subset=[shear_col])
  315. if len(dm) > min_n:
  316. def zwm(col):
  317. g = dm.groupby(turbine_col)[col]
  318. return ((dm[col] - g.transform('mean')) / g.transform('std')).fillna(0).to_numpy()
  319. ym = zwm('_y'); om = np.ones(len(dm))
  320. lw = np.column_stack([om] + [zwm(c) for c in load_cols] + [zwm(ti_col)])
  321. fw = np.column_stack([lw, zwm(shear_col)])
  322. r2l2, _ = _r2(ym, lw); r2f2, bf = _r2(ym, fw)
  323. within['dR2_shear_marginal'] = round(r2f2 - r2l2, 4)
  324. within['shear_coef'] = round(float(bf[-1]), 3)
  325. # 台间 (增益污染 → INSUFFICIENT)
  326. per = d.groupby(turbine_col).agg(del_mean=(del_col, 'mean'), ti_mean=(ti_col, 'mean'), n=(del_col, 'size'))
  327. per = per[per['n'] >= min_per_turbine]
  328. r_ti, p_ti, n_ti = spearman(per['ti_mean'], per['del_mean'])
  329. return {
  330. 'mapping': 'E_wind_load_DEL', 'n_windows': int(len(d)), 'n_turbines': int(d[turbine_col].nunique()),
  331. 'within_turbine': within, # 主轴 (护栏⑤消增益)
  332. 'pooled_gain_confounded': {'R2_load': round(r2lp, 3), 'dR2_TI': round(r2fp - r2lp, 4)},
  333. 'inter_turbine': {'TI_exposure_vs_DEL_r': round(r_ti, 3) if r_ti == r_ti else None,
  334. 'p': round(p_ti, 3) if p_ti == p_ti else None,
  335. 'verdict': 'INSUFFICIENT_gain_confounded (绝对DEL跨台不可比, 须加速度计标定)'},
  336. 'guards': ['台内自比z-within消传感器增益(护栏⑤)', 'log(DEL)长尾稳', '相对DEL非绝对寿命(无气弹标定)'],
  337. }
  338. # ----------------------------- D. 双向反馈 (设备 → 风资源) -----------------------------
  339. def _compass_bearing(dx, dy):
  340. """罗盘方位 j→i (0=N 顺时针), dx=Δ东, dy=Δ北."""
  341. return float(np.degrees(np.arctan2(dx, dy)) % 360)
  342. def map_device_to_wind(sector_metric, coords, *, turbine_col, sector_col, metric_col,
  343. n_sectors=8, independent_axis=False, z_thresh=1.5, max_dist=None,
  344. axis='fleet_relative'):
  345. """D 反馈映射: 设备异常方向/空间 pattern → 反推风资源特征(尾流). **护栏⑥ 防循环自证**.
  346. sector_metric: per-台per-扇区 设备指标 DataFrame (metric_col = **独立轴**: DEL/健康残差 by 风向扇区;
  347. sector_col = 风来向扇区 int [0, n_sectors)). coords: DataFrame[turbine_col, x, y] (米/相对, x东 y北).
  348. **护栏⑥ 硬门**: metric 必是独立于风况测量的设备响应(DEL/健康残差, 非 power/机舱风派生, 否则用设备欠发
  349. 反推风况亏空=循环自证)。**调用方须显式 independent_axis=True 声明**, 否则拒(REJECTED_circular)。
  350. **axis (消盛行风混杂)**:
  351. 'fleet_relative' (默认, 推荐): **两向可加残差** = metric − 台主效应 − 扇区主效应 + 总均 → 隔离 台×扇区
  352. 交互(=尾流)。台主效应消增益/offset, 扇区主效应消盛行风+风级共模(盛行风是扇区共模被完全吸收 → 假阳~0)。
  353. 正定量(DEL)自动 log 转乘性增益为加性。**代价**: 尾流须相对同侪可辨(同扇区多台齐受尾流→交互被主效应吸收→recall折损)。
  354. 'within_turbine' (legacy): 单步台内跨扇区 z。**对推力驱动量(fore-aft DEL)被盛行风严重骗**(卡8.6假阳/场)。
  355. 方法: 异常(台,扇区)(z>thresh) → 尾流几何检验: 异常扇区 S(风从S来) → 上风邻机 i(bearing j→i ∈ S, 取最近) → i 尾流 j。
  356. 返回: {wake_pairs, n_anomalous_sectors, independence_ok, axis, ...}.
  357. """
  358. if not independent_axis:
  359. return {'mapping': 'D_device_to_wind', 'verdict': 'REJECTED_circular',
  360. 'note': '护栏⑥: metric 未声明独立轴 → 用设备(可能power派生)反推风况=循环自证, 拒。'
  361. '须传独立轴(DEL/健康残差) + independent_axis=True。'}
  362. sw = 360.0 / n_sectors
  363. cd = {str(r[turbine_col]): (float(r['x']), float(r['y'])) for _, r in coords.iterrows()}
  364. df = sector_metric.copy()
  365. df[turbine_col] = df[turbine_col].astype(str)
  366. if axis == 'fleet_relative':
  367. # 两向可加残差 = metric − 台主效应 − 扇区主效应 + 总均 → 隔离 台×扇区交互(=尾流)。
  368. # 台主效应=增益/offset(消); 扇区主效应=盛行风+风级共模(消, 盛行风被完全吸收 → 假阳~0)。
  369. x = df[metric_col].to_numpy(float)
  370. df['_v'] = np.log(np.clip(x, 1e-9, None)) if np.all(x > 0) else x # 乘性增益(DEL)→log转加性
  371. gm = float(np.nanmean(df['_v']))
  372. tm = df.groupby(turbine_col)['_v'].transform('mean')
  373. sm2 = df.groupby(sector_col)['_v'].transform('mean')
  374. resid = df['_v'] - tm - sm2 + gm
  375. sd = float(resid.std())
  376. df['_z'] = resid / sd if sd > 0 else np.nan
  377. else: # legacy within_turbine: 单步台内跨扇区 z (被盛行风骗)
  378. gt = df.groupby(turbine_col)[metric_col]
  379. sd1 = gt.transform('std')
  380. df['_z'] = np.where(sd1 > 0, (df[metric_col] - gt.transform('mean')) / sd1, np.nan)
  381. anom = df[df['_z'] > z_thresh]
  382. pairs = {}
  383. for _, row in anom.iterrows():
  384. j = str(row[turbine_col]); s = int(row[sector_col])
  385. if j not in cd:
  386. continue
  387. xj, yj = cd[j]; lo, hi = s * sw, (s + 1) * sw
  388. best = None
  389. for i, (xi, yi) in cd.items():
  390. if i == j:
  391. continue
  392. dx, dy = xi - xj, yi - yj
  393. dist = (dx * dx + dy * dy) ** 0.5
  394. if max_dist and dist > max_dist:
  395. continue
  396. brg = _compass_bearing(dx, dy)
  397. if (lo <= brg < hi) and (best is None or dist < best[1]): # 上风邻机方位∈异常扇区, 取最近
  398. best = (i, dist, brg)
  399. if best:
  400. key = (j, s)
  401. pairs[key] = {'upwind': best[0], 'downwind': j, 'sector': s,
  402. 'z': round(float(row['_z']), 2), 'dist': round(best[1], 1),
  403. 'bearing_j_to_i': round(best[2], 1)}
  404. wake_pairs = sorted(pairs.values(), key=lambda p: -p['z'])
  405. return {
  406. 'mapping': 'D_device_to_wind', 'n_turbines': int(df[turbine_col].nunique()),
  407. 'n_anomalous_sectors': int(len(anom)), 'n_wake_pairs': len(wake_pairs),
  408. 'wake_pairs': wake_pairs, 'independence_ok': True, 'axis': axis,
  409. 'guards': ['独立轴防循环自证(护栏⑥, 拒power派生)',
  410. ('两步z消盛行风混杂(fleet_relative默认: 台内→扇区内跨台, 卡实测假阳8.6→~0)'
  411. if axis == 'fleet_relative' else
  412. '⚠legacy within_turbine轴: 推力驱动量被盛行风骗(卡8.6假阳/场), 宜换 fleet_relative'),
  413. '几何尾流检验(上风邻机方位∈异常扇区)', '结论=候选, 须performance-deficit交叉+现场核'],
  414. }