sensor_validation.py 83 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161
  1. # -*- coding: utf-8 -*-
  2. """sensor_validation.py — 传感器校核: 温度传感器方差退化检测 (二阶矩轴).
  3. 裁决对象 = per (机组×通道) **仪表可信度** — 区别于 contract_gate (per点清洗) / health (per机组部件)。
  4. 本模块 = 方差轴: 抓传感器**离差变化** (膨胀=接触噪声/退化, 坍缩=量化/失分辨), 一切均值/level 判据天然盲。
  5. 来源: gym S 赛道 sensor_varshift (EXP-S2-01 净胜 → EXP-S3-05 RV-1 gate CLOSED=HOLD 独立审4check全过 →
  6. 并回 src/ 2026-07-12)。gym 分数≠交付, 本件是走完设计§7四闸 (冻结suite/3原则审/RV-1独立审/labeled DP test)
  7. 后的生产实现; DP 验收 = tests/sop/test_sensor_variance_dp.py。
  8. ★核心主张 (可分性, 载荷解耦 = 仪表签名):
  9. 仪表方差退化 = **载荷无关** (各载荷箱同向离差变化); 真过程故障 (间歇/载荷共动) 只高载箱动。
  10. 故 score = 载荷分箱早late log-MAD 变化的 **min-across-bins** (两向), 去共模 → 真载荷共动故障低载箱≈0
  11. → 不误判为传感器坏 (RV-1 独立审实测: 真6℃故障 misattr 0.0, 仅~100℃非物理才破)。
  12. ★纪律 (RV-1 审 + gym 教训):
  13. - **窗内去趋势 load-bearing** (减 detrend_win 滚动中位): 否则慢均值移/真故障斜坡撑大长窗 std → 假阳。
  14. - **弃权区**: 近 LSB 极静通道 (σ≲0.2℃, 原生量化≈注入坍缩) → 低置信, 调用方须知。
  15. - **红线 (可分性)**: 载荷共动真故障 misattr ≤ cap; 单臂用于方差轴, 非通用仪表判据 (drift/swap 另有臂)。
  16. 纯核心 (sensor_variance_verdict) 无 I/O 可测; per-farm 加载在调用方 (skill/report 层)。
  17. """
  18. from __future__ import annotations
  19. import numpy as np
  20. import pandas as pd
  21. def _fleet_residual(df: pd.DataFrame, channel: str, power_col: str,
  22. rpm_col: str | None, ambient_col: str | None,
  23. gen_mask: np.ndarray):
  24. """fleet OLS: T ~ 1 + P + rpm + T_amb (全机群 gen-only 池化) → 逐行残差。缺列以 0 占位 (退化为可用子集)。"""
  25. y = df[channel].to_numpy(float)
  26. feats = [np.ones(len(df))]
  27. for c in (power_col, rpm_col, ambient_col):
  28. feats.append(df[c].to_numpy(float) if c and c in df.columns else np.zeros(len(df)))
  29. X = np.column_stack(feats)
  30. finite = np.isfinite(y) & np.all(np.isfinite(X), axis=1)
  31. fitmask = finite & gen_mask
  32. if fitmask.sum() < 50:
  33. return None, finite & gen_mask
  34. beta, *_ = np.linalg.lstsq(X[fitmask], y[fitmask], rcond=None)
  35. return y - X @ beta, finite & gen_mask
  36. def _load_bins(power: np.ndarray, ok: np.ndarray, n_bins: int):
  37. edges = np.nanquantile(power[ok], np.linspace(0, 1, n_bins + 1))
  38. edges[0], edges[-1] = -np.inf, np.inf
  39. return np.clip(np.digitize(power, edges) - 1, 0, n_bins - 1)
  40. def _windows(t: pd.Series, early_frac: float, late_frac: float):
  41. days = (t - t.min()).dt.total_seconds().to_numpy() / 86400.0
  42. dmax = max(days.max(), 1.0)
  43. return days <= early_frac * dmax, days >= (1.0 - late_frac) * dmax
  44. def _mad(s: np.ndarray) -> float:
  45. return float(np.median(np.abs(s - np.median(s)))) if len(s) else np.nan
  46. def sensor_variance_verdict(
  47. df: pd.DataFrame, *, channel: str, time_col: str, turbine_col: str, power_col: str,
  48. rpm_col: str | None = None, ambient_col: str | None = None, gen_flag_col: str | None = None,
  49. n_bins: int = 4, early_frac: float = 0.35, late_frac: float = 0.35,
  50. log_thresh: float = 0.30, detrend_win: int = 432, min_cell_n: int = 30,
  51. ) -> dict:
  52. """per-机组 传感器方差退化裁决 (方差轴)。
  53. 返回 {tid: {"score": 载荷无关方差变化幅度(log10, 任一向), "flag": bool, "direction": inflate|collapse,
  54. "n_bins": 参与分箱数}}。
  55. 机制: fleet-NBM 残差 → 窗内去趋势 (减 detrend_win 滚动中位, 防慢均值染离差) → per(台,载荷箱) 稳健离差 MAD
  56. → 早late log10(MAD) 变化 → 去共模 (减 per箱 fleet 中位变化) → **min-across-bins 两向** = 载荷无关分量。
  57. flag = score > log_thresh (0.30 ≈ 离差 ×2)。df 须按 (turbine, time) 排序 (调用方保证或本函数排)。
  58. """
  59. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  60. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  61. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  62. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  63. else np.ones(len(df), dtype=bool))
  64. resid, ok = _fleet_residual(df, channel, power_col, rpm_col, ambient_col, gen)
  65. if resid is None:
  66. return {t: {"score": 0.0, "flag": False, "direction": None, "n_bins": 0} for t in tids}
  67. tid_s = df[turbine_col].astype(str)
  68. # 窗内去趋势: per台 detrend_win 滚动中位 (防慢均值移伪装成离差)
  69. rs = pd.Series(np.where(ok, resid, np.nan))
  70. detr = rs.groupby(tid_s.values).transform(
  71. lambda x: x - x.rolling(detrend_win, min_periods=max(20, detrend_win // 5),
  72. center=True).median()).to_numpy()
  73. binid = _load_bins(df[power_col].to_numpy(float), ok, n_bins)
  74. early, late = _windows(df[time_col], early_frac, late_frac)
  75. sub = pd.DataFrame({"tid": tid_s.values, "bin": binid, "dt": detr, "early": early, "late": late})
  76. sub = sub[np.isfinite(sub["dt"])]
  77. eg = sub[sub["early"]].groupby(["tid", "bin"])["dt"].agg([_mad, "size"])
  78. lg = sub[sub["late"]].groupby(["tid", "bin"])["dt"].agg([_mad, "size"])
  79. raw = {}
  80. for t in tids:
  81. for b in range(n_bins):
  82. if (t, b) in eg.index and (t, b) in lg.index:
  83. em, es = eg.loc[(t, b), "_mad"], eg.loc[(t, b), "size"]
  84. lm, ls = lg.loc[(t, b), "_mad"], lg.loc[(t, b), "size"]
  85. if es >= min_cell_n and ls >= min_cell_n and em > 1e-6 and lm > 1e-6:
  86. raw[(t, b)] = float(np.log10(lm) - np.log10(em))
  87. from collections import defaultdict
  88. bag = defaultdict(list)
  89. for (t, b), v in raw.items():
  90. bag[b].append(v)
  91. binmed = {b: float(np.median(vs)) for b, vs in bag.items()} # per箱 fleet 共模
  92. out = {}
  93. for t in tids:
  94. shifts = [raw[(t, b)] - binmed.get(b, 0.0) for b in range(n_bins) if (t, b) in raw]
  95. if not shifts:
  96. out[t] = {"score": 0.0, "flag": False, "direction": None, "n_bins": 0}
  97. continue
  98. infl = min(shifts) # 全箱膨胀 (载荷无关)
  99. coll = min(-s for s in shifts) # 全箱坍缩
  100. score = max(infl, coll)
  101. out[t] = {"score": round(float(score), 4), "flag": bool(score > log_thresh),
  102. "direction": ("inflate" if infl >= coll else "collapse"), "n_bins": len(shifts)}
  103. return out
  104. def _member_resid_shift(df, channel, power_col, rpm_col, ambient_col, gen_mask,
  105. turbine_col, time_col, early_frac, late_frac):
  106. """单通道 fleet 残差的 per台 早late 均值移 (含载荷相关分量; swap 判别用原始移非载荷无关截距)。"""
  107. resid, ok = _fleet_residual(df, channel, power_col, rpm_col, ambient_col, gen_mask)
  108. if resid is None:
  109. return {}
  110. early, late = _windows(df[time_col], early_frac, late_frac)
  111. tid_s = df[turbine_col].astype(str)
  112. sub = pd.DataFrame({"tid": tid_s.values, "r": resid, "early": early, "late": late, "ok": ok})
  113. sub = sub[sub["ok"]]
  114. e = sub[sub["early"]].groupby("tid")["r"].mean()
  115. l = sub[sub["late"]].groupby("tid")["r"].mean()
  116. return {t: float(l.get(t, 0.0) - e.get(t, 0.0)) for t in sorted(tid_s.unique())}
  117. def sensor_swap_verdict(
  118. df: pd.DataFrame, *, channel_a: str, channel_b: str, time_col: str, turbine_col: str,
  119. power_col: str, rpm_col: str | None = None, ambient_col: str | None = None,
  120. gen_flag_col: str | None = None, early_frac: float = 0.35, late_frac: float = 0.35,
  121. member_shift_thresh: float = 1.0,
  122. ) -> dict:
  123. """per-机组 **通道互换 (接线/标签错) 检测** — 限定于**冗余对** (channel_a, channel_b)。
  124. 返回 {tid: {"swap_flag": bool, "shift_a": 通道a fleet残差早late移, "shift_b": 同b,
  125. "both_opposite": bool, "score": min(|a|,|b|) 当反号否则0}}。
  126. ★scope (3原则审定): 本件**非通用仪表判据**, 只判"这对冗余通道是否被对调"。签名 = 两成员 fleet 残差
  127. **反向移** (一升一降, ∵互换) — 区别于单成员真故障 (只一个动)。gym EXP-S3-06/08: redundancy 检出 +
  128. discrim2 判别; 本函数 = 两者合一的 both-opposite 判据。
  129. ★去共模 (RV-1 gate 逮 → 修): sa/sb **减 per通道 fleet 中位移** 再判反号 — 否则**全场共模早late漂移**
  130. (未建模季节/环温/油脂老化/设定值改) 与单成员真故障叠加会假造"反向", 令真故障被误判 swap。
  131. 实测 (RV-1 独立审复现): 未去共模时共模漂移 ≥1.5℃ 反向 → 单成员真故障 misattr **100%**; 去共模后 → 0。
  132. ★诚实界: (a) 可检度 ∝ 两通道差异 (近同通道趋不可检, score→0); (b) 去共模后可分性稳 (审者原型 60/60→0);
  133. 残余 misattr 仅噪声级; (c) 需 fleet≥~6 台 (中位去共模需多台) + 两通道都在 df; (d) fit 不足→note 显式弃权。
  134. """
  135. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  136. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  137. NULL = {"swap_flag": False, "shift_a": 0.0, "shift_b": 0.0, "both_opposite": False, "score": 0.0}
  138. if channel_a not in df.columns or channel_b not in df.columns:
  139. return {t: {**NULL, "note": "channel missing"} for t in tids}
  140. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  141. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  142. else np.ones(len(df), dtype=bool))
  143. sa = _member_resid_shift(df, channel_a, power_col, rpm_col, ambient_col, gen,
  144. turbine_col, time_col, early_frac, late_frac)
  145. sb = _member_resid_shift(df, channel_b, power_col, rpm_col, ambient_col, gen,
  146. turbine_col, time_col, early_frac, late_frac)
  147. if not sa or not sb: # fleet OLS 不足 → 显式弃权 (非静默全零"干净")
  148. return {t: {**NULL, "note": "insufficient fit"} for t in tids}
  149. med_a = float(np.median(list(sa.values()))) # per通道 fleet 中位 = 共模早late漂移
  150. med_b = float(np.median(list(sb.values())))
  151. out = {}
  152. for t in tids:
  153. ia, ib = sa.get(t, 0.0) - med_a, sb.get(t, 0.0) - med_b # 去共模后再判反向
  154. opp = (ia > 0) != (ib > 0)
  155. score = min(abs(ia), abs(ib)) if opp else 0.0
  156. out[t] = {"swap_flag": bool(opp and score > member_shift_thresh),
  157. "shift_a": round(ia, 3), "shift_b": round(ib, 3),
  158. "both_opposite": bool(opp), "score": round(float(score), 4)}
  159. return out
  160. def _load_indep_intercept(shifts_by_bin: dict, bin_center: dict) -> float:
  161. """载荷分箱 shift 对 bin 中心载荷回归 → 截距 = 载荷无关分量 (可分性统计)。"""
  162. xy = [(bin_center[b], v) for b, v in shifts_by_bin.items() if b in bin_center]
  163. if len(xy) >= 2:
  164. xs = np.array([p[0] for p in xy]); ys = np.array([p[1] for p in xy])
  165. return float(np.polyfit(xs, ys, 1)[1])
  166. if len(xy) == 1:
  167. return float(xy[0][1])
  168. return 0.0
  169. def sensor_drift_verdict(
  170. df: pd.DataFrame, *, channel: str, time_col: str, turbine_col: str, power_col: str,
  171. rpm_col: str | None = None, ambient_col: str | None = None, gen_flag_col: str | None = None,
  172. n_bins: int = 4, win_days: int = 14, studentize_k: float = 3.0, min_cell_n: int = 20,
  173. ) -> dict:
  174. """per-机组 传感器**慢漂移/零位偏置**检测 (load-independent level-shift 轴).
  175. 返回 {tid: {"score": studentized z, "flag": bool, "level_shift": 载荷无关level移(℃), "direction": up|down}}。
  176. 机制 (no black box): fleet-NBM 残差 → 滚动窗(win_days) per载荷箱去共模 → 载荷无关截距 a_w 时序 →
  177. **median(late半)−median(early半)** 鲁棒 level-shift (中位对噪声窗鲁棒, LESSONS#28) →
  178. **fleet-MAD studentize** z=(shift−fleet中位)/fleet-MAD, flag **|z|>k 两侧** (**先验 3-MAD, 非拟合** → 自归一干净离散度+跨场迁移)。
  179. ★两侧 (RV-1 gate 逮 → 修): flag=|z|>k 而非 z>k — 零位偏置**双向** (向上/向下), 单侧会漏向下漂移 (−10℃→z=−71 仍不报); direction 报升/降。
  180. ★可分性: 用载荷无关截距 → 真载荷共动过程故障截距≈0 → 不触发 (misattr 0.0, gym EXP-S3-09 实测双场)。
  181. **界 (RV-1 逮, 诚实)**: 只对 **≈线性/凸** 载荷共动成立; **凹/饱和**载荷共动真故障 (载荷指数 ≲0.85) 线性外推到 load=0
  182. 得伪正截距 → 被误判为向上漂移 (实测 exp0.8→z 8.6 误报)。凹故障需下游部件诊断复核, 不可单凭本臂定"仪表漂移"。
  183. ★caveat (诚实, 根本限制): **安静主轴承类通道** (per台短时 σ≲0.2℃) 的 per-seed 检出 floor ~78%ile =
  184. fleet-relative 根本地板 (idiosyncratic 季节残差 ≈ 慢漂移, 无外部绝对参照不可解, 同 t0/gain 教义);
  185. 有环温建模 + 适度噪声通道最强, 跨部件/无环温转弱 (gym gjd 齿箱 HS 轴承 64.9 vs fushan 88.9)。
  186. """
  187. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  188. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  189. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  190. NULL = {"score": 0.0, "flag": False, "level_shift": 0.0, "direction": None}
  191. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  192. else np.ones(len(df), dtype=bool))
  193. resid, ok = _fleet_residual(df, channel, power_col, rpm_col, ambient_col, gen)
  194. if resid is None:
  195. return {t: {**NULL, "note": "insufficient fit"} for t in tids}
  196. P = df[power_col].to_numpy(float)
  197. binid = _load_bins(P, ok, n_bins)
  198. bin_center = {b: float(np.nanmedian(P[ok & (binid == b)])) for b in range(n_bins)
  199. if (ok & (binid == b)).any()}
  200. t0 = df[time_col].min()
  201. day = (df[time_col] - t0).dt.total_seconds().to_numpy() / 86400.0
  202. winid = (day // win_days).astype(int)
  203. n_win = int(winid.max()) + 1
  204. tid_s = df[turbine_col].astype(str)
  205. sub = pd.DataFrame({"tid": tid_s.values, "win": winid, "bin": binid, "resid": resid, "ok": ok})
  206. sub = sub[sub["ok"]]
  207. g = sub.groupby(["tid", "win", "bin"])["resid"].agg(["mean", "size"])
  208. cell = {k: row["mean"] for k, row in g.iterrows() if row["size"] >= min_cell_n}
  209. from collections import defaultdict
  210. bag = defaultdict(list)
  211. for (t, w, b), val in cell.items():
  212. bag[(w, b)].append(val)
  213. fleet_med = {k: float(np.median(vs)) for k, vs in bag.items()} # per(win,bin) 共模
  214. half = n_win / 2.0
  215. level = {}
  216. for t in tids:
  217. e_aw, l_aw = [], []
  218. for w in range(n_win):
  219. shifts = {b: cell[(t, w, b)] - fleet_med[(w, b)] for b in range(n_bins)
  220. if (t, w, b) in cell and (w, b) in fleet_med}
  221. if shifts:
  222. aw = _load_indep_intercept(shifts, bin_center)
  223. (e_aw if w < half else l_aw).append(aw)
  224. level[t] = (float(np.median(l_aw) - np.median(e_aw)) if len(e_aw) >= 2 and len(l_aw) >= 2 else 0.0)
  225. vals = np.array(list(level.values()), dtype=float)
  226. med = float(np.median(vals))
  227. scale = max(float(1.4826 * np.median(np.abs(vals - med))), 0.05)
  228. out = {}
  229. for t in tids:
  230. s = level[t]
  231. z = (s - med) / scale
  232. out[t] = {"score": round(float(z), 4), "flag": bool(abs(z) > studentize_k), # 两侧 (RV-1 修): 向下漂移不漏
  233. "level_shift": round(float(s), 4), "direction": ("up" if s > 0 else "down")}
  234. return out
  235. def sensor_gear_parity_verdict(
  236. df: pd.DataFrame, *, genrpm_col: str, rotor_col: str, time_col: str, turbine_col: str,
  237. gen_flag_col: str | None = None, load_col: str | None = None,
  238. min_genrpm: float = 50.0, min_rotor: float = 2.0,
  239. early_frac: float = 0.35, late_frac: float = 0.35,
  240. rel_shift_thresh: float = 0.004, disp_thresh: float = 0.7,
  241. min_cell_n: int = 50, n_shift_bins: int = 5, min_bin_n: int = 30,
  242. min_overlap_frac: float = 0.35,
  243. ) -> dict:
  244. """per-机组 **转速/编码器校核** — 齿比恒等式 genrpm ≡ k·rotor (齿轮箱机械定比, 硬物理奇偶).
  245. 返回 {tid: {"score": 双轴归一 max(≥1=坏), "flag": bool, "ratio_shift_rel": 共同支撑齿比**相对**移(箱间中位),
  246. "disp_log": log10(MAD_late/MAD_early) on 共同支撑, "axis": shift|disp, "direction": up|down,
  247. "fleet_ratio": 该台中位齿比, "fleet_dev_rel": 相对fleet偏(**非判据**), "n_e"/"n_l": 窗内样本}}。
  248. ★机制 (no black box): per台 r = genrpm/rotor (gen ∧ rotor>min ∧ genrpm>min); **两轴均在 early∩late 载荷
  249. 共同支撑上算** (RV-1 round-4 结构性修, 见下), flag 当 max(两轴归一)>1:
  250. - **共同支撑 = [max(early_lo,late_lo), min(early_hi,late_hi)]** (各窗 2-98 分位); 覆盖 < min_overlap_frac×窄窗跨度
  251. → 窗间载荷分布不可比 → **两轴弃权** (flag=False + note, INSUFFICIENT), 不硬判。
  252. - **主轴 shift** = |共同支撑上**等宽分箱** median(r|late)−median(r|early) 的箱间中位| / fleet中位齿比 / rel_shift_thresh。
  253. 真**均匀** offset/gain 各载荷箱同向移 → 箱间中位保留 (需 ≥2 箱一致符号, 否则判 0); 载荷分布 confound → 箱内
  254. 载荷匹配 → ≈0 归零。
  255. - **弥散轴 disp** = max(0, log10 MAD_late/MAD_early) on 共同支撑 / disp_thresh。freeze 某轴 → r=const/另一轴
  256. 随另一轴弥散爆 → MAD 比升; **共同支撑上算 = 消掉"窗间载荷分布跨度差致 r 散布差"的 disp confound** (log-ratio 标度无关)。
  257. ★confound 根因与修 (RV-1 3 轮独立审逼出, 核心教训): 齿比 r 若载荷无关 (纯机械编码器) 则恒闭; 但 genrpm 若为**电气
  258. 推算转速** (滑差载荷耦合, 区域2 滑差∝load²) 则 r 载荷相关 → "窗间载荷分布移 (季节/尾流/限电) × 载荷相关 r" =
  259. confound。**早期分位分箱有无界端箱** (edges=±inf) 窗间载荷不匹配 → 近不重叠窗 (early5→late14m/s) 泄漏整场误报;
  260. disp 池化全幅同理被窗间散布差污染。**共同支撑等宽分箱 = 每箱两窗都有覆盖 → 结构性根除两泄漏** (实测: 5→14 窗
  261. quad滑差±5% 从 59/60 误报 → 0; narrow→wide disp 泄漏 10/10 → 0; freeze/offset 检出全保)。
  262. ★可分性 (王牌, 精确代数 vs 温度统计式): 真转速共变两轴同乘 g → r=(g·genrpm)/(g·rotor)=r **恒等不变**
  263. → 各箱 shift≈0 ∧ disp≈0 → 天然免疫 (gym EXP-R1-01 INV-sep misattr 0.0; RV-1 EXP-C 复现: 真15%共变 misattr 0)。
  264. ★诚实界 (3原则审 + RV-1 独立审 4 轮定):
  265. - **load-bearing 前提 (RV-1 逮, 必声明)**: genrpm 宜为**高速轴机械编码器** (齿轮箱刚性耦合 → r 严格载荷无关, 恒闭);
  266. 若为**电气推算转速** (定子频率+滑差反推), 滑差载荷耦合使 r 载荷相关 → 靠**共同支撑分箱**兜 (箱内载荷匹配)。
  267. - **confound 免疫 (RV-1 round-4 结构修后实测)**: 共同支撑分箱对 DFIG 电气推算转速滑差 (线性∧区域2二次形) **至 ±5%
  268. 全窗 (含近不重叠 5→14) misattr=0** (物理 DFIG 滑差仅 ±1-2%, ≥2.5× 余量); 窗间重叠不足则弃权非误报。
  269. - **局部故障检出地板 (RV-1 FINDING2, 已声明安全方向)**: **只破 <3/5 载荷箱的单侧故障** (如高速编码器高转速丢数)
  270. 会被 broad 轴箱间 median 洗掉 → 漏检 (方向安全=不误报, 非红线; candidate-screening, 靠下游部件诊断补)。
  271. **曾试** max|单箱| 副轴兜之, RV-1 round-3 逮: 二次滑差 confound 无界箱单箱破 (≤7×阈) 与真局部故障单箱破 (14×阈)
  272. 幅值重叠, 无单一阈可分 → 会重开 FINDING1 红线 → **撤回**。局部检出留 backlog (窗内残差回归/时序持续)。
  273. - **pinned-load 检出缺口 (RV-1 round-5, 安全方向)**: late 窗载荷全 pin 在 rated (单一载荷水平, per台方差极小) →
  274. 共同支撑内 <2 箱 → shift 轴弃权 (note 标注, **非静默漏**); 此时均匀 offset 可能漏检 (无第二载荷水平校齿比) →
  275. 属"单载荷水平不可校验"的诚实 INSUFFICIENT, 方差回升即恢复。
  276. - **disp 免疫 scope (RV-1 round-5)**: confound 免疫声明限 **光滑滑差 (线性∧区域2二次形)**; 病态**非光滑 (阶跃不连续)
  277. 滑差**在超包线幅 (≥±7%, 远超物理 ±1-2%) 可偶触 disp 轴 (实测 step±7% 1/480) → 真 DFIG 滑差光滑, 属超范围病态。
  278. - go/no-go **不定位哪一路错** (gen 还是 rotor); 需功率/频率第三参照。
  279. - **fleet_dev_rel = 副字段非判据**: 静态恒偏可能真硬件异质 (齿比铭牌/编码器线数不同), 无铭牌不可辨
  280. (同温度 levelparity REJECT + power-curve NTF 无绝对参照 → INSUFFICIENT 教义)。
  281. - **适用范围 = 齿轮箱机型 (k≫1)**; 直驱 (k≈1, genrpm≈rotor 常同一传感器反推) **超范围**, 调用方须自行判。
  282. - 需 rotor 列在 df ∧ fleet≥~6 台 (相对化用 fleet 中位齿比); 缺则 note 显式弃权 (非静默全零"干净")。
  283. 来源: gym R 赛道 sensor_gear_parity (EXP-R1-01, pctl 96 零 seed 方差 → §7 四闸 → 并回 src/ 2026-07-12)。
  284. 产化改动 (对 gym champion, 均经 RV-1 独立审 4 轮): (a) shift 轴绝对→**相对** (÷fleet中位齿比) 跨机 k 迁移, 同 gjd
  285. 净场地板定标 (0.5/117.64≈0.42%); (b) 两轴改**共同支撑等宽分箱** (FINDING1 + 窗间不匹配泄漏结构性修, gym 单场无);
  286. (c) 重叠不足 → 双轴弃权非发假 flag。DP 验收 = tests/sop/test_sensor_gear_parity_dp.py (含 slip×窗 misattr=0 回归闸
  287. + FINDING2 局部漏检 known-limitation 锁定)。
  288. """
  289. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  290. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  291. NULL = {"score": 0.0, "flag": False, "ratio_shift_rel": 0.0, "disp_log": 0.0,
  292. "axis": None, "direction": None, "fleet_ratio": None, "fleet_dev_rel": 0.0, "n_e": 0, "n_l": 0}
  293. if genrpm_col not in df.columns or rotor_col not in df.columns:
  294. return {t: {**NULL, "note": "genrpm/rotor 列缺; abstain"} for t in tids}
  295. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  296. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  297. else np.ones(len(df), dtype=bool))
  298. a = df[genrpm_col].to_numpy(float)
  299. b = df[rotor_col].to_numpy(float)
  300. ok = gen & np.isfinite(a) & np.isfinite(b) & (b > min_rotor) & (a > min_genrpm)
  301. with np.errstate(divide="ignore", invalid="ignore"):
  302. r = np.where(ok, a / b, np.nan) # b>min_rotor 已守; errstate 静默被掩位
  303. fleet_med = float(np.nanmedian(r[ok])) if ok.any() else np.nan
  304. if not np.isfinite(fleet_med) or fleet_med <= 0:
  305. return {t: {**NULL, "note": "fleet 齿比不可估; abstain"} for t in tids}
  306. early, late = _windows(df[time_col], early_frac, late_frac)
  307. tv = df[turbine_col].astype(str).to_numpy()
  308. # 载荷代理: 显式 load_col (>rated 更佳) 否则 rotor (region2 单调于风/载荷, 自足无需额外列)
  309. load_arr = (df[load_col].to_numpy(float) if (load_col and load_col in df.columns) else b)
  310. def _cs_axes(re_r, re_l, rl_r, rl_l):
  311. """early∩late **载荷共同支撑**上算 shift + disp (RV-1 round-4 结构修: 消无界箱/池化的窗间不匹配 confound)。
  312. 返回 (shift, disp, note): 重叠不足 → (None, None, 弃权); 共同支撑内箱<2 → (None, disp, shift弃权) disp 仍算。
  313. shift=箱间 median (均匀故障各箱同向→保留; confound→箱内匹配≈0); disp=共同支撑上 MAD 比 (freeze 抓, 消散布confound)。"""
  314. if len(re_l) < 2 * min_bin_n or len(rl_l) < 2 * min_bin_n:
  315. return None, None, "弃权(载荷样本不足)"
  316. e_lo, e_hi = np.nanpercentile(re_l, [2, 98])
  317. l_lo, l_hi = np.nanpercentile(rl_l, [2, 98])
  318. lo, hi = max(e_lo, l_lo), min(e_hi, l_hi)
  319. narrow = min(e_hi - e_lo, l_hi - l_lo)
  320. if not (hi > lo) or narrow <= 0 or (hi - lo) < min_overlap_frac * narrow:
  321. return None, None, "弃权(窗间载荷重叠不足→INSUFFICIENT)"
  322. em = (re_l >= lo) & (re_l <= hi); lm = (rl_l >= lo) & (rl_l <= hi)
  323. rer, rlr = re_r[em], rl_r[lm] # 共同支撑内样本 (两窗同载荷范围)
  324. mad_e, mad_l = _mad(rer), _mad(rlr) # disp: 共同支撑上 MAD 比 (消窗间散布 confound)
  325. disp = (float(np.log10(mad_l / mad_e)) if (mad_e and mad_l and mad_e > 1e-9 and mad_l > 1e-9) else 0.0)
  326. edges = np.linspace(lo, hi, n_shift_bins + 1) # 共同支撑上**等宽箱** (无无界端箱 → 每箱两窗都覆盖)
  327. eb = np.clip(np.digitize(re_l[em], edges) - 1, 0, n_shift_bins - 1)
  328. lb = np.clip(np.digitize(rl_l[lm], edges) - 1, 0, n_shift_bins - 1)
  329. sb = []
  330. for bn in range(n_shift_bins):
  331. reb = rer[eb == bn]; rlb = rlr[lb == bn]
  332. if len(reb) >= min_bin_n and len(rlb) >= min_bin_n:
  333. sb.append(float(np.median(rlb) - np.median(reb)))
  334. if len(sb) < 2:
  335. return None, disp, "shift弃权(共同支撑<2箱); disp仍算"
  336. med = float(np.median(sb))
  337. if med == 0.0:
  338. return 0.0, disp, None
  339. frac_same = float(np.mean(np.sign(sb) == np.sign(med))) # 各载荷箱同向 = 真单侧故障签名
  340. return (med if frac_same >= 0.6 else 0.0), disp, None # 箱间符号不一致 (载荷confound/freeze) → 0
  341. out = {}
  342. for t in tids:
  343. sel = (tv == t) & ok
  344. e_m, l_m = sel & early, sel & late
  345. re = r[e_m]; rl = r[l_m]; re_l = load_arr[e_m]; rl_l = load_arr[l_m]
  346. fe = np.isfinite(re) & np.isfinite(re_l); fl = np.isfinite(rl) & np.isfinite(rl_l)
  347. re, re_l = re[fe], re_l[fe]; rl, rl_l = rl[fl], rl_l[fl]
  348. per_med = float(np.median(r[sel][np.isfinite(r[sel])])) if sel.any() else np.nan
  349. if len(re) < min_cell_n or len(rl) < min_cell_n:
  350. out[t] = {**NULL, "fleet_ratio": (round(per_med, 4) if np.isfinite(per_med) else None),
  351. "n_e": int(len(re)), "n_l": int(len(rl)), "note": "窗内样本不足"}
  352. continue
  353. shift, disp, snote = _cs_axes(re, re_l, rl, rl_l) # 两轴均在共同支撑上算
  354. rel_shift = (shift / fleet_med) if shift is not None else 0.0 # 相对化: 跨机 k 迁移
  355. shift_norm = (abs(rel_shift) / rel_shift_thresh) if shift is not None else 0.0
  356. disp_norm = (max(0.0, disp) / disp_thresh) if disp is not None else 0.0
  357. score = max(shift_norm, disp_norm)
  358. fleet_dev_rel = ((per_med - fleet_med) / fleet_med if np.isfinite(per_med) else 0.0)
  359. rec = {"score": round(float(score), 4), "flag": bool(score > 1.0),
  360. "ratio_shift_rel": round(rel_shift, 6), "disp_log": round(float(disp), 4) if disp is not None else None,
  361. "axis": ("disp" if disp_norm >= shift_norm else "shift"),
  362. "direction": ("up" if (shift or 0.0) > 0 else "down"),
  363. "fleet_ratio": round(per_med, 4), "fleet_dev_rel": round(float(fleet_dev_rel), 6),
  364. "n_e": int(len(re)), "n_l": int(len(rl))}
  365. if snote:
  366. rec["note"] = snote
  367. out[t] = rec
  368. return out
  369. def _phase_derived_rel(L, bar, sel, min_n: int = 100) -> float:
  370. """per-台/段 三相**最小相对 pair-diff** = min_{i<j} median|l_i−l_j| / median(bar)。
  371. 派生/复制相 (同一测量写多列, 仅舍入差) → →0; 独立测量 → ≥~1e-3 (gucheng 真盘双峰隙 24×)。
  372. 样本 <min_n 或 bar 非正 → **inf** (不可判; 令调用方守卫 `rel<tol` 退化为 False = 既有"非派生"语义)。"""
  373. if sel.sum() < min_n:
  374. return np.inf
  375. b = float(np.median(bar[sel]))
  376. if not np.isfinite(b) or b <= 0:
  377. return np.inf
  378. k = L.shape[1]
  379. best = np.inf
  380. for i in range(k):
  381. for j in range(i + 1, k):
  382. r = float(np.median(np.abs(L[sel, i] - L[sel, j]))) / b
  383. if r < best:
  384. best = r
  385. return best
  386. def _phase_derived_guard(L, bar, sel, rel_tol: float = 1e-4) -> bool:
  387. """per-台**派生/复制相守卫** (RV-1 E2 审逮): 任一相对 median|l_i−l_j|/median(bar) < rel_tol →
  388. 疑似同一测量写多列 (仅舍入差) → **parity/level/swap 全不可证** (m≡0 是伪"干净")。
  389. gucheng 真盘 11/24 台如此 (rel ~3e-6..5e-5), 与独立台 (≥3.7e-4) 双峰隙 7× → 1e-4 不误伤。
  390. 教训: 前提核查必 **per-台** — 行级 fleet 池化 ("23%行相等→独立") 掩盖 per-台双峰 = 聚合层错误
  391. (3 First Principles #2)。核心判据抽入 _phase_derived_rel (契约层 channel_semantics 按月复用同判据)。"""
  392. return _phase_derived_rel(L, bar, sel) < rel_tol
  393. def sensor_phase_parity_verdict(
  394. df: pd.DataFrame, *, phase_cols: list, power_col: str, time_col: str, turbine_col: str,
  395. gen_flag_col: str | None = None, gen_power_min: float = 50.0, min_ibar: float = 10.0,
  396. early_frac: float = 0.35, late_frac: float = 0.35, thresh: float = 0.02,
  397. n_bins: int = 4, min_bin_n: int = 30, min_overlap_frac: float = 0.35,
  398. ) -> dict:
  399. """per-机组 **功率/电气传感器校核** — 三相电流平衡奇偶 (l1≈l2≈l3, 天然内建冗余三元组).
  400. 返回 {tid: {"score": 去共模载荷匹配后 imbalance 早late移, "flag": bool, "suspect_phase": 破平衡的相列名,
  401. "phase_dev_shift": 该相 signed 相对偏移, "n_load_bins": 参与箱数, "note"?: 弃权原因}}。
  402. ★机制 (no black box): imbalance m = max_i|l_i/Ibar − 1| (Ibar=三相均值); **标度不变** (三相同缩 → l_i/Ibar
  403. 逐相不变 → m 逐位不变)。单相 CT 故障 (gain/bias/drift/freeze) 破单相幅值平衡 → m 抬升; 三相共缩 (真负载变化)
  404. → m 恒不变。score = **共同支撑载荷分箱 early-late median(m) 移**的箱间中位 (**不去共模**, 见下)。
  405. 定位: per台 late-early 各相 signed dev 均值移, 最大绝对者 = suspect_phase (**仅 ≥3 相报**, 见诚实界)。
  406. ★可分性 (王牌, 物理零点锚定 vs 温度经验 fleet 均值): 三相共缩×g → m 逐位不变 → misattr 精确 0
  407. (gym EXP-E1-01: 共缩×1.15 misattr 0.0 全seed; 物理预言, 不靠 fleet 建模质量 → 比温度 fleetdrift 无环温跨场
  408. REJECT 的软肋更硬)。奇偶地板锚在**物理零点 (平衡)** 非经验 fleet 均值 → **无需 fleet 相对化** — 这正是
  409. **不去共模**的依据 (RV-1 逮): 去共模对 confound 免疫**零贡献** (箱内载荷匹配已消, A/B 实测 0/60 不变), 却把
  410. ≥~50% 机群**批次共性 CT 故障**中位减掉 → 归零遮蔽 (5/10→0, 8/10→0 检出; 删后全恢复且零新 FP) — 复现 memory
  411. relative-criterion-batch-blindness (批次退化场 fleet 基线污染致相对判据盲), 同齿比 round-3 local 轴 = 净负增项。
  412. ★confound 防护 (承齿比 §7 LESSONS #42, 主动套用): 真 CT 噪声是**加性/精度等级** (低电流相对误差大) → m 对健康机
  413. **载荷相关** (低载 m 地板抬, 实测低/高载比 ~7×); "窗间载荷分布移 (季节/限电) × 载荷相关 m" = confound。双重防护:
  414. (a) **共同支撑等宽分箱** (early∩late 功率重叠区, 无无界端箱 → 每箱两窗都覆盖; 重叠 < min_overlap_frac×窄窗 →
  415. 弃权 INSUFFICIENT); (b) **箱内载荷匹配守卫** (箱内 early/late 功率中位差 >半箱宽 → 该箱剔除)。RV-1 实测: 真载荷
  416. 相关 m (加性/CT-class) × 近不重叠窗 × 6seed → 0/60 misattr, 双重防护足, 无需第三重。
  417. ★诚实界 (3原则审 + RV-1 独立审定):
  418. - **只检 onset 起故障, 盲于 t0 起恒定单相偏** (early-late 差分, 同 S 赛道 levelparity/齿比静态恒偏 =
  419. 无外部绝对参照 INSUFFICIENT; E2 level 轴待做)。
  420. - **suspect_phase 仅 ≥3 相报** (RV-1 逮): 2 相时 Ibar=mean(l1,l2) → 单相故障 δ 致 dev1=+δ/2, dev2=−δ/2
  421. **等幅反号** → argmax 由噪声定 = 抛硬币 (实测 40% 正确) → len(ph)==2 时 suspect_phase=None (检出 score 仍
  422. 有效, 只是不定位)。双相同时故障时 suspect 只报最大者, 会误导"只一相坏" — 候选非定论。
  423. - **检出地板 (RV-1 定标, 加性噪声)**: gain+5% 在真加性噪声下 score≈0.0185 vs 阈 0.02 → margin 仅 ~1.5× →
  424. 低幅 gain (≲5%) 可能欠阈漏检 (安全方向); 乘性噪声环境 (干净 CT) 下同幅检出。
  425. - **数据前提 (RV-1 E2 审更正)**: 须**实测三相非派生/复制** — 早先"gucheng l1==l2 仅23%行→独立成立"是
  426. **聚合层错误** (行级 fleet 池化掩盖 per-台双峰; 3 First Principles #2): per-台实测 gucheng **11/24 台复制相**
  427. (rel pair-diff ~3e-6, 仅舍入差) vs 13/24 独立 (≥3.7e-4), 双峰隙 7× → 本函数内建 _phase_derived_guard
  428. (rel<1e-4 → INSUFFICIENT note 非静默"干净")。独立台的极平衡 (自然地板≤~1.6%) → 低幅故障可能欠阈。
  429. - **近不重叠窗 → 弃权非误报** (共同支撑修, 承齿比 round-4); go/no-go 只判"三相不平衡", suspect_phase 是**候选**
  430. (需现场核 CT vs grid 电压不平衡/绕组); 需 ≥2 相在 df ∧ power 列 (不需 fleet 相对化 → 单台也可判)。
  431. - **suspect_phase 在"两相同偏"模式下恰指唯一健康相** (gucheng 真盘 GC11/GC15 电压 episode 实证, 2026-07-15
  432. 场级 review): dev 相对 bar 归一 → 两相同低时最大|dev|是**没动的第三相** → 定位反指。破解 = **fleet 同窗
  433. 绝对量对照** (per相 vs 其余台中位) 定哪相动, 属调用方 review 层步骤, 本函数 suspect 仅"哪相与其余不一致"候选。
  434. - **相接线 swap (l1↔l2 双相反向)** 本臂不专门判 (E2 队列); 单相故障族 (gain/bias/drift/freeze) 是本臂 scope。
  435. 来源: gym E 赛道 sensor_phase_parity (EXP-E1-01, pctl95.8/recall1.0/fp0.0 + suspect_phase l1全命中 → §7四闸 →
  436. 并回 src/ 2026-07-15)。产化改动 (对 gym champion, 主动套齿比 §7 教训 + RV-1 审): (a) 载荷分箱改**共同支撑等宽**
  437. (gym 无界分位箱 → 近不重叠窗泄漏, 齿比 RV-1 逮同族); (b) 加箱内载荷匹配守卫 + 重叠不足弃权; (c) **删 gym 的
  438. 去共模** (RV-1 A/B 证冗余且遮蔽批次故障); (d) 2 相不报 suspect_phase。DP 验收 =
  439. tests/sop/test_sensor_phase_parity_dp.py (confound 闸用真加性噪声 + 批次检出回归闸)。
  440. """
  441. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  442. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  443. NULL = {"score": 0.0, "flag": False, "suspect_phase": None, "phase_dev_shift": 0.0, "n_load_bins": 0}
  444. ph = [c for c in phase_cols if c in df.columns]
  445. if len(ph) < 2 or power_col not in df.columns:
  446. return {t: {**NULL, "note": "三相通道<2 或缺功率列; abstain"} for t in tids}
  447. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  448. L = np.column_stack([df[c].to_numpy(float) for c in ph])
  449. with np.errstate(invalid="ignore", divide="ignore"):
  450. Ibar = np.nanmean(L, axis=1)
  451. dev = L / Ibar[:, None] - 1.0 # 逐相 signed 相对偏 (定位用)
  452. m = np.nanmax(np.abs(dev), axis=1) # imbalance (标度不变)
  453. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  454. else (df[power_col].to_numpy(float) > gen_power_min))
  455. ok = gen & np.isfinite(m) & (Ibar > min_ibar)
  456. P = df[power_col].to_numpy(float)
  457. early, late = _windows(df[time_col], early_frac, late_frac)
  458. tv = df[turbine_col].astype(str).to_numpy()
  459. e_ok, l_ok = ok & early, ok & late
  460. if e_ok.sum() < 2 * min_bin_n or l_ok.sum() < 2 * min_bin_n:
  461. return {t: {**NULL, "note": "窗内 gen 样本不足; abstain"} for t in tids}
  462. # fleet-wide 共同支撑 (承齿比 round-4: 无无界端箱, 每箱两窗都覆盖)
  463. e_lo, e_hi = np.nanpercentile(P[e_ok], [2, 98])
  464. l_lo, l_hi = np.nanpercentile(P[l_ok], [2, 98])
  465. lo, hi = max(e_lo, l_lo), min(e_hi, l_hi)
  466. narrow = min(e_hi - e_lo, l_hi - l_lo)
  467. if not (hi > lo) or narrow <= 0 or (hi - lo) < min_overlap_frac * narrow:
  468. return {t: {**NULL, "note": "窗间功率重叠不足→INSUFFICIENT"} for t in tids}
  469. edges = np.linspace(lo, hi, n_bins + 1)
  470. binid = np.clip(np.digitize(P, edges) - 1, 0, n_bins - 1)
  471. in_cs = (P >= lo) & (P <= hi)
  472. match_tol = 0.5 * (hi - lo) / n_bins # 箱内载荷匹配容差 = 半箱宽
  473. # per台 per箱 早late median(m) 移 (共同支撑内 + 箱内载荷匹配守卫)
  474. raw_shift = {t: {} for t in tids}
  475. for t in tids:
  476. base = (tv == t) & ok & in_cs
  477. for b in range(n_bins):
  478. e_in = base & early & (binid == b); l_in = base & late & (binid == b)
  479. if e_in.sum() >= min_bin_n and l_in.sum() >= min_bin_n:
  480. if abs(float(np.median(P[e_in])) - float(np.median(P[l_in]))) <= match_tol: # 箱内载荷真匹配
  481. raw_shift[t][b] = float(np.median(m[l_in]) - np.median(m[e_in]))
  482. # 不去共模 (RV-1 逮): m 锚物理零点无需 fleet 相对化; 去共模对 confound 零贡献 (箱内匹配已消) 且遮蔽批次共性 CT 故障
  483. out = {}
  484. for t in tids:
  485. shifts = list(raw_shift[t].values())
  486. base = (tv == t) & ok & in_cs
  487. if _phase_derived_guard(L, Ibar, (tv == t) & ok): # RV-1 E2 逮: 复制相 m≡0 = 伪"干净"
  488. out[t] = {**NULL, "note": "疑似派生/复制相 (rel pair-diff <1e-4): parity 不可证 → INSUFFICIENT"}
  489. continue
  490. if not shifts:
  491. out[t] = {**NULL, "note": "共同支撑内无载荷匹配箱; abstain"}
  492. continue
  493. score = float(np.median(shifts))
  494. rec = {"score": round(score, 5), "flag": bool(score > thresh), "n_load_bins": len(shifts),
  495. "suspect_phase": None, "phase_dev_shift": 0.0}
  496. if len(ph) >= 3: # 定位仅 ≥3 相 (RV-1 逮: 2相 dev 等幅反号=抛硬币)
  497. eb, lb = base & early, base & late # late-early 各相 signed dev 均值移
  498. if eb.sum() >= min_bin_n and lb.sum() >= min_bin_n:
  499. with np.errstate(invalid="ignore"):
  500. psh = np.nanmean(dev[lb], axis=0) - np.nanmean(dev[eb], axis=0)
  501. if not np.all(np.isnan(psh)):
  502. j = int(np.nanargmax(np.abs(psh)))
  503. rec["suspect_phase"] = ph[j]; rec["phase_dev_shift"] = round(float(psh[j]), 5)
  504. out[t] = rec
  505. return out
  506. def sensor_phase_level_verdict(
  507. df: pd.DataFrame, *, phase_cols: list, power_col: str, time_col: str, turbine_col: str,
  508. gen_flag_col: str | None = None, gen_power_min: float = 50.0, min_ibar: float = 10.0,
  509. level_thresh: float = 0.03, min_n: int = 200,
  510. ) -> dict:
  511. """per-机组 **三相电流/电压 level 校核** — 全窗绝对 imbalance vs **物理零点** (t0 起恒定单相偏可检).
  512. 返回 {tid: {"score": 高载半区 median(m)/level_thresh (≥1=坏), "flag": bool, "level_m": 绝对不平衡水平,
  513. "suspect_phase": 恒偏相 (仅≥3相), "phase_dev_level": 该相 signed dev, "n": 样本, "note"?: 弃权/低置信}}。
  514. ★机制 (no black box): m = max_i|l_i/Ibar−1|; score = per台**高载半区** (P ≥ fleet gen 中位) 的 median(m) /
  515. level_thresh。全窗量无窗差分 → **onset 差分法盲的 t0 恒偏可检** (gym EXP-E2-01 交叉印证: 差分法对 t0 场景
  516. pctl 30-45 盲, level 轴 95.8)。
  517. ★教义突破依据 (EXP-E2-01 TRUE): 温度 levelparity REJECT 的根因 = 无外部绝对参照 (per台基线异质不可解);
  518. 电气 m 锚**物理零点 (三相平衡)** + 场健康地板窄 → 绝对阈 a-priori 可定 (0.03 ≈ 2×全窗最坏健康台,
  519. gucheng 定标非 tune)。可分性代数继承: 真负载共缩 → m 逐位不变 → level 不变 (misattr 0 实测)。
  520. ★高载半区 (产化硬化, 送审前 probe 逮): 加性 CT 噪声 → m 地板**载荷相关** (gucheng 最低载箱健康地板 max
  521. 0.0274 = 距阈仅 1.1×; 高载半区 0.0086 = 3.5× margin) → 纯限电/低载分布台全窗 median 会被噪声地板顶到贴阈
  522. → **主判据取高载半区** (仪表恒偏载荷无关, 高载 = 噪声地板最低的干净估计); 高载样本 < min_n →
  523. **INSUFFICIENT (flag 强制 False, score+note 照报)** — 低载数据物理上分不清噪声地板与恒偏, 不 flag
  524. (DP 逮回退发 flag 的 bug → 修为弃权语义)。
  525. ★诚实界:
  526. - **t0 恒偏与"真·自 t0 存在的电气不平衡" (绕组不对称/持续 grid 不平衡) 不可分** — go/no-go 只判"持续
  527. 超地板不平衡", suspect_phase 是候选 (需现场核 CT/VT 校验 vs 绕组/电网侧; 同 E1 grid 裁决语义)。
  528. - **阈 gucheng 定标 (明阳 MY2.0 geared)**: 跨场先核该场健康地板 (per台 median m 分布) 再用; 地板 >0.015
  529. 的场须重定标。
  530. - suspect_phase 仅 ≥3 相报 (2 相 dev 等幅反号, 同 sensor_phase_parity_verdict RV-1 教训); **全相噪声型
  531. 退化 (CT 整体噪声↑而非单相偏) 也会 flag (正当: 仪表真退化), 但此时 suspect_phase 随机无意义** (RV-1 逮);
  532. **"两相同偏"模式下 suspect 恰指唯一健康相** (dev 相对归一的结构性反指; gucheng GC11/GC15 电压 episode
  533. 实证 — 破解 = fleet 同窗绝对量对照, review 层步骤); 电压通道同函数可用 (phase_cols 换电压列 + min_ibar 调 ~100)。
  534. - **复制相守卫**: _phase_derived_guard (rel pair-diff <1e-4) → INSUFFICIENT note (gucheng 11/24 台复制相,
  535. m≡0 是伪"干净"; RV-1 E2 逮, 前提核查必 per-台)。
  536. - 需 ≥2 相 ∧ power 列; 缺/样本不足 → note 显式弃权。
  537. 来源: gym E2 赛道 sensor_phase_level (EXP-E2-01 TRUE: t0 gain×1.10/×1.05 均 pctl 95.8×3seed, clean fp 0,
  538. 共缩 misattr 0 → §7 四闸 → 并回 src/ 2026-07-15)。产化改动: 全窗 median → **高载半区 median** (低载噪声地板
  539. confound, probe 实测 0.0274 贴阈)。DP 验收 = tests/sop/test_sensor_phase_level_dp.py。
  540. """
  541. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  542. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  543. NULL = {"score": 0.0, "flag": False, "level_m": 0.0, "suspect_phase": None,
  544. "phase_dev_level": 0.0, "n": 0}
  545. ph = [c for c in phase_cols if c in df.columns]
  546. if len(ph) < 2 or power_col not in df.columns:
  547. return {t: {**NULL, "note": "相通道<2 或缺功率列; abstain"} for t in tids}
  548. L = np.column_stack([df[c].to_numpy(float) for c in ph])
  549. with np.errstate(invalid="ignore", divide="ignore"):
  550. Ibar = np.nanmean(L, axis=1)
  551. dev = L / Ibar[:, None] - 1.0
  552. m = np.nanmax(np.abs(dev), axis=1)
  553. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  554. else (df[power_col].to_numpy(float) > gen_power_min))
  555. ok = gen & np.isfinite(m) & (Ibar > min_ibar)
  556. if ok.sum() < min_n:
  557. return {t: {**NULL, "note": "gen 样本不足; abstain"} for t in tids}
  558. P = df[power_col].to_numpy(float)
  559. p_med = float(np.nanmedian(P[ok])) # fleet gen 中位 = 高载半区界
  560. hi = ok & (P >= p_med)
  561. tv = df[turbine_col].astype(str).to_numpy()
  562. out = {}
  563. for t in tids:
  564. sel_hi = (tv == t) & hi
  565. if _phase_derived_guard(L, Ibar, (tv == t) & ok): # RV-1 E2 逮: 复制相 m≡0 = 伪"干净"
  566. out[t] = {**NULL, "n": int(((tv == t) & ok).sum()),
  567. "note": "疑似派生/复制相 (rel pair-diff <1e-4): level 不可证 → INSUFFICIENT"}
  568. continue
  569. note = None
  570. insufficient = False
  571. if sel_hi.sum() >= min_n:
  572. use = sel_hi
  573. else: # 限电/纯低载台: 低载噪声地板与恒偏物理不可分
  574. use = (tv == t) & ok
  575. note = "高载样本不足→INSUFFICIENT (低载噪声地板与恒偏不可分, score仅供参考)"
  576. insufficient = True
  577. if use.sum() < min_n:
  578. out[t] = {**NULL, "n": int(use.sum()), "note": "样本不足; abstain"}
  579. continue
  580. lvl = float(np.median(m[use]))
  581. score = lvl / level_thresh
  582. rec = {"score": round(score, 4), "flag": bool(score > 1.0 and not insufficient),
  583. "level_m": round(lvl, 5),
  584. "suspect_phase": None, "phase_dev_level": 0.0, "n": int(use.sum())}
  585. if len(ph) >= 3:
  586. med_dev = np.median(dev[use], axis=0)
  587. j = int(np.nanargmax(np.abs(med_dev)))
  588. rec["suspect_phase"] = ph[j]
  589. rec["phase_dev_level"] = round(float(med_dev[j]), 5)
  590. if note:
  591. rec["note"] = note
  592. out[t] = rec
  593. return out
  594. def sensor_phase_swap_verdict(
  595. df: pd.DataFrame, *, phase_cols: list, power_col: str, time_col: str, turbine_col: str,
  596. gen_flag_col: str | None = None, gen_power_min: float = 50.0, min_ibar: float = 10.0,
  597. early_frac: float = 0.35, late_frac: float = 0.35, swap_thresh: float = 0.005,
  598. null_ratio: float = 0.5, detectable_min_d12: float = 0.005,
  599. n_bins: int = 4, min_bin_n: int = 30, min_overlap_frac: float = 0.35,
  600. ) -> dict:
  601. """per-机组 **相接线 swap 检测** (l1↔l2 接线/标签对调 @onset) — 三相中前两相的互换判别.
  602. 返回 {tid: {"swap_flag": bool, "score": min(|Δdev1|,|Δdev2|)/swap_thresh 当签名成立, "ddev": [Δdev1,Δdev2,Δdev3],
  603. "detectable": 该台是否在可检域, "d12_persistent": persistent 相间差, "note"?: 弃权原因}}。
  604. ★机制 (no black box): swap 签名 = **Δdev1 ≈ −Δdev2 反号互换** (两相 persistent 差对调) 且
  605. **Δdev3 ≈ 0 (第三相 = null 控制)**。区别单相故障 (δ@l1 → Δdev=(+2δ/3,−δ/3,−δ/3): dev3 动且与 dev2 同号
  606. → null 控制拒)。判别 = opp(1,2) ∧ |Δdev3| < null_ratio×min(|Δdev1|,|Δdev2|) ∧ min > swap_thresh。
  607. **Δdev 在共同支撑载荷分箱内算** (见下), 箱间 median。
  608. ★共同支撑载荷分箱 (产化硬化, 送审前 probe 逮): 相间差 dev1−dev2 **强载荷相关** (gucheng 实测 GC1
  609. -0.035@低载→-0.015@高载, GC5 6.5× 变化) + 三相 dev 和≈0 约束 → **窗间载荷分布大移可造反号 Δdev1/Δdev2 +
  610. Δdev3 安静 = 完美伪 swap 签名** (gym INV 未测此角) → Δdev 必在 early∩late 载荷共同支撑等宽箱内算
  611. (箱内载荷匹配 → 载荷驱动的相间差移归零); 重叠不足 → 弃权 INSUFFICIENT (承齿比/电气 §7 四角图谱)。
  612. ★诚实可检域 (EXP-E2-01 §0 物理): **平衡三相的幅值互换 = 无观测量变化** → persistent |median(dev1−dev2)|
  613. < detectable_min_d12 的台**结构性不可检** (幅值 SCADA 教义; gucheng 可检域 7/24) → detectable=False +
  614. note, **不 flag 不误导"已核干净"**。互换幅 = 2×persistent 差, 检出∝该台相间异质度。**可检域判定用
  615. early 窗本底** (onset 前参照期) — 全窗会被互换本身污染 (early +2a / late −2a 抵消 → 伪"平衡", DP 逮 →
  616. 修); 语义 = early 窗假定 clean (同一切 early-late 差分法前提)。
  617. ★诚实界: 只判 phase_cols 前两相 (l1↔l2); 其他对 (l1↔l3/l2↔l3) 调用方换列序再跑; 需 ≥3 相 (dev3 null 控制
  618. by design, 2 相无 null 轴 → abstain); 只检 onset 起互换 (t0 起互换 = 标签错从头, 幅值不可辨, 同 t0 教义);
  619. 需 power 列 ∧ 窗间载荷重叠。**swap_flag = "互换 ∨ 双相反向漂移"候选** (RV-1 逮: l1−2%∧l2+2% 真双相 CT
  620. 反向故障与互换幅值结构不可辨 → 现场辨接线 vs 双 CT; 工单方向仍对)。复制相台由 _phase_derived_guard
  621. 区分 note (非"平衡")。
  622. 来源: gym E2 赛道 sensor_phase_swap (EXP-E2-01 TRUE 可检域内: 域内 pctl 95.8×3seed, INV-sep 单相 gain 误判
  623. 0 → §7 四闸 → 并回 src/ 2026-07-15)。产化改动: Δdev 裸窗差 → **共同支撑载荷分箱** (载荷相关相间差×窗移
  624. confound, probe 实测机制在)。DP 验收 = tests/sop/test_sensor_phase_swap_dp.py。
  625. """
  626. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  627. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  628. NULL = {"swap_flag": False, "score": 0.0, "ddev": None, "detectable": None, "d12_persistent": 0.0}
  629. ph = [c for c in phase_cols if c in df.columns]
  630. if len(ph) < 3 or power_col not in df.columns:
  631. return {t: {**NULL, "note": "需≥3相 (dev3 null控制) ∧ 功率列; abstain"} for t in tids}
  632. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  633. L = np.column_stack([df[c].to_numpy(float) for c in ph])
  634. with np.errstate(invalid="ignore", divide="ignore"):
  635. Ibar = np.nanmean(L, axis=1)
  636. dev = L / Ibar[:, None] - 1.0
  637. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  638. else (df[power_col].to_numpy(float) > gen_power_min))
  639. ok = gen & np.isfinite(dev).all(axis=1) & (Ibar > min_ibar)
  640. P = df[power_col].to_numpy(float)
  641. early, late = _windows(df[time_col], early_frac, late_frac)
  642. tv = df[turbine_col].astype(str).to_numpy()
  643. e_ok, l_ok = ok & early, ok & late
  644. if e_ok.sum() < 2 * min_bin_n or l_ok.sum() < 2 * min_bin_n:
  645. return {t: {**NULL, "note": "窗内 gen 样本不足; abstain"} for t in tids}
  646. # 共同支撑等宽载荷箱 (承四角图谱: 消载荷相关相间差 × 窗分布移伪签名)
  647. e_lo, e_hi = np.nanpercentile(P[e_ok], [2, 98])
  648. l_lo, l_hi = np.nanpercentile(P[l_ok], [2, 98])
  649. lo, hi = max(e_lo, l_lo), min(e_hi, l_hi)
  650. narrow = min(e_hi - e_lo, l_hi - l_lo)
  651. if not (hi > lo) or narrow <= 0 or (hi - lo) < min_overlap_frac * narrow:
  652. return {t: {**NULL, "note": "窗间载荷重叠不足→INSUFFICIENT"} for t in tids}
  653. edges = np.linspace(lo, hi, n_bins + 1)
  654. binid = np.clip(np.digitize(P, edges) - 1, 0, n_bins - 1)
  655. in_cs = (P >= lo) & (P <= hi)
  656. out = {}
  657. for t in tids:
  658. base = (tv == t) & ok
  659. d12p = 0.0
  660. e_base = base & early # 可检域判定用 early 本底 (onset 前参照期):
  661. if e_base.sum() >= 2 * min_bin_n: # 全窗会被互换本身污染 (early+2a/late−2a 抵消→0)
  662. md = np.median(dev[e_base], axis=0)
  663. d12p = float(md[0] - md[1])
  664. detectable = abs(d12p) >= detectable_min_d12
  665. rec = {"swap_flag": False, "score": 0.0, "ddev": None,
  666. "detectable": bool(detectable), "d12_persistent": round(d12p, 5)}
  667. if not detectable:
  668. rec["note"] = ("疑似派生/复制相 (rel pair-diff <1e-4): swap 不可证 → INSUFFICIENT"
  669. if _phase_derived_guard(L, Ibar, base)
  670. else "平衡两相: 幅值互换无观测量变化, 结构性不可检 (非'已核干净')")
  671. out[t] = rec
  672. continue
  673. cs = base & in_cs
  674. dds = []
  675. for bn in range(n_bins):
  676. e_in = cs & early & (binid == bn); l_in = cs & late & (binid == bn)
  677. if e_in.sum() >= min_bin_n and l_in.sum() >= min_bin_n:
  678. dds.append(np.median(dev[l_in], axis=0) - np.median(dev[e_in], axis=0))
  679. if len(dds) < 2:
  680. rec["note"] = "共同支撑内<2载荷箱; abstain"
  681. out[t] = rec
  682. continue
  683. dd = np.median(np.vstack(dds), axis=0) # 箱间 median (载荷匹配后)
  684. d1, d2, d3 = float(dd[0]), float(dd[1]), float(dd[2])
  685. opp = (d1 > 0) != (d2 > 0)
  686. pair_min = min(abs(d1), abs(d2))
  687. null_ok = (abs(d3) < null_ratio * pair_min) if pair_min > 0 else False
  688. score = (pair_min / swap_thresh) if (opp and null_ok) else 0.0
  689. rec.update({"swap_flag": bool(score > 1.0), "score": round(float(score), 4),
  690. "ddev": [round(d1, 5), round(d2, 5), round(d3, 5)]})
  691. out[t] = rec
  692. return out
  693. def channel_semantics_shift_verdict(
  694. df: pd.DataFrame, *, phase_cols: list, power_col: str, time_col: str, turbine_col: str,
  695. gen_flag_col: str | None = None, gen_power_min: float = 50.0, min_bar: float = 10.0,
  696. quantity: str = "current", freq: str = "M", min_month_n: int = 200,
  697. level_jump: float | None = None, rel_tol: float = 1e-4,
  698. ) -> dict:
  699. """per-机组 **通道语义中途变更 (契约级数据异常)** — 三相电气通道身份指纹按月漂移检测.
  700. 裁决对象 = 通道**语义/量纲是否在记录期内换制** (≠ 仪表故障, ≠ 机组部件): 若某台三相通道中途改测量口径
  701. (派生↔独立 ∨ 量纲/标度跳变), 则**跨该点的任何依赖该通道分析前后不可比** → 报 WARN (非阻断; 交付语义 =
  702. 该台该通道"分段化", 调用方须按段切窗)。本函数 = parity/level/swap 三臂的**前置分段器** (跨换制点混算 =
  703. _phase_derived_guard 之外的第二类伪信号)。**电流/电压双支持** (quantity 参数, 见下 — 量纲判据的归一方式按物理分流)。
  704. 返回 {tid: {"flag": bool, "regime_switch": bool, "level_jump": bool,
  705. "switch_month": 派生↔独立切换月(或None), "regimes": "DDDIIII"(每有效月 D/I 串),
  706. "level_jump_ratio": 量纲指纹相邻月最大比(≥1), "level_jump_month": 跳变月(或None),
  707. "n_months": 有效月数, "note"?: 弃权/WARN 摘要}}。
  708. ★两个正交签名 (任一触发 → flag; ∧/∨ 语义 = OR, 两者同点触发 = 强互证, GC9/GC17 真盘即如此):
  709. (a) **派生↔独立切换** (载荷无关, 二值, 电流/电压通用): per(台,月) _phase_derived_rel <rel_tol=派生(同一测量
  710. 复制写三列), ≥rel_tol=独立测量; 有效月间 regime 变 = 中途换制。**复用 _phase_derived_guard 同判据**。
  711. (b) **量纲级 level 跳变** (连续; **归一方式按 quantity 物理分流, Physics First CLAUDE.md #1**):
  712. - `quantity="current"` (默认, level_jump 默认 3.0): 发电时 I∝P → 指纹 = **功率归一** median(bar/P) 相邻月比。
  713. **必功率归一**: 真单位/标度 artifact 破坏 I/P, 而季节载荷保持 I/P 不变 → 消掉"raw median 月间健康台
  714. ×1.5 季节摆动"混淆 (gucheng 实测: 健康 22 台 raw 月比最高 ~1.5×→功率归一后 <1.03×; GC9/GC17 换制点 ~8×)。
  715. - `quantity="voltage"` (level_jump 默认 1.5): **V≈恒定 (与 P 弱相关, gucheng corr 0.44 非 ∝P)** → 指纹 =
  716. **raw-level** median(bar) 相邻月比。**禁功率归一**: V/P 会随季节载荷反向摆动 = 假阳源 (与电流相反)。
  717. 阈 1.5× 依据: gucheng 健康电压 raw 月比 max 1.072× (24 台×11 月, p99 1.022) → 1.5 给 1.40× 下margin,
  718. 且抓 √3(L-L↔L-N 换算)=1.732× / 690↔400V=1.725× / V↔kV=1000×。
  719. ★可分性 (为何不误报健康波动): (电流) 三相共缩/季节/限电 → bar 与 P 同缩 → bar/P 不变 → (b) 静; (电压) V 本恒定
  720. → raw-level 月比 ≈1 → (b) 静。独立测量 pair-diff 不随载荷变 → (a) 静。故健康台 flag=False (gucheng 真盘: 电流
  721. 24 台 0 FP + 电压 24 台 0 FP 实证)。
  722. ★诚实界:
  723. - **只判"换制发生"go/no-go, 不判换制后哪段可信** — 前后两段的仪表校核 (parity/level/swap) 须**按段独立跑**。
  724. - **level 跳变 ≠ 一定是 artifact**: 真 CT/VT 变比改档/电气重构也触发 (那也确是"语义换制", 跨点不可比 → WARN
  725. 正当); 定性靠现场核 (铭牌/CT-VT 变比台账)。故 verdict = WARN 非"确诊", 属候选级 (SOP §0.2)。
  726. - **电压 sub-阈换算漏检 (已声明, 安全方向)**: <level_jump 的量纲变 (如某些 VT 变比 ~1.3×, 贴近健康地板)
  727. 本门 raw-level 轴可能漏; 靠信号 (a) 切换 + 下游诊断补。**瞬态 excursion (非永久换制, 如 GC11/GC15 电压
  728. episode 单相 +3.8% 后复位) 不属本门 scope** — bar 平均后 ~1.3% << 阈, 正当不触发; 由 phase_level/parity 臂管。
  729. - 需 ≥2 相 ∧ power/time 列 ∧ ≥2 有效月; 缺 → note 显式弃权 (非静默"干净")。全程同一 regime 的台 (全复制或全独立)
  730. → (a) 不触发 (该台三相 parity 由各函数内 _phase_derived_guard 各自守 INSUFFICIENT, 与本函数正交)。
  731. 来源: RV-1 E2 审 2026-07-15 真盘发现 (gucheng GC9/GC17 电流: 2025-03~07 复制相 rel~3e-6 ∧ 2025-08 起独立 +
  732. 同月量纲 1049A→33A 跳变 = 契约级换制)。电压支持 = 2026-07-15 扩展 (V 物理异于 I → raw-level 归一)。gym
  733. LESSONS#44 契约层 backlog 落地。DP 验收 = tests/sop/test_channel_semantics_shift_dp.py (电流+电压双模式合成
  734. regime切换/量纲跳变/归一鲁棒 + gucheng 真盘电流 GC9/GC17 + 电压 0-flag 回归闸)。
  735. """
  736. if quantity not in ("current", "voltage"):
  737. raise ValueError(f"quantity 必为 'current'|'voltage', 收到 {quantity!r}")
  738. power_norm = quantity == "current" # 电流=功率归一; 电压=raw-level (物理分流)
  739. if level_jump is None:
  740. level_jump = 3.0 if power_norm else 1.5 # 电压健康地板窄→阈更严 (抓 √3 换算)
  741. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  742. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  743. NULL = {"flag": False, "regime_switch": False, "level_jump": False, "switch_month": None,
  744. "regimes": "", "level_jump_ratio": 1.0, "level_jump_month": None, "n_months": 0}
  745. ph = [c for c in phase_cols if c in df.columns]
  746. if len(ph) < 2 or power_col not in df.columns or time_col not in df.columns:
  747. return {t: {**NULL, "note": "相通道<2 或缺功率/时间列; abstain"} for t in tids}
  748. cols = [turbine_col, time_col, power_col] + ph
  749. if gen_flag_col and gen_flag_col in df.columns:
  750. cols.append(gen_flag_col)
  751. d = df.loc[:, cols].copy()
  752. tt = pd.to_datetime(d[time_col], errors="coerce")
  753. keep = tt.notna()
  754. d, tt = d.loc[keep], tt.loc[keep]
  755. if len(d) == 0:
  756. return {t: {**NULL, "note": "时间列全不可解析; abstain"} for t in tids}
  757. ym = tt.dt.to_period(freq).astype(str).to_numpy()
  758. L = np.column_stack([pd.to_numeric(d[c], errors="coerce").to_numpy(float) for c in ph])
  759. import warnings
  760. with warnings.catch_warnings():
  761. warnings.simplefilter("ignore", RuntimeWarning) # 全-NaN 行 nanmean 空切片 (无害)
  762. bar = np.nanmean(L, axis=1) # 三相均值 (电流→Ibar / 电压→Vbar)
  763. P = pd.to_numeric(d[power_col], errors="coerce").to_numpy(float)
  764. gen = ((pd.to_numeric(d[gen_flag_col], errors="coerce").to_numpy(float) == 1)
  765. if gen_flag_col and gen_flag_col in df.columns else (P > gen_power_min))
  766. ok = gen & np.isfinite(bar) & np.isfinite(P) & (bar > min_bar) & (P > gen_power_min)
  767. tv = d[turbine_col].astype(str).to_numpy()
  768. out = {}
  769. for t in tids:
  770. sel_t = (tv == t) & ok
  771. recs = [] # (ym, regime 'D'/'I', mag) per 有效月
  772. for mo in sorted(set(ym[sel_t])):
  773. m_sel = sel_t & (ym == mo)
  774. if m_sel.sum() < min_month_n:
  775. continue
  776. regime = "D" if _phase_derived_rel(L, bar, m_sel) < rel_tol else "I"
  777. with np.errstate(invalid="ignore", divide="ignore"):
  778. mag = (float(np.median(bar[m_sel] / P[m_sel])) if power_norm # 电流: 功率归一 (I∝P)
  779. else float(np.median(bar[m_sel]))) # 电压: raw-level (V≈const)
  780. recs.append((mo, regime, mag))
  781. if len(recs) < 2:
  782. out[t] = {**NULL, "n_months": len(recs), "note": "有效月<2 (gen 样本不足); abstain"}
  783. continue
  784. regimes = "".join(r[1] for r in recs)
  785. switch_month = next((recs[i][0] for i in range(1, len(recs))
  786. if recs[i][1] != recs[i - 1][1]), None) # (a) 首个 regime 切换月
  787. max_ratio, lj_month = 1.0, None # (b) 量纲指纹相邻月最大比
  788. for i in range(1, len(recs)):
  789. a, b = recs[i - 1][2], recs[i][2]
  790. if a > 0 and b > 0:
  791. ratio = max(a / b, b / a)
  792. if ratio > max_ratio:
  793. max_ratio, lj_month = ratio, recs[i][0]
  794. regime_switch = switch_month is not None
  795. level_jump_flag = max_ratio > level_jump
  796. flag = regime_switch or level_jump_flag
  797. rec = {"flag": bool(flag), "regime_switch": bool(regime_switch),
  798. "level_jump": bool(level_jump_flag), "switch_month": switch_month,
  799. "regimes": regimes, "level_jump_ratio": round(float(max_ratio), 3),
  800. "level_jump_month": lj_month, "n_months": len(recs)}
  801. if flag:
  802. magdesc = "功率归一电流" if power_norm else "电压raw-level"
  803. parts = ([f"派生↔独立切换@{switch_month}"] if regime_switch else []) + \
  804. ([f"{magdesc}×{max_ratio:.1f}@{lj_month}"] if level_jump_flag else [])
  805. rec["note"] = "通道语义中途变更 (契约级): " + " + ".join(parts) + " → 跨点不可比, 须按段切窗 (WARN)"
  806. out[t] = rec
  807. return out
  808. # ==================== 机舱风速计校核 (W 三轴: 相对 / power耦合 / 二阶矩) ====================
  809. # ★纪律 (EXP-W3-01, 状态表附带纪律洞的生产载体): 生产判 freeze/variance **必用 sensor_wind_var_verdict
  810. # (二阶矩轴), 勿信 mean-ratio 轴** — mean 轴对 freeze/variance 的 gym"检出"是 confound 假因
  811. # (freeze = 夏季冻值≠冬季 fleet 中位的季节偏, per台 ratio_shift −0.08~+0.50 方向乱, onset 换季即翻盲;
  812. # variance = min_ws 非对称削低尾抬中位 [frac<min_ws 2%→13%, 中位+0.8] + 右偏膨胀抬中位, mean保真≠median保真),
  813. # 脆/不可迁。二阶矩轴测真离差变 (freeze z 11-17 / variance z~10, 右因), 对 onset 季节/min_ws 地板不漂。
  814. _WIND_FREEZE_VAR_DISCIPLINE = (
  815. "判 freeze/variance 必用 sensor_wind_var_verdict (二阶矩轴), 勿信 mean-ratio 轴 "
  816. "(mean 轴检出=季节/min_ws confound 假因, 脆/不可迁; EXP-W3-01)")
  817. def _fleet_median_ratio(df, ws, ok, time_col, min_fleet_n):
  818. """fleet 同刻中位参照 + per行 ratio + **参照有效时刻掩码** (RV-1 W 审 F1 逮): per台时间戳错位 →
  819. 同刻组只剩单台 → 参照=自身 → ratio≡1 = **静默假阴** (输出"看似干净"零弃权)。
  820. 修法保守 (薄并回, 不改 gym 估计量): ratio 计算与 gym 逐位一致 (稀疏时刻照算); 另返回
  821. valid = 同刻有效贡献台 ≥ min_fleet_n 掩码, 调用方按**窗内 valid 占比** < 0.5 响亮弃权
  822. (对齐数据占比≈1 → 分数逐位不变; 全错位 → 占比 0 → abstain)。"""
  823. g = df.assign(_w=np.where(ok, ws, np.nan)).groupby(df[time_col])["_w"]
  824. fleet = g.transform("median").to_numpy()
  825. cnt = g.transform("count").to_numpy()
  826. with np.errstate(invalid="ignore", divide="ignore"):
  827. ratio = np.where(ok & (fleet > 0), ws / fleet, np.nan)
  828. return fleet, ratio, (cnt >= min_fleet_n)
  829. def sensor_wind_fleet_verdict(
  830. df: pd.DataFrame, *, ws_col: str, time_col: str, turbine_col: str,
  831. gen_flag_col: str | None = None, min_ws: float = 3.0, ratio_thresh: float = 0.05,
  832. early_frac: float = 0.35, late_frac: float = 0.35, min_win_n: int = 50,
  833. min_fleet_n: int = 4,
  834. ) -> dict:
  835. """per-机组 **机舱风速计校核 — 相对轴** (机舱 ws / fleet 同时刻中位 ws 的早late比移).
  836. 返回 {tid: {"score": |ratio_shift|, "flag": bool, "ratio_shift": signed 比移,
  837. "n_e"/"n_l": 窗内有效样本, "note"?: 弃权原因}}。
  838. ★机制 (no black box): 全场同刻见近同风 → fleet 中位 ws = 好参照; r_i(t)=ws_i/fleet中位ws(t);
  839. score = |median(r|late) − median(r|early)|, flag = score > ratio_thresh (0.05 ≈ 阜山自然
  840. NTF 台间散度 0.047 量级, EXP-W1-01 预注册锚)。抓 per台 bias/gain/drift onset
  841. (gym: bias+1 / gain×1.10 / drift+1/60d 均 pctl 94.4 ×3seed)。
  842. ★可分性: 真风事件 = fleet 共动 → 各台比不变 → 不 flag (clean fp 0; 真风空间共动 misattr 0,
  843. EXP-W1-01 H4)。
  844. ★诚实界:
  845. - **对 fleet-common 偏结构盲** (全场同偏 → fleet 中位同移 → 比不变; gym fleetbias 全场+1
  846. fleet_ratio_shift 0.0002 = 盲, 预期同温度 fleet-relative 教义) → fleet-common onset 检出
  847. 用 sensor_wind_ntf_verdict (power 耦合轴)。
  848. - 只检 **onset 变化**; t0 起恒定偏 (装机即偏) 不可检 (早late 差分教义); drift↔step-bias
  849. 不可分 (60d 漂 late 窗前完成 → 同签名; 无 onset 轴, EXP-W3-01 诚实注)。
  850. - **freeze/variance 勿用本轴**: 判 freeze/variance 必用 sensor_wind_var_verdict (二阶矩轴), 勿信 mean-ratio 轴 (mean 轴检出=季节/min_ws confound 假因, 脆/不可迁; EXP-W3-01) — mean 轴对二者的"检出"是 confound。
  851. - 检出地板 = 自然 NTF 台间散度 (~0.047, 阜山); 单台偏须超此。相对轴, 需 fleet ≥ min_fleet_n 台。
  852. - **前提: 时间戳须跨台对齐** (10min 栅格; RV-1 逮): fleet 参照按同刻中位聚合, per台时间戳
  853. 错位 → 同刻贡献台<min_fleet_n → 参照有效时刻占比 <0.5 → **响亮弃权** note (非静默 ratio≡1
  854. 假"干净"); ratio 数值本身与 gym 逐位一致 (稀疏时刻照算, 闸只管弃权语义)。**混合错位定量**
  855. (RV-1 复核实测): 占比贴 0.5 下沿时 shift 被稀释 ~×3 (0.139→0.047), 真 bias 可漏 —
  856. 方向 = 漏检非假阳 (安全); 占比 <0.5 由闸兜住。
  857. - **批次污染上界 (RV-1 逮, 承 memory relative-criterion-batch-blindness)**: fleet 中位击穿点 =
  858. 50%; 同向坏台占比 ≳1/3 起健康台可现**镜像反号 shift 假阳** (实测 2/4 台 bias+2 → 健康台 −0.15
  859. flag)。flag 台占比 >~1/3 或出现对称反号 shift → 疑 fleet 参照污染, 按批次场型处理勿逐台判。
  860. 来源: gym W 赛道 sensor_wind_fleet (EXP-W1-01 相对轴成立 → EXP-W3-01 变体补全 → §7 四闸并回
  861. src/ 2026-07-16, gym 逻辑 as-is 薄搬运)。DP 验收 = tests/sop/test_wind_sensor_dp.py。
  862. """
  863. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  864. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  865. NULL = {"score": 0.0, "flag": False, "ratio_shift": 0.0, "n_e": 0, "n_l": 0}
  866. if ws_col not in df.columns:
  867. return {t: {**NULL, "note": "机舱风速列缺; abstain"} for t in tids}
  868. if len(tids) < min_fleet_n:
  869. return {t: {**NULL, "note": f"fleet<{min_fleet_n}台, 相对参照不可估; abstain"} for t in tids}
  870. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  871. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  872. else np.ones(len(df), dtype=bool))
  873. ws = df[ws_col].to_numpy(float)
  874. tid = df[turbine_col].astype(str)
  875. ok = gen & np.isfinite(ws) & (ws > min_ws)
  876. fleet, ratio, valid = _fleet_median_ratio(df, ws, ok, time_col, min_fleet_n)
  877. early, late = _windows(df[time_col], early_frac, late_frac)
  878. out = {}
  879. for t in tids:
  880. sel = (tid.values == t) & ok
  881. win = sel & (early | late) & np.isfinite(ratio)
  882. fv = float(valid[win].mean()) if win.any() else 0.0 # 参照有效时刻占比 (RV-1 F1 闸)
  883. re = ratio[sel & early]; rl = ratio[sel & late]
  884. re = re[np.isfinite(re)]; rl = rl[np.isfinite(rl)]
  885. if len(re) < min_win_n or len(rl) < min_win_n:
  886. out[t] = {**NULL, "n_e": int(len(re)), "n_l": int(len(rl)), "note": "窗内有效样本不足; abstain"}
  887. continue
  888. if fv < 0.5:
  889. out[t] = {**NULL, "n_e": int(len(re)), "n_l": int(len(rl)),
  890. "note": f"fleet 参照有效时刻占比 {fv:.2f}<0.5 (同刻贡献台<{min_fleet_n}; "
  891. f"时间戳错位/机群过稀) → 参照不可信; abstain"}
  892. continue
  893. sh = float(np.median(rl) - np.median(re))
  894. out[t] = {"score": round(abs(sh), 4), "flag": bool(abs(sh) > ratio_thresh),
  895. "ratio_shift": round(sh, 4), "n_e": int(len(re)), "n_l": int(len(rl))}
  896. return out
  897. def sensor_wind_ntf_verdict(
  898. df: pd.DataFrame, *, ws_col: str, power_col: str, mast_col: str, time_col: str, turbine_col: str,
  899. gen_flag_col: str | None = None, min_ws: float = 3.0, ratio_thresh: float = 0.05,
  900. early_frac: float = 0.35, late_frac: float = 0.35, power_min: float = 50.0,
  901. n_pc_bins: int = 20, min_bin_n: int = 20, min_valid_bins: int = 5,
  902. min_mast_n: int = 200, min_win_n: int = 50, min_fleet_n: int = 4,
  903. ) -> dict:
  904. """**机舱风速计校核 — power 耦合绝对轴** (fleet-common 偏 onset 检出, 相对轴/mast 快照双盲的洞).
  905. 返回 {"verdict": "FLEET_COMMON_SHIFT" | "NO_FLEET_COMMON_SHIFT" | "INSUFFICIENT",
  906. "fleet_ratio_shift": fleet 中位早late比移 (核心量, |·|>ratio_thresh → 王牌 verdict),
  907. "per_turbine": {tid: {"score", "flag", "ratio_shift", "n_e", "n_l", "note"?}},
  908. "n_eff": 参与 fleet 中位的有效台数, "n_pc_bins": h(P) 有效功率箱数, "honesty": [...], "note": str}。
  909. ★机制 (no black box, EXP-W2-01 王牌): mast 窗 (ws_mast 有效期) 内拟合 h(P)=free-stream
  910. (P 分箱→per箱 ws_mast 中位→单调插值) — **P 与 ws_mast 均 nacelle 独立** → h(P) 是干净外部参照;
  911. 年 round ratio_i(t)=机舱ws_i/h(P_i) 恢复变化检测 (mast 短窗 [阜山 2.5月] 无 pre-onset baseline
  912. 做不到的)。传感器偏 (ws 移, P 不动) → 比移 flag; 真风 (P 与 ws 同动沿功率曲线) → 比稳不 flag。
  913. fleet_ratio_shift = median(per台早late比移) = fleet-common 信号 (gym fleetbias 全场+1:
  914. 0.1677 全 seed vs 相对轴 0.0002 盲 = 王牌兑现); per台移 = 相对信号 (bias pctl 94.4)。
  915. ★可分性: power 是 nacelle 独立不变量 — clean (自然 P-ws 耦合) fp 0 全 seed (EXP-W2-01 H3);
  916. **INV 场景须物理自洽** (真风增 → P 增; EXP-W3-01 逮 gym realwind-008 抬 ws 不抬 P = 场景对
  917. power 轴不完备 → fp 1.0 是场景缺陷非算法; 生产 DP test 用 P 同动的物理自洽真风场景直锁)。
  918. ★诚实界 (skill 铁律遵, power-curve-ntf 教义):
  919. - 只检 fleet-common **onset 变化**; **恒定 t0 fleet-common 仍 INSUFFICIENT** (无变化信号 +
  920. 单 mast 无空间代表性定不了绝对 NTF — fleet 中位比 1.08 vs 0.93 谁对, 无外部 NTF 无从判)。
  921. - 变化检测 robust to 恒定空间 NTF 散度 (常量不移); h(P) 拟合于 mast 窗, 年 round 外推设
  922. 功率曲线稳 (同型同物理; 大修/换叶片/控制版本改 → 前提破, 调用方须知)。
  923. - 额定区 P 饱和 → h(P) 对 ws 失敏 (P 封顶不载风信息) → 高风段贡献弱, 信号主来自部分负荷区。
  924. 深度/长时**限电** (P 被压而 ws 不动 = 与传感器偏同签名) bind 超窗内半数小时 → 前提破,
  925. 须先过限电剥离 (curtailment-screen; 浅限电由中位数稳健性阻尼, RV-1 实测 500kW 封顶不破)。
  926. - **fleet-common 断言须够台数 (RV-1 逮)**: 有效台 < min_fleet_n → INSUFFICIENT (单台/少台的
  927. 比移是个体传感器信号, 断言"批次/固件级共模"= 反向归因); n_eff 随 verdict 报。
  928. - 需 mast 列 (短窗即可) + power 列; mast 窗不足/功率曲线箱不足 → INSUFFICIENT 显式弃权。
  929. - **freeze/variance 勿用本轴**: 判 freeze/variance 必用 sensor_wind_var_verdict (二阶矩轴), 勿信 mean-ratio 轴 (mean 轴检出=季节/min_ws confound 假因, 脆/不可迁; EXP-W3-01)。
  930. 来源: gym W 赛道 sensor_wind_ntf (EXP-W2-01 王牌兑现 H1∧H2∧H3 全达 → §7 四闸并回 src/ 2026-07-16,
  931. gym 逻辑 as-is 薄搬运)。DP 验收 = tests/sop/test_wind_sensor_dp.py。
  932. """
  933. HONESTY = [
  934. "(a) 只检 fleet-common onset 变化; 恒定 t0 fleet-common 偏 INSUFFICIENT (单 mast 无空间代表性, 需外部 NTF/IEC 功率曲线基准)。",
  935. "(b) h(P) 拟合 mast 窗, 年 round 外推设功率曲线稳; 大修/换叶片/控制版本变更期不可用。",
  936. "(c) 真风 (P 与 ws 同动) 比稳不 flag = 可分性来自 power 的 nacelle 独立性; INV 验证场景须物理自洽 (风增→P增)。",
  937. "(d) " + _WIND_FREEZE_VAR_DISCIPLINE + "。",
  938. ]
  939. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  940. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  941. NULL = {"score": 0.0, "flag": False, "ratio_shift": 0.0, "n_e": 0, "n_l": 0}
  942. def _insuf(reason, pt=None, n_eff=0, frs=None):
  943. return {"verdict": "INSUFFICIENT", "fleet_ratio_shift": frs,
  944. "per_turbine": pt if pt is not None else {t: {**NULL, "note": reason} for t in tids},
  945. "n_eff": n_eff, "n_pc_bins": 0, "honesty": HONESTY, "note": reason}
  946. for c, nm in ((ws_col, "机舱风速"), (power_col, "功率"), (mast_col, "mast free-stream")):
  947. if c not in df.columns:
  948. return _insuf(f"{nm}列 ({c}) 缺 → power 耦合轴不可建; INSUFFICIENT")
  949. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  950. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  951. else np.ones(len(df), dtype=bool))
  952. ws = df[ws_col].to_numpy(float)
  953. P = df[power_col].to_numpy(float)
  954. mast = df[mast_col].to_numpy(float)
  955. tid = df[turbine_col].astype(str)
  956. # 1. h(P) = free-stream, mast 窗拟合 (nacelle 独立)
  957. mok = gen & np.isfinite(mast) & np.isfinite(P) & (mast > min_ws) & (P > power_min)
  958. if mok.sum() < min_mast_n:
  959. return _insuf(f"mast 窗有效样本 {int(mok.sum())} < {min_mast_n} → h(P) 不可拟合; INSUFFICIENT")
  960. Pm, Wm = P[mok], mast[mok]
  961. edges = np.nanquantile(Pm, np.linspace(0, 1, n_pc_bins + 1))
  962. edges[0], edges[-1] = -np.inf, np.inf
  963. binid = np.clip(np.digitize(Pm, edges) - 1, 0, n_pc_bins - 1)
  964. bc, bw = [], []
  965. for b in range(n_pc_bins):
  966. m = binid == b
  967. if m.sum() >= min_bin_n:
  968. bc.append(float(np.median(Pm[m]))); bw.append(float(np.median(Wm[m])))
  969. if len(bc) < min_valid_bins:
  970. return _insuf(f"功率曲线有效箱 {len(bc)} < {min_valid_bins}; INSUFFICIENT")
  971. bc, bw = np.array(bc), np.array(bw)
  972. order = np.argsort(bc); bc, bw = bc[order], bw[order]
  973. # 2. year-round ratio = 机舱 ws / h(P)
  974. ok = gen & np.isfinite(ws) & np.isfinite(P) & (ws > min_ws) & (P > power_min)
  975. implied = np.interp(P, bc, bw, left=bw[0], right=bw[-1])
  976. with np.errstate(invalid="ignore", divide="ignore"):
  977. ratio = np.where(ok & (implied > min_ws), ws / implied, np.nan)
  978. early, late = _windows(df[time_col], early_frac, late_frac)
  979. pt, shifts = {}, []
  980. for t in tids:
  981. sel = (tid.values == t) & ok
  982. re = ratio[sel & early]; rl = ratio[sel & late]
  983. re = re[np.isfinite(re)]; rl = rl[np.isfinite(rl)]
  984. if len(re) < min_win_n or len(rl) < min_win_n:
  985. pt[t] = {**NULL, "n_e": int(len(re)), "n_l": int(len(rl)), "note": "窗内有效样本不足; abstain"}
  986. continue
  987. sh = float(np.median(rl) - np.median(re)); shifts.append(sh)
  988. pt[t] = {"score": round(abs(sh), 4), "flag": bool(abs(sh) > ratio_thresh),
  989. "ratio_shift": round(sh, 4), "n_e": int(len(re)), "n_l": int(len(rl))}
  990. if len(shifts) < min_fleet_n: # RV-1 F2 逮: 少台比移=个体信号,
  991. frs = round(float(np.median(shifts)), 4) if shifts else None # 断言"批次共模"=反向归因
  992. return _insuf(f"有效台 {len(shifts)} < {min_fleet_n} → fleet-common 不可断言 "
  993. f"(per台结果照报, 个体信号请读 per_turbine); INSUFFICIENT",
  994. pt=pt, n_eff=len(shifts), frs=frs)
  995. frs = float(np.median(shifts))
  996. common = abs(frs) > ratio_thresh
  997. verdict = "FLEET_COMMON_SHIFT" if common else "NO_FLEET_COMMON_SHIFT"
  998. note = (f"fleet_ratio_shift={frs:+.4f} (n_eff={len(shifts)}台, {'>' if common else '≤'} 阈 {ratio_thresh}): "
  999. + ("全场机舱风速计共模偏 onset 候选 (传感器批次/固件级, ws 移而 P 不动) — 候选级, "
  1000. "确证须外部 NTF/现场标定" if common
  1001. else "无 fleet-common onset 信号 (恒定 t0 偏本轴不可证, 见 honesty a)"))
  1002. return {"verdict": verdict, "fleet_ratio_shift": round(frs, 4), "per_turbine": pt,
  1003. "n_eff": len(shifts), "n_pc_bins": int(len(bc)), "honesty": HONESTY, "note": note}
  1004. def sensor_wind_var_verdict(
  1005. df: pd.DataFrame, *, ws_col: str, time_col: str, turbine_col: str,
  1006. gen_flag_col: str | None = None, min_ws: float = 3.0, studentize_k: float = 3.0,
  1007. early_frac: float = 0.35, late_frac: float = 0.35, min_win_n: int = 50,
  1008. min_fleet_n: int = 4,
  1009. ) -> dict:
  1010. """per-机组 **机舱风速计校核 — 二阶矩轴** (freeze/variance 的**右因**判据; mean 轴对二者=confound 假因).
  1011. 返回 {tid: {"score": |z| (fleet studentized), "flag": bool, "logmad_ratio": log10(D_late/D_early),
  1012. "z": signed z, "note"?: 弃权原因}}。
  1013. ★机制 (no black box): r_t = ws_i / fleet 同刻中位 ws (相对参照 → 去 fleet-common 真风);
  1014. 早/late 窗稳健离差 D = MAD(r); raw = log10(D_late/D_early); fleet studentize
  1015. z = (raw − fleet中位) / (1.4826·fleet MAD), flag |z| > k。
  1016. 物理: freeze → 分子 const / 分母 varying → 相对离差**涨**; variance (湍流计/杯轴退化离差变)
  1017. → 绕中位噪声涨 → 相对离差**涨** — 两型同向, 单轴双收 (gym: freeze z 11-17 / variance z~10,
  1018. pctl 94.4 ×3seed 右因稳抓)。
  1019. ★为何必用本轴 (纪律, EXP-W3-01 rollout 逮): 判 freeze/variance 必用 sensor_wind_var_verdict (二阶矩轴), 勿信 mean-ratio 轴 (mean 轴检出=季节/min_ws confound 假因, 脆/不可迁; EXP-W3-01) — mean-ratio 轴对 freeze/variance
  1020. 也"flag"但假因 (季节偏/min_ws 削尾), 换 onset 季节即翻盲。本轴测真二阶矩, 对 onset 季节/
  1021. min_ws 地板不漂。
  1022. ★可分性: 纯均值移 (真风 fleet 共动/fleet-common 湍流同涨) 由相对参照+studentize 归零
  1023. (clean fp 0, realwind misattr 0, EXP-W3-01 H6)。
  1024. ★诚实界:
  1025. - **正交性部分证伪 (gym 实测)**: 对 per台加性 bias/gain 也漏 z~3 (贴 k=3 边界, 加性偏经
  1026. 1/fleet 渗离差; 比真信号小 ~10×) → 作 freeze/variance **检测器**无歧义; 作 1阶↔2阶
  1027. **判别器**边界不清 (勿单凭本轴断言"非均值故障")。
  1028. - freeze onset 首值 < min_ws → late 窗全滤 → 该型漏检 (seed edge, 安全方向: 不误报)。
  1029. - fleet-common 湍流增 (全台 TI 同涨) 由 studentize 归零 → 场级湍流变化本轴盲 (by design)。
  1030. - 相对轴, 需 fleet ≥ min_fleet_n 台有效; 窗内样本/离差不可估 → 显式弃权。**前提: 时间戳须跨台
  1031. 对齐** (同 sensor_wind_fleet_verdict; 同刻贡献台数闸 _fleet_median_ratio, 错位 → 响亮弃权)。
  1032. 来源: gym W 赛道 sensor_wind_var (EXP-W3-01 §3 预注册补轴, H6 CONFIRMED → §7 四闸并回 src/
  1033. 2026-07-16, gym 逻辑 as-is 薄搬运)。DP 验收 = tests/sop/test_wind_sensor_dp.py。
  1034. """
  1035. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  1036. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  1037. NULL = {"score": 0.0, "flag": False, "logmad_ratio": 0.0, "z": 0.0}
  1038. if ws_col not in df.columns:
  1039. return {t: {**NULL, "note": "机舱风速列缺; abstain"} for t in tids}
  1040. df = df.sort_values([turbine_col, time_col], kind="mergesort").reset_index(drop=True)
  1041. gen = ((df[gen_flag_col] == 1).to_numpy() if gen_flag_col and gen_flag_col in df.columns
  1042. else np.ones(len(df), dtype=bool))
  1043. ws = df[ws_col].to_numpy(float)
  1044. tid = df[turbine_col].astype(str)
  1045. ok = gen & np.isfinite(ws) & (ws > min_ws)
  1046. fleet, ratio, valid = _fleet_median_ratio(df, ws, ok, time_col, min_fleet_n)
  1047. early, late = _windows(df[time_col], early_frac, late_frac)
  1048. def _madv(a):
  1049. a = a[np.isfinite(a)]
  1050. return float(np.median(np.abs(a - np.median(a)))) if len(a) else np.nan
  1051. raw = {}
  1052. for t in tids:
  1053. sel = (tid.values == t) & ok
  1054. win = sel & (early | late) & np.isfinite(ratio)
  1055. if win.any() and float(valid[win].mean()) < 0.5: # 参照有效时刻占比闸 (RV-1 F1, 同 fleet 轴)
  1056. continue # → 该台落入弃权分支 (raw 缺)
  1057. re = ratio[sel & early]; rl = ratio[sel & late]
  1058. ne = int(np.isfinite(re).sum()); nl = int(np.isfinite(rl).sum())
  1059. de = _madv(re); dl = _madv(rl)
  1060. if ne >= min_win_n and nl >= min_win_n and de > 1e-4 and dl > 1e-4:
  1061. raw[t] = float(np.log10(dl) - np.log10(de))
  1062. if len(raw) < min_fleet_n:
  1063. return {t: {**NULL, "note": f"有效台 {len(raw)} < {min_fleet_n}, fleet studentize 不可估; abstain"}
  1064. for t in tids}
  1065. vals = np.array(list(raw.values()))
  1066. med = float(np.median(vals)); scale = max(float(1.4826 * np.median(np.abs(vals - med))), 1e-3)
  1067. out = {}
  1068. for t in tids:
  1069. if t not in raw:
  1070. out[t] = {**NULL, "note": "窗内有效样本/离差不可估; abstain"}
  1071. continue
  1072. z = (raw[t] - med) / scale
  1073. out[t] = {"score": round(abs(z), 4), "flag": bool(abs(z) > studentize_k),
  1074. "logmad_ratio": round(raw[t], 4), "z": round(z, 3)}
  1075. return out