| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- # -*- coding: utf-8 -*-
- """B翼 系统划分 (2026-08-25 用户令: 按交付分册的系统学重组, 温度NBM是方法不是系统).
- 九系统 = 交付汇编 01_分系统 蓝本: 变桨/偏航/主轴承/齿轮箱/发电机/变流器/主控与传感网 (+叶片/塔架=SCADA无轴, 边界注记).
- 每系统状态 = worst(该系统全部轴), 依据原文保留; 对账基线 = reference/rudong/<系统>_delivered_grades.json."""
- from app_common.app_common_guanlan.api import paths as P
- import json, pathlib
- import numpy as np, pandas as pd
- from src.windscada.config import farm
- from app_ETL.app_ETL_guanlan.api import load_10min
- SYSTEMS = ['变桨', '偏航', '主轴承', '齿轮箱', '发电机', '变流器', '主控与传感网', '叶片与叶根', '塔架与基础']
- # 9 系统 = 交付 9 分册一一对应 (2026-08-26 补齐: 先前只做 7 个, 缺叶片与叶根/塔架与基础)
- NO_SCADA = {'叶片与叶根': '无SCADA轴(分册蓝本=CMS叶频/巡检)', '塔架与基础': '无SCADA轴(分册蓝本=CMS塔频/巡检)'}
- # 温度通道 → 系统 (通道身份按契约命名指纹; GFilB*=定值80℃加热器带 [恒值哨兵], Brk=高速轴刹车归齿轮箱侧)
- TEMP2SYS = {
- 'MBearGTm': '主轴承', 'MBearHTm': '主轴承',
- 'GeOilTmp': '齿轮箱', 'IMSGenTm': '齿轮箱', 'IMSRotTm': '齿轮箱', 'HSGenTmp': '齿轮箱', 'HSRotTmp': '齿轮箱',
- 'GFilB1Tm': '齿轮箱', 'GFilB2Tm': '齿轮箱', 'GFilB3Tm': '齿轮箱', 'BrkTmpGn': '齿轮箱', 'BrkTmpGr': '齿轮箱',
- 'GenBeGTm': '发电机', 'GenBeRTm': '发电机', 'Gen1U1Tm': '发电机', 'Gen1V1Tm': '发电机', 'Gen1W1Tm': '发电机',
- 'ConvWTmp': '变流器', 'TrafoTMa': '变流器', 'TraOilTF': '变流器', 'TraRooTF': '变流器',
- 'A1ExtTmp': '主控与传感网', 'A21IntTm': '主控与传感网', 'A3LefTmp': '主控与传感网', 'A3RigTmp': '主控与传感网',
- 'NacelTmp': '主控与传感网', 'HubTemp': '主控与传感网',
- 'HydOilTm': '变桨',
- 'AmbieTmp': None, # 环温=热汇参考, 不归系统
- }
- LUB2SYS = {'din_wtc_MBLubPis_timeon': '主轴承', 'din_wtc_GBLubPis_timeon': '齿轮箱', 'dot_wtc_GenLubPu_timeon': '发电机'}
- def build_aux_store(cfg=None, since='2026-01-01'):
- """轻量补仓: 三系统润滑柱塞 duty (s/日) + ScComErr 通讯计数增量."""
- cfg = cfg or farm()
- rows = []
- for t in cfg['turbines']:
- rec = dict(turbine=t)
- try:
- d = load_10min(t, cfg, groups=['A.状态', 'B.润滑液压'])
- cur = d[d.ts >= since]
- byday = cur.groupby(cur.ts.dt.date)
- for col, sysname in LUB2SYS.items():
- rec[f'lub_{sysname}'] = float(byday[col].sum().mean())
- v = cur['cnt_wtc_ScComErr_accum'].dropna()
- rec['scomerr'] = float(v.iloc[-1] - v.iloc[0]) if len(v) > 2 else np.nan
- except Exception as e:
- rec['err'] = str(e)[:60]
- rows.append(rec); print(t, flush=True)
- df = pd.DataFrame(rows)
- df.to_parquet(pathlib.Path(cfg['store']) / 'system_aux.parquet')
- print('→ system_aux.parquet', df.shape)
- ORDER = {'报警': 0, '良好': 1, '不可判': 2, '优秀': 3, '—': 3}
- def _worst(states):
- js = [s for s in states if s in ORDER]
- return sorted(js, key=lambda x: ORDER[x])[0] if js else '不可判'
- def _load_tc(cfg):
- import pandas as _pd, pathlib as _pl
- f = _pl.Path(cfg['store']) / 'thermal_chain.parquet'
- return _pd.read_parquet(f).set_index('turbine') if f.exists() else None
- def system_matrix(cfg=None, span=None):
- """38台 × 7系统 状态 + 依据. 轴: 分系统模块(变桨/偏航/蓄能) + 温度通道归系统 + 润滑柱塞×fleet + 测风/通讯.
- `span=(起, 止)` 给了就**按所选时间窗重算判级**(用户令 2026-09-21,含两端):
- 四个判级面各自把它当判级窗(变桨=日粒度件切片、偏航/蓄能/温度=走 slim10min 窄仓重算;
- 温度的 ref/ref_sm 对照窗仍固定)。不给 = 各面原口径(近 60/90 日)。
- """
- cfg = cfg or farm()
- TC = _load_tc(cfg)
- # ★ 2026-09-17 缺件容错: 交付包不随产物 ⇒ 这批"无生成端"的件(pitch_daily/yaw_daily/system_aux/
- # thermal_chain…)在目标机上必然缺。这里逐个兜住: 缺件 = 该面无数据 (记 GAPS, 页面显示缺件),
- # 绝不让 KeyError/FileNotFoundError 把本体层整步打断 (实测清产物后重算就死在这里)。
- GAPS: list[str] = []
- def _missing(what: str, why: str = ''):
- tag = f'{what} 缺件 ({why})' if why else f'{what} 缺件'
- if tag not in GAPS:
- GAPS.append(tag)
- def _load_reg(fn, what, idx_col):
- try:
- reg = fn()
- reg = reg[0] if isinstance(reg, tuple) else reg
- return reg.set_index(idx_col)
- except FileNotFoundError as e:
- _missing(what, str(e).split("'")[-2] if "'" in str(e) else str(e)[:60])
- except Exception as e:
- _missing(what, f'{type(e).__name__}')
- return pd.DataFrame()
- def _load_parquet(name):
- f = pathlib.Path(cfg['store']) / name
- if not f.exists():
- _missing(name)
- return pd.DataFrame()
- try:
- return pd.read_parquet(f).set_index('turbine')
- except Exception as e:
- _missing(name, f'{type(e).__name__}')
- return pd.DataFrame()
- def _cell(i, t_, col, default='不可判'):
- """按台取一格 —— 表空/行缺/列缺都返回 default (缺件时该轴不可判, 不是崩溃)。"""
- try:
- v = i.loc[t_, col]
- except Exception:
- return default
- try:
- if v != v: # NaN
- return default
- except Exception:
- pass
- return v
- try:
- from .subsys import structure as stxmod
- _stx, STXD = stxmod.registry(cfg)
- STX = _stx.set_index('机组')
- except Exception:
- STX, STXD = None, {}
- from .subsys import pitch as pitchmod, yaw as yawmod, hydraulic, temp_nbm
- # ★2026-09-21 用户令「判级也按所选时间窗重算」: span 给了就逐面按窗重算(各面自己决定怎么按窗取数)。
- # 四个面里只有"变桨"的输入是日粒度序列;偏航/蓄能/温度走 slim10min 窄仓重算(同一份 load_10min 的列子集)。
- p_i = _load_reg(lambda: pitchmod.registry(span=span), '变桨面 pitch_daily.parquet', '机组')
- y_i = _load_reg(lambda: yawmod.registry(span=span), '偏航面 yaw_daily.parquet', '机组')
- h_i = _load_reg(lambda: hydraulic.registry(span=span), '蓄能面 (液压深挖件)', 'turbine')
- treg = temp_nbm.registry(win='cur', span=span)
- tdf = treg[0] if isinstance(treg, tuple) else treg
- if tdf is None or not len(tdf):
- tdf = pd.DataFrame(columns=['turbine', 'channel', 'dev_K', '判'])
- aux = _load_parquet('system_aux.parquet')
- ydf = _load_parquet('yaw_daily.parquet')
- pairs = temp_nbm.pairs_table()
- try:
- pcd = pd.read_parquet(pathlib.Path(cfg['store']) / 'powercurve_dev.parquet')
- pc_a = {r.turbine: f"dev{r.dev_w:+.1%} ws偏置{r.ws_bias:+.2f}" for _, r in pcd.iterrows() if '风速计' in str(r['判别'])}
- except Exception:
- pc_a = {}
- lub_med = {s: aux[f'lub_{s}'].median() for s in ('主轴承', '齿轮箱', '发电机')}
- sc_med = aux['scomerr'].median()
- tmap = {'温度候选(定向查)': '报警', '传感器不可信(A类)': '报警', '记基线观察': '良好', '不可判(返服嫌疑)': '不可判'}
- result = {}
- for t in cfg['turbines']:
- sysd = {}
- # 每系统: (states, 依据 list)
- acc = {s: ([], []) for s in SYSTEMS}
- # 变桨 = 分系统四维 + 蓄能深挖
- acc['变桨'][0].append(_cell(p_i, t, '系统级'))
- _pd_ = [_cell(p_i, t, d, '') for d in pitchmod.DIMS]
- if any(_pd_):
- acc['变桨'][1].append('四维:' + '/'.join(f'{d}{v}' for d, v in zip(pitchmod.DIMS, _pd_) if v))
- else:
- acc['变桨'][1].append('四维: 缺件, 不可判')
- hv = _cell(h_i, t, '判', '—')
- acc['变桨'][0].append('报警' if '候选' in hv else ('良好' if '记基线' in hv else '优秀'))
- if hv != '—': acc['变桨'][1].append(f"蓄能: {h_i.loc[t, '依据']}")
- # 偏航
- acc['偏航'][0].append(_cell(y_i, t, '系统级'))
- _yd_ = [_cell(y_i, t, d, '') for d in yawmod.DIMS]
- if any(_yd_):
- acc['偏航'][1].append('四维:' + '/'.join(f'{d[:2]}{v}' for d, v in zip(yawmod.DIMS, _yd_) if v))
- else:
- acc['偏航'][1].append('四维: 缺件, 不可判')
- # 温度通道归系统
- for _, r in tdf[tdf['turbine'] == t].iterrows():
- ch = r['channel'].replace('tmp_wtc_', '').replace('_mean', '').replace('_max', '')
- sysname = TEMP2SYS.get(ch)
- if not sysname: continue
- st = tmap.get(str(r['判']), None)
- if st:
- acc[sysname][0].append(st)
- acc[sysname][1].append(f"{ch} {r['dev_K']:+.1f}K({r['判'][:4]})")
- # 配对差 (主轴承双温差 / 发电机DE-NDE)
- for _, pr in pairs[pairs['turbine'] == t].iterrows():
- sysname = '主轴承' if '主轴承' in str(pr['pair']) or 'MBear' in str(pr['pair']) else '发电机'
- if abs(pr['pair_dev']) >= 4:
- acc[sysname][0].append('良好')
- acc[sysname][1].append(f"配对差{pr['pair']} {pr['pair_diff']:+.1f}K(偏{pr['pair_dev']:+.1f})")
- # 润滑柱塞 ×fleet (筛查级; 单窗相对门弱, 只报不定级除非极端)
- for s in ('主轴承', '齿轮箱', '发电机'):
- v = _cell(aux, t, f'lub_{s}', float('nan'))
- x = v / max(lub_med[s], 1e-9) if v == v else np.nan
- if x == x and (x >= 2.5 or x <= 0.25):
- acc[s][0].append('良好')
- acc[s][1].append(f"润滑duty {v:.0f}s/日 ({x:.2f}×fleet)")
- # 热链轴 (M13): 节点−热汇ΔT / 冷却执行器 duty / 三相极差 → 归所属系统
- if TC is not None and t in TC.index:
- tr = TC.loc[t]
- for col, sysn, thr, lab in (('齿轮箱高速轴−油', '齿轮箱', 4.0, '高速轴−油ΔT'),
- ('齿轮箱中速轴−油', '齿轮箱', 4.0, '中速轴−油ΔT'),
- ('齿箱油−冷却水', '齿轮箱', 4.0, '油−冷却水ΔT'),
- ('发电机绕组−冷却水', '发电机', 6.0, '绕组−冷却水ΔT')):
- v = tr.get(col)
- if v == v and (v - TC[col].median()) >= thr:
- acc[sysn][0].append('良好')
- acc[sysn][1].append(f'{lab} {v:.1f}K (超全场中位{v - TC[col].median():+.1f}K)')
- ph = tr.get('三相极差')
- if ph == ph and ph >= max(3.0, 2.5 * TC['三相极差'].median()):
- acc['发电机'][0].append('良好')
- acc['发电机'][1].append(f'绕组三相极差 {ph:.1f}K ({ph / TC["三相极差"].median():.1f}×fleet, 仪表/接线候选)')
- cd = tr.get('冷却器高速_高油温档'); cm = TC['冷却器高速_高油温档'].median()
- if cd == cd and cm > 0.02 and cd >= max(1.5 * cm, cm + 0.1):
- acc['齿轮箱'][0].append('良好')
- acc['齿轮箱'][1].append(f'冷却器高速档 duty {cd:.2f} ({cd / cm:.2f}×fleet, 同油温档) 换热能力低候选')
- # 叶片与叶根 / 塔架与基础 (M16 结构件)
- if STX is not None and t in STX.index:
- sr = STX.loc[t]
- for dim in ('叶片A', '叶片B', '叶片C', '叶根螺栓'):
- if sr.get(dim) in ('报警', '良好'):
- acc['叶片与叶根'][0].append(sr[dim]); acc['叶片与叶根'][1].append(f"{dim}: {STXD[t]['叶片'][dim][:60]}")
- for dim in ('塔筒频率', '塔筒振动', '塔筒湿度'):
- v = sr.get(dim)
- if v in ('报警', '良好'):
- acc['塔架与基础'][0].append(v); acc['塔架与基础'][1].append(f"{dim}: {STXD[t]['塔架'][dim][:70]}")
- elif v == '监测不可用':
- acc['塔架与基础'][0].append('不可判'); acc['塔架与基础'][1].append(f"{dim}: {STXD[t]['塔架'][dim][:70]}")
- # 主控与传感网: 测风(含曲线A类风速计路由) + 通讯
- if pc_a.get(t):
- acc['主控与传感网'][0].append('报警')
- acc['主控与传感网'][1].append(f"曲线判风速计偏置A类 ({pc_a[t]})")
- wf = float((_cell(ydf, t, 'windfau_s', 0) or 0) + (_cell(ydf, t, 'windfau_t', 0) or 0))
- yfa = int(_cell(ydf, t, 'D4风传感', 0) or 0)
- if wf >= 100 or yfa >= 30: acc['主控与传感网'][0].append('报警'); acc['主控与传感网'][1].append(f'测风故障计数{wf:.0f}+报警{yfa}')
- elif wf >= 30 or yfa >= 5: acc['主控与传感网'][0].append('良好'); acc['主控与传感网'][1].append(f'测风计数{wf:.0f}/报警{yfa}')
- sc = _cell(aux, t, 'scomerr', float('nan'))
- scx = sc / max(sc_med, 1e-9) if sc == sc else np.nan
- if scx == scx and scx >= 3 and sc >= 50:
- acc['主控与传感网'][0].append('良好'); acc['主控与传感网'][1].append(f'通讯错误增量{sc:.0f} ({scx:.1f}×fleet)')
- for s in SYSTEMS:
- states, why = acc[s]
- base = _worst(states) if states else '优秀'
- sysd[s] = dict(状态=base, 依据='; '.join(why) if why else '各轴无非常态')
- result[t] = sysd
- if GAPS:
- # 整表挂一条缺件说明 (页面/维护页据此显示"哪些面因缺件不可判"), 不静默造数
- result['_缺件说明'] = dict(GAPS=list(GAPS),
- 注='这些是"包内没有生成端"的产物 (docs §7); 交付包按用户令不随产物, '
- '运行期**不由交付包补齐** (用户令 2026-09-17): 放原始件后重算, '
- '或由研发补生成端 (逐族可逆性见 docs §13.6)。')
- # 振动映射进分系统状态 (2026-08-28 用户令): handoff 判级按 component 归系统, 取严合并+依据可溯.
- # 定论/报警/预警级 → 该系统升'报警'; 候选/监视/观察级 → 状态不动只追加依据 (筛查级展示态,
- # 振动判级本身已过双模审, 非本层产判级 — 转录取严)
- try:
- from .subsys import fusion as _fus
- _ftab, _ = _fus.fusion_table(cfg)
- _SYSMAP = [('发电机', '发电机'), ('齿轮箱', '齿轮箱'), ('行星', '齿轮箱'), ('中速', '齿轮箱'),
- ('高速', '齿轮箱'), ('主轴承', '主轴承'), ('主轴', '主轴承'), ('润滑泵', '主轴承')]
- import re as _re
- for _, r in _ftab.iterrows():
- t2, comp, lv = r.turbine, str(r.部件), str(r.振动结论)
- tgt = next((sysn for kw, sysn in _SYSMAP if kw in comp), None)
- if not tgt or t2 not in result:
- continue
- _cls, _mlv = _fus.verdict_class(lv)
- alarm = _mlv in ('bad', 'warn') and _cls != 'meta' # 测量链问题不入部件状态
- _dates = _re.findall(r'20\d{2}-\d{2}(?:-\d{2})?', str(r.claim_window))
- _cw = f"窗至{max(_dates)}" if _dates else '窗未标'
- note = f"振动: {comp} {lv} ({_cw}; 振动线转录)"
- d2 = result[t2][tgt]
- if alarm:
- d2['状态'] = '报警'
- if d2['依据'] == '各轴无非常态':
- d2['依据'] = note
- elif '振动:' not in d2['依据']:
- d2['依据'] += '; ' + note
- # ── windcms 评估报告转录 (2026-08-28 用户令: 与 windcms 核对设备状态评级) ──
- # 三方对拍逮出两类真问题, 都是"我方界面比 CMS 乐观":
- # A 漏报 6 处: CMS 判报警而本层显示优秀/良好 (09#齿轮箱 / 16#·20#·23#·38#主轴承 / 17#发电机)
- # C 10 处: CMS 判不可判而本层显示"优秀" — 把"监测面没覆盖"显示成"设备好", 是纪律问题
- # 合并规则 (取严, 不覆盖本层更强的证据):
- # CMS 报警/危险 → 抬到该级 (本层已≥该级则不动)
- # CMS 不可判 且 本层无自有异常证据 → 标不可判 (没测到 ≠ 正常)
- # CMS 良好/优秀 → 不动 (本层可能有 SCADA 侧证据, 那是另一条轴)
- try:
- _wg = _fus.windcms_grades()
- _WCOL = {'主轴承': ('主轴承前', '主轴承后'), '齿轮箱': ('齿轮箱',), '发电机': ('发电机',)}
- _ORD = ['危险', '报警', '良好', '优秀', '不可判']
- import pathlib as _pl, re as _re3
- from src.windcms.config import cms_out as _cms_out # 读写同一口 (WINDCMS_OUT 不能被读侧忽略)
- _wmd = sorted(_cms_out().glob('报告_CMS振动状态评估报告_*.md'))
- _wdate = _wmd[-1].stem.rsplit('_', 1)[-1] if _wmd else '?'
- _wpart = {}
- if _wmd:
- for _l in _wmd[-1].read_text(encoding='utf-8').split('## 附录 A')[1].splitlines():
- if _l.startswith('| WTG'):
- _c = [x.strip() for x in _l.strip('|').split('|')]
- _wpart[_c[0]] = dict(主轴承前=_c[1], 主轴承后=_c[2], 齿轮箱=_c[3], 发电机=_c[4])
- for _t3, _row3 in _wpart.items():
- if _t3 not in result:
- continue
- for _sysn3, _keys in _WCOL.items():
- _lvs = [_row3[k] for k in _keys if _row3.get(k) in _ORD]
- if not _lvs:
- continue
- _w = min(_lvs, key=_ORD.index)
- _d3 = result[_t3][_sysn3]
- _cur = _d3['状态']
- _note3 = f"CMS评估报告{_wdate}判{_w}"
- if _w in ('危险', '报警') and _cur not in ('危险', '报警'):
- _d3['状态'] = _w
- _d3['依据'] = (_note3 + ('; ' + _d3['依据'] if _d3['依据'] != '各轴无非常态' else '')).strip()
- elif _w == '不可判' and _cur in ('优秀', '良好'):
- _d3['状态'] = '不可判'
- _d3['依据'] = f"{_note3} (监测面未覆盖 — 没测到≠正常)" + (
- '; 本层: ' + _d3['依据'] if _d3['依据'] != '各轴无非常态' else '')
- elif _note3 not in _d3['依据'] and _w != _cur:
- _d3['依据'] = (_d3['依据'] + f"; {_note3}").lstrip('; ') if _d3['依据'] != '各轴无非常态' else _note3
- except Exception as _e3:
- for _t3 in result:
- for _s3 in ('主轴承', '齿轮箱', '发电机'):
- result[_t3][_s3]['依据'] += f'; ⚠CMS报告转录失败({_e3})'
- except Exception as e:
- # 振动映射失败必须可见 (依据里响亮标注, 不静默降级)
- for t2 in result:
- for sysn in ('主轴承', '齿轮箱', '发电机'):
- result[t2][sysn]['依据'] += f'; ⚠振动映射失败({str(e)[:40]})'
- return result
- def reconcile(cfg=None):
- """逐系统对账交付分册 (仅比 SCADA 可覆盖维; 分册振动维=CMS证据, SCADA 不可见, 不比)."""
- cfg = cfg or farm()
- mx = system_matrix(cfg)
- ref = pathlib.Path(cfg['contract']).parent
- out = {}
- books = {'主轴承': '主轴承_delivered_grades.json', '齿轮箱': '齿轮箱_delivered_grades.json',
- '发电机': '发电机_delivered_grades.json', '变流器': '变流器_delivered_grades.json',
- '主控与传感网': '主控传感网_delivered_grades.json'}
- # SCADA 可覆盖维 (白名单; 其余维=CMS/巡检证据不比)
- cover = {'主轴承': ['温度'], '齿轮箱': ['中速轴', '高速轴'],
- '发电机': ['温升'], '变流器': ['水冷系统', '变压器油温'],
- '主控与传感网': ['测风', '通讯与控制']} # 只列我真有轴的维; 润滑排油/散热/防凝露/电源电池等未建轴不入对账
- for s, fn in books.items():
- g = json.loads((ref / fn).read_text(encoding='utf-8'))
- book_alarm = {t for t, d in g.items() if any('报警' in str(d.get(c, '')) for c in cover[s])}
- mine_alarm = {t for t, d in mx.items() if d[s]['状态'] == '报警'}
- mine_near = {t for t, d in mx.items() if d[s]['状态'] in ('报警', '良好')}
- out[s] = dict(分册可比维报警=sorted(book_alarm), 我方报警=sorted(mine_alarm),
- 交集=sorted(book_alarm & mine_alarm), 分册独有=sorted(book_alarm - mine_alarm),
- 我独有=sorted(mine_alarm - book_alarm),
- 近失覆盖=f"{len(book_alarm & mine_near)}/{len(book_alarm)} (分册报警台落我报警∪良好)",
- 近失漏=sorted(book_alarm - mine_near))
- return out
|