thermreg_validation.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. # -*- coding: utf-8 -*-
  2. """thermreg_validation.py — 温控执行器卡死族分型: 反环温签名判别器 (加热器卡ON vs 泛过热).
  3. 裁决对象 = per 机组 **过热机制分型** — 上游 (temp_fleet_nbm / temp-nbm skill) 报"这台过热"但分不清:
  4. · 加热器卡ON = **反环温** (舱外温↓ 时残差↑, 残差 ∝ −T_amb) — memory thermal-regulation-stuck-family 模态①
  5. · 冷却卡死 / 泛过热 = 环温无关持续过热 (残差 ≈ 常偏置)
  6. 两者残差都升 → 需二阶判别量。**判别量 = per台 [fleet-NBM残差 vs 舱外温] 日均回归斜率**。
  7. 物理 trace (第一性, no black box): 温控加热器卡满档 → 恒温器追 T_set 注热 ∝ (T_set − T_amb)
  8. → 舱外越冷注热越多 → 残差 ∂/∂T_amb < 0 (反环温)。泛过热 (冷却退化/常偏置) → ∂/∂T_amb ≈ 0。
  9. NBM 已含 fleet-共模环温项 (ambient ∈ OLS) → pooled 残差 ⊥ 环温 (整场), 但 **per台** 残差仍可与
  10. 环温相关 ⟺ 该台偏离 fleet-共模环温响应 (EXP-M3-16 §8.2 教训)。
  11. 机制 (双门 AND, 同 sensor_drift_verdict 主-fp-cap+显著性模式):
  12. 1. fleet-池化 OLS 残差 (内联 gym temp_fleet_fullz.pooled_resid 语义, 见下) → 日均聚合 (降 10min 自相关)
  13. 2. per台 OLS 斜率 β_i = slope(resid_day ~ ambient_day)
  14. 3. **置换显著性门**: 逐台 shuffle 环温标签 × n_perm → null β 分布 → 双侧 p (+1 平滑) < p_gate
  15. 4. **健康 fp cap**: 斜率跨台稳健 z (median + 1.4826·MAD) → |z| ≥ z_cap
  16. flag = 两门 AND; **两侧**: β<0 = anti_ambient(加热器卡ON, 主目标) / β>0 = pro_ambient(异常正环温敏感)。
  17. 泛过热 (β 量级正常于 fleet) 被 cap 正确拒 → 不 flag = 本判别器价值。
  18. ★关键发现 (EXP-M3-22 §6, manipulation check 实证, DP test 锁回归): **双门皆 load-bearing, 缺一不可**。
  19. 常偏置后残留的 per台环温斜率仍可与 0 区分 → 泛过热台置换 p 亦显著 → **置换单门会误报泛过热 (2/2)
  20. + 全健康台 (8/8)**。分离主力 = fp cap ("斜率异常于 fleet 否", 特异); 置换门 = "斜率非零否" (灵敏不特异)。
  21. `cap_off=True` 仅为 manipulation-check/debug 保留 (gym 版 env GYM_ANTIAMB_CAPOFF), 生产禁用。
  22. ★残差为何内联而非复用 sensor_validation._fleet_residual (比对后决定, 语义不一致):
  23. _fleet_residual = OLS[1, P, rpm, T_amb] 无风速项、无月哑元、缺列零占位、fit≥50 行即走;
  24. gym pooled_resid = OLS[1, P, rpm, T_amb, wind, 月哑元 m2..m12] + dropna + 池化行数 ≥ MIN_POOL_ROWS(5000)。
  25. 月哑元 + 风速 = fleet-共模季节/风况吸收项, 对"per台环温斜率 = 偏离共模响应"这一判别量 load-bearing
  26. (少吸收 → 健康台残差留季节结构 → 斜率底噪抬 → cap 定标漂)。忠实并回 (阈值/口径不重调) → 内联
  27. `_pooled_fleet_resid` 逐语句镜像 gym `temp_fleet_fullz.pooled_resid`, 不复用 _fleet_residual。
  28. ★对 gym 版 (gym/algos/thermreg_antiamb.py, EXP-M3-22 PROMOTED) 的产化改动 (其余逐语句一致):
  29. (a) **种子策略**: gym = 单 rng(seed·7919+2203) 顺序流 (p 值依赖台遍历顺序/机群构成) → 产化改
  30. **sha256 逐台确定性** (seed = sha256(f"antiamb|{tid}")[:8], 同 yaw_validation estgate 既有产线模式)
  31. → 同输入恒同输出、增删台互不扰。**后果 (诚实, RV-1 独立审订正措辞)**: slope/z_slope/direction
  32. 恒逐字段相等 (斜率链路无 rng); 但 **perm_sig/flag 在 p≈p_gate 边界台可因种子策略差异翻转**
  33. (MC 噪声, 审核员对抗构造实测门口 |Δp|≈0.02-0.04、翻转 7/180 — 非语义变化, 两种 rng 都是合法
  34. MC 抽样; 高信噪台 p 打 1/201 地板不受影响)。置换 p 计算体与 gym 逐语句一致 (同 rng 状态 bitwise 等)。
  35. (b) 单窗口 (无 gym `_phase` 场景机器): 调用方切好一致性窗再喂; 多相位需求 = 调用方分窗多次调用。
  36. (c) 弃权显式化 (gym 静默 skip): 无环温列 / 池化行数不足 / 有效日数<min_days / fleet 斜率台数<min_turb
  37. → 全部带 note 的 abstain (INSUFFICIENT), 非静默全零"干净"。
  38. (d) cap_off 由 env 改显式关键字参数。
  39. ★纪律 / 诚实边界 (EXP-M3-22 §6 ledger, 未调参掩盖):
  40. - **候选级筛查**, 非确诊: flag = "反环温签名成立" → 现场核温控执行器/加热器才定谳。
  41. - **无变频器 gold**: 验证 = 合成 probe + INJ-only + feicheng 净场 fp 上界 (0/25); 无真实卡ON 标注召回。
  42. - **fleet-相对假设少数台异常**: majority-fleet 卡ON 会污染 median → fp cap 失效 (场景外声明;
  43. memory relative-criterion-batch-blindness 同族)。批次嫌疑场先做场型识别再用本判别器。
  44. - **全窗静态斜率 → 无 onset 维度** (何时卡的答不了); 需提前量走时序化 (backlog)。
  45. - **合成 probe 信噪比高于真注入** (z≈−44 vs §8.2 真注入 z≈−4.9): 真盘幅度更温和, 方向/分离一致。
  46. - 置换为日级 shuffle (10min 行级自相关已由日均聚合大幅降, 非严格块 bootstrap)。
  47. 来源: gym G4 家族③ thermreg_antiamb (EXP-M3-22, PROMOTED 2026-07-12) → §7 并回 2026-07-15。
  48. 并回闸门: gate-1 gym suite 复现 (8 passed) + gate-2 忠实度对拍/DP test (tests/sop/test_thermreg_antiamb_dp.py)
  49. 本会话完成; gate-3 RV-1 独立审由主会话另行 spawn (本文件不自证已审)。
  50. 纯核心无 I/O 可测; per-farm 加载在调用方 (skill/report 层)。
  51. """
  52. from __future__ import annotations
  53. import hashlib
  54. import numpy as np
  55. import pandas as pd
  56. N_PERM = 200 # 逐台置换重数 (null β 分布)
  57. P_GATE = 0.05 # 置换双侧 p 门
  58. Z_CAP = 3.0 # 斜率跨台稳健 |z| 门 = 健康 fp cap (3.0 控净场极值台假阳)
  59. MIN_DAYS = 30 # per台最少有效日数 (斜率回归 n)
  60. MIN_TURB = 5 # fleet-相对 z 最少台数
  61. MIN_POOL_ROWS = 5000 # 池化 OLS 最少行数 (镜像 temp_fleet_fullz.MIN_POOL_ROWS)
  62. def _pooled_fleet_resid(sub: pd.DataFrame, channel: str, load_cols: list,
  63. wind_col: str | None, turbine_col: str, time_col: str,
  64. min_pool_rows: int):
  65. """池化 OLS (载荷 + 风速 + 月哑元) → 行级残差列 _r。None = 样本不足。
  66. 逐语句镜像 gym temp_fleet_fullz.pooled_resid (列序: 1, load_cols..., wind, m2..m12);
  67. 唯一放宽: wind_col 可 None (无风速列时降级掉该项, 调用方须知残差模型变弱)。"""
  68. need = [turbine_col, time_col, channel] + ([wind_col] if wind_col else []) + load_cols
  69. d = sub[need].dropna()
  70. if len(d) < min_pool_rows:
  71. return None
  72. y = d[channel].to_numpy(float)
  73. cols = [np.ones(len(d))] + [d[c].to_numpy(float) for c in load_cols]
  74. if wind_col:
  75. cols.append(d[wind_col].to_numpy(float))
  76. mth = d[time_col].dt.month
  77. for m in range(2, 13):
  78. cols.append((mth == m).to_numpy(float))
  79. X = np.column_stack(cols)
  80. beta, *_ = np.linalg.lstsq(X, y, rcond=None)
  81. return d.assign(_r=y - X @ beta)
  82. def _slope(x: np.ndarray, y: np.ndarray) -> float:
  83. """OLS 斜率 slope(y ~ x) = cov(x,y)/var(x)。x 已保证非常量 (调用前 check)。"""
  84. xc = x - x.mean()
  85. vx = float(xc @ xc)
  86. if vx <= 0:
  87. return float("nan")
  88. return float((xc @ (y - y.mean())) / vx)
  89. def _perm_p(x: np.ndarray, y: np.ndarray, beta_obs: float, rng, n_perm: int) -> float:
  90. """逐台置换零: shuffle 环温标签 → null β 分布 → 双侧 p = P(|β_null| ≥ |β_obs|), +1 平滑。
  91. 计算体逐语句镜像 gym thermreg_antiamb._perm_p (同 rng 状态下 bitwise 等); rng 由调用方
  92. 以 sha256 逐台种子构造 (产化确定性, 见模块 docstring 改动 a)。"""
  93. if not np.isfinite(beta_obs):
  94. return 1.0
  95. xc = x - x.mean()
  96. vx = float(xc @ xc)
  97. if vx <= 0:
  98. return 1.0
  99. yc = y - y.mean()
  100. ge = 1 # +1 平滑 (含观测本身, 免 p=0)
  101. for _ in range(n_perm):
  102. yp = rng.permutation(yc)
  103. b = float(xc @ yp) / vx
  104. if abs(b) >= abs(beta_obs):
  105. ge += 1
  106. return ge / (n_perm + 1)
  107. def thermreg_antiamb_verdict(
  108. df: pd.DataFrame, *, channel: str, ambient_col: str, time_col: str, turbine_col: str,
  109. power_col: str, rpm_col: str | None = None, gen_flag_col: str | None = None,
  110. wind_col: str | None = None, gen_power_min: float = 50.0,
  111. n_perm: int = N_PERM, p_gate: float = P_GATE, z_cap: float = Z_CAP,
  112. min_days: int = MIN_DAYS, min_turb: int = MIN_TURB, min_pool_rows: int = MIN_POOL_ROWS,
  113. cap_off: bool = False,
  114. ) -> dict:
  115. """per-机组 反环温签名裁决 (温控卡死族分型: 加热器卡ON vs 泛过热)。
  116. 返回 {tid: {"flag": bool, "score": |z_slope|, "slope": 残差-环温斜率(℃/℃),
  117. "z_slope": 斜率跨台稳健z, "p_perm": 置换双侧p, "perm_sig": bool, "cap_hit": bool,
  118. "direction": "anti_ambient"|"pro_ambient"|None, "n_days": 有效日数, "note"?: 弃权原因}}。
  119. flag = (p_perm < p_gate) ∧ (|z_slope| ≥ z_cap) [cap_off=True 关后门, 仅 manipulation-check]。
  120. direction: slope<0 → anti_ambient (加热器卡ON 主目标) / slope>0 → pro_ambient / 弃权 → None。
  121. df 要求: time_col 为 datetime64; 调用方保证清洗后单一一致性窗 (阈值/口径见模块 docstring, 不重调)。
  122. """
  123. # NaN-safe: Arrow str backend 下 astype(str) 留 float NaN → sorted 混型崩; dropna + 显式 str()
  124. tids = sorted({str(x) for x in df[turbine_col].dropna().unique()})
  125. NULL = {"flag": False, "score": 0.0, "slope": 0.0, "z_slope": 0.0, "p_perm": None,
  126. "perm_sig": False, "cap_hit": False, "direction": None, "n_days": 0}
  127. if ambient_col is None or ambient_col not in df.columns:
  128. return {t: {**NULL, "note": "无 ambient_col → 反环温判别不适用; abstain (INSUFFICIENT)"}
  129. for t in tids}
  130. if channel not in df.columns or power_col not in df.columns:
  131. return {t: {**NULL, "note": "channel/power 列缺; abstain"} for t in tids}
  132. if not pd.api.types.is_datetime64_any_dtype(df[time_col]):
  133. return {t: {**NULL, "note": "time_col 非 datetime64; abstain (调用方先转)"} for t in tids}
  134. # gen 掩码 (gym 语义: flag≥0.5, 否则 P>gen_power_min)
  135. if gen_flag_col and gen_flag_col in df.columns:
  136. gen = df[gen_flag_col].astype(float) >= 0.5
  137. else:
  138. gen = df[power_col] > float(gen_power_min)
  139. sub = df[gen]
  140. out = {t: dict(NULL) for t in tids}
  141. load_cols = ([power_col] + ([rpm_col] if rpm_col and rpm_col in df.columns else [])
  142. + [ambient_col])
  143. rd = _pooled_fleet_resid(sub, channel, load_cols,
  144. wind_col if (wind_col and wind_col in df.columns) else None,
  145. turbine_col, time_col, min_pool_rows)
  146. if rd is None:
  147. for t in tids:
  148. out[t]["note"] = f"池化行数<{min_pool_rows}; abstain (INSUFFICIENT)"
  149. return out
  150. # 日均聚合 (降 10min 自相关) → per台 斜率 + 逐台置换 p
  151. daily = (rd.assign(_day=rd[time_col].dt.floor("D"))
  152. .groupby([turbine_col, "_day"])
  153. .agg(_rd=("_r", "mean"), _ad=(ambient_col, "mean"))
  154. .reset_index())
  155. slopes: dict = {}
  156. pvals: dict = {}
  157. for tid, g in daily.groupby(turbine_col):
  158. key = str(tid)
  159. x = g["_ad"].to_numpy(float)
  160. y = g["_rd"].to_numpy(float)
  161. m = np.isfinite(x) & np.isfinite(y)
  162. x, y = x[m], y[m]
  163. out[key]["n_days"] = int(len(x))
  164. if len(x) < min_days:
  165. out[key]["note"] = f"有效日数 {len(x)}<{min_days}; abstain"
  166. continue
  167. if x.std() <= 0:
  168. out[key]["note"] = "环温日均零方差; abstain"
  169. continue
  170. b = _slope(x, y)
  171. seed = int(hashlib.sha256(f"antiamb|{key}".encode()).hexdigest()[:8], 16)
  172. slopes[key] = b
  173. pvals[key] = _perm_p(x, y, b, np.random.default_rng(seed), n_perm)
  174. for t in tids:
  175. # RV-1 must-fix: 整台被 dropna/gen 掩码淘汰的机组从不进 daily 循环 → 原静默无 note
  176. # (违 docstring (c) "全部带 note"; 独立审逮)。补显式弃权。
  177. if out[t]["n_days"] == 0 and "note" not in out[t]:
  178. out[t]["note"] = f"有效日数 0<{min_days} (通道/环温全NaN或非gen态, 整台无有效日); abstain"
  179. keys = [k for k in slopes]
  180. if len(keys) < min_turb:
  181. for t in tids:
  182. if t in slopes: # 斜率/p 仍报 (信息), 但无跨台 z → 不可裁
  183. out[t].update({"slope": round(slopes[t], 4), "p_perm": round(pvals[t], 4),
  184. "perm_sig": bool(pvals[t] < p_gate)})
  185. out[t]["note"] = (f"fleet 有效斜率台数 {len(keys)}<{min_turb} → 跨台稳健 z 不可估; "
  186. "abstain (INSUFFICIENT)")
  187. return out
  188. # 健康 fp cap 轴: 斜率跨台稳健 z (median + 1.4826·MAD; MAD=0 退化 std)
  189. arr = np.array([slopes[k] for k in keys], float)
  190. med = float(np.median(arr))
  191. mad = float(np.median(np.abs(arr - med))) * 1.4826
  192. sd = mad if mad > 0 else float(arr.std(ddof=1))
  193. for k in keys:
  194. z = (slopes[k] - med) / sd if sd and sd > 0 else 0.0
  195. perm_sig = pvals[k] < p_gate
  196. cap_hit = abs(z) >= z_cap
  197. out[k].update({
  198. "flag": bool(perm_sig and (cap_hit or cap_off)),
  199. "score": round(abs(float(z)), 4),
  200. "slope": round(slopes[k], 4),
  201. "z_slope": round(float(z), 2),
  202. "p_perm": round(pvals[k], 4),
  203. "perm_sig": bool(perm_sig),
  204. "cap_hit": bool(cap_hit),
  205. "direction": "anti_ambient" if slopes[k] < 0 else "pro_ambient",
  206. })
  207. return out