#!/usr/bin/env python3 # -*- coding: utf-8 -*- """windscada serve (M7c): 交互后端 — 工作台(全场)+分台页, 全部带时间窗. 设计: ①台页只渲染"有问题的系统"的证据图(分册两型: A=判据线+摆幅标注, B=本台vs全场四分位带), 无问题不出图; ②台内交互查询: 任一契约通道×时间窗×聚合 即时出图; ③工作台: 系统分类问题(全系统)+故障统计梳理+SOP控制策略; ④时间窗: 月度类数据真窗; 判级类(变桨/偏航/蓄能=近90日, 曲线=2025H2判别窗)固定时间窗如实标注. 端口 8033. 判级=筛查级/候选, P级归综合报告线.""" try: from app_common.app_common_guanlan.api import install_root as _install_root except ImportError: # 极端兜底(包结构异常时按位置上跳) from pathlib import Path as _P def _install_root(_f): return _P(_f).resolve().parents[3] import sys as _sys, pathlib as _plb _sys.path.insert(0, str(_install_root(__file__))) from src import paths as P import sys, json, pathlib, re, threading, urllib.parse, collections import hashlib, time sys.path.insert(0, str(_install_root(__file__))) # 安装根入 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'])) r = dict(rows=rows, n=len(rows)) if not rows: # ★2026-09-18 用户令("页面只能基于输入数据重算")之后实逮: 清过产物再重算的机器上 # `objects.json` 里 **0 条 finding/* 对象** ⇒ 本页只是"空", 不说原因就等于让人以为 # "系统自查没问题"。这四族的生成端确实不在重算链上(见族表 guanlan/ontology 说明), # 所以如实写清, 并明确"不用旧版产出补"。 r['note'] = ('本体对象库里没有 finding/* 对象 (四族: data_quality / caliber / system_coverage / ' 'tier_trend) ⇒ 本页**如实为空**, 不是"自查无发现"。这些 finding 的生成端不在重算链上' '(唯一写方 scripts/ingest_ops_2025.py 的输入 data/raw/工作库 不在现场数据里), ' '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端。') return r 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) def _gap_page(path: str, missing: 'pathlib.Path | None' = None, what: str = '这一块') -> str: r"""产物缺失时的兜底页 (2026-09-17 现场问题驱动)。 原来 `/static/…`、`/turbines/…` 取不到文件就回一个裸 `404` 文本 —— 浏览器里表现为**一块空白**, 用户看到的现象是"这个板块没有信息", 而不是"缺件"。最典型的就是总览页那个 `