fleet_views.py 70 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210
  1. # -*- coding: utf-8 -*-
  2. r"""整场取数视图(P12):`fleet_view` 及其**传递闭包**内的全部本地函数与模块级全局,机械搬到算法服务。
  3. ★来源:`app_backEnd/app_backEnd_guanlan/serve.py`(原样搬运,未改写逻辑)。
  4. 搬运范围由 AST 计算(种子 `fleet_view` → 递归展开被调本地函数 → 带上被引用的模块级全局),避免漏项。
  5. """
  6. from __future__ import annotations
  7. from pathlib import Path as _P
  8. import sys as _sys, pathlib as _plb
  9. from src import paths as P
  10. import os as _os
  11. import collections
  12. import datetime as _dt
  13. import json
  14. import pathlib
  15. import re
  16. import threading
  17. import time
  18. from typing import Any
  19. from app_common.app_common_guanlan.api import paths as _P
  20. from src.windscada.config import farm
  21. from src import paths as _P # 路径唯一真源 (与 cwd 无关)
  22. from src.windscada import i18n
  23. from src.windscada import taxonomy
  24. from src.windscada.subsys import temp_nbm, hydraulic, yaw as yawmod, pitch as pitchmod
  25. import hashlib, time
  26. import numpy as np, pandas as pd
  27. import sys, json, pathlib, re, threading, urllib.parse, collections
  28. import threading as _thr
  29. # ── 原 serve.py 的模块级全局(逐字搬) ──
  30. CFG = farm(); ST = pathlib.Path(CFG['store'])
  31. LOCK = threading.Lock()
  32. CFG = farm(); ST = pathlib.Path(CFG['store'])
  33. TS = {'报警': 0, '危险': 0, '良好': 1, '不可判': 2, '优秀': 3}
  34. _CACHE = {}
  35. _HEAVY_LOCK = _thr.Lock()
  36. _STAMP = {'val': None, 'at': 0.0}
  37. _STAMP_TTL = 2.0 # 秒: 指纹有效期 (stat ~150 个文件 ≈ 1~3 ms, 不值得每请求都做)
  38. _WIN_BUSY: dict = {'sysmx': set(), 'curves': set(), 'm9': set()}
  39. _WIN_CACHE: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
  40. _WIN_ERR: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
  41. _WIN_LOCK = _thr.Lock()
  42. def _product_files():
  43. """页面取数依赖的产物文件清单 (顺序稳定: 供指纹; 只列**产物**, 不含 reference/ 随包契约)。
  44. 覆盖 _load() 直读的件 + 它经 taxonomy/temp_nbm/hydraulic/fusion 间接读的件:
  45. windscada/*.parquet|csv|json · ontology/*.json · pitch/*.parquet
  46. m5_cms_tcm/{handoff_vibration_v2,component_history,baseline_38}.json · windcms/报告_CMS*.md
  47. """
  48. try:
  49. from src.windcms.config import cms_out as _cms_out # 与写侧同一解析口 (WINDCMS_OUT)
  50. _cms_dir = _cms_out()
  51. except Exception:
  52. _cms_dir = _P.cms()
  53. pats = ((ST, ('*.parquet', '*.csv', '*.json')),
  54. (_P.ont(), ('*.json',)),
  55. (_P.pitch(), ('*.parquet',)),
  56. (_P.m5(), ('handoff_vibration_v2.json', 'component_history.json', 'baseline_38.json')),
  57. (_cms_dir, ('报告_CMS振动状态评估报告_*.md',)))
  58. files = []
  59. for d, ps in pats:
  60. for pat in ps:
  61. files.extend(sorted(d.glob(pat)))
  62. return files
  63. def products_stamp(force=False):
  64. """产物指纹 (sha1 前 16 位)。force=True 时忽略 TTL 立即重算。"""
  65. now = time.time()
  66. if not force and _STAMP['val'] is not None and (now - _STAMP['at']) < _STAMP_TTL:
  67. return _STAMP['val']
  68. h = hashlib.sha1()
  69. for p in _product_files():
  70. try:
  71. st = p.stat()
  72. h.update(f'{_P.rel(p)}|{st.st_mtime_ns}|{st.st_size}\n'.encode('utf-8'))
  73. except OSError:
  74. h.update(f'{_P.rel(p)}|MISSING\n'.encode('utf-8'))
  75. _STAMP.update(val=h.hexdigest()[:16], at=now)
  76. return _STAMP['val']
  77. def _load():
  78. with LOCK:
  79. stamp = products_stamp()
  80. if _CACHE.get('__loaded'):
  81. if _CACHE.get('__stamp') == stamp:
  82. return
  83. # 产物变了 (典型: 刚跑完重算) → 重载。**先建后换**: 下面任何一步抛异常都不会破坏旧缓存,
  84. # 请求照旧能用旧数 (降级但不空白), 同时日志留痕。
  85. print(f'[reload] 产物指纹变化 {_CACHE.get("__stamp")} → {stamp}, 重载', flush=True)
  86. tmp = {}
  87. try:
  88. for key, name in (('tm', 'temp_monthly.parquet'), ('al', 'alarms.parquet'),
  89. ('lm', 'loss_monthly.parquet'), ('bins', 'powercurve_bins.parquet'),
  90. ('pcd', 'powercurve_dev.parquet')):
  91. f = ST / name
  92. if not f.exists():
  93. raise ProductsMissing(name, f)
  94. tmp[key] = pd.read_parquet(f)
  95. tmp['al']['month'] = tmp['al']['t_on'].dt.to_period('M').astype(str)
  96. tmp['pcd'] = tmp['pcd'].set_index('turbine')
  97. for key, name in (('duty', 'duty_monthly.parquet'),):
  98. f = ST / name
  99. tmp[key] = pd.read_parquet(f) if f.exists() else None
  100. try:
  101. tmp['sysmx'] = taxonomy.system_matrix()
  102. tmp['treg'] = temp_nbm.registry()
  103. tmp['treg'] = tmp['treg'][0] if isinstance(tmp['treg'], tuple) else tmp['treg']
  104. tmp['hyd'], _ = hydraulic.registry()
  105. tmp['hyd'] = tmp['hyd'].set_index('turbine')
  106. except FileNotFoundError as e: # 这些派生件同样在产物仓里; 缺了就按"无产物"处理
  107. raise ProductsMissing(getattr(e, 'filename', '派生产物'), getattr(e, 'filename', ST))
  108. zp = _P.pitch() / 'pitch_zero_monthly.parquet'
  109. tmp['zero'] = pd.read_parquet(zp) if zp.exists() else None
  110. tmp['__stamp'] = stamp
  111. tmp['__loaded'] = True
  112. _CACHE.update(tmp) # 原子提交: 失败时不留下半截缓存
  113. except Exception:
  114. if _CACHE.get('__loaded'):
  115. print('[reload] 重载失败 → 继续用上一份缓存 (页面不会空白, 但数是旧的; 看上面的异常)', flush=True)
  116. raise
  117. def _month_end(m):
  118. """'YYYY-MM' → 该月最后一天 'YYYY-MM-DD'。"""
  119. import calendar
  120. y, mo = int(m[:4]), int(m[-2:])
  121. return f'{m}-{calendar.monthrange(y, mo)[1]:02d}'
  122. def months_of(win):
  123. """窗 → 覆盖到的月份列表(月度类数据按此过滤)。"""
  124. _load()
  125. all_m = sorted(_CACHE['tm'].month.unique())
  126. if win == '全程': return all_m
  127. if win == '2025H2': return [m for m in all_m if '2025-07' <= m <= '2025-12']
  128. if win == '2026H1': return [m for m in all_m if '2026-01' <= m <= '2026-06']
  129. if win == '2026年': return [m for m in all_m if m >= '2026-01']
  130. import re as _re
  131. if _re.fullmatch(r'\d{4}-\d{2}', win): # 单月窗 (2026-08-27 用户令: 按月份过滤和选择)
  132. return [m for m in all_m if m == win]
  133. m2 = _re.fullmatch(r'(\d{4}-\d{2})~(\d{4}-\d{2})', win)
  134. if m2: # 月区间窗 "2025-04~2025-10"
  135. return [m for m in all_m if m2.group(1) <= m <= m2.group(2)]
  136. m3 = _re.fullmatch(r'(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})', win)
  137. if m3: # ★自定义起止(含)→ 相交的月 (用户令 2026-09-21)
  138. a, b = sorted((m3.group(1), m3.group(2)))
  139. return [m for m in all_m if _month_end(m) >= a and f'{m}-01' <= b]
  140. n = 1 if win == '近30日' else 3
  141. return all_m[-n:]
  142. def win_range(win):
  143. """窗 → (起, 止) 日期串,**含两端**(用户令 2026-09-21:时间窗支持自定义起止日期)。
  144. 预设/逐月/月区间窗一律落到"该时间窗覆盖月的月首/月末",于是与 `months_of()` 同口径;
  145. 日区间窗原样返回。日粒度件(停机事件/报警/日粒度判据)用它做**含端**过滤。
  146. """
  147. import re as _re
  148. if win:
  149. if _re.fullmatch(r'\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}', win):
  150. a, b = win.split('~')
  151. return (a, b) if a <= b else (b, a)
  152. if _re.fullmatch(r'\d{4}-\d{2}', win):
  153. return (f'{win}-01', _month_end(win))
  154. m2 = _re.fullmatch(r'(\d{4}-\d{2})~(\d{4}-\d{2})', win)
  155. if m2:
  156. return (f'{m2.group(1)}-01', _month_end(m2.group(2)))
  157. if win == '2025H2':
  158. return ('2025-07-01', '2025-12-31')
  159. if win == '2026H1':
  160. return ('2026-01-01', '2026-06-30')
  161. ms = months_of(win)
  162. if not ms:
  163. return ('0001-01-01', '9999-12-31')
  164. return (f'{ms[0]}-01', _month_end(ms[-1]))
  165. def span_of(win):
  166. """窗 → 计算层用的 (起, 止) 元组(含);判级/曲线等按窗重算的口子都吃这个。"""
  167. return win_range(win)
  168. def _mem_mb():
  169. """本进程可用内存(MB);拿不到就返回 None(不因为这些诊断信息把重算搞挂)。"""
  170. try:
  171. import ctypes
  172. class _MS(ctypes.Structure):
  173. _fields_ = [('dwLength', ctypes.c_ulong), ('dwMemoryLoad', ctypes.c_ulong),
  174. ('ullTotalPhys', ctypes.c_ulonglong), ('ullAvailPhys', ctypes.c_ulonglong),
  175. ('ullTotalPageFile', ctypes.c_ulonglong), ('ullAvailPageFile', ctypes.c_ulonglong),
  176. ('ullTotalVirtual', ctypes.c_ulonglong), ('ullAvailVirtual', ctypes.c_ulonglong),
  177. ('ullAvailExtendedVirtual', ctypes.c_ulonglong)]
  178. st = _MS()
  179. st.dwLength = ctypes.sizeof(_MS)
  180. ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(st))
  181. return int(st.ullAvailPhys // (1024 * 1024))
  182. except Exception:
  183. return None
  184. def _win_key(win):
  185. a, b = span_of(win)
  186. return f'{a}~{b}'
  187. def _win_get(kind, win, fn):
  188. """→ 值 或 None(None = 正在算/刚起算)。失败**记名**(`_WIN_ERR`)而不是静默当"没数据"。"""
  189. key = _win_key(win)
  190. with _WIN_LOCK:
  191. if key in _WIN_CACHE[kind]:
  192. return _WIN_CACHE[kind][key]
  193. if key not in _WIN_BUSY[kind]:
  194. _WIN_BUSY[kind].add(key)
  195. def _run():
  196. try:
  197. with _HEAVY_LOCK: # 重活串行: 同一时刻只算一份
  198. m0 = _mem_mb()
  199. print(f'[win] {kind} 按窗重算开算 {key}(可用内存 {m0} MB)', flush=True)
  200. v = fn()
  201. print(f'[win] {kind} 按窗重算完成 {key}(可用内存 {_mem_mb()} MB)', flush=True)
  202. except Exception as e: # 守护失败必响亮
  203. _WIN_ERR[kind][key] = f'{type(e).__name__}: {e}'[:200]
  204. print(f'[win] {kind} 按窗重算失败 {key}: {_WIN_ERR[kind][key]}', flush=True)
  205. v = None
  206. with _WIN_LOCK:
  207. if v is not None:
  208. _WIN_CACHE[kind][key] = v
  209. _WIN_BUSY[kind].discard(key)
  210. _thr.Thread(target=_run, name=f'win-{kind}-{key}', daemon=True).start()
  211. print(f'[win] {kind} 按窗重算启动 {key}(首次约数十秒,页面会先出 pending)', flush=True)
  212. return None
  213. def sysmx_of(win):
  214. """→ (判级矩阵, pending)。`pending=True` 时返回的是"另一口径"的旧矩阵,页面必须如实标注。"""
  215. from src.windscada import taxonomy
  216. m = _win_get('sysmx', win, lambda: taxonomy.system_matrix(CFG, span=span_of(win)))
  217. if m is None:
  218. return _CACHE['sysmx'], True
  219. return m, False
  220. def m9_of(win):
  221. """→ (控制参数一致性, pending)。按窗重算(走窄仓 ≈5 s/窗);干净窗直接用正式产物。"""
  222. from src.windscada.perf import control as _cm
  223. a, b = span_of(win)
  224. if (a, b) == (_cm.WIN[0], '2025-12-31'):
  225. return _cm.registry(CFG), False
  226. v = _win_get('m9', win, lambda: _cm.registry(CFG, span=(a, b)))
  227. if v is None:
  228. return _cm.registry(CFG), True # 未就位: 先给上一口径, 页面按 pending 标注
  229. return v, False
  230. def fleet_view(win):
  231. _load()
  232. ms = months_of(win)
  233. # ★2026-09-21 用户令「判级也按所选时间窗重算」: 判级矩阵按窗算(后台+缓存,首次 pending)。
  234. sysmx, sysmx_pending = sysmx_of(win)
  235. # ① 系统分类问题 (全系统)
  236. systems, sysdist = {}, {}
  237. for s in taxonomy.SYSTEMS:
  238. rows = [dict(t=t, st=sysmx[t][s]['状态'], why=i18n.humanize(sysmx[t][s]['依据']))
  239. for t in CFG['turbines'] if sysmx[t][s]['状态'] in ('报警', '不可判')] # 良好不出 (用户令: 只显示有问题的)
  240. rows.sort(key=lambda r: TS.get(r['st'], 9))
  241. systems[s] = rows
  242. # 全场分布 (系统入口卡用: rows 只含问题台, 不能当全场分母 — 会显示"共12台")
  243. from collections import Counter as _C
  244. sysdist[s] = dict(_C(sysmx[t][s]['状态'] for t in CFG['turbines']))
  245. # 良好台清单单列 (系统详情页第三档)
  246. systems[s + '·良好'] = [dict(t=t, st='良好', why=i18n.humanize(sysmx[t][s]['依据']))
  247. for t in CFG['turbines'] if sysmx[t][s]['状态'] == '良好']
  248. # ①b 关注清单: ≥2 系统报警 (逐台独立台页)
  249. watch = []
  250. for t in CFG['turbines']:
  251. al_sys = [x for x in taxonomy.SYSTEMS if sysmx[t][x]['状态'] == '报警']
  252. if len(al_sys) >= 2:
  253. watch.append(dict(t=t, n=len(al_sys), syss=al_sys,
  254. # ★不截断: 截到 70 字会把依据切成半句, 而模板反解要完整形态
  255. # ⇒ 英文侧整条落回中文 (2026-09-03 实逮, 总览 7 卡 4 张中文)。
  256. # 显示长度归前端 CSS 管。
  257. why=';'.join(f"{x}: {i18n.humanize(sysmx[t][x]['依据'])}" for x in al_sys)))
  258. watch.sort(key=lambda r: -r['n'])
  259. n_alarm_t = sum(1 for t in CFG['turbines'] if any(sysmx[t][x]['状态'] == '报警' for x in taxonomy.SYSTEMS))
  260. # ② 故障统计梳理 (真时间窗)
  261. al = _CACHE['al']; a = al[al.month.isin(ms)]
  262. top_txt = a.groupby(['code', 'text']).agg(n=('code', 'size'), dur_h=('dur_s', lambda x: x.sum() / 3600)).reset_index()
  263. pareto_n = top_txt.sort_values('n', ascending=False).head(12)
  264. pareto_d = top_txt.sort_values('dur_h', ascending=False).head(12)
  265. monthly = a.groupby('month').size().reindex(ms).fillna(0)
  266. per_t = a.groupby('turbine').size().sort_values(ascending=False).head(10)
  267. # 经典故障分析: 条数与时长必须配对看 (2026-08-28)。两个独立排行榜分不出
  268. # "高频短时(信号抖动或重复触发)" 与 "低频长时(硬故障)" — 处置方向相反, 混在一起会误派工。
  269. # 第三维=影响台数, 分离"单台刷屏"与"全场批次共性"。
  270. # 残月识别 (2026-08-28 审核逮): 2026-07 只有 6 天数据(覆盖 19.4%), 却被当整月
  271. # 并进"2026年"的占比与月度趋势 → 末柱"下降"是假象。占比类分母也因此不完整。
  272. import calendar as _cal
  273. _lmc = _CACHE['lm']; _lmc = _lmc[_lmc.month.astype(str).isin(ms)]
  274. _cov = {}
  275. for _mm in ms:
  276. _sub = _lmc[_lmc.month.astype(str) == _mm]
  277. if not len(_sub):
  278. _cov[_mm] = 0.0; continue
  279. _y, _mo = int(_mm[:4]), int(_mm[-2:])
  280. _cal_h = _cal.monthrange(_y, _mo)[1] * 24
  281. _cov[_mm] = round(float(_sub['rows_'].sum() / 6 / max(_sub['turbine'].nunique(), 1) / _cal_h), 3)
  282. _nt = a.groupby(['code', 'text'])['turbine'].nunique().rename('nt')
  283. _med = a.groupby(['code', 'text'])['dur_s'].median().rename('med_s')
  284. qd = top_txt.set_index(['code', 'text']).join([_nt, _med]).reset_index()
  285. qd = qd.sort_values('n', ascending=False).head(30)
  286. faults = dict(
  287. 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()],
  288. 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()],
  289. monthly=dict(months=ms, vals=[int(v) for v in monthly],
  290. cov=[_cov.get(x, 0.0) for x in ms],
  291. per_day=[round(float(monthly[x]) / max(_cov.get(x, 0.0) * _cal.monthrange(int(x[:4]), int(x[-2:]))[1], 1e-9), 1)
  292. if _cov.get(x, 0) > 0.02 else None for x in ms]),
  293. cov=_cov, cov_min=min(_cov.values()) if _cov else 1.0,
  294. partial=[x for x in ms if _cov.get(x, 1) < 0.5],
  295. quad=[dict(code=str(r.code), k=i18n.alarm_label(r.code, r.text)[:20], n=int(r.n),
  296. h=round(float(r.dur_h), 1), nt=int(r.nt), med=round(float(r.med_s), 1))
  297. for _, r in qd.iterrows()],
  298. n_codes=int(len(top_txt)),
  299. per_t=[dict(k=k, v=int(v)) for k, v in per_t.items()], total=int(len(a)))
  300. # ③ SOP 控制策略
  301. lm = _CACHE['lm']; l = lm[lm.month.astype(str).isin(ms)]
  302. hrs = l.groupby('state')['rows_'].sum() / 6
  303. loss = l.groupby('state')['loss'].sum() / 1000
  304. hrs_tot = max(hrs.sum(), 1)
  305. stop_h = hrs.get('停机', 0) + hrs.get('停机(调度令)', 0)
  306. disp_h = hrs.get('停机(调度令)', 0)
  307. lw_h = hrs.get('低风待机', 0)
  308. # 口径与 availability.summary 单源一致: 1 − 停机/(总 − 调度令 − 低风待机)
  309. avail = 1 - (stop_h - disp_h) / max(hrs_tot - disp_h - lw_h, 1)
  310. pcd = _CACHE['pcd']
  311. # 能量账闭合 (2026-08-28 经典分析): 四行工况表看不出"电量流向"。
  312. # 理论可发 = 实际上网 + Σ各态损失; 过物理上限核 (不可超 38台×4.0MW×窗时长)。
  313. _act = l.groupby('state')['act'].sum() / 1000
  314. _wf = [dict(k=str(k), loss=round(float(loss.get(k, 0)), 0), act=round(float(_act.get(k, 0)), 0),
  315. h=round(float(hrs.get(k, 0)), 0)) for k in hrs.index]
  316. _A = float(_act.sum()); _L = float(loss.sum())
  317. _cap = len(CFG['turbines']) * 4.0 * float(hrs_tot) / max(len(CFG['turbines']), 1)
  318. # 逐月能量 (2026-08-28 报表端口): 汇报要环比与趋势, 全时间窗聚合给不出。
  319. # 残月覆盖率一并带出 — 报表里拿残月和整月比环比会读反 (故障月度图已踩过一次)。
  320. _em = []
  321. for _mm in ms:
  322. _sub = l[l.month.astype(str) == _mm]
  323. if not len(_sub):
  324. continue
  325. _a1 = float(_sub['act'].sum()) / 1000
  326. _l1 = float(_sub['loss'].sum()) / 1000
  327. _em.append(dict(m=_mm, act=round(_a1, 0), loss=round(_l1, 0), theo=round(_a1 + _l1, 0),
  328. loss_pct=round(_l1 / max(_a1 + _l1, 1) * 100, 1),
  329. cov=_cov.get(_mm, 1.0),
  330. eflh=round(_a1 / max(len(CFG['turbines']) * 4.0, 1), 0)))
  331. energy = dict(monthly=_em, act=round(_A, 0), loss=round(_L, 0), theo=round(_A + _L, 0),
  332. loss_pct=round(_L / max(_A + _L, 1) * 100, 1),
  333. eflh=round(_A / max(len(CFG['turbines']) * 4.0, 1), 0),
  334. cap=round(_cap, 0), cap_ok=bool(_A + _L < _cap),
  335. items=sorted(_wf, key=lambda r: -r['loss']))
  336. control = dict(
  337. energy=energy,
  338. 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()],
  339. avail=round(float(avail * 100), 1),
  340. curve=dict(sigma=round(float(pcd.dev_w.std() * 100), 2),
  341. cands=[dict(t=i, dev=round(float(r.dev_w * 100), 2), 判=r['判别']) for i, r in pcd.iterrows() if r['判别'] != '—'],
  342. note='固定判别窗 2025-H2'),
  343. anchors='限电命令面占比 2025≈0.197 → 2026≈0.769 (专项锚)')
  344. m8 = None
  345. try:
  346. from src.windscada.perf import faults as fmod
  347. if (ST / 'stop_events.parquet').exists():
  348. m8 = dict(mtbf=fmod.mtbf_summary(ms), stop_pareto=fmod.stop_pareto(ms), seasonal=fmod.seasonal())
  349. except Exception as e:
  350. m8 = dict(err=str(e)[:120])
  351. m9 = None
  352. try:
  353. from src.windscada.perf import control as cmod
  354. if (ST / 'control_profile.parquet').exists():
  355. m9, m9_pending = m9_of(win) # ★按窗重算(用户令 2026-09-21)
  356. except Exception as e:
  357. m9 = dict(err=str(e)[:120])
  358. m9_pending = False
  359. try:
  360. from src.windscada.perf import reliability as relmod
  361. rel = relmod.overview(CFG, span=span_of(win))
  362. except Exception as e:
  363. rel = dict(err=str(e)[:120])
  364. try:
  365. from src.windscada.subsys import fusion as fusmod
  366. ftab, fmeta = fusmod.fusion_table(CFG, all_turbines=True)
  367. mrows, mheads = fusmod.matrix(CFG, ms) # 整体时间窗: 矩阵现窗/记忆轨迹随全局窗选择器
  368. cap = fusmod.capability()
  369. ev, ev_bounds = fusmod.drivetrain_events(ms, CFG)
  370. lvcnt = {}
  371. for r in mrows:
  372. lv = r['振动']['level']; lvcnt[lv] = lvcnt.get(lv, 0) + 1
  373. fkpi = dict(定论=lvcnt.get('bad', 0), 预警=lvcnt.get('warn', 0), 候选观察=lvcnt.get('note', 0),
  374. 销案正常=lvcnt.get('ok', 0), 未列=lvcnt.get('unlisted', 0),
  375. 收录台=len(CFG['turbines']) - lvcnt.get('unlisted', 0), 全场=len(CFG['turbines']),
  376. 机制链=sum(1 for r in mrows if '机制链' in r['润滑'].get('note', '')),
  377. 系数=dict(wear=cap['classes'].get('wear_progressive', {}).get('coef'),
  378. thermal=cap['classes'].get('thermal_acute', {}).get('coef'),
  379. unknown=cap['classes'].get('unknown', {}).get('coef')))
  380. # 矩阵补 报警/工单 两列 (2026-08-28 用户令) — 报警=时间窗内真窗计数+族分布; 工单=历史台账(窗见列头)
  381. # 报警/工单两列只统计传动链三部件 (2026-08-28 用户令: 只说发电机/齿轮箱/主轴) —
  382. # 本矩阵每行是"该台传动链状态", 全场报警计数会把变桨/偏航/主控的量混进来 (316 条里绝大多数与传动链无关)
  383. _DT_PAT = '齿轮|齿箱|润滑油|油冷|滤芯|发电机|定子|绕组|滑环|主轴承|主轴|轴承'
  384. _a = _CACHE['al']; _aw = _a[_a.month.isin(ms)]
  385. _aw = _aw[_aw.text.str.contains(_DT_PAT, na=False)]
  386. _acnt = _aw.groupby('turbine').size()
  387. _arank = _acnt.rank(ascending=False, method='min')
  388. _FAM = [('齿轮箱', '齿轮|齿箱|润滑油|油冷|滤芯'), ('发电机', '发电机|定子|绕组|滑环'),
  389. ('主轴承', '主轴承|主轴')]
  390. try:
  391. from src.windscada.subsys import workorder as _wo
  392. _wod = _wo.load(CFG)
  393. except Exception:
  394. _wod = None
  395. for _r in mrows:
  396. _t = _r['turbine']; _g = _aw[_aw.turbine == _t]
  397. _fam, _taken = [], set()
  398. _CODEFAM = {'3225': '变桨叶片'} # 文本为空的码走码号归族 (3225=变桨液压, 现场西门子资料对译表)
  399. for _nm, _pat in _FAM:
  400. _byc = _g.code.astype(str).map(_CODEFAM) == _nm
  401. _sub = _g[~_g.index.isin(_taken) & (_g.text.str.contains(_pat, na=False) | _byc)]
  402. _taken |= set(_sub.index)
  403. if len(_sub): _fam.append(dict(k=_nm, n=int(len(_sub))))
  404. _oth = len(_g) - sum(f['n'] for f in _fam)
  405. if _oth > 0: _fam.append(dict(k='其他', n=int(_oth)))
  406. # 刷屏闸: 单码占比过半 → 条数不代表"问题多"而是一个码在抖 (08# 4609/4918=93.7% 码3225 集中2026-01)
  407. _burst = None
  408. if len(_g):
  409. _bc = _g.groupby('code').size().sort_values(ascending=False)
  410. _share = float(_bc.iloc[0]) / len(_g)
  411. if _share >= 0.5:
  412. _sub2 = _g[_g.code == _bc.index[0]]
  413. _burst = dict(code=str(_bc.index[0]), share=round(_share, 3), n=int(_bc.iloc[0]),
  414. months=sorted({str(m)[:7] for m in _sub2.t_on.dt.to_period('M').astype(str)}),
  415. med_s=float(_sub2.dur_s.median()) if 'dur_s' in _sub2 else None)
  416. # 明细 (用户令"浮窗说明之前发生问题"): top 码 + 首末时间
  417. _top = []
  418. if len(_g):
  419. for (_c2, _tx), _sub3 in _g.groupby(['code', 'text']):
  420. _top.append(dict(code=str(_c2), text=str(_tx)[:34], n=int(len(_sub3)),
  421. first=str(_sub3.t_on.min())[:10], last=str(_sub3.t_on.max())[:10]))
  422. _top.sort(key=lambda x: -x['n'])
  423. _r['报警'] = dict(n=int(_acnt.get(_t, 0)), rank=int(_arank.get(_t, 0)) if _t in _arank else None,
  424. tot=len(CFG['turbines']), fam=_fam, burst=_burst, items=_top[:6])
  425. if _wod is not None and len(_wod):
  426. _w = _wod[_wod.turbine == _t]
  427. _wtxt = (_w.get('故障名称', '').astype(str) + ' ' + _w.get('故障位置二级', '').astype(str)
  428. + ' ' + _w.get('维修对象', '').astype(str) + ' ' + _w.get('元器件名称', '').astype(str))
  429. _w = _w[_wtxt.str.contains(_DT_PAT, na=False)]
  430. _acts = _w['维修动作'].replace('', pd.NA).dropna().value_counts() if len(_w) else None
  431. _wi = []
  432. for _, _wr in _w.sort_values('t_report', ascending=False).head(6).iterrows():
  433. _wi.append(dict(date=str(_wr.get('t_report'))[:10], name=str(_wr.get('故障名称') or '')[:30],
  434. act=str(_wr.get('维修动作') or ''), part=str(_wr.get('维修对象') or _wr.get('元器件名称') or '')[:14]))
  435. _r['工单'] = dict(n=int(len(_w)),
  436. acts=[dict(k=str(k), n=int(v)) for k, v in (_acts.head(3).items() if _acts is not None else [])],
  437. items=_wi,
  438. 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)
  439. else:
  440. _r['工单'] = dict(n=0, acts=[], last=None)
  441. _wyr = ''
  442. if _wod is not None and len(_wod):
  443. _tv = _wod.t_report.dropna()
  444. _tv = _tv[_tv.dt.year > 2000] # 空日期落 1970 epoch, 剔除后再报窗 (否则列头写"1970~")
  445. if len(_tv): _wyr = f"{_tv.min():%Y}~{_tv.max():%Y}"
  446. mheads = dict(mheads, 报警=f"传动链三部件 · {ms[0]}~{ms[-1]}" if ms else '传动链三部件',
  447. 工单=f"传动链三部件 · 台账 {_wyr}" if _wyr else '台账不可用')
  448. # 决策链进度盘 (2026-08-28 用户问"这个到底能怎么用"): 原来只有 29# 一台的静态六格,
  449. # 是展示牌不是工作面 — 打开它做不了任何决定。改为对全部需跟踪台报"走到第几步、卡在哪、下一步做什么"。
  450. # 判定规则单源在此, 前端只渲染不判断。
  451. _CLOOP = {}
  452. try:
  453. _ch = json.loads((ST.parent / 'm5_cms_tcm' / 'component_history.json').read_text(encoding='utf-8'))
  454. for _k, _v in _ch.get('summary', {}).items():
  455. if _k.startswith('★换件闭环') and isinstance(_v, list):
  456. for _r in _v:
  457. _CLOOP[_r['turbine']] = _r
  458. if _k.startswith('★当前在升') and isinstance(_v, list):
  459. for _r in _v:
  460. _CLOOP.setdefault(_r['turbine'], {}).update(rising=_r)
  461. except Exception:
  462. pass
  463. _HIT = {'bad', 'warn', 'note'}
  464. # ★一台可能有多行 (29#/17# 各 2 行: 齿轮箱 + 主轴承)。用 {turbine: row} 直接建字典会被后一行覆盖,
  465. # 实测 29# 因此取到"齿轮箱二级行星内齿圈·监视"那行, 而它的决策链讲的是"主轴承+集中润滑泵·定论"
  466. # ⇒ 浮窗里部件、温度通道、油样全是另一个部件的 (memory: 画图须确认各维度来自同一个体)。
  467. _SEV = {'bad': 0, 'warn': 1, 'note': 2, 'stale': 3, 'ok': 4, 'unlisted': 5}
  468. _tbi = {}
  469. for _row in ftab.to_dict('records'):
  470. _k = _row['turbine']
  471. if _k not in _tbi or _SEV.get(_row.get('级'), 9) < _SEV.get(_tbi[_k].get('级'), 9):
  472. _tbi[_k] = _row
  473. board = []
  474. for _r in mrows:
  475. t = _r['turbine']
  476. srcs = [k for k in ('振动', '温度', '润滑', '油液') if (_r.get(k) or {}).get('level') in _HIT]
  477. lvl = (_r.get('振动') or {}).get('level')
  478. tb = _tbi.get(t, {})
  479. mech = ('机制链' in ((_r.get('润滑') or {}).get('note') or '')
  480. or '机制链' in (tb.get('振动结论') or ''))
  481. n_wo = (_r.get('工单') or {}).get('n') or 0
  482. cl = _CLOOP.get(t) or {}
  483. closed = bool(cl.get('verdict') == '恢复')
  484. rising = cl.get('rising')
  485. if not (lvl in _HIT or (srcs and lvl != 'ok')):
  486. continue
  487. # 六步: done=已达 / open=未达 / unknown=数据不足不可判
  488. steps = [
  489. dict(k='证据', st='done' if srcs else 'open', v=f"{len(srcs)} 源", d='/'.join(srcs)),
  490. dict(k='机制', st='done' if mech else 'open',
  491. v='已定性' if mech else '未定性', d='根因定到部件与失效模式'),
  492. dict(k='判级', st='done' if lvl in _HIT else 'open',
  493. v=tb.get('证据状态') or lvl or '—', d=f"设备状态 {tb.get('设备状态') or '—'}"),
  494. dict(k='排期', st='done' if t == 'WTG29' else 'open',
  495. v='情景A 2026-09' if t == 'WTG29' else '未排',
  496. d='进检修排程沙盘 (风险×损失双轴)'),
  497. # ★这一列一律"不可判", 不是偷懒: 工单台账止 2024-11 而判级时间窗在 2026,
  498. # 台账里的历史工单不可能是针对本次问题的 ⇒ 用 n>0 判"已派工"会造出假的完成态。
  499. dict(k='动作', st='unknown',
  500. v=(f"台账 {n_wo} 单" if n_wo else '台账无'),
  501. d='工单台账 2020~2024-11, 25-26 现场未提供 — 历史单不对应本次问题, 须向现场核实'),
  502. # ★历史闭环不占本格: 05#/20# 2024~2025 换过齿轮箱且已验证恢复, 但本轮又有新的候选级证据,
  503. # 把历史闭环填进第六格会出现"验收已达而机制未定"的自相矛盾行。历史闭环走 hist_loop 单独标记。
  504. dict(k='验收', st='open', v='未验',
  505. d='本轮动作执行后复测同一判据是否回落' + (
  506. f" (该台 {cl.get('replace_date')} 有过一次闭环: {cl.get('component')} 降至 {cl.get('ratio')}×)" if closed else '')),
  507. ]
  508. _first = next((x for x in steps if x['st'] != 'done'), None)
  509. stuck = _first['k'] if _first else None
  510. stuck_kind = _first['st'] if _first else None
  511. NEXT = {'证据': '补测: 四源全为常态, 先确认监测面有效',
  512. '机制': '定性: 现场/取样把根因定到部件与失效模式 — 机制不清则排期与动作都无依据',
  513. '判级': '送审: 按 RV-1 触发相应审级',
  514. '排期': '排程: 进沙盘比情景, 出建议窗口',
  515. '动作': '核实: 向现场调取 2025~2026 工单 — 台账止于 2024-11, 系统无法判定是否已派工',
  516. '验收': '复测: 动作已执行, 复测同一判据是否回落'}
  517. board.append(dict(t=t, lvl=lvl, ostate=tb.get('设备状态'), estate=tb.get('证据状态'),
  518. part=tb.get('部件'), steps=steps, stuck=stuck, stuck_kind=stuck_kind,
  519. next=NEXT.get(stuck, ''), done=sum(1 for x in steps if x['st'] == 'done'),
  520. rising=rising, hist_loop=(cl if closed else None), srcs=srcs))
  521. _ORD = {'bad': 0, 'warn': 1, 'note': 2}
  522. board.sort(key=lambda r: (_ORD.get(r['lvl'], 3), -r['done']))
  523. _cl_rows = [v for v in _CLOOP.values() if v.get('verdict') == '恢复']
  524. fus = dict(链盘=dict(rows=board, closed=_cl_rows,
  525. stuck=dict(collections.Counter(r['stuck'] for r in board if r['stuck'])),
  526. wo_bound='工单台账 2020~2024-11 (现场未提供 25-26)'),
  527. 表=ftab.to_dict('records'),
  528. 矩阵=mrows, 列窗=mheads, kpi=fkpi,
  529. 事件=ev, 事件边界=ev_bounds,
  530. 能力=dict(classes=cap['classes'], blind=[str(b) for b in cap['blind']], loop=cap.get('loop', {})),
  531. 窗=dict(振动=fmeta.get('振动窗', '—'), SCADA=fmeta.get('SCADA窗', ''), 油样=fmeta.get('油样窗', '')),
  532. 色标=fmeta.get('色标', {}), 数据时点=fmeta.get('数据时点', ''), handoff日期=fmeta.get('handoff日期', ''),
  533. 证据窗末=fmeta.get('证据窗末', ''),
  534. 纪律=str(fmeta.get('纪律', '')), 盲区=[str(b) for b in fmeta.get('盲区', [])],
  535. open_items=[str(o) for o in fmeta.get('open_items', [])], gap=fmeta.get('gap'))
  536. except Exception as e:
  537. fus = dict(err=str(e)[:160])
  538. kpi = dict(报警台=n_alarm_t, 关注台=len(watch), 全场=len(CFG['turbines']))
  539. return dict(win=win, months=ms, all_months=sorted(_CACHE['tm'].month.unique()),
  540. systems=systems, sysdist=sysdist, watch=watch, kpi=kpi, rel=rel, fus=fus, faults=faults, control=control, m8=m8, m9=m9,
  541. # ★2026-09-21: 判级轴现在**按所选窗重算**;首次是后台算,这一份仍是旧口径 ⇒ 如实标 pending
  542. sysmx_pending=bool(sysmx_pending),
  543. m9_pending=bool(m9_pending),
  544. win_pending=bool(sysmx_pending or m9_pending),
  545. sysmx_span=list(span_of(win)),
  546. note=('判级轴(变桨/偏航/蓄能/温度)按所选时间窗重算;曲线按所选时间窗重算;'
  547. '故障统计/五态/温度月轨迹/停机台账=所选窗真窗'
  548. + ('(⚠ 判级矩阵正在按所选时间窗重算,下面系统卡暂为上一份口径,稍后自动刷新)' if sysmx_pending else '')))
  549. def fleet(win: str = '2026年') -> dict[str, Any]:
  550. """详情层口径的薄包装:`win = q.get('win', '2026年')`(serve.py 的默认窗)。
  551. ★不改写搬运来的 `fleet_view`,只补"默认窗"这一层 —— 实逮:不加它,缺参调用会
  552. `TypeError: fleet_view() missing 1 required positional argument: 'win'`(算法服务 500)。
  553. """
  554. return fleet_view(win)
  555. from src import paths as _P # 路径唯一真源 (与 cwd 无关)
  556. from src.windscada import taxonomy
  557. from src.windscada.subsys import temp_nbm, hydraulic, yaw as yawmod, pitch as pitchmod
  558. import hashlib, time
  559. import numpy as np, pandas as pd
  560. import threading as _thr
  561. # ── curves 视图所需(同法逐字搬) ──
  562. LENSES = [('wsb', 'grd_wtc_ActPower_mean', 'L1 功率曲线 (风速-功率)', '风速 m/s (机舱风)', 'kW'),
  563. ('wsb', 'cp', 'L6 风能利用系数 Cp (风速-Cp)', '风速 m/s', 'Cp'),
  564. ('wsb', 'lam', 'L5 叶尖速比 λ (风速-λ)', '风速 m/s', 'λ'),
  565. ('grb', 'tq', 'L4 转矩-转速 (发电机侧)', '发电机转速 rpm', 'kNm'),
  566. ('pwb', 'tur_wtc_PitcPosA_mean', 'L2 功率-桨距 (控制律/削峰)', '功率 kW', '°'),
  567. ('pwb', 'tur_wtc_GenRpm_mean', 'L3 功率-发电机转速 (饱和点)', '功率 kW', 'rpm'),
  568. ('pwb', 'p3', 'L2b 三叶桨距极差 (集距/不平衡)', '功率 kW', '°'),
  569. ('pwb', 'ratio', 'L7 转速比 gen/rot (传动链)', '功率 kW', '—')]
  570. CFG = farm(); ST = pathlib.Path(CFG['store'])
  571. _WIN_BUSY: dict = {'sysmx': set(), 'curves': set(), 'm9': set()}
  572. _WIN_CACHE: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
  573. _WIN_ERR: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
  574. def curves_of(win):
  575. """→ (七镜头分箱件 或 None, pending)。按窗重算(干净窗就是正式产物本身)。"""
  576. from src.windscada.perf import curves as _cv
  577. a, b = span_of(win)
  578. if (a, b) == (_cv.WIN[0], '2025-12-31'): # 与正式产物的判别窗一致 ⇒ 直接用产物, 不重算
  579. import pandas as _pd
  580. return _pd.read_parquet(ST / 'curve_lenses.parquet'), False
  581. st = _win_get('curves', win, lambda: _cv.build_store(CFG, span=(a, b), write=False))
  582. return st, st is None
  583. def curve_view(win='2025H2'):
  584. """特性曲线多镜头 (SOP §4.6c): fleet 中位+四分位带 + 过绝对锚的离群台曲线.
  585. ★2026-09-21 用户令「发电性能随时间窗变化」: 七镜头**按所选时间窗重算**(首次后台算,先回 pending),
  586. 7 张月度时序图按所选窗的月份过滤。原口径(2025H2 干净判别窗)仍可作为窗之一被选中,
  587. 选到它时直接用正式产物,不重算。
  588. """
  589. import pandas as pd
  590. from src.windscada.perf import curves as cv
  591. a, b = span_of(win)
  592. ms = months_of(win)
  593. store, pending = curves_of(win)
  594. if pending:
  595. return dict(building=True, win=win, span=[a, b], months=ms,
  596. note=f'正在按所选时间窗 {a} ~ {b} 重算七镜头曲线(首次约 10~45 秒),完成后自动刷新。'
  597. f'期间下方月度时序图已按所选窗过滤,可直接看。')
  598. wlabel = f'{a} ~ {b}'
  599. figs = []
  600. for xc, yc, title, xlab, unit in LENSES:
  601. r = cv.lens(xc, yc, store_df=store, win_label=wlabel)
  602. if not r: continue
  603. outs = sorted(r['离群'].items(), key=lambda kv: -abs(kv[1]['z']))[:3]
  604. a = r['anchor']
  605. series = [dict(name='全场中位', vals=r['fleet'])]
  606. series += [dict(name=f"{t.replace('WTG','')}# {v['resid']:+.4g}{a['单位']}", vals=r['per_t'][t]) for t, v in outs]
  607. figs.append(dict(kind='multiline', title=title, unit=unit, months=[f"{x:g}" for x in r['x']],
  608. series=series, xlab=xlab,
  609. note=(f"显著门 |残差|≥{a['门']}{a['单位']} ({a['说明']}) ∧ |z|≥3 → 离群 "
  610. f"{'/'.join(t.replace('WTG','')+'#' for t,_ in outs) if outs else '无'}"
  611. f" | 灰域=全场四分位, 逐档中位聚合, 已剥限电(只正常发电态)"
  612. f" | 样本 {r['n_total']:,} 个10min点 / {r['n_turbines']} 台 / 最小档 {r['n_min_bin']:,}"
  613. f" | 时间窗 {r['win']}(随所选时间窗)"),
  614. band=dict(q1=r['q1'], q3=r['q3'])))
  615. # 残差视图 (§4.6c④ per机必算同型残差): 绝对量尺度上四分位带只有几个像素, 残差面才看得见
  616. if outs:
  617. rs = [dict(name=f"{t.replace('WTG','')}# {v['resid']:+.4g}{a['单位']}",
  618. 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)
  619. for i in range(len(r['x']))]) for t, v in outs]
  620. q1d = [round(r['q1'][i] - r['fleet'][i], 4) for i in range(len(r['x']))]
  621. q3d = [round(r['q3'][i] - r['fleet'][i], 4) for i in range(len(r['x']))]
  622. figs.append(dict(kind='multiline', title=title.split(' (')[0] + ' · 同型机群残差 (本台−全场中位)', unit=unit,
  623. months=[f"{x:g}" for x in r['x']], series=rs, xlab=xlab,
  624. thresholds=[dict(v=a['门'], label=f"显著门 +{a['门']}{a['单位']}"), dict(v=-a['门'], label='')],
  625. band=dict(q1=q1d, q3=q3d),
  626. note=(f"虚线=物理绝对锚 ±{a['门']}{a['单位']} ({a['说明']}); 灰域=全场四分位残差带; 出带且过锚才算离群"
  627. f" | 样本 {r['n_total']:,} 点 / {r['n_turbines']} 台 | 时间窗 {r['win']}")))
  628. # ---- 时序件: 控制参数月度 (M9b) + 偏航动态月度 (M12) ----
  629. def _ts(path, val, title, unit, xlab, picks=None, note='', thr=None):
  630. f = ST / path
  631. if not f.exists(): return None
  632. d = pd.read_parquet(f)
  633. if val not in d.columns: return None
  634. piv = d.pivot_table(index='month', columns='turbine', values=val)
  635. # ★2026-09-21 用户令: 月度时序图按**所选时间窗**的月份过滤(月度件无法按日切,如实写"按月取整")
  636. if ms:
  637. piv = piv.reindex([m for m in [str(x) for x in piv.index] if m in set(ms)])
  638. if not len(piv): return None
  639. msx = [str(x) for x in piv.index]
  640. med = piv.median(axis=1)
  641. dev = (piv.sub(med, axis=0)).abs().mean()
  642. picks = picks or list(dev.sort_values(ascending=False).head(3).index)
  643. series = [dict(name='全场中位', vals=[None if v != v else round(float(v), 3) for v in med])]
  644. series += [dict(name=t.replace('WTG', '') + '#', vals=[None if v != v else round(float(v), 3) for v in piv[t]])
  645. for t in picks if t in piv.columns]
  646. # 样本量与窗随图走 (2026-08-28 门禁 F1): 7 张时序图原来只有结论没有依据规模,
  647. # 违反第一性原理②"Sample size declared"。逐月×台的非空格数即样本量。
  648. _n = int(piv.notna().to_numpy().sum())
  649. _nt = int(piv.shape[1])
  650. note2 = (note + f" | 样本 {_n:,} 个月×台 / {_nt} 台 / {len(msx)} 个月"
  651. f" | 时间窗 {msx[0]}~{msx[-1]}(月度聚合,随所选时间窗·按月取整)") if msx else note
  652. return dict(kind='multiline', title=title, unit=unit, months=msx, series=series, xlab=xlab,
  653. note=note2, thresholds=thr or [],
  654. band=dict(q1=[None if v != v else round(float(v), 3) for v in piv.quantile(0.25, axis=1)],
  655. q3=[None if v != v else round(float(v), 3) for v in piv.quantile(0.75, axis=1)]))
  656. for args in (
  657. ('control_monthly.parquet', 'p_cap', '时序① 满发功率封顶 月度 (取每月高位稳定值; 阶跃=配置变更)', 'kW', '月', None,
  658. '灰域=全场四分位; 参数级分组见发电性能页 (4175/4200/4225 三组); 阶跃=配置变更, 平移=工况'),
  659. ('control_monthly.parquet', 'w_cap', '时序② 转速封顶 月度 (取每月高位稳定值)', 'rpm', '月', None, '两轴独立分组 (1672/1680)'),
  660. ('control_monthly.parquet', 'pitch_rated', '时序③ 额定段桨距角 月度 (标定漂移)', '°', '月',
  661. ['WTG03', 'WTG34', 'WTG19'], '离群台=桨距调度同档差 top3'),
  662. ('yaw_dynamic_monthly.parquet', 'err_sd', '时序④ 对风散布 σ 月度 (原始, 未清洗)', '°', '月',
  663. ['WTG08', 'WTG16', 'WTG04'], '三台19个月全程高 → 非发作型; 清洗后降至5-7°, 判为机舱位置通道拖偏(A类数据质量)非对风故障'),
  664. ('yaw_dynamic_monthly.parquet', 'travel_day', '时序⑤ 偏航行程 月度 (抗采样物理量)', '°/日', '月', None,
  665. '行程=活动量代理, 非真磨损量; 与状态位次数互核'),
  666. ('yaw_dynamic_monthly.parquet', 'twist_span', '时序⑥ 扭缆角月跨度 (ScYawPos 累计位置)', '°', '月', None, '解缆动作后回零'),
  667. ('yaw_dynamic_monthly.parquet', 'pump_med', '时序⑦ 偏航泵压 月度 (小站泵压)', 'bar', '月', None, '全场极齐 → 无泵压异常'),
  668. ):
  669. f = _ts(*args)
  670. if f: figs.append(f)
  671. return dict(figs=figs, physics=cv.physics_check(),
  672. note=('镜头口径 (SOP §4.6c): X轴优先功率(直测干净量); 机舱风 self-ref → 只判形状/相对, '
  673. '绝对达成率须现场测风; per机异常必算同型机群残差(禁眼估); 工况段筛选=剔除0kW与低转速混合态档'))
  674. def curves(win: str = '2026年') -> dict[str, Any]:
  675. """详情层口径的薄包装(`curve_view(q.get('win') or '2026年')`)。"""
  676. return curve_view(win)
  677. from src import paths as _P # 路径唯一真源 (与 cwd 无关)
  678. import hashlib, time
  679. # ── vibcms/reload 视图所需(逐字搬) ──
  680. CFG = farm(); ST = pathlib.Path(CFG['store'])
  681. def vibcms_results():
  682. """windcms 评估报告 → 结果层转录 (2026-08-28 用户令: 生接改融合·只显示结果·模型与计算隐藏).
  683. 取最新一期报告md; '融合级(模型)/CMS红黄'两列=模型输出, 不出结果层; 分析功能留独立cms.
  684. ★2026-09-18 用户令"页面只能基于输入数据重算、不许用旧版产出补"之后实逮: 清过产物再重算的机器上
  685. `windcms/` 里没有 `报告_CMS振动状态评估报告_*.md`, 于是 `sorted(glob)[-1]` 抛
  686. **IndexError: list index out of range**, 页面看到的是"windcms 报告解析失败: list index out of range"
  687. —— 与 `ProductsMissing` 的教训同一个病: **缺产物被报成了程序坏了**。现在按缺产物如实回结构化的
  688. `no_report`, 并写清它由谁生成。★2026-09-19 再修: 报告步的生成端(`scripts/windcms.py report`)与它的
  689. 上游(`rudong_model_run.py`/`rudong_fusion_run.py`)都已随包并上链, 缺件只剩"还没重算到那一步"这一个原因,
  690. 故这里再附一句"重算是否正在跑"(读 run/ops_job.json), 免得把"正在跑"读成"系统坏了"。
  691. """
  692. reps = sorted(ST.parent.glob('windcms/报告_CMS振动状态评估报告_*.md'))
  693. if not reps:
  694. # ★2026-09-19 修: 原话还写着"本包六层链的 model_run/fusion 两步脚本未随包" —— 那两步已在
  695. # 2026-09-19 按口径重建并上链(scripts/rudong_model_run.py / rudong_fusion_run.py), 话说反了。
  696. # 真实原因只有两种: ① 重算还没走到 ④b 的报告步(清过产物后这一步要跑很久); ② windcms 产物被清且未重算。
  697. try:
  698. from src import opsjob as _oj
  699. _run = (' ' + _oj.running_text()) if _oj.running_text() else ''
  700. except Exception:
  701. _run = ''
  702. return dict(date=None, overall='', action=[], grades=[], diff=dict(only_cms=[], only_handoff=[]),
  703. no_report=True,
  704. note_zh='本机没有 CMS 振动状态评估报告 (由重算链 ④b 的 report 步生成: '
  705. 'python scripts/windcms.py report)。如实为空, 不用旧版产出补。' + _run,
  706. err='无产物: windcms/报告_CMS振动状态评估报告_*.md 不在位')
  707. try:
  708. rp = reps[-1]
  709. md = rp.read_text(encoding='utf-8') # 不写 encoding 会按系统 locale(cp936) 读 → 中文报告乱码
  710. date = rp.stem.rsplit('_', 1)[-1]
  711. overall = ''
  712. if '### 3.1' in md:
  713. seg = md.split('### 3.1')[1].split('###')[0]
  714. overall = next((l.strip() for l in seg.splitlines() if l.strip().startswith('全场')), '')
  715. def _tab(sec):
  716. return [[c.strip() for c in l.strip('|').split('|')] for l in sec.splitlines() if l.startswith('| WTG')]
  717. action = _tab(md.split('### 3.2')[1].split('## ')[0]) if '### 3.2' in md else []
  718. grades = []
  719. if '## 附录 A' in md:
  720. for r in _tab(md.split('## 附录 A')[1]):
  721. # 列: 机组/前/后/齿/发/综合/融合级(模型·隐)/CMS红黄(隐)/行动
  722. grades.append(dict(t=r[0], 主轴承前=r[1], 主轴承后=r[2], 齿轮箱=r[3], 发电机=r[4],
  723. 综合=r[5], 行动=r[8] if len(r) > 8 else ''))
  724. # 与融合矩阵(handoff 定谳链)口径差异台
  725. from src.windscada.subsys import fusion as _f
  726. ft, _ = _f.fusion_table()
  727. hset = {r.turbine for _, r in ft.iterrows() if _f.verdict_class(str(r.振动结论))[1] in ('bad', 'warn')}
  728. cset = {r[0] for r in action}
  729. diff = dict(only_cms=sorted(cset - hset), only_handoff=sorted(hset - cset))
  730. # ★2026-09-19: 把**这个窗真实覆盖的日期区间**一并回给页面。原来只回 date(出件日) 与表格,
  731. # 页面上看不到"数据从哪天到哪天" ⇒ 用户拿 2026-03~04 的导出却只看到 4 月的谱时,
  732. # 无法判断是"数据没呈现"还是"源件本身就那么点"(实测源件最早只到 2026-03-16, 逐台更晚)。
  733. _span, _win = '', ''
  734. try:
  735. from src.windcms import config as _wcfg, data as _wdata
  736. _w2 = _wcfg.farm(str(CFG.get('key') or 'rudong'))
  737. _span = _wdata.span_text(_w2) or ''
  738. _win = ','.join(_wdata.windows(_w2))
  739. except Exception:
  740. _span = _win = ''
  741. return dict(date=date, overall=overall, action=action, grades=grades, diff=diff,
  742. 时间范围=_span, 窗=_win)
  743. except Exception as e:
  744. return dict(error=f'windcms 报告解析失败: {e}') # 响亮, 不静默空表
  745. def reload_products(reason=''):
  746. """显式清缓存 (下一次请求重载)。返回清前的指纹, 供日志/接口回显。"""
  747. with LOCK:
  748. old = _CACHE.get('__stamp')
  749. _CACHE.clear()
  750. print(f'[reload] 清产物缓存 ({reason or "手动"}) 旧指纹={old}', flush=True)
  751. return old
  752. def vibcms() -> Any:
  753. """详情层 `/api/vibcms`:`vibcms_results()` 原样返回。"""
  754. return vibcms_results()
  755. def reload(why: str = 'manual') -> dict[str, Any]:
  756. """详情层 `/api/reload`:`dict(ok, old_stamp, stamp=products_stamp(force=True), files=len(_product_files()))`。"""
  757. old = reload_products(why or 'manual')
  758. return dict(ok=True, old_stamp=old, stamp=products_stamp(force=True), files=len(_product_files()))
  759. # ── 脱敏/清洗(rpt_export 等需要;逐字搬,勿手改) ──
  760. INTERNAL = _os.environ.get('WINDSCADA_INTERNAL', '') == '1'
  761. _CRIT_MARK = ('z', 'σ', '×', '倍', 'dev', 'resid', '选择性', '峰', '结构度', 'ratio')
  762. _KEEP_UNIT = ('h', '小时', '天', '日', '月', '年', '台', '次', '条', '起', '个')
  763. _RE_HZ = re.compile(r'(?<![\d.])\d+(?:\.\d+)?\s*(?=Hz)')
  764. _RE_THR = re.compile(r'([≥≤])\s*(\d+(?:\.\d+)?)\s*([a-zA-Z%℃×/·²]*)')
  765. import math
  766. def _jclean(o):
  767. """NaN/Inf → null (非法 JSON 防线; pd.DataFrame 会把 None 变 NaN)."""
  768. if isinstance(o, float) and (math.isnan(o) or math.isinf(o)): return None
  769. if isinstance(o, dict): return {k: _jclean(v) for k, v in o.items()}
  770. if isinstance(o, (list, tuple)): return [_jclean(x) for x in o]
  771. return o
  772. def _redact_text(t):
  773. if INTERNAL or not isinstance(t, str) or not t:
  774. return t
  775. if '0.593' in t or '贝兹' in t or 'Betz' in t: # 物理常数不动
  776. return _RE_HZ.sub('▪▪', t)
  777. t = _RE_HZ.sub('▪▪', t)
  778. def _th(m):
  779. sign, num, unit = m.group(1), m.group(2), m.group(3) or ''
  780. pre = t[max(0, m.start() - 8):m.start()]
  781. if any(c in pre for c in _CRIT_MARK): # |z|≥3 / ×≥2.5 = 判据阈值, 必脱
  782. return f'{sign}▪{unit}'
  783. if unit in _KEEP_UNIT or (not unit and float(num) <= 10 and float(num) == int(float(num))):
  784. return m.group(0) # 时间/计数口径保留 (≥1h段 / ≥2 个系统)
  785. return f'{sign}▪{unit}'
  786. return _RE_THR.sub(_th, t)
  787. def _redact(o):
  788. if INTERNAL:
  789. return o
  790. if isinstance(o, str):
  791. return _redact_text(o)
  792. if isinstance(o, dict):
  793. return {k: _redact(v) for k, v in o.items()}
  794. if isinstance(o, list):
  795. return [_redact(v) for v in o]
  796. return o
  797. # ── 产物生成端(windscada_overview_build)所需:逐字搬自详情层 ──
  798. CFG = farm(); ST = pathlib.Path(CFG['store'])
  799. SYS_ALARM = {'偏航': yawmod.FAM, '变桨': {'蓄能缸检': ['3411', '3412', '3413'], '泵/过热': ['7106', '7100'], '油位/滤芯': ['7101', '7103', '7104']}}
  800. SYS_CODES = {
  801. '变桨': ['3130', '3145', '3173', '3224', '3225', '3411', '3412', '3413', '3418', '7100', '7101', '7102', '7103', '7104', '7106', '9306', '9309'],
  802. '偏航': ['10100', '10104', '10105', '10113', '10125', '10201', '10400', '10401', '7122', '1012'],
  803. '齿轮箱': ['4101', '4102', '4103'],
  804. '变流器': ['13902', '13905', '19024', '19170', '19285', '20000', '63036'],
  805. '主控与传感网': ['8108', '8110', '8171', '8184', '8185', '8186', '8187', '63209', '63004'],
  806. }
  807. _SENS_CN = {'Main_bearing_front': '主轴承前', 'Main_bearing_rear': '主轴承后', 'Gear_planet': '行星级',
  808. 'Gear_IMS': '中间轴', 'Gear_HS_rotor_side': '高速轴转子侧', 'Gear_HS_generator_side': '高速轴发电机侧',
  809. 'Generator_DE': '发电机DE', 'Generator_NDE': '发电机NDE'}
  810. _VIBC = {}
  811. _VIBMET = [('Peak', '冲击峰值'), ('Kurtosis', '峭度'), ('Rms_Vel', '速度有效值'), ('iso_rms', 'ISO烈度')]
  812. _VIBSENS = {'主轴承': ['Main_bearing_front', 'Main_bearing_rear'],
  813. '齿轮箱': ['Gear_planet', 'Gear_IMS', 'Gear_HS_rotor_side', 'Gear_HS_generator_side'],
  814. '发电机': ['Generator_DE', 'Generator_NDE']}
  815. def fig_temp(t, ch, win):
  816. tm = _CACHE['tm']; ms = months_of(win)
  817. g = tm[tm.channel == ch]
  818. piv = g.pivot_table(index='month', columns='turbine', values='med').reindex(ms)
  819. if t not in piv.columns or piv[t].dropna().empty: return None
  820. nm = i18n.ch_cn(ch, with_tag=False)
  821. return dict(kind='band', title=f'{t} {nm} 月度 (发电态P>500kW)', unit='℃', months=ms,
  822. self=[None if v != v else round(v, 2) for v in piv[t]],
  823. med=[None if v != v else round(v, 2) for v in piv.median(axis=1)],
  824. q1=[None if v != v else round(v, 2) for v in piv.quantile(.25, axis=1)],
  825. q3=[None if v != v else round(v, 2) for v in piv.quantile(.75, axis=1)],
  826. note='灰带=全场四分位区间, 深线=全场中位, 红线=本台')
  827. def fig_duty(t, ch, title, unit, win, thresholds=None):
  828. d = _CACHE['duty']
  829. if d is None: return None
  830. ms = months_of(win)
  831. g = d[d.channel == ch]
  832. piv = g.pivot_table(index='month', columns='turbine', values='val').reindex(ms)
  833. if t not in piv.columns or piv[t].dropna().empty: return None
  834. return dict(kind='band', title=f'{t} {title}', unit=unit, months=ms,
  835. self=[None if v != v else round(v, 2) for v in piv[t]],
  836. med=[None if v != v else round(v, 2) for v in piv.median(axis=1)],
  837. q1=[None if v != v else round(v, 2) for v in piv.quantile(.25, axis=1)],
  838. q3=[None if v != v else round(v, 2) for v in piv.quantile(.75, axis=1)],
  839. thresholds=thresholds or [], note='灰带=全场四分位区间')
  840. def fig_alarm_bars(t, codes, title, win):
  841. al = _CACHE['al']; ms = months_of(win)
  842. g = al[(al.turbine == t) & (al.code.isin(codes)) & (al.month.isin(ms))]
  843. if not len(g): return None
  844. cnt = g.groupby('month').size().reindex(ms).fillna(0)
  845. return dict(kind='bars', title=f'{t} {title} 月度条数', unit='条', months=ms,
  846. vals=[int(v) for v in cnt], note='时间窗内合计 %d 条' % int(cnt.sum()))
  847. def fig_curve(t):
  848. b = _CACHE['bins']; g = b[b.turbine == t].sort_values('bin')
  849. if not len(g): return None
  850. dv = _CACHE['pcd'].loc[t]
  851. return dict(kind='xy', title=f'{t} 功率曲线 vs fleet中位 [固定判别窗 2025-H2, 机舱风口径]', unit='kW',
  852. x=[float(x) for x in g['bin']], self=[round(float(v), 1) for v in g.p],
  853. med=[round(float(v), 1) for v in g.fleet_p], xlab='风速 m/s',
  854. note=f"偏差 {dv.dev_w:+.1%} · 风速计偏置 {dv.ws_bias:+.2f} m/s · {dv['判别']}")
  855. def fig_zero(t, win):
  856. z = _CACHE['zero']
  857. if z is None: return None
  858. tt = t.replace('WTG', '')
  859. g = z[(z.turbine.astype(str).str.contains(tt)) | (z.turbine == t)] if 'turbine' in z.columns else None
  860. if g is None or not len(g): return None
  861. ms = months_of(win)
  862. mcol = [c for c in g.columns if 'month' in c.lower() or '月' in c]
  863. if not mcol: return None
  864. g = g[g[mcol[0]].astype(str).isin(ms)]
  865. # 只画零位偏差本身 (样本数量纲不同, 混画会把判据线压扁 → 移入图注)
  866. n_med = int(g['n_rows'].median()) if 'n_rows' in g.columns and len(g) else 0
  867. return dict(kind='multiline', title=f'{t} 桨叶零位偏差 月度', unit='°', months=[str(x) for x in g[mcol[0]]],
  868. series=[dict(name='零位偏差', vals=[round(float(v), 3) if v == v else None for v in g['zero_dev']])],
  869. thresholds=[dict(v=0.6, label='报警 0.6°'), dict(v=-0.6, label='')],
  870. note=f'判据: 变桨分册 §3.2 (报警门 ±0.6°); 月样本中位 {n_med} 行')
  871. def fig_sys_alarms(t, sysname, win):
  872. """该台该系统报警族 月度条数 (与工单处理史并读)."""
  873. codes = SYS_CODES.get(sysname)
  874. if not codes: return None
  875. ms = months_of(win)
  876. al = _CACHE['al']
  877. g = al[(al.turbine == t) & (al.code.isin(codes)) & (al.month.isin(ms))]
  878. if not len(g): return None
  879. v = g.groupby('month').size().reindex(ms).fillna(0)
  880. top = g.groupby('code').size().sort_values(ascending=False).head(3)
  881. note = '时间窗内合计 %d 条; top: ' % len(g) + '; '.join(f'{c} {i18n.alarm_label(c)[:12]}×{n}' for c, n in top.items())
  882. return dict(kind='bars', title=f'{t} {sysname}系统 报警月度条数', unit='条', months=ms,
  883. vals=[int(x) for x in v], note=note)
  884. def turbine_problems(t, win):
  885. """有问题的系统 → 证据图 (分册式); 无问题不出."""
  886. _load()
  887. sysd = _CACHE['sysmx'][t]
  888. treg = _CACHE['treg']
  889. out = []
  890. for s in taxonomy.SYSTEMS:
  891. st = sysd[s]['状态']
  892. if st == '优秀': continue
  893. figs = []
  894. # 温度证据
  895. for _, r in treg[(treg.turbine == t)].iterrows():
  896. ch = r['channel']
  897. nm = ch.replace('tmp_wtc_', '').replace('_mean', '').replace('_max', '')
  898. if taxonomy.TEMP2SYS.get(nm) == s and str(r['判']) != '—':
  899. f = fig_temp(t, ch, win)
  900. if f: f['title'] = f"{t} {i18n.ch_cn(ch, with_tag=False)} — {r['判']} ({r['dev_K']:+.1f}K)"; figs.append(f)
  901. # 系统专属证据
  902. if s == '变桨':
  903. hv = _CACHE['hyd'].loc[t]
  904. if hv['判'] != '—':
  905. f = fig_duty(t, 'HydPress_band', '变桨液压10min摆幅 月度', 'bar', win,
  906. thresholds=[dict(v=52, label='正常充压滞环≈52bar')])
  907. if f: f['title'] = f"{t} 蓄能行为 — {hv['判']}"; figs.append(f)
  908. for nm, codes in SYS_ALARM['变桨'].items():
  909. f = fig_alarm_bars(t, codes, f'变桨{nm}报警', win)
  910. if f: figs.append(f)
  911. f = fig_zero(t, win)
  912. if f: figs.append(f)
  913. if s == '偏航':
  914. yrow_d = dict(zip(['D1润滑不可能', 'D2过热', 'D2变流器警告', 'D2解缆', 'D3制动液压错误', 'D3油位低', 'D4偏航失败', 'D4风传感'], [0]*8))
  915. f = fig_duty(t, 'dot_wtc_YawLubPu_timeon', '偏航润滑泵运行时长 月度', '秒/日', win)
  916. if f: figs.append(f)
  917. for nm, codes in yawmod.FAM.items():
  918. f = fig_alarm_bars(t, codes, '偏航·' + nm.lstrip('D1234'), win)
  919. if f: figs.append(f)
  920. if s in ('主轴承', '齿轮箱', '发电机'):
  921. vf = vib_scalar_fig(t, s)
  922. if vf: figs.append(vf)
  923. # 振动驱动的问题 (taxonomy 振动映射) → 配套分析曲线: 部件双端温度 + 主轴承柱塞 duty
  924. if '振动:' in sysd[s]['依据']:
  925. vib_chs = {'主轴承': ['tmp_wtc_MBearGTm_mean', 'tmp_wtc_MBearHTm_mean'],
  926. '发电机': ['tmp_wtc_GenBeRTm_mean', 'tmp_wtc_GenBeGTm_mean'],
  927. '齿轮箱': ['tmp_wtc_IMSGenTm_mean', 'tmp_wtc_HSGenTmp_mean']}[s]
  928. for ch in vib_chs:
  929. f = fig_temp(t, ch, win)
  930. if f: f['note'] = (f.get('note') or '') + ' | 振动判级交叉轴: 温度=同机制果侧观测(观测面≠源数)'; figs.append(f)
  931. if s == '主轴承':
  932. f = fig_duty(t, 'din_wtc_MBLubPis_timeon', '主轴承润滑柱塞运行时长 月度 (因侧执行链)', '秒/日', win)
  933. if f: f['note'] = (f.get('note') or '') + ' | 29#签名: 单次时长塌→回升→2026-07单月突变(泵打不满)'; figs.append(f)
  934. # 振动摘要配传动链图形 (2026-08-28 用户令: 关联图形填入) — comp=依据里的部件短语, sev=结论级
  935. _why = sysd[s]['依据']
  936. _m = re.search(r'振动: ([^ ]+) ([^ (]+)', _why)
  937. _comp, _lv = (_m.group(1), _m.group(2)) if _m else (s, '')
  938. _sev = 'var(--bad)' if any(k in _lv for k in ('定论', '报警')) else ('var(--warn)' if '预警' in _lv else 'var(--ok)')
  939. figs.append(dict(kind='vib', title=f'{t} {s} 振动分析摘要',
  940. comp=_comp, sev=_sev,
  941. text=_why + ' | 谱级深潜与逐窗趋势 → 振动融合板块·振动分析入口 (windcms)'))
  942. if '配对差' in sysd[s]['依据']:
  943. pair_chs = {'主轴承': ['tmp_wtc_MBearHTm_mean', 'tmp_wtc_MBearGTm_mean'],
  944. '发电机': ['tmp_wtc_GenBeGTm_mean', 'tmp_wtc_GenBeRTm_mean']}.get(s, [])
  945. for ch in pair_chs:
  946. f = fig_temp(t, ch, win)
  947. if f: figs.append(f)
  948. if '润滑' in sysd[s]['依据']:
  949. ch = {'主轴承': 'din_wtc_MBLubPis_timeon', '齿轮箱': 'din_wtc_GBLubPis_timeon', '发电机': 'dot_wtc_GenLubPu_timeon'}[s]
  950. f = fig_duty(t, ch, f'{s}润滑 月度', '秒/日', win)
  951. if f: figs.append(f)
  952. if s == '主控与传感网':
  953. if '测风' in sysd[s]['依据'] or '风速计' in sysd[s]['依据']:
  954. f = fig_alarm_bars(t, yawmod.FAM['D4风传感'], '风传感故障报警', win)
  955. if f: figs.append(f)
  956. if '曲线判风速计' in sysd[s]['依据'] or '风速计偏置' in sysd[s]['依据']:
  957. f = fig_curve(t)
  958. if f: figs.append(f)
  959. # ★每个问题配: 该系统报警月度图 + 工单处理史 (用户令)
  960. try:
  961. from src.windscada.subsys import workorder as womod
  962. hist = womod.history(t, s)
  963. except Exception as e:
  964. hist = dict(n=0, rows=[], note=f'工单不可用 {e}'[:60], 动作统计={}, 窗='')
  965. af = fig_sys_alarms(t, s, win)
  966. if af: figs.append(af)
  967. # 排版 (2026-08-28 用户令"排版更合理"): ①同标题图去重 (配对差/温度证据两处会重复挂同一通道)
  968. # ②按阅读顺序排 — 结论定位(传动链) → 证据强度(振动标量) → 时序(温度/润滑/报警) → 曲线
  969. seen, uniq = set(), []
  970. for _f in figs:
  971. k = (_f.get('kind'), _f.get('title'))
  972. if k in seen: continue
  973. seen.add(k); uniq.append(_f)
  974. _ORD = {'vib': 0, 'vibbar': 1, 'band': 2, 'line': 3, 'bar': 4, 'xy': 5, 'text': 6}
  975. figs = sorted(uniq, key=lambda x: _ORD.get(x.get('kind'), 9))
  976. out.append(dict(sys=s, 状态=st, 依据=i18n.humanize(sysd[s]['依据']), figs=figs, orders=hist))
  977. dv = _CACHE['pcd'].loc[t] if t in _CACHE['pcd'].index else None
  978. if dv is not None and abs(dv.dev_w) > 0.03:
  979. f = fig_curve(t)
  980. out.append(dict(sys='功率曲线', 状态='报警' if 'A类' in str(dv['判别']) else '良好',
  981. 依据=f"偏差 {dv.dev_w:+.1%}, 风速计偏置 {dv.ws_bias:+.2f} m/s → {dv['判别']}", figs=[f] if f else []))
  982. out.sort(key=lambda x: TS.get(x['状态'], 9))
  983. return out
  984. def vib_scalar_fig(t, sysname):
  985. """TCM 标量: 本台 vs 全场中位 (按测点×指标) + 六窗趋势. 数据=振动线 m5_cms_tcm 产物, 本页只转录不判级."""
  986. import pandas as _pd
  987. if 'z' not in _VIBC:
  988. base = _P.m5()
  989. try:
  990. _VIBC['z'] = _pd.read_parquet(base / 'fleet_scalar_z.parquet')
  991. _VIBC['tr'] = _pd.read_parquet(base / 'scalar_trend_6win.parquet')
  992. except Exception:
  993. _VIBC['z'] = _VIBC['tr'] = None
  994. z, tr = _VIBC['z'], _VIBC['tr']
  995. if z is None: return None
  996. sens = _VIBSENS.get(sysname, [])
  997. if not sens: return None
  998. g = z[(z.turbine == t) & (z.sensor.isin(sens)) & (z.meas.isin([m for m, _ in _VIBMET]))]
  999. if not len(g): return None
  1000. # 取最高功率档 (载荷高处信噪比最好); 该档缺则退最近档
  1001. order = sorted(g.bin.unique())
  1002. gb = g[g.bin == order[-1]]
  1003. rows = []
  1004. for sn in sens:
  1005. for mk, mcn in _VIBMET:
  1006. r = gb[(gb.sensor == sn) & (gb.meas == mk)]
  1007. if not len(r): continue
  1008. r = r.iloc[0]
  1009. tv = None
  1010. if tr is not None:
  1011. q = tr[(tr.turbine == t) & (tr.sensor == sn) & (tr.metric == mk)]
  1012. if len(q): tv = float(q.iloc[0]['ratio'])
  1013. rows.append(dict(测点=_SENS_CN.get(sn, sn), 指标=mcn, 本台=round(float(r.val), 3),
  1014. 全场中位=round(float(r.fleet_med), 3), z=round(float(r.z), 2),
  1015. 倍中位=round(float(r.val) / float(r.fleet_med), 2) if r.fleet_med else None,
  1016. 六窗比=round(tv, 2) if tv else None))
  1017. if not rows: return None
  1018. return dict(kind='vibbar', size='L', title=f'{t} {sysname} 振动标量 · 本台 vs 全场 ({str(order[-1]).replace("WPS-ActivePower ", "功率 ").rstrip(",")} kW 档)',
  1019. rows=rows, note='数据=TCM 六窗标量 (振动线 m5_cms_tcm 产物转录); z=fleet 稳健离群度, 六窗比=末窗/首窗 (>1 上升)')
  1020. def single_problem(t, sys, win):
  1021. """只回该台该系统这一个问题 (用户令: 点开就是一个问题, 其他不带出来)."""
  1022. return [p for p in turbine_problems(t, win) if p['sys'] == sys]
  1023. # ── 报告投影助手(搬自详情层) ──
  1024. def _proj(o, lang):
  1025. """把 `key_zh`/`key_en` 投影成裸 `key`。
  1026. 英文侧取不到 `_en` 时写 `[translation pending]` 而**不回落中文** ——
  1027. 回落会让缺口永远不被发现, 正是英文版残留三轮没被逮住的机制。
  1028. """
  1029. if isinstance(o, list):
  1030. return [_proj(x, lang) for x in o]
  1031. if not isinstance(o, dict):
  1032. return o
  1033. suf = '_' + lang
  1034. other = '_zh' if lang == 'en' else '_en'
  1035. # ★裸 `zh` / `en` 成对 (framework.py 的 SYSTEMS/RECORDS/stages 就是这个写法)。
  1036. # 它们不带下划线, 走不到下面的后缀规则; 而末尾"残留中文一律写 [translation pending]"
  1037. # 的兜底会把 zh 打成 pending —— **旁边的 en 明明就是对的**。实测 145 处 pending
  1038. # 里的大多数是这一种。消费端 JS 读的是 `.zh`, 所以英文版把 en 的值投影到 zh 上。
  1039. if lang != 'zh' and isinstance(o, dict) and 'zh' in o and 'en' in o:
  1040. o = dict(o, zh=o['en'])
  1041. # ★只有 k_en **有值**时才让裸 k 让位 (2026-09-01 parity 闸逮)。
  1042. # 原来无条件让位: 上游给了 action_en=None, 裸 action 就被顶成 None ——
  1043. # 页面上显示 "None", 比留中文糟得多。空的语言副本等于没给, 不该覆盖原值。
  1044. out = {}
  1045. source_fields = {'name', '步骤', '故障', '排查', '原因', '处置', 'check'}
  1046. originals = {key: o.get(key + '_zh', o.get(key)) for key in source_fields} if lang == 'en' else {}
  1047. originals = {key: value for key, value in originals.items() if isinstance(value, str) and _CJK.search(value)}
  1048. langed = {k[:-len(suf)] for k in o if k.endswith(suf) and o[k] not in (None, '', [])}
  1049. for k, v in o.items():
  1050. if k == '_original':
  1051. out[k] = dict(v) if isinstance(v, dict) else v
  1052. continue
  1053. if k.endswith(other): # 另一语言的副本不出 API
  1054. continue
  1055. if k.endswith(suf):
  1056. if v not in (None, '', []):
  1057. out[k[:-len(suf)]] = _proj(v, lang)
  1058. continue
  1059. if k in langed: # 已有 key_<lang>, 裸 key 让位
  1060. continue
  1061. v2 = _proj(v, lang)
  1062. if k in _KEEP_ZH or (isinstance(v2, str) and v2 in _KEEP_ZH_VALS):
  1063. out[k] = v2 # 判别值/查表键: 原样留中文 (见 _KEEP_ZH 注释)
  1064. continue
  1065. # 枚举层先查码表 — 这些值有确定译名, 不该退化成 [translation pending]。
  1066. # 列表型枚举 (如 facets) 逐项查。
  1067. if lang != 'zh' and _codes is not None and k in _codes.CODES:
  1068. if isinstance(v2, str):
  1069. c = _codes.normalize(k, v2)
  1070. if c:
  1071. v2 = _codes.label(k, c, lang)
  1072. else:
  1073. v2 = _enum_fallback(k, v2, lang)
  1074. elif isinstance(v2, list):
  1075. v2 = [(_codes.label(k, _codes.normalize(k, x), lang)
  1076. if isinstance(x, str) and _codes.normalize(k, x) else x) for x in v2]
  1077. # 单数字段名对应复数码表 (facet ← facets) 也走一遍
  1078. elif lang != 'zh' and _codes is not None and k.rstrip('s') in _codes.CODES:
  1079. f = k.rstrip('s')
  1080. if isinstance(v2, str):
  1081. c = _codes.normalize(f, v2)
  1082. if c:
  1083. v2 = _codes.label(f, c, lang)
  1084. elif isinstance(v2, list):
  1085. v2 = [(_codes.label(f, _codes.normalize(f, x), lang)
  1086. if isinstance(x, str) and _codes.normalize(f, x) else x) for x in v2]
  1087. # ★这里原来只判 isinstance(v2, str) —— **字符串列表整个绕过了英文投影**。
  1088. # _proj 递归进 list, 但 _proj(str) 原样返回, 所以 ["偏航","变桨"] 这种
  1089. # 一路穿到 API。实测漏掉 5 条路径 (parts[] / syss[] / srcs[] /
  1090. # open_items[] / blind[]), 是英文页数据层残留中文的唯一来源。
  1091. if lang == 'en':
  1092. # Action counts use display labels as dictionary keys, so _en_deep
  1093. # cannot reach them. Only this display map uses the established
  1094. # work-order action glossary; all other dictionary/URL IDs stay put.
  1095. if k == '动作统计' and isinstance(v2, dict):
  1096. from src.windscada.maint.workorder_en import ACTION
  1097. v2 = {ACTION.get(action) or action: count for action, count in v2.items()}
  1098. v2 = _en_deep(v2, k)
  1099. out[k] = v2
  1100. for key, original in originals.items():
  1101. if key in out and out[key] != original:
  1102. out.setdefault('_original', {})[key] = original
  1103. return out
  1104. _REQ = threading.local()