fusion.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. # -*- coding: utf-8 -*-
  2. """振动↔SCADA 融合面 (2026-08-27 用户令: 振动分析并入 windscada 融合; windcms 独立保留).
  3. 分工边界: 谱线/波形/六闸/判据全留 windcms (m5_cms_tcm); 本模块只消费振动线 handoff 接口
  4. (结论/该信多少/盲区), 与我方四源 (温度NBM watch / 润滑柱塞 / 油样索引 / 可靠性工单) 逐台拼证据.
  5. 三条融合纪律 (振动线接口 meta 钉死, 本处执行):
  6. 1. 同一失效机制的多观测面 ≠ 多源印证 — 禁按源数加权 (29# 四观测面 = 一条机制链);
  7. 2. 盲区权重: 振动"没报警"在 known_blind_spots 覆盖的模式上不构成阴性证据
  8. (急性热失效×冲击量 / 早期×谱线层 / 轻度×排名 = 振动瞎区);
  9. 3. claim 窗对齐: 三源数据窗各异 (振动至2026-08 / SCADA至2026-07-07 / 油样至2025-08),
  10. 每条融合行必带各源窗, 窗外无数据处写"未见数"不写"正常".
  11. 融合不产判级 (P0/P1 归综合报告线; 判级只转录振动线 verdict 原文)."""
  12. from app_common.app_common_guanlan.api import paths as P
  13. import json, pathlib, re
  14. import pandas as pd
  15. from src.windscada.config import farm
  16. _DATA_ROOT = pathlib.Path(__file__).resolve().parents[3]
  17. HANDOFF = P.m5() / 'handoff_vibration_v2.json'
  18. # 油样部件位目录名变体归一 (38台手工命名不一致; 归一后与融合部件轴对齐)
  19. OIL_COMP_NORM = {
  20. '主轴前轴承': '主轴前', '主轴后轴承': '主轴后',
  21. '发电机前轴承': '发电机前', '发电机后轴承': '发电机后',
  22. '发电机轴承前': '发电机前', '发电机轴承后': '发电机后',
  23. }
  24. GAP_HANDOFF = [None] # 缺 handoff 时记一句(供上层/页面显示); 见 load_handoff 的 2026-09-17 说明
  25. def load_handoff():
  26. """振动线接口 v2。
  27. ★ 2026-09-17 改口径 (用户令"打包不随产物"之后): 原来"缺失就 raise" —— 理由正当
  28. ("融合面不可静默降级"), 但它把**整条重算链**卡死: 交付包不再随产物 ⇒ 目标机上这件
  29. handoff 必然缺, 于是 `populate`(⑦) 直接 rc=1, 后面的本体审计全不执行。
  30. 现在改成**响亮降级**: 缺件返回空结构 + 在 `GAP_HANDOFF[0]` 写明缺什么、从哪儿补,
  31. 由上层写进结果与日志(页面/审计能看见), 而不是把链打断。判级本身不造数: 缺件时融合面
  32. 的状态为"不可判", 依据写明缺件。
  33. """
  34. if not HANDOFF.exists():
  35. GAP_HANDOFF[0] = (f'融合面缺件: {HANDOFF} (属"包内无生成端"的件, docs §13.6) —— '
  36. f'★用户令 2026-09-17/18: 运行期**不从交付包补齐**, 页面如实显示"不可判"; '
  37. f'要出这件必须由研发补生成端(振动六层链 fusion 步), 或放原始件重算')
  38. return {}
  39. return json.loads(HANDOFF.read_text(encoding='utf-8')) # 中文 JSON: 缺 encoding 会按系统 locale(cp936) 读
  40. _HB = None
  41. def huabiao_analysis(t):
  42. """华标 2026-07 轮 per 台分析短语 (2026-08-28 用户令: 油液填入分析).
  43. 转录检测机构判定(NB/T 10111)+重点抽样值+note, 不产新判级; 29# 缺采响亮."""
  44. global _HB
  45. if _HB is None:
  46. f = (P.ont() / 'oil_2026H2_huabiao.json')
  47. _HB = json.loads(f.read_text(encoding='utf-8')) if f.exists() else dict(meta={}, 抽样数值=[])
  48. rows = [r for r in _HB.get('抽样数值', []) if r.get('t') == t]
  49. if t == 'WTG29':
  50. return '★本轮缺采 (29#不在17台名单) — TCM-16分支B前置(主轴后Fe/PQ vs 2025-03基线15/12)未验'
  51. if not rows:
  52. return None # 名单内无重点值台由调用方给全批小结
  53. segs = []
  54. for r in rows:
  55. v = f"{r['pos']} Fe{r['Fe']}/PQ{r['PQ']}/疲劳{r.get('疲劳磨粒', '—')}"
  56. if r.get('note'):
  57. v += f" ({r['note'][:40]})"
  58. segs.append(v)
  59. return '华标判正常(NB/T宽限); 重点值: ' + '; '.join(segs)
  60. def oil_window(cfg=None):
  61. """油样索引 → per台 最新采样日期 (报告日期口径, 取样日≈报告日−1~2周)."""
  62. cfg = cfg or farm()
  63. p = pathlib.Path(cfg['store']) / 'oil_samples_index.parquet'
  64. if not p.exists():
  65. raise FileNotFoundError(f'油样索引缺失: {p} (scripts/windscada_watch_channels_build.py 不建它; 见 fusion 模块头)')
  66. df = pd.read_parquet(p)
  67. df['comp'] = df['comp'].replace(OIL_COMP_NORM)
  68. return df
  69. def fusion_table(cfg=None, all_turbines=False):
  70. """逐台融合视图: 振动 verdict (转录) × 温度 watch × 柱塞 × 油样窗.
  71. 行 = 振动线 handoff 里有 verdict 的台; 列 = 各证据轴 + 各源 claim 窗."""
  72. cfg = cfg or farm()
  73. h = load_handoff()
  74. if not h or not (h.get('per_turbine') or {}):
  75. # 缺 handoff (交付包不随产物 ⇒ 目标机必然缺): 返回**空表但列齐 + 列头齐全**, 上层把
  76. # GAP_HANDOFF[0] 那句"缺什么/怎么补"写进结果 —— 判级不造数, 页面显示缺件 (2026-09-17)。
  77. # ★2026-09-18 补: meta 原来给的是**空 dict**, 而 fleet_view 会取 meta['振动窗']/['纪律']/…
  78. # ⇒ 上层又变成 KeyError 报给用户(实测 `/api/fleet` 的 fus 回 `{"err": "'振动窗'"}` 那一类)。
  79. # 缺件就把"缺"写进 meta 的每个键, 页面照常渲染并显示缺件原因。
  80. gap = GAP_HANDOFF[0] or (f'融合面缺件: {HANDOFF} (包内无生成端, docs §13.6) — '
  81. '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端')
  82. return (pd.DataFrame(columns=['turbine', '部件', '振动结论', '温度', '柱塞', '油样窗', '依据']),
  83. dict(数据时点='缺件', handoff版本='—', handoff日期='', 证据窗末='',
  84. 振动窗='缺件 (需 handoff_vibration_v2.json)', SCADA窗='', 油样窗='',
  85. 纪律=gap, 盲区=[], open_items=[], 色标={}, gap=gap))
  86. oil = oil_window(cfg)
  87. from . import temp_nbm
  88. wp = pathlib.Path(cfg['store']) / 'watch_channels_monthly.parquet'
  89. watch = pd.read_parquet(wp) if wp.exists() else None
  90. # 部件 → 温度通道 stem 路由 (温度轴必须与振动结论的部件同对象, 不得拿别的部件温度充数)
  91. COMP_CH = {'发电机': ('GenBe',), '齿轮箱': ('GeOilTmp', 'IMS', 'HS'), '主轴承': ('MBear',)}
  92. treg = temp_nbm.registry()
  93. tdf = treg[0] if isinstance(treg, tuple) else treg
  94. rows = []
  95. # v2.1 起 component 为显式字段 (振动线未映射的显式标性质: "引用同族/谱线级/测量链/未映射"
  96. # — 后三类不当部件级结论消费, 原文透传); 缺字段时回退子块名
  97. v1 = [(r, r.get('component') or '发电机') for r in h['per_turbine'].get('v1_generator', [])] + \
  98. [(r, r.get('component') or '齿轮箱') for r in h['per_turbine'].get('v1_gearbox', [])]
  99. new = [(r, r.get('component', '?')) for r in h['per_turbine'].get('new_2026_08_26', [])]
  100. for r, comp in v1 + new:
  101. t = r['turbine']
  102. stems = next((v for k, v in COMP_CH.items() if k in comp), ())
  103. sub = tdf[(tdf.turbine == t) & tdf.channel.str.contains('|'.join(stems))] if stems else tdf.iloc[0:0]
  104. if len(sub):
  105. top = sub.reindex(sub.dev_K.abs().sort_values(ascending=False).index).iloc[0]
  106. temp_note = f"{top.channel.replace('tmp_wtc_','').replace('_mean','')} dev{top.dev_K:+.1f}K [{top['判']}]"
  107. else:
  108. temp_note = f'未路由到{comp}温度通道'
  109. # WATCHLIST 台补月度 fleet 位次口径 — NBM 变化型对"稳定高位"失明 (29# 后主轴承 19月位次1-11
  110. # 而 NBM 判"—" 即此盲区), watchlist 台必须双口径并示
  111. wch = [c for (wt, c) in temp_nbm.WATCHLIST if wt == t and any(s in c for s in stems)]
  112. if wch and watch is not None:
  113. c = wch[0]
  114. piv = watch.pivot_table(index='month', columns='turbine', values=c)
  115. last = piv.dropna(how='all').index.max()
  116. dv = float(piv.loc[last, t] - piv.loc[last].median())
  117. rk = int(piv.loc[last].rank(ascending=False, method='average')[t])
  118. temp_note += f"; watch月度({last}): {c.split('_')[2]} dev{dv:+.1f}K 位次{rk}/38"
  119. # 油样轴: 该台最新样日期 + stale 声明
  120. to = oil[oil.turbine == t]
  121. oil_note = '无样'
  122. if len(to):
  123. last = to.date.max()
  124. if last >= '2026-07':
  125. hb = huabiao_analysis(t)
  126. oil_note = f"末样{last}: " + (hb or '华标2026-07轮全位判正常/可继续使用 (近共时可作现时判据)')
  127. else:
  128. oil_note = f"末样{last} (距振动现窗>12月, stale: 只作回测锚非现时判据)"
  129. hb29 = huabiao_analysis(t)
  130. if t == 'WTG29':
  131. oil_note += '; ' + hb29
  132. _vc, _vl = verdict_class(r.get('verdict', ''))
  133. # 图形化结构字段 (2026-08-28 用户令"能表达清楚的就图形化UI"): 前端据此画偏差条/窗时间轴/时效胶囊,
  134. # 原字符串列保留作 tooltip 与降级
  135. _tdev = _tvd = None
  136. _mt = re.search(r'([A-Za-z0-9_]+) dev([+-][\d.]+)K \[([^\]]*)\]', temp_note)
  137. if _mt:
  138. _tch, _tdev, _tvd = _mt.group(1), float(_mt.group(2)), _mt.group(3)
  139. else:
  140. _tch = None
  141. _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', '')))]
  142. _olast = to.date.max() if len(to) else None
  143. _wl = to_wcms_level
  144. rows.append(dict(
  145. turbine=t, 部件=comp,
  146. 振动结论=r.get('verdict', ''), 语义类=_vc, 级=_vl, # 判级单源: 前端禁自行正则(27#'销案(定论)'曾被染红)
  147. 设备状态=to_owner_state(r.get('verdict', '')), # 业主口径五级 (windcms report_std 映射)
  148. 证据状态=to_wcms_level(r.get('verdict', '')), # 可信度九枚举
  149. 振动信号=r.get('signal', r.get('title', ''))[:80],
  150. 温度通道=_tch, 温度dev=_tdev, 温度判=_tvd, 窗区间=_spans,
  151. 油末样=_olast, 油新鲜=bool(_olast and _olast >= '2026-07'),
  152. claim_window=r.get('claim_window', '未标'),
  153. 温度轴=temp_note, 油样轴=oil_note,
  154. ))
  155. _hm = h.get('meta', {})
  156. _asof = str(_hm.get('date', '?'))
  157. _hver = str(_hm.get('version', '?'))
  158. # 证据窗末 = 全表 claim_window 里最大的日期 (转录源的真实末端, 不写死)
  159. import re as _re2
  160. _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', '')))})
  161. _win_end = _all_d[-1] if _all_d else '?'
  162. # 补齐全场 (2026-08-28 用户令"逐台, 38台"): handoff 只列有结论的台次, 未收录台原来整行不出现
  163. # → 页面写"28 台次"而全场 38 台, 读者会以为另外 10 台不存在。未收录台按 _unlisted_cell 给出
  164. # 明确状态 (正常/数据不足·不可判/两口径差异), 不留空行也不假装有结论。
  165. if all_turbines:
  166. _have = {r['turbine'] for r in rows}
  167. for _t in cfg['turbines']:
  168. if _t in _have:
  169. continue
  170. _u = _unlisted_cell(_t)
  171. _to = oil[oil.turbine == _t]
  172. _ol = _to.date.max() if len(_to) else None
  173. rows.append(dict(
  174. turbine=_t, 部件='(振动线未收录)',
  175. 振动结论=_u['note'], 语义类='unlisted', 级=_u['level'],
  176. 设备状态=_u.get('ostate_hint') or to_owner_state(_u['note']),
  177. 证据状态=to_wcms_level(_u['note']),
  178. 振动信号='—', claim_window='—', 窗区间=[],
  179. 温度通道=None, 温度dev=None, 温度判=None, 温度轴='(未收录台不参与振动侧温度交叉)',
  180. 油末样=_ol, 油新鲜=bool(_ol and _ol >= '2026-07'),
  181. 油样轴=(f'末样{_ol}' if _ol else '无样')))
  182. rows.sort(key=lambda r: r['turbine'])
  183. meta = dict(
  184. 色标=windcms_level_color(),
  185. 数据时点=f'振动证据包 · 出具 {_asof} · 证据窗末 {_win_end}',
  186. handoff版本=_hver, handoff日期=_asof, 证据窗末=_win_end,
  187. 振动窗=f'至{_win_end[:7]} (证据包出件 {_asof})',
  188. SCADA窗='至2026-07-07', 油样窗='SGS 2024-11~2025-08 (404份/38台) + 华标2026-07 (102份/17台)',
  189. 纪律=h['meta']['reading_discipline'],
  190. 盲区=h['detectability_prior']['known_blind_spots'],
  191. open_items=h.get('open_items', []),
  192. )
  193. return pd.DataFrame(rows), meta
  194. # windcms 分级色标 (单源: src/windcms/config.LEVEL_COLOR — 2026-08-28 用户令"色标和分级体系参照 windcms";
  195. # 两套系统对同一批振动结论上色必须一致, 否则同一台在两页颜色不同 = 判级看起来不同)
  196. def windcms_level_color():
  197. try:
  198. from src.windcms.config import LEVEL_COLOR
  199. return dict(LEVEL_COLOR)
  200. except Exception:
  201. return {}
  202. # 融合层 verdict 文本 → windcms 九级中的哪一级 (转录映射, 不产新判级)
  203. WCMS_LEVEL = [
  204. # ★顺序敏感 (同一 substring 坑第 3 次): 关闭类必须最先判 — "销案(定论)" 含"定论"
  205. (('销案', '正样本', '无新证', '可降', '降观察', '无异常'), '正常'),
  206. (('撤回',), '撤回'),
  207. (('不可判', '未评估', '数据不足', 'INSUFFICIENT'), 'INSUFFICIENT'),
  208. (('定论',), '定论'), (('预警',), '准定论·预警'), (('报警',), '准定论·预警'),
  209. (('新发',), '候选·新发'), (('记基线',), '候选·记基线'),
  210. (('候选', '监视', '仲裁', '弱同向', '新证'), '候选'),
  211. (('观察', '降观察', '保留'), '参考'),
  212. (('正常',), '正常'),
  213. ]
  214. def to_owner_state(text):
  215. """振动结论文本 → windcms 业主口径状态等级 (危险/报警/良好/优秀/不可判).
  216. 2026-08-28 用户令"参照 windcms 优秀/良好/报警/危险 级别体系". windcms 有两套并行:
  217. ① 证据状态 九枚举 (定论/准定论·预警/候选…/INSUFFICIENT) = 可信度轴
  218. ② 业主口径状态等级 五级 (危险>报警>良好>优秀>不可判) = 设备状态轴
  219. 映射单源 = src/windcms/report_std.line_state (它对未知词 raise, 不静默塌缩成"优秀" —
  220. 该闸是 2026-08-24 钉的, 我们这边照用不复制词表)。
  221. 台级"不可判"另判: 数据不足/未评估的格子直接给"不可判", 不走线级映射
  222. (线级 INSUFFICIENT 在 windcms 映射成"优秀" = 单条线不足以判 ≠ 整台没数据)。"""
  223. t = str(text or '')
  224. if any(k in t for k in ('数据不足', '未评估', '不可判')):
  225. return '不可判'
  226. lv = to_wcms_level(t)
  227. if lv == '—':
  228. return '不可判'
  229. try:
  230. from app_algorithmModel.app_algorithmModel_guanlan.windcms.report_std import line_state
  231. return line_state(lv)
  232. except Exception:
  233. return {'定论': '危险', '准定论·预警': '报警', '候选·新发': '报警', '候选': '良好',
  234. '候选·记基线': '良好', '参考': '优秀', 'INSUFFICIENT': '优秀',
  235. '撤回': '优秀', '正常': '优秀'}.get(lv, '不可判')
  236. def to_wcms_level(text):
  237. """振动结论/格文案 → windcms 级名 (供 UI 取色; 找不到给 '—')."""
  238. t = str(text or '')
  239. for kws, lv in WCMS_LEVEL:
  240. if any(k in t for k in kws):
  241. return lv
  242. return '—'
  243. VERDICT_CLASSES = [
  244. # 顺序敏感: 关闭类先判 — "销案(定论)"含'定论'但语义是关闭 (2026-08-28 用户逮映射粗暴, 27#误红实锤)
  245. ('closed', ('销案', '正样本', '无新证', '可降', '降观察'), 'ok'),
  246. ('meta', ('测量链', '传感器项'), 'meta'), # 仪器问题, 不入部件状态
  247. ('alarm', ('定论', '报警'), 'bad'),
  248. ('warn', ('预警',), 'warn'),
  249. ('hold', ('保留', '仲裁输入'), 'note'), # 义务未闭, 非关闭
  250. ('watch', ('候选', '监视', '观察', '跟踪', '弱同向', '新证'), 'note'),
  251. ]
  252. def verdict_class(verdict):
  253. """振动线 verdict 文本 → (语义类, 矩阵级). 单源: taxonomy 与本模块矩阵共用.
  254. 未识别词响亮标注而非静默归类."""
  255. v = str(verdict)
  256. for cls, kws, lv in VERDICT_CLASSES:
  257. if any(k in v for k in kws):
  258. return cls, lv
  259. return 'unknown', 'note'
  260. def _vib_level(verdict):
  261. """振动 verdict 文本 → 矩阵格状态级 (转录分级, 不产判级). meta(测量链)降 note 显示."""
  262. lv = verdict_class(verdict)[1]
  263. return 'note' if lv == 'meta' else lv
  264. def _ch_cn(ch):
  265. """通道中文名 (矩阵格用短名; i18n 未收录则回退原缩写, 不静默造名)."""
  266. from src.windscada import i18n
  267. nm = i18n.ch_cn(ch, with_tag=False)
  268. if nm == ch: # 未收录 → 去前后缀后原样给
  269. return ch.replace('tmp_wtc_', '').replace('_mean', '')
  270. return nm.replace('·均值', '').replace('·最大', '')
  271. _WCMS = None
  272. def _unlisted_cell(t):
  273. """振动线未收录该台时, 说清是哪一种 (2026-08-28 用户令"说清楚问题: 数据不足以判断 还是 正常运行"):
  274. ① 报告判不可判 → 数据不足, 不可判 (监测盲区/数据缺, 不是正常)
  275. ② 报告判报警/危险 → 两口径差异! 状态管理判报警而定谳链未收录 (须对拍, 不能显示成"未收录")
  276. ③ 报告判良好/优秀 → 已评估, 定谳链无需单列 (可作"本轮无异常"读)
  277. ④ 报告也没有 → 真未评估"""
  278. g = windcms_grades().get(t)
  279. if not g:
  280. return dict(level='unlisted', ostate_hint='不可判',
  281. note='未评估 — 不等于正常')
  282. lv, dt = g['综合'], g['日期']
  283. if lv in ('不可判',):
  284. return dict(level='unlisted', ostate_hint='不可判',
  285. note='振动线未收录 · 数据不足不可判 (非正常)')
  286. if lv in ('报警', '危险'):
  287. return dict(level='warn', ostate_hint=lv,
  288. note=f'⚠两口径差异 — 评估报告 {dt} 判{lv}, 振动线未收录')
  289. # 与其他列同词 (用户: "评估无异常不就是正常吗"); 来源留浮层。
  290. # ★但等级必须照报告原级 — 原来一律写"正常"会让业主状态统一抬成"优秀",
  291. # 把报告判"良好"的台降级掉了 (2026-08-28 补全 38 台时实逮)。
  292. # ★振动列只讲振动 (2026-08-28 用户"还是和之前的振动分析结果不一致"):
  293. # 原来未收录台在"振动"列写"良好 · 评估报告 2026-08-28" — 那是设备状态评估报告的等级,
  294. # 不是振动线结论, 摆在振动列里必然被读成振动结论。逐台对拍证实: 26 台有 handoff 结论的
  295. # 全部一致, 用户看到的"不一致"全部来自这 10 台未收录台的借位显示。
  296. # 评估报告等级仍经 ostate_hint 驱动右侧设备状态徽章, 信息不丢, 只是不占振动列。
  297. return dict(level='ok', ostate_hint=lv,
  298. note='振动线未收录 (设备状态见右侧徽章)')
  299. def windcms_grades():
  300. """windcms 最新评估报告的 38 台综合等级 (用于把"未收录"说清楚: 数据不足 vs 已评估无异常).
  301. 振动线 handoff 只列有结论的台, 未列不等于正常 — 但也不等于没测; 报告等级能区分这两者。"""
  302. global _WCMS
  303. if _WCMS is not None:
  304. return _WCMS
  305. _WCMS = {}
  306. try:
  307. from src.windcms.config import cms_out # 与写侧同一解析口 (WINDCMS_OUT; 见 windcms.config.cms_out)
  308. rp = sorted(cms_out().glob('报告_CMS振动状态评估报告_*.md'))[-1]
  309. md = rp.read_text(encoding='utf-8')
  310. for l in md.split('## 附录 A')[1].splitlines():
  311. if not l.startswith('| WTG'):
  312. continue
  313. c = [x.strip() for x in l.strip('|').split('|')]
  314. if len(c) > 5:
  315. _WCMS[c[0]] = dict(综合=c[5], 日期=rp.stem.rsplit('_', 1)[-1])
  316. except Exception:
  317. pass
  318. return _WCMS
  319. def matrix(cfg=None, months=None):
  320. """§1 全场融合矩阵: 38台 × 4源 (振动/温度/润滑/油液), 每格状态+时效.
  321. months = 全局时间窗的月列表 (None=全程) — 2026-08-27 用户令"整体时间窗": 温度/柱塞格的
  322. 现窗=所选窗末月, 记忆轨迹裁剪到窗内 (趋势参考=窗内趋势); 油液格=窗内样本;
  323. 振动格 verdict 是振动线 claim 窗的转录, **不随本窗变** (证据窗固定, 列头注明).
  324. 振动格纪律: handoff 只列有结论的台 → 未列台显示"未列", **未列≠正常** (盲区见§3);
  325. 油液全场 stale (末样距今>12月) → 该列只作时效展示不作现时状态."""
  326. cfg = cfg or farm()
  327. h = load_handoff()
  328. oil = oil_window(cfg)
  329. if months:
  330. oil = oil[oil.date.str[:7].isin(months)]
  331. st = pathlib.Path(cfg['store'])
  332. from . import temp_nbm
  333. treg = temp_nbm.registry()
  334. tdf = treg[0] if isinstance(treg, tuple) else treg
  335. # 振动: 逐台聚合 handoff verdicts (一台多部件取最重级)
  336. # ★2026-09-18 修: 这里原来直接取 `h['per_turbine']` —— 而 `load_handoff()` 在缺件时返回的
  337. # 正是**空 dict**(2026-09-17 改成响亮降级, 不再 raise), 于是清过产物、重算后没有
  338. # handoff_vibration_v2.json 的机器上, `/api/fleet` 的融合面回的是 `{"err": "'per_turbine'"}`
  339. # —— 一个 KeyError 被当成"这一面坏了"显示给用户, 而不是"缺哪件"。缺件就走空表 + gap 说明。
  340. vib = {}
  341. if not h or not (h.get('per_turbine') or {}):
  342. # 缺件就走**空表 + 列头说明**(与 fusion_table 同一口径): 页面显示"缺哪件", 不显示 KeyError。
  343. gap = GAP_HANDOFF[0] or (f'融合面缺件: {HANDOFF} (包内无生成端, docs §13.6) —— '
  344. '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端')
  345. return [], dict(振动=f'缺件: {HANDOFF.name}', 温度='', 润滑='', 油液='', months=[], gap=gap)
  346. v1 = [(r, '发电机') for r in h['per_turbine'].get('v1_generator', [])] + \
  347. [(r, '齿轮箱') for r in h['per_turbine'].get('v1_gearbox', [])]
  348. new = [(r, r.get('component', '?')) for r in h['per_turbine'].get('new_2026_08_26', [])]
  349. SEV = {'bad': 3, 'warn': 2, 'note': 1, 'ok': 0}
  350. for r, comp in v1 + new:
  351. t = r['turbine']; lv = _vib_level(r.get('verdict', ''))
  352. cur = vib.get(t)
  353. if cur is None or SEV[lv] > SEV[cur['level']]:
  354. vib[t] = dict(level=lv, note=f"{comp}: {r.get('verdict','')}")
  355. elif SEV[lv] == SEV[cur['level']] and comp not in cur['note']:
  356. cur['note'] += f"; {comp}: {r.get('verdict','')}"
  357. # 润滑 (主轴承柱塞): **信息列非判据列** — 跨台单次时长天然异质 (正常月 p10~p90=22~961s,
  358. # 各台润滑设定不同, 横比不成立; 2026-08-27 实测), 且现窗6天窗全场共模拖长 (大风连转窗效应).
  359. # 单格判级只随机制链定案 (29# = 19个月纵向形态+温度/振动时序锁定, 非单月横比), 由 handoff 驱动.
  360. lubp = st / 'mblub_monthly.parquet'
  361. lub = pd.read_parquet(lubp) if lubp.exists() else None
  362. if lub is not None and months:
  363. lub = lub[lub.month.isin(months)]
  364. if not len(lub): lub = None # 窗内无柱塞数据 (如纯2024窗) → 该列"窗内无数据"
  365. lub_cell = {}
  366. if lub is not None:
  367. last_m = lub.month.max()
  368. cur = lub[lub.month == last_m].set_index('turbine')
  369. per = (cur.pis / cur.cnt.replace(0, pd.NA)).dropna()
  370. rank = per.rank(ascending=False, method='min')
  371. chain_bad = {r['turbine'] for r, _ in new if '润滑' in str(r.get('component', '')) + str(r.get('title', ''))}
  372. for t in per.index:
  373. v = float(per[t]); rk = int(rank[t])
  374. lv = 'bad' if t in chain_bad else 'note'
  375. # tag 窗无关措辞: "泵打不满"特指 2026-07 形态, 单月历史窗下挂具体形态词会时间错位
  376. tag = ' [润滑断供机制链台(v5定案), 各期形态见轨迹]' if t in chain_bad else ''
  377. lub_cell[t] = dict(level=lv, note=f'{v:.0f}s/次 第{rk}/38{tag}', hist_label='单次循环时长逐月(s)')
  378. # 温度格双口径 (振动线⑥采纳, 全台不只 WATCHLIST 台): NBM 变化口径 + watch 月度水平位次口径 —
  379. # 变化型对"稳定高位"失明 (29# 后主轴承 19月位次1-11 而 NBM 判"—"), 两口径并示互补盲区
  380. wp = pathlib.Path(cfg['store']) / 'watch_channels_monthly.parquet'
  381. watch = pd.read_parquet(wp) if wp.exists() else None
  382. if watch is not None and months:
  383. watch = watch[watch.month.isin(months)]
  384. if not len(watch): watch = None
  385. def _level_rank(ch, t):
  386. # ★窗对齐 (2026-08-28 用户逮"振动融合没核对时间窗"): 原取全局末月, 而列头声明的是所选窗末月
  387. # ⇒ 选 2025H2 时列头写"窗末月2025-12"而格内是 2026-07 的数, 声明与实际不符。
  388. # NBM 变化口径本就是固定近90日 (列头已标"不随本窗"), 但 watch 月度水平位次必须落在所选窗内。
  389. if watch is None or ch not in watch.columns: return ''
  390. piv = watch.pivot_table(index='month', columns='turbine', values=ch)
  391. piv = piv.dropna(how='all')
  392. if months:
  393. piv = piv[piv.index.isin([str(m) for m in months])]
  394. if not len(piv): return ''
  395. last = piv.index.max()
  396. row = piv.loc[last]
  397. if pd.isna(row.get(t)): return ''
  398. dv = float(row[t] - row.median())
  399. rk = int(row.rank(ascending=False, method='average')[t])
  400. return f" · 现水平 {dv:+.1f}K 第{rk}/38"
  401. # 历史记忆轨迹 (2026-08-27 用户令: 显示最新窗, 旧窗变成记忆看趋势 — 现窗快照会丢形态,
  402. # 如 29# 柱塞两段式塌→回升→翻转拖长; 每格带逐月序列, 前端画迷你趋势线现窗点高亮)
  403. def _temp_hist(ch, t):
  404. if watch is None or ch not in watch.columns: return None
  405. piv = watch.pivot_table(index='month', columns='turbine', values=ch).dropna(how='all')
  406. if months:
  407. piv = piv[piv.index.isin([str(m) for m in months])]
  408. if t not in piv.columns: return None
  409. dev = (piv[t] - piv.median(axis=1)).round(1)
  410. return [None if pd.isna(x) else float(x) for x in dev]
  411. lub_hist = {}
  412. if lub is not None:
  413. lper = lub.assign(per=(lub.pis / lub.cnt.replace(0, pd.NA))).pivot_table(index='month', columns='turbine', values='per')
  414. for t in lper.columns:
  415. lub_hist[t] = [None if pd.isna(x) else round(float(x), 1) for x in lper[t]]
  416. months_axis = sorted(watch.month.unique()) if watch is not None else []
  417. # 温度列只收传动链三部件的通道 (2026-08-28 用户令"各列只列与问题相关的部件: 主轴/发电机/齿轮箱"):
  418. # 本矩阵的行是"该台传动链状态", 拿变压器/液压油温的最大偏来填温度列 = 跨部件充数,
  419. # 与 fusion_table 早已执行的 COMP_CH 路由纪律("不得拿别的部件温度充数")一致。
  420. # 非传动链通道 (变压器/机舱/环温/液压) 在分系统页各自的轴上看, 不进本列。
  421. _DRIVE_STEM = ('MBear', 'MBearH', 'MBearG', # 主轴承前/后
  422. 'GeOilTmp', 'IMS', 'HS', 'GBox', # 齿轮箱油/中间轴/高速轴
  423. 'GenBe', 'GenIV', 'GenSt') # 发电机轴承/绕组
  424. rows = []
  425. for t in cfg['turbines']:
  426. v = vib.get(t)
  427. sub = tdf[tdf.turbine == t]
  428. sub = sub[sub.channel.str.contains('|'.join(_DRIVE_STEM), na=False)]
  429. hit = sub[sub['判'] != '—']
  430. if len(hit):
  431. top = hit.reindex(hit.dev_K.abs().sort_values(ascending=False).index).iloc[0]
  432. nm = top.channel.replace('tmp_wtc_', '').replace('_mean', '')
  433. tcell = dict(level='warn', note=f"{_ch_cn(top.channel)} {top.dev_K:+.1f}K {top['判']}" + _level_rank(top.channel, t),
  434. hist=_temp_hist(top.channel, t), hist_label=f'{nm} 月度dev轨迹')
  435. elif len(sub):
  436. top = sub.reindex(sub.dev_K.abs().sort_values(ascending=False).index).iloc[0]
  437. nm = top.channel.replace('tmp_wtc_', '').replace('_mean', '')
  438. tcell = dict(level='ok', note=f"{_ch_cn(top.channel)} {top.dev_K:+.1f}K" + _level_rank(top.channel, t),
  439. hist=_temp_hist(top.channel, t), hist_label=f'{nm} 月度dev轨迹')
  440. else:
  441. tcell = dict(level='ok', note='传动链温度通道无越档')
  442. to = oil[oil.turbine == t]
  443. if len(to):
  444. last = to.date.max()
  445. fresh = last >= '2026-07' # 华标2026-07轮台 = 有近共时样, 不再挂 stale
  446. _hbn = huabiao_analysis(t) if fresh else (huabiao_analysis(t) if t == 'WTG29' else None)
  447. ocell = dict(level=('ok' if fresh else 'stale'),
  448. note=f"{len(to)}样 末{last[:7]}" + ('' if fresh else ' [stale>12月]')
  449. + (f" · {_hbn}" if _hbn else (' · 华标2026-07全位判正常' if fresh else '')),
  450. hist_label='采样时间线')
  451. else:
  452. ocell = dict(level='stale', note='窗内无样' if months else '无样')
  453. _vc = v or _unlisted_cell(t)
  454. for _cell in (_vc, tcell, lub_cell.get(t) or {}, ocell):
  455. if isinstance(_cell, dict):
  456. _cell['wlevel'] = to_wcms_level(_cell.get('note', '')) # 证据状态轴 (可信度)
  457. _cell['ostate'] = _cell.get('ostate_hint') or to_owner_state(_cell.get('note', '')) # 业主口径状态轴
  458. rows.append(dict(
  459. turbine=t,
  460. 振动=_vc,
  461. 温度=tcell, 润滑={**lub_cell.get(t, dict(level='note', note='无仓')), 'hist': lub_hist.get(t)}, 油液=ocell,
  462. ))
  463. heads = dict(振动='证据窗固定, 不随本窗 (至2026-08)',
  464. 温度=f"窗末月{watch.month.max() if watch is not None else '?'} (变化口径=同型机群比对·近90日固定)",
  465. 润滑=f"窗末月{lub.month.max() if lub is not None else '无数据'}",
  466. 油液='窗内样本 (华标2026-07轮17台=近共时; 其余台末样2025-08 stale)')
  467. heads['months'] = months_axis
  468. return rows, heads
  469. RENDERED_DP_KEYS = {'purpose', 'classes', 'meas_crossing', 'known_blind_spots', '端到端闭环'}
  470. def capability():
  471. """§3 振动能力边界: **全量透传 + 白名单渲染** (2026-08-27 双线约定, 替代白名单透传 —
  472. v2.2 "端到端闭环"新键曾静默漏消费, 教训='读同路径自动吃到'只对既有字段成立).
  473. 生产方新增顶层键 → unrendered 自动列出提示接线, 不再依赖交接消息记得说.
  474. ★2026-09-18 修 (同 matrix 的教训): 这里原来直接取 `h['detectability_prior']` —— 缺 handoff 时
  475. `load_handoff()` 返回空 dict, 于是 `KeyError: 'detectability_prior'` 被 fleet_view 包成
  476. `fus={"err": "'per_turbine'"}` 之类的**程序错**显示给人。缺件就该说"缺件"。
  477. """
  478. h = load_handoff()
  479. dp = h.get('detectability_prior') or {}
  480. if not dp:
  481. return dict(classes={}, blind=[], meas={}, purpose='', loop={}, unrendered=[],
  482. gap=GAP_HANDOFF[0] or (f'振动能力边界需 {HANDOFF.name} (包内无生成端, docs §13.6); '
  483. '本机该件不在位 ⇒ 如实为空'))
  484. return dict(classes=dp.get('classes', {}), blind=dp.get('known_blind_spots', []),
  485. meas=dp.get('meas_crossing', {}), purpose=dp.get('purpose', ''),
  486. loop=dp.get('端到端闭环', {}),
  487. unrendered=sorted(k for k in dp if k not in RENDERED_DP_KEYS))
  488. # 传动链三部件的报警/工单族过滤 (2026-08-27 用户令: 齿轮箱/发电机/主轴的报警与工单进融合板块)
  489. # 报警按 text 关键词 (码表对译后中文); 发电机族排除逆变器/通讯类 (13121 属变流器侧非发电机本体);
  490. # 主轴工单含"传动链"二级但排除滑环 (集团模板"(双馈)传动链"错标, 本场鼠笼异步无滑环属发电机励磁问题域)
  491. DRIVETRAIN = {
  492. '齿轮箱': dict(alarm_pat=r'齿轮', wo_pat=r'齿轮'),
  493. '发电机': dict(alarm_pat=r'发电机', alarm_excl=r'逆变器|通讯', wo_pat=r'发电机'),
  494. '主轴': dict(alarm_pat=r'主轴', wo_pat=r'主轴', wo_extra='传动链', wo_excl=r'滑环'),
  495. }
  496. def drivetrain_events(months=None, cfg=None):
  497. """传动链三部件 报警+工单, 按月窗过滤 (months=None 为全程).
  498. ⚠两源时间几乎不重叠: 报警仓 2025-01 起 / 工单 2020~2024-11 止 (2025-26 月表为集团模板无本场记录)
  499. — 选 2026 类窗工单必空, 空=数据边界非"无工单", 边界声明随节常显."""
  500. cfg = cfg or farm()
  501. st = pathlib.Path(cfg['store'])
  502. a = pd.read_parquet(st / 'alarms.parquet')
  503. w = pd.read_parquet(st / 'workorders.parquet')
  504. w = w[w.t_report > '2019'] # 1970 epoch 脏日期剔除
  505. a['month'] = a.t_on.dt.to_period('M').astype(str)
  506. w['month'] = w.t_report.dt.to_period('M').astype(str)
  507. if months:
  508. a = a[a.month.isin(months)]
  509. w = w[w.month.isin(months)]
  510. out = {}
  511. for comp, cf in DRIVETRAIN.items():
  512. aa = a[a.text.str.contains(cf['alarm_pat'], na=False)]
  513. if cf.get('alarm_excl'):
  514. aa = aa[~aa.text.str.contains(cf['alarm_excl'], na=False)]
  515. code_stats = (aa.groupby(['code', 'text']).agg(条数=('t_on', 'size'), 台数=('turbine', 'nunique'),
  516. 时长h=('dur_true_s', lambda x: round(float(x.sum()) / 3600, 1)))
  517. .reset_index().sort_values('条数', ascending=False))
  518. per_t = aa.groupby('turbine').size().sort_values(ascending=False)
  519. recent = aa.sort_values('t_on', ascending=False).head(12)
  520. alarm_rows = [dict(t=r.turbine, code=r.code, text=r.text, on=str(r.t_on)[:16],
  521. dur_min=round(float(r.dur_true_s) / 60, 1) if r.dur_true_s == r.dur_true_s else None)
  522. for _, r in recent.iterrows()]
  523. cols = ['故障位置二级', '故障位置三级', '维修对象', '故障名称']
  524. hit = w[cols].astype(str).apply(lambda c: c.str.contains(cf['wo_pat'], na=False)).any(axis=1)
  525. if cf.get('wo_extra'):
  526. hit = hit | w['故障位置二级'].astype(str).str.contains(cf['wo_extra'], na=False)
  527. if cf.get('wo_excl'):
  528. hit = hit & ~w[cols].astype(str).apply(lambda c: c.str.contains(cf['wo_excl'], na=False)).any(axis=1)
  529. ww = w[hit].sort_values('t_report', ascending=False)
  530. wo_rows = [dict(t=r.turbine, date=str(r.t_report)[:10], name=str(r['故障名称'])[:40],
  531. act=f"{r['维修动作']}·{r['维修对象']}" if pd.notna(r['维修动作']) else str(r['排查项目'])[:30],
  532. cause=str(r['故障原因'])[:40] if pd.notna(r['故障原因']) else '')
  533. for _, r in ww.head(15).iterrows()]
  534. cs = [dict(code=str(r['code']), text=str(r['text']), 条数=int(r['条数']), 台数=int(r['台数']),
  535. 时长h=float(r['时长h'])) for r in code_stats.head(8).to_dict('records')]
  536. out[comp] = dict(
  537. 报警=dict(总条数=int(len(aa)), 台数=int(aa.turbine.nunique()), 码统计=cs,
  538. top台={t: int(n) for t, n in per_t.head(6).items()}, 近况=alarm_rows),
  539. 工单=dict(总条数=int(len(ww)), 台数=int(ww.turbine.nunique()), 明细=wo_rows),
  540. )
  541. bounds = ('报警仓 2025-01-01~2026-07-15 / 工单 2020-01~2024-11-21 (2025-26 月表为集团模板无本场记录) '
  542. '— 两源时间几乎不重叠: 2026类窗工单空=数据边界非"无工单"; 全程窗才两源齐')
  543. return out, bounds