fusion.py 36 KB

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