# -*- coding: utf-8 -*- """分析工作台首页: 左机组树 + 右主视图 (2026-08-28 用户令, 参照经典振动分析系统的形式)。 两版**分别生成两个 HTML 文件**, 不做同页 JS 切换 — 前端隐藏的内容 F12 就能看到, 那不算脱敏 (memory client-deliverable-hide-core-parameters)。 - 客户版 `index.html` : 一句话结论 + 部件图 + 一条趋势 + 建议动作。不放谱图/特征频率/阈值。 - 工程师版 `index_eng.html`: 上述 + 判据明细 (过闸线/Hz/证据族) + 报警事件 + 逐测点趋势。 形式取自经典振动系统 (Bently System 1 / SKF @ptitude / G&J TCM): 左树导航, 右主视图, 每台带严重度色条; 但**内容按客户可读性裁剪** — 客户版不放频谱/包络谱/倒谱 (用户 2026-08-28: "给客户用的, 别太复杂")。 """ import pandas as pd from . import report_std, svg, tcm, ui from .config import SENSORS, SENSOR_CN from .report import STATE_CLASS from .redact import redact_always from .ui import esc COMP_SEG = {'主轴': [('前', 'Main_bearing_front'), ('后', 'Main_bearing_rear')], '齿轮箱': [('行星', 'Gear_planet'), ('中间', 'Gear_IMS'), ('高速', 'Gear_HS_rotor_side'), ('高速', 'Gear_HS_generator_side')], '发电机': [('DE', 'Generator_DE'), ('NDE', 'Generator_NDE')]} COMP_OF = {'主轴': ('主轴承前', '主轴承后'), '齿轮箱': ('齿轮箱',), '发电机': ('发电机',)} ATTN = ('危险', '报警', '不可判') STATE_COLOR = {'优秀': 'var(--st-exc)', '良好': 'var(--st-good)', '报警': 'var(--st-warn)', '危险': 'var(--st-bad)', '不可判': 'var(--st-na)'} # 客户版主趋势: 每台只画**一条** — 挑该台最能说明问题的测点 (状态最差的那个), 客户不需要看 8 条 PRIMARY_MEAS = 'Peak' def _worst_sensor(detail_t, reg_row): """挑该台状态最差的测点作主趋势 (并列时取 SENSORS 顺序靠前的)。""" l0 = detail_t.get('L0') or {} best, best_i = None, 99 for sen in SENSORS: st = None imp = (detail_t.get('冲击') or {}).get(sen) if imp and imp.get('type') not in (None, '正常', 'INSUFFICIENT'): st = report_std.impact_state(imp) lv = [r['定级'] for r in (detail_t.get('线') or []) if r.get('测点') == sen] if lv: s2 = report_std._worst_state([report_std.line_state(x) for x in lv]) st = s2 if st is None else report_std._worst_state([st, s2]) if st is None and l0.get(sen, '').startswith('可用'): st = '优秀' if st is None: continue i = report_std.STATE_ORDER.index(st) if st in report_std.STATE_ORDER else 98 if i < best_i: best, best_i = sen, i return best or 'Main_bearing_rear' def _trend_series(c, t, sen, meas=PRIMARY_MEAS): """跨窗趋势: 逐窗中位 + 机群同档中位 (作参照带)。返回 (x 标签, 自身, fleet)。""" try: q, _ = tcm.trend(c['scalars'], c['masks'], 'CGN Rudong', t, sen, meas) except Exception: return [], [], None if q is None or not len(q): return [], [], None per = q.groupby('window').scalar_value.median() try: ref = tcm.fleet_ref(c['scalars'], sen, meas) fleet = float(ref.get('fleet_median')) if ref else None except Exception: fleet = None return list(per.index), [float(v) for v in per.values], fleet def _trend_svg(xs, ys, fleet, unit='m/s²', title=''): if not xs: return '

该测点在当前数据窗内无可用趋势点。

' hl = [(fleet, '机群同档中位', 'var(--ref-line)')] if fleet else [] return svg.line_chart([{'name': '本机逐窗中位', 'x': list(range(len(xs))), 'y': ys, 'xlabels': xs}], hlines=hl, title=title, w=620, h=210, ylabel=unit, ymin0=True) def _seg_colors(t, detail_t, comp, cell_fn): seg, tips = {}, [] for k, sen in COMP_SEG[comp]: st, _ = cell_fn(t, sen) if st in ATTN: cur = seg.get(k + '_st') if cur is None or report_std.STATE_ORDER.index(st) < report_std.STATE_ORDER.index(cur): seg[k], seg[k + '_st'] = STATE_COLOR[st], st tips.append(f'{SENSOR_CN[sen]} {st}') return {k: v for k, v in seg.items() if not k.endswith('_st')}, (';'.join(tips) or '各测点未见异常') def tree_html(reg): """左树: 需关注在前, 每台一条严重度色条。展开到部件为止 (客户不下钻测点)。""" def node(r): t, st = r['机组'], r['状态等级'] comps = ''.join( f'
  • {esc(cn)}' f'{esc(r[c] if c in r else "—")}
  • ' for cn, c in (('主轴 前', '主轴承前'), ('主轴 后', '主轴承后'), ('齿轮箱', '齿轮箱'), ('发电机', '发电机'))) return (f'
  • ' f'' f'{esc(t)}{esc(st)}
    ' f'
  • ') # 需关注组按严重度排 (危险>报警>不可判), 不按机组号 —— 首屏默认选中第一个, 应该是最该看的那台, # 而不是恰好编号最小的 (原先默认落在 WTG02"不可判/数据不足"上, 首屏第一印象是"没数据")。 _si = lambda x: report_std.STATE_ORDER.index(x) if x in report_std.STATE_ORDER else 99 attn = reg[reg['状态等级'].isin(ATTN)].copy() attn = attn.assign(_o=attn['状态等级'].map(_si)).sort_values(['_o', '机组']).drop(columns='_o') rest = reg[~reg['状态等级'].isin(ATTN)].copy() rest = rest.assign(_o=rest['状态等级'].map(_si)).sort_values(['_o', '机组']).drop(columns='_o') return (f'
    需关注 {len(attn)} 台
    ' f'' f'
    未见异常 {len(rest)} 台
    ' f'
    ') def _trend_block(c, t, sen, meas, engineer, title=''): """一条趋势: 本机逐窗中位 (时间轴) + 机群同档中位参照 + [工程师版] 厂商阈值线与报警点。 ★阈值线纪律: masks 里确有 yellow/red, 但 mask_time 多为 **2017-12-28** — 8 年前设定, 与 2024~2026 的数据不同期。画可以, **必须把设定日期标出来**, 否则读者会当成现行阈值。 Peak 等测量厂商根本没设阈值 (th 为空) ⇒ **不画假线**, 明写"厂商未设阈值"。 ★2026-09-19 修 (用户报"重算后振动产物没呈现 2026-03 数据"): 原来按**窗**聚合 (`groupby('window')` → 中位), 而本机只有一个窗(w0316 = 2026-03-16 → 2026-04-21) ⇒ 整条趋势 只画**一个点**, 时间戳还是窗内中位时刻(≈4 月上旬), 于是"3 月"在图上完全不见。实测点密度: WTG29/主轴承前/Peak 窗内 9 条记录、跨 2026-03-18 → 2026-04-16 共 9 天 —— 数据在, 是**聚合粒度** 把 3 月压没了。现在按 (窗, 日) 聚合: 单窗也画逐日序列, 3 月起止如实出现在轴上。 """ try: q, th = tcm.trend(c['scalars'], c['masks'], 'CGN Rudong', t, sen, meas) except Exception: return '

    趋势数据不可用。

    ', {} if q is None or not len(q): return '

    该测点在当前数据窗内无趋势点。

    ', {} q = q.copy() q['ts'] = pd.to_datetime(q['trigger_time'], errors='coerce') # 逐 (窗, 日) 中位 —— 不用 groupby('window'): 单窗时那只会得到一个点, 把整段区间压成一个时刻 # (2026-09-19 实逮: 3 月的数据在图上"消失"就是这个原因, 不是数据没进来) q = q.dropna(subset=['ts']) q['day'] = q['ts'].dt.floor('D') per = (q.groupby(['window', 'day'], as_index=False) .agg(ts=('ts', 'median'), v=('scalar_value', 'median')) .sort_values('ts')) if not len(per): return '

    趋势点时间戳缺失。

    ', {} series = [{'name': '本机逐日中位', 'x': list(per['ts']), 'y': [float(x) for x in per['v']]}] hl = [] try: ref = tcm.fleet_ref(c['scalars'], sen, meas) if ref and ref.get('fleet_median'): hl.append((float(ref['fleet_median']), '机群同档中位', 'var(--ref-line)')) except Exception: pass note = '' if engineer and th: mt = str(th.get('mask_time', ''))[:10] if th.get('yellow'): hl.append((float(th['yellow']), f'厂商黄线({mt})', 'var(--th-yellow)')) if th.get('red'): hl.append((float(th['red']), f'厂商红线({mt})', 'var(--th-red)')) note = f'

    厂商阈值设定于 {esc(mt)},与本数据窗不同期,仅作对照。

    ' elif engineer: note = '

    该测量厂商未设阈值(masks 为空),故不画阈值线;判读以机群同档中位与自身纵向变化为准。

    ' n_alarm = int((q.alarm_type.isin(['RedMask', 'YellowMask'])).sum()) if 'alarm_type' in q else 0 unit = 'm/s²' if meas in ('Peak', 'Rms_HP', 'iso_rms') else ('m/s' if 'Vel' in meas else '') fig = svg.line_chart(series, hlines=hl, title=title or f'{SENSOR_CN.get(sen, sen)} · {meas}', w=620, h=210, ylabel=unit, ymin0=True) return fig + note, {'n_alarm': n_alarm, 'n_win': len(per), 'first': per['ts'].iloc[0], 'last': per['ts'].iloc[-1]} def panel_html(cfg, t, reg_row, detail_t, c, cell_fn, engineer=False): """单台右主视图。客户版=结论+部件图+一条趋势+建议; 工程师版另加判据明细与报警统计。""" st = reg_row['状态等级'] try: analysis, concl, act, accept = report_std.turbine_narrative(cfg, t, detail_t, reg_row, c) except Exception as e: analysis, concl, act, accept = f'(叙述生成失败:{type(e).__name__})', '', '', '' concl_txt = concl.replace('结论:', '', 1) if concl else '—' if not engineer: # 客户版强制脱敏 (不看 WINDCMS_INTERNAL — 交付物脱敏必须是结构性的) concl_txt, act, analysis = redact_always(concl_txt), redact_always(act), redact_always(analysis) glyphs = ''.join( f'
    {svg.part_glyph(comp, seg=seg)}' f'
    {esc(comp)}
    ' for comp, (seg, tip) in ((cp, _seg_colors(t, detail_t, cp, cell_fn)) for cp in COMP_SEG)) sen = _worst_sensor(detail_t, reg_row) fig, meta = _trend_block(c, t, sen, PRIMARY_MEAS, engineer, title=f'{SENSOR_CN.get(sen, sen)} 冲击峰值 · 逐窗') ev = '' if meta.get('n_alarm'): ev = f'

    厂商报警记录:{meta["n_alarm"]} 条(该测点,全数据窗)。

    ' b = [f'') return '\n'.join(b) WB_JS = """ (function(){ var tree=document.querySelector('.wb-tree'); if(!tree) return; function show(t){ document.querySelectorAll('.wb-panel').forEach(function(p){p.hidden = (p.id!=='wb-'+t)}); tree.querySelectorAll('.tnode').forEach(function(n){n.classList.toggle('on', n.dataset.t===t)}); try{ localStorage.setItem('wb_t', t); }catch(e){} var h=document.querySelector('.wb-main'); if(h) h.scrollTop=0; } tree.addEventListener('click', function(e){ var n=e.target.closest('.tnode'); if(n) show(n.dataset.t); }); tree.addEventListener('keydown', function(e){ if(e.key!=='Enter' && e.key!==' ') return; var n=e.target.closest('.tnode'); if(n){ e.preventDefault(); show(n.dataset.t); } }); var first=tree.querySelector('.tnode'), saved=null; try{ saved=localStorage.getItem('wb_t'); }catch(e){} var start=(saved && document.getElementById('wb-'+saved)) ? saved : (first?first.dataset.t:null); if(start) show(start); })(); """ def workbench_body(cfg, reg, detail, c, cell_fn, engineer=False): """左树 + 右主视图 的完整 body。""" turbines = list(reg['机组']) panels = '\n'.join( panel_html(cfg, t, reg[reg['机组'] == t].iloc[0], detail.get(t, {}), c, cell_fn, engineer) for t in turbines) ver = ('工程师版' if engineer else '客户版') # ★单向: 工程师版可以跳客户版, **客户版不出工程师版的链接** — # 否则客户在自己那份上点一下就看到全部特征频率与判据门槛 (2026-08-28 自逮 P0)。 xlink = '切到客户版 →' if engineer else '' bar = (f'
    {ver}{xlink}' f'全场总览表 →
    ') return (f'{bar}
    ' f'
    {panels}
    ' f'')