validate_pair.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. # -*- coding: utf-8 -*-
  2. """validate_pair.py — 配对金标准验证 harness (Tier-1 健康 yes/no vs 故障告警 + CMH 跨场池化).
  3. 把一次性脚本 (changyi/yuelinbai/feicheng) 泛化成 farm-agnostic 模块: 新配对场登记进
  4. PAIRED_FARMS → `python -m src.sop.validate_pair` 一命令跑各场 Tier-1 2×2 + CMH 池化。
  5. = 数据获取瓶颈的操作化件 (docs/业主数据需求清单_v1.md §4): 每多一个配对场, Tier-1 跨场立/证伪近一步。
  6. ★纪律 (验证程序五次实证):
  7. - **严格主传动链热口径** (剔桨叶/变桨润滑污染, 肥城 raw OR=0.17→严格 3.33)。口径摊开可审 (THERMAL_KW)。
  8. - **红台非忙台** 已证伪共因; 但 **filter-search 陷阱**: 换口径/分界结论会松动 → 报多口径不挑最优。
  9. - **跨场需 n≥3** 才算 systemic (CLAUDE.md §跨场); 单场显著 ≠ 跨场立。CMH 附单场驱动检查。
  10. 纯核心 (is_thermal / red_from_findings / tier1_table / cmh) 无 I/O, 可测; per-farm 加载在 driver。
  11. """
  12. try:
  13. from app_common.app_common_guanlan.api import install_root as _install_root
  14. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  15. from pathlib import Path as _P
  16. def _install_root(_f): return _P(_f).resolve().parents[4]
  17. import re
  18. import numpy as np
  19. from scipy.stats import fisher_exact
  20. # 严格主传动链热告警口径 (可审; 剔桨叶润滑/电气/环境/控制)
  21. THERMAL_KW = {
  22. 'inc': ['齿轮箱', '齿轮油', '主轴承', '低速轴', '高速轴', '驱动端', '自由端', '非驱动端',
  23. '定子', '发电机', '变流器', '轴承', '绕组'],
  24. 'temp': ['温', '冷却'],
  25. 'exc': ['桨', '润滑油位', '润滑出错', '润滑压力', '偏航', '电网', '电压', '电流', '通讯',
  26. '复位', '待机', '环境', '风速', '油位', '液压', '刹车', '制动'],
  27. }
  28. def is_thermal(desc, kw=THERMAL_KW):
  29. """告警描述 → 是否严格主传动链热 (纯). 剔 exc 污染, 需 inc 部件 ∧ temp 热词。"""
  30. s = str(desc)
  31. if any(e in s for e in kw['exc']):
  32. return False
  33. return any(k in s for k in kw['inc']) and any(t in s for t in kw['temp'])
  34. def red_from_findings(findings_doc, exclude_dead_verdict='参考'):
  35. """findings.json → agent 温度标红台并集 (纯). 剔死列/低于候选门的 finding。"""
  36. red = set()
  37. for f in (findings_doc.get('findings') if isinstance(findings_doc, dict) else findings_doc) or []:
  38. t = str(f.get('title', ''))
  39. if not ('残差' in t or 'NBM' in t or '温度' in t):
  40. continue
  41. v = str(f.get('verdict', '')).strip('【】')
  42. if v in ('候选', '准定论·预警', '定论'): # 剔参考/INSUFFICIENT (含死列)
  43. for m in (f.get('affected_machines') or []):
  44. red.add(str(m).strip())
  45. return red
  46. def tier1_table(thermal_counts, red_set, cutoff='median'):
  47. """per台严格热告警数 + 红台集 → Tier-1 2×2 + Fisher (纯).
  48. thermal_counts: {tid: count} 全台; red_set: set[tid]; cutoff: median|presence|mean.
  49. 返回 {table:[[a,b],[c,d]], or, p, cut, n} (a=红×热高)。"""
  50. tids = list(thermal_counts)
  51. vals = np.array([thermal_counts[t] for t in tids], dtype=float)
  52. if cutoff == 'presence':
  53. thr = 0.0
  54. elif cutoff == 'mean':
  55. thr = float(vals.mean())
  56. else:
  57. thr = float(np.median(vals))
  58. tab = [[0, 0], [0, 0]]
  59. for t in tids:
  60. r = 0 if str(t) in {str(x) for x in red_set} else 1
  61. h = 0 if thermal_counts[t] > thr else 1
  62. tab[r][h] += 1
  63. try:
  64. orr, p = fisher_exact(tab)
  65. except ValueError:
  66. orr, p = float('nan'), 1.0
  67. return {'table': tab, 'or': orr, 'p': p, 'cutoff': f'{cutoff}={thr:.4g}', 'n': len(tids)}
  68. def cmh(tables):
  69. """Cochran-Mantel-Haenszel 跨场池化 (纯, 手算). tables: list[[[a,b],[c,d]]].
  70. 返回 {mh_or, chi2, p, per_stratum_or, single_farm_driven}。防 statsmodels 依赖。"""
  71. from scipy.stats import chi2 as chi2dist
  72. num = den = 0.0
  73. a_sum = e_sum = v_sum = 0.0
  74. per = []
  75. for T in tables:
  76. (a, b), (c, d) = T
  77. n = a + b + c + d
  78. if n == 0:
  79. continue
  80. num += a * d / n
  81. den += b * c / n
  82. a_sum += a
  83. e_sum += (a + b) * (a + c) / n
  84. if n > 1:
  85. v_sum += (a + b) * (c + d) * (a + c) * (b + d) / (n * n * (n - 1))
  86. orr = (a * d) / (b * c) if b * c else float('inf')
  87. per.append(round(orr, 2))
  88. mh_or = num / den if den else float('inf')
  89. chi2v = (abs(a_sum - e_sum) - 0.5) ** 2 / v_sum if v_sum else 0.0 # 连续性校正
  90. p = float(chi2dist.sf(chi2v, 1))
  91. # 单场驱动检查: 去掉每一场后是否仍 OR>1 方向 (够粗)
  92. return {'mh_or': round(mh_or, 2), 'chi2': round(chi2v, 3), 'p': round(p, 4),
  93. 'per_stratum_or': per, 'n_strata': len(per),
  94. 'note': f'{len(per)}场; systemic门槛n≥3' + (' (未达)' if len(per) < 3 else '')}
  95. # ══ Tier-2 故障锁定 vs 检修记录 (先兆核; 审-derived 方法烤进纯函数) ══════════
  96. # changyi 五次过度包装教训烤进 lead_signal: 仅运行日残差 vs 对照(非raw分位) + 恒偏vs发展 + 样本闸。
  97. # 缺陷名 → 域: temp(温度可探) / blind(SCADA温度盲, 判 INSUFFICIENT 非漏检) / other
  98. DEFECT_SCOPE_KW = {
  99. 'temp': ['水冷', '冷却', '轴承', '齿轮', '油温', '油泵', '过热', '碳刷', '发电机', '定子', '温度', '绕组'],
  100. 'blind': ['叶片', '变桨', '桨距', '不变桨', '熔断', '690v', '电缆', '绝缘', '接触器'],
  101. }
  102. def defect_scope(name):
  103. """缺陷名 → temp|blind|other (纯). blind = SCADA温度看不见, agent 判 INSUFFICIENT 是对的非漏检。"""
  104. s = str(name).lower()
  105. if any(k in s for k in DEFECT_SCOPE_KW['blind']):
  106. return 'blind'
  107. if any(k in str(name) for k in DEFECT_SCOPE_KW['temp']):
  108. return 'temp'
  109. return 'other'
  110. def lead_signal(resid_window, control_resids, z_dev=1.0, dev_frac=0.5, min_points=8):
  111. """故障锁定先兆核 (纯, 审-derived; **只出客观描述, 不下命中**).
  112. resid_window: 该台该通道 发生前窗的 NBM 残差序列 (仅运行日, 按时序; 季节已在 NBM 扣)。
  113. control_resids: 对照台同通道 per-台均值残差 list (基线云)。
  114. kind 判据 (烤进 changyi 五次教训):
  115. insufficient: n<min_points (A19/A30: 发生前多停机, 运行日点极少, 趋势外推自空气)。
  116. no_signal: z_vs_control < z_dev (A25: 落对照云内, raw分位高但残差无特异)。
  117. constant_offset: z≥z_dev ∧ 窗首≈窗尾 (A28/A13: 恒偏一直高, 撞时间窗非预测先兆)。
  118. developing: z≥z_dev ∧ 窗尾显著高于窗首 (真发生前发展信号)。
  119. 返回 {n, z_vs_control, early_mean, late_mean, kind}。
  120. """
  121. w = [x for x in resid_window if x == x] # 去 NaN
  122. n = len(w)
  123. cr = [x for x in control_resids if x == x]
  124. cmean = float(np.mean(cr)) if cr else 0.0
  125. cstd = float(np.std(cr)) if cr and np.std(cr) > 0 else 1.0
  126. if n < min_points:
  127. return {'n': n, 'z_vs_control': None, 'early_mean': None, 'late_mean': None, 'kind': 'insufficient'}
  128. z = (float(np.mean(w)) - cmean) / cstd
  129. half = max(1, n // 2)
  130. early, late = float(np.mean(w[:half])), float(np.mean(w[half:]))
  131. if z < z_dev:
  132. kind = 'no_signal'
  133. elif (late - early) > dev_frac * cstd:
  134. kind = 'developing'
  135. else:
  136. kind = 'constant_offset'
  137. return {'n': n, 'z_vs_control': round(z, 2), 'early_mean': round(early, 2),
  138. 'late_mean': round(late, 2), 'kind': kind}
  139. def tier2_summary(results):
  140. """一场 Tier-2 逐缺陷 lead_signal 结果 → 客观汇总 (纯; 不下准确度定论)。
  141. results: list[{defect, scope, kind}]。返回各 kind 计数 + 明确"blind=出域非漏检"。"""
  142. from collections import Counter
  143. temp = [r for r in results if r.get('scope') == 'temp']
  144. kc = Counter(r.get('kind') for r in temp)
  145. return {
  146. 'n_defects': len(results),
  147. 'n_temp_detectable': len(temp),
  148. 'n_blind': sum(1 for r in results if r.get('scope') == 'blind'),
  149. 'developing': kc.get('developing', 0), 'constant_offset': kc.get('constant_offset', 0),
  150. 'no_signal': kc.get('no_signal', 0), 'insufficient': kc.get('insufficient', 0),
  151. 'note': 'developing=干净发生前信号; constant_offset=恒偏撞窗非预测; blind=SCADA盲区(agent判INSUFFICIENT非漏检)。'
  152. 'developing才算"温度锁定命中", 且须独立审(filter/口径)。',
  153. }
  154. # ── per-farm 加载适配 (I/O; 新配对场登记于此) ──────────────────────────────
  155. def _bx(s):
  156. m = re.search(r'B(\d+)号', str(s))
  157. return m.group(1) if m else re.sub(r'\D', '', str(s))[:3]
  158. PAIRED_FARMS = {
  159. 'yuelinbai': {
  160. 'findings': 'outputs/yuelinbai/sop/findings.json',
  161. 'fault_glob': '/Volumes/WINDDATA/DATA2/华电/华电山东/岳林柏风电场-山东-华电/收资数据/故障记录/**/*',
  162. 'enc': 'gbk', 'desc_col': '状态码描述', 'tid_col': '风机名', 'tid_norm': _bx,
  163. },
  164. 'feicheng': {
  165. 'findings': 'outputs/feicheng/sop/findings.json',
  166. 'fault_path': '/Volumes/WINDDATA/DATA2/华电/华电山东/历史/收资数据-华电山东/肥城风电场/肥城上汽数据/故障记录_20250101_20251124.csv',
  167. 'enc': 'gbk', 'desc_col': '状态码描述', 'tid_col': '风机名', 'tid_norm': lambda s: str(int(float(s))) if str(s).replace('.', '').isdigit() else str(s),
  168. },
  169. }
  170. def _load_farm(cfg):
  171. """加载一场: findings→红台 + 故障记录→per台严格热计数。返回 (red, thermal_counts)。"""
  172. import json
  173. import glob
  174. import pandas as pd
  175. from pathlib import Path
  176. root = _install_root(__file__)
  177. fd = json.loads((root / cfg['findings']).read_text(encoding='utf-8'))
  178. red = {cfg['tid_norm'](m) for m in red_from_findings(fd)}
  179. # 故障记录 (单文件或 glob 多文件)
  180. if cfg.get('fault_path'):
  181. files = [cfg['fault_path']]
  182. else:
  183. files = [f for f in glob.glob(cfg['fault_glob'], recursive=True)
  184. if f.lower().endswith(('.csv', '.xls', '.xlsx'))]
  185. parts = []
  186. for f in files:
  187. try:
  188. d = pd.read_excel(f) if f.lower().endswith(('.xls', '.xlsx')) else pd.read_csv(f, encoding=cfg['enc'])
  189. parts.append(d)
  190. except Exception:
  191. pass
  192. fa = pd.concat(parts, ignore_index=True)
  193. fa['tid'] = fa[cfg['tid_col']].map(cfg['tid_norm'])
  194. fa['th'] = fa[cfg['desc_col']].map(is_thermal)
  195. th = fa[fa['th']].groupby('tid').size()
  196. counts = {t: int(th.get(t, 0)) for t in fa['tid'].dropna().unique()}
  197. return red, counts
  198. if __name__ == '__main__':
  199. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  200. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  201. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  202. import sys as _sys
  203. for _s in (_sys.stdout, _sys.stderr):
  204. try: _s.reconfigure(errors='replace')
  205. except Exception: pass
  206. # CLI: python -m src.sop.validate_pair → 各场 Tier-1 + CMH 池化 (复现验证程序)
  207. import argparse
  208. ap = argparse.ArgumentParser()
  209. ap.add_argument('--cutoff', default='median', choices=['median', 'presence', 'mean'])
  210. a = ap.parse_args()
  211. tables = []
  212. print(f"=== Tier-1 配对验证 harness (严格主传动链热, cutoff={a.cutoff}) ===")
  213. for farm, cfg in PAIRED_FARMS.items():
  214. try:
  215. red, counts = _load_farm(cfg)
  216. r = tier1_table(counts, red, a.cutoff)
  217. tables.append(r['table'])
  218. (ta, tb), (tc, td) = r['table']
  219. print(f" {farm:12s}: 红[{ta},{tb}] 非红[{tc},{td}] OR={r['or']:.2f} p={r['p']:.3f} (红{len(red)}台/共{r['n']})")
  220. except Exception as e:
  221. print(f" {farm:12s}: 加载失败 {str(e)[:50]}")
  222. if len(tables) >= 2:
  223. pool = cmh(tables)
  224. print(f"\n CMH 池化: M-H OR={pool['mh_or']} chi2={pool['chi2']} p={pool['p']} | 各场OR={pool['per_stratum_or']} | {pool['note']}")
  225. if pool['n_strata'] < 3:
  226. print(" ⚠ 单场驱动风险 + 未达 n≥3 systemic 门槛 → 跨场未立 (需更多配对场, 见 业主数据需求清单)")