| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615 |
- # -*- coding: utf-8 -*-
- """振动↔SCADA 融合面 (2026-08-27 用户令: 振动分析并入 windscada 融合; windcms 独立保留).
- 分工边界: 谱线/波形/六闸/判据全留 windcms (m5_cms_tcm); 本模块只消费振动线 handoff 接口
- (结论/该信多少/盲区), 与我方四源 (温度NBM watch / 润滑柱塞 / 油样索引 / 可靠性工单) 逐台拼证据.
- 三条融合纪律 (振动线接口 meta 钉死, 本处执行):
- 1. 同一失效机制的多观测面 ≠ 多源印证 — 禁按源数加权 (29# 四观测面 = 一条机制链);
- 2. 盲区权重: 振动"没报警"在 known_blind_spots 覆盖的模式上不构成阴性证据
- (急性热失效×冲击量 / 早期×谱线层 / 轻度×排名 = 振动瞎区);
- 3. claim 窗对齐: 三源数据窗各异 (振动至2026-08 / SCADA至2026-07-07 / 油样至2025-08),
- 每条融合行必带各源窗, 窗外无数据处写"未见数"不写"正常".
- 融合不产判级 (P0/P1 归综合报告线; 判级只转录振动线 verdict 原文)."""
- from app_common.app_common_guanlan.api import paths as P
- import json, pathlib, re
- import pandas as pd
- from src.windscada.config import farm
- # ── ★P1(用户令 2026-10-06):窗数口径与单窗拒判 —— 明文条款随 fus.纪律/fus.盲区 下发页面 ──
- # 依据:P0 已把持续性门/覆盖门分母改为 max(4, ceil(2N/3));本常量把「口径 + 拒判」显式化(防名实误读)。
- WINDOW_CLAUSE = (
- '窗数口径(2026-10-06 P1): 持续性/覆盖门的分母 = max(4, ceil(2N/3))(N≤6 恒 4 · N=9 ⇒ 6 · 分母只增不减); '
- 'N<3 时不得用持续性与逐窗趋势类判据出个体结论(只能标"窗内周段趋势(单窗补充口径, 仅参考不作判据)"); '
- '不得静默丢窗(谱窗与仅标量/原厂状态窗分列, 缺窗显式进盲区)'
- )
- _DATA_ROOT = pathlib.Path(__file__).resolve().parents[3]
- HANDOFF = P.m5() / 'handoff_vibration_v2.json'
- # 油样部件位目录名变体归一 (38台手工命名不一致; 归一后与融合部件轴对齐)
- OIL_COMP_NORM = {
- '主轴前轴承': '主轴前', '主轴后轴承': '主轴后',
- '发电机前轴承': '发电机前', '发电机后轴承': '发电机后',
- '发电机轴承前': '发电机前', '发电机轴承后': '发电机后',
- }
- GAP_HANDOFF = [None] # 缺 handoff 时记一句(供上层/页面显示); 见 load_handoff 的 2026-09-17 说明
- def load_handoff():
- """振动线接口 v2。
- ★ 2026-09-17 改口径 (用户令"打包不随产物"之后): 原来"缺失就 raise" —— 理由正当
- ("融合面不可静默降级"), 但它把**整条重算链**卡死: 交付包不再随产物 ⇒ 目标机上这件
- handoff 必然缺, 于是 `populate`(⑦) 直接 rc=1, 后面的本体审计全不执行。
- 现在改成**响亮降级**: 缺件返回空结构 + 在 `GAP_HANDOFF[0]` 写明缺什么、从哪儿补,
- 由上层写进结果与日志(页面/审计能看见), 而不是把链打断。判级本身不造数: 缺件时融合面
- 的状态为"不可判", 依据写明缺件。
- """
- if not HANDOFF.exists():
- GAP_HANDOFF[0] = (f'融合面缺件: {HANDOFF} (属"包内无生成端"的件, docs §13.6) —— '
- f'★用户令 2026-09-17/18: 运行期**不从交付包补齐**, 页面如实显示"不可判"; '
- f'要出这件必须由研发补生成端(振动六层链 fusion 步), 或放原始件重算')
- return {}
- return json.loads(HANDOFF.read_text(encoding='utf-8')) # 中文 JSON: 缺 encoding 会按系统 locale(cp936) 读
- _HB = None
- def huabiao_analysis(t):
- """华标 2026-07 轮 per 台分析短语 (2026-08-28 用户令: 油液填入分析).
- 转录检测机构判定(NB/T 10111)+重点抽样值+note, 不产新判级; 29# 缺采响亮."""
- global _HB
- if _HB is None:
- f = (P.ont() / 'oil_2026H2_huabiao.json')
- _HB = json.loads(f.read_text(encoding='utf-8')) if f.exists() else dict(meta={}, 抽样数值=[])
- rows = [r for r in _HB.get('抽样数值', []) if r.get('t') == t]
- if t == 'WTG29':
- return '★本轮缺采 (29#不在17台名单) — TCM-16分支B前置(主轴后Fe/PQ vs 2025-03基线15/12)未验'
- if not rows:
- return None # 名单内无重点值台由调用方给全批小结
- segs = []
- for r in rows:
- v = f"{r['pos']} Fe{r['Fe']}/PQ{r['PQ']}/疲劳{r.get('疲劳磨粒', '—')}"
- if r.get('note'):
- v += f" ({r['note'][:40]})"
- segs.append(v)
- return '华标判正常(NB/T宽限); 重点值: ' + '; '.join(segs)
- def oil_window(cfg=None):
- """油样索引 → per台 最新采样日期 (报告日期口径, 取样日≈报告日−1~2周)."""
- cfg = cfg or farm()
- p = pathlib.Path(cfg['store']) / 'oil_samples_index.parquet'
- if not p.exists():
- raise FileNotFoundError(f'油样索引缺失: {p} (scripts/windscada_watch_channels_build.py 不建它; 见 fusion 模块头)')
- df = pd.read_parquet(p)
- df['comp'] = df['comp'].replace(OIL_COMP_NORM)
- return df
- def fusion_table(cfg=None, all_turbines=False):
- """逐台融合视图: 振动 verdict (转录) × 温度 watch × 柱塞 × 油样窗.
- 行 = 振动线 handoff 里有 verdict 的台; 列 = 各证据轴 + 各源 claim 窗."""
- cfg = cfg or farm()
- h = load_handoff()
- if not h or not (h.get('per_turbine') or {}):
- # 缺 handoff (交付包不随产物 ⇒ 目标机必然缺): 返回**空表但列齐 + 列头齐全**, 上层把
- # GAP_HANDOFF[0] 那句"缺什么/怎么补"写进结果 —— 判级不造数, 页面显示缺件 (2026-09-17)。
- # ★2026-09-18 补: meta 原来给的是**空 dict**, 而 fleet_view 会取 meta['振动窗']/['纪律']/…
- # ⇒ 上层又变成 KeyError 报给用户(实测 `/api/fleet` 的 fus 回 `{"err": "'振动窗'"}` 那一类)。
- # 缺件就把"缺"写进 meta 的每个键, 页面照常渲染并显示缺件原因。
- gap = GAP_HANDOFF[0] or (f'融合面缺件: {HANDOFF} (包内无生成端, docs §13.6) — '
- '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端')
- return (pd.DataFrame(columns=['turbine', '部件', '振动结论', '温度', '柱塞', '油样窗', '依据']),
- dict(数据时点='缺件', handoff版本='—', handoff日期='', 证据窗末='',
- 振动窗='缺件 (需 handoff_vibration_v2.json)', SCADA窗='', 油样窗='',
- 纪律=gap, 盲区=[], open_items=[], 色标={}, gap=gap))
- oil = oil_window(cfg)
- from . import temp_nbm
- wp = pathlib.Path(cfg['store']) / 'watch_channels_monthly.parquet'
- watch = pd.read_parquet(wp) if wp.exists() else None
- # 部件 → 温度通道 stem 路由 (温度轴必须与振动结论的部件同对象, 不得拿别的部件温度充数)
- COMP_CH = {'发电机': ('GenBe',), '齿轮箱': ('GeOilTmp', 'IMS', 'HS'), '主轴承': ('MBear',)}
- treg = temp_nbm.registry()
- tdf = treg[0] if isinstance(treg, tuple) else treg
- rows = []
- # v2.1 起 component 为显式字段 (振动线未映射的显式标性质: "引用同族/谱线级/测量链/未映射"
- # — 后三类不当部件级结论消费, 原文透传); 缺字段时回退子块名
- v1 = [(r, r.get('component') or '发电机') for r in h['per_turbine'].get('v1_generator', [])] + \
- [(r, r.get('component') or '齿轮箱') for r in h['per_turbine'].get('v1_gearbox', [])]
- new = [(r, r.get('component', '?')) for r in h['per_turbine'].get('new_2026_08_26', [])]
- for r, comp in v1 + new:
- t = r['turbine']
- stems = next((v for k, v in COMP_CH.items() if k in comp), ())
- sub = tdf[(tdf.turbine == t) & tdf.channel.str.contains('|'.join(stems))] if stems else tdf.iloc[0:0]
- if len(sub):
- top = sub.reindex(sub.dev_K.abs().sort_values(ascending=False).index).iloc[0]
- temp_note = f"{top.channel.replace('tmp_wtc_','').replace('_mean','')} dev{top.dev_K:+.1f}K [{top['判']}]"
- else:
- temp_note = f'未路由到{comp}温度通道'
- # WATCHLIST 台补月度 fleet 位次口径 — NBM 变化型对"稳定高位"失明 (29# 后主轴承 19月位次1-11
- # 而 NBM 判"—" 即此盲区), watchlist 台必须双口径并示
- wch = [c for (wt, c) in temp_nbm.WATCHLIST if wt == t and any(s in c for s in stems)]
- if wch and watch is not None:
- c = wch[0]
- piv = watch.pivot_table(index='month', columns='turbine', values=c)
- last = piv.dropna(how='all').index.max()
- dv = float(piv.loc[last, t] - piv.loc[last].median())
- rk = int(piv.loc[last].rank(ascending=False, method='average')[t])
- temp_note += f"; watch月度({last}): {c.split('_')[2]} dev{dv:+.1f}K 位次{rk}/38"
- # 油样轴: 该台最新样日期 + stale 声明
- to = oil[oil.turbine == t]
- oil_note = '无样'
- if len(to):
- last = to.date.max()
- if last >= '2026-07':
- hb = huabiao_analysis(t)
- oil_note = f"末样{last}: " + (hb or '华标2026-07轮全位判正常/可继续使用 (近共时可作现时判据)')
- else:
- oil_note = f"末样{last} (距振动现窗>12月, stale: 只作回测锚非现时判据)"
- hb29 = huabiao_analysis(t)
- if t == 'WTG29':
- oil_note += '; ' + hb29
- _vc, _vl = verdict_class(r.get('verdict', ''))
- # 图形化结构字段 (2026-08-28 用户令"能表达清楚的就图形化UI"): 前端据此画偏差条/窗时间轴/时效胶囊,
- # 原字符串列保留作 tooltip 与降级
- _tdev = _tvd = None
- _mt = re.search(r'([A-Za-z0-9_]+) dev([+-][\d.]+)K \[([^\]]*)\]', temp_note)
- if _mt:
- _tch, _tdev, _tvd = _mt.group(1), float(_mt.group(2)), _mt.group(3)
- else:
- _tch = None
- _spans = [[a, b] for a, b in re.findall(r'(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})', str(r.get('claim_window', '')))]
- _olast = to.date.max() if len(to) else None
- _wl = to_wcms_level
- rows.append(dict(
- turbine=t, 部件=comp,
- 振动结论=r.get('verdict', ''), 语义类=_vc, 级=_vl, # 判级单源: 前端禁自行正则(27#'销案(定论)'曾被染红)
- 设备状态=to_owner_state(r.get('verdict', '')), # 业主口径五级 (windcms report_std 映射)
- 证据状态=to_wcms_level(r.get('verdict', '')), # 可信度九枚举
- 振动信号=r.get('signal', r.get('title', ''))[:80],
- 温度通道=_tch, 温度dev=_tdev, 温度判=_tvd, 窗区间=_spans,
- 油末样=_olast, 油新鲜=bool(_olast and _olast >= '2026-07'),
- claim_window=r.get('claim_window', '未标'),
- 温度轴=temp_note, 油样轴=oil_note,
- ))
- _hm = h.get('meta', {})
- _asof = str(_hm.get('date', '?'))
- _hver = str(_hm.get('version', '?'))
- # 证据窗末 = 全表 claim_window 里最大的日期 (转录源的真实末端, 不写死)
- import re as _re2
- _all_d = sorted({d for w in rows for d in _re2.findall(r'20\d\d-\d\d-\d\d', str(w.get('claim_window', '')))})
- _win_end = _all_d[-1] if _all_d else '?'
- # 补齐全场 (2026-08-28 用户令"逐台, 38台"): handoff 只列有结论的台次, 未收录台原来整行不出现
- # → 页面写"28 台次"而全场 38 台, 读者会以为另外 10 台不存在。未收录台按 _unlisted_cell 给出
- # 明确状态 (正常/数据不足·不可判/两口径差异), 不留空行也不假装有结论。
- if all_turbines:
- _have = {r['turbine'] for r in rows}
- for _t in cfg['turbines']:
- if _t in _have:
- continue
- _u = _unlisted_cell(_t)
- _to = oil[oil.turbine == _t]
- _ol = _to.date.max() if len(_to) else None
- rows.append(dict(
- turbine=_t, 部件='(振动线未收录)',
- 振动结论=_u['note'], 语义类='unlisted', 级=_u['level'],
- 设备状态=_u.get('ostate_hint') or to_owner_state(_u['note']),
- 证据状态=to_wcms_level(_u['note']),
- 振动信号='—', claim_window='—', 窗区间=[],
- 温度通道=None, 温度dev=None, 温度判=None, 温度轴='(未收录台不参与振动侧温度交叉)',
- 油末样=_ol, 油新鲜=bool(_ol and _ol >= '2026-07'),
- 油样轴=(f'末样{_ol}' if _ol else '无样')))
- rows.sort(key=lambda r: r['turbine'])
- meta = dict(
- 色标=windcms_level_color(),
- 数据时点=f'振动证据包 · 出具 {_asof} · 证据窗末 {_win_end}',
- handoff版本=_hver, handoff日期=_asof, 证据窗末=_win_end,
- 振动窗=f'至{_win_end[:7]} (证据包出件 {_asof})',
- SCADA窗='至2026-07-07', 油样窗='SGS 2024-11~2025-08 (404份/38台) + 华标2026-07 (102份/17台)',
- 纪律=(str(h['meta'].get('reading_discipline') or '') + ' | ' + WINDOW_CLAUSE),
- 盲区=(list(h['detectability_prior'].get('known_blind_spots') or []) + [WINDOW_CLAUSE]),
- open_items=h.get('open_items', []),
- )
- return pd.DataFrame(rows), meta
- # windcms 分级色标 (单源: src/windcms/config.LEVEL_COLOR — 2026-08-28 用户令"色标和分级体系参照 windcms";
- # 两套系统对同一批振动结论上色必须一致, 否则同一台在两页颜色不同 = 判级看起来不同)
- def windcms_level_color():
- try:
- from src.windcms.config import LEVEL_COLOR
- return dict(LEVEL_COLOR)
- except Exception:
- return {}
- # 融合层 verdict 文本 → windcms 九级中的哪一级 (转录映射, 不产新判级)
- WCMS_LEVEL = [
- # ★顺序敏感 (同一 substring 坑第 3 次): 关闭类必须最先判 — "销案(定论)" 含"定论"
- (('销案', '正样本', '无新证', '可降', '降观察', '无异常'), '正常'),
- (('撤回',), '撤回'),
- (('不可判', '未评估', '数据不足', 'INSUFFICIENT'), 'INSUFFICIENT'),
- (('定论',), '定论'), (('预警',), '准定论·预警'), (('报警',), '准定论·预警'),
- (('新发',), '候选·新发'), (('记基线',), '候选·记基线'),
- (('候选', '监视', '仲裁', '弱同向', '新证'), '候选'),
- (('观察', '降观察', '保留'), '参考'),
- (('正常',), '正常'),
- ]
- def to_owner_state(text):
- """振动结论文本 → windcms 业主口径状态等级 (危险/报警/良好/优秀/不可判).
- 2026-08-28 用户令"参照 windcms 优秀/良好/报警/危险 级别体系". windcms 有两套并行:
- ① 证据状态 九枚举 (定论/准定论·预警/候选…/INSUFFICIENT) = 可信度轴
- ② 业主口径状态等级 五级 (危险>报警>良好>优秀>不可判) = 设备状态轴
- 映射单源 = src/windcms/report_std.line_state (它对未知词 raise, 不静默塌缩成"优秀" —
- 该闸是 2026-08-24 钉的, 我们这边照用不复制词表)。
- 台级"不可判"另判: 数据不足/未评估的格子直接给"不可判", 不走线级映射
- (线级 INSUFFICIENT 在 windcms 映射成"优秀" = 单条线不足以判 ≠ 整台没数据)。"""
- t = str(text or '')
- if any(k in t for k in ('数据不足', '未评估', '不可判')):
- return '不可判'
- lv = to_wcms_level(t)
- if lv == '—':
- return '不可判'
- try:
- from app_algorithmModel.app_algorithmModel_guanlan.windcms.report_std import line_state
- return line_state(lv)
- except Exception:
- return {'定论': '危险', '准定论·预警': '报警', '候选·新发': '报警', '候选': '良好',
- '候选·记基线': '良好', '参考': '优秀', 'INSUFFICIENT': '优秀',
- '撤回': '优秀', '正常': '优秀'}.get(lv, '不可判')
- def to_wcms_level(text):
- """振动结论/格文案 → windcms 级名 (供 UI 取色; 找不到给 '—')."""
- t = str(text or '')
- for kws, lv in WCMS_LEVEL:
- if any(k in t for k in kws):
- return lv
- return '—'
- VERDICT_CLASSES = [
- # 顺序敏感: 关闭类先判 — "销案(定论)"含'定论'但语义是关闭 (2026-08-28 用户逮映射粗暴, 27#误红实锤)
- ('closed', ('销案', '正样本', '无新证', '可降', '降观察'), 'ok'),
- ('meta', ('测量链', '传感器项'), 'meta'), # 仪器问题, 不入部件状态
- ('alarm', ('定论', '报警'), 'bad'),
- ('warn', ('预警',), 'warn'),
- ('hold', ('保留', '仲裁输入'), 'note'), # 义务未闭, 非关闭
- ('watch', ('候选', '监视', '观察', '跟踪', '弱同向', '新证'), 'note'),
- ]
- def verdict_class(verdict):
- """振动线 verdict 文本 → (语义类, 矩阵级). 单源: taxonomy 与本模块矩阵共用.
- 未识别词响亮标注而非静默归类."""
- v = str(verdict)
- for cls, kws, lv in VERDICT_CLASSES:
- if any(k in v for k in kws):
- return cls, lv
- return 'unknown', 'note'
- def _vib_level(verdict):
- """振动 verdict 文本 → 矩阵格状态级 (转录分级, 不产判级). meta(测量链)降 note 显示."""
- lv = verdict_class(verdict)[1]
- return 'note' if lv == 'meta' else lv
- def _ch_cn(ch):
- """通道中文名 (矩阵格用短名; i18n 未收录则回退原缩写, 不静默造名)."""
- from src.windscada import i18n
- nm = i18n.ch_cn(ch, with_tag=False)
- if nm == ch: # 未收录 → 去前后缀后原样给
- return ch.replace('tmp_wtc_', '').replace('_mean', '')
- return nm.replace('·均值', '').replace('·最大', '')
- _WCMS = None
- def _unlisted_cell(t):
- """振动线未收录该台时, 说清是哪一种 (2026-08-28 用户令"说清楚问题: 数据不足以判断 还是 正常运行"):
- ① 报告判不可判 → 数据不足, 不可判 (监测盲区/数据缺, 不是正常)
- ② 报告判报警/危险 → 两口径差异! 状态管理判报警而定谳链未收录 (须对拍, 不能显示成"未收录")
- ③ 报告判良好/优秀 → 已评估, 定谳链无需单列 (可作"本轮无异常"读)
- ④ 报告也没有 → 真未评估"""
- g = windcms_grades().get(t)
- if not g:
- return dict(level='unlisted', ostate_hint='不可判',
- note='未评估 — 不等于正常')
- lv, dt = g['综合'], g['日期']
- if lv in ('不可判',):
- return dict(level='unlisted', ostate_hint='不可判',
- note='振动线未收录 · 数据不足不可判 (非正常)')
- if lv in ('报警', '危险'):
- return dict(level='warn', ostate_hint=lv,
- note=f'⚠两口径差异 — 评估报告 {dt} 判{lv}, 振动线未收录')
- # 与其他列同词 (用户: "评估无异常不就是正常吗"); 来源留浮层。
- # ★但等级必须照报告原级 — 原来一律写"正常"会让业主状态统一抬成"优秀",
- # 把报告判"良好"的台降级掉了 (2026-08-28 补全 38 台时实逮)。
- # ★振动列只讲振动 (2026-08-28 用户"还是和之前的振动分析结果不一致"):
- # 原来未收录台在"振动"列写"良好 · 评估报告 2026-08-28" — 那是设备状态评估报告的等级,
- # 不是振动线结论, 摆在振动列里必然被读成振动结论。逐台对拍证实: 26 台有 handoff 结论的
- # 全部一致, 用户看到的"不一致"全部来自这 10 台未收录台的借位显示。
- # 评估报告等级仍经 ostate_hint 驱动右侧设备状态徽章, 信息不丢, 只是不占振动列。
- return dict(level='ok', ostate_hint=lv,
- note='振动线未收录 (设备状态见右侧徽章)')
- def windcms_grades():
- """windcms 最新评估报告的 38 台综合等级 (用于把"未收录"说清楚: 数据不足 vs 已评估无异常).
- 振动线 handoff 只列有结论的台, 未列不等于正常 — 但也不等于没测; 报告等级能区分这两者。"""
- global _WCMS
- if _WCMS is not None:
- return _WCMS
- _WCMS = {}
- try:
- from src.windcms.config import cms_out # 与写侧同一解析口 (WINDCMS_OUT; 见 windcms.config.cms_out)
- rp = sorted(cms_out().glob('报告_CMS振动状态评估报告_*.md'))[-1]
- md = rp.read_text(encoding='utf-8')
- for l in md.split('## 附录 A')[1].splitlines():
- if not l.startswith('| WTG'):
- continue
- c = [x.strip() for x in l.strip('|').split('|')]
- if len(c) > 5:
- _WCMS[c[0]] = dict(综合=c[5], 日期=rp.stem.rsplit('_', 1)[-1])
- except Exception:
- pass
- return _WCMS
- def matrix(cfg=None, months=None):
- """§1 全场融合矩阵: 38台 × 4源 (振动/温度/润滑/油液), 每格状态+时效.
- months = 全局时间窗的月列表 (None=全程) — 2026-08-27 用户令"整体时间窗": 温度/柱塞格的
- 现窗=所选窗末月, 记忆轨迹裁剪到窗内 (趋势参考=窗内趋势); 油液格=窗内样本;
- 振动格 verdict 是振动线 claim 窗的转录, **不随本窗变** (证据窗固定, 列头注明).
- 振动格纪律: handoff 只列有结论的台 → 未列台显示"未列", **未列≠正常** (盲区见§3);
- 油液全场 stale (末样距今>12月) → 该列只作时效展示不作现时状态."""
- cfg = cfg or farm()
- h = load_handoff()
- oil = oil_window(cfg)
- if months:
- oil = oil[oil.date.str[:7].isin(months)]
- st = pathlib.Path(cfg['store'])
- from . import temp_nbm
- treg = temp_nbm.registry()
- tdf = treg[0] if isinstance(treg, tuple) else treg
- # 振动: 逐台聚合 handoff verdicts (一台多部件取最重级)
- # ★2026-09-18 修: 这里原来直接取 `h['per_turbine']` —— 而 `load_handoff()` 在缺件时返回的
- # 正是**空 dict**(2026-09-17 改成响亮降级, 不再 raise), 于是清过产物、重算后没有
- # handoff_vibration_v2.json 的机器上, `/api/fleet` 的融合面回的是 `{"err": "'per_turbine'"}`
- # —— 一个 KeyError 被当成"这一面坏了"显示给用户, 而不是"缺哪件"。缺件就走空表 + gap 说明。
- vib = {}
- if not h or not (h.get('per_turbine') or {}):
- # 缺件就走**空表 + 列头说明**(与 fusion_table 同一口径): 页面显示"缺哪件", 不显示 KeyError。
- gap = GAP_HANDOFF[0] or (f'融合面缺件: {HANDOFF} (包内无生成端, docs §13.6) —— '
- '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端')
- return [], dict(振动=f'缺件: {HANDOFF.name}', 温度='', 润滑='', 油液='', months=[], gap=gap)
- v1 = [(r, '发电机') for r in h['per_turbine'].get('v1_generator', [])] + \
- [(r, '齿轮箱') for r in h['per_turbine'].get('v1_gearbox', [])]
- new = [(r, r.get('component', '?')) for r in h['per_turbine'].get('new_2026_08_26', [])]
- SEV = {'bad': 3, 'warn': 2, 'note': 1, 'ok': 0}
- for r, comp in v1 + new:
- t = r['turbine']; lv = _vib_level(r.get('verdict', ''))
- cur = vib.get(t)
- if cur is None or SEV[lv] > SEV[cur['level']]:
- vib[t] = dict(level=lv, note=f"{comp}: {r.get('verdict','')}")
- elif SEV[lv] == SEV[cur['level']] and comp not in cur['note']:
- cur['note'] += f"; {comp}: {r.get('verdict','')}"
- # 润滑 (主轴承柱塞): **信息列非判据列** — 跨台单次时长天然异质 (正常月 p10~p90=22~961s,
- # 各台润滑设定不同, 横比不成立; 2026-08-27 实测), 且现窗6天窗全场共模拖长 (大风连转窗效应).
- # 单格判级只随机制链定案 (29# = 19个月纵向形态+温度/振动时序锁定, 非单月横比), 由 handoff 驱动.
- lubp = st / 'mblub_monthly.parquet'
- lub = pd.read_parquet(lubp) if lubp.exists() else None
- if lub is not None and months:
- lub = lub[lub.month.isin(months)]
- if not len(lub): lub = None # 窗内无柱塞数据 (如纯2024窗) → 该列"窗内无数据"
- lub_cell = {}
- if lub is not None:
- last_m = lub.month.max()
- cur = lub[lub.month == last_m].set_index('turbine')
- per = (cur.pis / cur.cnt.replace(0, pd.NA)).dropna()
- rank = per.rank(ascending=False, method='min')
- chain_bad = {r['turbine'] for r, _ in new if '润滑' in str(r.get('component', '')) + str(r.get('title', ''))}
- for t in per.index:
- v = float(per[t]); rk = int(rank[t])
- lv = 'bad' if t in chain_bad else 'note'
- # tag 窗无关措辞: "泵打不满"特指 2026-07 形态, 单月历史窗下挂具体形态词会时间错位
- tag = ' [润滑断供机制链台(v5定案), 各期形态见轨迹]' if t in chain_bad else ''
- lub_cell[t] = dict(level=lv, note=f'{v:.0f}s/次 第{rk}/38{tag}', hist_label='单次循环时长逐月(s)')
- # 温度格双口径 (振动线⑥采纳, 全台不只 WATCHLIST 台): NBM 变化口径 + watch 月度水平位次口径 —
- # 变化型对"稳定高位"失明 (29# 后主轴承 19月位次1-11 而 NBM 判"—"), 两口径并示互补盲区
- wp = pathlib.Path(cfg['store']) / 'watch_channels_monthly.parquet'
- watch = pd.read_parquet(wp) if wp.exists() else None
- if watch is not None and months:
- watch = watch[watch.month.isin(months)]
- if not len(watch): watch = None
- def _level_rank(ch, t):
- # ★窗对齐 (2026-08-28 用户逮"振动融合没核对时间窗"): 原取全局末月, 而列头声明的是所选窗末月
- # ⇒ 选 2025H2 时列头写"窗末月2025-12"而格内是 2026-07 的数, 声明与实际不符。
- # NBM 变化口径本就是固定近90日 (列头已标"不随本窗"), 但 watch 月度水平位次必须落在所选窗内。
- if watch is None or ch not in watch.columns: return ''
- piv = watch.pivot_table(index='month', columns='turbine', values=ch)
- piv = piv.dropna(how='all')
- if months:
- piv = piv[piv.index.isin([str(m) for m in months])]
- if not len(piv): return ''
- last = piv.index.max()
- row = piv.loc[last]
- if pd.isna(row.get(t)): return ''
- dv = float(row[t] - row.median())
- rk = int(row.rank(ascending=False, method='average')[t])
- return f" · 现水平 {dv:+.1f}K 第{rk}/38"
- # 历史记忆轨迹 (2026-08-27 用户令: 显示最新窗, 旧窗变成记忆看趋势 — 现窗快照会丢形态,
- # 如 29# 柱塞两段式塌→回升→翻转拖长; 每格带逐月序列, 前端画迷你趋势线现窗点高亮)
- def _temp_hist(ch, t):
- if watch is None or ch not in watch.columns: return None
- piv = watch.pivot_table(index='month', columns='turbine', values=ch).dropna(how='all')
- if months:
- piv = piv[piv.index.isin([str(m) for m in months])]
- if t not in piv.columns: return None
- dev = (piv[t] - piv.median(axis=1)).round(1)
- return [None if pd.isna(x) else float(x) for x in dev]
- lub_hist = {}
- if lub is not None:
- lper = lub.assign(per=(lub.pis / lub.cnt.replace(0, pd.NA))).pivot_table(index='month', columns='turbine', values='per')
- for t in lper.columns:
- lub_hist[t] = [None if pd.isna(x) else round(float(x), 1) for x in lper[t]]
- months_axis = sorted(watch.month.unique()) if watch is not None else []
- # 温度列只收传动链三部件的通道 (2026-08-28 用户令"各列只列与问题相关的部件: 主轴/发电机/齿轮箱"):
- # 本矩阵的行是"该台传动链状态", 拿变压器/液压油温的最大偏来填温度列 = 跨部件充数,
- # 与 fusion_table 早已执行的 COMP_CH 路由纪律("不得拿别的部件温度充数")一致。
- # 非传动链通道 (变压器/机舱/环温/液压) 在分系统页各自的轴上看, 不进本列。
- _DRIVE_STEM = ('MBear', 'MBearH', 'MBearG', # 主轴承前/后
- 'GeOilTmp', 'IMS', 'HS', 'GBox', # 齿轮箱油/中间轴/高速轴
- 'GenBe', 'GenIV', 'GenSt') # 发电机轴承/绕组
- rows = []
- for t in cfg['turbines']:
- v = vib.get(t)
- sub = tdf[tdf.turbine == t]
- sub = sub[sub.channel.str.contains('|'.join(_DRIVE_STEM), na=False)]
- hit = sub[sub['判'] != '—']
- if len(hit):
- top = hit.reindex(hit.dev_K.abs().sort_values(ascending=False).index).iloc[0]
- nm = top.channel.replace('tmp_wtc_', '').replace('_mean', '')
- tcell = dict(level='warn', note=f"{_ch_cn(top.channel)} {top.dev_K:+.1f}K {top['判']}" + _level_rank(top.channel, t),
- hist=_temp_hist(top.channel, t), hist_label=f'{nm} 月度dev轨迹')
- elif len(sub):
- top = sub.reindex(sub.dev_K.abs().sort_values(ascending=False).index).iloc[0]
- nm = top.channel.replace('tmp_wtc_', '').replace('_mean', '')
- tcell = dict(level='ok', note=f"{_ch_cn(top.channel)} {top.dev_K:+.1f}K" + _level_rank(top.channel, t),
- hist=_temp_hist(top.channel, t), hist_label=f'{nm} 月度dev轨迹')
- else:
- tcell = dict(level='ok', note='传动链温度通道无越档')
- to = oil[oil.turbine == t]
- if len(to):
- last = to.date.max()
- fresh = last >= '2026-07' # 华标2026-07轮台 = 有近共时样, 不再挂 stale
- _hbn = huabiao_analysis(t) if fresh else (huabiao_analysis(t) if t == 'WTG29' else None)
- ocell = dict(level=('ok' if fresh else 'stale'),
- note=f"{len(to)}样 末{last[:7]}" + ('' if fresh else ' [stale>12月]')
- + (f" · {_hbn}" if _hbn else (' · 华标2026-07全位判正常' if fresh else '')),
- hist_label='采样时间线')
- else:
- ocell = dict(level='stale', note='窗内无样' if months else '无样')
- _vc = v or _unlisted_cell(t)
- for _cell in (_vc, tcell, lub_cell.get(t) or {}, ocell):
- if isinstance(_cell, dict):
- _cell['wlevel'] = to_wcms_level(_cell.get('note', '')) # 证据状态轴 (可信度)
- _cell['ostate'] = _cell.get('ostate_hint') or to_owner_state(_cell.get('note', '')) # 业主口径状态轴
- rows.append(dict(
- turbine=t,
- 振动=_vc,
- 温度=tcell, 润滑={**lub_cell.get(t, dict(level='note', note='无仓')), 'hist': lub_hist.get(t)}, 油液=ocell,
- ))
- heads = dict(振动='证据窗固定, 不随本窗 (至2026-08)',
- 温度=f"窗末月{watch.month.max() if watch is not None else '?'} (变化口径=同型机群比对·近90日固定)",
- 润滑=f"窗末月{lub.month.max() if lub is not None else '无数据'}",
- 油液='窗内样本 (华标2026-07轮17台=近共时; 其余台末样2025-08 stale)')
- heads['months'] = months_axis
- return rows, heads
- RENDERED_DP_KEYS = {'purpose', 'classes', 'meas_crossing', 'known_blind_spots', '端到端闭环'}
- def capability():
- """§3 振动能力边界: **全量透传 + 白名单渲染** (2026-08-27 双线约定, 替代白名单透传 —
- v2.2 "端到端闭环"新键曾静默漏消费, 教训='读同路径自动吃到'只对既有字段成立).
- 生产方新增顶层键 → unrendered 自动列出提示接线, 不再依赖交接消息记得说.
- ★2026-09-18 修 (同 matrix 的教训): 这里原来直接取 `h['detectability_prior']` —— 缺 handoff 时
- `load_handoff()` 返回空 dict, 于是 `KeyError: 'detectability_prior'` 被 fleet_view 包成
- `fus={"err": "'per_turbine'"}` 之类的**程序错**显示给人。缺件就该说"缺件"。
- """
- h = load_handoff()
- dp = h.get('detectability_prior') or {}
- if not dp:
- return dict(classes={}, blind=[], meas={}, purpose='', loop={}, unrendered=[],
- gap=GAP_HANDOFF[0] or (f'振动能力边界需 {HANDOFF.name} (包内无生成端, docs §13.6); '
- '本机该件不在位 ⇒ 如实为空'))
- return dict(classes=dp.get('classes', {}), blind=dp.get('known_blind_spots', []),
- meas=dp.get('meas_crossing', {}), purpose=dp.get('purpose', ''),
- loop=dp.get('端到端闭环', {}),
- unrendered=sorted(k for k in dp if k not in RENDERED_DP_KEYS))
- # 传动链三部件的报警/工单族过滤 (2026-08-27 用户令: 齿轮箱/发电机/主轴的报警与工单进融合板块)
- # 报警按 text 关键词 (码表对译后中文); 发电机族排除逆变器/通讯类 (13121 属变流器侧非发电机本体);
- # 主轴工单含"传动链"二级但排除滑环 (集团模板"(双馈)传动链"错标, 本场鼠笼异步无滑环属发电机励磁问题域)
- DRIVETRAIN = {
- '齿轮箱': dict(alarm_pat=r'齿轮', wo_pat=r'齿轮'),
- '发电机': dict(alarm_pat=r'发电机', alarm_excl=r'逆变器|通讯', wo_pat=r'发电机'),
- '主轴': dict(alarm_pat=r'主轴', wo_pat=r'主轴', wo_extra='传动链', wo_excl=r'滑环'),
- }
- def drivetrain_events(months=None, cfg=None):
- """传动链三部件 报警+工单, 按月窗过滤 (months=None 为全程).
- ⚠两源时间几乎不重叠: 报警仓 2025-01 起 / 工单 2020~2024-11 止 (2025-26 月表为集团模板无本场记录)
- — 选 2026 类窗工单必空, 空=数据边界非"无工单", 边界声明随节常显."""
- cfg = cfg or farm()
- st = pathlib.Path(cfg['store'])
- a = pd.read_parquet(st / 'alarms.parquet')
- w = pd.read_parquet(st / 'workorders.parquet')
- w = w[w.t_report > '2019'] # 1970 epoch 脏日期剔除
- a['month'] = a.t_on.dt.to_period('M').astype(str)
- w['month'] = w.t_report.dt.to_period('M').astype(str)
- if months:
- a = a[a.month.isin(months)]
- w = w[w.month.isin(months)]
- out = {}
- for comp, cf in DRIVETRAIN.items():
- aa = a[a.text.str.contains(cf['alarm_pat'], na=False)]
- if cf.get('alarm_excl'):
- aa = aa[~aa.text.str.contains(cf['alarm_excl'], na=False)]
- code_stats = (aa.groupby(['code', 'text']).agg(条数=('t_on', 'size'), 台数=('turbine', 'nunique'),
- 时长h=('dur_true_s', lambda x: round(float(x.sum()) / 3600, 1)))
- .reset_index().sort_values('条数', ascending=False))
- per_t = aa.groupby('turbine').size().sort_values(ascending=False)
- recent = aa.sort_values('t_on', ascending=False).head(12)
- alarm_rows = [dict(t=r.turbine, code=r.code, text=r.text, on=str(r.t_on)[:16],
- dur_min=round(float(r.dur_true_s) / 60, 1) if r.dur_true_s == r.dur_true_s else None)
- for _, r in recent.iterrows()]
- cols = ['故障位置二级', '故障位置三级', '维修对象', '故障名称']
- hit = w[cols].astype(str).apply(lambda c: c.str.contains(cf['wo_pat'], na=False)).any(axis=1)
- if cf.get('wo_extra'):
- hit = hit | w['故障位置二级'].astype(str).str.contains(cf['wo_extra'], na=False)
- if cf.get('wo_excl'):
- hit = hit & ~w[cols].astype(str).apply(lambda c: c.str.contains(cf['wo_excl'], na=False)).any(axis=1)
- ww = w[hit].sort_values('t_report', ascending=False)
- wo_rows = [dict(t=r.turbine, date=str(r.t_report)[:10], name=str(r['故障名称'])[:40],
- act=f"{r['维修动作']}·{r['维修对象']}" if pd.notna(r['维修动作']) else str(r['排查项目'])[:30],
- cause=str(r['故障原因'])[:40] if pd.notna(r['故障原因']) else '')
- for _, r in ww.head(15).iterrows()]
- cs = [dict(code=str(r['code']), text=str(r['text']), 条数=int(r['条数']), 台数=int(r['台数']),
- 时长h=float(r['时长h'])) for r in code_stats.head(8).to_dict('records')]
- out[comp] = dict(
- 报警=dict(总条数=int(len(aa)), 台数=int(aa.turbine.nunique()), 码统计=cs,
- top台={t: int(n) for t, n in per_t.head(6).items()}, 近况=alarm_rows),
- 工单=dict(总条数=int(len(ww)), 台数=int(ww.turbine.nunique()), 明细=wo_rows),
- )
- bounds = ('报警仓 2025-01-01~2026-07-15 / 工单 2020-01~2024-11-21 (2025-26 月表为集团模板无本场记录) '
- '— 两源时间几乎不重叠: 2026类窗工单空=数据边界非"无工单"; 全程窗才两源齐')
- return out, bounds
|