|
|
@@ -725,7 +725,14 @@ def _load():
|
|
|
raise
|
|
|
|
|
|
WINDOWS = ['近30日', '近90日', '2026年', '2026H1', '2025H2', '全程']
|
|
|
+def _month_end(m):
|
|
|
+ """'YYYY-MM' → 该月最后一天 'YYYY-MM-DD'。"""
|
|
|
+ import calendar
|
|
|
+ y, mo = int(m[:4]), int(m[-2:])
|
|
|
+ return f'{m}-{calendar.monthrange(y, mo)[1]:02d}'
|
|
|
+
|
|
|
def months_of(win):
|
|
|
+ """窗 → 覆盖到的月份列表(月度类数据按此过滤)。"""
|
|
|
_load()
|
|
|
all_m = sorted(_CACHE['tm'].month.unique())
|
|
|
if win == '全程': return all_m
|
|
|
@@ -738,9 +745,126 @@ def months_of(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)]
|
|
|
+ m3 = _re.fullmatch(r'(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})', win)
|
|
|
+ if m3: # ★自定义起止(含)→ 相交的月 (用户令 2026-09-21)
|
|
|
+ a, b = sorted((m3.group(1), m3.group(2)))
|
|
|
+ return [m for m in all_m if _month_end(m) >= a and f'{m}-01' <= b]
|
|
|
n = 1 if win == '近30日' else 3
|
|
|
return all_m[-n:]
|
|
|
|
|
|
+def win_range(win):
|
|
|
+ """窗 → (起, 止) 日期串,**含两端**(用户令 2026-09-21:时间窗支持自定义起止日期)。
|
|
|
+
|
|
|
+ 预设/逐月/月区间窗一律落到"该窗覆盖月的月首/月末",于是与 `months_of()` 同口径;
|
|
|
+ 日区间窗原样返回。日粒度件(停机事件/报警/日粒度判据)用它做**含端**过滤。
|
|
|
+ """
|
|
|
+ import re as _re
|
|
|
+ if win:
|
|
|
+ if _re.fullmatch(r'\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}', win):
|
|
|
+ a, b = win.split('~')
|
|
|
+ return (a, b) if a <= b else (b, a)
|
|
|
+ if _re.fullmatch(r'\d{4}-\d{2}', win):
|
|
|
+ return (f'{win}-01', _month_end(win))
|
|
|
+ m2 = _re.fullmatch(r'(\d{4}-\d{2})~(\d{4}-\d{2})', win)
|
|
|
+ if m2:
|
|
|
+ return (f'{m2.group(1)}-01', _month_end(m2.group(2)))
|
|
|
+ if win == '2025H2':
|
|
|
+ return ('2025-07-01', '2025-12-31')
|
|
|
+ if win == '2026H1':
|
|
|
+ return ('2026-01-01', '2026-06-30')
|
|
|
+ ms = months_of(win)
|
|
|
+ if not ms:
|
|
|
+ return ('0001-01-01', '9999-12-31')
|
|
|
+ return (f'{ms[0]}-01', _month_end(ms[-1]))
|
|
|
+
|
|
|
+def in_win(ts, win):
|
|
|
+ """按窗过滤**有日粒度**的时间列(含两端)。月度列请用 `months_of()`(闰月/跨月取整不同)。"""
|
|
|
+ import pandas as pd
|
|
|
+ a, b = win_range(win)
|
|
|
+ t = pd.to_datetime(ts, errors='coerce')
|
|
|
+ return (t >= pd.Timestamp(a)) & (t <= pd.Timestamp(b) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1))
|
|
|
+
|
|
|
+def span_of(win):
|
|
|
+ """窗 → 计算层用的 (起, 止) 元组(含);判级/曲线等按窗重算的口子都吃这个。"""
|
|
|
+ return win_range(win)
|
|
|
+
|
|
|
+
|
|
|
+# ── 按窗重算:后台算 + 缓存(用户令 2026-09-21)─────────────────────────────────
|
|
|
+# 判级矩阵 ≈25 s/窗、七镜头 ≈9~44 s/窗 —— 直接同步算会把 HTTP 请求挂几十秒(远端还要过网关,
|
|
|
+# 长请求有被掐的风险)。所以统一口径:**缓存命中即回;未命中就起后台线程,本次先回 pending,
|
|
|
+# 页面轮询再取**。同一窗只算一次;算完常驻内存(进程内)。
|
|
|
+import threading as _thr
|
|
|
+
|
|
|
+_WIN_CACHE: dict = {'sysmx': {}, 'curves': {}}
|
|
|
+_WIN_BUSY: dict = {'sysmx': set(), 'curves': set()}
|
|
|
+_WIN_ERR: dict = {'sysmx': {}, 'curves': {}}
|
|
|
+_WIN_LOCK = _thr.Lock()
|
|
|
+
|
|
|
+
|
|
|
+def _win_key(win):
|
|
|
+ a, b = span_of(win)
|
|
|
+ return f'{a}~{b}'
|
|
|
+
|
|
|
+
|
|
|
+def _win_get(kind, win, fn):
|
|
|
+ """→ 值 或 None(None = 正在算/刚起算)。失败**记名**(`_WIN_ERR`)而不是静默当"没数据"。"""
|
|
|
+ key = _win_key(win)
|
|
|
+ with _WIN_LOCK:
|
|
|
+ if key in _WIN_CACHE[kind]:
|
|
|
+ return _WIN_CACHE[kind][key]
|
|
|
+ if key not in _WIN_BUSY[kind]:
|
|
|
+ _WIN_BUSY[kind].add(key)
|
|
|
+
|
|
|
+ def _run():
|
|
|
+ try:
|
|
|
+ v = fn()
|
|
|
+ except Exception as e: # 守护失败必响亮
|
|
|
+ _WIN_ERR[kind][key] = f'{type(e).__name__}: {e}'[:200]
|
|
|
+ print(f'[win] {kind} 按窗重算失败 {key}: {_WIN_ERR[kind][key]}', flush=True)
|
|
|
+ v = None
|
|
|
+ with _WIN_LOCK:
|
|
|
+ if v is not None:
|
|
|
+ _WIN_CACHE[kind][key] = v
|
|
|
+ _WIN_BUSY[kind].discard(key)
|
|
|
+ _thr.Thread(target=_run, name=f'win-{kind}-{key}', daemon=True).start()
|
|
|
+ print(f'[win] {kind} 按窗重算启动 {key}(首次约数十秒,页面会先出 pending)', flush=True)
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def _win_wait(kind, win, timeout=120.0):
|
|
|
+ """等某窗的按窗重算落地(预热用)。超时/失败返回 None,绝不抛。"""
|
|
|
+ import time as _t
|
|
|
+ key = _win_key(win)
|
|
|
+ t0 = _t.time()
|
|
|
+ while _t.time() - t0 < timeout:
|
|
|
+ with _WIN_LOCK:
|
|
|
+ if key in _WIN_CACHE[kind]:
|
|
|
+ return _WIN_CACHE[kind][key]
|
|
|
+ if key in _WIN_ERR[kind]:
|
|
|
+ return None
|
|
|
+ _t.sleep(1.0)
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def sysmx_of(win):
|
|
|
+ """→ (判级矩阵, pending)。`pending=True` 时返回的是"另一口径"的旧矩阵,页面必须如实标注。"""
|
|
|
+ from src.windscada import taxonomy
|
|
|
+ m = _win_get('sysmx', win, lambda: taxonomy.system_matrix(CFG, span=span_of(win)))
|
|
|
+ if m is None:
|
|
|
+ return _CACHE['sysmx'], True
|
|
|
+ return m, False
|
|
|
+
|
|
|
+
|
|
|
+def curves_of(win):
|
|
|
+ """→ (七镜头分箱件 或 None, pending)。按窗重算(干净窗就是正式产物本身)。"""
|
|
|
+ from src.windscada.perf import curves as _cv
|
|
|
+ a, b = span_of(win)
|
|
|
+ if (a, b) == (_cv.WIN[0], '2025-12-31'): # 与正式产物的判别窗一致 ⇒ 直接用产物, 不重算
|
|
|
+ import pandas as _pd
|
|
|
+ return _pd.read_parquet(ST / 'curve_lenses.parquet'), False
|
|
|
+ st = _win_get('curves', win, lambda: _cv.build_store(CFG, span=(a, b), write=False))
|
|
|
+ return st, st is None
|
|
|
+
|
|
|
TS = {'报警': 0, '危险': 0, '良好': 1, '不可判': 2, '优秀': 3}
|
|
|
|
|
|
# ---------- 图规格 (分册两型) ----------
|
|
|
@@ -940,13 +1064,26 @@ LENSES = [('wsb', 'grd_wtc_ActPower_mean', 'L1 功率曲线 (风速-功率)', '
|
|
|
('pwb', 'p3', 'L2b 三叶桨距极差 (集距/不平衡)', '功率 kW', '°'),
|
|
|
('pwb', 'ratio', 'L7 转速比 gen/rot (传动链)', '功率 kW', '—')]
|
|
|
|
|
|
-def curve_view():
|
|
|
+def curve_view(win='2025H2'):
|
|
|
+ """特性曲线多镜头 (SOP §4.6c): fleet 中位+四分位带 + 过绝对锚的离群台曲线.
|
|
|
+
|
|
|
+ ★2026-09-21 用户令「发电性能随时间窗变化」: 七镜头**按所选时间窗重算**(首次后台算,先回 pending),
|
|
|
+ 7 张月度时序图按所选窗的月份过滤。原口径(2025H2 干净判别窗)仍可作为窗之一被选中,
|
|
|
+ 选到它时直接用正式产物,不重算。
|
|
|
+ """
|
|
|
import pandas as pd
|
|
|
- """特性曲线多镜头 (SOP §4.6c): fleet 中位+四分位带 + 过绝对锚的离群台曲线."""
|
|
|
from src.windscada.perf import curves as cv
|
|
|
+ a, b = span_of(win)
|
|
|
+ ms = months_of(win)
|
|
|
+ store, pending = curves_of(win)
|
|
|
+ if pending:
|
|
|
+ return dict(building=True, win=win, span=[a, b], months=ms,
|
|
|
+ note=f'正在按所选时间窗 {a} ~ {b} 重算七镜头曲线(首次约 10~45 秒),完成后自动刷新。'
|
|
|
+ f'期间下方月度时序图已按所选窗过滤,可直接看。')
|
|
|
+ wlabel = f'{a} ~ {b}'
|
|
|
figs = []
|
|
|
for xc, yc, title, xlab, unit in LENSES:
|
|
|
- r = cv.lens(xc, yc)
|
|
|
+ r = cv.lens(xc, yc, store_df=store, win_label=wlabel)
|
|
|
if not r: continue
|
|
|
outs = sorted(r['离群'].items(), key=lambda kv: -abs(kv[1]['z']))[:3]
|
|
|
a = r['anchor']
|
|
|
@@ -958,7 +1095,7 @@ def curve_view():
|
|
|
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']} (固定判别窗, 不随上方窗切换)"),
|
|
|
+ f" | 窗 {r['win']} (随所选窗)"),
|
|
|
band=dict(q1=r['q1'], q3=r['q3'])))
|
|
|
# 残差视图 (§4.6c④ per机必算同型残差): 绝对量尺度上四分位带只有几个像素, 残差面才看得见
|
|
|
if outs:
|
|
|
@@ -980,7 +1117,11 @@ def curve_view():
|
|
|
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]
|
|
|
+ # ★2026-09-21 用户令: 月度时序图按**所选时间窗**的月份过滤(月度件无法按日切,如实写"按月取整")
|
|
|
+ if ms:
|
|
|
+ piv = piv.reindex([m for m in [str(x) for x in piv.index] if m in set(ms)])
|
|
|
+ if not len(piv): return None
|
|
|
+ msx = [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)
|
|
|
@@ -991,9 +1132,9 @@ def curve_view():
|
|
|
# 违反第一性原理②"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,
|
|
|
+ note2 = (note + f" | 样本 {_n:,} 个月×台 / {_nt} 台 / {len(msx)} 个月"
|
|
|
+ f" | 窗 {msx[0]}~{msx[-1]} (月度聚合, 随所选窗·按月取整)") if msx else note
|
|
|
+ return dict(kind='multiline', title=title, unit=unit, months=msx, 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)]))
|
|
|
@@ -1073,7 +1214,8 @@ def single_problem(t, sys, win):
|
|
|
def fleet_view(win):
|
|
|
_load()
|
|
|
ms = months_of(win)
|
|
|
- sysmx = _CACHE['sysmx']
|
|
|
+ # ★2026-09-21 用户令「判级也按所选时间窗重算」: 判级矩阵按窗算(后台+缓存,首次 pending)。
|
|
|
+ sysmx, sysmx_pending = sysmx_of(win)
|
|
|
# ① 系统分类问题 (全系统)
|
|
|
systems, sysdist = {}, {}
|
|
|
for s in taxonomy.SYSTEMS:
|
|
|
@@ -1199,7 +1341,7 @@ def fleet_view(win):
|
|
|
m9 = dict(err=str(e)[:120])
|
|
|
try:
|
|
|
from src.windscada.perf import reliability as relmod
|
|
|
- rel = relmod.overview(CFG)
|
|
|
+ rel = relmod.overview(CFG, span=span_of(win))
|
|
|
except Exception as e:
|
|
|
rel = dict(err=str(e)[:120])
|
|
|
try:
|
|
|
@@ -1379,7 +1521,12 @@ def fleet_view(win):
|
|
|
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; 故障统计/五态/温度月轨迹=所选时间窗真窗')
|
|
|
+ # ★2026-09-21: 判级轴现在**按所选窗重算**;首次是后台算,这一份仍是旧口径 ⇒ 如实标 pending
|
|
|
+ sysmx_pending=bool(sysmx_pending),
|
|
|
+ sysmx_span=list(span_of(win)),
|
|
|
+ note=('判级轴(变桨/偏航/蓄能/温度)按所选窗重算;曲线按所选窗重算;'
|
|
|
+ '故障统计/五态/温度月轨迹/停机台账=所选窗真窗'
|
|
|
+ + ('(⚠ 判级矩阵正在按窗重算,下面系统卡暂为上一份口径,稍后自动刷新)' if sysmx_pending else '')))
|
|
|
|
|
|
# ---------- 本体网页面 (ontology 的网页体现: 机制链/决策台/机制库/对象浏览器; 只读, 复用 MCP 纯函数) ----------
|
|
|
def ontology_view():
|
|
|
@@ -4734,7 +4881,9 @@ class H(BaseHTTPRequestHandler):
|
|
|
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')
|
|
|
+ # ★2026-09-21: 接 win(按所选时间窗重算;未命中就起后台线程 + 回 building,页面轮询)
|
|
|
+ self._send(_jdump(curve_view(q.get('win') or '2026年'), 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')
|
|
|
@@ -5278,4 +5427,25 @@ if __name__ == '__main__':
|
|
|
except Exception:
|
|
|
_ip = _host
|
|
|
print(f'⚠ 已对局域网开放: http://{_ip}:{a.port} (同网段任何人可访问, 演示结束请关闭)')
|
|
|
+ # ★按窗重算的**预热**(用户令 2026-09-21:判级/曲线随所选时间窗变化)。
|
|
|
+ # 判级矩阵 ≈25 s/窗、镜头 ≈10~45 s/窗 —— 不预热的话重启后第一个访客要等 pending 轮询。
|
|
|
+ # 这里在**后台守护线程**里顺序把预设窗算进进程缓存(页面照常可用,未就位的窗如实标 pending)。
|
|
|
+ def _warm():
|
|
|
+ n = 0
|
|
|
+ for w in list(WINDOWS):
|
|
|
+ try:
|
|
|
+ sysmx_of(w) # 未命中即起后台线程
|
|
|
+ if _win_wait('sysmx', w, timeout=180) is not None:
|
|
|
+ n += 1
|
|
|
+ except Exception as e:
|
|
|
+ print(f'[warm] 判级 {w} 失败: {type(e).__name__}: {e}'[:160], flush=True)
|
|
|
+ for w in ('2026年',):
|
|
|
+ try:
|
|
|
+ curves_of(w) # 未命中即起后台线程
|
|
|
+ _win_wait('curves', w, timeout=240)
|
|
|
+ except Exception as e:
|
|
|
+ print(f'[warm] 镜头 {w} 失败: {type(e).__name__}: {e}'[:160], flush=True)
|
|
|
+ print(f'[warm] 按窗缓存预热完成: 判级 {n}/{len(WINDOWS)} 窗', flush=True)
|
|
|
+
|
|
|
+ _thr.Thread(target=_warm, name='win-warm', daemon=True).start()
|
|
|
ThreadingHTTPServer((_host, a.port), H).serve_forever()
|