thermal_chain.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. # -*- coding: utf-8 -*-
  2. """B翼 热链与冷却执行器件 (windscada M13; SOP §4.6 局部摩擦判据 + §4.6c①a 参考通道存活守卫).
  3. ★补上分册对账缺口"水冷/冷却行为轴我无" — 原生层实有整组冷却执行器 (先前未契约化).
  4. 轴:
  5. T1 节点−即时热汇 ΔT (局部摩擦判据, 每测点先声明热汇通道):
  6. 发电机绕组(U/V/W max) − 变流器冷却水 ConvWTmp
  7. 齿轮箱 HS/IMS 轴承 − 齿轮箱油 GeOilTmp (超 fleet 中位 ≥4℃ = 该轴承真摩擦候选)
  8. 齿轮箱油 GeOilTmp − 冷却水 ConvWTmp (大 = 油冷器/油路, 非轴承)
  9. 配对 DE−NDE 张开符号 + 绕组三相极差
  10. T2 冷却执行器 duty (counts+timeon 三件套, 动作次数必配时间占比):
  11. 同油温档 冷却器高/低速 duty ×fleet (同档 duty 高 = 换热能力低/换热器脏)
  12. 油冷器旁通阀 OilCoByp duty (常开 = 不走油冷器)
  13. 齿轮箱加热 GearHeat duty × 环温 (反-环温缺失 = 加热器卡ON 家族)
  14. 油泵 GearPuHS/LS duty
  15. T3 判别: 温控调节卡死家族 (加热器卡ON=反-环温 / 冷却卡全流=过冷 / 冷却不投入=贴环温)
  16. 守卫: 热汇通道先过 liveness (死/冻结/卡值) → 死则该 ΔT 判 INSUFFICIENT 不排名 (§4.6c①a);
  17. ΔT 判据用 fleet 中位相对门, 不用固定绝对阈 (防设计签名假阳)."""
  18. import numpy as np, pandas as pd, pathlib
  19. from src.windscada.config import farm
  20. from app_ETL.app_ETL_guanlan.api import load_10min
  21. PAIRS = [('发电机绕组−冷却水', ['tmp_wtc_Gen1U1Tm_mean', 'tmp_wtc_Gen1V1Tm_mean', 'tmp_wtc_Gen1W1Tm_mean'], 'tmp_wtc_ConvWTmp_mean'),
  22. ('齿轮箱高速轴−油', ['tmp_wtc_HSGenTmp_mean', 'tmp_wtc_HSRotTmp_mean'], 'tmp_wtc_GeOilTmp_mean'),
  23. ('齿轮箱中速轴−油', ['tmp_wtc_IMSGenTm_mean', 'tmp_wtc_IMSRotTm_mean'], 'tmp_wtc_GeOilTmp_mean'),
  24. ('齿箱油−冷却水', ['tmp_wtc_GeOilTmp_mean'], 'tmp_wtc_ConvWTmp_mean')]
  25. ACT = {'冷却器高速': 'dot_wtc_GearCoHS', '冷却器低速': 'dot_wtc_GearCoLS', '油冷器旁通': 'dot_wtc_OilCoByp',
  26. '齿箱加热': 'dot_wtc_GearHeat', '油泵高速': 'dot_wtc_GearPuHS', '油泵低速': 'dot_wtc_GearPuLS'}
  27. PBIN = 500 # 载荷匹配档 kW
  28. OBIN = 5 # 油温档 ℃
  29. def _live(sr):
  30. s = sr.dropna()
  31. if len(s) < 200: return False
  32. if s.nunique() <= 2: return False
  33. if float(s.value_counts(normalize=True).iloc[0]) > 0.5: return False # 卡值门 (§4.6c①a stuck_frac)
  34. return True
  35. def build_store(cfg=None, days=180):
  36. cfg = cfg or farm()
  37. rows = []
  38. for t in cfg['turbines']:
  39. d = load_10min(t, cfg, groups=['A.功率', 'B.温度NBM', 'B.冷却执行器'])
  40. d = d[d.ts >= str(d.ts.max() - pd.Timedelta(days=days))[:10]]
  41. op = d[d['grd_wtc_ActPower_mean'] > 500].copy()
  42. if not len(op): continue
  43. op['pb'] = (op['grd_wtc_ActPower_mean'] // PBIN * PBIN)
  44. op['ob'] = (op['tmp_wtc_GeOilTmp_mean'] // OBIN * OBIN)
  45. rec = dict(turbine=t, n=len(op))
  46. # T1 ΔT (热汇 liveness 守卫)
  47. for name, nodes, sink in PAIRS:
  48. if sink not in op.columns or not _live(op[sink]):
  49. rec[name] = np.nan; rec[name + '_守卫'] = '热汇通道不可信'; continue
  50. nod = [c for c in nodes if c in op.columns and _live(op[c])]
  51. if not nod:
  52. rec[name] = np.nan; rec[name + '_守卫'] = '节点通道不可信'; continue
  53. hot = op[nod].max(axis=1)
  54. dt = (hot - op[sink])
  55. per_pb = dt.groupby(op['pb']).median()
  56. n_pb = dt.groupby(op['pb']).size()
  57. keep = n_pb[n_pb >= 30].index
  58. rec[name] = float(per_pb.loc[keep].median()) if len(keep) else np.nan
  59. rec[name + '_守卫'] = ''
  60. rec['DE_NDE'] = float((op['tmp_wtc_GenBeGTm_mean'] - op['tmp_wtc_GenBeRTm_mean']).median())
  61. ph = op[['tmp_wtc_Gen1U1Tm_mean', 'tmp_wtc_Gen1V1Tm_mean', 'tmp_wtc_Gen1W1Tm_mean']]
  62. rec['三相极差'] = float((ph.max(axis=1) - ph.min(axis=1)).median())
  63. # T2 执行器 duty (秒/行 → 占比) 同油温档
  64. for nm, base in ACT.items():
  65. c_on, c_ct = base + '_timeon', base + '_counts'
  66. if c_on not in op.columns: rec[nm] = np.nan; continue
  67. duty = op[c_on] / 600.0
  68. # ★稀发执行器必用 mean 聚合: 中位对 counts~1/日 的位必然塌成 0 (自逮)
  69. per_ob = duty.groupby(op['ob']).mean()
  70. n_ob = duty.groupby(op['ob']).size()
  71. keep = n_ob[n_ob >= 30].index
  72. rec[nm] = float(per_ob.loc[keep].mean()) if len(keep) else np.nan
  73. # 高油温档 duty (换热需求最大处) — 冷却能力的真检验面
  74. hot_ob = [b for b in keep if b >= 45]
  75. rec[nm + '_高油温档'] = float(per_ob.loc[hot_ob].mean()) if hot_ob else np.nan
  76. rec[nm + '_次日'] = float(op[c_ct].sum() / max(len(op) / 144, 1)) if c_ct in op.columns else np.nan
  77. # T3 反-环温 (加热器): duty 与环温的相关; 正常加热器应负相关
  78. if 'dot_wtc_GearHeat_timeon' in op.columns and _live(op['tmp_wtc_AmbieTmp_mean']):
  79. hd = op['dot_wtc_GearHeat_timeon'] / 600.0
  80. rec['加热反环温corr'] = float(hd.corr(op['tmp_wtc_AmbieTmp_mean'])) if hd.std() > 0 else np.nan
  81. rec['油温中位'] = float(op['tmp_wtc_GeOilTmp_mean'].median())
  82. rec['冷却水中位'] = float(op['tmp_wtc_ConvWTmp_mean'].median()) if 'tmp_wtc_ConvWTmp_mean' in op.columns else np.nan
  83. rec['环温中位'] = float(op['tmp_wtc_AmbieTmp_mean'].median())
  84. rows.append(rec); print(t, flush=True)
  85. df = pd.DataFrame(rows)
  86. df.to_parquet(pathlib.Path(cfg['store']) / 'thermal_chain.parquet')
  87. print('→ thermal_chain.parquet', df.shape)
  88. def typology(cfg=None, cols=None):
  89. """劣化分型 (纪律: 劣化判据必须落时序现窗非全窗).
  90. 持续上行 = 末6月均 − 前6月均 ≥ 1.0K (病情在演进) / 稳定高位 = 末6月高但Δ小 (长期固有, 变化型判据看不见)
  91. / 已回落 = 前高末低 (历史尖峰, 勿当现况)。"""
  92. cfg = cfg or farm()
  93. f = pathlib.Path(cfg['store']) / 'thermal_monthly.parquet'
  94. if not f.exists(): return {}
  95. d = pd.read_parquet(f)
  96. cols = cols or [n for n, _, _ in PAIRS]
  97. out = {}
  98. for col in cols:
  99. if col not in d.columns: continue
  100. piv = d.pivot_table(index='month', columns='turbine', values=col)
  101. dev = piv.sub(piv.median(axis=1), axis=0)
  102. for t in dev.columns:
  103. s_ = dev[t].dropna()
  104. if len(s_) < 12: continue
  105. d0, d1 = float(s_.head(6).mean()), float(s_.tail(6).mean())
  106. out.setdefault(t, {})[col] = dict(前6=round(d0, 2), 末6=round(d1, 2), Δ=round(d1 - d0, 2),
  107. 末月=round(float(s_.iloc[-1]), 2),
  108. 型=('持续上行' if d1 - d0 >= 1.0 else
  109. '已回落' if d0 - d1 >= 1.0 else '稳定'))
  110. return out
  111. def registry(cfg=None):
  112. cfg = cfg or farm()
  113. df = pd.read_parquet(pathlib.Path(cfg['store']) / 'thermal_chain.parquet')
  114. out, detail = [], {}
  115. TY = typology(cfg)
  116. med = df.median(numeric_only=True)
  117. for _, r in df.iterrows():
  118. t = r['turbine']; axes = []
  119. for name, _, _ in PAIRS:
  120. g = r.get(name + '_守卫', '')
  121. if g: axes.append(f'{name}: {g} → INSUFFICIENT'); continue
  122. v = r.get(name)
  123. if v != v: continue
  124. dev = v - med[name]
  125. if '−油' in name and dev >= 4:
  126. ty = TY.get(t, {}).get(name, {})
  127. tag = ''
  128. if ty:
  129. tag = (f" [时序: 前6月{ty['前6']:+.1f}→末6月{ty['末6']:+.1f} (Δ{ty['Δ']:+.1f}) = {ty['型']}]"
  130. + (' ★病情在演进, 优先' if ty['型'] == '持续上行' else
  131. ' (长期固有非新发展)' if ty['型'] == '稳定' else ' (历史峰已回落, 勿当现况)'))
  132. axes.append(f'{name} {v:.1f}K (超全场中位 {dev:+.1f}K ≥4K) → 该轴承摩擦候选{tag}')
  133. elif '油−冷却水' in name and dev >= 4:
  134. axes.append(f'{name} {v:.1f}K ({dev:+.1f}K) → 油冷器/油路候选 (非轴承)')
  135. elif '绕组−冷却水' in name and dev >= 6:
  136. axes.append(f'{name} {v:.1f}K ({dev:+.1f}K) → 绕组散热候选')
  137. for nm in ACT:
  138. v = r.get(nm)
  139. if v != v or med.get(nm, np.nan) != med.get(nm, np.nan): continue
  140. m = med[nm]
  141. if nm == '油冷器旁通' and v >= 0.5 and v >= m + 0.2:
  142. axes.append(f'{nm} duty {v:.2f} (全场{m:.2f}) → 旁通常开=不走油冷器')
  143. elif nm.startswith('冷却器') and m > 0.02 and v >= max(1.5 * m, m + 0.1):
  144. axes.append(f'{nm} 同油温档 duty {v:.2f} ({v/m:.2f}×全场) → 换热能力低候选(换热器/风扇)')
  145. elif nm == '齿箱加热' and v >= max(1.5 * m, m + 0.1) and m > 0.01:
  146. axes.append(f'{nm} duty {v:.2f} ({v/m:.2f}×全场)')
  147. ph = r.get('三相极差')
  148. if ph == ph and ph >= max(3.0, 2.5 * med['三相极差']):
  149. axes.append(f'绕组三相极差 {ph:.1f}K ({ph/med["三相极差"]:.1f}×全场) → 仪表/接线候选 (SOP §4.6b: 破平衡=测量非部件)')
  150. c = r.get('加热反环温corr')
  151. if c == c and c > -0.05 and r.get('齿箱加热', 0) > 0.05:
  152. axes.append(f'加热器 duty 与环温相关 {c:+.2f} (正常应显著负) → 加热器卡ON候选')
  153. for nm in ACT:
  154. if nm in med and abs(float(df[nm].std())) < 1e-6:
  155. pass # 全场恒定 → 无分辨力, 在 note 里统一声明, 不逐台刷屏
  156. real = [a for a in axes if 'INSUFFICIENT' not in a]
  157. rising = any('持续上行' in a for a in axes)
  158. verdict = '热链候选(定向查)' if (len(real) >= 2 or rising) else ('记基线观察' if real else '—')
  159. out.append(dict(turbine=t, 判=verdict, 依据='; '.join(axes) if axes else
  160. f"绕组−水 {r.get('发电机绕组−冷却水', float('nan')):.1f}K, HS−油 {r.get('齿轮箱高速轴−油', float('nan')):.1f}K, "
  161. f"油−水 {r.get('齿箱油−冷却水', float('nan')):.1f}K 均在全场带内",
  162. 油温=round(float(r['油温中位']), 1), 冷却水=round(float(r['冷却水中位']), 1) if r['冷却水中位'] == r['冷却水中位'] else None))
  163. detail[t] = axes
  164. return pd.DataFrame(out), detail