#!/usr/bin/env python3 # -*- coding: utf-8 -*- """windscada serve (M7c): 交互后端 — 工作台(全场)+分台页, 全部带时间窗. 设计: ①台页只渲染"有问题的系统"的证据图(分册两型: A=判据线+摆幅标注, B=本台vs全场四分位带), 无问题不出图; ②台内交互查询: 任一契约通道×时间窗×聚合 即时出图; ③工作台: 系统分类问题(全系统)+故障统计梳理+SOP控制策略; ④时间窗: 月度类数据真窗; 判级类(变桨/偏航/蓄能=近90日, 曲线=2025H2判别窗)固定窗如实标注. 端口 8033. 判级=筛查级/候选, P级归综合报告线.""" import sys as _sys, pathlib as _plb _sys.path.insert(0, str(_plb.Path(__file__).resolve().parents[1])) from src import paths as P import sys, json, pathlib, re, threading, urllib.parse, collections sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # 安装根入 sys.path (先于 src.* 导入) from src import paths as _P # 路径唯一真源 (与 cwd 无关) import numpy as np, pandas as pd import math def _jclean(o): """NaN/Inf → null (非法 JSON 防线; pd.DataFrame 会把 None 变 NaN).""" if isinstance(o, float) and (math.isnan(o) or math.isinf(o)): return None if isinstance(o, dict): return {k: _jclean(v) for k, v in o.items()} if isinstance(o, (list, tuple)): return [_jclean(x) for x in o] return o # ── 客户交付脱敏 (2026-08-28 demo 门禁逮出 7 处特征频率 + 21 处判据门槛上屏) ── # 规则来自 memory client-deliverable-hide-core-parameters: 判据名与实测值可见, 数值门槛与特征频率隐去。 # 分级而非一刀切 — 一刀切会把业主必须知道的口径也糊掉: # 脱 特征频率 (134.9Hz 是我们解出来的轴承几何, 属核心参数) # 脱 判据门槛 (≥40kW / |z|≥3 / ≥2.5× — 我们的判别阈值) # 留 时间口径 (≥1h 段 / ≥3 天 — 业主要据此理解统计范围) # 留 物理常数 (Cp ≤ 0.593 贝兹极限 — 公开知识, 不是我们的) # 留 台数与占比 (≥2 个系统报警 — 业主自己数得出来) # 内部模式: WINDSCADA_INTERNAL=1 import os as _os INTERNAL = _os.environ.get('WINDSCADA_INTERNAL', '') == '1' # ★ Hz 后不能用 \b: 中文属于 \w, "597Hz线" 里 z 与 线 之间没有词边界 ⇒ \b 不成立, 实测漏脱 WTG05 的 597Hz。 # ★ 前瞻也不能用 \w: 中文同属 \w, "HS_gen测点274Hz" 里 274 前是"点" ⇒ 断言失败漏脱。 # 只需排除数字与小数点 (防止把 1274 截成 274)。 _RE_HZ = re.compile(r'(?r.st==='报警')` 零命中 → **报警 12 台整表不渲染**, # 而柱状条读的是 sysdist (**键**没被翻) 仍显示 "Alarm 12" ⇒ 数字与明细自相矛盾。 # · ostate 被翻成 Good/Excellent → `C[c.ostate]` (C 是中文键) 全部 MISS # → 融合矩阵**每一格颜色都退成灰**。 # 判据: 凡是 JS 里 `X[obj.字段]` 查中文键表、或 `obj.字段==='中文'` 比较的字段, 一律进本表。 # 显示层 (ui_en.value_script) 负责把渲染出来的中文文本换成英文, 所以人看到的仍是英文。 # tests/test_keep_zh_discriminators.py 扫源码断言本表齐全, 防以后加字段漏登记。 _KEEP_ZH = frozenset(( 'st', 'ostate', 'wlevel', '状态', '设备状态', '证据状态', '部件', 'liveness', 'loc', # 模型本地/云端档位: `v.loc==='本地'` 决定"数据不出本地"这句能不能说 # ★第三类判别用法, 本闸的扫描器 (tests/test_keep_zh_discriminators.py) 抓不到: # 不是比较也不是查表, 而是拼进 URL 路径当 ID (`/problem/${t}/${encodeURIComponent(x)}`)。 # syss 被翻成 'Yaw' 后, 浮层请求 sys=Yaw 永远查不到烤的是 sys=偏航 的快照键, # 点开必是"本窗无记录"——页面不报错, 数据静默消失, 和 st/ostate 那两类同一后果不同机制。 # 2026-09-03 用户实测逮出 (Watch list → 系统名链接)。显示文字仍由 sysName(x) 客户端译。 'syss', )) # ★按**值**豁免 —— 给 `k` 这种一名多用的字段用。 # `k` 在告警排行里是显示串 (`"7122 偏航液压油位低"`, 要翻), # 在控制状态与部件配色里是枚举键 (`'停机'` / `'齿轮箱'`, 不能翻)。 # 键名分不开两者, 只能按值。2026-09-02 实测: 我先把 'k' 整个放进 _KEEP_ZH, # 结果**报警名全部不再翻译** —— 修一个洞挖一个洞, 按值豁免才收得住边界。 # 来源: JS 里 CO / LOSSPLAIN 两张中文键表的键 + `x.k==='停机'` 比较。 _KEEP_ZH_VALS = frozenset(( '齿轮箱', '发电机', '主轴承', '变桨叶片', '偏航', '测风', '其他', # CO 配色键 '限电·绑定', '限电命令·未绑定', '停机(调度令)', '停机', '正常发电', '低风待机', # LOSSPLAIN 键 )) def _proj(o, lang): """把 `key_zh`/`key_en` 投影成裸 `key`。 英文侧取不到 `_en` 时写 `[translation pending]` 而**不回落中文** —— 回落会让缺口永远不被发现, 正是英文版残留三轮没被逮住的机制。 """ if isinstance(o, list): return [_proj(x, lang) for x in o] if not isinstance(o, dict): return o suf = '_' + lang other = '_zh' if lang == 'en' else '_en' # ★裸 `zh` / `en` 成对 (framework.py 的 SYSTEMS/RECORDS/stages 就是这个写法)。 # 它们不带下划线, 走不到下面的后缀规则; 而末尾"残留中文一律写 [translation pending]" # 的兜底会把 zh 打成 pending —— **旁边的 en 明明就是对的**。实测 145 处 pending # 里的大多数是这一种。消费端 JS 读的是 `.zh`, 所以英文版把 en 的值投影到 zh 上。 if lang != 'zh' and isinstance(o, dict) and 'zh' in o and 'en' in o: o = dict(o, zh=o['en']) # ★只有 k_en **有值**时才让裸 k 让位 (2026-09-01 parity 闸逮)。 # 原来无条件让位: 上游给了 action_en=None, 裸 action 就被顶成 None —— # 页面上显示 "None", 比留中文糟得多。空的语言副本等于没给, 不该覆盖原值。 out = {} source_fields = {'name', '步骤', '故障', '排查', '原因', '处置', 'check'} originals = {key: o.get(key + '_zh', o.get(key)) for key in source_fields} if lang == 'en' else {} originals = {key: value for key, value in originals.items() if isinstance(value, str) and _CJK.search(value)} langed = {k[:-len(suf)] for k in o if k.endswith(suf) and o[k] not in (None, '', [])} for k, v in o.items(): if k == '_original': out[k] = dict(v) if isinstance(v, dict) else v continue if k.endswith(other): # 另一语言的副本不出 API continue if k.endswith(suf): if v not in (None, '', []): out[k[:-len(suf)]] = _proj(v, lang) continue if k in langed: # 已有 key_, 裸 key 让位 continue v2 = _proj(v, lang) if k in _KEEP_ZH or (isinstance(v2, str) and v2 in _KEEP_ZH_VALS): out[k] = v2 # 判别值/查表键: 原样留中文 (见 _KEEP_ZH 注释) continue # 枚举层先查码表 — 这些值有确定译名, 不该退化成 [translation pending]。 # 列表型枚举 (如 facets) 逐项查。 if lang != 'zh' and _codes is not None and k in _codes.CODES: if isinstance(v2, str): c = _codes.normalize(k, v2) if c: v2 = _codes.label(k, c, lang) else: v2 = _enum_fallback(k, v2, lang) elif isinstance(v2, list): v2 = [(_codes.label(k, _codes.normalize(k, x), lang) if isinstance(x, str) and _codes.normalize(k, x) else x) for x in v2] # 单数字段名对应复数码表 (facet ← facets) 也走一遍 elif lang != 'zh' and _codes is not None and k.rstrip('s') in _codes.CODES: f = k.rstrip('s') if isinstance(v2, str): c = _codes.normalize(f, v2) if c: v2 = _codes.label(f, c, lang) elif isinstance(v2, list): v2 = [(_codes.label(f, _codes.normalize(f, x), lang) if isinstance(x, str) and _codes.normalize(f, x) else x) for x in v2] # ★这里原来只判 isinstance(v2, str) —— **字符串列表整个绕过了英文投影**。 # _proj 递归进 list, 但 _proj(str) 原样返回, 所以 ["偏航","变桨"] 这种 # 一路穿到 API。实测漏掉 5 条路径 (parts[] / syss[] / srcs[] / # open_items[] / blind[]), 是英文页数据层残留中文的唯一来源。 if lang == 'en': # Action counts use display labels as dictionary keys, so _en_deep # cannot reach them. Only this display map uses the established # work-order action glossary; all other dictionary/URL IDs stay put. if k == '动作统计' and isinstance(v2, dict): from src.windscada.maint.workorder_en import ACTION v2 = {ACTION.get(action) or action: count for action, count in v2.items()} v2 = _en_deep(v2, k) out[k] = v2 for key, original in originals.items(): if key in out and out[key] != original: out.setdefault('_original', {})[key] = original return out def _en_deep(v, field='', _depth=0): """对字符串与**字符串列表/嵌套列表**一律走 _data_text_en。 只对 str 生效的旧写法会让 list-of-str 静默穿透 —— 这类漏法的特征是 "抽查单值全对, 页面上却成片中文", 因为漏的是容器不是值。 """ if isinstance(v, str): return _data_text_en(v, field) if _CJK.search(v) else v if isinstance(v, list) and _depth < 4: return [_en_deep(x, field, _depth + 1) for x in v] return v _DATA_EN_CACHE = {} _DATA_EN_MISS = set() # 查不到的原文集合 (与命中缓存分开: 未命中的返回值随请求模式而异) def _data_text_en(v, field=''): """数据层中文文本 → 英文。取不到才写 [translation pending]。 ★为什么要有这一层: 原实现是"残留中文一律 [translation pending]" —— 干净但过于绝对。 实测 /api/fleet 一次就打出 **2199 处** pending, 而其中大量是厂商报警名、工单原文、 部件名、判级词, 这些在本项目里**已经有译源**: 厂商中英对照表 3834 条、IPC 部件表 2113 条、枚举码表、工单名构词表。不查就直接标 pending, 等于把已有的资产扔掉, 还让英文页看起来像没做完。 查不到仍写 pending —— **不猜译**。这条不能松: 猜译出来的厂商报警名会误导现场。 """ if v in _DATA_EN_CACHE: return _DATA_EN_CACHE[v] if v in _DATA_EN_MISS: return v if getattr(_REQ, 'keep_orig', False) else '[translation pending]' out = None # ⓪⁻ 数据层**整串**直译表 (融合面盲区/未决项长文)。放在最前: 这些是整段人写的 # 分析结论, 走模板反解或分层词表都只会碎成片段。 try: from src.windscada import data_tpl_en as _tplN out = getattr(_tplN, 'NOTES', {}).get(v) if out is None: from src.windscada.en_text import standard_term out = standard_term(v) if out is None: out = getattr(_tplN, 'RECORD_EN', {}).get(v) except Exception: out = None if out is not None: _DATA_EN_CACHE[v] = out return out # ⓪ 「码 + 中文描述」→ 本体 AlarmCode 的权威 name_en (查表, 不翻译) try: from src.windscada import en_text as _et0 out = _et0.alarm_en(v) except Exception: out = None if out is not None: _DATA_EN_CACHE[v] = out return out # ① 服务端拼串走**模板反解** (精确, 非翻译): 抽数字得形态 → 查英文模板 → 数字按序填回 try: from src.windscada import data_tpl_en as _tpl out = _tpl.render(v) except ValueError as e: print('[_data_text_en] 模板占位符对不上, 已跳过不填: %s' % e, file=sys.stderr) out = None except Exception: out = None if out is not None: _DATA_EN_CACHE[v] = out return out try: from src.windscada import en_text as _et # ① 分层查表 (厂商报警 → IPC 部件 → 枚举/文案) for sc in ('fault', 'part', 'ui', None): hit, _src = _et.lookup(v, sc) if sc else _et.lookup(v) if hit: out = hit break # ② 部件自由文本解析 (复合名/带注解/多部件连写) if out is None: out = _et.part_en(v) except Exception: pass # ②b 词表直查 —— 部件名/判级词/系统名作为**独立字段**出现时 (fus.表[].部件 / .振动结论 / # rel.表[].部件), 既不是模板串也不在 en_text 分层表里, 而 data_tpl_en 的 # CHAN/GRADE2/SYSTEM_EN 早就登记了同一批词 (供模板内部用)。不查就整片留中文 # (2026-09-03 实逮 fus.表 328 字)。**同一批词只因出现位置不同就译不出, 是接线漏不是词条漏。** if out is None: try: from src.windscada import data_tpl_en as _t2 out = _t2.CHAN.get(v) or _t2.SYSTEM_EN.get(v) if out is None: g = _t2.GRADE2.get(v) or _t2.GRADE.get(v) if g: # 判级词在表里是小写短语 (模板内嵌用), 独立成字段要首字母大写 out = g[0].upper() + g[1:] except Exception: pass if out is None: try: from src.windscada.maint import workname_en as _wn # ★只用 work_name_en (逐段都命中才拼, 命不中返回 None), **不用 category_en**。 # category_en 是正则归类兜底: 它对看不懂的文本也会给出一个"像样"的答案。 # 实测它把化验样注记译成 work item: # '末样2025-01-09 (距振动现窗>12月, stale: 只作检测链回测)' # → 'Condition monitoring — work item' ← 错, 那不是工单 # '末样2026-07-13: 华标判正常(NB/T宽限); 重点值…' # → 'Generator — work item' ← 错且误导 # 我在本函数的说明里写了"不猜译"然后把猜译器接了进来。 # **看得懂就译, 看不懂就标 pending —— pending 是诚实的, 猜译不是。** out = _wn.work_name_en(v) except Exception: pass if out: _DATA_EN_CACHE[v] = out return out # 查不到: 经典页写 [translation pending] (显示层再藏); v2 (keep=1) **留原文** —— 前端按原文 # 标注为"手册原文", 不丢信息也不冒充英文。命中与未命中分开缓存, 两种模式互不污染。 _DATA_EN_MISS.add(v) return v if getattr(_REQ, 'keep_orig', False) else '[translation pending]' def maint_framework_view(): """运维管理体系三维骨架 —— **唯一实现** (在线 API 与快照预烤共用)。 ★同 dq_findings_view 的教训: 这段原来也是两份, 在线那份我接了 unmapped_top 的 英文化, 预烤那份没有 ⇒ 在线干净、快照里还是中文, 而上云的是快照。 抽出来一次, 消掉这类"改了一半"的可能。 """ import json as _j, pathlib as _p from src.windscada.maint import framework as _fw db = _j.loads(_P.objects_json().read_text(encoding='utf-8')) wo = [(v.get('props') or {}) for k, v in db.items() if k.startswith('workorder/')] r = dict(systems=_fw.system_load(wo)['rows'], tiers=_fw.systems_by_tier(), stages=_fw.stage_matrix(), records=_fw.RECORDS, gaps=_fw.capability_gaps(wo), coverage=_fw.coverage(wo)) # unmapped_top = **未映射到系统码的现场手写部件名**, 不是枚举, 码表投影对它无效。 # 走 part_en 自由文本解析; 空值在这个语境下写 "(not recorded)" 而非破折号。 lang = _lang_of() if lang and lang != 'zh' and (r.get('gaps') or {}).get('unmapped_top'): try: from src.windscada import en_text as _et out = [] for it in r['gaps']['unmapped_top']: if isinstance(it, (list, tuple)) and len(it) == 2: e = _et.part_en(it[0]) or it[0] out.append(['(not recorded)' if e == '\u2014' else e, it[1]]) else: out.append(it) r['gaps'] = dict(r['gaps'], unmapped_top=out) except Exception as e: print('[maint_framework_view] unmapped_top 英文化失败: %s' % e, file=sys.stderr) return r def dq_findings_view(): """数据可信度 finding 行 —— **唯一实现**。 ★2026-08-31: 这段原来存在两份 —— 一份在 /api/dq_findings 处理分支, 一份在快照的 `_dq()` 预烤闭包里。我给 API 那份补了 sensitivity / age_basis 等字段, 预烤那份没动 ⇒ **在线看得到, 快照里没有**。而上云交付的恰恰是快照。 同型教训已在 memory `library-gate-not-wired-to-production`: 落库不等于接上生产路径。 这里直接消掉重复: 两边都调本函数。 """ import json as _j, pathlib as _p db = _j.loads(_P.objects_json().read_text(encoding='utf-8')) KEEP = ('finding/data_quality/', 'finding/caliber/', 'finding/system_coverage/', 'finding/tier_trend/') rows = [] for k, o in db.items(): if not k.startswith(KEEP) or not isinstance(o, dict): continue p2 = o.get('props') or {} rows.append(dict( id=k, kind=k.split('/')[1], name=p2.get('name_zh') or p2.get('name'), name_zh=p2.get('name_zh') or p2.get('name'), name_en=p2.get('name_en'), grade=p2.get('grade'), claim_window=p2.get('claim_window'), evidence=p2.get('evidence_zh') or p2.get('evidence') or [], evidence_zh=p2.get('evidence_zh') or [], evidence_en=p2.get('evidence_en') or [], caveat=p2.get('caveat'), caveat_en=p2.get('caveat_en'), impact=p2.get('discriminator_zh') or p2.get('disposition_zh') or p2.get('implication_zh') or p2.get('corrected_comparison_zh'), impact_en=p2.get('discriminator_en') or p2.get('disposition_en') or p2.get('implication_en') or p2.get('corrected_comparison_en'), got_wrong=p2.get('what_i_got_wrong_zh'), got_wrong_en=p2.get('what_i_got_wrong_en'), quality_flag=p2.get('quality_flag_zh'), quality_flag_en=p2.get('quality_flag_en'), robust=p2.get('robust_zh'), robust_en=p2.get('robust_en'), basis=p2.get('basis_zh'), basis_en=p2.get('basis_en'), sensitivity=(p2.get('sensitivity') or {}).get('note_zh'), sensitivity_en=(p2.get('sensitivity') or {}).get('note_en'), age_basis=p2.get('age_basis_zh'), age_basis_en=p2.get('age_basis_en'), )) rows.sort(key=lambda x: (x['kind'] != 'data_quality', x['id'])) return dict(rows=rows, n=len(rows)) def _en_page(page): """lang=en 时把页面文案换成英文。**中文版不经过这里** (lang 空 → 直接返回原页)。 用的是 ui_en 的**整条短语表**, 不是 i18n_en 的逐词词表 —— 后者会产出 "Verification 状态机" 这类混杂串 (已实测作废)。缺条目由 ui_en.audit 报出, 不静默留在页面上。 """ lang = _lang_of() if not lang or lang == 'zh': return page try: from src.windscada import ui_en page = ui_en.apply(page) # ★译后语法闸: 保护规则一松一紧都能把 JS 译坏, 而症状是**空白骨架页** —— # 此时"可见中文=0", 自查全绿。必须用外部解析器判, 不能靠数引号。 for _m in re.finditer(r']*>(.*?)', page, re.S): _code = _m.group(1) if _code.strip() and 'application/json' not in _m.group(0): ui_en.check_js(_code, label='英文页内联脚本') # 数据值 (报警/定论/工单…) 既做比较又要显示。比较照旧用中文, 显示层在 render # 之后替换文本节点 —— 只碰显示不碰逻辑, 所以判断不会失效。 return page.replace('', ui_en.value_script() + '') \ if '' in page else page + ui_en.value_script() except Exception as e: # ★退回中文页比给个坏页安全, 但**必须响亮** —— 静默退回会让"英文版没做完" # 看起来像"英文版做完了只是有中文残留"。守护步骤失败必须可见。 print('[_en_page] 🔴 英文化失败, 已退回中文页: %s: %s' % (type(e).__name__, e), file=sys.stderr) return page def _jdump(obj, **kw): o = _redact(_jclean(obj)) lang = _lang_of() if lang and lang != 'zh': # 中文版走这行以外的路径, 一字不动 o = _proj(o, lang) return json.dumps(o, allow_nan=False, **kw) from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from src.windscada.config import farm from src.windscada import taxonomy from src.windscada.subsys import temp_nbm, hydraulic, yaw as yawmod, pitch as pitchmod from src.windscada.perf import availability from src.windscada import i18n CFG = farm(); ST = pathlib.Path(CFG['store']) _CJK = re.compile(r'[\u4e00-\u9fff]') def vibcms_results(): """windcms 评估报告 → 结果层转录 (2026-08-28 用户令: 生接改融合·只显示结果·模型与计算隐藏). 取最新一期报告md; '融合级(模型)/CMS红黄'两列=模型输出, 不出结果层; 分析功能留独立cms.""" try: rp = sorted(ST.parent.glob('windcms/报告_CMS振动状态评估报告_*.md'))[-1] md = rp.read_text() date = rp.stem.rsplit('_', 1)[-1] overall = '' if '### 3.1' in md: seg = md.split('### 3.1')[1].split('###')[0] overall = next((l.strip() for l in seg.splitlines() if l.strip().startswith('全场')), '') def _tab(sec): return [[c.strip() for c in l.strip('|').split('|')] for l in sec.splitlines() if l.startswith('| WTG')] action = _tab(md.split('### 3.2')[1].split('## ')[0]) if '### 3.2' in md else [] grades = [] if '## 附录 A' in md: for r in _tab(md.split('## 附录 A')[1]): # 列: 机组/前/后/齿/发/综合/融合级(模型·隐)/CMS红黄(隐)/行动 grades.append(dict(t=r[0], 主轴承前=r[1], 主轴承后=r[2], 齿轮箱=r[3], 发电机=r[4], 综合=r[5], 行动=r[8] if len(r) > 8 else '')) # 与融合矩阵(handoff 定谳链)口径差异台 from src.windscada.subsys import fusion as _f ft, _ = _f.fusion_table() hset = {r.turbine for _, r in ft.iterrows() if _f.verdict_class(str(r.振动结论))[1] in ('bad', 'warn')} cset = {r[0] for r in action} diff = dict(only_cms=sorted(cset - hset), only_handoff=sorted(hset - cset)) return dict(date=date, overall=overall, action=action, grades=grades, diff=diff) except Exception as e: return dict(error=f'windcms 报告解析失败: {e}') # 响亮, 不静默空表 LOCK = threading.Lock() _CACHE = {} class ProductsMissing(RuntimeError): """产物缺失 (outputs/<场>/… 被清空或尚未生成)。 2026-09-11 人工检查空状态时逮: 原来 `temp_monthly.parquet` 一缺, `_load()` 直接抛 FileNotFoundError, 外层处理器把它当**未知异常**回 500 + traceback —— 人看到的是"服务器错误", 分不清"没算产物"还是"程序坏了"。 现在这类缺失单独成类, 由请求层回**结构化的无数据**(HTTP 200 + err=no_products), 与 `adhoc_query` 的 `no_source` 一致。真 bug 仍然照旧 500 并带 traceback (不静默)。""" def __init__(self, name, path): super().__init__(f'产物缺失: {name} (期望 {path})') self.what, self.path = name, path def _load(): with LOCK: if _CACHE.get('__loaded'): return tmp = {} for key, name in (('tm', 'temp_monthly.parquet'), ('al', 'alarms.parquet'), ('lm', 'loss_monthly.parquet'), ('bins', 'powercurve_bins.parquet'), ('pcd', 'powercurve_dev.parquet')): f = ST / name if not f.exists(): raise ProductsMissing(name, f) tmp[key] = pd.read_parquet(f) tmp['al']['month'] = tmp['al']['t_on'].dt.to_period('M').astype(str) tmp['pcd'] = tmp['pcd'].set_index('turbine') for key, name in (('duty', 'duty_monthly.parquet'),): f = ST / name tmp[key] = pd.read_parquet(f) if f.exists() else None try: tmp['sysmx'] = taxonomy.system_matrix() tmp['treg'] = temp_nbm.registry() tmp['treg'] = tmp['treg'][0] if isinstance(tmp['treg'], tuple) else tmp['treg'] tmp['hyd'], _ = hydraulic.registry() tmp['hyd'] = tmp['hyd'].set_index('turbine') except FileNotFoundError as e: # 这些派生件同样在产物仓里; 缺了就按"无产物"处理 raise ProductsMissing(getattr(e, 'filename', '派生产物'), getattr(e, 'filename', ST)) zp = _P.pitch() / 'pitch_zero_monthly.parquet' tmp['zero'] = pd.read_parquet(zp) if zp.exists() else None tmp['__loaded'] = True _CACHE.update(tmp) # 原子提交: 失败时不留下半截缓存 WINDOWS = ['近30日', '近90日', '2026年', '2026H1', '2025H2', '全程'] def months_of(win): _load() all_m = sorted(_CACHE['tm'].month.unique()) if win == '全程': return all_m if win == '2025H2': return [m for m in all_m if '2025-07' <= m <= '2025-12'] if win == '2026H1': return [m for m in all_m if '2026-01' <= m <= '2026-06'] if win == '2026年': return [m for m in all_m if m >= '2026-01'] import re as _re if _re.fullmatch(r'\d{4}-\d{2}', win): # 单月窗 (2026-08-27 用户令: 按月份过滤和选择) return [m for m in all_m if m == win] m2 = _re.fullmatch(r'(\d{4}-\d{2})~(\d{4}-\d{2})', win) if m2: # 月区间窗 "2025-04~2025-10" return [m for m in all_m if m2.group(1) <= m <= m2.group(2)] n = 1 if win == '近30日' else 3 return all_m[-n:] TS = {'报警': 0, '危险': 0, '良好': 1, '不可判': 2, '优秀': 3} # ---------- 图规格 (分册两型) ---------- def fig_temp(t, ch, win): tm = _CACHE['tm']; ms = months_of(win) g = tm[tm.channel == ch] piv = g.pivot_table(index='month', columns='turbine', values='med').reindex(ms) if t not in piv.columns or piv[t].dropna().empty: return None nm = i18n.ch_cn(ch, with_tag=False) return dict(kind='band', title=f'{t} {nm} 月度 (发电态P>500kW)', unit='℃', months=ms, self=[None if v != v else round(v, 2) for v in piv[t]], med=[None if v != v else round(v, 2) for v in piv.median(axis=1)], q1=[None if v != v else round(v, 2) for v in piv.quantile(.25, axis=1)], q3=[None if v != v else round(v, 2) for v in piv.quantile(.75, axis=1)], note='灰带=全场四分位区间, 深线=全场中位, 红线=本台') def fig_duty(t, ch, title, unit, win, thresholds=None): d = _CACHE['duty'] if d is None: return None ms = months_of(win) g = d[d.channel == ch] piv = g.pivot_table(index='month', columns='turbine', values='val').reindex(ms) if t not in piv.columns or piv[t].dropna().empty: return None return dict(kind='band', title=f'{t} {title}', unit=unit, months=ms, self=[None if v != v else round(v, 2) for v in piv[t]], med=[None if v != v else round(v, 2) for v in piv.median(axis=1)], q1=[None if v != v else round(v, 2) for v in piv.quantile(.25, axis=1)], q3=[None if v != v else round(v, 2) for v in piv.quantile(.75, axis=1)], thresholds=thresholds or [], note='灰带=全场四分位区间') def fig_alarm_bars(t, codes, title, win): al = _CACHE['al']; ms = months_of(win) g = al[(al.turbine == t) & (al.code.isin(codes)) & (al.month.isin(ms))] if not len(g): return None cnt = g.groupby('month').size().reindex(ms).fillna(0) return dict(kind='bars', title=f'{t} {title} 月度条数', unit='条', months=ms, vals=[int(v) for v in cnt], note='窗内合计 %d 条' % int(cnt.sum())) def fig_curve(t): b = _CACHE['bins']; g = b[b.turbine == t].sort_values('bin') if not len(g): return None dv = _CACHE['pcd'].loc[t] return dict(kind='xy', title=f'{t} 功率曲线 vs fleet中位 [固定判别窗 2025-H2, 机舱风口径]', unit='kW', x=[float(x) for x in g['bin']], self=[round(float(v), 1) for v in g.p], med=[round(float(v), 1) for v in g.fleet_p], xlab='风速 m/s', note=f"偏差 {dv.dev_w:+.1%} · 风速计偏置 {dv.ws_bias:+.2f} m/s · {dv['判别']}") def fig_zero(t, win): z = _CACHE['zero'] if z is None: return None tt = t.replace('WTG', '') g = z[(z.turbine.astype(str).str.contains(tt)) | (z.turbine == t)] if 'turbine' in z.columns else None if g is None or not len(g): return None ms = months_of(win) mcol = [c for c in g.columns if 'month' in c.lower() or '月' in c] if not mcol: return None g = g[g[mcol[0]].astype(str).isin(ms)] # 只画零位偏差本身 (样本数量纲不同, 混画会把判据线压扁 → 移入图注) n_med = int(g['n_rows'].median()) if 'n_rows' in g.columns and len(g) else 0 return dict(kind='multiline', title=f'{t} 桨叶零位偏差 月度', unit='°', months=[str(x) for x in g[mcol[0]]], series=[dict(name='零位偏差', vals=[round(float(v), 3) if v == v else None for v in g['zero_dev']])], thresholds=[dict(v=0.6, label='报警 0.6°'), dict(v=-0.6, label='')], note=f'判据: 变桨分册 §3.2 (报警门 ±0.6°); 月样本中位 {n_med} 行') SYS_ALARM = {'偏航': yawmod.FAM, '变桨': {'蓄能缸检': ['3411', '3412', '3413'], '泵/过热': ['7106', '7100'], '油位/滤芯': ['7101', '7103', '7104']}} SYS_CODES = { '变桨': ['3130', '3145', '3173', '3224', '3225', '3411', '3412', '3413', '3418', '7100', '7101', '7102', '7103', '7104', '7106', '9306', '9309'], '偏航': ['10100', '10104', '10105', '10113', '10125', '10201', '10400', '10401', '7122', '1012'], '齿轮箱': ['4101', '4102', '4103'], '变流器': ['13902', '13905', '19024', '19170', '19285', '20000', '63036'], '主控与传感网': ['8108', '8110', '8171', '8184', '8185', '8186', '8187', '63209', '63004'], } def fig_sys_alarms(t, sysname, win): """该台该系统报警族 月度条数 (与工单处理史并读).""" codes = SYS_CODES.get(sysname) if not codes: return None ms = months_of(win) al = _CACHE['al'] g = al[(al.turbine == t) & (al.code.isin(codes)) & (al.month.isin(ms))] if not len(g): return None v = g.groupby('month').size().reindex(ms).fillna(0) top = g.groupby('code').size().sort_values(ascending=False).head(3) note = '窗内合计 %d 条; top: ' % len(g) + '; '.join(f'{c} {i18n.alarm_label(c)[:12]}×{n}' for c, n in top.items()) return dict(kind='bars', title=f'{t} {sysname}系统 报警月度条数', unit='条', months=ms, vals=[int(x) for x in v], note=note) def turbine_problems(t, win): """有问题的系统 → 证据图 (分册式); 无问题不出.""" _load() sysd = _CACHE['sysmx'][t] treg = _CACHE['treg'] out = [] for s in taxonomy.SYSTEMS: st = sysd[s]['状态'] if st == '优秀': continue figs = [] # 温度证据 for _, r in treg[(treg.turbine == t)].iterrows(): ch = r['channel'] nm = ch.replace('tmp_wtc_', '').replace('_mean', '').replace('_max', '') if taxonomy.TEMP2SYS.get(nm) == s and str(r['判']) != '—': f = fig_temp(t, ch, win) if f: f['title'] = f"{t} {i18n.ch_cn(ch, with_tag=False)} — {r['判']} ({r['dev_K']:+.1f}K)"; figs.append(f) # 系统专属证据 if s == '变桨': hv = _CACHE['hyd'].loc[t] if hv['判'] != '—': f = fig_duty(t, 'HydPress_band', '变桨液压10min摆幅 月度', 'bar', win, thresholds=[dict(v=52, label='正常充压滞环≈52bar')]) if f: f['title'] = f"{t} 蓄能行为 — {hv['判']}"; figs.append(f) for nm, codes in SYS_ALARM['变桨'].items(): f = fig_alarm_bars(t, codes, f'变桨{nm}报警', win) if f: figs.append(f) f = fig_zero(t, win) if f: figs.append(f) if s == '偏航': yrow_d = dict(zip(['D1润滑不可能', 'D2过热', 'D2变流器警告', 'D2解缆', 'D3制动液压错误', 'D3油位低', 'D4偏航失败', 'D4风传感'], [0]*8)) f = fig_duty(t, 'dot_wtc_YawLubPu_timeon', '偏航润滑泵运行时长 月度', '秒/日', win) if f: figs.append(f) for nm, codes in yawmod.FAM.items(): f = fig_alarm_bars(t, codes, '偏航·' + nm.lstrip('D1234'), win) if f: figs.append(f) if s in ('主轴承', '齿轮箱', '发电机'): vf = vib_scalar_fig(t, s) if vf: figs.append(vf) # 振动驱动的问题 (taxonomy 振动映射) → 配套分析曲线: 部件双端温度 + 主轴承柱塞 duty if '振动:' in sysd[s]['依据']: vib_chs = {'主轴承': ['tmp_wtc_MBearGTm_mean', 'tmp_wtc_MBearHTm_mean'], '发电机': ['tmp_wtc_GenBeRTm_mean', 'tmp_wtc_GenBeGTm_mean'], '齿轮箱': ['tmp_wtc_IMSGenTm_mean', 'tmp_wtc_HSGenTmp_mean']}[s] for ch in vib_chs: f = fig_temp(t, ch, win) if f: f['note'] = (f.get('note') or '') + ' | 振动判级交叉轴: 温度=同机制果侧观测(观测面≠源数)'; figs.append(f) if s == '主轴承': f = fig_duty(t, 'din_wtc_MBLubPis_timeon', '主轴承润滑柱塞运行时长 月度 (因侧执行链)', '秒/日', win) if f: f['note'] = (f.get('note') or '') + ' | 29#签名: 单次时长塌→回升→2026-07单月突变(泵打不满)'; figs.append(f) # 振动摘要配传动链图形 (2026-08-28 用户令: 关联图形填入) — comp=依据里的部件短语, sev=结论级 _why = sysd[s]['依据'] _m = re.search(r'振动: ([^ ]+) ([^ (]+)', _why) _comp, _lv = (_m.group(1), _m.group(2)) if _m else (s, '') _sev = 'var(--bad)' if any(k in _lv for k in ('定论', '报警')) else ('var(--warn)' if '预警' in _lv else 'var(--ok)') figs.append(dict(kind='vib', title=f'{t} {s} 振动分析摘要', comp=_comp, sev=_sev, text=_why + ' | 谱级深潜与逐窗趋势 → 振动融合板块·振动分析入口 (windcms)')) if '配对差' in sysd[s]['依据']: pair_chs = {'主轴承': ['tmp_wtc_MBearHTm_mean', 'tmp_wtc_MBearGTm_mean'], '发电机': ['tmp_wtc_GenBeGTm_mean', 'tmp_wtc_GenBeRTm_mean']}.get(s, []) for ch in pair_chs: f = fig_temp(t, ch, win) if f: figs.append(f) if '润滑' in sysd[s]['依据']: ch = {'主轴承': 'din_wtc_MBLubPis_timeon', '齿轮箱': 'din_wtc_GBLubPis_timeon', '发电机': 'dot_wtc_GenLubPu_timeon'}[s] f = fig_duty(t, ch, f'{s}润滑 月度', '秒/日', win) if f: figs.append(f) if s == '主控与传感网': if '测风' in sysd[s]['依据'] or '风速计' in sysd[s]['依据']: f = fig_alarm_bars(t, yawmod.FAM['D4风传感'], '风传感故障报警', win) if f: figs.append(f) if '曲线判风速计' in sysd[s]['依据'] or '风速计偏置' in sysd[s]['依据']: f = fig_curve(t) if f: figs.append(f) # ★每个问题配: 该系统报警月度图 + 工单处理史 (用户令) try: from src.windscada.subsys import workorder as womod hist = womod.history(t, s) except Exception as e: hist = dict(n=0, rows=[], note=f'工单不可用 {e}'[:60], 动作统计={}, 窗='') af = fig_sys_alarms(t, s, win) if af: figs.append(af) # 排版 (2026-08-28 用户令"排版更合理"): ①同标题图去重 (配对差/温度证据两处会重复挂同一通道) # ②按阅读顺序排 — 结论定位(传动链) → 证据强度(振动标量) → 时序(温度/润滑/报警) → 曲线 seen, uniq = set(), [] for _f in figs: k = (_f.get('kind'), _f.get('title')) if k in seen: continue seen.add(k); uniq.append(_f) _ORD = {'vib': 0, 'vibbar': 1, 'band': 2, 'line': 3, 'bar': 4, 'xy': 5, 'text': 6} figs = sorted(uniq, key=lambda x: _ORD.get(x.get('kind'), 9)) out.append(dict(sys=s, 状态=st, 依据=i18n.humanize(sysd[s]['依据']), figs=figs, orders=hist)) dv = _CACHE['pcd'].loc[t] if t in _CACHE['pcd'].index else None if dv is not None and abs(dv.dev_w) > 0.03: f = fig_curve(t) out.append(dict(sys='功率曲线', 状态='报警' if 'A类' in str(dv['判别']) else '良好', 依据=f"偏差 {dv.dev_w:+.1%}, 风速计偏置 {dv.ws_bias:+.2f} m/s → {dv['判别']}", figs=[f] if f else [])) out.sort(key=lambda x: TS.get(x['状态'], 9)) return out LENSES = [('wsb', 'grd_wtc_ActPower_mean', 'L1 功率曲线 (风速-功率)', '风速 m/s (机舱风)', 'kW'), ('wsb', 'cp', 'L6 风能利用系数 Cp (风速-Cp)', '风速 m/s', 'Cp'), ('wsb', 'lam', 'L5 叶尖速比 λ (风速-λ)', '风速 m/s', 'λ'), ('grb', 'tq', 'L4 转矩-转速 (发电机侧)', '发电机转速 rpm', 'kNm'), ('pwb', 'tur_wtc_PitcPosA_mean', 'L2 功率-桨距 (控制律/削峰)', '功率 kW', '°'), ('pwb', 'tur_wtc_GenRpm_mean', 'L3 功率-发电机转速 (饱和点)', '功率 kW', 'rpm'), ('pwb', 'p3', 'L2b 三叶桨距极差 (集距/不平衡)', '功率 kW', '°'), ('pwb', 'ratio', 'L7 转速比 gen/rot (传动链)', '功率 kW', '—')] def curve_view(): import pandas as pd """特性曲线多镜头 (SOP §4.6c): fleet 中位+四分位带 + 过绝对锚的离群台曲线.""" from src.windscada.perf import curves as cv figs = [] for xc, yc, title, xlab, unit in LENSES: r = cv.lens(xc, yc) if not r: continue outs = sorted(r['离群'].items(), key=lambda kv: -abs(kv[1]['z']))[:3] a = r['anchor'] series = [dict(name='全场中位', vals=r['fleet'])] series += [dict(name=f"{t.replace('WTG','')}# {v['resid']:+.4g}{a['单位']}", vals=r['per_t'][t]) for t, v in outs] figs.append(dict(kind='multiline', title=title, unit=unit, months=[f"{x:g}" for x in r['x']], series=series, xlab=xlab, note=(f"显著门 |残差|≥{a['门']}{a['单位']} ({a['说明']}) ∧ |z|≥3 → 离群 " f"{'/'.join(t.replace('WTG','')+'#' for t,_ in outs) if outs else '无'}" f" | 灰域=全场四分位, 逐档中位聚合, 已剥限电(只正常发电态)" f" | 样本 {r['n_total']:,} 个10min点 / {r['n_turbines']} 台 / 最小档 {r['n_min_bin']:,}" f" | 窗 {r['win']} (固定判别窗, 不随上方窗切换)"), band=dict(q1=r['q1'], q3=r['q3']))) # 残差视图 (§4.6c④ per机必算同型残差): 绝对量尺度上四分位带只有几个像素, 残差面才看得见 if outs: rs = [dict(name=f"{t.replace('WTG','')}# {v['resid']:+.4g}{a['单位']}", vals=[None if (r['per_t'][t][i] is None or r['fleet'][i] is None) else round(r['per_t'][t][i] - r['fleet'][i], 4) for i in range(len(r['x']))]) for t, v in outs] q1d = [round(r['q1'][i] - r['fleet'][i], 4) for i in range(len(r['x']))] q3d = [round(r['q3'][i] - r['fleet'][i], 4) for i in range(len(r['x']))] figs.append(dict(kind='multiline', title=title.split(' (')[0] + ' · 同型机群残差 (本台−全场中位)', unit=unit, months=[f"{x:g}" for x in r['x']], series=rs, xlab=xlab, thresholds=[dict(v=a['门'], label=f"显著门 +{a['门']}{a['单位']}"), dict(v=-a['门'], label='')], band=dict(q1=q1d, q3=q3d), note=(f"虚线=物理绝对锚 ±{a['门']}{a['单位']} ({a['说明']}); 灰域=全场四分位残差带; 出带且过锚才算离群" f" | 样本 {r['n_total']:,} 点 / {r['n_turbines']} 台 | 窗 {r['win']}"))) # ---- 时序件: 控制参数月度 (M9b) + 偏航动态月度 (M12) ---- def _ts(path, val, title, unit, xlab, picks=None, note='', thr=None): f = ST / path if not f.exists(): return None d = pd.read_parquet(f) if val not in d.columns: return None piv = d.pivot_table(index='month', columns='turbine', values=val) ms = [str(x) for x in piv.index] med = piv.median(axis=1) dev = (piv.sub(med, axis=0)).abs().mean() picks = picks or list(dev.sort_values(ascending=False).head(3).index) series = [dict(name='全场中位', vals=[None if v != v else round(float(v), 3) for v in med])] series += [dict(name=t.replace('WTG', '') + '#', vals=[None if v != v else round(float(v), 3) for v in piv[t]]) for t in picks if t in piv.columns] # 样本量与窗随图走 (2026-08-28 门禁 F1): 7 张时序图原来只有结论没有依据规模, # 违反第一性原理②"Sample size declared"。逐月×台的非空格数即样本量。 _n = int(piv.notna().to_numpy().sum()) _nt = int(piv.shape[1]) note2 = (note + f" | 样本 {_n:,} 个月×台 / {_nt} 台 / {len(ms)} 个月" f" | 窗 {ms[0]}~{ms[-1]} (月度聚合, 不随上方窗切换)") if ms else note return dict(kind='multiline', title=title, unit=unit, months=ms, series=series, xlab=xlab, note=note2, thresholds=thr or [], band=dict(q1=[None if v != v else round(float(v), 3) for v in piv.quantile(0.25, axis=1)], q3=[None if v != v else round(float(v), 3) for v in piv.quantile(0.75, axis=1)])) for args in ( ('control_monthly.parquet', 'p_cap', '时序① 满发功率封顶 月度 (取每月高位稳定值; 阶跃=配置变更)', 'kW', '月', None, '灰域=全场四分位; 参数级分组见发电性能页 (4175/4200/4225 三组); 阶跃=配置变更, 平移=工况'), ('control_monthly.parquet', 'w_cap', '时序② 转速封顶 月度 (取每月高位稳定值)', 'rpm', '月', None, '两轴独立分组 (1672/1680)'), ('control_monthly.parquet', 'pitch_rated', '时序③ 额定段桨距角 月度 (标定漂移)', '°', '月', ['WTG03', 'WTG34', 'WTG19'], '离群台=桨距调度同档差 top3'), ('yaw_dynamic_monthly.parquet', 'err_sd', '时序④ 对风散布 σ 月度 (原始, 未清洗)', '°', '月', ['WTG08', 'WTG16', 'WTG04'], '三台19个月全程高 → 非发作型; 清洗后降至5-7°, 判为机舱位置通道拖偏(A类数据质量)非对风故障'), ('yaw_dynamic_monthly.parquet', 'travel_day', '时序⑤ 偏航行程 月度 (抗采样物理量)', '°/日', '月', None, '行程=活动量代理, 非真磨损量; 与状态位次数互核'), ('yaw_dynamic_monthly.parquet', 'twist_span', '时序⑥ 扭缆角月跨度 (ScYawPos 累计位置)', '°', '月', None, '解缆动作后回零'), ('yaw_dynamic_monthly.parquet', 'pump_med', '时序⑦ 偏航泵压 月度 (小站泵压)', 'bar', '月', None, '全场极齐 → 无泵压异常'), ): f = _ts(*args) if f: figs.append(f) return dict(figs=figs, physics=cv.physics_check(), note=('镜头口径 (SOP §4.6c): X轴优先功率(直测干净量); 机舱风 self-ref → 只判形状/相对, ' '绝对达成率须现场测风; per机异常必算同型机群残差(禁眼估); 工况段筛选=剔除0kW与低转速混合态档')) _VIBSENS = {'主轴承': ['Main_bearing_front', 'Main_bearing_rear'], '齿轮箱': ['Gear_planet', 'Gear_IMS', 'Gear_HS_rotor_side', 'Gear_HS_generator_side'], '发电机': ['Generator_DE', 'Generator_NDE']} _SENS_CN = {'Main_bearing_front': '主轴承前', 'Main_bearing_rear': '主轴承后', 'Gear_planet': '行星级', 'Gear_IMS': '中间轴', 'Gear_HS_rotor_side': '高速轴转子侧', 'Gear_HS_generator_side': '高速轴发电机侧', 'Generator_DE': '发电机DE', 'Generator_NDE': '发电机NDE'} _VIBMET = [('Peak', '冲击峰值'), ('Kurtosis', '峭度'), ('Rms_Vel', '速度有效值'), ('iso_rms', 'ISO烈度')] _VIBC = {} def vib_scalar_fig(t, sysname): """TCM 标量: 本台 vs 全场中位 (按测点×指标) + 六窗趋势. 数据=振动线 m5_cms_tcm 产物, 本页只转录不判级.""" import pandas as _pd if 'z' not in _VIBC: base = _P.m5() try: _VIBC['z'] = _pd.read_parquet(base / 'fleet_scalar_z.parquet') _VIBC['tr'] = _pd.read_parquet(base / 'scalar_trend_6win.parquet') except Exception: _VIBC['z'] = _VIBC['tr'] = None z, tr = _VIBC['z'], _VIBC['tr'] if z is None: return None sens = _VIBSENS.get(sysname, []) if not sens: return None g = z[(z.turbine == t) & (z.sensor.isin(sens)) & (z.meas.isin([m for m, _ in _VIBMET]))] if not len(g): return None # 取最高功率档 (载荷高处信噪比最好); 该档缺则退最近档 order = sorted(g.bin.unique()) gb = g[g.bin == order[-1]] rows = [] for sn in sens: for mk, mcn in _VIBMET: r = gb[(gb.sensor == sn) & (gb.meas == mk)] if not len(r): continue r = r.iloc[0] tv = None if tr is not None: q = tr[(tr.turbine == t) & (tr.sensor == sn) & (tr.metric == mk)] if len(q): tv = float(q.iloc[0]['ratio']) rows.append(dict(测点=_SENS_CN.get(sn, sn), 指标=mcn, 本台=round(float(r.val), 3), 全场中位=round(float(r.fleet_med), 3), z=round(float(r.z), 2), 倍中位=round(float(r.val) / float(r.fleet_med), 2) if r.fleet_med else None, 六窗比=round(tv, 2) if tv else None)) if not rows: return None return dict(kind='vibbar', size='L', title=f'{t} {sysname} 振动标量 · 本台 vs 全场 ({str(order[-1]).replace("WPS-ActivePower ", "功率 ").rstrip(",")} kW 档)', rows=rows, note='数据=TCM 六窗标量 (振动线 m5_cms_tcm 产物转录); z=fleet 稳健离群度, 六窗比=末窗/首窗 (>1 上升)') def single_problem(t, sys, win): """只回该台该系统这一个问题 (用户令: 点开就是一个问题, 其他不带出来).""" return [p for p in turbine_problems(t, win) if p['sys'] == sys] def fleet_view(win): _load() ms = months_of(win) sysmx = _CACHE['sysmx'] # ① 系统分类问题 (全系统) systems, sysdist = {}, {} for s in taxonomy.SYSTEMS: rows = [dict(t=t, st=sysmx[t][s]['状态'], why=i18n.humanize(sysmx[t][s]['依据'])) for t in CFG['turbines'] if sysmx[t][s]['状态'] in ('报警', '不可判')] # 良好不出 (用户令: 只显示有问题的) rows.sort(key=lambda r: TS.get(r['st'], 9)) systems[s] = rows # 全场分布 (系统入口卡用: rows 只含问题台, 不能当全场分母 — 会显示"共12台") from collections import Counter as _C sysdist[s] = dict(_C(sysmx[t][s]['状态'] for t in CFG['turbines'])) # 良好台清单单列 (系统详情页第三档) systems[s + '·良好'] = [dict(t=t, st='良好', why=i18n.humanize(sysmx[t][s]['依据'])) for t in CFG['turbines'] if sysmx[t][s]['状态'] == '良好'] # ①b 关注清单: ≥2 系统报警 (逐台独立台页) watch = [] for t in CFG['turbines']: al_sys = [x for x in taxonomy.SYSTEMS if sysmx[t][x]['状态'] == '报警'] if len(al_sys) >= 2: watch.append(dict(t=t, n=len(al_sys), syss=al_sys, # ★不截断: 截到 70 字会把依据切成半句, 而模板反解要完整形态 # ⇒ 英文侧整条落回中文 (2026-09-03 实逮, 总览 7 卡 4 张中文)。 # 显示长度归前端 CSS 管。 why=';'.join(f"{x}: {i18n.humanize(sysmx[t][x]['依据'])}" for x in al_sys))) watch.sort(key=lambda r: -r['n']) n_alarm_t = sum(1 for t in CFG['turbines'] if any(sysmx[t][x]['状态'] == '报警' for x in taxonomy.SYSTEMS)) # ② 故障统计梳理 (真时间窗) al = _CACHE['al']; a = al[al.month.isin(ms)] top_txt = a.groupby(['code', 'text']).agg(n=('code', 'size'), dur_h=('dur_s', lambda x: x.sum() / 3600)).reset_index() pareto_n = top_txt.sort_values('n', ascending=False).head(12) pareto_d = top_txt.sort_values('dur_h', ascending=False).head(12) monthly = a.groupby('month').size().reindex(ms).fillna(0) per_t = a.groupby('turbine').size().sort_values(ascending=False).head(10) # 经典故障分析: 条数与时长必须配对看 (2026-08-28)。两个独立排行榜分不出 # "高频短时(信号抖动或重复触发)" 与 "低频长时(硬故障)" — 处置方向相反, 混在一起会误派工。 # 第三维=影响台数, 分离"单台刷屏"与"全场批次共性"。 # 残月识别 (2026-08-28 审核逮): 2026-07 只有 6 天数据(覆盖 19.4%), 却被当整月 # 并进"2026年"的占比与月度趋势 → 末柱"下降"是假象。占比类分母也因此不完整。 import calendar as _cal _lmc = _CACHE['lm']; _lmc = _lmc[_lmc.month.astype(str).isin(ms)] _cov = {} for _mm in ms: _sub = _lmc[_lmc.month.astype(str) == _mm] if not len(_sub): _cov[_mm] = 0.0; continue _y, _mo = int(_mm[:4]), int(_mm[-2:]) _cal_h = _cal.monthrange(_y, _mo)[1] * 24 _cov[_mm] = round(float(_sub['rows_'].sum() / 6 / max(_sub['turbine'].nunique(), 1) / _cal_h), 3) _nt = a.groupby(['code', 'text'])['turbine'].nunique().rename('nt') _med = a.groupby(['code', 'text'])['dur_s'].median().rename('med_s') qd = top_txt.set_index(['code', 'text']).join([_nt, _med]).reset_index() qd = qd.sort_values('n', ascending=False).head(30) faults = dict( pareto_n=[dict(k=f"{r.code} {i18n.alarm_label(r.code, r.text)[:18]}", v=int(r.n)) for _, r in pareto_n.iterrows()], pareto_d=[dict(k=f"{r.code} {i18n.alarm_label(r.code, r.text)[:18]}", v=round(float(r.dur_h), 1)) for _, r in pareto_d.iterrows()], monthly=dict(months=ms, vals=[int(v) for v in monthly], cov=[_cov.get(x, 0.0) for x in ms], per_day=[round(float(monthly[x]) / max(_cov.get(x, 0.0) * _cal.monthrange(int(x[:4]), int(x[-2:]))[1], 1e-9), 1) if _cov.get(x, 0) > 0.02 else None for x in ms]), cov=_cov, cov_min=min(_cov.values()) if _cov else 1.0, partial=[x for x in ms if _cov.get(x, 1) < 0.5], quad=[dict(code=str(r.code), k=i18n.alarm_label(r.code, r.text)[:20], n=int(r.n), h=round(float(r.dur_h), 1), nt=int(r.nt), med=round(float(r.med_s), 1)) for _, r in qd.iterrows()], n_codes=int(len(top_txt)), per_t=[dict(k=k, v=int(v)) for k, v in per_t.items()], total=int(len(a))) # ③ SOP 控制策略 lm = _CACHE['lm']; l = lm[lm.month.astype(str).isin(ms)] hrs = l.groupby('state')['rows_'].sum() / 6 loss = l.groupby('state')['loss'].sum() / 1000 hrs_tot = max(hrs.sum(), 1) stop_h = hrs.get('停机', 0) + hrs.get('停机(调度令)', 0) disp_h = hrs.get('停机(调度令)', 0) lw_h = hrs.get('低风待机', 0) # 口径与 availability.summary 单源一致: 1 − 停机/(总 − 调度令 − 低风待机) avail = 1 - (stop_h - disp_h) / max(hrs_tot - disp_h - lw_h, 1) pcd = _CACHE['pcd'] # 能量账闭合 (2026-08-28 经典分析): 四行工况表看不出"电量流向"。 # 理论可发 = 实际上网 + Σ各态损失; 过物理上限核 (不可超 38台×4.0MW×窗时长)。 _act = l.groupby('state')['act'].sum() / 1000 _wf = [dict(k=str(k), loss=round(float(loss.get(k, 0)), 0), act=round(float(_act.get(k, 0)), 0), h=round(float(hrs.get(k, 0)), 0)) for k in hrs.index] _A = float(_act.sum()); _L = float(loss.sum()) _cap = len(CFG['turbines']) * 4.0 * float(hrs_tot) / max(len(CFG['turbines']), 1) # 逐月能量 (2026-08-28 报表端口): 汇报要环比与趋势, 全窗聚合给不出。 # 残月覆盖率一并带出 — 报表里拿残月和整月比环比会读反 (故障月度图已踩过一次)。 _em = [] for _mm in ms: _sub = l[l.month.astype(str) == _mm] if not len(_sub): continue _a1 = float(_sub['act'].sum()) / 1000 _l1 = float(_sub['loss'].sum()) / 1000 _em.append(dict(m=_mm, act=round(_a1, 0), loss=round(_l1, 0), theo=round(_a1 + _l1, 0), loss_pct=round(_l1 / max(_a1 + _l1, 1) * 100, 1), cov=_cov.get(_mm, 1.0), eflh=round(_a1 / max(len(CFG['turbines']) * 4.0, 1), 0))) energy = dict(monthly=_em, act=round(_A, 0), loss=round(_L, 0), theo=round(_A + _L, 0), loss_pct=round(_L / max(_A + _L, 1) * 100, 1), eflh=round(_A / max(len(CFG['turbines']) * 4.0, 1), 0), cap=round(_cap, 0), cap_ok=bool(_A + _L < _cap), items=sorted(_wf, key=lambda r: -r['loss'])) control = dict( energy=energy, states=[dict(k=k, h=round(float(v), 0), pct=round(float(v / hrs_tot * 100), 1), loss=round(float(loss.get(k, 0)), 0)) for k, v in hrs.items()], avail=round(float(avail * 100), 1), curve=dict(sigma=round(float(pcd.dev_w.std() * 100), 2), cands=[dict(t=i, dev=round(float(r.dev_w * 100), 2), 判=r['判别']) for i, r in pcd.iterrows() if r['判别'] != '—'], note='固定判别窗 2025-H2'), anchors='限电命令面占比 2025≈0.197 → 2026≈0.769 (专项锚)') m8 = None try: from src.windscada.perf import faults as fmod if (ST / 'stop_events.parquet').exists(): m8 = dict(mtbf=fmod.mtbf_summary(ms), stop_pareto=fmod.stop_pareto(ms), seasonal=fmod.seasonal()) except Exception as e: m8 = dict(err=str(e)[:120]) m9 = None try: from src.windscada.perf import control as cmod if (ST / 'control_profile.parquet').exists(): m9 = cmod.registry() except Exception as e: m9 = dict(err=str(e)[:120]) try: from src.windscada.perf import reliability as relmod rel = relmod.overview(CFG) except Exception as e: rel = dict(err=str(e)[:120]) try: from src.windscada.subsys import fusion as fusmod ftab, fmeta = fusmod.fusion_table(CFG, all_turbines=True) mrows, mheads = fusmod.matrix(CFG, ms) # 整体时间窗: 矩阵现窗/记忆轨迹随全局窗选择器 cap = fusmod.capability() ev, ev_bounds = fusmod.drivetrain_events(ms, CFG) lvcnt = {} for r in mrows: lv = r['振动']['level']; lvcnt[lv] = lvcnt.get(lv, 0) + 1 fkpi = dict(定论=lvcnt.get('bad', 0), 预警=lvcnt.get('warn', 0), 候选观察=lvcnt.get('note', 0), 销案正常=lvcnt.get('ok', 0), 未列=lvcnt.get('unlisted', 0), 收录台=len(CFG['turbines']) - lvcnt.get('unlisted', 0), 全场=len(CFG['turbines']), 机制链=sum(1 for r in mrows if '机制链' in r['润滑'].get('note', '')), 系数=dict(wear=cap['classes'].get('wear_progressive', {}).get('coef'), thermal=cap['classes'].get('thermal_acute', {}).get('coef'), unknown=cap['classes'].get('unknown', {}).get('coef'))) # 矩阵补 报警/工单 两列 (2026-08-28 用户令) — 报警=窗内真窗计数+族分布; 工单=历史台账(窗见列头) # 报警/工单两列只统计传动链三部件 (2026-08-28 用户令: 只说发电机/齿轮箱/主轴) — # 本矩阵每行是"该台传动链状态", 全场报警计数会把变桨/偏航/主控的量混进来 (316 条里绝大多数与传动链无关) _DT_PAT = '齿轮|齿箱|润滑油|油冷|滤芯|发电机|定子|绕组|滑环|主轴承|主轴|轴承' _a = _CACHE['al']; _aw = _a[_a.month.isin(ms)] _aw = _aw[_aw.text.str.contains(_DT_PAT, na=False)] _acnt = _aw.groupby('turbine').size() _arank = _acnt.rank(ascending=False, method='min') _FAM = [('齿轮箱', '齿轮|齿箱|润滑油|油冷|滤芯'), ('发电机', '发电机|定子|绕组|滑环'), ('主轴承', '主轴承|主轴')] try: from src.windscada.subsys import workorder as _wo _wod = _wo.load(CFG) except Exception: _wod = None for _r in mrows: _t = _r['turbine']; _g = _aw[_aw.turbine == _t] _fam, _taken = [], set() _CODEFAM = {'3225': '变桨叶片'} # 文本为空的码走码号归族 (3225=变桨液压, 现场西门子资料对译表) for _nm, _pat in _FAM: _byc = _g.code.astype(str).map(_CODEFAM) == _nm _sub = _g[~_g.index.isin(_taken) & (_g.text.str.contains(_pat, na=False) | _byc)] _taken |= set(_sub.index) if len(_sub): _fam.append(dict(k=_nm, n=int(len(_sub)))) _oth = len(_g) - sum(f['n'] for f in _fam) if _oth > 0: _fam.append(dict(k='其他', n=int(_oth))) # 刷屏闸: 单码占比过半 → 条数不代表"问题多"而是一个码在抖 (08# 4609/4918=93.7% 码3225 集中2026-01) _burst = None if len(_g): _bc = _g.groupby('code').size().sort_values(ascending=False) _share = float(_bc.iloc[0]) / len(_g) if _share >= 0.5: _sub2 = _g[_g.code == _bc.index[0]] _burst = dict(code=str(_bc.index[0]), share=round(_share, 3), n=int(_bc.iloc[0]), months=sorted({str(m)[:7] for m in _sub2.t_on.dt.to_period('M').astype(str)}), med_s=float(_sub2.dur_s.median()) if 'dur_s' in _sub2 else None) # 明细 (用户令"浮窗说明之前发生问题"): top 码 + 首末时间 _top = [] if len(_g): for (_c2, _tx), _sub3 in _g.groupby(['code', 'text']): _top.append(dict(code=str(_c2), text=str(_tx)[:34], n=int(len(_sub3)), first=str(_sub3.t_on.min())[:10], last=str(_sub3.t_on.max())[:10])) _top.sort(key=lambda x: -x['n']) _r['报警'] = dict(n=int(_acnt.get(_t, 0)), rank=int(_arank.get(_t, 0)) if _t in _arank else None, tot=len(CFG['turbines']), fam=_fam, burst=_burst, items=_top[:6]) if _wod is not None and len(_wod): _w = _wod[_wod.turbine == _t] _wtxt = (_w.get('故障名称', '').astype(str) + ' ' + _w.get('故障位置二级', '').astype(str) + ' ' + _w.get('维修对象', '').astype(str) + ' ' + _w.get('元器件名称', '').astype(str)) _w = _w[_wtxt.str.contains(_DT_PAT, na=False)] _acts = _w['维修动作'].replace('', pd.NA).dropna().value_counts() if len(_w) else None _wi = [] for _, _wr in _w.sort_values('t_report', ascending=False).head(6).iterrows(): _wi.append(dict(date=str(_wr.get('t_report'))[:10], name=str(_wr.get('故障名称') or '')[:30], act=str(_wr.get('维修动作') or ''), part=str(_wr.get('维修对象') or _wr.get('元器件名称') or '')[:14])) _r['工单'] = dict(n=int(len(_w)), acts=[dict(k=str(k), n=int(v)) for k, v in (_acts.head(3).items() if _acts is not None else [])], items=_wi, last=(lambda v: str(v.max())[:10] if len(v) else None)(_w.t_report.dropna()[_w.t_report.dropna().dt.year > 2000]) if len(_w) else None) else: _r['工单'] = dict(n=0, acts=[], last=None) _wyr = '' if _wod is not None and len(_wod): _tv = _wod.t_report.dropna() _tv = _tv[_tv.dt.year > 2000] # 空日期落 1970 epoch, 剔除后再报窗 (否则列头写"1970~") if len(_tv): _wyr = f"{_tv.min():%Y}~{_tv.max():%Y}" mheads = dict(mheads, 报警=f"传动链三部件 · {ms[0]}~{ms[-1]}" if ms else '传动链三部件', 工单=f"传动链三部件 · 台账 {_wyr}" if _wyr else '台账不可用') # 决策链进度盘 (2026-08-28 用户问"这个到底能怎么用"): 原来只有 29# 一台的静态六格, # 是展示牌不是工作面 — 打开它做不了任何决定。改为对全部需跟踪台报"走到第几步、卡在哪、下一步做什么"。 # 判定规则单源在此, 前端只渲染不判断。 _CLOOP = {} try: _ch = json.loads((ST.parent / 'm5_cms_tcm' / 'component_history.json').read_text(encoding='utf-8')) for _k, _v in _ch.get('summary', {}).items(): if _k.startswith('★换件闭环') and isinstance(_v, list): for _r in _v: _CLOOP[_r['turbine']] = _r if _k.startswith('★当前在升') and isinstance(_v, list): for _r in _v: _CLOOP.setdefault(_r['turbine'], {}).update(rising=_r) except Exception: pass _HIT = {'bad', 'warn', 'note'} # ★一台可能有多行 (29#/17# 各 2 行: 齿轮箱 + 主轴承)。用 {turbine: row} 直接建字典会被后一行覆盖, # 实测 29# 因此取到"齿轮箱二级行星内齿圈·监视"那行, 而它的决策链讲的是"主轴承+集中润滑泵·定论" # ⇒ 浮窗里部件、温度通道、油样全是另一个部件的 (memory: 画图须确认各维度来自同一个体)。 _SEV = {'bad': 0, 'warn': 1, 'note': 2, 'stale': 3, 'ok': 4, 'unlisted': 5} _tbi = {} for _row in ftab.to_dict('records'): _k = _row['turbine'] if _k not in _tbi or _SEV.get(_row.get('级'), 9) < _SEV.get(_tbi[_k].get('级'), 9): _tbi[_k] = _row board = [] for _r in mrows: t = _r['turbine'] srcs = [k for k in ('振动', '温度', '润滑', '油液') if (_r.get(k) or {}).get('level') in _HIT] lvl = (_r.get('振动') or {}).get('level') tb = _tbi.get(t, {}) mech = ('机制链' in ((_r.get('润滑') or {}).get('note') or '') or '机制链' in (tb.get('振动结论') or '')) n_wo = (_r.get('工单') or {}).get('n') or 0 cl = _CLOOP.get(t) or {} closed = bool(cl.get('verdict') == '恢复') rising = cl.get('rising') if not (lvl in _HIT or (srcs and lvl != 'ok')): continue # 六步: done=已达 / open=未达 / unknown=数据不足不可判 steps = [ dict(k='证据', st='done' if srcs else 'open', v=f"{len(srcs)} 源", d='/'.join(srcs)), dict(k='机制', st='done' if mech else 'open', v='已定性' if mech else '未定性', d='根因定到部件与失效模式'), dict(k='判级', st='done' if lvl in _HIT else 'open', v=tb.get('证据状态') or lvl or '—', d=f"设备状态 {tb.get('设备状态') or '—'}"), dict(k='排期', st='done' if t == 'WTG29' else 'open', v='情景A 2026-09' if t == 'WTG29' else '未排', d='进检修排程沙盘 (风险×损失双轴)'), # ★这一列一律"不可判", 不是偷懒: 工单台账止 2024-11 而判级窗在 2026, # 台账里的历史工单不可能是针对本次问题的 ⇒ 用 n>0 判"已派工"会造出假的完成态。 dict(k='动作', st='unknown', v=(f"台账 {n_wo} 单" if n_wo else '台账无'), d='工单台账 2020~2024-11, 25-26 现场未提供 — 历史单不对应本次问题, 须向现场核实'), # ★历史闭环不占本格: 05#/20# 2024~2025 换过齿轮箱且已验证恢复, 但本轮又有新的候选级证据, # 把历史闭环填进第六格会出现"验收已达而机制未定"的自相矛盾行。历史闭环走 hist_loop 单独标记。 dict(k='验收', st='open', v='未验', d='本轮动作执行后复测同一判据是否回落' + ( f" (该台 {cl.get('replace_date')} 有过一次闭环: {cl.get('component')} 降至 {cl.get('ratio')}×)" if closed else '')), ] _first = next((x for x in steps if x['st'] != 'done'), None) stuck = _first['k'] if _first else None stuck_kind = _first['st'] if _first else None NEXT = {'证据': '补测: 四源全为常态, 先确认监测面有效', '机制': '定性: 现场/取样把根因定到部件与失效模式 — 机制不清则排期与动作都无依据', '判级': '送审: 按 RV-1 触发相应审级', '排期': '排程: 进沙盘比情景, 出建议窗口', '动作': '核实: 向现场调取 2025~2026 工单 — 台账止于 2024-11, 系统无法判定是否已派工', '验收': '复测: 动作已执行, 复测同一判据是否回落'} board.append(dict(t=t, lvl=lvl, ostate=tb.get('设备状态'), estate=tb.get('证据状态'), part=tb.get('部件'), steps=steps, stuck=stuck, stuck_kind=stuck_kind, next=NEXT.get(stuck, ''), done=sum(1 for x in steps if x['st'] == 'done'), rising=rising, hist_loop=(cl if closed else None), srcs=srcs)) _ORD = {'bad': 0, 'warn': 1, 'note': 2} board.sort(key=lambda r: (_ORD.get(r['lvl'], 3), -r['done'])) _cl_rows = [v for v in _CLOOP.values() if v.get('verdict') == '恢复'] fus = dict(链盘=dict(rows=board, closed=_cl_rows, stuck=dict(collections.Counter(r['stuck'] for r in board if r['stuck'])), wo_bound='工单台账 2020~2024-11 (现场未提供 25-26)'), 表=ftab.to_dict('records'), 矩阵=mrows, 列窗=mheads, kpi=fkpi, 事件=ev, 事件边界=ev_bounds, 能力=dict(classes=cap['classes'], blind=[str(b) for b in cap['blind']], loop=cap.get('loop', {})), 窗=dict(振动=fmeta['振动窗'], SCADA=fmeta['SCADA窗'], 油样=fmeta['油样窗']), 色标=fmeta.get('色标', {}), 数据时点=fmeta.get('数据时点', ''), handoff日期=fmeta.get('handoff日期', ''), 证据窗末=fmeta.get('证据窗末', ''), 纪律=str(fmeta['纪律']), 盲区=[str(b) for b in fmeta['盲区']], open_items=[str(o) for o in fmeta['open_items']]) except Exception as e: fus = dict(err=str(e)[:160]) kpi = dict(报警台=n_alarm_t, 关注台=len(watch), 全场=len(CFG['turbines'])) return dict(win=win, months=ms, all_months=sorted(_CACHE['tm'].month.unique()), systems=systems, sysdist=sysdist, watch=watch, kpi=kpi, rel=rel, fus=fus, faults=faults, control=control, m8=m8, m9=m9, note='判级窗: 变桨/偏航/蓄能/温度=近90日固定, 曲线=2025H2; 故障统计/五态/温度月轨迹=所选时间窗真窗') # ---------- 本体网页面 (ontology 的网页体现: 机制链/决策台/机制库/对象浏览器; 只读, 复用 MCP 纯函数) ---------- def ontology_view(): from src.ontology import mcp_server as om d = om.db() from collections import Counter stats = dict(Counter(v['type'] for v in d.values())) mechs = [dict(id=o['id'], **{k: o['props'].get(k) for k in ('name', 'status', 'facets', 'source')}, affects=o.get('links', {}).get('affects', [])) for o in d.values() if o['type'] == 'FailureMechanism'] # 29# 机制链 (§2 规划补齐): 机制为中心的分层链 ev = [dict(id=i, **{k: d[i]['props'].get(k) for k in ('facet', 'claim_window', 'summary', 'source')}) for i in d['mechanism/WTG29/lub_starvation']['links']['evidencedBy']] chain29 = dict( mechanism=dict(id='mechanism/WTG29/lub_starvation', **d['mechanism/WTG29/lub_starvation']['props']), evidence=ev, verdict=dict(id='verdict/WTG29/TCM-16', **{k: d['verdict/WTG29/TCM-16']['props'].get(k) for k in ('level', 'title', 'review_tier', 'claim_window')}), scenario=dict(id='scenario/WTG29/repair_scheduling_v1', **{k: d['scenario/WTG29/repair_scheduling_v1']['props'].get(k) for k in ('key_insight', 'recommendation', 'scenarios')}), action=dict(id='action/WTG29/repair_pump', **d['action/WTG29/repair_pump']['props']), acceptance=dict(id='acceptance/WTG29/state', **d.get('acceptance/WTG29/state', {}).get('props', {})), tree=dict(id='decision/WTG29/acceptance_tree', **d['decision/WTG29/acceptance_tree']['props'])) decisions = [dict(id=o['id'], **{k: o['props'].get(k) for k in ('decided_by', 'when', 'decision', 'status', 'outcome')}) for o in d.values() if o['id'].startswith('decision/user') or o['id'].startswith('decision/pending')] roll = d.get('meta/roll_state', {}).get('props', {}) constraints = ('C1 证据必带claim_window+观测面+源身份 · C2 判级必带审级+证据链接 · C3 独立源数按观测面去重禁按条数 · ' 'C4 判读三分(支持|排除|不可判, 不可判≠阴性) · C5 状态措辞窗相关必带窗 · C6 动作必带可逆级(L3须审级)') weather = d.get('mwindow/rudong/climatology', {}).get('props', {}).get('monthly', {}) return dict(stats=stats, constraints=constraints, mechanisms=mechs, chain29=chain29, decisions=decisions, roll=roll, weather=weather) def ontology_list(otype, turbine, limit=60): from src.ontology import mcp_server as om return om.ont_query(type=otype or '', turbine=turbine or '', limit=int(limit)).get('data', []) def ontology_obj(oid): from src.ontology import mcp_server as om r = om.ont_get(id=oid) lk = om.ont_links(id=oid).get('data', {}) return dict(obj=r.get('data'), links=lk, err=r.get('error')) # ---------- AI 问答 (P2 离线编排的网页入口; dsh + ontology MCP; 串行锁=一次一问, windcms五类错教训) ---------- CUR_WIN = ['2026年'] # 最近一次 fleet 查询的窗, 供报告助手复用 ASK = {'state': 'idle', 'q': '', 'model': '', 'answer': '', 'verify': None, 't0': 0, 'err': '', 'step': '', 'steps': [], 'review': None, 'xrev': False, 'lang': 'zh'} # step/steps: 推理进度 (229s 全程无反馈会被读成"给不出结果"); review: 另一模型的交叉审核结果 _ASK_STEP_KEY_EN = { '启动': 'Starting', '预取': 'Prefetch', '推理': 'Reasoning', '取证': 'Evidence retrieval', '成文': 'Drafting', '校闸': 'Grounding gate', '交叉审核': 'Cross-review', '完成': 'Done', '失败': 'Failed', } def _ask_step_text(k, d='', lang='zh'): k = '' if k is None else str(k) d = '' if d is None else str(d) if lang != 'en': return f'{k}· {d}' if d else k key = _ASK_STEP_KEY_EN.get(k, k) detail = d.strip() m = re.search(r'第\s*(\d+\s*/\s*\d+)\s*轮', detail) if m: detail = 'round ' + m.group(1).replace(' ', '') else: m = re.search(r'(\d{1,2})\s*一站式证据包', detail) if m: alias = 'focus unit' try: from src.windscada.deid import unitize_answer alias = unitize_answer(f"WTG{int(m.group(1)):02d}") except Exception: pass detail = f'{alias} evidence pack' elif '闭环' in detail and '证据' in detail: n = ''.join(re.findall(r'\d+', detail)) detail = f'closure and trend evidence: {n} records' if n else 'closure and trend evidence' elif '全场' in detail and '判级' in detail: n = ''.join(re.findall(r'\d+', detail)) detail = f'fleet ratings: {n} units' if n else 'fleet ratings' elif '换模型' in detail or '复核' in detail: detail = 'reviewing with another model' elif re.search(r'[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]', detail): detail = '' if re.search(r'[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]', key): key = 'Working' if re.search(r'[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]', detail): detail = '' return f'{key} · {detail}' if detail else key # 模型档位 (2026-08-28 用户裁: 去掉 qwen3:30b-a3b, 用 27B)。 # 每档标注适用场景与实测耗时 — 让人按"这个问题值不值得等"来选, 而不是凭型号大小猜。 ASK_MODELS = {'qwen3:8b': 'deploy/dsh/model.qwen8b.patch.yml', 'qwen3.8:27b': 'deploy/dsh/model.qwen27b.patch.yml', # 2026-09-06 用户裁 "加档": 部署包写的是 "本机 Ollama 的 DeepSeek 模型", 机上实有 r1 14b/32b 却选不到. # 本地档一律走 fast_agent 快路径 (Ollama 直连), 不走 dsh, 故 patch 为 None; r1 不支持 tools 时快路径自动退化为预取直答 (见 fast_agent._chat). 'deepseek-r1:14b': None, 'deepseek-r1:32b': None, 'qwen3:30b-a3b': None, # 2026-09-07: 8B 校闸未过的自动升档目标 (30B 参数 / 3B 激活, 单轮 ~20 s) 'deepseek-v4-pro': None} # ★这张表原来只有本地模型时代的三档, **不含实际部署的 deepseek-chat / kimi-fast / # qwen-plus** —— 于是 MODEL_SPEC[sel.value] 落空, v.warn 为空, 徽章就显示「零外发」。 # 云端模型对用户说"零外发"是**假保证**, 比时间写错严重得多 (2026-09-01 用户说"太慢" # 时顺带查出来的)。补全 + 下面 mdlNote 的兜底改为"按外发处理", 宁可说重不可说轻。 MODEL_SPEC = { 'qwen3:8b': dict(nm='轻量 8B', loc='本地', t='约 30 到 60 秒', use='默认档: 快速简答问答 (用户裁 2026-09-07)'), 'qwen3.8:27b': dict(nm='标准 27B', loc='本地', t='约 2 到 4 分钟', use='复杂问题档: 多台横向比对与机制推断 (用户裁 2026-09-07)'), 'qwen3:30b-a3b': dict(nm='快 30B (a3b)', loc='本地', t='约 20 到 60 秒', use='8B 校闸未过时的自动升档目标; 也可直接选'), 'deepseek-r1:14b': dict(nm='本机 DeepSeek 14B', loc='本地', t='约 1 到 3 分钟', use='交叉审核审核档 (作答请优先 qwen 27B; 用户裁 2026-09-07)'), 'deepseek-r1:32b': dict(nm='本机 DeepSeek 32B', loc='本地', t='约 3 到 8 分钟', use='备用: 更强更慢, 引用格式不稳 (约三成概率不出文)'), 'deepseek-chat': dict(nm='DeepSeek', loc='云端', t='约 3 到 10 秒', use='默认档, 状态查询与机制推断', warn='内容会外发到模型厂商, 场名与台号已去标识化'), 'kimi-fast': dict(nm='Kimi', loc='云端', t='约 5 到 20 秒', use='长文归纳', warn='内容会外发到模型厂商, 场名与台号已去标识化'), 'qwen-plus': dict(nm='通义千问', loc='云端', t='约 5 到 20 秒', use='备用云端档', warn='内容会外发到模型厂商, 场名与台号已去标识化'), 'deepseek-v4-pro': dict(nm='云端 DS', loc='云端', t='约 2 到 6 分钟', use='复杂推理与交叉审核', warn='内容会外发到模型厂商, 场名与台号已去标识化'), } def _warm(model): # 预热+常驻: 空载请求把模型拉进内存并 keep_alive 30m (与 dsh 冷启并行, 消掉 18GB 级冷加载) try: import urllib.request urllib.request.urlopen(urllib.request.Request( 'http://127.0.0.1:11434/api/generate', method='POST', data=json.dumps(dict(model=model, prompt='', keep_alive='30m')).encode(), headers={'Content-Type': 'application/json'}), timeout=120).read() except Exception: pass def _ask_worker(q, model, lang='zh'): import subprocess, threading, time as _t out = _P.ont() / 'ask_last.out' try: if MODEL_SPEC.get(model, {}).get('loc') == '本地': # 本地档 (qwen3 / deepseek-r1) 走直连快路径; 原按 'qwen' 前缀硬判, 加 r1 档后改按档表 loc # 本地模型走直连快路径 (fast_agent: ollama chat+tools 同进程, 砍 dsh/node/MCP 三层; # 实测 8b 116s→19s; 同一套工具与闸, 服务端强制双闸). deepseek 云端仍走 dsh 完整编排. threading.Thread(target=_warm, args=(model,), daemon=True).start() sys.path.insert(0, str(pathlib.Path('.').resolve())) sys.path.insert(0, str(pathlib.Path('scripts').resolve())) from src.ontology.fast_agent import answer_escalate as _fast # 2026-09-07: 8B 拦了自动升 a3b 重答 (用户令) def _on_step(k, d=''): ASK['step'] = _ask_step_text(k, d, lang) ASK.setdefault('steps', []).append(ASK['step']) def _on_partial(txt): # 先快后准 (用户裁 2026-09-07): 30 s 内先出初答 (契约直出), 状态 partial, 前端继续轮询; 27B 出来后整段替换 ASK.update(answer=txt, state='partial', partial=True) r = _fast(q, model, on_step=_on_step, lang=lang, on_partial=_on_partial) _esc = f" · 升档 {r['escalated_from']}→{r['model']}" if r.get('escalated_from') else '' ans = r['answer'] + f"\n\n({r['gate']} · {r['rounds']}轮 · 直连快路径{_esc})" out.write_text(ans) from ontology_p2_verify import verify as _v0 ASK.update(answer=ans, verify=_v0(ans, lang=lang), partial=False, refined_from=r.get('escalated_from')) # 交叉审核: 换一个本地模型复核同一份证据 (同模型自审 = 自己给自己判卷) if ASK.get('xrev'): _on_step('交叉审核', '换模型复核中') from src.ontology.fast_agent import cross_review as _xr # 审核档 = DeepSeek r1:14b (用户裁 2026-09-07); 作答本身是 DeepSeek 时换 qwen 27B 审 —— 始终跨家族 (同家族自审 = 自己判卷) _rm = 'deepseek-r1:14b' if not model.startswith('deepseek') else 'qwen3.8:27b' ASK['review'] = _xr(q, r['answer'], r.get('facts') or [], model=_rm) ASK.update(state='done') return if False: q = '/no_think ' + q # (快路径内已带 /no_think; dsh 路径仅 deepseek) cmd = ['deploy/dsh/dsh_run.sh', '--profile', 'headless', '--patch', 'deploy/dsh/headless.patch.ontology.yml'] mp = ASK_MODELS.get(model) if mp: cmd += ['--patch', mp] if lang == 'en': cmd.append(q + ' Answer in English only. End every conclusion sentence with [object/id]. Finally run claim_check.') else: cmd.append(q + ' 每个结论句末尾用[对象id]标注来源。最后调用claim_check。') r = subprocess.run(cmd, capture_output=True, text=True, timeout=600) ans = r.stdout.strip() or ('(空输出) stderr: ' + r.stderr[-500:]) out.write_text(ans) sys.path.insert(0, str(pathlib.Path('scripts').resolve())) from ontology_p2_verify import verify as _v ASK.update(answer=ans, verify=_v(ans, lang=lang), state='done') except Exception as e: _msg = str(e) if 'Connection refused' in _msg or 'Errno 61' in _msg or '11434' in _msg: _msg = '本机模型未启动 (Ollama 127.0.0.1:11434 拒连); 离线版不转云, 请先启动 Ollama' # 2026-09-07 真停实测: 原样抛 urlopen 错误给用户 ASK.update(state='error', err=_msg[:300]) # ── 报告定制助手 (2026-08-28 用户令: 留一个互动交互窗, 报告按定制, 离线模型支持) ── # 两件事: ①听懂"给集团看的月报, 重点讲设备问题"这类话 → 选出该放哪些块 # ②基于**真实数字**写一段总结叙述 — 为图表补充文字结论, 供汇报使用。 # 防编造: 数字只许用下面这份摘要里的, 生成后过 grounding 闸复核, 越界即标注。 RPT_BLOCK_KEYS = ['kpi', 'water', 'emon', 'blame', 'ram', 'parts', 'attrib', 'pareto', 'quad', 'frate', 'units', 'chain', 'loop', 'plan', 'caliber'] RPT_SYS = ('你是风电场报告编排助手。根据用户要求做两件事, 严格按格式输出, 不要解释:\n' 'BLOCKS: 用逗号分隔的块名 (只能从给定清单里选, 按报告顺序排)\n' 'TEXT: 一段中文总结 (150~300 字, 可分 2~3 句一组)\n' '铁律: TEXT 里出现的每个数字都必须来自"可用数据"里, 不许自己算不许估计; ' '没有依据的话就不写。不要写套话, 不要写"综上所述"。') def rpt_compose(need, canvas, model='qwen3:8b'): import time as _t fl = fleet_view(CUR_WIN[0] if CUR_WIN else '2026年') E = (fl.get('control') or {}).get('energy') or {} R = fl.get('rel') or {} M = ((fl.get('m8') or {}).get('mtbf')) or {} F = fl.get('faults') or {} BD = ((fl.get('fus') or {}).get('链盘')) or {} facts = [ f"统计期 {fl.get('win')}; 38 台 4.0MW", f"上网电量 {E.get('act')} MWh; 理论可发 {E.get('theo')}; 损失 {E.get('loss')} MWh; 损失率 {E.get('loss_pct')}%", f"等效满发 {E.get('eflh')} h; 时间可用率 {(fl.get('control') or {}).get('avail')}%", f"总停机 {R.get('总停机h')} h = 设备类 {R.get('设备类停机h')} + 外部类 {R.get('外部类停机h')}", f"平均停机间隔 {M.get('MTBF_h')} h; 停机 {M.get('停机事件数')} 起; 单次平均 {M.get('MDT_h')} h", f"报警 {F.get('total')} 条 / {F.get('n_codes')} 个码", f"需行动机组 {len(BD.get('rows') or [])} 台; 卡点 {BD.get('stuck')}", # 占比直接给出: 不给模型就得自己算, 算对了也会被接地闸判为"未接地数字"(实测 31.4/46.6) f"损失构成: " + '; '.join( f"{x['k']} {x['loss']} MWh (占损失 {round(x['loss'] / max(E.get('loss') or 1, 1) * 100, 1)}%)" for x in (E.get('items') or [])), f"停机 {R.get('总停机h')} h 中设备类占 {round((R.get('设备类停机h') or 0) / max(R.get('总停机h') or 1, 1) * 100, 1)}%", f"端到端闭环 {len(BD.get('closed') or [])} 例", ] prompt = (f"可用数据:\n" + '\n'.join('- ' + x for x in facts) + f"\n\n可选块 (只能从这里选): {', '.join(RPT_BLOCK_KEYS)}" f"\n块的含义: kpi=一级指标 water=能量瀑布 emon=逐月发电 blame=损失分账 ram=可靠性象限 " f"parts=部件停机 attrib=归因覆盖 pareto=故障帕累托 quad=故障四象限 frate=月度故障率 " f"units=重点机组 chain=决策链 loop=闭环实证 plan=下期建议 caliber=口径边界" f"\n\n当前画布: {', '.join(canvas) if canvas else '(空)'}" f"\n用户要求: {need}") import urllib.request as _ur # 顶部只 import urllib.parse, urllib.request 需显式导入 body = dict(model=model, messages=[dict(role='system', content=RPT_SYS), dict(role='user', content='/no_think ' + prompt)], stream=False, keep_alive='30m', options=dict(num_ctx=8192, num_predict=800)) t0 = _t.time() try: req = _ur.Request('http://127.0.0.1:11434/api/chat', data=json.dumps(body).encode(), headers={'Content-Type': 'application/json'}) with _ur.urlopen(req, timeout=300) as r: txt = (json.loads(r.read())['message'].get('content') or '') except Exception as e: return dict(err=f'本地模型不可达: {str(e)[:120]}') txt = re.sub(r'.*?', '', txt, flags=re.S).strip() mb = re.search(r'BLOCKS\s*[::]\s*(.+)', txt) mt = re.search(r'TEXT\s*[::]\s*(.+)', txt, re.S) blocks = [b.strip() for b in re.split(r'[,,\s]+', mb.group(1))] if mb else [] blocks = [b for b in blocks if b in RPT_BLOCK_KEYS] narrative = (mt.group(1).strip() if mt else '').strip() # 接地闸: 叙述里的数字必须在 facts 里出现 (同 fast_agent 的 grounding 口径) bad = [] try: from src.windscada.audit import grounding as _gr ok, b = _gr(narrative, '\n'.join(facts)) bad = (b or {}).get('numbers') or [] except Exception: pass return dict(blocks=blocks, narrative=narrative, model=model, secs=round(_t.time() - t0, 1), ungrounded=bad, raw=txt[:900]) ASK_CAT_HINTS = { '设备类': '(设备状态类: 从本体的机组档案、对象查询与机制检索三条路取证)', '检修排程决策': '(检修排程类: 用 ont_scenario/ont_get 取排期沙盘与验收树; 建议级动作注明待人裁)', '损失分析': '(损失分析类: 用 ont_loss/ont_scenario 取证; 电量kWh为主, 金额必带电价区间0.391~0.85口径; 限电类损失≠设备责任)', '安全风险预测': '(风险预测类: 用 ont_risk/ont_mechanism_search 取证; 区分升级中/稳定高位/盲区, 未列≠正常, 不可判≠阴性)', } ASK_CAT_HINTS_EN = { '设备类': '(Equipment state: use unit profiles, object queries and mechanism search for evidence)', '检修排程决策': '(Maintenance scheduling: use scenarios and acceptance trees; mark advisory actions as pending human decision)', '损失分析': '(Loss analysis: use loss accounts and scenarios; energy in kWh/MWh, monetary value must carry the tariff-range caveat)', '安全风险预测': '(Risk prediction: distinguish escalating, stable-high and blind-spot cases; no listing is not proof of normality)', } def ask_start(q, model, cat='', xrev=False, lang='zh', display_q=''): import threading, time as _t if not q.strip(): return dict(err='问题为空' if lang != 'en' else 'The question is empty') if ASK['state'] in ('running', 'partial'): # partial = 初答已出、27B 复核中, 仍占槽 return dict(err='已有问题在推理中 (串行锁, 一次一问)', state=ASK['state']) if model not in ASK_MODELS: return dict(err=f'未知模型 {model}; 可选 {list(ASK_MODELS)}') hints = ASK_CAT_HINTS_EN if lang == 'en' else ASK_CAT_HINTS q2 = (hints.get(cat, '') + ' ' + q).strip() if lang == 'en': try: from src.windscada.deid import ununitize_question q2 = ununitize_question(q2) except Exception as e: print('[ask] 代号反解失败: %s' % e, file=sys.stderr) ASK.update(state='running', q=display_q or q, model=model, answer='', verify=None, err='', t0=_t.time(), step='启动' if lang != 'en' else 'Starting', steps=[], review=None, lang=lang) ASK['xrev'] = bool(xrev) threading.Thread(target=_ask_worker, args=(q2, model, lang), daemon=True).start() return dict(state='running', model=model) def ask_status(): import time as _t d = dict(ASK) d['elapsed'] = int(_t.time() - ASK['t0']) if ASK['t0'] else 0 return d # ---------- 台内即时查询 ---------- _RAW_CACHE = {} def adhoc_query(t, ch, win, agg): from src.windscada.data import load_10min, contracted_cols _load() key = t with LOCK: if key not in _RAW_CACHE: if len(_RAW_CACHE) > 6: _RAW_CACHE.clear() try: _RAW_CACHE[key] = load_10min(t, CFG, groups=['A.功率', 'A.风况', 'A.转速', 'A.状态', 'B.变桨', 'B.温度NBM', 'B.偏航', 'B.润滑液压']) except Exception as e: # 2026-09-11 场景① 逮: 原始件不在时这里原来直接把异常抛给 HTTP 处理器 → 前端拿到 500, # 面板只显示"服务器错误", 人看不出是"没放数据"还是"程序坏了"。改为**结构化的无数据**回答: # 与成功路径同一个形状 (kind/title/unit/months/series/note), 前端按正常渲染即可显示原因。 src = CFG.get('src_10min') return dict(kind='multiline', title=f'{t} {i18n.ch_cn(ch)}', unit='', months=[], series=[], err='no_source', note=f'无原始件: 本机应在 {src}\\{t}.csv (即 data\\raw\\<场站名称>\\scada_10min\\)' f' — 放入后重跑 scripts\\rebuild_from_raw.py --scada') d = _RAW_CACHE[key] if ch not in d.columns: return dict(err=f'通道 {ch} 不在契约内') ms = months_of(win) dd = d[d.ts.dt.to_period('M').astype(str).isin(ms)] if agg == 'daily': g = dd.groupby(dd.ts.dt.date)[ch].median() xs = [str(x) for x in g.index] else: g = dd.groupby(dd.ts.dt.to_period('M').astype(str))[ch].median() if agg == 'med' else \ dd.groupby([dd.ts.dt.to_period('M').astype(str), dd.ts.dt.date])[ch].sum().groupby(level=0).mean() xs = list(g.index) agg_cn = {'med': '月中位', 'sum': '月duty(秒/日)', 'daily': '逐日中位'}.get(agg, agg) fig = dict(kind='multiline', title=f'{t} {i18n.ch_cn(ch)} ({agg_cn}, {win})', unit='', months=xs, series=[dict(name=i18n.ch_cn(ch, with_tag=False), vals=[None if v != v else round(float(v), 3) for v in g])], note='台内即时查询') # fleet 带 (若月仓有) for store, col in ((_CACHE['tm'], 'med'), (_CACHE['duty'], 'val')): if store is None or agg == 'daily': continue gg = store[store.channel == ch] if len(gg): piv = gg.pivot_table(index='month', columns='turbine', values=col).reindex(xs) fig = dict(kind='band', title=fig['title'], unit='', months=xs, self=[None if v != v else round(float(v), 3) for v in piv.get(t, pd.Series(index=xs, dtype=float))], med=[None if v != v else round(float(v), 3) for v in piv.median(axis=1)], q1=[None if v != v else round(float(v), 3) for v in piv.quantile(.25, axis=1)], q3=[None if v != v else round(float(v), 3) for v in piv.quantile(.75, axis=1)], note='灰带=全场四分位 (月仓)') break return fig def channel_list(): from src.windscada.data import contract c = contract(CFG) out = {} for g, cols in c['groups'].items(): out[g] = [dict(code=k, cn=i18n.ch_cn(k, with_tag=False) + ' · ' + k) for k, v in cols.items() if v['liveness'] == '活'] return out # ---------- HTML ---------- from src.windscada.design import CSS as BASE_CSS CHART_JS = """ // 设备状态色 = 观澜语义色单源 (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 `
${over?``:''} ${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 `
${blocks}
${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?` ${em.map(x=>``).join('')}
月上网 MWh损失 MWh损失率等效满发 h覆盖
${x.m}${x.act.toLocaleString()}${x.loss.toLocaleString()}${x.loss_pct}%${x.eflh} ${(x.cov*100).toFixed(0)}%${x.cov<0.5?' 不满月':''}
`:'—', '覆盖率<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('部件停机', ` ${R.表.slice(0,9).map(x=>``).join('')}
部件停机h事件MTBF hMDT h影响台
${x.部件}${x.外部?' (外部)':''}${x.停机时长h}${x.事件数} ${x.MTBF_h||'—'}${x.MDT_h}${x.影响台数}
`, '归因=段起点±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=>``).join('')}
机组状态部件与证据进度卡在
${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||'全通'}
`, '四源融合判级'); } 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=>``).join('')}
机组部件换件日期换前换后比值
${x.turbine.replace('WTG','')}#${x.component}${x.replace_date} ${x.pre}${x.post}${x.ratio}×

这几台正是算法当初的检出台 ⇒ 故障前能看见、换件后能看见恢复。

`, '振动标量前后对比'); } if(k==='plan') return wrap('下期计划与建议', `
  1. 补机制定性 — ${(BD.stuck||{}).机制||0} 台停在"判了级但根因未定", 是当前最大单一瓶颈
  2. 回收 25~26 工单 — 台账止 2024-11, 本期检修执行与闭环无法自动判定
  3. 油液采样节奏 — 四源里油液长期 stale, 只能作回测锚
  4. 限电侧 — 损失六成在交易与调度侧, 设备侧改善空间有限
`, '建议级 — 排期与动作须经审批'); 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=>``).join('')}
情景窗作业级停机损失
${x.id}${x['修理窗']||'—'} ${(x['作业级']||'').slice(0,20)} ${((x['停机损失']||{})['电费口径']||'—').slice(0,22)}
⑥ 验收 · ${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', `
${t} · ${part}载入中… ×
`); 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=`

查询失败: ${e.message}

`; return } out.innerHTML = (m==='fault' && d.data) ? faultCards(d.data) : (m==='tree' && d.data) ? treeCards(d.data) : `
${(d.text||'').replace(/
`; 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 `

${md.error}

`; 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 => ``).join('')}
源当前覆盖 末次更新频率责任补数据怎么做
${r.存在 ? '' : '✗ '}${r.名} ${r.覆盖}${r.条数 ? ` (${r.条数.toLocaleString()} 条)` : ''} ${r.末次更新 || '—'} ${r.频率}${r.责任}
放到: ${r.位置}
跑: ${r.摄入}
${r.说明}
`; }); 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=>``).join('')}
节点查什么
${n.name}${n.check}
${(st.known_tags&&st.known_tags.length)?`
该系统手册出现过的位号 (对图纸时参考): ${st.known_tags.map(k=>`${k.tag}`).join('')}
`:''}
`:''} ${(st.specs&&st.specs.length)?`
判定规范 (实机参数表) ${st.specs.map(sp=>``).join('')}
参数号名称整定值允许范围
${sp.no}${sp.text} ${sp.val!=null?sp.val+(sp.unit||''):'—'} ${sp.lo!=null?`${sp.lo} ~ ${sp.hi}`:'—'}
`:''} ${(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=>``).join('')}
日期机组故障处置
${w.date}${w.turbine.replace('WTG','')}# ${w.name}${w.action} ${w.part}

工单窗止 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 ` ${zone(28,74)}${zone(112,90,1)}${zone(212,96)} ${node(46,'主轴承前','b')}${node(88,'主轴承后','b')} ${node(133,'齿轮箱','g')}${node(157,'齿轮箱','g')}${node(181,'齿轮箱','g')} ${node(236,'发电机','b')}${node(282,'发电机','b')} `}; // 证据强度阶梯 (确诊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 `
${cells} ${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 => `${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 ` ${brg(4, hasF || both)}${brg(26, hasR || both)}`; } 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) => ` ${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 `
脂桶 润滑泵 ${on('泵') ? '本体失效' : ''} 主管路 分配器 柱塞往复=出脂计量 前支路 后支路 ${[['主轴承前', 34], ['主轴承后', 132]].map(([k, y]) => ` ${k}`).join('')} 因侧: 柱塞计数/单次时长 果侧: 轴承温度 · 振动冲击
脂路: 泵 → 主管路 → 分配器(柱塞定量) → 前/后支路 → 轴承。因侧看柱塞行为, 果侧看轴承温度与冲击 — 两侧不是两个独立源, 是同一条机制链的两端。
`; } 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 `
齿轮箱壳体 (i=119.752) 一级行星 中速 中间轴 高速轴 → 发电机 油泵 滤芯 冷却器 喷淋回箱 观测面: · 振动 (各级测点) · 轴承/油温 ΔT · 滤芯压差码 · 油样铁谱
传动: 行星级(输入) → 中间轴 → 高速轴(输出)。油路: 箱底 → 泵 → 滤芯 → 冷却器 → 喷淋回箱 — 过热类失效沿油路查(冷却/滤芯), 磨损类沿齿轮线查(振动/铁谱)。
`; } 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 `
滑环 电/信号 蓄能器 氮气预充 比例阀 压力 变桨油缸 ${[0,120,240].map(a=>{const r=(a-90)*Math.PI/180; return ``}).join('')} 变桨轴承 桨叶 A/B/C 观测: 蓄能带宽/停机泄压 · 桨距跟踪偏差 · 三叶零位一致性 · 轴承润滑脂
动力: 蓄能器储压 → 比例阀 → 油缸 → 变桨轴承 → 桨叶。压力侧失效看蓄能带宽与泄压率, 执行侧看桨距跟踪偏差, 三叶不一致指向单支路。
`; } 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 ` ${zone(28,74)}${zone(112,90,1)}${zone(212,96)} ${node(46,'主轴承前','b')}${node(88,'主轴承后','b')} ${node(133,'行星','g')}${node(157,'中间','g')}${node(181,'高速','g')} ${node(236,'发电机DE','b')}${node(282,'发电机NDE','b')} `}; 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 ` ${shaft(46,206,75,12)} ${brg(88,'主轴承前','主轴承 前',126)}${brg(154,'主轴承后','主轴承 后',126)} ${part('行星','行星级',236,146, ` `,[203,17,66,116])} ${shaft(256,306,75,7)} ${gear(300,75,46,'中间','中间轴',146)} ${gear(300,37,30,'中间','',0)} ${shaft(306,352,37,6)} ${gear(348,37,50,'高速','高速轴',146)} ${gear(348,75,26,'高速','',0)} ${shaft(360,438,75,8)} ${shaft(430,652,75,8)} ${part('发电机DE','发电机 DE',478,146,` `,[464,48,28,54])} ${part('发电机NDE','发电机 NDE',628,146,` `,[614,48,28,54])} `}; 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}\u000A${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 ` ${yt}${xt} ${qn(ML+6,MT+12,'低频长时 · 硬故障','备件与检修窗')} ${qn(W-MR-96,MT+12,'高频长时 · 重点治理','优先派工')} ${qn(ML+6,H-MB-18,'低频短时 · 本底','不单独处置')} ${qn(W-MR-96,H-MB-18,'高频短时 · 重复触发','调整参数或屏蔽, 非硬件故障')} ${pts}${lab} 发生条数 (对数) 单次中位时长 (对数) `; } // 月度故障率图 (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 ` ${bars}${lab}${yt} 条/日`; } // 能量账瀑布 (经典发电量分析)。四行工况表回答不了业主唯一关心的问题: 电去哪了。 // 左=理论可发, 中间逐段扣损(按损失降序), 右=实际上网; 段高即电量, 一眼看出谁是大头。 // 物理闸: 理论可发不得超 台数×额定×窗时长 (能量守恒, 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 ` MWh${h}`; } // 可靠性-可维修性象限 (经典 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 ` ${xt}${yt} ${qn(ML+6,MT+12,'start','常坏且难修','最高优先级')} ${qn(W-MR-6,MT+12,'end','罕发难修','备件与作业窗')} ${qn(ML+6,H-MB-16,'start','频发易修','查根因, 换件无用')} ${qn(W-MR-6,H-MB-16,'end','少发好修','维持')} ${pts} 平均无故障间隔 MTBF (h, 对数) → 越右越可靠 单次停机时长 MDT (h) → 越上越难修 设备类 外部类 圆面积 = 累计停机时长 虚线 = 本场中位分界 `; } // 归因覆盖率条: "无记录停机" 是最大单项却因不给 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 ` ${seg(0,w1,'var(--accent)',0.55)}${seg(w1,w2,'var(--warn)',0.5)}${seg(w1+w2,w3,'var(--bad)',0.5)} 已归因到部件 ${Math.round(ok)}h (${(ok/tot*100).toFixed(0)}%) 无记录 ${Math.round(unk)}h (${(unk/tot*100).toFixed(0)}%) 未归类 ${Math.round(un2)}h `; } // 失败必须响亮 (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 ` ${row(44, P, 'kW', '满发封顶')} ${row(120, W9, 'rpm', '转速封顶')} 圆内数字=台数, 圆面积∝台数`; } 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 ` 全场中位 ${med}° ${rows}`; } 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 ` 正态期望 3.7σ ${rows}`; } // 责任分账 (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 ` 损失 ${Math.round(tot).toLocaleString()} MWh 按责任归属 ${seg}${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 ` ${bars} 累计 80% ${dots} ${xt}${yt} 报警码 (悬停看全名)

${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 += '' + '' + ''; }); h += '
系统需跟踪判良好占全场
' + r.sys + '' + r.bad + '' + r.good + '' + tot + '
'; } 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+=``;}); h+='
码系统类别作业次数台数次/台
${r.code}${r.zh}
${r.en}
${tierName[r.tier]||r.tier} ${r.jobs}${r.n_units}${r.per_unit}
'; // ④ 阶段 × 出口闸 × 记录 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+=``;}); h+='
阶段放行条件必备记录
${st.seq} ${st.zh||st.en||''} ${(st.en&&st.en!==st.zh)?`
${st.en}
`:''}
${st.gate||st.gate_zh||''}${rn}
'; // ⑤ 未映射 — 不猜归属 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 `

${d.err}

`; 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=>``).join('')}
机组部件作业次数首次末次最短间隔
${x.turbine}${x.part}${x.n_jobs} ${x.first}${x.last}${x.min_gap_months} 月
`; // R2 批次候选 const B=(d.batch||[]).slice(0,8); if(B.length) h+=`

共性候选 · ${d.batch.length} 项

仅候选。分布形态无法区分 批次 / 共模应力 / 耗损期 — 三者形态相似而处置方向完全不同(追索赔 / 改运行条件 / 排寿命更换)。定性需物料证据。

${B.map(x=>``).join('')}
部件涉及台数作业次数每台缺什么才能定性
${x.part}${x.n_units}${x.n_jobs} ${x.per_unit}${x.needs}
`; // 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 `
${g}

全场态势${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+=``; }); inner+='
${r.测点} ${v>HI?``:''} ${r.倍中位!=null?r.倍中位+'×':'—'} ${sig(r.本台)} 中位 ${sig(r.全场中位)} z ${r.z} ${r.六窗比!=null?'趋势'+r.六窗比+'×':'趋势—'}
'; } 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, `${yt}${body}${xt}${leg}`+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+=``}); h+=`
日期故障/工作排查发现处置类别
${r.日期}${r.故障}${r.排查&&r.排查!=='nan'?r.排查:'—'}${r.处置||'—'}${r.类别||''}

${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)=>`
${label}
${val}
`; const arrow=`
→
`; // 每格加一行"这一格回答什么" (2026-08-28 用户问"什么意思" — 六个词现场看不出是干嘛的) const node2=(t,v,c,q)=>`
${t}
${v}
${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+=``;}); 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}×`:''}

「动作」一列全场都是不可判, 不是偷懒: ${BD.wo_bound}, 而判级窗在 2026 — 台账里的历史工单不可能是针对本次问题的, 用"有没有工单"判已派工会造出假的完成态。要填这一列须向现场要 25~26 工单。

`; if(BD.closed&&BD.closed.length){ h+=`
已走完全程并退出盘面 · ${BD.closed.length} 台 (端到端闭环实证)
${BD.closed.map(x=>`
${x.turbine.replace('WTG','')}# ${x.component}${x.title?' · '+x.title:''} ${x.replace_date} 换件 → 振动 ${x.pre} 降至 ${x.post} = ${x.ratio}×
`).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=>``).join('')}`; h+=`${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+=`
${m.slice(2)}
CTV人员转运
吊装作业

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)=>`

${t}

${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+=``}); h+=`
决策裁决人时间状态
${(x.decision||'').slice(0,130)}${x.decided_by}${x.when} ${x.status}

月度滚动: 数据月 ${O.roll.data_month||'—'} · 上次动作 ${(O.roll.last_roll_actions||[]).join('; ')||'—'} · 变化 ${(O.roll.last_changes||[]).join('; ')||'—'}

`; h+=`

对象浏览器 (${Object.values(O.stats).reduce((a,b)=>a+b,0)} 对象)

选类型/台号后查询; 点结果行看对象全文与链接。
`; 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=``+d.rows.map(r=> ``).join('')+`
id类型要点
${r.id}${r.type}${r.name||r.level||r.status||''} ${r.review_tier?'· '+r.review_tier:''}
`; } 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=``}
"""

PAGE_FLEET = """
观澜 · 风电智能分析决策

观澜 如东海上

""" PAGE_PROBLEM = """ %T% %SYS% · windscada

%T% · %SYS%

← 总览本台全部问题
加载中…
单问题视图: 只呈现本台该系统的判据与证据图, 不带出其他系统
""" PAGE_TURBINE = """ %T% · windscada

%T% ← 工作台

只显示有问题的系统; 无问题不出图
加载中…

台内即时查询 (任一契约通道 × 时间窗 × 聚合)

边界: 叶片/塔架无SCADA轴 | 对风误差=wrap(风向−机舱位置) vane口径; 死区/速率需≤30s数据=INSUFFICIENT | 判级窗: 变桨/偏航/蓄能/温度判级=近90日固定, 曲线=2025H2; 图内月序=所选时间窗 | P级归综合报告线
""" class H(BaseHTTPRequestHandler): def log_message(self, *a): pass def _send(self, body, ctype='text/html; charset=utf-8', code=200): if 'text/html' in ctype and not self.path.startswith('/v2'): # 2026-09-06 用户批: /v2 = detail_style_baseline, 不注入 unified-review 样式/导航条 (基线令牌须等于门户) from src.windscada.design import GUANLAN_REVIEW_CSS text = body.decode('utf-8') if isinstance(body, bytes) else body text = text.replace('', '', 1) if '' in text: body = text.replace('', '', 1) b = body.encode('utf-8') if isinstance(body, str) else body self.send_response(code) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(b))) self.end_headers() self.wfile.write(b) def do_POST(self): try: u = urllib.parse.urlparse(self.path) n = int(self.headers.get('Content-Length', 0)) body = json.loads(self.rfile.read(n) or b'{}') if u.path == '/api/ask': self._send(_jdump(ask_start(body.get('q', ''), body.get('model', 'qwen3:8b'), body.get('cat', ''), body.get('xrev', False), body.get('lang', 'zh'), body.get('display_q', '')), ensure_ascii=False), 'application/json; charset=utf-8') else: self._send('404', code=404) except Exception as e: self._send(_jdump(dict(err=str(e)[:300]), ensure_ascii=False), 'application/json; charset=utf-8', 500) def do_GET(self): try: u = urllib.parse.urlparse(self.path) # encoding='utf-8' 显式给定 (默认 latin-1 会把中文参数解成乱码 — "齿轮箱"→"齿轮箱", # 浏览器 encodeURIComponent 路径侥幸正常, curl/直传中文即崩; 2026-08-28 四链接口实逮) q = dict(urllib.parse.parse_qsl(u.query, encoding='utf-8', errors='replace')) _REQ.lang = q.get('lang', '') # 本请求的语言, _jdump 出口据此投影文本层 _REQ.keep_orig = q.get('keep') == '1' # v2: 译不出留原文, 不写 [translation pending] win = q.get('win', '2026年') if u.path == '/': _pg = PAGE_FLEET.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS) self._send(_en_page(_pg)) elif u.path == '/v2': # 新前端 (2026-09-03 用户裁"完全重新做一套, 在原来的基础上"): 同一套 /api/*, # 表现层双语原生 —— 不经 _en_page/ui_en 那三层事后翻译。迁移期与 / 并存, # 页签逐个搬完后再切默认。 from src.windscada.ui import build as _v2 self._send(_v2.render('en' if q.get('lang') == 'en' else 'zh')) elif u.path == '/v2/snapshot': # 新版单文件快照 (静态站用): 接口响应烤进页面, 拦截器实时优先快照兜底。 # 取数函数与在线 API 同一实现; 英文走同一套 _redact → _proj 投影 (keep=1 留原文)。 from src.windscada.ui import snapshot as _snap from src.ontology import mcp_server as _omc import json as _j3, pathlib as _p3 def _codes(): db = _j3.loads(_p3.Path(_omc.OBJ_PATH).read_text(encoding='utf-8')) return sorted(k.split('/')[-1] for k in db if k.startswith('alarmcode/')) _ctx = dict(fleet_view=fleet_view, curve_view=curve_view, vibcms_results=vibcms_results, survey=__import__('src.ontology.maintenance', fromlist=['survey']).survey, ont=_omc, alarm_codes=_codes, turbine_problems=turbine_problems, single_problem=single_problem, _redact=_redact, _jclean=_jclean, _proj=_proj, _REQ=_REQ) _v2_lang = 'en' if q.get('lang') == 'en' else 'zh' _html, _rep = _snap.bake(_v2_lang, _ctx, peer=q.get('peer', ''), noindex=q.get('noindex') == '1', slim=(q.get('slim') != '0' if _v2_lang == 'en' else False)) print('[v2 snapshot] %s' % _j3.dumps(_rep, ensure_ascii=False)[:600], flush=True) self._send(_html) elif u.path.startswith('/turbine/'): t = u.path.rsplit('/', 1)[1] _pg = PAGE_TURBINE.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS).replace('%T%', t) self._send(_en_page(_pg)) elif u.path == '/api/maint_survey': try: from src.ontology.maintenance import survey as _sv r = dict(rows=_sv()) except Exception as e: r = dict(rows=[], error=f'维护面盘点失败: {e}'[:200]) self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ont_chain': try: from src.ontology import mcp_server as _om kind = q.get('kind', 'fault') fn = {'fault': lambda: _om.ont_chain_fault(code=q.get('code', '')), 'prev': lambda: _om.ont_chain_preventive(system=q.get('system', '')), 'tree': lambda: _om.ont_fault_tree(system=q.get('system', '')), 'plan': lambda: _om.ont_maint_plan(top=q.get('top', 10)), 'doc': lambda: _om.ont_doc_search(keyword=q.get('keyword', ''), kind=q.get('dkind', ''))} r = fn[kind]() if kind in fn else dict(text=f'未知链 {kind}', error=True) except Exception as e: r = dict(text=f'本体查询失败: {e}'[:200], error=True) self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/maint_std': # 运维标准四判据 + 成熟度评分 (docs/如东运维标准化设计.md) try: import json as _j, pathlib as _p from src.windscada.maint import standards as _st _db = _j.loads(_P.objects_json() .read_text(encoding='utf-8')) _wo = [(v.get('props') or {}) for k, v in _db.items() if k.startswith('workorder/')] _r = _st.run_all(_wo) _r['score'] = _st.score(_wo, _r) except Exception as e: _r = dict(err=f'运维标准判据失败: {e}'[:200]) self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/maint_framework': # 运维管理体系 —— 与快照预烤**同一实现** (maint_framework_view) try: _r = maint_framework_view() except Exception as e: _r = dict(err=f'运维体系读取失败: {e}'[:200]) self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/dq_findings': # 数据可信度发现 —— 与快照预烤**同一实现** (dq_findings_view) try: _r = dq_findings_view() except Exception as e: _r = dict(rows=[], err=f'数据可信度读取失败: {e}'[:200]) self._send(_jdump(_r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/vibcms': self._send(_jdump(vibcms_results(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/curves': self._send(_jdump(curve_view(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/fleet': CUR_WIN[0] = win self._send(_jdump(fleet_view(win), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path.startswith('/problem/'): import urllib.parse as _up _, _, t, sysn = u.path.split('/', 3) self._send(PAGE_PROBLEM.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS) .replace('%T%', t).replace('%SYS%', _up.unquote(sysn))) elif u.path == '/api/problem': self._send(_jdump(dict(problems=single_problem(q['t'], q['sys'], win)), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/turbine': self._send(_jdump(dict(problems=turbine_problems(q['t'], win)), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/query': agg = q.get('agg', 'med') self._send(json.dumps(adhoc_query(q['t'], q['ch'], win, 'sum' if agg == 'dutyd' else agg), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/snapshot': _lang = urllib.parse.parse_qs(u.query).get('lang', [''])[0] # 单文件快照 (2026-08-28 用户: 我怎么发给别人)。 # 把各窗 fleet / curves / ontology / 维护盘点全部内联进 HTML, 并在页内改写 # window.fetch 直接返回内联数据 —— 前端代码一行不改, 收件人双击即开, 不需要 # 装 Python、不需要连内网、断网也能看。数据按当前受众设置脱敏后再内联。 try: _wins = ['2026年', '2026H1', '2025H2', '全程'] snap, _skip = {}, [] def _nk(path, **kw): """快照 key 归一化: 参数**解码后**排序拼接。 必须与页内 window.__NK 同语义 —— 直接用 URL 字符串做 key 会被 encodeURIComponent 与 urllib quote 的编码差异 (! * ' ( ) 一编一不编) 和参数顺序打掉, 而这种失效是**静默**的: 命中不了就退到 "快照版不含此功能", 看起来像功能没做, 不像 key 错了。 """ a = sorted('%s=%s' % (k, v) for k, v in kw.items() if v not in (None, '')) return path + ('?' + '&'.join(a) if a else '') def _bake(key, fn): """烤一个 API 响应。失败**记名**不静默 —— 守护步骤失败必须响亮。 ★2026-08-31: 此前直接 _redact(_jclean(...)), **绕过 _jdump 里的语言投影**, 于是 ?lang=en 的快照内联的仍是中文数据 (实测 57 万→11 万中文只是 页面文案变了, 数据没变)。这里补上同一套投影。 """ try: # ★顺序是 **_redact 先, _proj 后**, 不要"为了与在线同序"去调它: # 曲线图注等模板的 key 是照**脱敏后**的文本登记的 (含 ▪ 掩码), # 先投影的话字符串里还是真实数字, 模板全部对不上, 整批退回 # [translation pending]。我按"同序"改过一次, 当场被 parity 闸逮住。 # 在线路径没有这个问题是因为脱敏在页面层统一做, 不在数据层。 _v = _redact(_jclean(fn())) if _lang and _lang != 'zh': _v = _proj(_v, _lang) snap[key] = _v return True except Exception as e: _skip.append('%s: %s' % (key, str(e)[:60])) return False for _w in _wins: _bake(_nk('/api/fleet', win=_w), lambda w=_w: fleet_view(w)) _bake('/api/curves', curve_view) _bake('/api/ontology', ontology_view) # ★后加的面板接口也要预烤 —— 漏了会在离线快照里空掉。 # 新增接口时必须回到这里补一行, 否则快照静默少一块。 def _maint_std(): import json as _j2, pathlib as _p2 from src.windscada.maint import standards as _st2 _db2 = _j2.loads(_P.objects_json() .read_text(encoding='utf-8')) _wo2 = [(v.get('props') or {}) for k, v in _db2.items() if k.startswith('workorder/')] _r2 = _st2.run_all(_wo2) _r2['score'] = _st2.score(_wo2, _r2) return _r2 _maint_fw = maint_framework_view # ★与在线 API 同一实现 _dq = dq_findings_view # ★与在线 API 同一实现 _bake('/api/maint_std', _maint_std) _bake('/api/maint_framework', _maint_fw) _bake('/api/dq_findings', _dq) _bake('/api/maint_survey', lambda: dict(rows=__import__('src.ontology.maintenance', fromlist=['survey']).survey())) # ── 扩展预烤 (2026-08-30 用户"完整展示") ─────────────────────── # 原先只烤上面 4 个, 另外 7 个接口在离线版里是死的: 部件问题下钻、 # 本体对象/清单/链、振动详情点开全是"快照版不含此功能" —— 页面能渲染 # 掩盖了功能不可用, 我此前按"十页可交互"汇报是说过头了。 # ask / rpt_compose 要现跑模型, 不烤 (页面另给在线提示), 其余全量枚举。 _bake('/api/vibcms', vibcms_results) # 离线桩: 快照里没有服务端跑模型, 所以这条**有意**与在线不同。 # note 双写 —— 靠翻译层会得到 [translation pending], 而这是给读者看的一句话。 _bake('/api/ask_status', lambda: dict( offline=True, models=[], note_zh='离线版: 模型问答需连接系统', note_en='Offline copy: the model Q&A needs a live connection to the system')) # 部件问题: (机组, 系统) 从各窗 systems 展开, 每个窗各烤一份 _pairs = set() for _w in _wins: _fv = snap.get(_nk('/api/fleet', win=_w)) or {} for _sysn, _rows in (_fv.get('systems') or {}).items(): for _r in (_rows or []): if isinstance(_r, dict) and _r.get('t'): _pairs.add((_r['t'], _sysn)) # ★系统名两种形态都要烤 (2026-09-01 实逮"点开是空浮层")。 # systems 的分组键带**等级后缀** (`塔架与基础·良好`), 而页面上 # `/problem//` 链接发出的是**裸系统名** (`塔架与基础`) —— # 于是 key 差一截, 取不到数, 浮层开了但里面是"本窗无记录"。 # **这种失效最难发现: 浮层确实弹出来了, 看着像"这台这系统没数据", # 而不像"key 错了"。** 两种形态都烤成同一份数据即可。 for _t, _sy in sorted(_pairs): _forms = {_sy} if '·' in _sy: _forms.add(_sy.split('·')[0]) for _w in _wins: for _f in sorted(_forms): # ★每种形态烤**它自己**的数据。上一版这里捕获的是带等级的 _sy, # 于是裸名 key 拿到的是带等级那份 —— 实测差出 0 vs 1 条, # **被对拍闸逮到**。lambda 默认参数必须捕获循环变量 _f 本身。 _bake(_nk('/api/problem', t=_t, sys=_f, win=_w), lambda t=_t, sy=_f, w=_w: dict(problems=single_problem(t, sy, w))) # 整机视图: 页面上机组号链到 /turbine/, 弹层要用 for _t in sorted({t for t, _ in _pairs}): for _w in _wins: _bake(_nk('/api/turbine', t=_t, win=_w), lambda t=_t, w=_w: dict(problems=turbine_problems(t, w))) # 本体清单 + 每个对象详情 (清单里出现的 id 才可能被点开) _oids = set() for _ty in sorted((ontology_view().get('stats') or {})): _k = _nk('/api/ont_list', type=_ty, limit=60) if _bake(_k, lambda ty=_ty: dict(rows=ontology_list(ty, '', 60))): for _row in (snap[_k].get('rows') or []): if isinstance(_row, dict) and _row.get('id'): _oids.add(_row['id']) for _oid in sorted(_oids): _bake(_nk('/api/ont_obj', id=_oid), lambda i=_oid: ontology_obj(i)) # 本体链: kind 五种, 参数取页面下拉的实际取值域 try: from src.ontology import mcp_server as _omc _bake(_nk('/api/ont_chain', kind='plan', top='12'), lambda: _omc.ont_maint_plan(top=12)) _syslist = sorted({sy for _, sy in _pairs}) or ['齿轮箱'] for _sy in _syslist: _bake(_nk('/api/ont_chain', kind='prev', system=_sy), lambda sy=_sy: _omc.ont_chain_preventive(system=sy)) _bake(_nk('/api/ont_chain', kind='tree', system=_sy), lambda sy=_sy: _omc.ont_fault_tree(system=sy)) # ★原来码源是**另一处烤好的** /api/ont_list?limit=60 的行, 于是全场 555 个码 # 只进了按码号排序的前 60 个; 而页面上"点一下就查"的示例码 # (3225/64000/64001/4110/13121) 恰好都不在那 60 个里 # ⇒ **页面在推荐自己答不上的码**, 点下去整条抢修链没反应。 # "排序后取前 N"的裁剪最容易把真正要用的挡在外面。 # ★改为**直接从本体读**, 不再依赖别处烤没烤、烤了多少 —— # 跨模块借数据是这次截断的根因, 借来的上限管不着。 # (中途踩坑: 我先写成 _omc.ont_list(...), 而 mcp_server 根本没有这个函数, # AttributeError 被外层 try 吞掉 ⇒ 整段 fault 一条都没烤, 还静默。) import json as _js _db = _js.loads(pathlib.Path(_omc.OBJ_PATH).read_text(encoding='utf-8')) _codes = sorted(k.split('/')[-1] for k in _db if k.startswith('alarmcode/')) for _c in [c for c in _codes if c]: _bake(_nk('/api/ont_chain', kind='fault', code=_c), lambda c=_c: _omc.ont_chain_fault(code=c)) except Exception as _e: _skip.append('ont_chain: %s' % str(_e)[:60]) if _skip: print('[snapshot] 未烤成 %d 项: %s' % (len(_skip), '; '.join(_skip[:6])), file=sys.stderr) print('[snapshot] 预烤 %d 键 (%s)' % ( len(snap), ', '.join(sorted({k.split('?')[0] for k in snap}))), file=sys.stderr) if _lang and _lang != 'zh': # ★key 统一在**全部烤完之后**加 lang: 英文页的 fetch 会自动追加 # ?lang=en, 预烤 key 不带就永远对不上 (miss 是静默的, 退到 err 提示, # 看着像功能没做)。 # ★不能在 _bake 内部改 —— 后续代码还要用原 key 从 snap 取值, # 改了就 KeyError (本次实测踩到)。 # ★存双 key: 带 lang 与不带。页面首帧的 fetch 可能早于 fetch patch # 生效, 那一发不带 lang —— 只存带 lang 的会 miss 一次 (实测首屏 fleet)。 # # ★★ 加 lang 必须走**同一个归一化器**, 不能字符串拼接 (2026-09-01 实逮)。 # __NK 把参数**按字母排序**, 所以正确形态是 `?lang=en&sys=…&t=…&win=…`; # 而拼接得到的是 `?sys=…&t=…&win=…&lang=en` —— 差一个顺序, 全部 miss。 # 后果极隐蔽: 十个标签页照常显示 (它们命中的是**不带 lang** 的那份), # 只有**点开下钻**才走带 lang 的 key ⇒ 浮层弹出来却是"本窗无记录", # 看着像"这台这系统没数据", 不像 key 错了。用户报的"有些点不开"就是它。 def _with_lang(k): base, _, q = k.partition('?') kv = dict(x.split('=', 1) for x in q.split('&') if '=' in x) if q else {} kv['lang'] = _lang return base + '?' + '&'.join(sorted('%s=%s' % (a, b) for a, b in kv.items())) snap = dict(snap, **{_with_lang(k): v for k, v in snap.items()}) page = PAGE_FLEET.replace('%CSS%', BASE_CSS).replace('%JS%', CHART_JS) if _lang and _lang != 'zh': # 快照页面本身也走英文化 (文案表 + 显示层脚本), 并改 lang 属性 —— # 此前只投影了 API 路径, 快照页漏了, 导出的 index.html 一直是 lang="zh" page = _en_page(page).replace('window.__SNAP__=' + json.dumps(snap, ensure_ascii=False, allow_nan=False) + ';\n' '/* 快照模式: 拦截 fetch 从内联数据取。key 走归一化(参数解码后排序)而非 */' '/* 原样 URL —— 编码差异(encodeURIComponent 不编 !*\'() 而 quote 编)与参数 */' '/* 顺序都会让字符串比对落空, 且落空是静默的: 退到 err 提示, 看着像功能没做。 */' 'window.__NK=function(u){var s=String(u),i=s.indexOf("?");if(i<0)return s;' 'var p=s.slice(0,i),a=s.slice(i+1).split("&").filter(Boolean).map(function(kv){' 'var j=kv.indexOf("=");if(j<0)return kv;var k=kv.slice(0,j),v=kv.slice(j+1);' 'try{v=decodeURIComponent(v.replace(/\\+/g," "))}catch(e){}' 'return v===""?null:k+"="+v}).filter(function(x){return x!==null}).sort();' 'return p+(a.length?"?"+a.join("&"):"")};' 'window.__HIT={ok:0,miss:[]};window.__RF=window.fetch;' # ★没烤的窗按钮直接摘掉: 让用户点一个必然空白的选项, 比少给几个窗更糟。 # 默认窗取**已烤窗里最新的那个**, 供上面的回落候选用。 'window.__WINS=(function(){var s={};Object.keys(window.__SNAP__).forEach(' 'function(k){var m=/[?&]win=([^&]*)/.exec(k);if(m)s[decodeURIComponent(m[1])]=1});' 'return Object.keys(s)})();' 'window.__DEFWIN=window.__WINS[0]||"";' 'window.__WINLABEL=' + json.dumps( 'Latest completed build' if _lang == 'en' else '最新一次完整分析') + ';' # ★快照里**去掉窗选择器**, 换成固定标签 (2026-09-01 用户令 # "设定不随时间窗动, 就显示最新的")。 # 理由: 选择器给约 20 个窗而只烤了 4 个, 选中其余任何一个都空白; # 而这 4 个窗的内容**完全一样** (fus 40 行 / 需跟踪 76 台次 逐窗相同), # 所以留着选择器只会让人以为能切、切了还空。 # ★先前那版是"摘掉没烤的 option" —— 摘过头了: 选项显示文本已英文化 # ("2026 full year"), 而 __WINS 是中文键, 匹配不上就把有效的也摘了。 # **文本匹配在译过的 UI 上天然不可靠**, 所以改为整体替换而非逐项筛。 'window.__PINWIN=function(){try{' 'var sel=document.querySelector("select#win");if(!sel)return;' 'var lab=document.createElement("span");lab.id="winpin";' 'lab.style.cssText="font-size:12.5px;color:var(--muted);padding:5px 9px;' 'border:1px solid var(--line);border-radius:7px;background:var(--panel)";' 'lab.textContent=window.__WINLABEL||"Latest build";' 'lab.title="This snapshot is fixed to the latest completed build; ' 'the panels below do not vary by window.";' 'sel.parentNode.replaceChild(lab,sel)}catch(e){}};' 'try{new MutationObserver(function(){window.__PINWIN()})' '.observe(document.documentElement,{childList:true,subtree:true})}catch(e){}' 'document.addEventListener("DOMContentLoaded",function(){' 'setTimeout(window.__PINWIN,300)});' # 问答接口不能被离线拦截: 云上 nginx /api/ 后面有真服务在跑, # 其余接口才走预烤。放行名单写死, 免得日后新增接口误穿。 'window.fetch=function(u,o){var k=window.__NK(u);' 'if(/^\\/api\\/(ask|ask_status|ask_models|rpt_compose|rpt_export)\\b/.test(String(u)))return window.__RF(u,o);' # ★第 4 个候选: 把 win 换成默认窗 (2026-09-01 用户令"不随时间窗动, 就显示最新的")。 # 窗选择器给了约 20 个选项而快照只烤了 4 个 —— 选中其余任何一个 # 都会整页空白 (表现为"该系统全场无异常"、振动页空), 看着像没数据, # 实则是没烤。这些面板的内容本就不随窗变 (四个窗 fus 完全一样), # 所以回落到默认窗是**正确答案**而不是遮丑。 # ★同 src/windscada/ui/snapshot.py 的坑: 这里不能 encodeURIComponent —— k 与 # __SNAP__ 键都是原文(未编码)中文, 编码后拼出的键谁都不匹配, 兜底静默失效 # (2026-09-03 实逮: 换窗点浮层报"本窗无记录", 默认窗其实有数据)。 'var c=[k,k.replace(/&?win=[^&]*/,"").replace(/\\?$/,""),String(u).split("?")[0],' 'k.replace(/win=[^&]*/,"win="+(window.__DEFWIN||""))];' 'for(var i=0;i') # ★英文版改走**源码级生成**, 不再注入运行时翻译 hook # (2026-08-31 用户令: 不要打补丁式翻译, 整个系统重新生成一遍)。 # hook 的本质是给中文系统套一层英文 —— 只译写入 DOM 的那一刻, # JS 逻辑与 JSON 键仍是中文, 长尾补不完且源码里满是中文。 # 改为在页面拼好后整页替换 (见本函数末尾与 i18n_en.to_english_source)。 # ★中文版完全不受影响: 不带 lang=en 时这条分支不执行, 国内照常用。 # ★ 单文件版的下钻: 页面上 74 个链接指向 /problem// 与 /turbine/, # 那是服务端独立页面 —— 快照里点了就是死链 (404/白页)。此前"十页可交互" # 的说法没覆盖这一层。改法: 拦截这两类链接, 用已预烤的同一份数据在页内 # 开浮层, 图表复用共享的 drawFig, 与在线版看到的是同一批图。 # ★注入块里的文案要**自己**按语言选: 它加在 _en_page 之后, # 英文化那一遍已经跑完了, 这里的中文字面量永远不会被翻译。 # 实逮: 英文页浮层里弹出"本窗无记录"。 # ('状态'/'依据' 是 JSON 键, '年' 是窗名数据值 —— 都不能动。) inject += ('') page = page.replace('', page, re.S): if 'application/json' in _m.group(1) or not _m.group(2).strip(): continue _uie.check_js(_m.group(2), label='快照脚本#%d' % (_nchk + 1)) _nchk += 1 print('[snapshot] 成品页语法闸: %d 个脚本全部通过 node --check' % _nchk, file=sys.stderr) if _nchk == 0: print('[snapshot] 🔴 语法闸切出 0 个脚本 —— **本次未校验**, 非通过', file=sys.stderr) except Exception as _e: print('[snapshot] 🔴 成品页语法闸失败: %s' % _e, file=sys.stderr) raise if _lang == 'en': _n1 = len(re.findall(r'[\u4e00-\u9fff]', page)) print('[snapshot] 英文快照: 页面残留中文 %d 字 (含内联数据)' % _n1, file=sys.stderr) # ★脱敏必须是**最后一步**, 且只对上云那一版做 (2026-09-01 用户令)。 # 中文快照是内部工作副本, 要保留真实场名/机型, 不脱敏。 # 英文快照是要发到公网的, 而它此前带着 rudong×366 / Siemens×194 / # SWT×89 —— **同时页面上写着 "site, owner and OEM removed"**, # 产物与自己的声明不符, 这比不声明更糟。 # 放最后: 对象 id 与引用它的地方必须换成同一个词, 只能整体做。 if _lang == 'en': from src.windscada import deid_public as _dp page = _dp.scrub_or_die(page, label='英文快照 (上云件)') print('[snapshot] 脱敏回扫: 干净 (rudong/OEM/机型/省市/业主/坐标 全零)', file=sys.stderr) fn = (f'Guanlan_Snapshot_{CUR_WIN[0]}_EN.html' if _lang == 'en' else f'观澜快照_{CUR_WIN[0]}_{"上报版" if not INTERNAL else "内部版"}.html') data = page.encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(data))) self.send_header('Content-Disposition', "attachment; filename*=UTF-8''" + urllib.parse.quote(fn)) self.end_headers() self.wfile.write(data) except Exception as e: import traceback self._send(_jdump(dict(err=str(e)[:200], tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500) elif u.path == '/api/rpt_export': # 导出 Word/Excel (2026-08-28 用户令: 导出 demo)。图表块转数据表, 不放位图 — # Word 里的位图既不能编辑也无法核对, 上报集团要的是能核的数字。 q = urllib.parse.parse_qs(u.query) fmt = q.get('fmt', ['docx'])[0] blocks = [x for x in q.get('blocks', [''])[0].split(',') if x] aud = q.get('aud', ['in'])[0] w = q.get('win', ['2026年'])[0] narr = q.get('narr', [''])[0] or None try: from src.windscada import report_export as _rx fl = fleet_view(w) if aud == 'up': # 上报版走同一套脱敏规则 fl = _redact(_jclean(fl)) data = (_rx.to_xlsx if fmt == 'xlsx' else _rx.to_docx)(fl, blocks, w, aud, narr) ext = 'xlsx' if fmt == 'xlsx' else 'docx' mime = ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' if fmt == 'xlsx' else 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') fn = f'运行分析报告_{w}_{"上报版" if aud == "up" else "内部版"}.{ext}' self.send_response(200) self.send_header('Content-Type', mime) self.send_header('Content-Length', str(len(data))) self.send_header('Content-Disposition', "attachment; filename*=UTF-8''" + urllib.parse.quote(fn)) self.end_headers() self.wfile.write(data) except Exception as e: import traceback self._send(_jdump(dict(err=str(e)[:200], tb=traceback.format_exc()[-400:]), ensure_ascii=False), 'application/json; charset=utf-8', 500) elif u.path in ('/api/facts', '/api/facts/claim'): # 结构化事实契约 (用户批 2026-09-06 "8033 /api 切到契约生成"): 只读 derived/ 产物, 不算数不改写; # 生成器 = scripts/guanlan_facts_contract.py render, 契约 sha 随体返回供前端/CI 对拍. import json as _json _dd = _P.guanlan() / 'derived' try: if u.path == '/api/facts': _body = _dd.joinpath('detail_cards.json').read_text(encoding='utf-8') else: _cid = urllib.parse.parse_qs(u.query).get('id', [''])[0] _qa = _json.loads(_dd.joinpath('qa_refs.json').read_text(encoding='utf-8')) _hit = next((r for r in _qa.get('refs', []) if r.get('claim_id') == _cid), None) _body = _jdump(dict(contract_sha256=_qa.get('contract_sha256'), claim=_hit, err=None if _hit else 'unknown claim_id'), ensure_ascii=False) self._send(_body, 'application/json; charset=utf-8') except FileNotFoundError as e: self._send(_jdump(dict(err='facts derived 缺失: %s' % e), ensure_ascii=False), 'application/json; charset=utf-8', 503) elif u.path == '/api/rpt_compose': q = urllib.parse.parse_qs(u.query) if q.get('probe', [''])[0]: # 前端只探"编排接口是否存在" (v2 bindReport probe=1); 原先照跑 rpt_compose('', [], model) 走模型, # 汇报定制页首屏空等 ~23 s (2026-09-06 实测) —— 探针只答存在性, 不算数. self._send(_jdump(dict(ok=True, probe=True), ensure_ascii=False), 'application/json; charset=utf-8'); return r = rpt_compose(q.get('need', [''])[0], [x for x in q.get('canvas', [''])[0].split(',') if x], q.get('model', ['qwen3:8b'])[0]) self._send(_jdump(r, ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ask_status': self._send(_jdump(ask_status(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ask_models': # 与云端问答微服务 (windscada_ask_serve.py) 同形: {models: {id: {...}}}。此前本地没有 # 这条路由, 经典页的模型下拉在本地永远是"无可用模型" —— v2 与经典页同源取清单。 self._send(_jdump(dict(models={k: {} for k in ASK_MODELS}), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ontology': self._send(_jdump(ontology_view(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ont_list': self._send(_jdump(dict(rows=ontology_list(q.get('type', ''), q.get('t', ''), q.get('limit', 60))), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/ont_obj': self._send(_jdump(ontology_obj(q.get('id', '')), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path == '/api/channels': self._send(_jdump(channel_list(), ensure_ascii=False), 'application/json; charset=utf-8') elif u.path.startswith('/static/'): f = ST / u.path[len('/static/'):] if f.is_file(): self._send(f.read_bytes(), 'text/html; charset=utf-8') else: self._send('404', code=404) elif u.path.startswith('/turbines/'): f = ST / u.path.lstrip('/') if f.is_file(): raw = f.read_text(encoding='utf-8', errors='ignore') light = '' raw = raw.replace('', light + '', 1) if '' in raw else light + raw self._send(raw) else: self._send('404', code=404) elif u.path.startswith('/windcms/'): # windcms 振动分析本体嵌入 (用户令: 原振动分析板块进入振动融合板块; 静态挂载非复制, # 正本仍在 outputs/rudong/windcms 由振动线维护, 本路由只读) base = (ST.parent / 'windcms').resolve() f = (base / u.path[len('/windcms/'):]).resolve() if base in f.parents or f == base: if f.is_dir(): f = f / 'index.html' if f.is_file(): ext = f.suffix.lower() mime = {'.html': 'text/html; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.css': 'text/css', '.js': 'application/javascript', '.md': 'text/plain; charset=utf-8'}.get(ext, 'application/octet-stream') self._send(f.read_bytes(), mime) else: self._send('404', code=404) else: self._send('403', code=403) else: self._send('404', code=404) except ProductsMissing as e: # 产物缺失不是"程序坏了": 回 200 + 结构化无数据, 前端按正常渲染显示原因 (2026-09-11 空状态检查) self._send(_jdump(dict(kind='multiline', title='无产物', unit='', months=[], series=[], err='no_products', note=f'{e.what} 不存在 → {_P.rel(e.path)}; 先跑 ' f'scripts/rebuild_from_raw.py (SCADA 侧加 --scada) 生成产物, ' f'或用 scripts/products_state.py --on 还原'), ensure_ascii=False), 'application/json; charset=utf-8') except Exception as e: import traceback self._send(_jdump(dict(err=str(e), tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500) if __name__ == '__main__': import argparse ap = argparse.ArgumentParser() import os ap.add_argument('--port', type=int, default=int(os.environ.get('PORT', 8033))) # PORT 环境变量优先, 供 preview 分配端口 ap.add_argument('--host', default='127.0.0.1', help='0.0.0.0 = 对局域网开放 (演示用, 结束请关)') ap.add_argument('--farm', default=None, help='场名 (默认 rudong; 可用场见 configs/farms/)') a = ap.parse_args() print(f'windscada serve :{a.port}', flush=True) # --host 0.0.0.0 供局域网演示 (2026-08-28 用户: 怎么发给别人)。 # 默认仍只绑本机 — 内部数据不该因为一次演示就对整个网段开放, 要开是显式动作。 if getattr(a, 'farm', None): # ★CFG/ST 在模块加载时就绑定了 (第 81 行), 这里必须显式重绑而不是只切 current — # 否则 --farm 只改了配置模块里的指针, 服务用的仍是启动时那个场 (静默不生效)。 # 用模块字典赋值而非 global: main 里更早处已引用过 CFG, global 声明会触发 # "used prior to global declaration"。改模块属性等效且不受声明位置约束。 from src.windscada import config as _cfg import sys as _sysmod _me = _sysmod.modules[__name__] _cfg.set_current(a.farm) _me.CFG = _cfg.farm() _me.ST = pathlib.Path(_me.CFG['store']) _CACHE.clear() print(f'场: {_me.CFG["name"]} ({a.farm}, {_me.CFG["n_turbines"]} 台, 仓 {_me.ST})') else: print(f'场: {CFG["name"]} (默认; 可用 {list(__import__("src.windscada.config", fromlist=["available"]).available())})') _host = getattr(a, 'host', '127.0.0.1') if _host != '127.0.0.1': import socket as _sk try: _ip = _sk.gethostbyname(_sk.gethostname()) except Exception: _ip = _host print(f'⚠ 已对局域网开放: http://{_ip}:{a.port} (同网段任何人可访问, 演示结束请关闭)') ThreadingHTTPServer((_host, a.port), H).serve_forever()