structure.py 6.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. # -*- coding: utf-8 -*-
  2. """B翼 结构件: 塔架与基础 + 叶片与叶根连接 (windscada M16).
  3. ★补齐交付 9 分册中先前缺的两册 (系统不全的实质: windscada 只做了 7 系统, 交付是 9 册).
  4. 塔架与基础 四维:
  5. 塔筒频率 — TowerFrq_Frequenc_mean 月度中位 Theil-Sen 年变化; 告警限 10mHz (分册口径);
  6. 判据 = 年降 ≥ 本台噪声 且 触限年数 < 10 → 报警; < 20 → 良好
  7. 塔筒振动 — 塔振模块CAN通讯故障 ≥5 次 → **监测不可用(不给等级)**; 强烈振动停机/振动检测计数 → 良好
  8. 塔筒湿度 — 湿度高告警 ≥2 或 >80%占时 ≥1% → 良好
  9. 基础与法兰 — **无监测** (SCADA 无该轴, 如实)
  10. 叶片与叶根 四维:
  11. 叶片A/B/C — 工单点名该叶的断裂/维修记录 (窗前累计) + 窗内 3418 轮毂松动
  12. 叶根螺栓 — 窗内 3418 (新发嫌疑) + 工单累计断裂条数
  13. 边界: 工单覆盖 2020-2024 (2025-26 现场未提供) ⇒ "窗前累计"可信, "窗内新发"只能靠报警码;
  14. 塔筒频率与分册口径差异 (分册 13# -2.20mHz/yr vs 本件 -1.48, 同台同号同排名第1, 窗口/聚合不同)."""
  15. import numpy as np, pandas as pd, pathlib
  16. from src.windscada.config import farm
  17. TOWER_DIMS = ['塔筒频率', '塔筒振动', '塔筒湿度', '基础与法兰']
  18. BLADE_DIMS = ['叶片A', '叶片B', '叶片C', '叶根螺栓']
  19. FREQ_LIMIT_MHZ = 10.0
  20. def _slope_mHz_yr(series_str):
  21. if not series_str: return np.nan, 0
  22. kv = [x.split(':') for x in series_str.split(';')]
  23. s = pd.Series({k: float(v) for k, v in kv}).sort_index()
  24. if len(s) < 12: return np.nan, len(s)
  25. sl = np.median([(s.iloc[j] - s.iloc[i]) / (j - i) for i in range(len(s)) for j in range(i + 1, len(s))])
  26. return float(sl * 12 * 1000), len(s)
  27. def _blade_orders(cfg):
  28. f = pathlib.Path(cfg['store']) / 'workorders.parquet'
  29. if not f.exists(): return {}
  30. w = pd.read_parquet(f)
  31. tx = (w['故障名称'].astype(str) + ' ' + w['故障描述'].astype(str) + ' ' + w['排查项目'].astype(str)
  32. + ' ' + w['维修对象'].astype(str) + ' ' + w['元器件名称'].astype(str))
  33. w = w.assign(tx=tx)
  34. bolt = w[w.tx.str.contains('叶根螺栓|叶片螺栓|螺栓断裂|断栓', na=False)]
  35. out = {}
  36. for t, g in bolt.groupby('turbine'):
  37. rec = dict(累计=int(len(g)), 末次=str(g.t_report.max())[:10])
  38. for b in 'ABC':
  39. rec[b] = int(g.tx.str.contains(f'{b}叶片|叶片{b}|{b}叶', na=False).sum())
  40. out[t] = rec
  41. return out
  42. def registry(cfg=None):
  43. cfg = cfg or farm()
  44. df = pd.read_parquet(pathlib.Path(cfg['store']) / 'structure.parquet').set_index('turbine')
  45. bo = _blade_orders(cfg)
  46. rows, detail = [], {}
  47. for t, r in df.iterrows():
  48. d = {}
  49. # ---- 塔架 ----
  50. sl, nm = _slope_mHz_yr(r.freq_series)
  51. noise = float(r.freq_noise_mHz)
  52. yrs = FREQ_LIMIT_MHZ / abs(sl) if sl == sl and sl < 0 else np.inf
  53. real = sl == sl and abs(sl) >= 0.8 * noise
  54. st_f = '不可判' if sl != sl else ('报警' if (real and yrs < 10) else '良好' if (real and yrs < 25) else '优秀')
  55. why_f = (f"年变化 {sl:+.2f} mHz/yr ({sl/(r.freq_med*1000)*100:+.3f}%/yr), 本台噪声 {noise:.2f} mHz "
  56. f"({abs(sl)/max(noise,1e-9):.2f}×), 按此速率触 {FREQ_LIMIT_MHZ:.0f}mHz 限需 {yrs:.1f} 年, 月数 {nm}"
  57. if sl == sl else '月数不足')
  58. can = int(r['塔振模块CAN'])
  59. vib_n = int(r['强烈振动停机']) + int(r['叶片振动检测'])
  60. st_v = '监测不可用' if can >= 5 else ('良好' if vib_n >= 3 else '优秀')
  61. why_v = f"塔振模块CAN通讯故障 {can} 次, 强烈振动停机 {int(r['强烈振动停机'])} 次, 叶片振动检测 {int(r['叶片振动检测'])} 次" + \
  62. (' → 该台塔振无有效监测, 不给等级' if can >= 5 else '')
  63. hum_n = int(r['塔筒湿度高'])
  64. st_h = '良好' if (hum_n >= 2 or (r.twr_hum_gt80 == r.twr_hum_gt80 and r.twr_hum_gt80 >= 0.01)) else '优秀'
  65. why_h = f"湿度高告警 {hum_n} 次, 中位 {r.twr_hum_med:.1f}%, p95 {r.twr_hum_p95:.1f}%, >80%占时 {r.twr_hum_gt80:.2%}"
  66. tower = {'塔筒频率': st_f, '塔筒振动': st_v, '塔筒湿度': st_h, '基础与法兰': '无监测'}
  67. # ---- 叶片 ----
  68. b = bo.get(t, {})
  69. hub = int(r['轮毂松动'])
  70. cum = b.get('累计', 0)
  71. # 2026-08-26 双模审(Gemini claim6)采纳: 窗前工单累计(≤2024)不得抬 2025-26 现窗等级 (claim窗≠evidence窗);
  72. # 等级只由窗内报警(3418 轮毂松动)驱动, 工单累计降为史注记
  73. st_bolt = '报警' if hub >= 2 else ('良好' if hub >= 1 else '优秀')
  74. why_bolt = (f"窗内轮毂松动报警 {hub} 次 (松动嫌疑·代理轴, 非螺栓断裂确诊); 工单累计断裂/换栓 {cum} 条"
  75. + (f", 末次 {b['末次']}" if cum else '')
  76. + " [口径: 本件为代理轴, 与分册'窗内确诊断裂'不同源 — 工单止于2024, 2025-26现场未提供]")
  77. blade = {}
  78. for x in 'ABC':
  79. n = b.get(x, 0)
  80. blade[f'叶片{x}'] = '良好' if n >= 1 else '优秀' # 窗前史最多良好(需复查), 不给现窗报警级
  81. blade['叶根螺栓'] = st_bolt
  82. why_blade = {f'叶片{x}': f"工单点名该叶断裂/换栓 {b.get(x,0)} 条 (2020-2024窗; 分册另含2025-26窗内记录, 两者非同源)" for x in 'ABC'}
  83. why_blade['叶根螺栓'] = why_bolt
  84. order = {'报警': 0, '良好': 1, '监测不可用': 2, '无监测': 3, '不可判': 3, '优秀': 4}
  85. tj = [v for v in tower.values() if v in ('报警', '良好', '优秀')]
  86. bj = [v for v in blade.values() if v in ('报警', '良好', '优秀')]
  87. rows.append(dict(机组=t,
  88. 塔架系统级=sorted(tj, key=lambda x: order[x])[0] if tj else '不可判',
  89. 叶片系统级=sorted(bj, key=lambda x: order[x])[0] if bj else '不可判',
  90. **tower, **blade))
  91. detail[t] = dict(塔架={'塔筒频率': why_f, '塔筒振动': why_v, '塔筒湿度': why_h, '基础与法兰': 'SCADA 无该轴 (需水下检测/法兰螺栓力矩记录)'},
  92. 叶片=why_blade)
  93. return pd.DataFrame(rows).sort_values('机组').reset_index(drop=True), detail