temp_nbm.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. # -*- coding: utf-8 -*-
  2. """B翼 温度NBM (windscada M6a, 2026-08-24). 判据出处: skill temp-nbm + 如东教训七坑设防.
  3. 方法: 发电态×功率档(500kW) 同工况 fleet 相对偏差 (ref=2025-H2 基线代 / cur=末90天), n加权.
  4. 七坑: ①恒值哨兵(契约层已排GFilB2/B3) ②传感器不可信 dev≥15K→A类非热 ③返服run-in(cur运行量不足→不可判)
  5. ④配对差(主轴承双温/发电机NDE-DE) ⑤热汇列(环温/舱温自身偏→该台ΔT类解释存疑, 单列注记)
  6. ⑥冷浸盲区(发电态过滤天然规避) ⑦相对判据配绝对量(dev以K计, 报警需绝对dev≥4K非只z)."""
  7. import numpy as np, pandas as pd, pathlib
  8. from src.windscada.config import farm
  9. from app_ETL.app_ETL_guanlan.api import load_10min, contracted_cols
  10. from ..perf.curtail import classify
  11. PBINS = np.arange(0, 4500, 500)
  12. SENSOR_IMPLAUSIBLE_K = 15.0
  13. # 季节解耦闸: 环温通道自身窗间位移超此值 ⇒ 两窗季节错配, common_shift 拒绝出结论 (见其 docstring)
  14. SEASON_MATCH_MAX_K = 1.0
  15. PAIRS = {'主轴承双温差': ('tmp_wtc_MBearHTm_mean', 'tmp_wtc_MBearGTm_mean'),
  16. '发电机轴承NDE-DE(G-R)': ('tmp_wtc_GenBeGTm_mean', 'tmp_wtc_GenBeRTm_mean')} # 官方IO表: G=NDE, R=DE (2026-08-26 定案)
  17. HEATSINK = ['tmp_wtc_AmbieTmp_mean', 'tmp_wtc_NacelTmp_mean']
  18. # 跨线联动跟踪名单 (2026-08-26 与振动线约定, 机器位非口头):
  19. # 触发 = 最新月 dev≥2.5K 或 位次进前3 → temp_overview 置顶显示 [跨线通知], 见即发信箱给振动线
  20. WATCHLIST = {
  21. ('WTG17', 'tmp_wtc_GenBeRTm_mean'): '17# 发电机DE(R端): 振动iso_rms_vel排1/35台, 温度2026-02起爬升(07月+2.3K第7)',
  22. ('WTG23', 'tmp_wtc_GenBeGTm_mean'): '23# 发电机NDE(G端): 振动单端1.5-1.9×fleet, 温度全程正常(对照台)',
  23. # 29# 机制=润滑断供·泵本体 (振动线v5定案, 2026-08-27更正: 非新报警非轴承损伤).
  24. # 温度/振动尖峰/柱塞=同一机制的果侧两观测+因侧一观测, 禁写"三源印证"(振动线④纪律采纳).
  25. # 柱塞两段式签名(mblub_monthly, 全纵向口径): 25-04~10单次时长由自身25-01~03的132~261s塌到34~57s→温度峰(25-05/08第1);
  26. # 26-07窗(至07-07)**单月突变**: 26-06单次68s完全正常→07窗1784s=自身18月区间(34~261s)的7~52倍,
  27. # 总打脂10.3h/天 vs 自身正常~1.4h/天, counts 6天窗应有288实测145=50% = 泵打不满挣扎态;
  28. # 突变后数日振动尖峰起(07-11首发在台风窗内, 窗外净尖峰07-24起). [口径勘误2026-08-27: 原"6×中位/70×
  29. # 对照台"为横比, 柱塞单次时长跨台天然异质(p90/p10=16~100×)横比无效, 双线一致弃用, 判读只认自身纵向]
  30. # 并读纪律: 温度dev与振动尖峰频次同步恶化才升级, 单独一个上升可能是工况.
  31. # 修泵后验收正本 = 振动线 findings.json TCM-16 (三分支决策树带量化门: A尖峰消14天/20条/max<8扣台风,
  32. # B柱塞回带尖峰不消才内窥, C柱塞不回带重排泵/管路; 温度不降不否证A). 本处只留指针不复制, 防载体漂移.
  33. # ★温度滞后条款(TCM-16③): 泵修好后温度dev允许滞后1~2月缓降 — watch通知若在修后窗内触发, 先对照TCM-16再升级.
  34. ('WTG29', 'tmp_wtc_MBearGTm_mean'): '29# 后主轴承(齿轮箱侧): 润滑断供机制(v5), 温度=果侧观测, 与振动尖峰并读',
  35. ('WTG29', 'tmp_wtc_MBearHTm_mean'): '29# 前主轴承(轮毂侧): 同上, 双端并盯',
  36. }
  37. def watchlist_check(cfg=None):
  38. """联动名单当前状态: 最新月 dev 与位次; 触发返回 [跨线通知] 行."""
  39. cfg = cfg or farm()
  40. import pandas as _pd
  41. f = pathlib.Path(cfg['store']) / 'watch_channels_monthly.parquet'
  42. if not f.exists():
  43. return ['[联动跟踪] watch_channels_monthly 仓缺失 — 跟踪失效, 须重建 scripts/windscada_watch_channels_build.py (守护失败必响亮)']
  44. df = _pd.read_parquet(f)
  45. out = []
  46. for (t, ch), why in WATCHLIST.items():
  47. piv = df.pivot_table(index='month', columns='turbine', values=ch)
  48. m = piv.index.max()
  49. row = piv.loc[m]
  50. dev = float(row[t] - row.median())
  51. rank = int(row.rank(ascending=False)[t])
  52. n = int(row.notna().sum())
  53. hit = dev >= 2.5 or rank <= 3
  54. tag = '🔔[跨线通知-发信箱给振动线]' if hit else '[联动跟踪中]'
  55. out.append(f"{tag} {t} {ch.replace('tmp_wtc_','').replace('_mean','')} {m}: dev{dev:+.1f}K 位次{rank}/{n} (门=dev≥2.5K∨前3) | {why}")
  56. return out
  57. def build_store(cfg=None, span=None, write=True):
  58. """温度 NBM 比档件。`span=(起, 止)` 给了就把 `cur` 档换成**所选时间窗**(含两端,用户令 2026-09-21);
  59. `ref`/`ref_sm` 两个对照窗保持不变;span 模式走 `slim10min` 窄仓且**不写盘**(不覆盖正式产物)。
  60. """
  61. cfg = cfg or farm()
  62. tmps = [x for x in contracted_cols(cfg, groups=['B.温度NBM']) if x.endswith('_mean')]
  63. rows, failed = [], []
  64. for t in cfg['turbines']:
  65. try:
  66. if span:
  67. from app_ETL.app_ETL_guanlan.api import slim
  68. # ★一次读、内存里切三窗(2026-09-21 性能实测): 三窗各读一次 = 3×38 次 parquet 读,
  69. # 每台 261k 行 ⇒ 37 s/窗。改成"按列裁剪读一次 + 内存掩码切片" ⇒ 列少一半、读一次,
  70. # 口径不变(掩码仍按各窗边界,逐行同源)。
  71. _cols_t = ['ts', 'grd_wtc_ActPower_mean', 'tur_wtc_PowerRef_endvalue',
  72. 'tur_wtc_SecAnemo_mean', 'tur_wtc_GenRpm_mean',
  73. *[c for c in tmps if c not in ('grd_wtc_ActPower_mean',)]]
  74. d = slim.load(t, cfg, span=None, columns=_cols_t)
  75. else:
  76. d = load_10min(t, cfg, groups=['A.功率', 'A.风况', 'A.转速', 'B.温度NBM'])
  77. except Exception as e:
  78. # 守护失败必响亮: 原为裸 `except: continue`, 数据根整个搬走时会**静默**产出空仓
  79. # (2026-09-07 实测: 源盘路径失效 → 38 台全失败 → 0 行落盘, 无任何报错, 只能手工 debug)。
  80. failed.append((t, f'{type(e).__name__}: {e}'))
  81. continue
  82. d['state'] = classify(d, cfg['rated_kw'])
  83. d = d[(d['state'].isin(['正常发电', '限电·绑定', '限电命令·未绑定'])) & (d['grd_wtc_ActPower_mean'] > 200)]
  84. cur_lo = str(d.ts.max() - pd.Timedelta(days=90))[:10]
  85. # ★ref_sm (2026-09-07): 与 cur **同历月、早一年**的季节匹配窗, 供 common_shift 做季节解耦。
  86. # 为什么不沿用互斥 np.where: ref_sm 与 ref 有约一周重叠, 互斥赋值会**静默削掉** ref_sm 一截
  87. # (先匹配者胜)。改为每窗独立取子集 → 允许重叠, 且 **ref/cur 的内容逐行不变** (加窗零副作用)。
  88. # span 模式: ref_sm 跟着**所选窗**早一年(季节匹配的是用户选的窗,不是"数据末端 90 天")。
  89. if span:
  90. sm_lo = str((pd.Timestamp(span[0]) - pd.DateOffset(years=1)).date())
  91. sm_hi = str((pd.Timestamp(span[1]) - pd.DateOffset(years=1)).date())
  92. else:
  93. sm_lo = str(pd.Timestamp(cur_lo) - pd.DateOffset(years=1))[:10]
  94. sm_hi = str(d.ts.max() - pd.DateOffset(years=1))[:10]
  95. d['pbin'] = pd.cut(d['grd_wtc_ActPower_mean'], PBINS).map(lambda iv: iv.mid if pd.notna(iv) else np.nan)
  96. d = d.dropna(subset=['pbin'])
  97. # 掩码必须在 dropna **之后**建: 先建会让布尔序列索引与 d 错位, pandas 靠索引静默重对齐
  98. # (UserWarning: Boolean Series key will be reindexed) —— 那是会出错的地方, 不留。
  99. # 窗边界仍取自 state 过滤后、pbin 过滤前的 tmax (与加窗前口径一致, 保 cur 逐行不变)。
  100. # ★2026-09-21 用户令「判级按所选时间窗」: span 给了就把 `cur` 档换成所选窗(含两端),
  101. # `ref`/`ref_sm` 仍固定(季节解耦的对照窗必须钉住,否则失去对照意义)。
  102. if span:
  103. _a, _b = pd.Timestamp(span[0]), pd.Timestamp(span[1]) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)
  104. cur_m = (d.ts >= _a) & (d.ts <= _b)
  105. else:
  106. cur_m = d.ts >= cur_lo
  107. wins = {'ref': (d.ts >= '2025-07-01') & (d.ts < '2026-01-01'),
  108. 'cur': cur_m,
  109. 'ref_sm': (d.ts >= sm_lo) & (d.ts <= sm_hi)}
  110. for wname, m in wins.items():
  111. dw = d[m]
  112. if dw.empty: # 早期场可能没有早一年的数据 → 该窗缺, common_shift 会 raise 而非静默
  113. continue
  114. if span:
  115. # ★按窗重算走**批量**分组(2026-09-21 性能实测: 逐通道 27 次 groupby/台 ⇒ 37 s/窗;
  116. # 一次 groupby 全通道 ⇒ ~2 s。口径逐格相同: 同 median、同 size 门 ≥18,
  117. # 只是行序不同 —— 默认路径保持原样不动,正式产物 byte 级不变。
  118. cols = [c for c in tmps if c in dw.columns]
  119. g = dw.groupby('pbin', observed=True)[cols].agg(['median', 'size'])
  120. for ch in cols:
  121. sub = g[ch]
  122. sub = sub[sub['size'] >= 18]
  123. for pb, r in sub.iterrows():
  124. rows.append(dict(turbine=t, channel=ch, win=wname, pbin=float(pb),
  125. med=float(r['median']), n=int(r['size'])))
  126. continue
  127. for ch in tmps:
  128. if ch not in dw.columns:
  129. continue
  130. g = dw.groupby('pbin', observed=True)[ch].agg(['median', 'size']).reset_index()
  131. g = g[g['size'] >= 18]
  132. for _, r in g.iterrows():
  133. rows.append(dict(turbine=t, channel=ch, win=wname, pbin=float(r['pbin']),
  134. med=float(r['median']), n=int(r['size'])))
  135. if write:
  136. print(t, flush=True)
  137. if failed:
  138. print(f'⚠ {len(failed)}/{len(cfg["turbines"])} 台加载失败: '
  139. + '; '.join(f'{t} {e}' for t, e in failed[:3])
  140. + (' …' if len(failed) > 3 else ''), flush=True)
  141. if not rows:
  142. raise RuntimeError(
  143. f'build_store 一行都没产出 ({len(failed)}/{len(cfg["turbines"])} 台加载失败) — '
  144. f'源目录 {cfg.get("src_10min")} 是否存在? 不静默落空仓覆盖既有 store。'
  145. + (f' 首个错误: {failed[0][1]}' if failed else ''))
  146. st = pathlib.Path(cfg['store']); st.mkdir(parents=True, exist_ok=True)
  147. out = pd.DataFrame(rows)
  148. if write:
  149. out.to_parquet(st / 'temp_bins.parquet')
  150. print('→ temp_bins.parquet', len(rows), f'(窗: {sorted({r["win"] for r in rows})})')
  151. return out
  152. def registry(cfg=None, win='cur', span=None):
  153. """温度 NBM 判级。`span=(起, 止)` 给了就**按所选时间窗**重算 `cur` 档(用户令 2026-09-21):
  154. `ref`(2025-07~2025-12 干净窗)与 `ref_sm`(同历月早一年)仍是**固定基线**——
  155. 它们的作用是"季节解耦的对照窗",跟着用户窗漂移就失去对照意义;页面据此如实标注。
  156. """
  157. cfg = cfg or farm()
  158. if span:
  159. tb = build_store(cfg, span=span, write=False)
  160. else:
  161. tb = pd.read_parquet(pathlib.Path(cfg['store']) / 'temp_bins.parquet')
  162. tb = tb[tb.win == win]
  163. # ★2026-09-21 实逮(服务器按窗重算时): 窗内样本退化(如"近30日"=2026-09 只有 1 个节拍 ⇒ 比档件为空)时,
  164. # `groupby(...).apply(...)` 返回的是**空 DataFrame** 而不是 Series ⇒ `.rename('dev_K')` 抛
  165. # `TypeError: Index(...) must be called with a collection of some kind, 'dev_K' was passed`,
  166. # 把整面判级打断。退化窗应当**如实"不可判"**(样本不足),不是崩。
  167. if not len(tb):
  168. return pd.DataFrame(columns=['turbine', 'channel', 'dev_K', '判'])
  169. fleet = tb.groupby(['channel', 'pbin'])['med'].median().rename('fleet_med')
  170. tb = tb.join(fleet, on=['channel', 'pbin'])
  171. tb['dev'] = tb['med'] - tb['fleet_med']
  172. dev = tb.groupby(['turbine', 'channel']).apply(lambda x: float(np.average(x['dev'], weights=x['n'])), include_groups=False).rename('dev_K').reset_index()
  173. mad = dev.groupby('channel')['dev_K'].apply(lambda s: float(1.4826 * np.median(np.abs(s - s.median())))).rename('mad')
  174. dev = dev.join(mad, on='channel')
  175. dev['z'] = dev['dev_K'] / dev['mad'].clip(lower=0.3)
  176. nrows = tb.groupby('turbine')['n'].sum().rename('n_rows')
  177. dev = dev.join(nrows, on='turbine')
  178. def verdict(r):
  179. if r['n_rows'] < 500:
  180. return '不可判(样本不足/返服嫌疑)'
  181. if r['dev_K'] >= SENSOR_IMPLAUSIBLE_K:
  182. return '传感器候选(A类,非热)'
  183. if r['dev_K'] >= 4.0 and r['z'] >= 3.0:
  184. return '温度候选(定向查)'
  185. if r['dev_K'] >= 2.5 and r['z'] >= 2.0:
  186. return '记基线观察'
  187. return '—'
  188. dev['判'] = dev.apply(verdict, axis=1)
  189. dev['热汇注'] = dev['channel'].isin(HEATSINK)
  190. dev = dev.sort_values('dev_K', ascending=False)
  191. # ★批次盲声明 (实验 K): dev_K/z 是 fleet 相对量, 对全场同步平移逐值不变 ⇒ 本表**看不见批次退化**。
  192. # 判'—'只等于"该台不偏离机群", 不等于"该台正常"。共模轴须看 common_shift()。
  193. dev.attrs['batch_blind'] = {
  194. 'blind_to': '全场同步平移 (批次退化 / 场级参考量变化)',
  195. 'why': "dev_K = med − fleet_med; 全体同移则 dev_K 逐值不变 (实测 max|Δz|=0.00e+00)",
  196. 'not_a_threshold_issue': True,
  197. 'read_as': "判'—' ≠ 该台正常, 只等于'不偏离机群'; 共模轴见 temp_nbm.common_shift()",
  198. }
  199. return dev
  200. def common_shift(cfg=None, win_cur='cur', win_ref=None):
  201. """共模图 — registry 缺的那另一半 (实验 K 接线 2026-09-07; 判据族见 sop.discriminators.fleet_two_component_split)。
  202. registry() 算 `fleet_med` 只为把它**减掉**, 自身从不被看。但 `d − fleet_med` 对全场同步平移
  203. **逐值恒等不变** (如东 38 台×4 通道实测 max|Δz|=0.00e+00) ⇒ **批次退化在 registry 上必然全绿**,
  204. 且这不是阈值问题, 调阈值无效。本函数补上被丢掉的那一半: 同工况分档下 fleet_med 自身 cur vs ref 的位移。
  205. 数据不用新采 —— `temp_bins.parquet` 里 ref(2025-07~2026-01) 与 cur(末90天) 两窗都在, registry 只读了 cur。
  206. ⚠⚠ **季节混杂 — 本函数在如东当前 store 上给不出结论 (2026-09-07 首跑实测, 非理论顾虑)**:
  207. build_store 的 ref(2025-07~2026-01) 与 cur(末90天) **不同历月**, pbin 只匹配**载荷**没匹配**环温**。
  208. 实测 `amb_common_K = −5.5K` ⇒ 两窗差大半个季节。此时"raw 减环温"的一阶修正 (隐含各通道对环温增益≈1)
  209. 把**全部 19 个通道同向抬成候选 = 19/19 全报 = 零分辨力** —— 真实增益逐通道不同 (热汇类≈1, 强自发热部件<1)。
  210. 故本函数设**季节解耦闸**: |amb_common_K| ≥ SEASON_MATCH_MAX_K 时全表判 INSUFFICIENT, **拒绝出结论**,
  211. 不发全红表让人自己悟。要真答案走 `sop.discriminators.fleet_two_component_split`
  212. (逐台 OLS 扣 P/环温 + 季节匹配 H0 + 时序 SPRT) 或 `nbm_residual(common_*_window=...)`。
  213. ⇒ 本表当前的用途 = **让批次盲这件事在 UI 上可见**, 外加 `apparent_gain` 诊断列; **不是**批次退化检出器。
  214. 修法 (待裁, 需改 build_store 并重建 store): 加一个与 cur **同历月、早一年**的窗 (如 ref_sm), 两窗季节对齐。
  215. ⚠ 共模图**不能单独定因**: 全场同移既可能是批次退化, 也可能是场级参考量变化 (环温族偏/微气象/控制版本
  216. 全场升级/限电制度切换) ⇒ 单靠本表最高 **准定论·预警**, 升定论须外部绝对锚。
  217. 返回 DataFrame: channel / common_K_raw / common_K_net / amb_common_K / vs_mad(net 相对台间散布 MAD 的倍数)
  218. / n_pbin / 判 / 热汇注。
  219. """
  220. cfg = cfg or farm()
  221. tb = pd.read_parquet(pathlib.Path(cfg['store']) / 'temp_bins.parquet')
  222. if win_ref is None: # 优先季节匹配窗; 老 store 无 ref_sm 时退回 ref, 由季节解耦闸兜底判 INSUFFICIENT
  223. win_ref = 'ref_sm' if 'ref_sm' in set(tb['win'].unique()) else 'ref'
  224. if not {win_cur, win_ref} <= set(tb['win'].unique()):
  225. raise KeyError(f'temp_bins 缺窗 {win_cur}/{win_ref} (实有 {sorted(tb["win"].unique())}); '
  226. '共模图是硬前置, 不静默跳过')
  227. # 每 (win, channel, pbin) 的 fleet 中位 = registry 里被减掉的那个量
  228. fm = tb.groupby(['win', 'channel', 'pbin']).agg(fleet_med=('med', 'median'), n=('n', 'sum')).reset_index()
  229. cur = fm[fm.win == win_cur].set_index(['channel', 'pbin'])
  230. ref = fm[fm.win == win_ref].set_index(['channel', 'pbin'])
  231. j = cur.join(ref, how='inner', lsuffix='_c', rsuffix='_r')
  232. j['shift'] = j['fleet_med_c'] - j['fleet_med_r']
  233. j['w'] = j[['n_c', 'n_r']].min(axis=1)
  234. raw = j.groupby('channel').apply(
  235. lambda x: float(np.average(x['shift'], weights=x['w'])), include_groups=False).rename('common_K_raw')
  236. npb = j.groupby('channel').size().rename('n_pbin')
  237. out = pd.concat([raw, npb], axis=1).reset_index()
  238. # 一阶季节修正: 减环温通道自身的共模位移 (假定增益≈1 — 粗, 见 docstring)
  239. amb_row = out[out.channel == 'tmp_wtc_AmbieTmp_mean']['common_K_raw']
  240. amb_k = float(amb_row.iloc[0]) if len(amb_row) else np.nan
  241. out['amb_common_K'] = amb_k
  242. out['common_K_net'] = out['common_K_raw'] - (amb_k if np.isfinite(amb_k) else 0.0)
  243. # 相对台间散布: 全场整体移了多少个"台间 MAD" — >1 即整体位移已超过 registry 能看见的散布
  244. dev = registry(cfg, win=win_cur)
  245. mad = dev.groupby('channel')['dev_K'].apply(
  246. lambda x: float(1.4826 * np.median(np.abs(x - x.median())))).rename('mad')
  247. out = out.join(mad, on='channel')
  248. out['vs_mad'] = out['common_K_net'] / out['mad'].clip(lower=0.3)
  249. # 表观增益 = 该通道随环温走了几成 (诊断量; 自发热强的部件 <1, 热汇类 ≈1)
  250. # ⚠ 只在环温**确有可观位移**时才算表观增益 —— 季节对齐后 amb_k→0, 除以近零分母会得出 7~8 的假增益
  251. # (2026-09-07 装 ref_sm 后实测: amb_k 从 −5.54K 降到 −0.31K, gain 立刻虚涨到 7.5~8.4)。
  252. # 本列只是"季节错配时"的诊断量, 季节对齐时它无意义 → NaN, 不给假数。
  253. out['apparent_gain'] = (out['common_K_raw'] / amb_k
  254. if (np.isfinite(amb_k) and abs(amb_k) >= SEASON_MATCH_MAX_K) else np.nan)
  255. # ★季节解耦闸 (2026-09-07 首跑实测逼出): 环温自身窗间位移 = 两窗季节错配的**实测**代理。
  256. # 如东实跑 amb_common_K = −5.5K ⇒ ref(7~12月) 与 cur(末90天) 差了大半个季节。此时
  257. # "raw 减环温" 的一阶修正 (假定增益≈1) 会把**全部 19 通道**同向抬成候选 = 19/19 全报 = 零分辨力。
  258. # 故此处**不许出结论**: 全表判 INSUFFICIENT 并指向真判据, 而不是发一张全红的表让人自己悟。
  259. # (失效必须落在显式状态 — 同 memory parse-failure-read-as-positive-result 的反向: 别让"全阳"冒充检出。)
  260. season_decoupled = bool(np.isfinite(amb_k) and abs(amb_k) < SEASON_MATCH_MAX_K)
  261. out.attrs['season_decoupled'] = season_decoupled
  262. out.attrs['amb_common_K'] = amb_k
  263. out.attrs['win_ref'] = win_ref # 用了哪个参考窗 — 别让读者猜 ref 还是 ref_sm
  264. def verdict(r):
  265. if not np.isfinite(amb_k):
  266. return '不可判(环温通道缺, 季节无法修)'
  267. if not season_decoupled:
  268. return f'INSUFFICIENT(季节未解耦: 环温窗间移{amb_k:+.1f}K)'
  269. if r['channel'] == 'tmp_wtc_AmbieTmp_mean':
  270. return '— (季节基准本身)'
  271. if r['n_pbin'] < 3:
  272. return '不可判(共同档位<3)'
  273. a = abs(r['common_K_net'])
  274. if a >= SENSOR_IMPLAUSIBLE_K:
  275. return '共模巨移(口径/数据嫌疑, 非热)'
  276. if a >= 2.5 and abs(r['vs_mad']) >= 1.0:
  277. return '共模漂移候选(批次/场级, 须外部锚)'
  278. if a >= 1.5:
  279. return '记基线观察'
  280. return '—'
  281. out['判'] = out.apply(verdict, axis=1)
  282. out['热汇注'] = out['channel'].isin(HEATSINK)
  283. return out.sort_values('common_K_net', key=lambda s: s.abs(), ascending=False).reset_index(drop=True)
  284. def pairs_table(cfg=None):
  285. cfg = cfg or farm()
  286. tb = pd.read_parquet(pathlib.Path(cfg['store']) / 'temp_bins.parquet')
  287. tb = tb[tb.win == 'cur']
  288. out = []
  289. for name, (a, b) in PAIRS.items():
  290. pa = tb[tb.channel == a].groupby('turbine')['med'].median()
  291. pb = tb[tb.channel == b].groupby('turbine')['med'].median()
  292. dd = (pa - pb).dropna().rename('pair_diff').reset_index()
  293. dd['pair'] = name
  294. med = dd['pair_diff'].median()
  295. dd['pair_dev'] = dd['pair_diff'] - med
  296. out.append(dd)
  297. return pd.concat(out, ignore_index=True)