// 设备状态色 = 观澜语义色单源 (2026-08-28 用户裁: 观澜色块体系更好, 保留; windcms 那边改过来)
// 对照 windcms 现值见 design.py 的 --st-* (迁移期保留, 供振动线对齐用)
const C={'优秀':'var(--ok)','良好':'var(--info)','报警':'var(--warn)','危险':'var(--bad)','不可判':'var(--unknown)','监测不可用':'var(--unknown)','无监测':'var(--unknown)'};
// 传动链运动简图 (2026-08-28 用户令"纯图形": 弃方块标签, 用工程简图语言 —
// 轴=横线 · 齿轮=竖矩形(啮合处相切) · 轴承=剖面符号 · 行星级=齿圈+行星架 · 发电机=定子/转子;
// 只有命中部件着色并标名, 其余全灰 = 一眼看出"链条哪一环出问题")
const baBar=(pre,post,col)=>{
const M=Math.max(pre,post,7), W=120, sc2=v=>Math.max(2,v/M*(W-4));
return ``};
const esc=t=>String(t==null?'':t).replace(/&/g,'&').replace(/"/g,'"').replace(/{
if(dev==null||dev===undefined) return `${esc(raw).slice(0,30)}`;
const M=6, W=104, mid=W/2, k=Math.max(-1,Math.min(1,dev/M)), bw=Math.abs(k)*(mid-8);
// 只有真越档(|dev|>=3K)才上语义色, 其余淡灰 — 避免整列彩条抢注意力
const strong = Math.abs(dev) >= 3;
const col = !strong ? 'var(--muted)' : (/候选/.test(vd||'')?'var(--warn)':/记基线/.test(vd||'')?'var(--info)':/不可判|返服/.test(vd||'')?'var(--unknown)':'var(--muted)');
const over=Math.abs(dev)>M;
return `
${dev>0?'+':''}${dev}K
${esc(ch||'')}
`};
// 证据窗时间条: 固定 2026-01→09 轴, 块=claim 窗覆盖段 (跨行可比, 一眼看窗对不对得上)
const winBar=(spans,raw)=>{
if(!spans||!spans.length) return `未标时间窗`;
const T0=Date.parse('2026-01-01'), T1=Date.parse('2026-09-01'), W=118;
const px=d=>{const v=Date.parse(d); return 3+Math.max(0,Math.min(1,(v-T0)/(T1-T0)))*(W-6)};
const blocks=spans.map(([a,b])=>{const x1=px(a), x2=px(b);
return ``}).join('');
return `
${spans.length} 个窗段
`};
// 油液时效胶囊: 绿=近共时可作现时判据 / 灰=stale 只作回测锚
const oilPill=(last,fresh,raw)=>`
${last||'无样'}
${last?(fresh?'近共时':'stale >12 个月'):'—'}
`;
// 术语去黑话 (2026-08-28 用户令"翻译·审核黑话"): 内部判级词/口径缩写 → 现场话, 括注保留原词以便对账
const JARGON=[[/正样本/g,'健康对照台'],[/可降(?![级])/g,'可降级观察'],[/记基线观察/g,'记基线·下轮复核'],
[/CLAIM窗|claim窗/g,'证据窗'],[/位次([0-9]+)[/]([0-9]+)/g,'全场第$1名(共$2台)'],[/未列 [((]≠正常[^))]*[))]/g,'未收录 (≠已排除异常)'],
[/温度候选[(]定向查[)]/g,'温度偏高·待定向排查'],[/最大偏/g,'偏差最大通道'],[/单次([0-9]+)s/g,'单次循环 $1 秒'],
[/时间窗内无样/g,'本时间窗未取样'],[/仲裁输入/g,'待仲裁输入'],[/无新证/g,'本轮无新证据'],[/降观察/g,'降为观察'],
[/弱同向新证/g,'弱同向新证据'],[/传感器项/g,'测量链(仪器)问题'],[/份样/g,' 份油样'],[/NBM/g,'基线模型(NBM)']];
// 表述规范单源 = windcms agent.py 的业主口径注解 (2026-08-28 用户令: 等级评价和表述一定要参照 windcms)。
// 规范原文: 状态等级打头(优秀=未见异常保持常规监视 / 良好=轻微偏离记录观察 / 报警=存在需现场核实或处置的异常 /
// 危险=异常严重建议尽快停机检查 / 不可判=数据不足或监测中断); 证据状态放括号里说可信度
// (候选=待现场核实 / 准定论·预警=基本确认 / 候选·新发=新出现待核实 / 候选·记基线=已记为基线下窗观察 /
// 参考=仅记录 / INSUFFICIENT=数据不足 / 撤回=已取消)。
// ★不自造措辞: 之前我译的"待现场核实/观察即可"虽通顺但偏离了 windcms 词表, 已撤。
const ST_NOTE = {'优秀':'未见异常, 保持常规监视', '良好':'轻微偏离, 记录观察',
'报警':'存在需现场核实或处置的异常', '危险':'异常严重, 建议尽快停机检查', '不可判':'数据不足或监测中断'};
const EV_NOTE = {'候选':'待现场核实', '准定论·预警':'基本确认', '候选·新发':'新出现待核实',
'候选·记基线':'已记为基线, 下窗观察', '参考':'仅记录', 'INSUFFICIENT':'数据不足',
'定论':'已定案', '正常':'未见异常', '撤回':'已取消'};
const plain = t => String(t || ''); // 判级词一律用 windcms 原词, 注解走 ST_NOTE/EV_NOTE
// 振动技术速记 → 人话 (2026-08-28 用户令: 黑话改掉)。只翻术语不动数字, 原文进悬停。
// 顺序敏感: 长词先于短词 (DE/NDE 要先于 DE), 否则会被短词切碎。
const VIBJARGON = [
[/DE[/]NDE/g, '驱动端与非驱动端'], [/GenDE/g, '发电机驱动端'], [/GenNDE/g, '发电机非驱动端'],
[/HS_gen2?/g, '高速轴发电机侧'], [/HS_rot/g, '高速轴转子侧'], [/IMS/g, '中间轴'],
[/(? { let x = String(t || ''); VIBJARGON.forEach(([a, b]) => x = x.replace(a, b)); return x; };
const zh=t=>{let x=String(t==null?'':t); JARGON.forEach(([a,b])=>x=x.replace(a,b)); return x};
// 族分布堆叠条 (报警列)
// 族分布条 (2026-08-28 用户逮"故障多少长度都一样"):
// 条总长 = 该台条数/全场最大 → 跨行可比 (原来段宽按台内占比算, 总和恒 100% 所以每行一样长, 量的信息全丢);
// 段内比例 = 族构成。总长封顶 118px, 最短 6px 保证可见。
const famBar=(fam,n,maxN)=>{
if(!n) return `时间窗内无报警`;
const CO={'齿轮箱':'#8A6D3B','发电机':'#4A6FA5','主轴承':'#2E7D6B','变桨叶片':'#4A6FA5','偏航':'#2E7D6B','测风':'#7C5295','其他':'#5A6470'};
const W=118, w=Math.max(6, Math.round(n/Math.max(maxN||n,1)*W));
return ``
+fam.map(f=>``).join('')
+`
${fam.slice(0,2).map(f=>f.k+' '+f.n).join(' · ')}
`};
const PART2SYS=p=>/主轴承|主轴/.test(p)?'主轴承':/齿轮|行星|中间|中速|高速/.test(p)?'齿轮箱':/发电机/.test(p)?'发电机':p;
// ────────────────────────────────────────────────────────────────────────
// 报表汇报端口 (2026-08-28 用户令: 按客户汇报管理要求做定制化报表端口, 一级显示, ontology 支持)
//
// 范式不是自创的 — 国内风电场上报集团的月度运行分析报告结构是固定的:
// 发电完成 → 设备运行 → 故障分析 → 限电情况 → 检修执行 → 重点机组 → 下期计划。
// 端口做三件事: ①选范式与周期 ②勾章节(定制化) ③选受众(决定脱敏级别)。
// "一级显示" = 顶部六个核心指标用大字直出, 明细下沉到章节; 领导只看第一屏也拿得到结论。
// 每个数字带口径与来源, 结论可回溯到本体对象 — 这是它区别于"手搓 PPT"的地方。
// ────────────────────────────────────────────────────────────────────────
// 报表画布 (2026-08-28 用户令: "像画布一样让客户选, 不是放太多信息")
//
// 改法: 从"满配 + 关掉不要的"翻成"空画布 + 挑要的"。前者默认给一大堆再让人做减法,
// 客户看到的是负担; 后者每加一块都是一次主动选择, 报告长度天然由客户控制。
// 组件粒度也从"章"降到"块" — 一张图/一张表就是一块, 挑的时候看得见自己在放什么。
// 范式(月报/周报/专题/年度) 退化为**预设组合**: 点一下把一组块填进画布, 之后照样能加减调序。
// 组件按主题分组 (2026-08-28 用户"交互界面设计一下"): 15 块平铺成两行时,
// 客户要在一堆同质按钮里找, 且原生 title 弹出会遮住旁边两个按钮 (截图逮)。
// 改为按报告的叙事顺序分五组, 说明走固定说明区不浮动。
const RPT_GROUPS = [['发电', 'gen'], ['可靠性', 'rel'], ['故障', 'flt'], ['机组', 'unit'], ['收尾', 'end']];
const RPT_BLOCKS = {
kpi: {g:'gen', nm:'一级指标', ic:'▦', d:'六个核心数: 上网电量 / 等效满发 / 可用率 / 损失率 / 停机间隔 / 需行动台'},
water: {g:'gen', nm:'能量瀑布', ic:'▤', d:'理论可发 → 逐段扣损 → 实际上网, 用于定位电量流向'},
emon: {g:'gen', nm:'逐月发电表', ic:'▥', d:'逐月上网 / 损失 / 损失率 / 等效满发, 含数据覆盖率'},
blame: {g:'gen', nm:'损失责任分账', ic:'◧', d:'交易调度侧 / 设备侧 / 运行侧 三段占比 — 回答"谁的责任"'},
ram: {g:'rel', nm:'可靠性象限', ic:'◈', d:'平均无故障间隔(MTBF)× 单次停机时长(MDT)部件象限, 分出"频发易修"与"罕发难修", 处置方向相反'},
parts: {g:'rel', nm:'部件停机表', ic:'▤', d:'逐部件停机时长 / 事件数 / 平均无故障间隔(MTBF)/ 单次停机时长(MDT)/ 影响机组数'},
attrib: {g:'rel', nm:'归因覆盖', ic:'▬', d:'已归因 vs 无记录停机的占比 — 无记录不等于没有故障'},
pareto: {g:'flt', nm:'故障帕累托', ic:'▮', d:'按条数降序 + 累计 80% 线, 找关键少数'},
quad: {g:'flt', nm:'故障四象限', ic:'◇', d:'条数 × 单次时长, 区分重复触发与实际停机 — 按条数排序会误定优先级'},
frate: {g:'flt', nm:'月度故障率', ic:'▪', d:'按天归一的日均条数, 数据不满月单独标注'},
units: {g:'unit', nm:'重点机组', ic:'☰', d:'需跟踪台的部件 / 证据源 / 六步进度 / 卡在哪一步'},
chain: {g:'unit', nm:'决策链进度', ic:'⛓', d:'证据→机制→判级→排期→动作→验收 六步的瓶颈分布'},
loop: {g:'unit', nm:'闭环实证', ic:'✓', d:'检出 → 换件 → 复测回落的历史例证, 证明这条链是通的'},
plan: {g:'end', nm:'下期建议', ic:'✎', d:'由数据推出的行动项, 不是套话'},
caliber:{g:'end', nm:'口径与边界', ic:'⚑', d:'统计期 / 残月 / 工单窗 / 判级性质 — 建议常驻, 没有边界的数字堆不能作汇报依据'},
narr: {g:'end', nm:'运行总结', ic:'✍', d:'本地模型基于实测数据生成的运行总结 — 为图表补充文字结论, 供汇报使用'},
};
const RPT_PRESETS = {
month: {nm:'月度运行分析', blocks:['kpi','water','emon','blame','ram','pareto','units','plan','caliber']},
week: {nm:'周报', blocks:['kpi','frate','units','plan','caliber']},
topic: {nm:'设备专题', blocks:['units','chain','ram','loop','caliber']},
year: {nm:'年度总结', blocks:['kpi','water','emon','ram','loop','caliber']},
blank: {nm:'空白画布', blocks:[]},
};
function rptInit(){
if(!window.RPTAUD) window.RPTAUD='in';
// 默认空画布 (2026-08-28 用户令"都去掉, 只剩选项栏"): 打开只有选项栏, 一块都不预填。
// 起手给几块仍是替客户做决定 — 画布的意思就是空着等人放东西。
if(!window.RPTCANVAS) window.RPTCANVAS=[];
}
// 报告定制交互 (2026-08-28 用户令: 留一个互动交互窗, 报告按定制, 离线模型支持)。
// 模型做两件事: 按需求挑组件 + 据实测摘要写总结。数字全部来自后端摘要, 生成后过接地闸;
// 模型自己算的派生值(如占比求和)会被标出来让人核 — 算得对也要标, 因为核不了的数字不能进报告。
// ★下拉原来写死两个**本地**模型 (qwen3:8b / qwen3.8:27b), 而云端部署上一个都没配;
// /api/rpt_compose 在云端的问答微服务里也不存在 (只有 ask / ask_status / ask_models)。
// 于是这个按钮在生产环境是死的: 点了拿回 404 的 {"err":"not found"}, 显示成 "not found"。
// 改为按**实际可用**填充: 有编排接口才启用, 模型清单与问答助手同源 (/api/ask_models)。
// 交付件不该摆一个点了就报错的按钮。
// ★不能叫 rptInit —— 上面 L1528 已经有一个同名函数 (初始化 RPTAUD / RPTCANVAS)。
// 函数声明后者覆盖前者, 于是 RPTAUD 永远初始化不了, 渲染直接 ReferenceError,
// 整个汇报定制面板消失。命名撞车只在运行时才炸, 语法闸看不见。
// ★模型下拉必须按**实际配了密钥的**填, 不能把 MODEL_SPEC 全列出来 ——
// MODEL_SPEC 是"档位说明表"(含本地档), 不是"可用清单"。全列出来并默认选第一个
// (qwen3:8b, 本地档) 的后果: 用户一提问就报「模型 qwen3:8b 未配置密钥」。
// 云主机 2 核 1GB, 本地模型本来就跑不起来。可用清单唯一来源 = /api/ask_models。
async function fillModels(selId){
const sel=document.getElementById(selId); if(!sel) return {};
let models={};
try{ models=(await (await fetch('/api/ask_models')).json()).models||{} }catch(e){}
const ks=Object.keys(models);
sel.innerHTML = ks.length
? ks.map(k=>{const v=MODEL_SPEC[k]||{}; return ``}).join('')
: '';
return models;
}
async function askModelInit(){
await fillModels('askm');
if(typeof mdlNote==='function') mdlNote();
}
async function rptModelInit(){
const sel=document.getElementById('rptmdl'), btn=document.getElementById('rptgo'),
out=document.getElementById('rptout');
if(!sel) return;
let hasCompose=false;
const models=await fillModels('rptmdl');
// 编排已移植上云 (2026-09-01), 故改回**真探测** —— 拦截器已把 rpt_compose 放行到
// 真网络, 探得到 200 就启用, 离线打开探不到就禁用并说明。
try{ const r=await fetch('/api/rpt_compose?probe=1'); hasCompose = !!(r&&r.ok) }catch(e){ hasCompose=false }
const ks=Object.keys(models);
// ★这句原文是「本地模型…数据不出本地」—— 选 DeepSeek 时数据当然出本地。
// 与徽章同一判据: 按选中档位的 loc 给话, 不写死。
const hint=document.getElementById('rpthint');
const syncHint=()=>{ if(!hint) return;
const v=MODEL_SPEC[sel.value]||{};
hint.innerHTML='模型依据需求选取组件, 并基于实测数据生成运行总结'
+ (v.loc==='本地' ? '; 数据不出本地'
: '; 内容会外发到模型厂商'); };
sel.addEventListener('change', syncHint); syncHint();
if(!hasCompose||!ks.length){
if(btn){ btn.disabled=true; btn.style.opacity='.5'; btn.style.cursor='not-allowed' }
if(out) out.innerHTML = !ks.length ? '本部署未配置任何模型密钥, 编排不可用'
: '本部署未启用编排接口, 画布仍可手动拼装';
}
}
async function rptCompose(){
const need=(document.getElementById('rptneed')||{}).value||'';
const mdl=(document.getElementById('rptmdl')||{}).value||'qwen3:8b';
const out=document.getElementById('rptout');
if(!need.trim()){ out.innerHTML='先输入报告要求'; return }
const _sp=MODEL_SPEC[mdl]||{}, _loc=(_sp.loc==='本地');
out.innerHTML='模型编排中… 挑组件 + 写总结'
+ (_loc ? ', 全程零外发' : ', 内容会外发到模型厂商');
let d;
try{ d=await (await fetch(`/api/rpt_compose?need=${encodeURIComponent(need)}&canvas=${encodeURIComponent(RPTCANVAS.join(','))}&model=${encodeURIComponent(mdl)}`)).json(); }
catch(e){ out.innerHTML=`生成失败: ${e.message}`; return }
if(d.err){ out.innerHTML=`${d.err}`; return }
window.RPTNARR={text:d.narrative, model:d.model, ungrounded:d.ungrounded||[]};
const bl=(d.blocks||[]).filter(b=>RPT_BLOCKS[b]);
if(bl.length) window.RPTCANVAS = (d.narrative? ['narr'] : []).concat(bl);
else if(d.narrative && !RPTCANVAS.includes('narr')) RPTCANVAS.unshift('narr');
out.innerHTML=`已按需求编排 ${bl.length} 块${d.narrative?' + 一段运行总结':''} ·
${d.model} 用时 ${d.secs}s${(d.ungrounded||[]).length?` · ${d.ungrounded.length} 个数字待核`:' · 数字全部接地'}`;
render();
setTimeout(()=>{const el=document.getElementById('rptout'); if(el&&!el.innerHTML) el.innerHTML='';},50);
}
function rptExport(fmt){
// 导出走后端生成真 .docx/.xlsx: 图表块转成对应数据表, 信息不丢且可核对。
// 上报版沿用界面同一套脱敏规则, 不需要导出后再手工处理。
if(!RPTCANVAS.length){ alert('画布是空的 — 先从组件库选内容'); return }
const n=(window.RPTNARR&&RPTNARR.text)?RPTNARR.text:'';
const win=(window.D&&D.win)||'2026年';
const url=`/api/rpt_export?fmt=${fmt}&blocks=${encodeURIComponent(RPTCANVAS.join(','))}`
+`&aud=${RPTAUD}&win=${encodeURIComponent(win)}&narr=${encodeURIComponent(n.slice(0,4000))}`;
const a=document.createElement('a'); a.href=url; a.download=''; document.body.appendChild(a); a.click(); a.remove();
}
function rptHint(k){
// 固定说明区而非浮动 tooltip: 原生 title 弹出会盖住相邻按钮 (用户截图逮),
// 且鼠标一移就消失, 读不完。放在组件库下方定位不动。
const el=document.getElementById('rpthint'); if(!el) return;
const v=k?RPT_BLOCKS[k]:null;
el.innerHTML = v ? `${v.ic} ${v.nm} — ${v.d}`
: '悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序';
}
function rptPreset(k){ window.RPTCANVAS=RPT_PRESETS[k].blocks.slice(); render(); }
function rptAdd(k){ if(!RPTCANVAS.includes(k)){ RPTCANVAS.push(k); render(); } }
function rptDel(k){ const i=RPTCANVAS.indexOf(k); if(i>=0){ RPTCANVAS.splice(i,1); render(); } }
function rptMove(k,dir){
const i=RPTCANVAS.indexOf(k), j=i+dir;
if(i<0||j<0||j>=RPTCANVAS.length) return;
[RPTCANVAS[i],RPTCANVAS[j]]=[RPTCANVAS[j],RPTCANVAS[i]]; render();
}
function rptK(v,u,lab,note,cls){
return `${v} ${u}
${lab}
${note||''}
`;
}
function rptBlock(k, d){
const E=(d.control&&d.control.energy)||{}, em=E.monthly||[], R=(d.rel&&!d.rel.err)?d.rel:null,
M=(d.m8&&!d.m8.err)?d.m8.mtbf:null, F=d.faults||{}, FU=d.fus||{}, BD=(FU.链盘)||{};
const wrap=(t,b,src)=>`${t}
${b}
${src?`
口径: ${src}
`:''}
`;
if(k==='kpi'){
const last=em[em.length-1]||{}, prev=em[em.length-2]||{};
const cmpOK=(last.cov||0)>=0.5 && (prev.cov||0)>=0.5;
const dl=(a,b)=>b?((a-b)/b*100):0;
return `
${rptK((E.act||0).toLocaleString(),'MWh','上网电量',`理论可发 ${(E.theo||0).toLocaleString()} | ${cmpOK?`环比 ${dl(last.act,prev.act)>=0?'+':''}${dl(last.act,prev.act).toFixed(0)}%`:'环比不可比(末月不满)'}`)}
${rptK((E.eflh||0).toLocaleString(),'h','等效满发小时',`年化约 ${Math.round((E.eflh||0)/Math.max(em.filter(x=>x.cov>=0.5).length,1)*12).toLocaleString()} h`)}
${rptK(d.control&&d.control.avail,'%','时间可用率','不含调度令与低风待机',(d.control&&d.control.avail>=95)?'ok':'warn')}
${rptK(E.loss_pct,'%','损失率',`${(E.loss||0).toLocaleString()} MWh`,(E.loss_pct>=15)?'warn':'')}
${rptK(M?M.MTBF_h:'—','h','平均停机间隔',`停机 ${M?M.停机事件数:'—'} 起 · 单次均 ${M?M.MDT_h:'—'}h`)}
${rptK((BD.rows||[]).length,'台','需行动机组',`定论 ${(FU.kpi||{}).定论||0} · 预警 ${(FU.kpi||{}).预警||0} · 候选 ${(FU.kpi||{}).候选观察||0}`,(BD.rows||[]).length>20?'warn':'')}
`;
}
if(k==='water') return wrap('发电完成情况', waterfall(E)+
`理论可发 ${(E.theo||0).toLocaleString()} = 上网 ${(E.act||0).toLocaleString()} + 损失 ${(E.loss||0).toLocaleString()} MWh (${E.loss_pct}%)
| 量级自检: 理论 ${E.cap_ok?'低于':'≥'} 全额定上限 ${(E.cap||0).toLocaleString()} MWh ${E.cap_ok?'✔':'✗ 数字不可信'}
`,
'SCADA 10min 工况态分解; 损失=同工况理论出力与实测之差。量级自检为防虚高的下限闸, 不是风资源判据');
if(k==='emon') return wrap('逐月发电', em.length?`| 月 | 上网 MWh | 损失 MWh | 损失率 | 等效满发 h | 覆盖 |
${em.map(x=>`| ${x.m} | ${x.act.toLocaleString()} | ${x.loss.toLocaleString()} | ${x.loss_pct}% | ${x.eflh} |
${(x.cov*100).toFixed(0)}%${x.cov<0.5?' 不满月':''} |
`).join('')}
`:'—',
'覆盖率<50% 的月不参与环比');
if(k==='blame') return wrap('损失责任分账', blameBar(E)+
`进设备考核的只有"故障与检修停机"; 限电属交易与调度侧。
限电不是"低风时段收到的空令": 限电·绑定时段平均出力 1.435 MW, 正常发电时段 1.412 MW, 比值 1.02 —
风况相同而出力被压, 说明限电是真压制; 但"限电命令期·功率未被压制"那一档平均出力仅 0.724 MW(18% 额定), 确属低风时段收令。
`,
'工况态分解 + 限电命令面绑定判别; 平均出力=实发÷台时, 用作风资源代理量');
if(k==='ram') return R?wrap('可靠性 × 可维修性', ramChart(R.表)+
`设备类 ${R.设备类停机h}h / 外部类 ${R.外部类停机h}h (电网·风况·运维操作不计设备可靠性)
`,
'SCADA ≥1h 停机段, 不含调度令; 海上单次停机时长(MDT)含交通窗口'):'';
if(k==='parts') return R?wrap('部件停机', `| 部件 | 停机h | 事件 | 平均无故障间隔(MTBF,h) | 单次停机时长(MDT,h) | 影响机组 |
${R.表.slice(0,9).map(x=>`| ${x.部件}${x.外部?' (外部)':''} | ${x.停机时长h} | ${x.事件数} |
${x.MTBF_h||'—'} | ${x.MDT_h} | ${x.影响台数} |
`).join('')}
`,
'归因=段起点±30min 首发码'):'';
if(k==='attrib') return R?wrap('归因覆盖', attribBar(R.表,R.总停机h)+
`"无记录停机"不是没有故障, 是没归上因。
`,''):'';
if(k==='pareto') return wrap('故障帕累托', paretoChart(F.pareto_n||[],' 条',''), '报警事件表');
if(k==='quad') return wrap('故障模式四象限', quadChart(F.quad||[])+
`右下角报警码属重复触发, 不应按条数排定优先级。
`,'纵界=10min(一个SCADA采样格)');
if(k==='frate') return wrap('月度故障率', rateChart(F.monthly||{months:[],vals:[]})+
`柱高=日均条数, 不是月总数。
`,'不满月按天归一');
if(k==='units'){
const rows=(BD.rows||[]).slice(0,14);
return wrap('重点机组与处置', `需跟踪 ${(BD.rows||[]).length} 台 / 38 | 瓶颈 ${Object.entries(BD.stuck||{}).map(([a,b])=>`${b} 台卡在「${a}」`).join(' · ')}
| 机组 | 状态 | 部件与证据 | 进度 | 卡在 |
${rows.map(r=>`| ${r.t.replace('WTG','')}# | ${chip(r.ostate||'—')} |
${r.part&&r.part.indexOf('未收录')<0?`${r.part} · `:''}${(r.srcs||[]).join('/')||'—'}${r.rising?` · 在升 ${r.rising.ratio}×`:''} |
${r.done}/6 | ${r.stuck||'全通'} |
`).join('')}
`,
'四源融合判级');
}
if(k==='chain') return wrap('决策链进度', `六步: 证据 → 机制 → 判级 → 排期 → 动作 → 验收。
当前 ${Object.entries(BD.stuck||{}).map(([a,b])=>`${b} 台卡在「${a}」`).join(', ')}。
`,
'「动作」一列不可判 — ' + (BD.wo_bound||''));
if(k==='loop'){
const cl=BD.closed||[];
return wrap('端到端闭环实证', `| 机组 | 部件 | 换件日期 | 换前 | 换后 | 比值 |
${cl.map(x=>`| ${x.turbine.replace('WTG','')}# | ${x.component} | ${x.replace_date} |
${x.pre} | ${x.post} | ${x.ratio}× |
`).join('')}
这几台正是算法当初的检出台 ⇒ 故障前能看见、换件后能看见恢复。
`,
'振动标量前后对比');
}
if(k==='plan') return wrap('下期计划与建议', `
- 补机制定性 — ${(BD.stuck||{}).机制||0} 台停在"判了级但根因未定", 是当前最大单一瓶颈
- 回收 25~26 工单 — 台账止 2024-11, 本期检修执行与闭环无法自动判定
- 油液采样节奏 — 四源里油液长期 stale, 只能作回测锚
- 限电侧 — 损失六成在交易与调度侧, 设备侧改善空间有限
`,
'建议级 — 排期与动作须经审批');
if(k==='narr'){
const N=window.RPTNARR;
if(!N||!N.text) return wrap('运行总结','还没有生成 — 在上面的交互窗输入报告要求。
','');
return wrap('运行总结', `${N.text.replace(/')}
${(N.ungrounded||[]).length?`
⚠ 以下数值由模型自行推算, 不在原始数据摘要中, 引用前须核对: ${N.ungrounded.join(' / ')}
`:''}`,
`本地模型 ${N.model} 生成 · 数字取自系统实测摘要 · 接地闸${(N.ungrounded||[]).length?'报 '+N.ungrounded.length+' 处待核':'全通过'}`);
}
if(k==='caliber'){
const part=(F.partial||[]);
return wrap('口径与数据边界', `
- 统计期 ${d.win}${part.length?` — ${part.join('/')} 数据不满月(覆盖 ${Math.round((F.cov_min||0)*100)}%), 月度已按天归一`:''}
- 可用率与损失率分母不同 可用率分母剔除低风待机与调度令(问"该发时能不能发"),
损失率分母含全部时段(问"理论能发多少"); 同一低风时段在可用率里不计时间、在损失率里计入基数 —
这是两个指标的设计差异不是错, 但两者不可直接相乘或互推。低风待机对损失的贡献仅 118 MWh(0.5%)
- 停机 可靠性口径 ≥1h 段, 电量账为逐条工况态 — 两个数都对, 不可相减
- 工单 台账 2020~2024-11, 本报告不对本期检修执行下结论
- 判级 由振动/温度/润滑/油液四源融合得出; 行动等级由综合报告出具
${RPTAUD==='up'?'- 脱敏 上报版隐去判据门槛与特征频率
':''}
`,'');
}
return '';
}
function renderReport(d){
rptInit();
const FU=d.fus||{};
let h=`汇报定制 · 画布
`;
// ── 画布控制条: 预设 / 组件库 / 受众 / 打印 ──
h+=`
预设
${Object.entries(RPT_PRESETS).map(([k,v])=>``).join('')}
${RPTAUD==='up'?'上报版 — 判据门槛与特征频率已脱敏':''}
组件库
点击加入报告, 再次点击移出 · 画布里已有 ${RPTCANVAS.length} 块
${RPT_GROUPS.map(([gn,gk])=>`
${gn}
${Object.entries(RPT_BLOCKS).filter(([,v])=>v.g===gk).map(([k,v])=>{const on=RPTCANVAS.includes(k);
return ``}).join('')}
`).join('')}
悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序
输入报告要求
模型依据需求选取组件, 并基于实测数据生成运行总结
`;
setTimeout(rptModelInit, 60);
if(!RPTCANVAS.length)
return h+`
文档预览窗 — 从上面的组件库点选内容, 此处显示报告预览
`;
// 报表头卡撤 (2026-08-28 用户令): 场名与统计期页眉已有, 数据源列表与"结论可回溯"声明
// 属口径块的内容 — 摆在报告最上面是重复且抢戏。打印时的抬头由下面这行轻量署名承担。
// ── 文档预览窗 (2026-08-28 用户令: 生成的文档能预览才打印) ──
// 画布内容包进"一页纸": 有抬头、有边界、有页脚, 所见即将打印出来的样子。
const _asof=(FU.数据时点||'').slice(0,10);
h+=`
如东海上风电场 · 运行分析报告
统计期 ${d.win} | 38 × SWT-4.0-130${RPTAUD==='up'?' | 上报版':''}
${RPTCANVAS.length} 个章节
`;
RPTCANVAS.forEach((k,i)=>{
const v=RPT_BLOCKS[k]; if(!v) return;
// 控制条移到块**上方**独立一行 — 原来绝对定位压在卡右上角, 挡住 KPI 数字与图例 (用户截图逮)
const bb=(t,fn,dis)=>`
`;
h+=`
${bb('↑',`rptMove('${k}',-1)`,i===0)}${bb('↓',`rptMove('${k}',1)`,i===RPTCANVAS.length-1)}${bb('×',`rptDel('${k}')`,false)}
${rptBlock(k,d)}`;
});
h+=`
数据源 SCADA 10min · 报警事件 · 工单台账 · CMS 振动 · 油液化验${_asof?` | 出件 ${_asof}`:''}
结论可回溯至本体对象${RPTAUD==='up'?' | 判据门槛与特征频率已脱敏':''}
`;
return h;
}
// 决策链浮窗 (2026-08-28 用户令: 29号点开应该是决策链的具体信息的浮窗)。
// 盘上一行只给"卡在哪", 点开给这一台的六步全貌: 每步达没达、依据是什么、为什么卡住。
// 29# 另有本体链(证据清单/机制/四情景/验收树), 其余台给四源证据明细。
function popChain(t){
const BD=(D&&D.fus&&D.fus.链盘)||{}; const r=(BD.rows||[]).find(x=>x.t===t); if(!r) return;
const STC={done:'var(--ok)',open:'var(--warn)',unknown:'var(--muted)'};
const STT={done:'已达',open:'未达',unknown:'不可判'};
const num='①②③④⑤⑥';
// ★列宽按语言取。原来写死 44/52/96px, 是照**两个汉字**量的; 英文 "Verification" /
// "Not evaluable" 塞不下 ⇒ 标签被截成 "Mechanis"/"Scheduli"/"Verificati", chip 压到
// 下一列上 (用户截图实逮)。中文页取原值, 一个像素不动。
const EN=(typeof window!=='undefined'&&window.__EN), W=EN?[92,104,132]:[44,52,96];
const steps=r.steps.map((x,i)=>{
const cur=(x.k===r.stuck);
return `
${num[i]}
${x.k}
${STT[x.st]}
${x.v}
${x.d}${cur?` ← 卡在这里`:''}
`;}).join('');
// 与后端 _tbi 同口径: 多行台取最严重那行, 否则浮窗四源卡会指向另一个部件
const SEV={bad:0,warn:1,note:2,stale:3,ok:4,unlisted:5};
const F=(D&&D.fus)||{};
const frow=(F.表||[]).filter(x=>x.turbine===t).sort((a,b)=>(SEV[a.级]??9)-(SEV[b.级]??9))[0];
let mini='';
if(frow){
mini=`
振动结论${(frow.振动结论||'').slice(0,26)}
${frow.部件||''}
证据窗${winBar(frow.时间窗区间,frow.claim_window)}
温度轴 (fleet相对)${devBar(frow.温度通道,frow.温度dev,frow.温度判,frow.温度轴)}
油液时效${oilPill(frow.油末样,frow.油新鲜,frow.油样轴)}
`;
}
let ext='';
const C=(typeof ONT!=='undefined'&&ONT)?ONT.chain29:null;
if(t==='WTG29'&&C){
const ev=(C.evidence||[]).map(e=>`
${e.facet||e.id||''}
窗 ${e.claim_window||'—'}
${(e.summary||'').toString().slice(0,150)}
`).join('');
const sc=(C.scenario&&C.scenario.scenarios)||[];
ext=`
① 证据清单 · ${(C.evidence||[]).length} 条 / ${C.mechanism.independent_source_count} 个观测面
${ev}
四个观测面是同一条机制链的四个面, 不算四个独立源 — 加权时不可当多源印证。
② 机制 · ${C.mechanism.name||''}
${C.mechanism.note||''}
④ 排期沙盘 · ${sc.length} 个情景
| 情景 | 窗 | 作业级 | 停机损失 |
${sc.map(x=>`| ${x.id} | ${x['修理窗']||'—'} |
${(x['作业级']||'').slice(0,20)} |
${((x['停机损失']||{})['电费口径']||'—').slice(0,22)} |
`).join('')}
⑥ 验收 · ${C.acceptance.state||'待执行'}
${(C.acceptance.note||'').slice(0,200)}
`;
}
document.body.insertAdjacentHTML('beforeend',
`
${t.replace('WTG','')}# 决策链
${chip(r.ostate||'—')}走到 ${r.done}/6 · 卡在「${r.stuck||'全通'}」
×
从发现异常到修完验收的六步。
下一步: ${r.next||'—'}
${steps}${mini}${ext}
`);
}
async function popPart(t,part){
const sys=PART2SYS(part), win=(D&&D.win)||'2026年';
document.body.insertAdjacentHTML('beforeend',
``);
let figs=[], p0=null;
try{ const r=await fetch(`/api/problem?t=${t}&sys=${encodeURIComponent(sys)}&win=${encodeURIComponent(win)}`);
const d=await r.json(); p0=(d.problems||[])[0]; figs=(p0&&p0.figs)||[]; }catch(e){}
const F=(D&&D.fus)||{}, frow=(F.表||[]).filter(x=>x.turbine===t&&PART2SYS(x.部件)===sys)[0];
const curves=figs.filter(f=>f.kind!=='text'&&f.kind!=='vib');
const mini=[];
if(frow){
mini.push(`振动结论${frow.振动结论}
${frow.部件}
`);
mini.push(`证据窗${winBar(frow.时间窗区间,frow.claim_window)}
`);
mini.push(`温度轴 (fleet相对)${devBar(frow.温度通道,frow.温度dev,frow.温度判,frow.温度轴)}
`);
mini.push(`油液时效${oilPill(frow.油末样,frow.油新鲜,frow.油样轴)}
`);
}
if(p0) mini.unshift(`系统判级${chip(p0['状态']||'—')}
${(p0['依据']||'').slice(0,70)}
`);
const hd=document.querySelector('#popm .pop-hd span.small');
if(hd) hd.outerHTML=`${sys} · 窗 ${win}`;
document.getElementById('popbd').innerHTML=
`${dtChainStates({[part.replace(/[((].*/,'')]:LVC2(p0&&p0['状态']==='报警'?'bad':'warn')},260,null)}
${mini.join('')}
`
+(curves.length?curves.slice(0,2).map(f=>`${drawFig(f)}
`).join('')
:`该部件在本时间窗无 SCADA 曲线 (振动谱级证据走 windcms)
`)
+`完整问题页 (全部曲线与工单史) →
· 本台总览 →
`;
}
// 三个场景各自独立提交与展示 (2026-08-28 用户令"都单独放在一个输入窗")。
// 原来共用一个输入框 + 一个结果区, 切场景就把上一个结果冲掉 —
// 而实际用法是"先查报警码怎么修, 顺手看这个系统的预防任务和故障树", 三者要并存对照。
if(!window.ASSTCACHE3) window.ASSTCACHE3={};
async function asstGo(mode){
const m=mode||ASSTMODE;
const out=document.getElementById('asstOut_'+m); if(!out) return;
const el=document.getElementById('asstIn_'+m); const v=el?el.value.trim():'';
out.innerHTML=``;
const qs={fault:`kind=fault&code=${encodeURIComponent(v)}`,
prev:`kind=prev&system=${encodeURIComponent(v||'齿轮箱')}`,
tree:`kind=tree&system=${encodeURIComponent(v||'齿轮箱')}`}[m];
let d;
try{ d=await (await fetch('/api/ont_chain?'+qs)).json(); }
catch(e){ out.innerHTML=``; return }
out.innerHTML = (m==='fault' && d.data) ? faultCards(d.data)
: (m==='tree' && d.data) ? treeCards(d.data)
: ``;
window.ASSTCACHE3[m]=out.innerHTML;
}
function treeCards(x){
// 故障树两层: 失效模式(果) ← causedBy ← 部件级原因(因); 条长=实证量, 步骤=手册原文
const nodes = x.nodes || [];
if(!nodes.length) return ``;
const maxE = Math.max(1, ...nodes.map(n => n.n_alarm + n.n_wo * 10));
let h = `${x.system} 故障树 · ${nodes.length} 个失效模式
一层=系统×失效类(归纳自本场码表与工单) · 二层=手册排查方向归一+工单原因计数;
条长按实证量(报警条数 + 工单×10), 因果来自手册与工单原文非推断, 树深两层不外推。
`;
nodes.forEach(n => {
const w = Math.max(3, (n.n_alarm + n.n_wo * 10) / maxE * 100);
const col = n.n_wo >= 5 ? 'var(--bad)' : n.n_alarm >= 100 ? 'var(--warn)' : 'var(--info)';
h += `
${n.name}
本场报警 ${n.n_alarm} 条 · 工单 ${n.n_wo} 条${n.codes.length?` · 征兆码 ${n.codes.join(', ')}`:''}
`;
if(n.causes && n.causes.length){
h += `
← 可能原因 (手册排查方向 + 工单实证)
`
+ n.causes.map(c => `
${c.name}
手册 ${c.n_ref} 次 · 工单 ${c.n_wo} 次 · 涉码 ${c.n_codes}
${(c.steps||[]).map(st => `
· ${st}
`).join('')}
`).join('') + `
`;
} else {
h += `
(手册无该类排查条目 — 原因层待补)
`;
}
h += `
`;
});
return h;
}
function maintCards(md){
// 数据维护面: 两层分开 (混在一起会让"机理被数据覆盖" — 更新频率与责任方完全不同)
const rows = md.rows || [];
if(md.error) return ``;
const LAYER = [['数据','随运营滚动 — 新数据到位后跑摄入, 本体与判级自动刷新','var(--accent)'],
['机理','相对稳定 — 换厂商资料或现场反馈修正时才动; 它是判据的地基, 变更须过审','var(--warn)']];
let h = `数据维护面 · ${rows.length} 个源
每个源给出: 当前覆盖 / 末次更新 / 约定位置 / 摄入命令 / 更新频率 / 责任方。
要补数据就把新文件放到"约定位置"再跑"摄入命令"。
`;
LAYER.forEach(([lv, note, col]) => {
const rs = rows.filter(r => r.层 === lv);
if(!rs.length) return;
h += `${lv}${LAYER_SFX} (${rs.length})
${note}
| 源 | 当前覆盖 |
末次更新 | 频率 | 责任 | 补数据怎么做 |
${rs.map(r => `
| ${r.存在 ? '' : '✗ '}${r.名} |
${r.覆盖}${r.条数 ? ` (${r.条数.toLocaleString()} 条)` : ''} |
${r.末次更新 || '—'} |
${r.频率} | ${r.责任} |
放到: ${r.位置}
跑: ${r.摄入}
${r.说明} |
`).join('')}
`;
});
return h;
}
function faultCards(x){
// 抢修链结构化渲染: 码身份 → 失效模式 → 分步处置(卡片) → 历史工单 → 技术依据
const sev = x.n_site>=1000?'var(--bad)':x.n_site>=100?'var(--warn)':'var(--muted)';
let h=`
${x.system?partGlyph(x.system,'var(--accent)'):''}
${x.code} · ${x.name}
系统 ${x.system||'未归属'} · 本场 ${x.n_site} 条${x.alarm_type?` · 告警类型 ${x.alarm_type}`:''}
`;
if(x.failmodes&&x.failmodes.length)
h+=`
指向的失效模式
${x.failmodes.map(f=>`
· ${f.name} (该类本场报警 ${f.n_alarm} 条 / 工单 ${f.n_wo} 条)
`).join('')}
`;
h+=`
`;
if(x.steps&&x.steps.length){
h+=`现场处置 ${x.steps.length} 步 (${x.wi_source||''})
节点部件=手册原文元件位号(确定); 判定规范=实机参数表(2024-10-21 导出)按物理量匹配的候选判据,
非厂商绑定 — 现场以铭牌与图纸复核; 参数 ID 与告警码是不同命名空间, 未按同号关联。
`
+x.steps.map(st=>`
第 ${st.序} 步${st.类别?` · ${st.类别}`:''}${st.qty?` · 量: ${st.qty}`:''}
${st.步骤}
${(st.tags&&st.tags.length)?`
节点部件
${st.tags.map(t=>`${t}`).join('')}
`:''}
${(st.nodes&&st.nodes.length)?`
应检节点 · ${st.circuit||''} (手册此步未给位号 → 按回路构成展开, 逐点查)
| 节点 | 查什么 |
${st.nodes.map(n=>`| ${n.name} | ${n.check} |
`).join('')}
${(st.known_tags&&st.known_tags.length)?`
该系统手册出现过的位号 (对图纸时参考):
${st.known_tags.map(k=>`${k.tag}`).join('')}
`:''}
`:''}
${(st.specs&&st.specs.length)?`
判定规范 (实机参数表)
| 参数号 | 名称 | 整定值 | 允许范围 |
${st.specs.map(sp=>`| ${sp.no} | ${sp.text} |
${sp.val!=null?sp.val+(sp.unit||''):'—'} |
${sp.lo!=null?`${sp.lo} ~ ${sp.hi}`:'—'} |
`).join('')}
`:''}
${(st.drawings&&st.drawings.length)?`
对应图纸
${st.drawings.map(dw=>`
· ${dw.name}
`).join('')}
`:''}
`).join('')
+`
`;
} else {
h+=`本码无手册处置条目 (手册覆盖本场 161/322 码) — 需查图纸或联系厂商支持。
`;
}
if(x.workorders&&x.workorders.length)
h+=`现场实际怎么修的 (${x.system} 历史工单 ${x.wo_total} 条, 台账 2020~2024)
| 日期 | 机组 | 故障 | 处置 |
${x.workorders.map(w=>`| ${w.date} | ${w.turbine.replace('WTG','')}# |
${w.name} | ${w.action} ${w.part} |
`).join('')}
工单窗止 2024-11-21; 2025-26 现场未提供 ⇒ "0 条"是记录缺失非未修过。
`;
// 技术依据卡撤 (2026-08-28 用户令): 按文件名关键词匹配出来的文档与本次故障常不相关
// (液压压力偏高 → 推出"单叶片吊具培训资料"/"轮毂起吊图纸"), 列出来是噪音不是依据。
// ont_doc_search API 保留, 需要时按码精确查, 不再在抢修链里自动铺开。
return h;
}
async function ontChain(kind){
const out=document.getElementById('ocOut'); if(!out) return;
out.style.display='block'; out.textContent='查询中…';
const qs={fault:()=>`kind=fault&code=${encodeURIComponent((document.getElementById('ocCode')||{}).value||'')}`,
prev:()=>`kind=prev&system=${encodeURIComponent((document.getElementById('ocSys')||{}).value||'')}`,
tree:()=>`kind=tree&system=${encodeURIComponent((document.getElementById('ocSys')||{}).value||'')}`,
plan:()=>'kind=plan&top=12',
doc:()=>`kind=doc&keyword=${encodeURIComponent((document.getElementById('ocDoc')||{}).value||'')}`}[kind];
try{ const r=await fetch('/api/ont_chain?'+qs()); const d=await r.json();
out.textContent=d.text||JSON.stringify(d); }
catch(e){ out.textContent='查询失败: '+e.message }
}
function closePop(){const m=document.getElementById('popm'); if(m)m.remove()}
const LVC2=lv=>({bad:'var(--bad)',warn:'var(--warn)',ok:'var(--ok)',meta:'var(--muted)',note:'var(--info)'}[lv]||'var(--warn)');
document.addEventListener('keydown',e=>{if(e.key==='Escape')closePop()});
// 状态链条 (per 节点各自着色版; windcms 结果层用: 四部件等级一次性画在传动链上, 替代四列 chips)
const dtChainStates=(cmap,W,T2)=>{
const w=W||208, Y=20, MU='var(--muted)', PN='var(--panel)', LN='var(--line)';
const zone=(x,w2,dash)=>``;
const node=(x,key,kind)=>{
const col=cmap[key]||null, k=!!col, r=k?6.6:5;
const teeth=kind==='g'?[0,90,180,270].map(a=>{const t=a*Math.PI/180;
return ``}).join(''):'';
const core=``
+(kind==='b'?``:teeth);
return (k&&T2)?`${key} — 点看曲线与四源微视图
${core}`:core};
return ``};
// 证据强度阶梯 (确诊4格/准定论3/候选2/参考1/INSUFFICIENT 0虚框) — 文字分级换成可比的阶梯
const evLadder=t=>{
const T=String(t||''), n=/确诊/.test(T)?4:/准定论|定论/.test(T)?3:/候选/.test(T)?2:/参考/.test(T)?1:0;
const col=n>=4?'var(--bad)':n===3?'var(--warn)':n===2?'var(--info)':n===1?'var(--muted)':'var(--unknown)';
const cells=[0,1,2,3].map(i=>`=n?'stroke-dasharray="2 1.5"':''}/>`).join('');
return `
${esc(T)}
`};
// 部件微缩符号 (矩阵/列表行内用): 与解剖图同一套图形语言的 24px 版 —
// 齿轮箱=行星轮系 · 发电机=定子转子 · 主轴承=滚子剖面 · 变桨=桨叶轴承 · 偏航=齿圈驱动
function partIcon(name, col){
const c = col || 'currentColor', n = String(name || '');
const W = 30, H = 26;
// 尺寸走 inline style (全局 svg{width:100%} 会吃掉 width 属性 — 同一个坑第三次: dtSVG/台页/此处)
const wrap = body => ``;
if(/齿轮箱|行星|中间轴|中速|高速/.test(n))
return wrap(`
`);
if(/发电机|定子|转子|DE|NDE/.test(n))
return wrap(`
`);
if(/主轴承|主轴/.test(n)){
// 前后两个轴承都画出来 (2026-08-28 用户令), 命中的那个着色加粗, 另一个留灰 —
// 左=前(轮毂侧) 右=后(齿轮箱侧), 与传动链图同一朝向; 未指明前后则两个都着色
const hasF = /前/.test(n), hasR = /后/.test(n), both = !hasF && !hasR;
const brg = (x, on) => {
const st = on ? c : 'var(--muted)', w = on ? 1.7 : 1.05, op = on ? 1 : 0.55;
return `
`;
};
return ``;
}
if(/变桨|桨|叶片|叶根|轮毂/.test(n))
return wrap(`
`);
if(/偏航|解缆|扭缆|对风/.test(n))
return wrap(`
${[0,72,144,216,288].map(a=>{const r=(a-90)*Math.PI/180;
return ``}).join('')}`);
if(/变流|变频|逆变/.test(n))
return wrap(`
`);
if(/塔架|塔筒|基础|法兰/.test(n))
return wrap(`
`);
return wrap(``);
}
// 部件小图 (按部件截取: 只画命中的那一段, 不画整条链; 比八节点链条形象 — 带座体/齿形/散热筋等外形特征)
function partGlyph(name, col, solid){
// solid=true 仅用于报警/危险级 — 良好/优秀级用描边不填充, 否则密集表格里满屏深色块
// (2026-08-28 用户逮'满屏黑化'; 对应 skill 原则: boldness 只花在一处, 其余安静)
const c = col || 'currentColor', n = String(name || ''), MU = 'var(--muted)';
const svg = (w, body) => ``;
if(/主轴承|主轴/.test(n)){
const F = /前/.test(n), R = /后/.test(n), both = !F && !R;
// 带座体的轴承: 座体梯形 + 外圈 + 滚子 + 内圈, 轴穿过
const unit = (x, on) => {
const st = on ? c : MU, w = on ? 1.7 : 1, op = on ? 1 : 0.5;
return `
`;
};
return svg(96, `
${unit(8, F||both)}${unit(56, R||both)}
前
后`);
}
if(/齿轮箱|行星|中间轴|中速|高速/.test(n)){
const P = /行星/.test(n), M = /中间|中速/.test(n), H = /高速/.test(n), all = !P && !M && !H;
const gear = (cx, cy, r, teeth, on) => {
const st = on ? c : MU, sw = on ? 1.6 : 1, op = on ? 1 : 0.45;
let t = '';
for(let i=0;i`}
return `${t}
`;
};
return svg(104, `
${gear(22, 21, 9, 12, P||all)}${gear(22, 21, 3.4, 0, P||all)}
${gear(55, 18, 7.5, 10, M||all)}${gear(80, 25, 5.5, 8, H||all)}
`);
}
if(/发电机|定子|转子|DE|NDE/.test(n)){
const D = /(? `
`;
return svg(96, `
${[26,34,42,50,58,66].map(x=>``).join('')}
${end(8, D||all)}${end(79, ND||all)}
DE
NDE`);
}
if(/变桨|桨|叶片|叶根|轮毂/.test(n))
return svg(80, `
${[0,120,240].map(a=>{const r=(a-90)*Math.PI/180;
return ``}).join('')}
`);
if(/偏航|解缆|扭缆|对风/.test(n))
return svg(80, `
${Array.from({length:14},(_,i)=>{const a=i*2*Math.PI/14;
return ``}).join('')}
`);
return svg(60, ``);
}
// ══ 部件解剖图 (2026-08-28 用户令"部件UI图像化认真对待") ══
// 原则: 画机制不画名字 — 每张图展示该系统的介质/动力如何流经部件, 箭头带口径标注,
// 命中部件按状态着色; 一图一论点。与传动链简图分工: 传动链=链上哪一环, 解剖图=环内怎么坏的。
const ANAT_C = st => ({'报警':'var(--bad)','危险':'var(--bad)','预警':'var(--warn)','良好':'var(--info)',
'优秀':'var(--ok)','不可判':'var(--unknown)'}[st] || 'var(--muted)');
function anatMainBearing(hit, states){
// 主轴承集中润滑链: 泵 →(管路)→ 分配器 →(柱塞往复=出脂计量)→ 前/后轴承
// 画的是"脂怎么到轴承", 29# 的失效点在泵本体 (柱塞单次时长拖长=打不满)
const c = k => ANAT_C((states||{})[k]);
const on = k => (hit||'').includes(k);
const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
return `
脂路: 泵 → 主管路 → 分配器(柱塞定量) → 前/后支路 → 轴承。因侧看柱塞行为, 果侧看轴承温度与冲击 —
两侧不是两个独立源, 是同一条机制链的两端。`;
}
function anatGearbox(hit, states){
// 齿轮箱: 一级行星 + 两级平行轴 + 润滑/冷却回路 (油从箱底经泵/滤/冷却器回喷淋)
const c = k => ANAT_C((states||{})[k]);
const on = k => (hit||'').includes(k);
const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
return `
传动: 行星级(输入) → 中间轴 → 高速轴(输出)。油路: 箱底 → 泵 → 滤芯 → 冷却器 → 喷淋回箱 —
过热类失效沿油路查(冷却/滤芯), 磨损类沿齿轮线查(振动/铁谱)。`;
}
function anatPitch(hit, states){
// 变桨: 蓄能器储压 → 比例阀 → 油缸推动变桨轴承 → 桨叶; 滑环供电与信号
const c = k => ANAT_C((states||{})[k]);
const on = k => (hit||'').includes(k);
const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
return `
动力: 蓄能器储压 → 比例阀 → 油缸 → 变桨轴承 → 桨叶。压力侧失效看蓄能带宽与泄压率,
执行侧看桨距跟踪偏差, 三叶不一致指向单支路。`;
}
const ANAT = {'主轴承': anatMainBearing, '齿轮箱': anatGearbox, '变桨': anatPitch};
// 链条缩略版 (表格用: 列宽有限, 全剖面被挤成49px不可读) — 同一图形语言的 sparkline。
// 2026-08-28 用户"更清楚": 八个一样的圆点分不出段落 → 节点形状语义化 (轴承=双环 · 齿轮=带齿圆)
// + 三段分区底纹 (主轴 / 齿轮箱 / 发电机), 命中环放大填色+外发光, 仍零文字。
const dtMini=(comp,sev,W,T2)=>{
const c=String(comp||''), w=W||208;
const hit=n=>({'主轴承前':/主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/,'主轴承后':/主轴承[((]后|主轴承后/,
'行星':/行星|二级行星/,'中间':/中间轴|中速/,'高速':/高速轴|高速(?!.*发电)/,
'发电机DE':/发电机DE|发电机[((]|发电机(?!NDE)/,'发电机NDE':/发电机NDE|NDE/}[n].test(c));
const gball=/齿轮箱/.test(c)&&!/行星|中间轴|中速|高速轴/.test(c);
const on=n=>hit(n)||(gball&&['行星','中间','高速'].includes(n));
const Y=20, MU='var(--muted)', PN='var(--panel)', LN='var(--line)';
const zone=(x,w2,dash)=>``;
// 轴承节点 = 双环 (外圈+滚道); 齿轮节点 = 圆+四齿
const node=(x,n,kind)=>{
const k=on(n), col=k?sev:MU, r=k?7:5.4;
const teeth=kind==='g'?[0,90,180,270].map(a=>{const t=a*Math.PI/180;
return ``}).join(''):'';
const core=(k?``:'')
+``
+(kind==='b'?``:teeth);
return (k&&T2)?`${n} — 点看曲线与四源微视图
${core}`:core;
};
return ``};
const dtSVG=(comp,sev,W)=>{
const c=String(comp||''), w=W||660;
const hit=n=>({'主轴承前':/主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/,'主轴承后':/主轴承[((]后|主轴承后/,
'行星':/行星|二级行星/,'中间':/中间轴|中速/,'高速':/高速轴|高速(?!.*发电)/,
'发电机DE':/发电机DE|发电机[((]|发电机(?!NDE)/,'发电机NDE':/发电机NDE|NDE/}[n].test(c));
const gball=/齿轮箱/.test(c)&&!/行星|中间轴|中速|高速轴/.test(c);
const on=n=>hit(n)||(gball&&['行星','中间','高速'].includes(n));
const LN='var(--line)', MU='var(--muted)', PN='var(--panel)';
const K=660/w, FS=(11*K).toFixed(1), SW=(1.5*K).toFixed(2); // 缩放补偿: 显示字号/线宽与图宽无关
// 部件绘制器: 命中→光晕+sev描边加粗+标名; 未命中→灰描边无字 (纯图形: 只有出问题的那一环有字)
const part=(n,label,lx,ly,body,halo)=>{
const k=on(n), col=k?sev:MU;
return (k&&halo?``:'')
+`${body}`
+(k?`${label}`:'');
};
// 轴承剖面符号 (上下外圈+滚子+内圈)
const brg=(x,n,label,ly)=>part(n,label,x,ly,
``+
[-11,0,11].map(d=>``).join('')+
``,[x-24,40,48,70]);
// 齿轮 = 竖矩形 + 齿纹
const gear=(x,cy,D,n,label,ly)=>part(n,label,x+6,ly,
``+
Array.from({length:Math.max(3,Math.round(D/9))},(_,i)=>{const yy=cy-D/2+4+i*(D-8)/Math.max(2,Math.round(D/9)-1);
return ``}).join(''),[x-7,cy-D/2-7,27,D+14]);
const shaft=(x1,x2,y,th)=>``;
return ``};
function chip(s){return `${s}`}
function sc(v,lo,hi,a,b){return hi>lo? a+(b-a)*(v-lo)/(hi-lo) : (a+b)/2}
// 图幅分级 (2026-08-28 用户令"根据系统和分类要合理大小"):
// L=整行 (传动链/振动标量/功率曲线 — 需要横向空间, 但图身封顶 880/760 不随容器无限拉伸)
// M=常规格 (月度趋势/计数条 — 两张并排可比)
const FIGW = {vib: 880, vibbar: 900, xy: 760, band: 620, bar: 620, line: 620};
const isL = f => f.size === 'L' || ['vib', 'vibbar', 'xy'].includes(f.kind);
const figWrap = (f, inner, note) => `
${f.title || ''}
${inner}
${note ? `
${note}
` : ''}
`;
// 故障模式四象限 (经典可靠性分析)。x=发生条数(对数) y=单次中位时长(对数);
// 右下=高频短时(抖动/刷屏, 调参或屏蔽) 左上=低频长时(硬故障, 备件/检修)
// 右上=高频长时(重点治理) 左下=低频短时(本底)。点面积=影响台数, 分离单台刷屏与批次共性。
function quadChart(items){
const it=(items||[]).filter(x=>x&&x.n>0&&x.med>0);
if(it.length<3) return '时间窗内码数不足, 不作四象限
';
const W=720,H=330,ML=58,MR=16,MB=44,MT=16;
const lx=x=>Math.log10(x), xs=it.map(d=>lx(d.n)), ys=it.map(d=>lx(d.med));
const x0=Math.min(...xs)-0.15,x1=Math.max(...xs)+0.15,y0=Math.min(...ys)-0.15,y1=Math.max(...ys)+0.15;
const PX=v=>ML+(lx(v)-x0)/(x1-x0)*(W-ML-MR), PY=v=>H-MB-(lx(v)-y0)/(y1-y0)*(H-MT-MB);
// 分界: 条数中位 与 单次时长 600s(10min, 一个SCADA采样格) — 时长界是物理刻度不是分位数
const srt=[...it].map(d=>d.n).sort((a,b)=>a-b), nmid=srt[Math.floor(srt.length/2)];
const cx=PX(nmid), cy=PY(600);
const mt=Math.max(...it.map(d=>d.nt));
const pts=it.map(d=>{
const r=3+Math.sqrt(d.nt/mt)*7, hot=(d.n>=nmid&&d.med>=600);
return `
${d.code} ${d.k}
${d.n} 条 · 单次中位 ${d.med}s · 累计 ${d.h}h · ${d.nt} 台`;}).join('');
const lab=it.filter(d=>d.n>=nmid||d.med>=600).slice(0,8).map(d=>
`${d.code}`).join('');
const qn=(x,y,t,sub)=>`${t}
${sub}`;
const tick=v=>v>=3600?(v/3600)+'h':(v>=60?(v/60)+'min':v+'s');
const yt=[10,60,600,3600,36000].filter(v=>lx(v)>=y0&&lx(v)<=y1).map(v=>
`
${tick(v)}`).join('');
const xt=[1,10,100,1000,10000].filter(v=>lx(v)>=x0&&lx(v)<=x1).map(v=>
`${v}`).join('');
return ``;
}
// 月度故障率图 (2026-08-28 审核逮的反向误读)。
// 原图画"月度条数", 末月 1371 条看着是全时间窗最低=在改善; 但 2026-07 只有 6 天数据(覆盖 19.4%),
// 按日均归一后是 228 条/日 = 全时间窗第二高, 是 6 月(75)的 3 倍 — 方向完全相反。
// ⇒ 残月绝不能与整月并列比较。柱=日均(可比), 灰斜纹=数据不满月, 数字标总条数(原始事实)。
function rateChart(mo){
const ms=mo.months, vs=mo.vals, cov=mo.cov||ms.map(()=>1), pd=mo.per_day||[];
const W=720,H=250,ML=48,MR=14,MB=52,MT=18, n=ms.length;
const mx=Math.max(...pd.filter(x=>x!=null),1), bw=(W-ML-MR)/n;
let bars='', lab='';
ms.forEach((m,i)=>{
const v=pd[i], part=cov[i]<0.5;
if(v==null) return;
const hh=v/mx*(H-MT-MB), bx=ML+i*bw+bw*0.16, by=H-MB-hh;
bars+=`
${m}: ${v} 条/日 (总 ${vs[i]} 条, 数据覆盖 ${(cov[i]*100).toFixed(0)}%)`;
lab+=`${v}`
+`${m.slice(5)}月`
+`${vs[i]}条`
+(part?`仅${Math.round(cov[i]*30)}天`:'');
});
const yt=[0,.5,1].map(f=>`${Math.round(mx*f)}`).join('');
return ``;
}
// 能量账瀑布 (经典发电量分析)。四行工况表回答不了业主唯一关心的问题: 电去哪了。
// 左=理论可发, 中间逐段扣损(按损失降序), 右=实际上网; 段高即电量, 一眼看出谁是大头。
// 物理闸: 理论可发不得超 台数×额定×窗时长 (能量守恒, memory ledger-loss-numbers-need-physical-cap-check)。
const LOSSPLAIN = {
'限电·绑定':'限电 (命令与实际功率对得上)',
'限电命令·未绑定':'限电命令期 · 功率未被压制',
'停机(调度令)':'调度令停机',
'停机':'故障与检修停机',
'正常发电':'正常发电时的欠发',
'低风待机':'低风待机'};
function waterfall(e){
if(!e||!e.theo) return '';
const W=720,H=300,ML=8,MR=8,MT=30,MB=64, items=(e.items||[]).filter(x=>x.loss>0);
const cols=items.length+2, cw=(W-ML-MR)/cols, sc=(H-MT-MB)/e.theo;
let run=e.theo, h='', i=0;
const bar=(idx,y,hh,fill,op)=>``;
const lab=(idx,y,t,v,c)=>`${v}`
+`${t}`;
h+=bar(0,H-MB-e.theo*sc,e.theo*sc,'var(--info)',0.45)
+lab(0,H-MB-e.theo*sc,'理论可发',Math.round(e.theo).toLocaleString());
items.forEach(x=>{ i++;
const top=H-MB-run*sc, hh=x.loss*sc; run-=x.loss;
const nm=LOSSPLAIN[x.k]||x.k;
h+=bar(i,top,hh,'var(--bad)',0.55)
+``
+lab(i,top,nm.length>10?nm.slice(0,9)+'…':nm,'−'+Math.round(x.loss).toLocaleString(),'var(--bad)')
+`${nm}: 损失 ${x.loss.toLocaleString()} MWh, 占理论 ${(x.loss/e.theo*100).toFixed(1)}%, 时长 ${x.h.toLocaleString()}h`;
});
h+=bar(cols-1,H-MB-e.act*sc,e.act*sc,'var(--ok)',0.6)
+lab(cols-1,H-MB-e.act*sc,'实际上网',Math.round(e.act).toLocaleString(),'var(--ok)');
return ``;
}
// 可靠性-可维修性象限 (经典 RAM 分析, 2026-08-28 用户令: 停机事件化与可靠性可视化)。
// x=MTBF 平均无故障间隔(可靠性), y=MDT 平均单次停机时长(可维修性), 点面积=累计停机时长。
// 四象限的处置方向完全不同 — 表格按"停机时长"排序把这层结构整个抹掉了:
// 左上 常坏且难修 → 最高优先级 左下 频发易修 → 查根因(治本, 换件无用)
// 右上 罕发难修 → 备件与作业窗 右下 少发好修 → 维持
function ramChart(tb){
const it=(tb||[]).filter(x=>x.MTBF_h&&x.MDT_h);
if(it.length<3) return '部件数不足, 不作象限
';
const W=720,H=340,ML=56,MR=112,MB=48,MT=16;
const lg=v=>Math.log10(v), xs=it.map(d=>lg(d.MTBF_h)), ys=it.map(d=>d.MDT_h);
const x0=Math.min(...xs)-0.08,x1=Math.max(...xs)+0.08,y0=0,y1=Math.max(...ys)*1.12;
const PX=v=>ML+(lg(v)-x0)/(x1-x0)*(W-ML-MR), PY=v=>H-MB-(v-y0)/(y1-y0)*(H-MT-MB);
const md=a=>{const z=[...a].sort((p,q)=>p-q);return z[Math.floor(z.length/2)]};
const cx=PX(md(it.map(d=>d.MTBF_h))), cy=PY(md(ys));
const mh=Math.max(...it.map(d=>d.停机时长h));
const pts=it.map(d=>{
const r=4+Math.sqrt(d.停机时长h/mh)*13;
const ext=d.外部, hot=(d.MTBF_hv.MTBF_h))&&d.MDT_h>=md(ys));
const c=ext?'var(--muted)':(hot?'var(--bad)':'var(--accent)');
return `${d.部件}: 平均无故障间隔(MTBF)${d.MTBF_h}h · 单次停机时长(MDT)${d.MDT_h}h · 停机 ${d.停机时长h}h · ${d.事件数}起 · ${d.影响台数} 台机组${ext?' · 外部类不计设备可靠性':''}
${d.部件.slice(0,7)}`;}).join('');
const qn=(x,y,a,t,sub)=>`${t}
${sub}`;
const xt=[1000,2000,5000,10000,20000].filter(v=>lg(v)>=x0&&lg(v)<=x1).map(v=>
`
${v>=1000?(v/1000)+'k':v}`).join('');
const yt=[0,5,10,15,20].filter(v=>v<=y1).map(v=>
`${v}`).join('');
return ``;
}
// 归因覆盖率条: "无记录停机" 是最大单项却因不给 MTBF 而在可靠性排序里隐身 (规划 P1-2)
function attribBar(tb, tot){
const nore=(tb||[]).find(x=>x.部件==='无记录停机');
const unk=nore?nore.停机时长h:0, un2=(tb||[]).filter(x=>x.部件==='未归类').reduce((a,b)=>a+b.停机时长h,0);
const ok=tot-unk-un2, W=720,H=54;
const seg=(x,w,c,op)=>``;
const w1=ok/tot*W, w2=un2/tot*W, w3=unk/tot*W;
return ``;
}
// 失败必须响亮 (2026-08-28 全系统盘点逮): 后端 m8/m9/rel/fus 四块都是
// `except: X = {err: ...}`, 前端一律 `&&!d.X.err` 直接跳过渲染 —— 界面看不出"这块没算出来",
// 看起来只是"这一页没这个内容"。reliability 的 bool 位取反 bug 就是这样活下来的。
// ⇒ 出错时改为渲染一张醒目失败卡: 说清哪块没了、意味着什么缺失、错在哪。
const BLOCKDESC = {
rel: ['部件停机贡献与可靠性', '本页看不到部件级平均无故障间隔(MTBF)/单次停机时长(MDT)与停机归因'],
m8: ['停机事件化与故障季节性', '故障统计页缺停机归因与季节形态'],
m9: ['控制参数一致性', '发电性能页缺封顶分组与桨距调度差'],
fus: ['振动四源融合', '振动融合页缺逐台判级矩阵'],
};
function blockErr(key, err){
const d = BLOCKDESC[key] || [key, ''];
return `
⚠ ${d[0]} — 未算出
${d[1]}。非"本时间窗无数据", 属计算失败 — 下方数字不完整, 不要据此下结论。
${String(err).slice(0,300)} `;
}
// 逐页窗声明 (2026-08-28 审核逮的交互欺骗)。
// 顶部窗选择器对"发电性能"完全无效 — 那 19 张图是预计算产物, 窗在生成时钉死为 2025-07~2026-01;
// 用户切到"2026年"图纹丝不动却毫无提示。"控制策略"曾更糟(工况态跟随+参数件固定时间窗并列),
// 两个窗并列在同一页。窗对不齐 = claim窗≠证据窗, 是判据失效的头号来源, 必须写在脸上。
const WINSPEC = {
'发电性能': {mode:'fixed', win:'2025-07 ~ 2025-12',
why:'限电前干净窗 — 2026 起交易策略主导, 曲线与控制参数含策略性少发不可作设备判据。本页含特性曲线与控制参数一致性两件, 同窗'},
// 2026-08-28 用户裁: 控制参数件(固定时间窗)迁入发电性能, "电量算账"只留跟随时间窗内容
// → 每页只有一个窗制, 混合窗从根上消掉 (原来是本系统唯一的双窗页)
};
function winBanner(tab, curWin, extra){
const w = WINSPEC[tab];
if(!w) return `时间窗 ${curWin} · 本页全部数字随上方窗切换${extra?' | '+extra:''}
`;
const c = w.mode==='fixed' ? 'var(--warn)' : 'var(--info)';
const tag = w.mode==='fixed' ? '固定时间窗 · 不随上方切换' : '本页含两个窗';
return `
${tag} ${w.win}
${w.why}${extra?' | '+extra:''}
`;
}
// 控制参数三图 (2026-08-28 用户令: 控制参数一致性要图形, 在发电性能展示)。
// 原来是三段密文字, 关键结论"离散成组≠连续分布"完全看不出来 — 而这正是判"参数级差异"的依据。
// A 封顶参数数轴: 台数落在几个离散位置上 = 配置/版本差异; 若是磨损或工况会是连续散布。
// B 桨距同档差: 零中线偏差条, 长度即幅度, 与全场中位同尺比。
// C 尺子巡检: 极差/σ 比 — 38 台正态抽样的极差期望约 3.7σ, 明显超出即分布非单峰(佐证 A 的分组)。
function capAxis(P, W9){
const W=500, H=158, ML=54, MR=34;
const row=(y, spec, unit, label)=>{
const gs=Object.entries(spec.分组).map(([k,v])=>({v:parseFloat(k), n:v, raw:k}));
if(!gs.length) return '';
const lo=Math.min(...gs.map(g=>g.v)), hi=Math.max(...gs.map(g=>g.v));
const pad=(hi-lo)*0.22||1, x0=lo-pad, x1=hi+pad;
const PX=v=>ML+(v-x0)/(x1-x0)*(W-ML-MR);
const mn=Math.max(...gs.map(g=>g.n));
const dots=gs.map(g=>{const r=5+Math.sqrt(g.n/mn)*9;
return `
${g.raw}
${g.n}`}).join('');
const med=spec['中位'];
return `${label}
中位 ${med}
${dots}`;
};
return ``;
}
function betaBar(b){
const outs=(b.离群||[]), med=b['全场同档差中位'];
const W=500, H=34+outs.length*26, ML=52, MR=58;
const mx=Math.max(med, ...outs.map(o=>o.平均偏))*1.18||1;
const PX=v=>ML+v/mx*(W-ML-MR);
const rows=outs.map((o,i)=>{const y=30+i*26;
return `${o.t.replace('WTG','')}#
${o.平均偏}°`}).join('');
return ``;
}
function rulerBar(rl){
const ks=Object.entries(rl); const W=500, H=26+ks.length*30, ML=88, MR=76;
const NORM=3.7; // 38 台正态抽样极差期望 ≈ 3.7σ
const mxr=Math.max(NORM*1.35, ...ks.map(([,v])=>v.极差/v.σ));
const PX=v=>ML+v/mxr*(W-ML-MR);
const rows=ks.map(([k,v],i)=>{const y=22+i*30, ratio=v.极差/v.σ, wide=ratio>NORM;
return `${k}
${ratio.toFixed(1)}σ σ=${v.σ}`}).join('');
return ``;
}
// 责任分账 (2026-08-28 用户令: 电量算账优化显示)。
// 瀑布回答"电量流向", 但业主真正要问的下一句是"谁的责任" — 原来只在一段小字里写了个百分比。
// 三分账: 交易与调度侧(限电两项+调度令停机) / 设备侧(故障与检修停机) / 运行侧(正常发电欠发+低风)。
function blameBar(e){
const cat = k => (k.indexOf('限电')>=0 || k.indexOf('调度')>=0) ? 0 : (k==='停机' ? 1 : 2);
const NM = ['交易与调度侧','设备侧','运行侧'];
const NOTE = ['限电与调度令, 不进设备责任','故障与检修停机, 唯一进设备考核','正常发电欠发与低风待机'];
const CL = ['var(--info)','var(--bad)','var(--muted)'];
const g = [0,0,0];
(e.items||[]).forEach(x => { g[cat(x.k)] += x.loss; });
const tot = g.reduce((a,b)=>a+b,0) || 1;
const W=500, H=44;
let x=0, seg='';
g.forEach((v,i)=>{ const w=v/tot*W;
seg += `${NM[i]}: ${Math.round(v).toLocaleString()} MWh`
+ (w>46 ? `${(v/tot*100).toFixed(0)}%` : '');
x += w; });
// 图例走 HTML 而非 SVG text: SVG 里估算中文字宽不准, 三段说明会互相压字 (实测重叠)
const leg = g.map((v,i)=>`
${NM[i]}
${Math.round(v).toLocaleString()} MWh
${NOTE[i]}
`).join('');
return `${leg}`;
}
// 帕累托图 (2026-08-28 用户令: 故障统计按经典分析展示)。
// 经典做法 = 条形按降序 + 右轴累计百分比折线 + 80% 参考线 → 一眼看出"关键少数";
// 原来只有两列排序表, 看不出前几项就占了大头。
function paretoChart(items, unit, note){
const it = (items || []).filter(x => x && x.v > 0);
if(!it.length) return '时间窗内无数据
';
// 尺寸 (2026-08-28 排布优化): 原 720x260 + 旋转长标签, 两列并排时被缩到 0.60 倍 = 字高 6.5px 不可读。
// 改紧凑版 520x236: x 轴只标码号(水平, 不旋转 → 底部留白 86→40), 全名进悬停与图注前三行。
const W = 520, H = 236, ML = 46, MR = 40, MB = 40, MT = 14;
const n = it.length, tot = it.reduce((a, x) => a + x.v, 0);
const mx = Math.max(...it.map(x => x.v));
const bw = (W - ML - MR) / n;
const short = k => { const m = String(k).match(/^([0-9]+)/); return m ? m[1] : String(k).slice(0, 4); };
let cum = 0, bars = '', line = [], dots = '', k80 = null;
it.forEach((x, i) => {
const hh = (x.v / mx) * (H - MT - MB);
const bx = ML + i * bw + bw * 0.14, by = H - MB - hh;
bars += `${x.k}: ${x.v}${unit}`;
cum += x.v;
const pct = cum / tot;
if(k80 === null && pct >= 0.8) k80 = i + 1;
const px = ML + i * bw + bw / 2, py = MT + (1 - pct) * (H - MT - MB);
line.push(`${px.toFixed(1)},${py.toFixed(1)}`);
dots += `${x.k} · 累计 ${(pct*100).toFixed(0)}%`;
});
const y80 = MT + 0.2 * (H - MT - MB);
const xt = it.map((x, i) => `${short(x.k)}`).join('');
const yt = [0, .5, 1].map(f => `${Math.round(mx*f).toLocaleString()}`).join('')
+ [0, .5, 1].map(f => `${Math.round(f*100)}%`).join('');
const top3 = it.slice(0, 3).map(x => `${short(x.k)} ${String(x.k).replace(/^[0-9]+ */, '').slice(0, 13)}`).join(' · ');
return `
${k80 ? `关键少数: 前 ${k80} 项占 80% (共 ${n} 项, 合计 ${Math.round(tot).toLocaleString()}${unit})` : `共 ${n} 项, 合计 ${Math.round(tot).toLocaleString()}${unit}`}
前三: ${top3}${note ? '
' + note : ''}
`;
}
// ── 风场阵列 (2026-08-31 用户令: 风机与环境形象要用视觉体现) ──────────────
// 视觉语言与推介页 hero 同源: 细线几何 + stroke-opacity 造景深, 不写实。
// 它同时是**信息图**不只是插画 —— 每台按其最严重系统状态着色, 全场健康分布一眼可见。
// 快照版总览原本只有一句"内嵌页不可用", 这里正好补上。
// ── 运维成熟度与四判据 (2026-08-31) ───────────────────────────────
// 为什么放在检修决策页顶端: 这四条是"系统会主动拦下来"的判据, 不是又一张统计图。
// 那三个问题(复发/共性/记录断层)在本场存在五年没被发现, 不是没人懂,
// 是没有任何环节会主动报出来。
// 本体里的文本用 **x** 标重点 (那是给人读源文件时用的)。直接插进 HTML 会把星号
// 原样印在客户交付页上。这里只转粗体, 不引入 markdown 解析器 —— 需求就这一条。
// 用字符类 [*] 而非反斜杠转义 —— CHART_JS 是**非 raw** 的 Python 三引号串,
// 反斜杠在这里会被 Python 当转义处理: 反斜杠加星号触发 SyntaxWarning, 反斜杠加 n
// 直接变成真换行, 把单行注释劈成两行 —— 第二行不再是注释, 当场语法错、页面白屏。
// (写这条注释时我为了举例又写了一次那个转义, 于是当场复现了它描述的 bug,
// 被成品页语法闸逮到。所以: 这个字符串里能不写反斜杠就不写, 举例也别写。)
// 层级后缀单独成常量 —— 原来写成 `${lv}层` 时那个 `层` 是模板里的**字面量孤字**,
// 数据侧的 `机理` 译成了 Domain model 而它没译, 页面上就是 'Domain model层'。
// 提成常量后它是一个可查表的完整词。
const LAYER_SFX = '层';
const TIMES_SFX = '倍';
const mdB = s => String(s == null ? '' : s).replace(/[*][*]([^*]+)[*][*]/g, '$1');
// 快照版总览面板: 用已固化的数据汇总, 而不是道歉。
// 逐系统两个键: 裸名 = 有问题的机组, `·良好` = 判良好的机组 —— 汇总时要分开数。
function snapOverview(d){
const SY = d.systems || {};
const rows = [];
Object.keys(SY).forEach(function(k){
if (k.indexOf('·') >= 0) return; // 只遍历裸名, 良好那半单独取
const bad = (SY[k] || []).length;
const good = (SY[k + '·良好'] || []).length;
if (bad + good) rows.push({sys: k, bad: bad, good: good});
});
rows.sort(function(a, b){ return b.bad - a.bad; });
const K = d.kpi || {};
const M = (d.m8 && d.m8.mtbf) || {};
let h = 'Where to look
';
h += '
'
+ '本页为固化快照: 上方每个标签页的数据都已随页面打包, 断网也能看。'
+ '下表按系统汇总当前状态, 点系统名进该系统的机组清单。
';
if (rows.length){
h += '
| 系统 | 需跟踪 | 判良好 | '
+ '占全场 |
';
rows.forEach(function(r){
const tot = r.bad + r.good;
// 用 data 属性 + 委托事件, **不写嵌套引号** —— CHART_JS 是非 raw 的 Python
// 三引号串, 里面的反斜杠转义会被 Python 吃掉, 反引号/单引号一嵌套就断串。
// (今天第三次栽在这条上, 每次都是成品页语法闸拦下来的。)
h += '| '
+ r.sys + ' | '
+ ''
+ r.bad + ' | ' + r.good + ' | '
+ '' + tot + ' |
';
});
h += '
';
}
h += '
'
+ '
' + (K['报警台'] != null ? K['报警台'] : '—') + ''
+ '机组带报警级判定
'
+ '
' + (K['关注台'] != null ? K['关注台'] : '—') + ''
+ '机组在跟踪清单上
'
+ '
' + (M['停机事件数'] != null ? M['停机事件数'] : '—') + ''
+ '停机事件 (本时间窗)
';
h += '
'
+ '各屏回答什么
'
+ '电量算账 — 电去哪了, 每一度归到哪个原因
'
+ '部件问题 — 哪台哪系统该管, 依据是什么
'
+ '振动融合分析 — 振动与 SCADA 对上没有
'
+ '发电性能 — 曲线偏了没有, 偏在哪一段
'
+ '故障统计 — 报警的关键少数与可靠性象限
'
+ '检修决策 — 数据本身可不可信, 运维体系差在哪
';
h += '
';
return h;
}
// 总览表里的系统名跳到对应标签页。委托绑定, 只挂一次。
document.addEventListener('click', function(e){
var a = e.target && e.target.closest ? e.target.closest('a[data-gotab]') : null;
if (!a) return;
e.preventDefault();
TAB = a.getAttribute('data-gotab');
render();
});
function dqFindings(d){
// 数据可信度 —— 系统自查出的数据质量与口径问题。
// 放在判据面板之前: 先说"这些数字可不可信", 再说"数字说明什么"。
// 一个只报结论不报数据缺陷的系统, 是在把风险留给读者。
if(!d) return '数据可信度加载中…
';
if(d.err) return '数据可信度: '+d.err+'
';
const KIND={data_quality:['数据质量','var(--bad)'], caliber:['口径','var(--warn)'],
system_coverage:['覆盖','var(--accent)'], tier_trend:['结构趋势','var(--ok)']};
const GR={established:['定论','var(--bad)'], candidate:['候选','var(--warn)'],
reference:['参考','var(--muted)']};
let h='数据可信度 · 系统自查发现
';
h+='
'
+'先回答"这些数字可不可信", 再谈数字说明什么。以下每条都是在做分析的过程中被判据拦下来的, '
+'不是事后补的说明。
';
(d.rows||[]).forEach(r=>{
const k=KIND[r.kind]||['其他','var(--muted)'];
const g=GR[r.grade]||[r.grade,'var(--muted)'];
h+=`
`;
h+=`
${k[0]}
${g[0]}
${r.claim_window||''}
`;
h+=`
${mdB(r.name)}
`;
if((r.evidence||[]).length){
h+='
';
(r.evidence||[]).slice(0,4).forEach(e=>{h+=`- ${mdB(e)}
`});
h+='
';
}
if(r.impact) h+=`
判别 / 处置 ${mdB(r.impact)}
`;
if(r.got_wrong) h+=`
首版判错 ${mdB(r.got_wrong)}
`;
// 数据质量标志 + 稳健性复算: 削弱结论的证据, 与证据同级显示
if(r.quality_flag) h+=`
记录质量 ${mdB(r.quality_flag)}
`;
if(r.robust) h+=`
稳健性复算 ${mdB(r.robust)}
`;
if(r.basis) h+=`
分类依据 ${mdB(r.basis)}
`;
if(r.sensitivity) h+=`
敏感性检验 ${mdB(r.sensitivity)}
`;
if(r.age_basis) h+=`
场龄口径 ${mdB(r.age_basis)}
`;
// ★边界从 11px/muted 的脚注提到 12px/正文色 (2026-08-31 独立审逮):
// 它**限定结论成立的范围**, 不是补充说明。原样式让它成为卡片里最小最淡的元素,
// 排在一串证据数字之后 —— 读者读完数字就走了, 限定语等于没写。
if(r.caveat) h+=`
结论边界 ${mdB(r.caveat)}
`;
h+='
';
});
h+='
';
return h;
}
function maintFw(d){
// 运维管理体系三维骨架 — 架构取自一套跨厂商通用的培训编号体系, 实证取自本场五年工单。
// 放在四判据之后: 判据回答"哪台该管", 本面板回答"这个场的运维能力结构长什么样"。
// 配色一律走 CSS 变量 —— 页面有明暗两套 :root, 硬编码色在另一套下不可读。
if(!d) return '运维体系加载中…
';
if(d.err) return '运维体系: '+d.err+'
';
const c=d.coverage||{}, g=d.gaps||{};
const tierName={common:'通用 · 换机型仍有效', platform:'机型专属 · 绑定当前机型', basic:'技术基础', eng:'工程改造'};
const tierCol={common:'var(--accent)', platform:'var(--warn)', basic:'var(--muted)', eng:'var(--unknown)'};
let h='运维管理体系 · 系统 × 阶段 × 记录
';
// ★跨厂商借用必须在**用它的地方**声明 (2026-08-31 双票独立命中)。
// ★这套码是**借来的跨厂商分类法**, 不点任何厂商名 (点了等于用否定把机型漏回去)。
// 声明写在别的页面等于没声明: 读者是在这一屏做判断的。
h+='
'
+'三位系统码分两层 (3xx 跨机型通用 / 4xx 机型专属), 判据与缺口取自本场五年工单实证。'
+'口径声明: '
// ★整句不嵌 —— 中间插标签会把这段切成多个 PH 片段, 逐片查表再拼回去,
// 结果是"arenot"这种缺空格拼接 + 某一片查不到就留中文 (实测两种都发生了)。
// 要强调就整句强调, 别在句中加标记。整段一个 key, 查得到就整段换, 查不到就整段留。
// ★整句放在**一个**字符串字面量里。apply() 跑在**源码文本**上, 拆成多个字面量拼接时,
// 源码里它们被换行加 + 号隔开 ⇒ PH 切出的段与渲染后看到的整句**不是同一个东西**,
// (注意: CHART_JS 是非 raw 的 Python 三引号串, 注释里写反斜杠-n 会变成**真换行**,
// 把 // 注释劈成两行 —— 第二行不再是注释, 直接语法错。本行原来就踩了这个坑。)
// 我按渲染后的整句注册 key, 页面上却按源码分段查表 → 全部 miss。
// (实测踩到: 助手 --segments 报"命中", 页面上仍是中文。验证跑在渲染后, 翻译跑在源码上。)
+'三位码仅用于能力分级与培训投资参照。部件到系统码的映射由本项目建立、未经确认, 因此结构趋势比绝对占比可靠。
';
// ① 覆盖率 — 本轮最实的一条发现
h+='
';
h+=`
${c.system_pct}%
工单可归属到系统
${c.mapped} / ${c.n_orders}
`;
h+=`
${c.part_field_pct}%
工单填了部件字段
${c.with_part} / ${c.n_orders}
`;
h+=`
怎么补
工单模板增加系统码必填项 (31 个系统码下拉), 此后每条工单按构造自动归属。
归属率低是模板缺字段, 不是维修没做 — 后者要贵得多。
`;
h+='
';
// ② 通用 vs 机型专属 — 决定培训投资与合同边界
h+='
作业量结构 · 通用能力 vs 机型专属
';
h+='
通用能力换机型仍成立, 属场站资产; 机型专属绑定当前机队, 更适合合同覆盖
';
const byT=g.by_tier||{}, tks=Object.keys(byT).sort((a,b)=>byT[b].jobs-byT[a].jobs);
h+='
';
tks.forEach(t=>{h+=`
${byT[t].share}%
`;});
h+='
';
tks.forEach(t=>{h+=`${tierName[t]||t} · ${byT[t].jobs} 次`;});
h+='
';
// ③ 系统作业量排行
h+='
按系统码的作业量
';
h+='
| 码 | 系统 | 类别 | 作业次数 | 台数 | 次/台 |
';
(d.systems||[]).slice(0,10).forEach(r=>{
h+=`| ${r.code} | ${r.zh} ${r.en} |
${tierName[r.tier]||r.tier} |
${r.jobs} | ${r.n_units} | ${r.per_unit} |
`;});
h+='
';
// ④ 阶段 × 出口闸 × 记录
h+='
六个运维阶段与各自的放行条件
';
h+='
放行条件 = 不满足就不该进下一阶段; 记录 = 该阶段必须留下的凭据
';
h+='
| 阶段 | 放行条件 | 必备记录 |
';
(d.stages||[]).forEach(st=>{
const rn=(st.records||[]).map(r=>((d.records||{})[r]||{}).zh||r)
.map(x=>`${x}`).join('');
// ★英文侧 zh 已被 _proj 投影成英文 (裸 zh/en 成对的处理), 两行会显示成一样的字 ——
// 相同就只出一行。出口闸那列同理: 页面原读 st.gate_zh, 而投影后字段名是 gate,
// 于是**整列 undefined** —— 而"出口闸"正是这张表最要紧的一列 (无闸的阶段划分只是标签)。
h+=`| ${st.seq} ${st.zh||st.en||''}
${(st.en&&st.en!==st.zh)?` ${st.en} `:''} |
${st.gate||st.gate_zh||''} | ${rn} |
`;});
h+='
';
// ⑤ 未映射 — 不猜归属
const um=(g.unmapped_top||[]);
if(um.length){
h+='
未映射到系统码的部件 (不猜归属, 留待补映射表): '
+ um.slice(0,10).map(x=>`${x[0]}×${x[1]}`).join('、')+'
';}
h+='
';
return h;
}
function maintStd(d){
if(!d) return '';
if(d.err) return ``;
const S=d.score||{}; let h='';
const dims=(S.dims||[]).map(x=>{
// ★"不可评估"要在**出现的地方**就自明 —— 用户读到这四个字问"什么意思",
// 说明把解释放在上一行的口径说明里不够。它不是"做得差", 是"没法判":
// 工单模板里没有记录该证据的位置, 所以既不能记功也不能记过。
if(!x.evaluable) return `—
${x.dim} · 不可评估
(工单里没有记录这项证据的字段, 所以既不能算做到、也不能算没做到 — 不是 0 分, 是无法判断)
${mdB(x.detail)}
`;
const pct=Math.round(x.score/x.full*100);
const cls=pct>=70?'ok':pct>=40?'':'bad';
return `${x.score}/${x.full}
${x.dim}
${mdB(x.detail)}
`;
}).join('');
// 两个分并排、各自带标签; 口径与理由见 src/windscada/maint/standards.py。
// (页面源码随交付件一起发出去, 内部审核讨论不留在这里。)
const _en=(typeof window!=='undefined'&&window.__EN);
const twoScore=(lab,val,ev,n,tone)=>`
${lab||''}
${val!=null?val:'—'}/100
${ev} / ${n} ${_en?'dimensions measurable':'维可测'}
`;
h+=`${_en?'Maintenance scorecard':'运维评分卡'}
${S.stage_label||('第 '+S.stage+' 阶')} · ${S.stage_name}
${twoScore(S.record_label,S.record_score,S.record_evaluable,S.record_dims,'var(--bad)')}
${twoScore(S.exec_label,S.exec_score,S.exec_evaluable,S.exec_dims,'var(--ok)')}
${S.score_basis?`
${mdB(S.score_basis)}
`:''}
${dims}
${mdB(S.caveat)}
${S.model_source?`
评分方法来源 ${mdB(S.model_source)}
`:''}
${S.bottleneck?`
当前瓶颈: ${S.bottleneck} — 先补这一项, 其余维度的提升都受它限制。
`:''}
`;
// R1 复发
const R=(d.recurrence||[]).slice(0,10);
if(R.length) h+=`复发 · ${d.recurrence.length} 组
同一机组同一部件多次更换 — 换件未解决产生它的原因。按作业日去重计数, 非工单条数。
| 机组 | 部件 | 作业次数 | 首次 | 末次 | 最短间隔 |
${R.map(x=>`| ${x.turbine} | ${x.part} | ${x.n_jobs} |
${x.first} | ${x.last} | ${x.min_gap_months} 月 |
`).join('')}
`;
// R2 批次候选
const B=(d.batch||[]).slice(0,8);
if(B.length) h+=`共性候选 · ${d.batch.length} 项
仅候选。分布形态无法区分 批次 / 共模应力 / 耗损期 — 三者形态相似而处置方向完全不同(追索赔 / 改运行条件 / 排寿命更换)。定性需物料证据。
| 部件 | 涉及台数 | 作业次数 | 每台 | 缺什么才能定性 |
${B.map(x=>`| ${x.part} | ${x.n_units} | ${x.n_jobs} |
${x.per_unit} | ${x.needs} |
`).join('')}
`;
// R4 断层
const D=d.discontinuity||{};
if((D.breaks||[]).length) h+=`记录断层
${D.note}
${D.breaks.map(b=>`
${b.before} → ${b.after}: ${b.n_before} → ${b.n_after} 条 (${b.ratio}×) — 机组不会一年之间变坏这么多, 变的是记录制度。
`).join('')}
`;
return h;
}
function farmScene(d){
const RANK={'危险':4,'报警':3,'不可判':2,'良好':1,'优秀':0};
const COL={'危险':'var(--bad)','报警':'var(--bad)','不可判':'var(--unknown)',
'良好':'var(--ok)','优秀':'var(--st-exc)'};
const S={};
Object.entries((d&&d.systems)||{}).forEach(([sys,rows])=>(rows||[]).forEach(r=>{
if(!r||!r.t) return;
const st=String(r.st||'').split('·')[0].trim();
if(!(st in RANK)) return;
if(!(r.t in S) || RANK[st] > RANK[S[r.t]]) S[r.t]=st;
}));
// 补全: 预烤 key 里有全场台号; 在线模式下退回已知台集合
let all=Object.keys(S);
if(typeof window.__SNAP__!=='undefined'){
const ks=new Set();
Object.keys(window.__SNAP__).forEach(k=>{const m=/[?&]t=([A-Za-z0-9]+)/.exec(k); if(m)ks.add(m[1])});
if(ks.size>all.length) all=[...ks];
}
all=all.sort();
if(!all.length) return '';
const n=all.length, W=980, H=190, m=30;
const per=Math.ceil(n/2), gap=(W-m*2)/(per-1||1);
let g='';
all.forEach((t,i)=>{
const row=i${t} · ${st}`
+``
+``
+``
+``
+``;
});
const cnt={};
Object.values(S).forEach(v=>cnt[v]=(cnt[v]||0)+1);
const legend=['危险','报警','不可判','良好','优秀'].filter(k=>cnt[k])
.map(k=>`
${k} ${cnt[k]}`).join('');
return `
全场态势${legend}
按各台最严重系统状态着色 · 悬停看台号
`;
}
function drawFig(f){
if(f.kind==='vib'){return figWrap(f, dtSVG(f.comp||f.text||'',f.sev||'var(--warn)',880), (f.text||'').replace(/${f.title||''}
${(f.text||'').replace(/`}
if(f.kind==='vibbar'){
// 倍中位条 (1× 为基准竖线; 绝对值量级差 3 个数量级时按绝对值画条会全部顶满 — 速度有效值 0.001 实例)
const rows=f.rows||[]; if(!rows.length) return '';
const sig=v=>{const a=Math.abs(v); return a>=100?v.toFixed(0):a>=10?v.toFixed(1):a>=1?v.toFixed(2):a>=0.01?v.toFixed(3):v.toExponential(1)};
const byM={}; rows.forEach(r=>{(byM[r.指标]=byM[r.指标]||[]).push(r)});
const LO=0.5, HI=2.5, W2=190, xr=v=>((Math.max(LO,Math.min(HI,v))-LO)/(HI-LO))*W2;
let inner=`
条为本台与全场中位之比, 竖线取 1× 基准, 右端封顶 2.5×; 色按 z (稳健离群度): 绿<2 · 橙2~3 · 红≥3
`;
for(const [mk,rs] of Object.entries(byM)){
inner+=`${mk}`;
rs.forEach(r=>{
const v=r.倍中位||1, col=Math.abs(r.z)>=3?'var(--bad)':Math.abs(r.z)>=2?'var(--warn)':'var(--ok)';
const x1=xr(1), xv=xr(v), left=Math.min(x1,xv), wid=Math.max(Math.abs(xv-x1),1.5);
inner+=`| ${r.测点} |
|
${r.倍中位!=null?r.倍中位+'×':'—'} |
${sig(r.本台)} |
中位 ${sig(r.全场中位)} |
z ${r.z} |
${r.六窗比!=null?'趋势'+r.六窗比+'×':'趋势—'} |
`;
});
inner+='
';
}
return figWrap(f, inner, f.note);
}
const W=620,H=250,ML=52,MB=34;
let xs=f.months||f.x, n=xs.length; if(!n) return '';
const X=i=>sc(i,0,Math.max(n-1,1),ML,W-12);
let all=[];
if(f.kind==='band'){all=[...(f.self||[]),...(f.med||[]),...(f.q1||[]),...(f.q3||[])]}
else if(f.kind==='bars'){all=[0,...(f.vals||[])]}
else if(f.kind==='xy'){all=[...(f.self||[]),...(f.med||[])]}
else {(f.series||[]).forEach(s=>all.push(...s.vals))}
(f.thresholds||[]).forEach(t=>all.push(t.v));
all=all.filter(v=>v!==null&&v===v); if(!all.length) return '';
let lo=Math.min(...all),hi=Math.max(...all); if(lo===hi){lo-=1;hi+=1} const pad=(hi-lo)*0.08; lo-=pad; hi+=pad;
const Y=v=>sc(v,lo,hi,H-MB,14);
const line=(vals,st)=>{let p=vals.map((v,i)=>v===null||v!==v?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`);
let segs=[],cur=[]; p.forEach(q=>{if(q)cur.push(q);else{if(cur.length)segs.push(cur);cur=[]}}); if(cur.length)segs.push(cur);
return segs.map(sg=>``).join('')}
let body='';
if(f.kind==='band'&&f.q1){
let up=f.q3.map((v,i)=>v===null?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`).filter(Boolean);
let dn=f.q1.map((v,i)=>v===null?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`).filter(Boolean).reverse();
if(up.length&&dn.length) body+=``;
}
if(f.kind==='bars'){const bw=Math.max(3,(W-ML-20)/n*0.6);
body+=f.vals.map((v,i)=>``).join('');}
if(f.med) body+=line(f.med,'stroke:#64748b;stroke-width:1.7');
if(f.self) body+=line(f.self,'stroke:#c0392b;stroke-width:2.1')+
f.self.map((v,i)=>v===null||v!==v?'':``).join('');
const cols=['#0f4c75','#ef6c00','#0e6e66','#7c5295'];
(f.series||[]).forEach((s,k)=>{body+=line(s.vals,`stroke:${cols[k%4]};stroke-width:1.8`)});
(f.thresholds||[]).forEach(t=>{body+=``+
(t.label?`${t.label}`:'')});
const step=Math.max(1,Math.ceil(n/8));
let xt=''; for(let i=0;i${String(xs[i]).includes('-')?String(xs[i]).slice(2,7):String(xs[i])}`;
let yt=''; for(let k=0;k<5;k++){const v=lo+(hi-lo)*k/4; yt+=`${v.toFixed(Math.abs(hi)<10?1:0)}`+
``}
if(f.band&&f.band.q1){const q1=f.band.q1,q3=f.band.q3;
let up='',dn='';
for(let i=0;i=0;i--){if(q1[i]!=null)dn+=`${X(i)},${Y(q1[i])} `}
body=``+body}
if(f.xlab) body+=`${f.xlab}`;
let leg = f.kind==='band' ? `— 本台— 全场中位▧ 四分位` :
((f.series&&f.series.length<=2)?(()=>{const n2=f.series.length,gap=Math.min(118,Math.floor((W-ML-20)/n2));
return f.series.map((s,k)=>`— ${s.name}`).join('')})():'');
const legendHtml=(f.series&&f.series.length>2)?``+f.series.map((s,k)=>
`${s.name}`).join('')+`
`:'';
// 统一走 figWrap: 图身按类别封顶宽度 (曲线760/月度620), 不再随容器拉伸成巨图
return figWrap(f, ``+legendHtml,
f.note||'').replace(''+(f.title||''), ''+(f.title||'')+(f.unit?' ('+f.unit+')':''));
}
function ordersHtml(o){
if(!o) return '';
let h=`工单处理史 · 该系统 (处理 ${o.n} 次${o.窗?'; 窗 '+o.窗:''})
`;
if(o.n===0){h+=`
该时间窗内无工单记录。${o.note||''}
`; return h}
const acts=Object.entries(o.动作统计||{}).map(([k,v])=>k+'×'+v).join(' / ');
if(acts) h+=`
处置动作统计: ${acts}
`;
h+=`| 日期 | 故障/工作 | 排查发现 | 处置 | 类别 |
`;
o.rows.forEach(r=>{h+=`| ${r.日期} | ${r.故障} | ${r.排查&&r.排查!=='nan'?r.排查:'—'} | ${r.处置||'—'} | ${r.类别||''} |
`});
h+=`
${o.note||''}
`;
return h;
}
let ONT=null;
async function loadDbase(){
const el=document.getElementById('dbase'); if(!el) return;
if(!ONT) ONT=await (await fetch('/api/ontology')).json();
const C=ONT.chain29; let h='';
// ① 机制链横图 (决策的证据底座, 图形化)
const node=(label,val,color)=>``;
const arrow=`→
`;
// 每格加一行"这一格回答什么" (2026-08-28 用户问"什么意思" — 六个词现场看不出是干嘛的)
const node2=(t,v,c,q)=>``;
// 决策链进度盘 (2026-08-28 用户问"这个到底能怎么用, 我没看太懂")。
// 原来是 29# 一台的静态六格 — 打开它做不了任何决定, 是展示牌不是工作面。
// 改为: 全部需跟踪台各走到第几步 / 卡在哪一步 / 下一步具体做什么 / 点台号进该台。
// 这样它回答的是"今天该管谁、卡在哪、谁去做什么", 而不是"29# 的故事是怎样的"。
const BD=(window.D&&D.fus&&D.fus.链盘)?D.fus.链盘:(D.fus&&D.fus.链盘);
if(BD&&BD.rows&&BD.rows.length){
const SK=Object.entries(BD.stuck||{}).sort((a,b)=>b[1]-a[1]);
const tot=BD.rows.length;
const cell=st=>st==='done'?``
:st==='unknown'?``
:``;
h+=`决策链进度盘 · ${tot} 台需跟踪
一台机组从发现异常到修完验收要走六步。这张盘不讲某一台的故事, 用于确认当前需处置机组、各自所处环节、后续动作。
■ 已达 ·
▢ 虚线 数据不足不可判 ·
▢ 实线 未达
瓶颈${SK.map(([k,n])=>`
${n} 台 卡在「${k}」`).join('
·')}
${SK.length&&SK[0][1]/tot>0.5?`
— 瓶颈集中在一步, 不是"再多看点数据"能解的`:''}
${SK.map(([k,n])=>{const r=BD.rows.find(x=>x.stuck===k);
/* 值为空就整块不出 —— 原来无条件渲染, 没 next 的两个环节在页面上留下
"「机制」24 台的下一步:" 后面空着的悬空标签 (中英文版同样露)。 */
return (r&&r.next)?`
「${k}」${n} 台的下一步: ${r.next}
`:''}).join('')}
| 机组 | 状态 |
六步进度 | 卡在 | 本台情况 |
`;
BD.rows.forEach(r=>{
const ris=r.rising;
h+=`| ${r.t.replace('WTG','')}#
${ris?`↑${ris.ratio}×`:''} |
${chip(r.ostate||'—')} |
${r.steps.map((x,i)=>`${cell(x.st)}`).join('')}
${r.done}/6 |
${r.stuck||'全通'}${r.stuck_kind==='unknown'?' ?':''} |
${r.part&&r.part.indexOf('未收录')<0?`${r.part} · `:''}证据 ${(r.srcs||[]).join('/')||'—'}
${ris?` · ${ris.component}末三窗/早三窗 ${ris.ratio}×${ris.工单可对===false?', 待现场/工单确认':''}`:''}
${r.hist_loop?` · ${r.hist_loop.replace_date} 有过闭环 ${r.hist_loop.ratio}×`:''} |
`;});
h+=`
「动作」一列全场都是不可判, 不是偷懒: ${BD.wo_bound}, 而判级时间窗在 2026 —
台账里的历史工单不可能是针对本次问题的, 用"有没有工单"判已派工会造出假的完成态。要填这一列须向现场要 25~26 工单。
`;
if(BD.closed&&BD.closed.length){
h+=`
已走完全程并退出盘面 · ${BD.closed.length} 台 (端到端闭环实证)
${BD.closed.map(x=>`
`).join('')}
这三台正是算法当初的检出台 ⇒ 故障前能看见、换件后能看见恢复, 这条链是通的; 闭环成功的台会从盘面消失。
`;
}
h+=`
`;
}
// ② 四情景损失对比 (log 尺度条形; 数值解析电费口径区间取中值)
const sc=C.scenario.scenarios||[];
const parseWan=t=>{const m=/([0-9.]+)~([0-9.]+) 万元/.exec(t||'');return m?(+m[1]+ +m[2])/2:null};
const rows=sc.map(x=>{
let v=parseWan(x.停机损失&&x.停机损失.电费口径), extra=0, lab=x.id;
if(x.停机损失&&x.停机损失.大部件成本){extra=350; lab+=' (+大部件成本200-500万)'; // 用 - 不用 ~ : ~ 不在 PH 续接字符类里, 会把整串切断
}
return {lab, v:(v||0)+extra, base:v||0, extra};});
if(rows.length){
const mx=Math.max(...rows.map(r=>r.v));
h+=`排期沙盘 · 四情景损失对比 (万元, 电价区间中值; 建议级待人裁)
`;
rows.forEach(r=>{
const w=Math.max(3,Math.round(Math.log10(1+r.v)/Math.log10(1+mx)*320));
const col=r.extra?'var(--bad)':(r.lab.startsWith('A')?'var(--ok)':'var(--warn)');
h+=`
${r.lab}
${r.v>=100?r.v.toFixed(0):r.v.toFixed(1)} 万元
`});
h+=`
条长=log 尺度 (跨两个数量级); D 含大部件成本中值 350 万 (grade D 弱源)。早修成本被限电稀释, 晚修成本被窗口与船期放大。
`;
}
// ③ 天气窗热力条 (近12月, CTV/吊装 双行)
const W=ONT.weather||{}; const ms=Object.keys(W).sort().slice(-12);
if(ms.length){
const cell=(v,d)=>{const r=v/d; const bg=r>0.9?'var(--ok)':r>0.7?'var(--warn)':'var(--bad)';
return `${v} | `};
h+=`海上作业天气窗 · 近12月可作业日 (公开再分析统计, 非派工依据)
| ${ms.map(m=>`${m.slice(2)} | `).join('')}
`;
h+=`| CTV人员转运 | ${ms.map(m=>cell(W[m]['CTV人员转运']||0,W[m]['天数']||30)).join('')}
`;
h+=`| 吊装作业 | ${ms.map(m=>cell(W[m]['吊装作业']||0,W[m]['天数']||30)).join('')}
`;
h+=`
CTV 级小修全年窗宽; 吊装级受台风季约束, 色深表示可作业占比。限值取行业常规 (CTV Hs≤1.5m∧风速≤12m/s, 吊装 1.2/8), 非本场合同值。
`;
}
el.innerHTML=h;
}
async function renderOntology(){
const root=document.getElementById('root');
if(!ONT){
root.innerHTML='';
ONT=await (await fetch('/api/ontology')).json();
}
const O=ONT; let h='';
h+=`语义本体 · 对象/机制/决策/约束 (Palantir 思路轻量自建; 判读纪律=写入闸, 违反写不进库)
`;
h+=`六条写入约束 (库级保证)${O.constraints}
`;
h+=``+Object.entries(O.stats).map(([k,v])=>`
${v}${k}
`).join('')+`
`;
const C=O.chain29;
h+=`机制链视图 · 29# 范例 (证据 → 机制 → 判级 → 沙盘 → 动作 → 验收)
`;
h+=`
① 证据 ×${C.evidence.length} (${C.mechanism.independent_source_count||4} 观测面)
`;
C.evidence.forEach(e=>{h+=`
${e.facet} · 窗 ${e.claim_window}
${(e.summary||'').slice(0,90)}…
${e.id}
`});
h+=`
`;
const box=(t,color,inner)=>`
`;
h+=box('② 机制','var(--warn)',`
${C.mechanism.name}源数=观测面去重 ${C.mechanism.independent_source_count} (证据${C.evidence.length}条)
${C.mechanism.id}`);
h+=box('③ 判级','var(--bad)',`
${C.verdict.level} · 审级 ${C.verdict.review_tier}
${(C.verdict.title||'').slice(0,110)}…
${C.verdict.id}`);
h+=box('④ 沙盘','var(--accent)',`${(C.scenario.recommendation||'').slice(0,130)}…
${C.scenario.id}`);
h+=box('⑤ 动作','var(--warn)',`${(C.action.name||'').slice(0,90)}
闸: ${C.action.gate?C.action.gate.reversibility+' · '+C.action.gate.review_tier:''} · ${C.action.status||''}
${C.action.id}`);
h+=box('⑥ 验收状态机','var(--ok)',`状态
${C.acceptance.state||'—'} 分支 ${C.acceptance.branch||'—'}
${(C.acceptance.note||'').slice(0,110)}
acceptance/WTG29/state`);
h+=`
判读纪律: 六个观测量=一条机制链 (C3 源数按观测面), 禁写多源印证; 判级只在 L1/审级产生, 本体转录带审级。
`;
h+=`失效机制库 (${O.mechanisms.length} 族)
`;
O.mechanisms.forEach(m=>{h+=`
${m.name}
状态: ${m.status||''}
观测面: ${(m.facets||[]).join('/')}
涉及: ${(m.affects||[]).map(a=>a.split('/').pop()).join(', ')||'fleet'}
${m.id}
`});
h+=`
`;
h+=`决策台 (决策捕获 + 月度滚动)
| 决策 | 裁决人 | 时间 | 状态 |
`;
O.decisions.forEach(x=>{const on=x.status==='pending_user';
h+=`| ${(x.decision||'').slice(0,130)} | ${x.decided_by} | ${x.when} |
${x.status} |
`});
h+=`
月度滚动: 数据月 ${O.roll.data_month||'—'} · 上次动作 ${(O.roll.last_roll_actions||[]).join('; ')||'—'} · 变化 ${(O.roll.last_changes||[]).join('; ')||'—'}
`;
h+=``;
root.innerHTML=h;
document.getElementById('foot').innerHTML='本体=只读语义层; 判级权在 L1+审级, 模型与网页均零判级权';
}
async function obGo(){
const t=document.getElementById('obType').value, tb=document.getElementById('obT').value;
const d=await (await fetch(`/api/ont_list?type=${encodeURIComponent(t)}&t=${encodeURIComponent(tb)}&limit=60`)).json();
const el=document.getElementById('obOut');
if(!d.rows||!d.rows.length){el.textContent='无结果';return}
el.innerHTML=`| id | 类型 | 要点 |
`+d.rows.map(r=>
`| ${r.id} | ${r.type} | ${r.name||r.level||r.status||''} ${r.review_tier?'· '+r.review_tier:''} |
`).join('')+`
`;
}
async function obOpen(id){
const d=await (await fetch('/api/ont_obj?id='+encodeURIComponent(id))).json();
const el=document.getElementById('obDetail'); if(!el) return;
el.innerHTML=`${JSON.stringify(d,null,1).replace(/`;
}
// 模型档位 (2026-08-28 用户令: 模型切换的应用你设计好; 去 30B 用 27B)。
// 按"这个问题值不值得等"选, 而非凭型号大小猜 — 每档写清 在哪跑 / 要多久 / 适合什么。
// 云端档单独标"内容会外发", 因为那是一个需要用户知情的选择而不是性能选项。
// ★页面用的是**这一份** (JS), 上面 L1158 那份是 Python 的 —— 两份同名不同源。
// 我第一次只改了 Python 那份, 页面一点没变 (deepseek-chat 在成品件里 0 命中)。
// 同名双实现: 改一处不报错、不生效, 只能靠回查产物发现。
const MODEL_SPEC = {
'qwen3:8b': {nm:'轻量 8B', loc:'本地', t:'约 30 到 60 秒', use:'默认档: 快速简答问答', warn:''},
'qwen3.8:27b': {nm:'标准 27B', loc:'本地', t:'约 2 到 4 分钟', use:'复杂问题档: 多台横向比对与机制推断', warn:''},
'qwen3:30b-a3b': {nm:'快 30B (a3b)', loc:'本地', t:'约 20 到 60 秒', use:'8B 成文校验未过时自动升档目标', warn:''},
'deepseek-r1:14b': {nm:'本机 DeepSeek 14B', loc:'本地', t:'约 1 到 3 分钟', use:'交叉审核审核档 (作答请优先 27B)', warn:''},
'deepseek-r1:32b': {nm:'本机 DeepSeek 32B', loc:'本地', t:'约 3 到 8 分钟', use:'备用: 更强更慢, 引用格式不稳', warn:''},
'deepseek-chat': {nm:'DeepSeek', loc:'云端', t:'约 3 到 10 秒', use:'默认档, 状态查询与机制推断', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
'kimi-fast': {nm:'Kimi', loc:'云端', t:'约 5 到 20 秒', use:'长文归纳', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
'qwen-plus': {nm:'通义千问', loc:'云端', t:'约 5 到 20 秒', use:'备用云端档', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
'deepseek-v4-pro': {nm:'云端 DS', loc:'云端', t:'约 2 到 6 分钟', use:'复杂推理与交叉审核', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
};
function mdlNote(){
const el=document.getElementById('mdlnote'), sel=document.getElementById('askm');
if(!el||!sel) return;
const v=MODEL_SPEC[sel.value]||{};
// ★查不到档位时**不能**默认"零外发" —— 未知档只可能是新加的云端模型。宁可说重不说轻。
const note = v.warn ? `${v.warn}`
: (v.loc==='本地' ? '全程零外发' : '未知档位, 按内容会外发处理');
el.innerHTML=`${v.use||''} · ${note}`;
}
async function askGo(){
const q=document.getElementById('askq').value, m=document.getElementById('askm').value;
if(!q.trim()){document.getElementById('askout').textContent='问题为空';return}
const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({q,model:m,cat:window.ASKCAT||'',xrev:(document.getElementById('askxrev')||{}).checked||false})});
const d=await r.json();
if(d.err){document.getElementById('askout').textContent=d.err;return}
askPoll();
}
// ★这句提示原文是「本地直连约 45~250 秒 (复合问题更久; 全程零外发)」—— 那是本地模型
// 时期的文案, 两处都已失真 (2026-09-01 用户说"太慢", 实测才发现):
// ① 时间: 云端档实测 3~7 秒, 而提示写 45~250 秒 —— **是提示语在告诉用户它很慢**
// ② 「全程零外发」现在是**假的**: 云端档的问题会发到厂商 API。
// 向用户保证一件不成立的事, 比写慢了严重得多。
// 故改为按档给话, 本地档才说零外发。
function MDLNOTE(m){
// ★不用正则: CHART_JS 是**非 raw** 的 Python 三引号串, 反斜杠转义序列会被 Python
// 吃掉并报 SyntaxWarning/SyntaxError。本地档判别用朴素前缀/子串即可。
// (本会话第 4 次踩这个坑 —— 而这一次是在**写这条警告的注释里**踩的。)
const s0=String(m||'').toLowerCase();
const local = s0.indexOf(':')>=0 || ['qwen3','llama','gemma','deepseek-r1'].some(x=>s0.indexOf(x)===0);
return local ? '本地直连, 约 45 到 250 秒 (复合问题更久), 全程零外发'
: '云端模型, 通常 3 到 10 秒 (复合问题更久), 问题与检索到的证据会发往模型厂商';
}
async function askPoll(){
const el=document.getElementById('askout'); if(!el) return;
const r=await fetch('/api/ask_status'); const s=await r.json();
if((s.state==='running'||s.state==='partial')){
// 进度上屏 (2026-08-28 用户"刚发现给不出结果"): 原来只有一个秒数在跳, 本地模型一问 45~250s,
// 看不见它在干什么就会被读成卡死。现在报当前阶段(预取/推理第n轮/取证工具名/成文/校闸)与轨迹。
const st=(s.steps||[]);
el.innerHTML=`推理中… ${s.elapsed}s · ${s.step||'启动'}
${s.model} · ${MDLNOTE(s.model)}
${st.length?`${st.slice(-6).join(' → ')}
`:''}
问题: ${(s.q||'').slice(0,90)}
`;
setTimeout(askPoll, 2000);
}else if(s.state==='done'){
const v=s.verify||{};
const badge=v.全通过?`溯源核验 ${v.引用id数}/${v.引用id数} 全真`
:`溯源核验未全过 (引用${v.引用id数||0})`;
const R=s.review;
const rv=R?`
交叉审核 · ${R.model} ${R.verdict}
由另一本地模型基于同一份证据复核上述答案。溯源核验仅校验数字与机组号是否越界, 本项校验结论是否有证据支撑。
${(R.issues||[]).map(i=>`
${i.type}
「${i.quote}」 ${i.why}
`).join('')}
${R.err?`
审核未完成: ${R.err}
`:''}
`:'';
el.innerHTML=`${badge} · 模型 ${s.model} · 用时 ${s.elapsed}s · ${(s.steps||[]).length} 步
${(s.answer||'').replace(/${rv}`;
}else if(s.state==='error'){
el.textContent='出错: '+s.err;
}
}
function winSel(cur){
const base=['近30日','近90日','2026年','2026H1','2025H2','全程'];
const ms=(window.ALLMONTHS&&ALLMONTHS.length)?ALLMONTHS:[]; // 按月份过滤和选择 (用户令): 预设窗+逐月
let h=``}