trend.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. # -*- coding: utf-8 -*-
  2. """A翼 月度滚动趋势 + 欠发归因 router (windscada M10; SOP 1.4 + 1.3c).
  3. 纪律:
  4. - 2026-01 = 限电制度断点 → 禁跨断点单斜率 (trend-across-gap: 断点期 level shift 更可能是工况非退化);
  5. 分段: 2025段斜率 / 2026段斜率 / 断点Δ(2026均值−2025H2均值) 三件分开报;
  6. - dev = 同月同风档配对 (只正常发电态行, 已剥限电);
  7. - 渐进退化判据: 段内斜率持续同号 + 端段对比 (斜率只作形态描述, SOP slope-dilution 纪律);
  8. - 归因 router (1.3c): 候选台逐轴排除 → 桨角(M7已排)/转速跟踪(λ代理)/刹车拖曳(温度轴)/形态(阶跃vs渐进vs恒低), 余判 INSUFFICIENT."""
  9. import numpy as np, pandas as pd, pathlib
  10. from src.windscada.config import farm
  11. BREAK = '2026-01'
  12. # 断点依据 (数据内证, 2026-08-26 外审后补): 逐月限电时长占比 2025=间歇(3.2%~66.6%波动, 夏季高)
  13. # vs 2026=常态化(逐月 55%~73% 持续) → 2026 段内部环境同质, 分段有效; 表述用"限电常态化"非"制度切换"(制度归因需外部文件)
  14. def monthly_dev(cfg=None):
  15. cfg = cfg or farm()
  16. df = pd.read_parquet(pathlib.Path(cfg['store']) / 'pc_monthly_bins.parquet')
  17. base = df.groupby(['month', 'wb']).apply(lambda g: np.average(g.p, weights=g.n), include_groups=False).rename('fleet_p')
  18. df = df.join(base, on=['month', 'wb'])
  19. def dev(g):
  20. if g.n.sum() < 300: return np.nan # 月样本门: <50h 正常发电行 → 不出数 (重限电月饥饿防噪)
  21. w = g.fleet_p * g.n
  22. return float(((g.p - g.fleet_p) * g.n).sum() / max(w.sum(), 1e-9))
  23. out = df.groupby(['turbine', 'month']).apply(dev, include_groups=False).rename('dev').reset_index()
  24. rpm = df.groupby(['turbine', 'month'])[['rpm_ws', 'ws_mid']].first().reset_index()
  25. return out.merge(rpm, on=['turbine', 'month'], how='left')
  26. def _ts_slope(y):
  27. """Theil-Sen (%/月); 仅形态描述."""
  28. y = [v for v in y if v == v]
  29. n = len(y)
  30. if n < 4: return np.nan
  31. sl = [(y[j] - y[i]) / (j - i) for i in range(n) for j in range(i + 1, n)]
  32. return float(np.median(sl)) * 100
  33. def registry(cfg=None):
  34. cfg = cfg or farm()
  35. md = monthly_dev(cfg)
  36. piv = md.pivot_table(index='month', columns='turbine', values='dev')
  37. m25 = [m for m in piv.index if m < BREAK]
  38. m25h2 = [m for m in piv.index if '2025-07' <= m < BREAK]
  39. m26 = [m for m in piv.index if m >= BREAK]
  40. rows = []
  41. for t in piv.columns:
  42. s = piv[t]
  43. sl25, sl26 = _ts_slope(s.loc[m25]), _ts_slope(s.loc[m26])
  44. d_break = float(s.loc[m26].mean() - s.loc[m25h2].mean()) * 100 if len(m26) and len(m25h2) else np.nan
  45. seg26 = s.loc[m26].dropna()
  46. persist = bool(len(seg26) >= 4 and (seg26.diff().dropna() < 0).mean() >= 0.7)
  47. verdict = '平稳'
  48. if sl26 == sl26 and sl26 <= -0.8:
  49. verdict = '2026段下行(形态描述级)'
  50. if d_break == d_break and d_break < -3:
  51. verdict = '断点下移(先判工况: 2026起限电常态化, 逐月占比55-73% vs 2025间歇)'
  52. if sl26 == sl26 and sl26 < -0.8 and persist:
  53. verdict = '渐进退化候选(筛查级, 2026段内持续下行)'
  54. rows.append(dict(turbine=t, slope25=round(sl25, 2) if sl25 == sl25 else None,
  55. slope26=round(sl26, 2) if sl26 == sl26 else None,
  56. break_delta=round(d_break, 2) if d_break == d_break else None,
  57. dev_last=round(float(s.dropna().iloc[-1]) * 100, 2) if len(s.dropna()) else None,
  58. 判=verdict))
  59. out = pd.DataFrame(rows)
  60. return out.sort_values('dev_last').reset_index(drop=True)
  61. def attribution_router(cfg=None):
  62. """欠发候选逐轴排除 (SOP 1.3c; 机制候选级封顶)."""
  63. cfg = cfg or farm()
  64. st = pathlib.Path(cfg['store'])
  65. pcd = pd.read_parquet(st / 'powercurve_dev.parquet').set_index('turbine')
  66. att = pd.read_parquet(st / 'underperf_pitchmid.parquet').set_index('turbine')
  67. md = monthly_dev(cfg)
  68. rpiv = md.pivot_table(index='month', columns='turbine', values='rpm_ws')
  69. rdev = (rpiv - rpiv.median(axis=1).values[:, None]).mean()
  70. from ..subsys import temp_nbm
  71. treg = temp_nbm.registry()
  72. tdf = treg[0] if isinstance(treg, tuple) else treg
  73. out = []
  74. for t, r in pcd.iterrows():
  75. if r['判别'] == '—': continue
  76. axes = {}
  77. axes['风速计'] = f"ws偏置 {r.ws_bias:+.2f} m/s" + (' → A类先校风' if 'A类' in r['判别'] else ' (小, 非主因)')
  78. pd_dev = float(att.loc[t, 'pitch_dev']) if t in att.index else np.nan
  79. axes['桨角'] = f"1-2MW档 {pd_dev:+.2f}° vs 全场 → {'挂桨候选' if pd_dev == pd_dev and pd_dev > 0.5 else '排除(全场σ=0.2°)'}"
  80. rv = float(rdev.get(t, np.nan))
  81. axes['转速跟踪'] = f"中载转速偏 {rv:+.1f} rpm vs 全场 → {'跟踪偏低候选' if rv == rv and rv < -4 else '排除'}"
  82. # 第五轴 扇区形态 (R3, DS claim5 复现指令落库): 30°扇区×1m/s档 vs fleet同格,
  83. # ★绝对量因本台机舱风自指配档而放大(风速计偏置台尤甚), 只看**形态**不引绝对量
  84. sec_note = ''
  85. try:
  86. sp = pd.read_parquet(st / 'sector_power.parquet')
  87. me = sp[sp.turbine == t]
  88. rest = sp[sp.turbine != t].groupby(['sec', 'wsb']).p.median().rename('fleet')
  89. j = me.join(rest, on=['sec', 'wsb']).dropna(subset=['fleet'])
  90. if len(j) >= 60:
  91. j = j.assign(sdev=(j.p - j.fleet) / j.fleet)
  92. ps = j.groupby('sec').apply(lambda g: np.average(g.sdev, weights=g.n), include_groups=False)
  93. spread = float(ps.max() - ps.min()); best = float(ps.max())
  94. if spread >= 0.10 and best > -0.03:
  95. axes['扇区形态'] = (f"强方向依赖(极差{spread:.0%}, 最好扇区{best:+.0%}≥0) → 尾流/布局效应候选(需机位图核), "
  96. f"机组自身欠发证据弱化")
  97. elif spread < 0.08:
  98. axes['扇区形态'] = f"全向均匀(极差{spread:.0%}) → 与仪器/机组自身因一致, 非尾流形态"
  99. else:
  100. axes['扇区形态'] = f"全向基底+个别扇区加深(极差{spread:.0%}, 最差{float(ps.min()):+.0%}@{int(ps.idxmin())}°) → 扇区分量存在, 布局核对可判"
  101. except Exception:
  102. pass
  103. brk = tdf[(tdf.turbine == t) & tdf.channel.str.contains('BrkTmp')]
  104. hot = brk[brk['dev_K'] >= 4]
  105. axes['刹车拖曳'] = ('候选: ' + '; '.join(f"{x.channel.split('_')[2]} {x.dev_K:+.1f}K" for _, x in hot.iterrows())) if len(hot) else '排除(刹车温正常)'
  106. open_axes = [k for k, v in axes.items() if '候选' in v and '排除' not in v and k != '扇区形态']
  107. wake = '尾流/布局' in axes.get('扇区形态', '')
  108. # 2026-08-26 外审(Gemini)逮逻辑错并采纳: "A类→不计欠发"混淆了数据事实与根因诊断 —
  109. # 欠发 -14.7% 是 SCADA 面客观事实, "风速计偏高"是它的根因; 损失仍计入, 归因传感器, 修复路径=校风
  110. # v3 (双模审两票收敛): dev = SCADA面自指口径的 fleet 相对偏差 — 是测量面事实;
  111. # 但机舱风自指 ⇒ **真实(来流面)性能欠发与否 INSUFFICIENT**, 不下绝对欠发结论 (Gemini v2 错误3 采纳)
  112. conclusion = ('SCADA面相对偏差成立(自指口径); 真实性能欠发与否 INSUFFICIENT(需独立风源) → 先校风复测, 校后重判'
  113. if 'A类' in r['判别'] else \
  114. ('扇区形态=强方向依赖 → 主体或为尾流/布局效应(需机位图定谳), 机组自身欠发证据不足' if wake else
  115. (f"机制候选: {'/'.join(open_axes)}" if open_axes else
  116. '五轴皆无机组自身指向 → 余因(气动/自由来流) INSUFFICIENT, 需 mast/激光雷达')))
  117. out.append(dict(turbine=t, dev=f"{r.dev_w:+.1%}", 判别=r['判别'], 轴=axes, 结论=conclusion))
  118. return out