| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- # -*- coding: utf-8 -*-
- """变桨系统四维度判级 (v1, 2026-08-24). 判据规范出处: 变桨分册 V2.0 (四维度/§3.2归并) + 零位专项 20260821 口径.
- 全部确定性; 模型无判级通道. 阈值: 分册场标定优先, 无者 fleet 相对 [暂行标注]. 数据止 2026-06-30 (边界).
- ★2026-09-19 (用户令「所有的计算均要形成观澜的源代码」): 本面的输入 `pitch/pitch_daily.parquet` 与
- `pitch/pitch_zero_monthly.parquet` 过去是**随包快照**(无生成端) ⇒ 换台机器重算后必然缺件、本面恒"不可判"。
- 现在由 `scripts/pitch_face_build.py`(重算链第 ④c 步)从 `data/raw` 的 `scada_10min` + `scada_1min` 算出,
- 口径与证据写在同目录 `pitch_face_manifest.json`。零位轴按用户令**分列**: `zero_dev`(停机顺桨段, 机械止挡位)、
- `zero_dev_full`(满发段, 并列参考)、`zero_dev_run`(运行段同工况分档 = 零位偏差的行业口径, 实测能把 19# 拎出来);
- D3 判据按"停机段与运行段**取更不利**"(§3.2 同一纪律), 依据行三个数都写。
- ★★**用户令 2026-09-19 就"判据轴怎么定"的裁决 = 维持本口径**(三口径都存、依据行都写、判据取更不利的那一个):
- 既保留用户选定的"停机段中位"这一列, 又不会漏掉 19# 这类**运行区**零位偏差 —— 本机实测三个口径对 19# 分别
- 是 +0.11°(停机顺桨段, 机械止挡抹平差异) / +0.61°(满发段) / −0.46°(运行段同工况分档), 只有第三个把它拎出来。
- 改动口沿时**必须同步**: `scripts/pitch_face_build.py` 的口径注释与本文件这段说明。
- """
- from app_common.app_common_guanlan.api import paths as P
- import numpy as np, pandas as pd, pathlib, json
- from src.windscada.config import ROOT
- PITCH_DIR = P.pitch()
- DIMS = ['蓄能与压力调节', '变桨轴承与轮毂润滑', '执行与位置反馈', '油路与密封']
- # 场标定常数 (分册§5.1/5.2): 正常锯齿带宽≈52bar; 润滑泵全场中位171s/日, 310s+为高位; 零位专项: 精度门0.25°, 19#确诊-0.87°
- NORM_BAND, HUBLUB_FLEET_MED = 52.0, 171.0
- ZERO_GATE_STD, ZERO_ALARM, ZERO_WATCH = 0.25, 0.6, 0.3 # [暂行] 报警阈=3×2025离散度上界0.2°
- def _cur_win(d, days=60, span=None):
- """判级窗:默认 = 数据末端往前 `days` 天(原口径);`span=(a, b)` 给了就按**所选时间窗**(含两端)。
- ★2026-09-21 用户令「判级也按所选时间窗重算」:本面输入 `pitch_daily.parquet` 是**日粒度**序列
- (每台每日一行),所以按窗重算是**精确**的——只是换一个日期切片,判据不动。
- """
- if span:
- a, b = span
- lo, hi = pd.Timestamp(a).date(), pd.Timestamp(b).date()
- dd = pd.to_datetime(d['date']).dt.date
- return d[(dd >= lo) & (dd <= hi)]
- end = d['date'].max()
- return d[d['date'] >= (pd.Timestamp(end) - pd.Timedelta(days=days)).date()]
- def _rate(v) -> str:
- """越线率排版: 缺 1min 计数来源时是 NaN ⇒ 写「—」, 不写成 0.000 (那是"没有越线"的意思)。"""
- return f"{v:.3f}" if v == v else '—'
- def load():
- """读变桨面产物 —— **缺件时返回空表而不是抛异常** (2026-09-17, 见下)。
- 生成端: `scripts/pitch_face_build.py` (重算链 ④c, 输入 = `data/raw/<场站>/{scada_10min,scada_1min}`)。
- 为什么仍要容缺: ① 目标机上"放了原始件但还没重算"是常态; ② 现场没给 1min 导出件时, 零位两口径
- 与越线计数会按缺 —— 缺件就该显示缺件, 不能造数。原先这里直接 `pd.read_parquet` ⇒ `FileNotFoundError`
- 一路上抛到本体层 `taxonomy.system_matrix()`, 把"整条重算链"卡在第 ⑦ 步
- (2026-09-17 实测: 清产物后重算, 2267 s 后 rc=1 断在这里)。
- 现在缺件返回空表, 下游按"该面无数据"降级(页面显示缺件, 不静默造数)。
- """
- p = PITCH_DIR / 'pitch_daily.parquet'
- if p.is_file():
- daily = pd.read_parquet(p)
- else:
- daily = pd.DataFrame(columns=['date', 'turbine', 'n', 'hydlevel_timeon', 'hydfilt_timeon'])
- zp = PITCH_DIR / 'pitch_zero_monthly.parquet'
- zero = pd.read_parquet(zp) if zp.is_file() else pd.DataFrame()
- return daily, zero
- ALARM_FAM = { # M4b 报警轴 (windscada alarms.parquet; 分册监测量对齐)
- '执行反馈': r'跟踪|pawl|反馈|停止位|制转杆|电磁阀|安全阀',
- '蓄能压力': r'高压|蓄能器|压力开关',
- '润滑': r'变桨润滑|无润滑|润滑警告',
- '油路密封': r'液压油位|液压油温|变桨液压',
- }
- def _alarm_counts(win_start, win_end, store=None):
- import re as _re
- # ★store 显式传入 (2026-09-16 修): 原写 `P.store()` —— 它跟的是环境变量 WINDSCADA_FARM,
- # 而调用方 registry(cfg) 拿的是**显式选定的场**; 多场部署下这里会读到 rudong 的 alarms.parquet
- # 而其余输入都来自当前场 ⇒ 跨场串数据, 且因为"文件存在、列名兼容"而完全不报错。
- ap = (pathlib.Path(store) if store else P.store()) / 'alarms.parquet'
- if not ap.exists():
- return None
- al = pd.read_parquet(ap)
- al = al[(al.t_on >= str(win_start)) & (al.t_on <= str(win_end)) & al.text.str.contains('桨|液压|润滑|高压|蓄能', na=False, regex=True)]
- out = {}
- for fam, pat in ALARM_FAM.items():
- sub = al[al.text.str.contains(pat, na=False, regex=True)]
- out[fam] = sub.groupby('turbine').size()
- return out
- def registry(cfg=None, span=None):
- """变桨面登记表。`cfg` 给定时, 其 `store` 决定读哪个场的 alarms (见 _alarm_counts 的注释)。
- `span=(起, 止)` 给了就按**所选时间窗**(含两端)重算判级(用户令 2026-09-21);不给 = 原口径
- (数据末端往前 60 天)。日粒度件 ⇒ 按窗重算是精确切片,判据/阈值一字未动。
- """
- daily, zero = load()
- if not len(daily):
- # 缺"无生成端"的 pitch_daily (交付包不随产物 ⇒ 目标机上必然缺): 该面整体**不可判**,
- # 但**结构要在**(列齐、零行), 否则下游 `reg.set_index('机组')` 会 KeyError, 又把整条链卡住。
- # 页面据此显示"变桨面缺件: outputs/<场>/pitch/pitch_daily.parquet (docs §7)", 不静默造数。
- return pd.DataFrame(columns=['机组', '系统级', *DIMS]), {}
- daily['date'] = pd.to_datetime(daily['date']).dt.date
- cur = _cur_win(daily, span=span).copy()
- # 语义修正 (2026-08-24 数据实逮): HydLevel/HydrFilt 开关 1=正常 → 报警秒 = 当日应测秒(n×600) − timeon;
- # 润滑泵/柱塞用窗内日均 (日中位恒0); PitchPum 使能位恒开=无判据力, 废弃
- cur['lvl_alarm'] = (cur['n'] * 600 - cur['hydlevel_timeon']).clip(lower=0)
- cur['filt_alarm'] = (cur['n'] * 600 - cur['hydfilt_timeon']).clip(lower=0)
- # 双通道同时近整日置零 = 断链/停机伪影日 (24# 实逮 05-08), 剔出油路报警秒
- _cm = (cur['lvl_alarm'] > 600) & (cur['lvl_alarm'] == cur['filt_alarm']) # 两独立开关逐秒相等=共因伪影(断链/停机)
- cur.loc[_cm, ['lvl_alarm', 'filt_alarm']] = 0.0
- fleet = cur.groupby('turbine').agg(band=('hyd_band', 'median'), over=('hyd_over_relief', 'sum'), under=('hyd_under_pump', 'sum'),
- lub=('hublub_timeon', 'mean'), pump=('pitchpum_timeon', 'median'),
- lvl=('lvl_alarm', 'sum'), filt=('filt_alarm', 'sum'),
- pa=('pistA', 'mean'), pb=('pistB', 'mean'), pc=('pistC', 'mean'),
- nd=('n', 'count'), nop=('n_op', 'sum'))
- fleet['over_rate'] = fleet['over'] / (fleet['nop'] * 10).clip(lower=1) # 越线**分钟**/运行分钟 (工况归一)
- fleet['under_rate'] = fleet['under'] / (fleet['nop'] * 10).clip(lower=1)
- med = fleet.median()
- # 零位: 精度门逐月 (全场逐台偏差 std ≤0.25° 才可判)
- # ★2026-09-19: 输入件现由 scripts/pitch_face_build.py 自算, 零位有**三个口径**(见该脚本 docstring):
- # 停机顺桨段 `zero_dev`(机械止挡位, 量不出运行区零位差) / 满发段 `zero_dev_full`(混风况, 参考) /
- # 运行段分档 `zero_dev_run`(同工况读数差 = 行业口径的零位, 分档归一后能把 19# 这类台单独拎出来)。
- # 判据按"**取更不利**"(§3.2 同一纪律): 停机段与运行段谁偏得多用谁, 依据行三个数都写出来。
- zsum = {}
- if len(zero):
- mstd = zero.groupby('month')['zero_dev'].std()
- ok_months = mstd[mstd <= ZERO_GATE_STD].index
- zok = zero[zero['month'].isin(ok_months)]
- for t, g in zok.groupby('turbine'):
- g = g.sort_values('month')
- _full = float(g['zero_dev_full'].median()) if 'zero_dev_full' in g.columns else float('nan')
- _run = float(g['zero_dev_run'].median()) if 'zero_dev_run' in g.columns else float('nan')
- _n_alarm = int((g['zero_dev'].abs() >= ZERO_ALARM).sum())
- if _run == _run:
- _n_alarm = int(((g['zero_dev'].abs() >= ZERO_ALARM)
- | (g['zero_dev_run'].abs() >= ZERO_ALARM)).sum())
- zsum[t] = dict(months=int(len(g)), dev_med=float(g['zero_dev'].median()), last=float(g['zero_dev'].iloc[-1]),
- dev_full_med=_full, dev_run_med=_run, n_alarm=_n_alarm,
- last_month=str(g['month'].iloc[-1]))
- # 报警轴同窗(用户令 2026-09-21:按所选窗重算):span 给了就用 span 的两端,
- # 否则仍是"数据末端往前 60 天"(原口径)。
- if span:
- win_start, win_end = pd.Timestamp(span[0]), pd.Timestamp(span[1])
- else:
- win_end = daily['date'].max(); win_start = pd.Timestamp(win_end) - pd.Timedelta(days=60)
- ac = _alarm_counts(win_start, win_end, store=(cfg or {}).get('store') if cfg else None)
- rows, detail = [], {}
- for t, r in fleet.iterrows():
- d = dict(维度={}, 依据={})
- blind = (r['nd'] < 10) or (r['nop'] < 144) # 现窗运行不足1天等效 → 不可判
- # D1 蓄能与压力调节
- band_x = r['band'] / max(med['band'], 1e-9)
- st1, why1 = '优秀', f"带宽中位 {r['band']:.0f}bar ({band_x:.2f}×fleet, 正常参考≈{NORM_BAND:.0f}bar 1min口径)"
- if r['band'] >= 2.0 * med['band']:
- st1 = '报警'; why1 += (f"; 越卸荷 {_rate(r['over_rate'])}/跌启泵 {_rate(r['under_rate'])}"
- f"(运行分钟占比)作佐证 [暂行: 带宽≥2×单证报警; 蓄能失效佐证=锯齿失稳]")
- elif r['band'] >= 1.4 * med['band']:
- st1 = '良好'
- # D2 润滑
- lub_x = r['lub'] / max(med['lub'], 1e-9)
- st2, why2 = '优秀', f"轮毂润滑泵日均 {r['lub']:.0f}s ({lub_x:.2f}×fleet中位{med['lub']:.0f}s; 分册: 全场中位171s/高位310s)"
- pist = [r['pa'], r['pb'], r['pc']]
- if all(x == x for x in pist) and min(pist) > 0 and max(pist) / min(pist) > 1.5:
- why2 += f"; 三叶柱塞不对称 {max(pist)/min(pist):.2f}×"
- if r['lub'] >= 1.8 * med['lub']:
- st2 = '报警'
- 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):
- st2 = '良好'
- # D3 执行与位置反馈 (零位轴并入 + 带宽骤变签名)
- st3, why3 = '优秀', ''
- z = zsum.get(t)
- if z:
- _stop, _full, _run = z['dev_med'], z.get('dev_full_med'), z.get('dev_run_med')
- _mag = abs(_stop)
- _lead = '停机顺桨段'
- if _run == _run and abs(_run) > _mag:
- _mag, _lead = abs(_run), '运行段'
- why3 = f"零位偏差 停机顺桨段 {_stop:+.2f}°"
- if _full == _full:
- why3 += f" / 满发段 {_full:+.2f}°"
- if _run == _run:
- why3 += f" / 运行段(同工况) {_run:+.2f}°"
- why3 += f" (判据轴取更不利的 {_lead}, 可判月 {z['months']}, 精度门0.25°)"
- if z['n_alarm'] >= 3 and _mag >= ZERO_ALARM:
- st3 = '报警'; why3 += f" ≥{ZERO_ALARM}°持续{z['n_alarm']}月 → 偏差确诊·成因待诊 [专项口径]"
- elif _mag >= ZERO_WATCH:
- st3 = '良好'
- if _mag >= ZERO_ALARM and abs(z['last']) < ZERO_WATCH:
- st3 = '良好'; why3 += ' (末月已归零→已闭环观察)'
- else:
- why3 = '零位: 可判月不足 (精度门/样本)'
- prev = daily[(daily['turbine'] == t) & (~daily['date'].isin(cur['date']))]
- if len(prev) > 20:
- pb_ = prev['hyd_band'].median()
- if pb_ > 0 and (r['band'] / pb_ < 0.5):
- st3 = st3 if st3 == '报警' else '良好'
- why3 += f"; 带宽骤降 {pb_:.0f}→{r['band']:.0f}bar (整定改动签名)"
- # D4 油路与密封
- st4, why4 = '优秀', f"油位报警 {r['lvl']:.0f}s/窗, 滤网报警 {r['filt']:.0f}s/窗 (开关1=正常, 报警秒=应测−timeon; 变桨泵使能位恒开无判据力已弃)"
- if r['lvl'] > 3600 or r['filt'] > 3600:
- st4 = '报警' if max(r['lvl'], r['filt']) > 6 * 3600 else '良好'
- # M4b 报警轴佐证: 族内计数 ≥10 且 ≥3×fleet中位 → 该维度升报警 (分册监测量的报警面, 7#/38# 类)
- if ac is not None:
- for fam, st_ref in (('蓄能压力', 'st1'), ('润滑', 'st2'), ('执行反馈', 'st3'), ('油路密封', 'st4')):
- cnt = int(ac[fam].get(t, 0)); fam_med = float(ac[fam].median()) if len(ac[fam]) else 0.0
- if cnt >= 10 and cnt >= 3 * max(fam_med, 1):
- if fam == '蓄能压力': st1 = '报警'; why1 += f'; 报警轴 {cnt}条 (fleet中位{fam_med:.0f})'
- if fam == '润滑': st2 = '报警'; why2 += f'; 报警轴 {cnt}条 (fleet中位{fam_med:.0f})'
- if fam == '执行反馈': st3 = '报警'; why3 += f'; 报警轴 {cnt}条 (fleet中位{fam_med:.0f})'
- if fam == '油路密封': st4 = '报警'; why4 += f'; 报警轴 {cnt}条 (fleet中位{fam_med:.0f})'
- for dim, st, why in zip(DIMS, (st1, st2, st3, st4), (why1, why2, why3, why4)):
- d['维度'][dim] = '不可判' if blind else st
- d['依据'][dim] = why
- order = {'报警': 0, '良好': 1, '优秀': 2, '不可判': 3}
- judged = [v for v in d['维度'].values() if v != '不可判']
- sys_st = sorted(judged, key=lambda x: order[x])[0] if judged else '不可判' # §3.2: 取最不利; 不可判不传染
- rows.append(dict(机组=t, 系统级=sys_st, **d['维度']))
- detail[t] = d
- reg = pd.DataFrame(rows).sort_values('机组').reset_index(drop=True)
- return reg, detail
- def reconcile_delivered():
- ref = json.load(open(ROOT / 'reference/rudong/pitch_delivered_grades.json', encoding='utf-8'))
- reg, _ = registry()
- ix = reg.set_index('机组')
- m = {'蓄能压力': '蓄能与压力调节', '润滑': '变桨轴承与轮毂润滑', '执行反馈': '执行与位置反馈', '油路密封': '油路与密封'}
- rows = []
- for t, v in ref.items():
- if t not in ix.index: continue
- for k, dim in m.items():
- a, b = v[k], ix.loc[t, dim]
- rows.append(dict(台=t, 维度=k, 交付=a, 现算=b, 一致=(a == '报警') == (b == '报警')))
- df = pd.DataFrame(rows)
- return df
|