pitch.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. # -*- coding: utf-8 -*-
  2. """变桨系统四维度判级 (v1, 2026-08-24). 判据规范出处: 变桨分册 V2.0 (四维度/§3.2归并) + 零位专项 20260821 口径.
  3. 全部确定性; 模型无判级通道. 阈值: 分册场标定优先, 无者 fleet 相对 [暂行标注]. 数据止 2026-06-30 (边界).
  4. ★2026-09-19 (用户令「所有的计算均要形成观澜的源代码」): 本面的输入 `pitch/pitch_daily.parquet` 与
  5. `pitch/pitch_zero_monthly.parquet` 过去是**随包快照**(无生成端) ⇒ 换台机器重算后必然缺件、本面恒"不可判"。
  6. 现在由 `scripts/pitch_face_build.py`(重算链第 ④c 步)从 `data/raw` 的 `scada_10min` + `scada_1min` 算出,
  7. 口径与证据写在同目录 `pitch_face_manifest.json`。零位轴按用户令**分列**: `zero_dev`(停机顺桨段, 机械止挡位)、
  8. `zero_dev_full`(满发段, 并列参考)、`zero_dev_run`(运行段同工况分档 = 零位偏差的行业口径, 实测能把 19# 拎出来);
  9. D3 判据按"停机段与运行段**取更不利**"(§3.2 同一纪律), 依据行三个数都写。
  10. ★★**用户令 2026-09-19 就"判据轴怎么定"的裁决 = 维持本口径**(三口径都存、依据行都写、判据取更不利的那一个):
  11. 既保留用户选定的"停机段中位"这一列, 又不会漏掉 19# 这类**运行区**零位偏差 —— 本机实测三个口径对 19# 分别
  12. 是 +0.11°(停机顺桨段, 机械止挡抹平差异) / +0.61°(满发段) / −0.46°(运行段同工况分档), 只有第三个把它拎出来。
  13. 改动口沿时**必须同步**: `scripts/pitch_face_build.py` 的口径注释与本文件这段说明。
  14. """
  15. from app_common.app_common_guanlan.api import paths as P
  16. import numpy as np, pandas as pd, pathlib, json
  17. from src.windscada.config import ROOT
  18. PITCH_DIR = P.pitch()
  19. DIMS = ['蓄能与压力调节', '变桨轴承与轮毂润滑', '执行与位置反馈', '油路与密封']
  20. # 场标定常数 (分册§5.1/5.2): 正常锯齿带宽≈52bar; 润滑泵全场中位171s/日, 310s+为高位; 零位专项: 精度门0.25°, 19#确诊-0.87°
  21. NORM_BAND, HUBLUB_FLEET_MED = 52.0, 171.0
  22. ZERO_GATE_STD, ZERO_ALARM, ZERO_WATCH = 0.25, 0.6, 0.3 # [暂行] 报警阈=3×2025离散度上界0.2°
  23. def _cur_win(d, days=60, span=None):
  24. """判级窗:默认 = 数据末端往前 `days` 天(原口径);`span=(a, b)` 给了就按**所选时间窗**(含两端)。
  25. ★2026-09-21 用户令「判级也按所选时间窗重算」:本面输入 `pitch_daily.parquet` 是**日粒度**序列
  26. (每台每日一行),所以按窗重算是**精确**的——只是换一个日期切片,判据不动。
  27. """
  28. if span:
  29. a, b = span
  30. lo, hi = pd.Timestamp(a).date(), pd.Timestamp(b).date()
  31. dd = pd.to_datetime(d['date']).dt.date
  32. return d[(dd >= lo) & (dd <= hi)]
  33. end = d['date'].max()
  34. return d[d['date'] >= (pd.Timestamp(end) - pd.Timedelta(days=days)).date()]
  35. def _rate(v) -> str:
  36. """越线率排版: 缺 1min 计数来源时是 NaN ⇒ 写「—」, 不写成 0.000 (那是"没有越线"的意思)。"""
  37. return f"{v:.3f}" if v == v else '—'
  38. def load():
  39. """读变桨面产物 —— **缺件时返回空表而不是抛异常** (2026-09-17, 见下)。
  40. 生成端: `scripts/pitch_face_build.py` (重算链 ④c, 输入 = `data/raw/<场站>/{scada_10min,scada_1min}`)。
  41. 为什么仍要容缺: ① 目标机上"放了原始件但还没重算"是常态; ② 现场没给 1min 导出件时, 零位两口径
  42. 与越线计数会按缺 —— 缺件就该显示缺件, 不能造数。原先这里直接 `pd.read_parquet` ⇒ `FileNotFoundError`
  43. 一路上抛到本体层 `taxonomy.system_matrix()`, 把"整条重算链"卡在第 ⑦ 步
  44. (2026-09-17 实测: 清产物后重算, 2267 s 后 rc=1 断在这里)。
  45. 现在缺件返回空表, 下游按"该面无数据"降级(页面显示缺件, 不静默造数)。
  46. """
  47. p = PITCH_DIR / 'pitch_daily.parquet'
  48. if p.is_file():
  49. daily = pd.read_parquet(p)
  50. else:
  51. daily = pd.DataFrame(columns=['date', 'turbine', 'n', 'hydlevel_timeon', 'hydfilt_timeon'])
  52. zp = PITCH_DIR / 'pitch_zero_monthly.parquet'
  53. zero = pd.read_parquet(zp) if zp.is_file() else pd.DataFrame()
  54. return daily, zero
  55. ALARM_FAM = { # M4b 报警轴 (windscada alarms.parquet; 分册监测量对齐)
  56. '执行反馈': r'跟踪|pawl|反馈|停止位|制转杆|电磁阀|安全阀',
  57. '蓄能压力': r'高压|蓄能器|压力开关',
  58. '润滑': r'变桨润滑|无润滑|润滑警告',
  59. '油路密封': r'液压油位|液压油温|变桨液压',
  60. }
  61. def _alarm_counts(win_start, win_end, store=None):
  62. import re as _re
  63. # ★store 显式传入 (2026-09-16 修): 原写 `P.store()` —— 它跟的是环境变量 WINDSCADA_FARM,
  64. # 而调用方 registry(cfg) 拿的是**显式选定的场**; 多场部署下这里会读到 rudong 的 alarms.parquet
  65. # 而其余输入都来自当前场 ⇒ 跨场串数据, 且因为"文件存在、列名兼容"而完全不报错。
  66. ap = (pathlib.Path(store) if store else P.store()) / 'alarms.parquet'
  67. if not ap.exists():
  68. return None
  69. al = pd.read_parquet(ap)
  70. al = al[(al.t_on >= str(win_start)) & (al.t_on <= str(win_end)) & al.text.str.contains('桨|液压|润滑|高压|蓄能', na=False, regex=True)]
  71. out = {}
  72. for fam, pat in ALARM_FAM.items():
  73. sub = al[al.text.str.contains(pat, na=False, regex=True)]
  74. out[fam] = sub.groupby('turbine').size()
  75. return out
  76. def registry(cfg=None, span=None):
  77. """变桨面登记表。`cfg` 给定时, 其 `store` 决定读哪个场的 alarms (见 _alarm_counts 的注释)。
  78. `span=(起, 止)` 给了就按**所选时间窗**(含两端)重算判级(用户令 2026-09-21);不给 = 原口径
  79. (数据末端往前 60 天)。日粒度件 ⇒ 按窗重算是精确切片,判据/阈值一字未动。
  80. """
  81. daily, zero = load()
  82. if not len(daily):
  83. # 缺"无生成端"的 pitch_daily (交付包不随产物 ⇒ 目标机上必然缺): 该面整体**不可判**,
  84. # 但**结构要在**(列齐、零行), 否则下游 `reg.set_index('机组')` 会 KeyError, 又把整条链卡住。
  85. # 页面据此显示"变桨面缺件: outputs/<场>/pitch/pitch_daily.parquet (docs §7)", 不静默造数。
  86. return pd.DataFrame(columns=['机组', '系统级', *DIMS]), {}
  87. daily['date'] = pd.to_datetime(daily['date']).dt.date
  88. cur = _cur_win(daily, span=span).copy()
  89. # 语义修正 (2026-08-24 数据实逮): HydLevel/HydrFilt 开关 1=正常 → 报警秒 = 当日应测秒(n×600) − timeon;
  90. # 润滑泵/柱塞用窗内日均 (日中位恒0); PitchPum 使能位恒开=无判据力, 废弃
  91. cur['lvl_alarm'] = (cur['n'] * 600 - cur['hydlevel_timeon']).clip(lower=0)
  92. cur['filt_alarm'] = (cur['n'] * 600 - cur['hydfilt_timeon']).clip(lower=0)
  93. # 双通道同时近整日置零 = 断链/停机伪影日 (24# 实逮 05-08), 剔出油路报警秒
  94. _cm = (cur['lvl_alarm'] > 600) & (cur['lvl_alarm'] == cur['filt_alarm']) # 两独立开关逐秒相等=共因伪影(断链/停机)
  95. cur.loc[_cm, ['lvl_alarm', 'filt_alarm']] = 0.0
  96. fleet = cur.groupby('turbine').agg(band=('hyd_band', 'median'), over=('hyd_over_relief', 'sum'), under=('hyd_under_pump', 'sum'),
  97. lub=('hublub_timeon', 'mean'), pump=('pitchpum_timeon', 'median'),
  98. lvl=('lvl_alarm', 'sum'), filt=('filt_alarm', 'sum'),
  99. pa=('pistA', 'mean'), pb=('pistB', 'mean'), pc=('pistC', 'mean'),
  100. nd=('n', 'count'), nop=('n_op', 'sum'))
  101. fleet['over_rate'] = fleet['over'] / (fleet['nop'] * 10).clip(lower=1) # 越线**分钟**/运行分钟 (工况归一)
  102. fleet['under_rate'] = fleet['under'] / (fleet['nop'] * 10).clip(lower=1)
  103. med = fleet.median()
  104. # 零位: 精度门逐月 (全场逐台偏差 std ≤0.25° 才可判)
  105. # ★2026-09-19: 输入件现由 scripts/pitch_face_build.py 自算, 零位有**三个口径**(见该脚本 docstring):
  106. # 停机顺桨段 `zero_dev`(机械止挡位, 量不出运行区零位差) / 满发段 `zero_dev_full`(混风况, 参考) /
  107. # 运行段分档 `zero_dev_run`(同工况读数差 = 行业口径的零位, 分档归一后能把 19# 这类台单独拎出来)。
  108. # 判据按"**取更不利**"(§3.2 同一纪律): 停机段与运行段谁偏得多用谁, 依据行三个数都写出来。
  109. zsum = {}
  110. if len(zero):
  111. mstd = zero.groupby('month')['zero_dev'].std()
  112. ok_months = mstd[mstd <= ZERO_GATE_STD].index
  113. zok = zero[zero['month'].isin(ok_months)]
  114. for t, g in zok.groupby('turbine'):
  115. g = g.sort_values('month')
  116. _full = float(g['zero_dev_full'].median()) if 'zero_dev_full' in g.columns else float('nan')
  117. _run = float(g['zero_dev_run'].median()) if 'zero_dev_run' in g.columns else float('nan')
  118. _n_alarm = int((g['zero_dev'].abs() >= ZERO_ALARM).sum())
  119. if _run == _run:
  120. _n_alarm = int(((g['zero_dev'].abs() >= ZERO_ALARM)
  121. | (g['zero_dev_run'].abs() >= ZERO_ALARM)).sum())
  122. zsum[t] = dict(months=int(len(g)), dev_med=float(g['zero_dev'].median()), last=float(g['zero_dev'].iloc[-1]),
  123. dev_full_med=_full, dev_run_med=_run, n_alarm=_n_alarm,
  124. last_month=str(g['month'].iloc[-1]))
  125. # 报警轴同窗(用户令 2026-09-21:按所选窗重算):span 给了就用 span 的两端,
  126. # 否则仍是"数据末端往前 60 天"(原口径)。
  127. if span:
  128. win_start, win_end = pd.Timestamp(span[0]), pd.Timestamp(span[1])
  129. else:
  130. win_end = daily['date'].max(); win_start = pd.Timestamp(win_end) - pd.Timedelta(days=60)
  131. ac = _alarm_counts(win_start, win_end, store=(cfg or {}).get('store') if cfg else None)
  132. rows, detail = [], {}
  133. for t, r in fleet.iterrows():
  134. d = dict(维度={}, 依据={})
  135. blind = (r['nd'] < 10) or (r['nop'] < 144) # 现窗运行不足1天等效 → 不可判
  136. # D1 蓄能与压力调节
  137. band_x = r['band'] / max(med['band'], 1e-9)
  138. st1, why1 = '优秀', f"带宽中位 {r['band']:.0f}bar ({band_x:.2f}×fleet, 正常参考≈{NORM_BAND:.0f}bar 1min口径)"
  139. if r['band'] >= 2.0 * med['band']:
  140. st1 = '报警'; why1 += (f"; 越卸荷 {_rate(r['over_rate'])}/跌启泵 {_rate(r['under_rate'])}"
  141. f"(运行分钟占比)作佐证 [暂行: 带宽≥2×单证报警; 蓄能失效佐证=锯齿失稳]")
  142. elif r['band'] >= 1.4 * med['band']:
  143. st1 = '良好'
  144. # D2 润滑
  145. lub_x = r['lub'] / max(med['lub'], 1e-9)
  146. st2, why2 = '优秀', f"轮毂润滑泵日均 {r['lub']:.0f}s ({lub_x:.2f}×fleet中位{med['lub']:.0f}s; 分册: 全场中位171s/高位310s)"
  147. pist = [r['pa'], r['pb'], r['pc']]
  148. if all(x == x for x in pist) and min(pist) > 0 and max(pist) / min(pist) > 1.5:
  149. why2 += f"; 三叶柱塞不对称 {max(pist)/min(pist):.2f}×"
  150. if r['lub'] >= 1.8 * med['lub']:
  151. st2 = '报警'
  152. elif r['lub'] >= 1.4 * med['lub'] or (all(x == x for x in pist) and min(pist) > 0 and max(pist) / min(pist) > 1.5):
  153. st2 = '良好'
  154. # D3 执行与位置反馈 (零位轴并入 + 带宽骤变签名)
  155. st3, why3 = '优秀', ''
  156. z = zsum.get(t)
  157. if z:
  158. _stop, _full, _run = z['dev_med'], z.get('dev_full_med'), z.get('dev_run_med')
  159. _mag = abs(_stop)
  160. _lead = '停机顺桨段'
  161. if _run == _run and abs(_run) > _mag:
  162. _mag, _lead = abs(_run), '运行段'
  163. why3 = f"零位偏差 停机顺桨段 {_stop:+.2f}°"
  164. if _full == _full:
  165. why3 += f" / 满发段 {_full:+.2f}°"
  166. if _run == _run:
  167. why3 += f" / 运行段(同工况) {_run:+.2f}°"
  168. why3 += f" (判据轴取更不利的 {_lead}, 可判月 {z['months']}, 精度门0.25°)"
  169. if z['n_alarm'] >= 3 and _mag >= ZERO_ALARM:
  170. st3 = '报警'; why3 += f" ≥{ZERO_ALARM}°持续{z['n_alarm']}月 → 偏差确诊·成因待诊 [专项口径]"
  171. elif _mag >= ZERO_WATCH:
  172. st3 = '良好'
  173. if _mag >= ZERO_ALARM and abs(z['last']) < ZERO_WATCH:
  174. st3 = '良好'; why3 += ' (末月已归零→已闭环观察)'
  175. else:
  176. why3 = '零位: 可判月不足 (精度门/样本)'
  177. prev = daily[(daily['turbine'] == t) & (~daily['date'].isin(cur['date']))]
  178. if len(prev) > 20:
  179. pb_ = prev['hyd_band'].median()
  180. if pb_ > 0 and (r['band'] / pb_ < 0.5):
  181. st3 = st3 if st3 == '报警' else '良好'
  182. why3 += f"; 带宽骤降 {pb_:.0f}→{r['band']:.0f}bar (整定改动签名)"
  183. # D4 油路与密封
  184. st4, why4 = '优秀', f"油位报警 {r['lvl']:.0f}s/窗, 滤网报警 {r['filt']:.0f}s/窗 (开关1=正常, 报警秒=应测−timeon; 变桨泵使能位恒开无判据力已弃)"
  185. if r['lvl'] > 3600 or r['filt'] > 3600:
  186. st4 = '报警' if max(r['lvl'], r['filt']) > 6 * 3600 else '良好'
  187. # M4b 报警轴佐证: 族内计数 ≥10 且 ≥3×fleet中位 → 该维度升报警 (分册监测量的报警面, 7#/38# 类)
  188. if ac is not None:
  189. for fam, st_ref in (('蓄能压力', 'st1'), ('润滑', 'st2'), ('执行反馈', 'st3'), ('油路密封', 'st4')):
  190. cnt = int(ac[fam].get(t, 0)); fam_med = float(ac[fam].median()) if len(ac[fam]) else 0.0
  191. if cnt >= 10 and cnt >= 3 * max(fam_med, 1):
  192. if fam == '蓄能压力': st1 = '报警'; why1 += f'; 报警轴 {cnt}条 (fleet中位{fam_med:.0f})'
  193. if fam == '润滑': st2 = '报警'; why2 += f'; 报警轴 {cnt}条 (fleet中位{fam_med:.0f})'
  194. if fam == '执行反馈': st3 = '报警'; why3 += f'; 报警轴 {cnt}条 (fleet中位{fam_med:.0f})'
  195. if fam == '油路密封': st4 = '报警'; why4 += f'; 报警轴 {cnt}条 (fleet中位{fam_med:.0f})'
  196. for dim, st, why in zip(DIMS, (st1, st2, st3, st4), (why1, why2, why3, why4)):
  197. d['维度'][dim] = '不可判' if blind else st
  198. d['依据'][dim] = why
  199. order = {'报警': 0, '良好': 1, '优秀': 2, '不可判': 3}
  200. judged = [v for v in d['维度'].values() if v != '不可判']
  201. sys_st = sorted(judged, key=lambda x: order[x])[0] if judged else '不可判' # §3.2: 取最不利; 不可判不传染
  202. rows.append(dict(机组=t, 系统级=sys_st, **d['维度']))
  203. detail[t] = d
  204. reg = pd.DataFrame(rows).sort_values('机组').reset_index(drop=True)
  205. return reg, detail
  206. def reconcile_delivered():
  207. ref = json.load(open(ROOT / 'reference/rudong/pitch_delivered_grades.json', encoding='utf-8'))
  208. reg, _ = registry()
  209. ix = reg.set_index('机组')
  210. m = {'蓄能压力': '蓄能与压力调节', '润滑': '变桨轴承与轮毂润滑', '执行反馈': '执行与位置反馈', '油路密封': '油路与密封'}
  211. rows = []
  212. for t, v in ref.items():
  213. if t not in ix.index: continue
  214. for k, dim in m.items():
  215. a, b = v[k], ix.loc[t, dim]
  216. rows.append(dict(台=t, 维度=k, 交付=a, 现算=b, 一致=(a == '报警') == (b == '报警')))
  217. df = pd.DataFrame(rows)
  218. return df