| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- # -*- 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 '<p class="muted small">该测点在当前数据窗内无可用趋势点。</p>'
- 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'<li class="cnode"><i style="background:{STATE_COLOR[r[c] if c in r else "优秀"]}"></i>{esc(cn)}'
- f'<em style="--tstc:{STATE_COLOR[r[c] if c in r else "优秀"]}">{esc(r[c] if c in r else "—")}</em></li>'
- for cn, c in (('主轴 前', '主轴承前'), ('主轴 后', '主轴承后'), ('齿轮箱', '齿轮箱'), ('发电机', '发电机')))
- return (f'<li class="tnode" data-t="{esc(t)}"><div class="trow" role="button" tabindex="0">'
- f'<i class="bar" style="background:{STATE_COLOR[st]}"></i>'
- f'<b>{esc(t)}</b><span class="tst" style="--tstc:{STATE_COLOR[st]}">{esc(st)}</span></div>'
- f'<ul class="comps">{comps}</ul></li>')
- # 需关注组按严重度排 (危险>报警>不可判), 不按机组号 —— 首屏默认选中第一个, 应该是最该看的那台,
- # 而不是恰好编号最小的 (原先默认落在 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'<div class="tree"><div class="tgrp">需关注 <b>{len(attn)}</b> 台</div>'
- f'<ul>{"".join(node(r) for _, r in attn.iterrows())}</ul>'
- f'<div class="tgrp">未见异常 <b>{len(rest)}</b> 台</div>'
- f'<ul>{"".join(node(r) for _, r in rest.iterrows())}</ul></div>')
- 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 '<p class="muted small">趋势数据不可用。</p>', {}
- if q is None or not len(q):
- return '<p class="muted small">该测点在当前数据窗内无趋势点。</p>', {}
- 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 '<p class="muted small">趋势点时间戳缺失。</p>', {}
- 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'<p class="small muted">厂商阈值设定于 <b>{esc(mt)}</b>,与本数据窗不同期,仅作对照。</p>'
- elif engineer:
- note = '<p class="small muted">该测量厂商未设阈值(masks 为空),故不画阈值线;判读以机群同档中位与自身纵向变化为准。</p>'
- 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'<figure class="pg-fig"><span class="tipw" data-tip="{esc(tip)}">{svg.part_glyph(comp, seg=seg)}</span>'
- f'<figcaption>{esc(comp)}</figcaption></figure>'
- 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'<p class="small">厂商报警记录:<b>{meta["n_alarm"]}</b> 条(该测点,全数据窗)。</p>'
- b = [f'<div class="wb-panel" id="wb-{esc(t)}" hidden>',
- f'<div class="wb-head"><i class="bar" style="background:{STATE_COLOR[st]}"></i>',
- f'<h2>{esc(t)}</h2><span class="lv {STATE_CLASS.get(st, "l-none")}">{esc(st)}</span>',
- f'<a class="wb-more" href="turbines/{esc(t)}.html">逐台完整页 →</a></div>',
- f'<p class="wb-concl">{esc(concl_txt)}</p>']
- if act:
- b.append(f'<p class="wb-act"><b>建议动作</b> {esc(act)}</p>')
- b.append(f'<div class="wb-glyphs">{glyphs}</div>')
- b.append(f'<div class="wb-fig">{fig}{ev}</div>')
- if engineer:
- b.append('<details class="wb-det" open><summary>判据明细(工程师版)</summary>'
- f'<p class="small">{esc(analysis)}</p>')
- lines = detail_t.get('线') or []
- if lines:
- b.append('<table><thead><tr><th>测点</th><th>线</th><th class="num">Hz</th><th>域</th>'
- '<th class="num">绝对量</th><th class="num">×fleet</th><th>定级</th></tr></thead><tbody>' +
- ''.join(f'<tr><td>{esc(SENSOR_CN.get(r["测点"], r["测点"]))}</td><td>{esc(r["线"])}</td>'
- f'<td class="num">{esc(r["hz"])}</td><td>{esc(report_std.dom_cn(r["域"]))}</td>'
- f'<td class="num">{esc(r["绝对量"])} {esc(r["单位"])}</td><td class="num">{esc(r["xfleet"])}</td>'
- f'<td>{esc(r["定级"])}</td></tr>' for r in lines) + '</tbody></table>')
- else:
- b.append('<p class="small muted">无过闸特征线。</p>')
- if accept:
- b.append(f'<p class="small"><b>验收判据</b> {esc(accept)}</p>')
- b.append('</details>')
- b.append('</div>')
- 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 = '<a href="index.html">切到客户版 →</a>' if engineer else ''
- bar = (f'<div class="wb-bar"><span class="wb-ver">{ver}</span>{xlink}'
- f'<a href="overview.html">全场总览表 →</a></div>')
- return (f'{bar}<div class="wb"><aside class="wb-tree">{tree_html(reg)}</aside>'
- f'<section class="wb-main">{panels}</section></div>'
- f'<script>{WB_JS}</script>')
|