Переглянути джерело

2.9.0 时间窗真正生效:部件问题/发电性能按所选窗重算 + 自定义起止日期窗

用户令:「『部件问题』『发电性能』应随『时间窗』下拉框值的变化而变化」「判级也按所选时间窗重算,
时间窗中增加自定义时间段——开始日期+结束日期(含)」。

- 窗词表: 新增 YYYY-MM-DD~YYYY-MM-DD(含两端);months_of() 取相交月,新增 win_range()/in_win()
  给日粒度件做含端过滤;v2 顶栏加两个日期输入框 + 应用(校验起<=止)。月度件按月取整、日粒度件按日精确。
- 判级按窗: taxonomy.system_matrix(span=…) 透到 变桨(日粒度件切片,精确)/偏航/蓄能/温度(窄仓重算);
  reliability.overview(span=…) 让部件可靠性表按窗;温度的 ref/ref_sm 对照窗故意仍固定并标注。
- 发电性能按窗: /api/curves?win= 七镜头按窗重算 + 7 张月度时序图按窗过滤;选到 2025-07~2025-12
  (限电前干净窗)直接用正式产物不重算。
- 新产物 windscada/slim10min/*(46 列公共子集,不裁剪行)=按窗重算底座:scripts/scada_slim_build.py
  一次全量扫≈2 分钟,把'每换一个窗重读 15GB'降到秒级;进重算链 ③b + 反向审计族表 scada_slim10min。
- 按窗重算一律'后台算+进程缓存':判级≈25s/窗、镜头≈10~45s/窗;命中即回,未命中回 pending/building
  由页面轮询(6s×40),启动预热 6 个预设窗;pending 期间页面在脸上写明'仍是上一份口径',不静默混用。
- 顺带: service_worker 手拼 configs 路径改走 P.config()(config_audit rc=0);raw_scan 增 ③b 步标签。
- 实测: 判级逐系统报警台数随窗变(变桨 15/13/10、偏航 15/11/5、齿轮箱 7/4/4 对应 2026年/2025H2/2026-07),
  曲线图注窗=所选窗;验收反向审计 3548 件 0 未归类、pages/detail_deps/config_audit/chain_gap/raw_scan 全绿。
zhouyang.xie 2 тижнів тому
батько
коміт
9116f1dfb9

+ 2 - 2
docs/detail_页面依赖与重算台账_v0.1.md

@@ -6,7 +6,7 @@
 <!-- DETAIL-DEPS:BEGIN -->
 # /detail 页面依赖与重算台账 (自动生成, 禁手改)
 
-> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 3,509 件
+> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 3,548 件
 >
 > 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, 数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。
 
@@ -48,7 +48,7 @@
 |  | `windscada/loss_monthly.parquet` ✅必需 | 1 | 0.1 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 |  | `windscada/powercurve_bins.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 |  | `windscada/powercurve_dev.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
-| /api/fleet | `windscada/*.parquet` ○可选 | 17 | 2.0 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms·scada_derived·scada_oil·scada_monthly·scada_workorders) | 在位 |
+| /api/fleet | `windscada/*.parquet` ○可选 | 55 | 577.9 MB | (未登记)·raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms·scada_derived·scada_oil·scada_slim10min) | 在位 |
 |  | `ontology/turbine_params.parquet` ○可选 | 1 | 0.1 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 |  | `m5_cms_tcm/handoff_vibration_v2.json` ○可选 | 1 | 0.1 MB | raw-derived | scripts/rudong_fusion_handoff.py (vib_fusion_handoff) | 在位 |
 |  | `m5_cms_tcm/fleet_scalar_z.parquet` ○可选 | 1 | 0.7 MB | raw-derived | scripts/rudong_fusion_run.py (vib_fleet_scalar_z) | 在位 |

Різницю між файлами не показано, бо вона завелика
+ 4 - 3
docs/版本记录.md


+ 46 - 0
docs/系统设计说明.md

@@ -1149,3 +1149,49 @@ scripts/version_log.py             ← 由 HISTORY 生成 docs/版本记录.md
 
 > 结论口径:`可逆(直接)` = 包内已有生成端,接上即可;`可逆(需反推口径)` = 内容是测量数据的函数,照 `temp_monthly` 的办法反推 + 逐值对拍;`不可逆(人工判断)` = 逆向工程推不出来,要么由研发补生成端、要么承认它是人工件(不该按产物管)。
 <!-- REVERSE-AUDIT:END -->
+
+## 16. 时间窗口径:按窗重算(2026-09-21 用户令)
+
+用户令原话:「『部件问题』、『发电性能』应随『时间窗』下拉框值的变化,而相应变化」,并追加
+「判级也按所选时间窗重算;时间窗中增加自定义时间段 —— 开始日期+结束日期(含)」。
+
+### 16.1 窗的词表
+
+| 窗写法 | 含义 | 覆盖到的月(月度件用它) | 起止(日粒度件用它,**含两端**) |
+|---|---|---|---|
+| `近30日` / `近90日` | 数据末端的 1 / 3 个月 | 该 1/3 个月 | 该段的月首 ~ 月末 |
+| `2026年` / `2026H1` / `2025H2` / `全程` | 预设段 | 段内月 | 段首月首 ~ 段末月末 |
+| `YYYY-MM` | 单月 | 该月 | 该月 01 ~ 月末 |
+| `YYYY-MM~YYYY-MM` | 月区间 | 区间内月 | 起月 01 ~ 止月月末 |
+| **`YYYY-MM-DD~YYYY-MM-DD`** | **自定义(新增)** | **与区间相交的月** | **原样,含两端** |
+
+实现:`scripts/windscada_serve.py` 的 `months_of(win)`(月度件)+ `win_range(win)/in_win(ts, win)`(日粒度件);
+v2 顶栏两个日期输入框 + 「应用」(校验起 ≤ 止,非法拒绝并提示)。**月度件按月取整、日粒度件按日精确**,
+页面上写明这句,不让读者以为月度均值能切到天。
+
+### 16.2 哪些随窗、哪些不随(单源)
+
+| 内容 | 随窗? | 机制 |
+|---|---|---|
+| 报障统计 / 五态控制策略 / 温度月轨迹 / 停机台账 | ✔ | 月度件按 `months_of()` 过滤(原有口径) |
+| **判级四轴**(变桨 / 偏航 / 蓄能 / 温度) | ✔(本轮新增) | `taxonomy.system_matrix(span=…)` → 各面 `registry(span=…)`。变桨的输入是**日粒度**序列 ⇒ 精确切片;偏航/蓄能/温度走窄仓重算(同代码同阈值,只换数据切片) |
+| **部件可靠性表**(MTBF/MDT/停机时长) | ✔(本轮新增) | `reliability.overview(span=…)`:停机事件与报警都是日粒度 ⇒ 精确 |
+| **发电性能**:7 张月度时序图 | ✔(本轮新增) | 按 `months_of(win)` 过滤(月度件 ⇒ 按月取整) |
+| **发电性能**:七镜头曲线 | ✔(本轮新增) | `/api/curves?win=` → 按窗重算分箱(`curves.build_store(span=…)`);选到 2025-07~2025-12 时直接用正式产物 |
+| 温度面的 `ref`(2025H2 干净窗)与 `ref_sm`(同历月早一年) | ✘(**故意固定**) | 它们是"季节解耦的对照窗",跟着用户窗漂就失去对照意义;页面如实标注 |
+| 振动判级 / 厂家报告转录 / 工单台账 | ✘ | 各有自己的数据时点,不随时间窗变(原有口径,页面已标注) |
+
+### 16.3 为什么要有 `slim10min` 窄仓
+
+按窗重算的真实卡点是"每换一个窗就要重读 15 GB 10min CSV"(实测 ≈2 分钟/面)。新增产物
+`outputs/<场>/windscada/slim10min/<台号>.parquet`(46 列公共子集:功率/限电命令/风况/转速/桨距/
+温度 `_mean` 27/偏航/液压;**不裁剪行**),由 `scripts/scada_slim_build.py` 一次全量扫(≈2 分钟)生成,
+进重算链 **③b** 步,并登记在反向审计族表 `scada_slim10min`。之后任何窗的判级/镜头重算都只是
+"窄仓过滤 + 分组"(秒级)。**口径同源**:窄仓就是 `load_10min(…, groups=[…])` 的列子集。
+
+### 16.4 慢就慢在算,不慢在等:pending 契约
+
+判级矩阵 ≈25 s/窗、七镜头 ≈10~45 s/窗。直接同步算会把 HTTP 请求挂几十秒(远端还要过网关),
+所以统一口径:**进程缓存命中即回;未命中就起后台线程、本次先回 `sysmx_pending` / `building`,
+页面轮询再取**(6 s 一次,至多 40 次)。服务启动时后台预热 6 个预设窗(判级 + 默认窗镜头)。
+`pending` 期间页面**在脸上写明**"下面这几张卡还是上一份口径",绝不静默把旧口径当新窗。

+ 7 - 0
scripts/products_reverse_audit.py

@@ -68,6 +68,13 @@ FAMILIES: list[dict] = [
     dict(id='vib_raw_manifest', glob='m5_cms_tcm/vib_raw_manifest.json', kind='raw-derived',
          func='振动摄入 · 清单', algo='scripts/vib_raw_build.py(汇总窗/谱件数、时间跨度、missing_chain 缺口)',
          gen='scripts/vib_raw_build.py', input='windcms', pred=('manifest',)),
+    dict(id='scada_slim10min', glob='windscada/slim10min/*', kind='raw-derived',
+         func='按窗重算底座 · SCADA 10min 窄仓',
+         algo='scripts/scada_slim_build.py(逐台 load_10min 的公共列子集约 46 列:功率/限电命令/风况/转速/'
+              '桨距/温度 _mean 27/偏航/液压;不裁剪行。用户令 2026-09-21「判级/曲线随所选时间窗重算」的底座:'
+              '一次全量扫 ≈2 分钟,之后任何窗的判级与七镜头重算都只是窄仓上的过滤+分组)',
+         gen='scripts/scada_slim_build.py', input='scada_10min', pred=('rows', 'turbines'),
+         time_col='ts'),
     dict(id='scada_alarms', glob='windscada/alarms.parquet', kind='raw-derived',
          func='三门台账 · 报警', algo='scripts/windscada_alarms_ingest.py(SpreadsheetML *.xls → 事件表)',
          gen='scripts/windscada_alarms_ingest.py', input='故障报警', pred=('span', 'rows'), time_col='t_on'),

+ 3 - 2
scripts/raw_scan.py

@@ -62,8 +62,9 @@ FAMILIES: tuple[tuple[str, tuple[str, ...], tuple[str, ...], str], ...] = (
      '检修工单台账 → windscada/workorders.parquet(重复检修/停机损失)'),
     ('油样报告', ('*.pdf',), ('② 三门台账',),
      '油液化验报告 → windscada/oil_samples_index.parquet(油液面)'),
-    ('scada_10min', ('*.csv',), ('③ SCADA 侧 10 个构建器', '④ 月度派生件', '④c 变桨面', '⑤b 事实契约 claim'),
-     '10min 导出 → 功率曲线/损失/温度/停机/偏航/液压等 + 变桨面日粒度(温度/性能/变桨面)'),
+    ('scada_10min', ('*.csv',), ('③ SCADA 侧 10 个构建器', '③b SCADA 10min 窄仓 (按窗重算底座)',
+                                '④ 月度派生件', '④c 变桨面', '⑤b 事实契约 claim'),
+     '10min 导出 → 功率曲线/损失/温度/停机/偏航/液压等 + 变桨面日粒度(温度/性能/变桨面)+ 窄仓(按窗重算底座)'),
     ('scada_1min', ('*.csv',), ('④c 变桨面',),
      '1min 导出(内部台号 01E…38B)→ 变桨面零位三口径与越线计数'),
     ('scada_mdb', ('*.mdb', '*.zip'), ('③ SCADA 侧 10 个构建器', '④ 月度派生件', '④c 变桨面'),

+ 5 - 0
scripts/rebuild_all.py

@@ -93,6 +93,11 @@ def build_plan(a) -> list:
     plan.append(step_cmd('② 三门台账', [PY, 'scripts/rebuild_from_raw.py']))
     if not a.skip_scada:
         plan.append(step_cmd('③ SCADA 侧 10 个构建器 (约 15 分钟)', [PY, 'scripts/rebuild_from_raw.py', '--scada']))
+    # ③b SCADA 10min **窄仓**(用户令 2026-09-21「判级/曲线随所选时间窗重算」的底座):
+    #   一次全量扫(≈2 分钟)抽出约 46 列公共子集 ⇒ 之后任何窗的判级/镜头重算从"重读 15 GB"
+    #   变成"窄仓上过滤+分组"(秒级)。数据换了必须重跑,否则按窗重算会用旧值。
+    if not a.skip_scada:
+        plan.append(step_cmd('③b SCADA 10min 窄仓 (按窗重算底座)', [PY, 'scripts/scada_slim_build.py']))
     # ④ 月度派生件: `windscada_monthly_build.py` 会拿随包基线**逐值比对**, 没有基线时它返回 5
     #    ("无法比对 —— 这不是通过", 这是它的诚实口径, 不要改它的退出码)。
     #    ★但"重算链"与"等价验收"是两件事: 基线只是**验收标准答案**, 缺它不该让整条链停在第④步

+ 151 - 0
scripts/scada_slim_build.py

@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""SCADA 10min **精简轴仓**(slim10min):按窗重算的公共底座(用户令 2026-09-21)。
+
+## 为什么要有它
+
+用户令「部件问题/发电性能 应随时间窗变化」时,实测卡点不是算不出来,而是**每换一个窗都要重读 15 GB 10min CSV**:
+变桨/偏航/蓄能/温度四个判级面与七镜头曲线各自的 `build_store()` 都是"逐台 `load_10min` 全量 → 只留一个固定窗",
+换窗重算一次 ≈ 2 分钟/面。而它们真正需要的列只有 **约 50 列**(温度 `_mean` 27 + 功率/风况/转速/桨距/偏航/液压)。
+
+本器把这份"公共列子集"抽成**逐台一份的窄仓**:一次全量扫(≈3 分钟),之后任何窗的重算都只是
+窄仓上的过滤+分组(秒级),并且**口径与各面原有实现逐行同源**(同一份 `load_10min` 输出、同列同值)。
+
+## 落点与口径
+
+    outputs/<场>/windscada/slim10min/<台号>.parquet      (列: ts + 公共列子集)
+    outputs/<场>/windscada/slim10min/_manifest.json      (行数/列清单/缺列/构建时间)
+
+    · 缺列**如实记**(`columns_missing`),不造 0、不静默丢列 —— 各面按列名取,缺了就当"不可判"。
+    · 本件是**产物**(raw-derived):落盘后自登记进 `outputs/<场>/_derived_manifest.json`。
+    · 口径不变式:本件 = `load_10min(台, groups=GROUPS)` 的列子集,**不裁剪行**(时间范围 = 该台 CSV 全量)。
+
+## 用法
+
+    python scripts/scada_slim_build.py                 # 全 38 台
+    python scripts/scada_slim_build.py --turbines WTG01,WTG02
+    python scripts/scada_slim_build.py --check         # 只看现状(行数/列/最新时间),不写
+
+退出码: 0 成功 · 2 参数/源不齐 · 5 落盘为空(不覆盖既有仓)
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from src import paths as P                                                    # noqa: E402
+from src.windscada.config import farm                                         # noqa: E402
+from src.windscada.data import contracted_cols, load_10min                    # noqa: E402
+
+GROUPS = ['A.功率', 'A.风况', 'A.转速', 'B.变桨', 'B.温度NBM', 'B.偏航', 'B.润滑液压']
+# 公共列子集 = 四个判级面 + 七镜头曲线实际取用的列(逐条对着源码点名,别用整组,整组 128 列太肥)
+COLS_CORE = [
+    'grd_wtc_ActPower_mean',           # 功率: 五态/pbin/发电态门
+    'tur_wtc_PowerRef_endvalue',       # 限电命令面: perf.curtail.classify 的必需列 (缺它直接 KeyError)
+    'tur_wtc_SecAnemo_mean',           # 机舱风: 曲线 X 轴 + 低风待机门 (活风源)
+    'tur_wtc_GenRpm_mean', 'tur_wtc_MainSRpm_mean',                     # 转速: 曲线/转矩/λ
+    'tur_wtc_PitcPosA_mean', 'tur_wtc_PitcPosB_mean', 'tur_wtc_PitcPosC_mean',   # 桨距: 曲线/三叶不平衡
+    'tmp_wtc_AmbieTmp_mean',           # 环温: 空气密度口径
+    # 偏航面 (yaw.build_store)
+    'prs_wtc_YawPress_mean', 'prs_wtc_YawPress_max', 'prs_wtc_YawPress_min',
+    'dot_wtc_YawLubPu_timeon', 'din_wtc_Y1LubPis_timeon', 'flg_wtc_ScYawOpe_counts',
+    'cnt_wtc_WindFauS_endvalue', 'cnt_wtc_WindFauT_endvalue',
+    # 蓄能面 (hydraulic.build_store)
+    'prs_wtc_HydPress_mean', 'prs_wtc_HydPress_max', 'prs_wtc_HydPress_min', 'tmp_wtc_HydOilTm_mean',
+]
+
+
+def slim_cols(cfg) -> list[str]:
+    """公共列 = 核心列 + 温度 NBM 的全部 `_mean`(温度面按 NBM 全通道比档)。"""
+    temps = [c for c in contracted_cols(cfg, groups=['B.温度NBM']) if c.endswith('_mean')]
+    out = []
+    for c in COLS_CORE + temps:
+        if c not in out:
+            out.append(c)
+    return out
+
+
+def out_dir(farm_name: str | None = None) -> pathlib.Path:
+    return P.out_root(farm_name or P.farm()) / 'windscada' / 'slim10min'
+
+
+def build(cfg, want: list[str] | None, check: bool) -> int:
+    cols = slim_cols(cfg)
+    d = out_dir()
+    d.mkdir(parents=True, exist_ok=True)
+    man_p = d / '_manifest.json'
+    man = json.loads(man_p.read_text(encoding='utf-8')) if man_p.is_file() else {}
+    if check:
+        import pandas as pd
+        files = sorted(d.glob('WTG*.parquet'))
+        print(f'== slim10min 现状 · {P.rel(d)} ==')
+        print(f'   件 {len(files)} · 期望列 {len(cols)} · 手册: '
+              f'{(man.get("at") or "—")} · 构建行 {man.get("rows_total", 0):,}')
+        for f in files[:3]:
+            x = pd.read_parquet(f, columns=['ts'])
+            print(f'   {f.name}: {len(x):,} 行 · {x.ts.min()} → {x.ts.max()}')
+        if len(files) > 3:
+            print(f'   … 共 {len(files)} 件')
+        return 0
+    turbs = want or list(cfg['turbines'])
+    total = 0
+    missing_all: dict[str, list[str]] = {}
+    t0 = time.time()
+    for i, t in enumerate(turbs, 1):
+        df = load_10min(t, cfg, groups=GROUPS)
+        df = df.sort_values('ts').reset_index(drop=True)
+        miss = [c for c in cols if c not in df.columns]
+        if miss:
+            missing_all[t] = miss
+        keep = ['ts'] + [c for c in cols if c in df.columns]
+        sub = df[keep]
+        sub.to_parquet(d / f'{t}.parquet', index=False)
+        total += len(sub)
+        print(f'  [{i}/{len(turbs)}] {t}: {len(sub):,} 行 × {len(keep) - 1} 列 '
+              f'({sub.ts.min()} → {sub.ts.max()}){f" · 缺 {len(miss)} 列" if miss else ""}  '
+              f'已用 {time.time() - t0:.0f}s', flush=True)
+    if not total:
+        print('[X] 一行都没产出 —— 不覆盖既有仓')
+        return 5
+    man = dict(at=time.strftime('%Y-%m-%d %H:%M:%S'), turbines=len(turbs), rows_total=total,
+               columns=cols, columns_n=len(cols), groups=GROUPS,
+               columns_missing=missing_all,
+               note='公共列子集(判级/曲线按窗重算的底座);不裁剪行;缺列如实记,不造数')
+    man_p.write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+    print(f'\n合计 {total:,} 行 · 列 {len(cols)} · 用时 {time.time() - t0:.0f}s → {P.rel(d)}')
+    if missing_all:
+        print(f'   ⚠ 有缺列的台 {len(missing_all)} 个(例 {list(missing_all)[:3]})—— 已写进 _manifest.json')
+    try:
+        from src.derived_manifest import record as _rec
+        # ★键必须是**相对产物仓**的路径(2026-09-21 实逮: 先写成 basename `WTG01.parquet` ⇒
+        #   反向审计按 `windscada/slim10min/*` 一条也匹配不上,39 件全被记成"未归类")。
+        _root = P.out_root(P.farm())
+        rels = {d.joinpath(p.name).relative_to(_root).as_posix():
+                'scada_slim_build.py(10min 公共列子集,供判级/曲线按窗重算)'
+                for p in sorted(d.glob('WTG*.parquet'))}
+        rels[d.joinpath('_manifest.json').relative_to(_root).as_posix()] = 'scada_slim_build.py(窄仓手册)'
+        _rec(_root, rels, by='scada_slim_build.py')
+        print(f'  自登记 {len(rels)} 件 → {P.rel(_root / "_derived_manifest.json")}')
+    except Exception as e:                       # 登记失败不该把数据构建判成失败, 但要响亮
+        print(f'  ⚠ 自登记失败: {type(e).__name__}: {e}')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='SCADA 10min 精简轴仓(按窗重算底座)')
+    ap.add_argument('--turbines', default='', help='只做这几台(逗号分隔)')
+    ap.add_argument('--check', action='store_true', help='只看现状, 不写')
+    a = ap.parse_args()
+    cfg = farm()
+    want = [x.strip() for x in a.turbines.split(',') if x.strip()] or None
+    return build(cfg, want, a.check)
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 2 - 1
scripts/service_worker.py

@@ -94,7 +94,8 @@ def raw_watch_cfg() -> dict:
     d = dict(enabled=False, minutes=30, auto_rebuild=False)
     try:
         import json
-        c = json.loads((ROOT / 'configs' / 'serve.json').read_text(encoding='utf-8-sig'))
+        from src import paths as P                      # 配置路径的唯一取用口(不手拼 configs/)
+        c = json.loads(P.config('serve.json').read_text(encoding='utf-8-sig'))
         w = c.get('raw_watch') or {}
         if isinstance(w, dict):
             d.update({k: w[k] for k in ('enabled', 'minutes', 'auto_rebuild') if k in w})

+ 182 - 12
scripts/windscada_serve.py

@@ -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()

+ 20 - 1
src/version.py

@@ -19,7 +19,7 @@ import json
 import pathlib
 
 NAME = '观澜·如东样板 v2'
-VERSION = '2.8.2'          # ★ 改版本只改这里
+VERSION = '2.9.0'          # ★ 改版本只改这里
 EDITION = 'offline-single-package'
 PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
 
@@ -49,6 +49,25 @@ BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/
 #   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
 #   旧编号体系(0.x,未按本规则),仅作历史对账用。
 HISTORY: tuple[dict, ...] = (
+    dict(version='2.9.0', date='2026-09-21', level='minor',
+         title='时间窗真正生效(用户令):「部件问题」与「发电性能」随所选窗重算,并新增自定义起止日期窗',
+         note='功能增减改 ⇒ 中版本 +1。要点: '
+              '① 窗口词表新增**自定义起止日期**(`YYYY-MM-DD~YYYY-MM-DD`,**含两端**):'
+              '`months_of()` 取相交月、新增 `win_range()/in_win()` 供日粒度件做含端过滤;'
+              'v2 顶栏加两个日期输入框 + 应用(校验 a≤b),月度件按所跨月取整、日粒度件按日精确,页上写明; '
+              '② 判级四轴按窗重算:`taxonomy.system_matrix(span=…)` 把窗透到 变桨(日粒度件切片, 精确)/'
+              '偏航/蓄能/温度(走新窄仓重算);`reliability.overview(span=…)` 让部件可靠性表按窗;'
+              '温度面的 ref/ref_sm 对照窗**仍固定**(季节解耦基线,跟着漂就失去对照意义); '
+              '③ 发电性能按窗:`/api/curves?win=` 七镜头按窗重算 + 7 张月度时序图按窗过滤;'
+              '选到 2025-07~2025-12(限电前干净判别窗)时直接用正式产物不重算;'
+              '④ 新产物 `windscada/slim10min/*`(46 列公共子集,不裁剪行)作**按窗重算底座**:'
+              '一次全量扫≈2 分钟,把"每换一个窗重读 15 GB"降到秒级;已进重算链 ③b 并登记反向审计族表; '
+              '⑤ 按窗重算一律"后台算+进程缓存"(判级≈25 s/窗、镜头≈10~45 s/窗):命中即回,'
+              '未命中先回 `pending/building` 并由页面轮询,**不静默拿旧口径当新窗**;'
+              '启动时后台预热 6 个预设窗(判级+默认窗镜头); '
+              '⑥ 实测(本机 38 台): 判级逐系统报警台数随窗变(变桨 15/13/10、偏航 15/11/5、齿轮箱 7/4/4 对应'
+              '2026年/2025H2/2026-07),曲线图注窗=所选窗且样本量随窗变;验收: 反向审计 3548 件 0 未归类、'
+              'pages_audit/detail_deps/config_audit/raw_scan(selftest,rc=0)/chain_gap 全绿'),
     dict(version='2.8.2', date='2026-09-21', level='patch',
          title='消缺:扫描器的"变化"判据被两类**非摄入件**常年占住 —— 现场按类目交付的月度通道组库'
                '(`scada_mdb/<年>年/<月>月/<年>-<月>-<类>.mdb`)被当成"数据没人读·缺口",用户指定的按月提取件'

+ 28 - 4
src/windscada/lang.py

@@ -269,6 +269,24 @@ _add({
     'win.label':   ('时间窗', 'Window'),
     'win.presets': ('预设窗', 'Presets'),
     'win.months':  ('按月', 'By month'),
+    # ★自定义起止日期窗(用户令 2026-09-21):含两端;月度件按月取整,日粒度件按日精确
+    'win.custom':      ('自定义', 'Custom'),
+    'win.custom_hint': ('自定义起止日期(含两端)。日粒度件(判级/停机台账/曲线)按日精确;'
+                        '月度聚合件按所跨月份取整,图上会写明。',
+                        'Custom inclusive date range. Day-resolution items (grading, stop ledger, curves) are exact; '
+                        'monthly aggregates round to the spanned months and say so on the chart.'),
+    'win.from':        ('开始日期(含)', 'Start date (inclusive)'),
+    'win.to':          ('结束日期(含)', 'End date (inclusive)'),
+    'win.apply':       ('应用', 'Apply'),
+    'win.need_both':   ('请把开始与结束日期都选上', 'Pick both start and end dates'),
+    'win.bad_order':   ('开始日期不能晚于结束日期', 'Start date must not be after the end date'),
+    'win.custom_sel':  ('自定义 {a} ~ {b}', 'Custom {a} to {b}'),
+    'cmp.grading_pending': ('正在按所选时间窗重算判级…', 'Recomputing grading for the selected window…'),
+    'cmp.grading_pending_why': ('窗 {span}:变桨/偏航/蓄能/温度四个判级面正按该窗重算(首次约 25 秒),'
+                                '下面的系统卡暂时还是上一份口径 —— 算完会自动刷新,不会静默混用。',
+                                'Window {span}: the four grading axes (pitch / yaw / accumulator / temperature) '
+                                'are being recomputed for it (about 25 s the first time). The cards below still show '
+                                'the previous grading until it lands, then refresh automatically — no silent mixing.'),
     'win.30d':     ('近30日', 'Last 30 days'),
     'win.90d':     ('近90日', 'Last 90 days'),
     'win.2026':    ('2026年', '2026'),
@@ -754,11 +772,17 @@ _add({
 # ── 发电性能 (固定干净窗; 第一性原理闸卡按用户 2026-08-28 裁定不出现在交付界面) ──
 _add({
     'gen.eyebrow':    ('特性曲线与控制参数', 'Characteristic curves and control parameters'),
-    'gen.fixed_win':  ('固定窗 · 不随上方切换', 'Fixed window · does not follow the selector'),
-    'gen.win_range':  ('2025-07 ~ 2025-12', '2025-07 to 2025-12'),
-    'gen.win_why':    ('限电前干净窗 — 2026 起交易策略主导, 曲线与控制参数含策略性少发不可作设备判据。本页含特性曲线与控制参数一致性两件, 同窗。',
-                       'Clean pre-curtailment window — from 2026 trading strategy dominates, so curves and control parameters contain strategic under-production and cannot serve as equipment criteria. Both items on this page share this window.'),
+    'gen.fixed_win':  ('随上方时间窗 · 按窗重算', 'Follows the selector · recomputed per window'),
+    'gen.win_range':  ('窗 {span}', 'window {span}'),
+    'gen.win_why':    ('七镜头与月度时序都按所选时间窗重算(含自定义起止日期)。'
+                       '提示:2025-07 ~ 2025-12 是"限电前干净判别窗"——2026 起交易策略主导,'
+                       '曲线含策略性少发,作设备判据时须注意;选到含 2026 的窗时该影响存在但不再遮掩。',
+                       'Both the seven lenses and the monthly series are recomputed for the selected window '
+                       '(including custom date ranges). Note: 2025-07 to 2025-12 is the "clean pre-curtailment" '
+                       'window — from 2026 trading strategy dominates, so curves contain strategic '
+                       'under-production; the effect is present (not hidden) when a 2026 window is selected.'),
     'gen.loading':    ('正在计算七个镜头 (剥限电 · 逐档中位 · 同型机群残差)…', 'Computing seven lenses (curtailment stripped · per-bin medians · same-type fleet residuals)…'),
+    'gen.recomputing': ('正在按所选时间窗重算七镜头…', 'Recomputing the seven lenses for the selected window…'),
     'gen.ctrl':       ('控制参数一致性', 'Control-parameter consistency'),
     'gen.ctrl_win':   ('窗 {win}', 'window {win}'),
     'gen.cap':        ('封顶参数落位', 'Where the caps sit'),

+ 35 - 11
src/windscada/perf/curves.py

@@ -24,6 +24,10 @@ BETZ = 0.593
 WSB = np.arange(3.0, 15.5, 0.5)   # 风速档
 PWB = np.arange(0, 4250, 250)     # 功率档
 GRB = np.arange(600, 1760, 40)    # 发电机转速档
+# 七镜头真正取用的列(slim10min 窄仓里必须有这些;缺列 ⇒ 该列 liveness 判否,不造数)
+CURVE_COLS = ['grd_wtc_ActPower_mean', 'tur_wtc_PowerRef_endvalue', 'tur_wtc_SecAnemo_mean',
+              'tur_wtc_GenRpm_mean', 'tur_wtc_MainSRpm_mean', 'tur_wtc_PitcPosA_mean',
+              'tur_wtc_PitcPosB_mean', 'tur_wtc_PitcPosC_mean', 'tmp_wtc_AmbieTmp_mean']
 
 def _liveness(sr, lo=None, hi=None):
     s = sr.dropna()
@@ -35,12 +39,22 @@ def _liveness(sr, lo=None, hi=None):
     if lo is not None and (s.median() < lo or s.median() > hi): return False, f'物理域外(中位{s.median():.1f})'
     return True, f'活(nuniq={nu}, 卡值{stuck:.0%})'
 
-def build_store(cfg=None):
+def build_store(cfg=None, span=None, write=True):
+    """七镜头分箱件。`span=(起, 止)` 给了就按**所选时间窗**(含两端)重算(用户令 2026-09-21)。
+
+    span 模式走 `slim10min` 窄仓(同一份 `load_10min` 的列子集 ⇒ 同列同值),并且 **不写盘**:
+    按窗重算是服务时算给页面看的,不能覆盖正式产物 `curve_lenses.parquet`
+    (它的窗是"限电前干净判别窗 2025-07~2026-01",另有消费者 `physics_check`)。
+    """
     cfg = cfg or farm()
     rows, live_log = [], []
     for t in cfg['turbines']:
-        d = load_10min(t, cfg, groups=['A.功率', 'A.风况', 'A.转速', 'B.变桨', 'B.温度NBM'])
-        d = d[(d.ts >= WIN[0]) & (d.ts < WIN[1])]
+        if span:
+            from .. import slim
+            d = slim.load(t, cfg, span=span, columns=CURVE_COLS)
+        else:
+            d = load_10min(t, cfg, groups=['A.功率', 'A.风况', 'A.转速', 'B.变桨', 'B.温度NBM'])
+            d = d[(d.ts >= WIN[0]) & (d.ts < WIN[1])]
         d['state'] = classify(d, cfg['rated_kw'])
         d = d[d.state == '正常发电']          # ★限电剥离前置
         # ① 列 liveness 先验
@@ -74,11 +88,14 @@ def build_store(cfg=None):
                 for y in ycols:
                     v = r[y]
                     if v == v: rows.append(dict(turbine=t, lens=xcol, x=float(x), y=y, v=float(v), n=int(n[x])))
-        print(t, flush=True)
+        if write:
+            print(t, flush=True)
     df = pd.DataFrame(rows); st = pathlib.Path(cfg['store'])
-    df.to_parquet(st / 'curve_lenses.parquet')
-    pd.DataFrame(live_log).to_parquet(st / 'curve_liveness.parquet')
-    print('→ curve_lenses.parquet', df.shape)
+    if write:
+        df.to_parquet(st / 'curve_lenses.parquet')
+        pd.DataFrame(live_log).to_parquet(st / 'curve_liveness.parquet')
+        print('→ curve_lenses.parquet', df.shape)
+    return df
 
 # 显著门 = 物理绝对锚 (z 只说"相对同类偏离", 定级必须过绝对量; MAD≈0 的镜头会造 z=200 的伪离群)
 ANCHOR = {'grd_wtc_ActPower_mean': (40, 'kW', '=1%额定'), 'tur_wtc_PitcPosA_mean': (0.3, '°', '=变桨分册报警门半值'),
@@ -86,10 +103,17 @@ ANCHOR = {'grd_wtc_ActPower_mean': (40, 'kW', '=1%额定'), 'tur_wtc_PitcPosA_me
           'tq': (0.25, 'kNm', '≈1%额定转矩'), 'lam': (0.2, '—', '≈2%λ_opt'),
           'cp': (0.01, '—', '≈3%Cp峰'), 'ratio': (0.6, '—', '=0.5%齿比')}
 
-def lens(xcol, ycol, cfg=None):
-    """回 fleet 中位曲线 + 逐台残差 (§4.6c④: per机必算残差, 禁眼估)."""
+def lens(xcol, ycol, cfg=None, store_df=None, win_label=None):
+    """回 fleet 中位曲线 + 逐台残差 (§4.6c④: per机必算残差, 禁眼估).
+
+    `store_df` 给了就用它(= 按**所选时间窗**重算出来的分箱件),否则读正式产物
+    `curve_lenses.parquet`(固定判别窗)。`win_label` 只进图注,说明这张图的窗是什么。
+    """
     cfg = cfg or farm()
-    df = pd.read_parquet(pathlib.Path(cfg['store']) / 'curve_lenses.parquet')
+    if store_df is not None:
+        df = store_df
+    else:
+        df = pd.read_parquet(pathlib.Path(cfg['store']) / 'curve_lenses.parquet')
     g = df[(df.lens == xcol) & (df.y == ycol)]
     # ★工况段闸: 剔非发电段档 (0kW 档=切入/空转混合态, 转速散度极大; 低转速档同理)
     #   实逮: 未剔时 19#/29#/21#/31# 因单个 0kW 档 -66~-81rpm 被误判"转速跟踪偏低族",
@@ -115,7 +139,7 @@ def lens(xcol, ycol, cfg=None):
     n_by_x = [int(v) for v in npv.sum(axis=1).fillna(0)]
     return dict(x=[float(v) for v in med.index], fleet=[float(v) for v in med],
                 n_total=int(sum(n_by_x)), n_by_x=n_by_x, n_min_bin=int(min(n_by_x) if n_by_x else 0),
-                n_turbines=int(piv.shape[1]), win=f"{WIN[0]}~{WIN[1]}",
+                n_turbines=int(piv.shape[1]), win=(win_label or f"{WIN[0]}~{WIN[1]}"),
                 anchor=dict(门=thr, 单位=unit, 说明=thr_note), 离群=out_t,
                 q1=[float(v) for v in piv.quantile(0.25, axis=1)], q3=[float(v) for v in piv.quantile(0.75, axis=1)],
                 per_t={t: [None if v != v else float(v) for v in piv[t]] for t in piv.columns},

+ 14 - 3
src/windscada/perf/reliability.py

@@ -38,13 +38,24 @@ def comp_of(code):
         if c.startswith(p): return comp
     return '未归类'
 
-def overview(cfg=None, since='2026-01-01'):
+def overview(cfg=None, since='2026-01-01', span=None):
+    """部件可靠性概览。
+
+    `span=(起, 止)` 给了就按**所选时间窗**(含两端)重算(用户令 2026-09-21)——
+    停机事件与报警都是**日粒度**(`t_start`/`t_on`),所以按窗是精确切片;不给 = 原口径(`since` 起)。
+    """
     cfg = cfg or farm()
     st = pathlib.Path(cfg['store'])
     ev = pd.read_parquet(st / 'stop_events.parquet')
-    ev = ev[(ev.t_start >= since) & (~ev.dispatch)].copy()
     al = pd.read_parquet(st / 'alarms.parquet')
-    al = al[al.t_on >= since].copy()
+    if span:
+        a, b = str(span[0]), str(span[1]) + ' 23:59:59'
+        ev = ev[(ev.t_start >= a) & (ev.t_start <= b) & (~ev.dispatch)].copy()
+        al = al[(al.t_on >= a) & (al.t_on <= b)].copy()
+        since = str(span[0])
+    else:
+        ev = ev[(ev.t_start >= since) & (~ev.dispatch)].copy()
+        al = al[al.t_on >= since].copy()
     ev['comp'] = ev.cause_code.map(lambda c: '无记录停机' if not c else comp_of(c))
     al['comp'] = al.code.map(comp_of)
     n_t = len(cfg['turbines'])

+ 69 - 0
src/windscada/slim.py

@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+r"""slim10min 窄仓读取口 —— **判级/曲线按窗重算的底座**(用户令 2026-09-21)。
+
+窄仓由 `scripts/scada_slim_build.py` 构建(`outputs/<场>/windscada/slim10min/<台号>.parquet`,
+列 = 现成 `load_10min` 的公共列子集,**不裁剪行**)。本模块只做三件事:
+
+    have(cfg) / manifest(cfg)                     现状与手册(缺件、缺列都如实回)
+    load(t, cfg, span=(a, b), columns=[...])      取一台、按窗(**含两端**)过滤
+    loads(turbs, cfg, span, columns)              批量取(各面 build_store 按窗重算用)
+
+为什么不让各面自己读 10min:换一个窗就要重读 15 GB CSV(实测 ≈2 分钟/面)。窄仓一次建好(≈2 分钟),
+之后任何窗都只是窄仓上的过滤+分组(秒级),而口径与各面原实现**同源同列**。
+"""
+from __future__ import annotations
+
+import json
+import pathlib
+
+import pandas as pd
+
+from .config import farm
+
+
+def dir_of(cfg=None) -> pathlib.Path:
+    """窄仓目录 = 产物仓下的 `slim10min/`(与 `scripts/scada_slim_build.py` 同一落点)。"""
+    cfg = cfg or farm()
+    return pathlib.Path(cfg['store']) / 'slim10min'
+
+
+def path_of(turbine: str, cfg=None) -> pathlib.Path:
+    return dir_of(cfg) / f'{turbine}.parquet'
+
+
+def manifest(cfg=None) -> dict:
+    p = dir_of(cfg) / '_manifest.json'
+    return json.loads(p.read_text(encoding='utf-8')) if p.is_file() else {}
+
+
+def have(cfg=None) -> bool:
+    return path_of('WTG01', cfg).is_file()
+
+
+def _mask(ts: pd.Series, span) -> pd.Series:
+    """窗(含两端):span = ('YYYY-MM-DD', 'YYYY-MM-DD');末日晚 23:59:59 也算在内。"""
+    a, b = span
+    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 load(turbine: str, cfg=None, span=None, columns=None) -> pd.DataFrame:
+    """取一台窄仓:`span` 给了就按窗过滤(含两端),`columns` 是列白名单(缺列不报错)。"""
+    p = path_of(turbine, cfg)
+    if not p.is_file():
+        raise FileNotFoundError(f'slim10min 缺 {p} —— 先跑 python scripts/scada_slim_build.py')
+    want = None
+    if columns:
+        want = ['ts'] + [c for c in columns if c != 'ts']
+    df = pd.read_parquet(p, columns=want)
+    if span:
+        df = df[_mask(df['ts'], span)].reset_index(drop=True)
+    return df
+
+
+def loads(turbines, cfg=None, span=None, columns=None) -> dict[str, pd.DataFrame]:
+    """批量取(逐台一份,缺台**响亮**报错,不静默少台)。"""
+    out = {}
+    for t in turbines:
+        out[t] = load(t, cfg, span=span, columns=columns)
+    return out

+ 32 - 9
src/windscada/subsys/hydraulic.py

@@ -12,14 +12,26 @@ from ..data import load_10min
 
 ACC_CODES = {'3411': 'A缸超时', '3412': 'B缸超时', '3413': 'C缸超时', '7106': '泵超时', '7100': '液压过热'}
 
-def build_store(cfg=None, since='2026-01-01'):
+def build_store(cfg=None, since='2026-01-01', span=None, write=True):
+    """蓄能面登记件。`span=(起, 止)` 给了就按**所选时间窗**(含两端)重算(用户令 2026-09-21)。
+
+    span 模式走 `slim10min` 窄仓(同一份 `load_10min` 的列子集)且 **不写盘** —— 按窗重算是
+    服务时算给页面看的,不能覆盖正式产物 `hydraulic_accum.parquet`(原口径 = `since` 起)。
+    """
     cfg = cfg or farm()
     rows = []
     for t in cfg['turbines']:
         rec = dict(turbine=t)
         try:
-            d = load_10min(t, cfg, groups=['A.功率', 'B.变桨', 'B.温度NBM'])
-            d = d[d.ts >= since].sort_values('ts').reset_index(drop=True)
+            if span:
+                from .. import slim
+                d = slim.load(t, cfg, span=span,
+                              columns=['grd_wtc_ActPower_mean', 'prs_wtc_HydPress_mean',
+                                       'prs_wtc_HydPress_max', 'prs_wtc_HydPress_min',
+                                       'tmp_wtc_HydOilTm_mean']).sort_values('ts').reset_index(drop=True)
+            else:
+                d = load_10min(t, cfg, groups=['A.功率', 'B.变桨', 'B.温度NBM'])
+                d = d[d.ts >= since].sort_values('ts').reset_index(drop=True)
             # A2 同油温档带宽 (发电态)
             op = d[d['grd_wtc_ActPower_mean'] > 100].copy()
             op['band'] = op['prs_wtc_HydPress_max'] - op['prs_wtc_HydPress_min']
@@ -46,19 +58,30 @@ def build_store(cfg=None, since='2026-01-01'):
             rec['minp_p5'] = float(op['prs_wtc_HydPress_min'].quantile(0.05))
         except Exception as e:
             rec['err'] = str(e)[:60]
-        rows.append(rec); print(t, flush=True)
+        rows.append(rec)
+        if write:
+            print(t, flush=True)
     df = pd.DataFrame(rows)
     al = pd.read_parquet(pathlib.Path(cfg['store']) / 'alarms.parquet')
-    a = al[(al['t_on'] >= since)]
+    if span:                       # ★按窗:报警计数也只看窗内(原口径 = since 起)
+        a = al[(al['t_on'] >= str(span[0])) & (al['t_on'] <= str(span[1]) + ' 23:59:59')]
+    else:
+        a = al[(al['t_on'] >= since)]
     for code, name in ACC_CODES.items():
         cnt = a[a.code == code].groupby('turbine').size()
         df[name] = df['turbine'].map(cnt).fillna(0).astype(int)
-    df.to_parquet(pathlib.Path(cfg['store']) / 'hydraulic_accum.parquet')
-    print('→ hydraulic_accum.parquet', df.shape)
+    if write:
+        df.to_parquet(pathlib.Path(cfg['store']) / 'hydraulic_accum.parquet')
+        print('→ hydraulic_accum.parquet', df.shape)
+    return df
 
-def registry(cfg=None):
+def registry(cfg=None, span=None):
+    """蓄能面判级。`span=(起, 止)` 给了就按**所选时间窗**重算(判据不动,只换数据切片)。"""
     cfg = cfg or farm()
-    df = pd.read_parquet(pathlib.Path(cfg['store']) / 'hydraulic_accum.parquet')
+    if span:
+        df = build_store(cfg, span=span, write=False)
+    else:
+        df = pd.read_parquet(pathlib.Path(cfg['store']) / 'hydraulic_accum.parquet')
     # fleet 同档带宽基线
     import json as _json
     fleet_bins = {}

+ 24 - 5
src/windscada/subsys/pitch.py

@@ -23,7 +23,17 @@ DIMS = ['蓄能与压力调节', '变桨轴承与轮毂润滑', '执行与位置
 NORM_BAND, HUBLUB_FLEET_MED = 52.0, 171.0
 ZERO_GATE_STD, ZERO_ALARM, ZERO_WATCH = 0.25, 0.6, 0.3   # [暂行] 报警阈=3×2025离散度上界0.2°
 
-def _cur_win(d, days=60):
+def _cur_win(d, days=60, span=None):
+    """判级窗:默认 = 数据末端往前 `days` 天(原口径);`span=(a, b)` 给了就按**所选时间窗**(含两端)。
+
+    ★2026-09-21 用户令「判级也按所选时间窗重算」:本面输入 `pitch_daily.parquet` 是**日粒度**序列
+    (每台每日一行),所以按窗重算是**精确**的——只是换一个日期切片,判据不动。
+    """
+    if span:
+        a, b = span
+        lo, hi = pd.Timestamp(a).date(), pd.Timestamp(b).date()
+        dd = pd.to_datetime(d['date']).dt.date
+        return d[(dd >= lo) & (dd <= hi)]
     end = d['date'].max()
     return d[d['date'] >= (pd.Timestamp(end) - pd.Timedelta(days=days)).date()]
 
@@ -76,8 +86,12 @@ def _alarm_counts(win_start, win_end, store=None):
     return out
 
 
-def registry(cfg=None):
-    """变桨面登记表。`cfg` 给定时, 其 `store` 决定读哪个场的 alarms (见 _alarm_counts 的注释)。"""
+def registry(cfg=None, span=None):
+    """变桨面登记表。`cfg` 给定时, 其 `store` 决定读哪个场的 alarms (见 _alarm_counts 的注释)。
+
+    `span=(起, 止)` 给了就按**所选时间窗**(含两端)重算判级(用户令 2026-09-21);不给 = 原口径
+    (数据末端往前 60 天)。日粒度件 ⇒ 按窗重算是精确切片,判据/阈值一字未动。
+    """
     daily, zero = load()
     if not len(daily):
         # 缺"无生成端"的 pitch_daily (交付包不随产物 ⇒ 目标机上必然缺): 该面整体**不可判**,
@@ -85,7 +99,7 @@ def registry(cfg=None):
         # 页面据此显示"变桨面缺件: outputs/<场>/pitch/pitch_daily.parquet (docs §7)", 不静默造数。
         return pd.DataFrame(columns=['机组', '系统级', *DIMS]), {}
     daily['date'] = pd.to_datetime(daily['date']).dt.date
-    cur = _cur_win(daily).copy()
+    cur = _cur_win(daily, span=span).copy()
     # 语义修正 (2026-08-24 数据实逮): HydLevel/HydrFilt 开关 1=正常 → 报警秒 = 当日应测秒(n×600) − timeon;
     # 润滑泵/柱塞用窗内日均 (日中位恒0); PitchPum 使能位恒开=无判据力, 废弃
     cur['lvl_alarm'] = (cur['n'] * 600 - cur['hydlevel_timeon']).clip(lower=0)
@@ -122,7 +136,12 @@ def registry(cfg=None):
             zsum[t] = dict(months=int(len(g)), dev_med=float(g['zero_dev'].median()), last=float(g['zero_dev'].iloc[-1]),
                            dev_full_med=_full, dev_run_med=_run, n_alarm=_n_alarm,
                            last_month=str(g['month'].iloc[-1]))
-    win_end = daily['date'].max(); win_start = pd.Timestamp(win_end) - pd.Timedelta(days=60)
+    # 报警轴同窗(用户令 2026-09-21:按所选窗重算):span 给了就用 span 的两端,
+    # 否则仍是"数据末端往前 60 天"(原口径)。
+    if span:
+        win_start, win_end = pd.Timestamp(span[0]), pd.Timestamp(span[1])
+    else:
+        win_end = daily['date'].max(); win_start = pd.Timestamp(win_end) - pd.Timedelta(days=60)
     ac = _alarm_counts(win_start, win_end, store=(cfg or {}).get('store') if cfg else None)
     rows, detail = [], {}
     for t, r in fleet.iterrows():

+ 59 - 10
src/windscada/subsys/temp_nbm.py

@@ -58,13 +58,26 @@ def watchlist_check(cfg=None):
         out.append(f"{tag} {t} {ch.replace('tmp_wtc_','').replace('_mean','')} {m}: dev{dev:+.1f}K 位次{rank}/{n} (门=dev≥2.5K∨前3) | {why}")
     return out
 
-def build_store(cfg=None):
+def build_store(cfg=None, span=None, write=True):
+    """温度 NBM 比档件。`span=(起, 止)` 给了就把 `cur` 档换成**所选时间窗**(含两端,用户令 2026-09-21);
+    `ref`/`ref_sm` 两个对照窗保持不变;span 模式走 `slim10min` 窄仓且**不写盘**(不覆盖正式产物)。
+    """
     cfg = cfg or farm()
     tmps = [x for x in contracted_cols(cfg, groups=['B.温度NBM']) if x.endswith('_mean')]
     rows, failed = [], []
     for t in cfg['turbines']:
         try:
-            d = load_10min(t, cfg, groups=['A.功率', 'A.风况', 'A.转速', 'B.温度NBM'])
+            if span:
+                from .. import slim
+                # ★一次读、内存里切三窗(2026-09-21 性能实测): 三窗各读一次 = 3×38 次 parquet 读,
+                #   每台 261k 行 ⇒ 37 s/窗。改成"按列裁剪读一次 + 内存掩码切片" ⇒ 列少一半、读一次,
+                #   口径不变(掩码仍按各窗边界,逐行同源)。
+                _cols_t = ['ts', 'grd_wtc_ActPower_mean', 'tur_wtc_PowerRef_endvalue',
+                           'tur_wtc_SecAnemo_mean', 'tur_wtc_GenRpm_mean',
+                           *[c for c in tmps if c not in ('grd_wtc_ActPower_mean',)]]
+                d = slim.load(t, cfg, span=None, columns=_cols_t)
+            else:
+                d = load_10min(t, cfg, groups=['A.功率', 'A.风况', 'A.转速', 'B.温度NBM'])
         except Exception as e:
             # 守护失败必响亮: 原为裸 `except: continue`, 数据根整个搬走时会**静默**产出空仓
             # (2026-09-07 实测: 源盘路径失效 → 38 台全失败 → 0 行落盘, 无任何报错, 只能手工 debug)。
@@ -76,20 +89,45 @@ def build_store(cfg=None):
         # ★ref_sm (2026-09-07): 与 cur **同历月、早一年**的季节匹配窗, 供 common_shift 做季节解耦。
         #   为什么不沿用互斥 np.where: ref_sm 与 ref 有约一周重叠, 互斥赋值会**静默削掉** ref_sm 一截
         #   (先匹配者胜)。改为每窗独立取子集 → 允许重叠, 且 **ref/cur 的内容逐行不变** (加窗零副作用)。
-        sm_lo = str(pd.Timestamp(cur_lo) - pd.DateOffset(years=1))[:10]
-        sm_hi = str(d.ts.max() - pd.DateOffset(years=1))[:10]
+        #   span 模式: ref_sm 跟着**所选窗**早一年(季节匹配的是用户选的窗,不是"数据末端 90 天")。
+        if span:
+            sm_lo = str((pd.Timestamp(span[0]) - pd.DateOffset(years=1)).date())
+            sm_hi = str((pd.Timestamp(span[1]) - pd.DateOffset(years=1)).date())
+        else:
+            sm_lo = str(pd.Timestamp(cur_lo) - pd.DateOffset(years=1))[:10]
+            sm_hi = str(d.ts.max() - pd.DateOffset(years=1))[:10]
         d['pbin'] = pd.cut(d['grd_wtc_ActPower_mean'], PBINS).map(lambda iv: iv.mid if pd.notna(iv) else np.nan)
         d = d.dropna(subset=['pbin'])
         # 掩码必须在 dropna **之后**建: 先建会让布尔序列索引与 d 错位, pandas 靠索引静默重对齐
         # (UserWarning: Boolean Series key will be reindexed) —— 那是会出错的地方, 不留。
         # 窗边界仍取自 state 过滤后、pbin 过滤前的 tmax (与加窗前口径一致, 保 cur 逐行不变)。
+        # ★2026-09-21 用户令「判级按所选时间窗」: span 给了就把 `cur` 档换成所选窗(含两端),
+        #   `ref`/`ref_sm` 仍固定(季节解耦的对照窗必须钉住,否则失去对照意义)。
+        if span:
+            _a, _b = pd.Timestamp(span[0]), pd.Timestamp(span[1]) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)
+            cur_m = (d.ts >= _a) & (d.ts <= _b)
+        else:
+            cur_m = d.ts >= cur_lo
         wins = {'ref': (d.ts >= '2025-07-01') & (d.ts < '2026-01-01'),
-                'cur': d.ts >= cur_lo,
+                'cur': cur_m,
                 'ref_sm': (d.ts >= sm_lo) & (d.ts <= sm_hi)}
         for wname, m in wins.items():
             dw = d[m]
             if dw.empty:                      # 早期场可能没有早一年的数据 → 该窗缺, common_shift 会 raise 而非静默
                 continue
+            if span:
+                # ★按窗重算走**批量**分组(2026-09-21 性能实测: 逐通道 27 次 groupby/台 ⇒ 37 s/窗;
+                #   一次 groupby 全通道 ⇒ ~2 s。口径逐格相同: 同 median、同 size 门 ≥18,
+                #   只是行序不同 —— 默认路径保持原样不动,正式产物 byte 级不变。
+                cols = [c for c in tmps if c in dw.columns]
+                g = dw.groupby('pbin', observed=True)[cols].agg(['median', 'size'])
+                for ch in cols:
+                    sub = g[ch]
+                    sub = sub[sub['size'] >= 18]
+                    for pb, r in sub.iterrows():
+                        rows.append(dict(turbine=t, channel=ch, win=wname, pbin=float(pb),
+                                         med=float(r['median']), n=int(r['size'])))
+                continue
             for ch in tmps:
                 if ch not in dw.columns:
                     continue
@@ -98,7 +136,8 @@ def build_store(cfg=None):
                 for _, r in g.iterrows():
                     rows.append(dict(turbine=t, channel=ch, win=wname, pbin=float(r['pbin']),
                                      med=float(r['median']), n=int(r['size'])))
-        print(t, flush=True)
+        if write:
+            print(t, flush=True)
     if failed:
         print(f'⚠ {len(failed)}/{len(cfg["turbines"])} 台加载失败: '
               + '; '.join(f'{t} {e}' for t, e in failed[:3])
@@ -109,12 +148,22 @@ def build_store(cfg=None):
             f'源目录 {cfg.get("src_10min")} 是否存在? 不静默落空仓覆盖既有 store。'
             + (f' 首个错误: {failed[0][1]}' if failed else ''))
     st = pathlib.Path(cfg['store']); st.mkdir(parents=True, exist_ok=True)
-    pd.DataFrame(rows).to_parquet(st / 'temp_bins.parquet')
-    print('→ temp_bins.parquet', len(rows), f'(窗: {sorted({r["win"] for r in rows})})')
+    out = pd.DataFrame(rows)
+    if write:
+        out.to_parquet(st / 'temp_bins.parquet')
+        print('→ temp_bins.parquet', len(rows), f'(窗: {sorted({r["win"] for r in rows})})')
+    return out
 
-def registry(cfg=None, win='cur'):
+def registry(cfg=None, win='cur', span=None):
+    """温度 NBM 判级。`span=(起, 止)` 给了就**按所选时间窗**重算 `cur` 档(用户令 2026-09-21):
+    `ref`(2025-07~2025-12 干净窗)与 `ref_sm`(同历月早一年)仍是**固定基线**——
+    它们的作用是"季节解耦的对照窗",跟着用户窗漂移就失去对照意义;页面据此如实标注。
+    """
     cfg = cfg or farm()
-    tb = pd.read_parquet(pathlib.Path(cfg['store']) / 'temp_bins.parquet')
+    if span:
+        tb = build_store(cfg, span=span, write=False)
+    else:
+        tb = pd.read_parquet(pathlib.Path(cfg['store']) / 'temp_bins.parquet')
     tb = tb[tb.win == win]
     fleet = tb.groupby(['channel', 'pbin'])['med'].median().rename('fleet_med')
     tb = tb.join(fleet, on=['channel', 'pbin'])

+ 37 - 10
src/windscada/subsys/yaw.py

@@ -31,14 +31,29 @@ FAM = {
     'D4风传感': ['8108', '8110', '8184', '8185', '8186', '8187', '8171'],
 }
 
-def build_store(cfg=None):
+def build_store(cfg=None, span=None, write=True):
+    """偏航面登记件。`span=(起, 止)` 给了就按**所选时间窗**(含两端)重算(用户令 2026-09-21)。
+
+    span 模式下数据源换成 `slim10min` 窄仓(同一份 `load_10min` 的列子集 ⇒ 同列同值),
+    并 **不写盘**(`write=False`):按窗重算是"服务时算给页面看",不能覆盖正式产物
+    `yaw_daily.parquet`(那是"数据末端往前 90 天"的口径,缺了它其它消费者会变味)。
+    """
     cfg = cfg or farm()
     rows = []
     for t in cfg['turbines']:
         rec = dict(turbine=t)
         try:
-            d = load_10min(t, cfg, groups=['A.功率', 'B.偏航', 'B.润滑液压'])
-            cur = d[d.ts >= str(d.ts.max() - pd.Timedelta(days=90))[:10]]
+            if span:
+                from .. import slim
+                cur = slim.load(t, cfg, span=span,
+                                columns=['grd_wtc_ActPower_mean', 'prs_wtc_YawPress_mean',
+                                         'prs_wtc_YawPress_max', 'prs_wtc_YawPress_min',
+                                         'dot_wtc_YawLubPu_timeon', 'din_wtc_Y1LubPis_timeon',
+                                         'flg_wtc_ScYawOpe_counts', 'cnt_wtc_WindFauS_endvalue',
+                                         'cnt_wtc_WindFauT_endvalue'])
+            else:
+                d = load_10min(t, cfg, groups=['A.功率', 'B.偏航', 'B.润滑液压'])
+                cur = d[d.ts >= str(d.ts.max() - pd.Timedelta(days=90))[:10]]
             op = cur[cur['grd_wtc_ActPower_mean'] > 100]
             byday = cur.groupby(cur.ts.dt.date)
             rec['yawpress_med'] = float(op['prs_wtc_YawPress_mean'].median())
@@ -52,19 +67,31 @@ def build_store(cfg=None):
             rec['n_op'] = int(len(op))
         except Exception as e:
             rec['err10'] = str(e)[:60]
-        rows.append(rec); print(t, flush=True)
+        rows.append(rec)
+        if write:
+            print(t, flush=True)
     df = pd.DataFrame(rows)
     al = pd.read_parquet(pathlib.Path(cfg['store']) / 'alarms.parquet')
-    a26 = al[al['t_on'].dt.year == 2026]
+    if span:                       # ★按窗:报警族计数也只看窗内(原口径 = 2026 年全年)
+        a_w = al[(al['t_on'] >= str(span[0])) & (al['t_on'] <= str(span[1]) + ' 23:59:59')]
+    else:
+        a_w = al[al['t_on'].dt.year == 2026]
     for name, codes in FAM.items():
-        cnt = a26[a26.code.isin(codes)].groupby('turbine').size()
+        cnt = a_w[a_w.code.isin(codes)].groupby('turbine').size()
         df[name] = df['turbine'].map(cnt).fillna(0).astype(int)
-    st = pathlib.Path(cfg['store']); df.to_parquet(st / 'yaw_daily.parquet')
-    print('→ yaw_daily.parquet', df.shape)
+    if write:
+        st = pathlib.Path(cfg['store']); df.to_parquet(st / 'yaw_daily.parquet')
+        print('→ yaw_daily.parquet', df.shape)
+    return df
+
 
-def registry(cfg=None):
+def registry(cfg=None, span=None):
+    """偏航面判级。`span=(起, 止)` 给了就按**所选时间窗**重算(判据/阈值不动,只换数据切片)。"""
     cfg = cfg or farm()
-    df = pd.read_parquet(pathlib.Path(cfg['store']) / 'yaw_daily.parquet')
+    if span:
+        df = build_store(cfg, span=span, write=False)
+    else:
+        df = pd.read_parquet(pathlib.Path(cfg['store']) / 'yaw_daily.parquet')
     ep = pathlib.Path(cfg['store']) / 'yaw_err_clean.parquet'
     ERR = pd.read_parquet(ep) if ep.exists() else None
     med = df.median(numeric_only=True)

+ 13 - 6
src/windscada/taxonomy.py

@@ -59,8 +59,13 @@ def _load_tc(cfg):
     return _pd.read_parquet(f).set_index('turbine') if f.exists() else None
 
 
-def system_matrix(cfg=None):
-    """38台 × 7系统 状态 + 依据. 轴: 分系统模块(变桨/偏航/蓄能) + 温度通道归系统 + 润滑柱塞×fleet + 测风/通讯."""
+def system_matrix(cfg=None, span=None):
+    """38台 × 7系统 状态 + 依据. 轴: 分系统模块(变桨/偏航/蓄能) + 温度通道归系统 + 润滑柱塞×fleet + 测风/通讯.
+
+    `span=(起, 止)` 给了就**按所选时间窗重算判级**(用户令 2026-09-21,含两端):
+    四个判级面各自把它当判级窗(变桨=日粒度件切片、偏航/蓄能/温度=走 slim10min 窄仓重算;
+    温度的 ref/ref_sm 对照窗仍固定)。不给 = 各面原口径(近 60/90 日)。
+    """
     cfg = cfg or farm()
     TC = _load_tc(cfg)
 
@@ -116,10 +121,12 @@ def system_matrix(cfg=None):
     except Exception:
         STX, STXD = None, {}
     from .subsys import pitch as pitchmod, yaw as yawmod, hydraulic, temp_nbm
-    p_i = _load_reg(lambda: pitchmod.registry(), '变桨面 pitch_daily.parquet', '机组')
-    y_i = _load_reg(lambda: yawmod.registry(), '偏航面 yaw_daily.parquet', '机组')
-    h_i = _load_reg(lambda: hydraulic.registry(), '蓄能面 (液压深挖件)', 'turbine')
-    treg = temp_nbm.registry()
+    # ★2026-09-21 用户令「判级也按所选时间窗重算」: span 给了就逐面按窗重算(各面自己决定怎么按窗取数)。
+    #   四个面里只有"变桨"的输入是日粒度序列;偏航/蓄能/温度走 slim10min 窄仓重算(同一份 load_10min 的列子集)。
+    p_i = _load_reg(lambda: pitchmod.registry(span=span), '变桨面 pitch_daily.parquet', '机组')
+    y_i = _load_reg(lambda: yawmod.registry(span=span), '偏航面 yaw_daily.parquet', '机组')
+    h_i = _load_reg(lambda: hydraulic.registry(span=span), '蓄能面 (液压深挖件)', 'turbine')
+    treg = temp_nbm.registry(win='cur', span=span)
     tdf = treg[0] if isinstance(treg, tuple) else treg
     if tdf is None or not len(tdf):
         tdf = pd.DataFrame(columns=['turbine', 'channel', 'dev_K', '判'])

+ 42 - 6
src/windscada/ui/app.js

@@ -91,7 +91,10 @@
   // ── 顶 + 页签 ──
   function renderTop(d) {
     const months = BAKED ? [] : ((d && d.all_months) || []);
+    const isCustom = /^\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}$/.test(WIN);
+    const custOpt = isCustom ? `<option value="${esc(WIN)}" selected>${t('win.custom_sel', { a: WIN.split('~')[0], b: WIN.split('~')[1] })}</option>` : '';
     const opts = `<optgroup label="${t('win.presets')}">` + WIN_PRESETS.filter(([w]) => !BAKED || BAKED.includes(w)).map(([w]) => `<option value="${winSlug(w)}" ${w === WIN ? 'selected' : ''}>${winName(w)}</option>`).join('') + `</optgroup>`
+      + (custOpt ? `<optgroup label="${t('win.custom')}">${custOpt}</optgroup>` : '')
       + (months.length ? `<optgroup label="${t('win.months')}">` + months.slice().reverse().map(m => `<option value="${m}" ${m === WIN ? 'selected' : ''}>${m}</option>`).join('') + `</optgroup>` : '');
     // 静态快照下 ?lang= 不起作用 (点了只重载同一文件) —— 用 __PEER 指向对方语言的真实文件。
     // 没有 peer 就**不给**对方按钮: 一个点了没反应的切换比没有切换更糟。
@@ -125,11 +128,26 @@
         <button type="button" class="reselect" id="reselect" title="${t('ui.reselect')}">${t('ui.reselect')}</button>
         <label class="small muted" for="win">${t('win.label')}</label>
         <select id="win" aria-label="${t('win.label')}">${opts}</select>
+        ${SNAP ? '' : `<span class="winrng" title="${t('win.custom_hint')}">
+          <input type="date" id="wina" aria-label="${t('win.from')}" value="${(D && D.sysmx_span ? D.sysmx_span[0] : ((Q.get('wina') || '')))}">
+          <span class="muted">~</span>
+          <input type="date" id="winb" aria-label="${t('win.to')}" value="${(D && D.sysmx_span ? D.sysmx_span[1] : ((Q.get('winb') || '')))}">
+          <button type="button" id="winapply">${t('win.apply')}</button></span>`}
         ${showToggle ? `<div class="toggle" role="group" aria-label="language">
           <a class="${EN ? '' : 'on'}" href="${href('zh')}" style="padding:6px 12px;color:inherit">${EN ? 'ZH' : '中文'}</a><a class="${EN ? 'on' : ''}" href="${href('en')}" style="padding:6px 12px;color:inherit">EN</a></div>` : ''}
       </div>`;
     document.getElementById('reselect').addEventListener('click', () => { SYSV = null; TAB = 'overview'; location.hash = 'tab=overview'; renderTabs(); render(); const w = document.getElementById('win'); if (w) { w.focus(); } window.scrollTo({ top: 0, behavior: 'smooth' }); });
     document.getElementById('win').addEventListener('change', e => { WIN = winFromSlug(e.target.value); const u = new URL(location.href); u.searchParams.set('win', winSlug(WIN)); history.replaceState(null, '', u); load(); });
+    // ★自定义起止日期窗(含两端;用户令 2026-09-21):校验 a ≤ b、且不超出数据月范围,然后按同一路径重载
+    const _ap = document.getElementById('winapply');
+    if (_ap) _ap.addEventListener('click', () => {
+      const a = (document.getElementById('wina') || {}).value, b = (document.getElementById('winb') || {}).value;
+      if (!a || !b) { alert(t('win.need_both')); return; }
+      if (a > b) { alert(t('win.bad_order')); return; }
+      WIN = `${a}~${b}`;
+      const u = new URL(location.href); u.searchParams.set('win', WIN); u.searchParams.delete('wina'); u.searchParams.delete('winb');
+      history.replaceState(null, '', u); GEN.win = null; load();
+    });
     document.querySelectorAll('.toggle a.on').forEach(a => { a.style.background = 'var(--ink)'; a.style.color = '#fff'; });
   }
   function portalHref() { try { const q = new URLSearchParams(location.search).get('portal'); if (q) { localStorage.setItem('guanlan.portal', q); return q; } const s = localStorage.getItem('guanlan.portal'); if (s) return s; const r = document.referrer; if (r && !/\/v2(\?|#|$)/.test(r) && /门户|portal|_demo\/site|18084/.test(decodeURIComponent(r))) { localStorage.setItem('guanlan.portal', r); return r; } return '/'; } catch (e) { return '/'; } }   // 2026-09-06 用户批: 门户链接改指 /v2; 来路是门户时记住它作返回目标
@@ -684,7 +702,9 @@
     const d = D;
     if (SYSV) return sysDetail(d, SYSV);
     const SD = d.sysdist || {};
-    let h = `<section><div class="eyebrow">${t('cmp.by_system')}</div><div class="panel mb"><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px">`
+    // ★判级按所选时间窗重算是后台算(首次约 25 s)⇒ 先出上一口径并**在脸上写清**,轮询到就自动换掉
+    let h = (d.sysmx_pending ? `<section><div class="panel mb small" style="border-left:3px solid var(--accent)"><b>${t('cmp.grading_pending')}</b> ${t('cmp.grading_pending_why', { span: (d.sysmx_span || []).join(' ~ ') })}</div></section>` : '')
+      + `<section><div class="eyebrow">${t('cmp.by_system')}</div><div class="panel mb"><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px">`
       + SYS9.filter(s => SD[s]).map(s => { const dist = SD[s], a = dist['报警'] || 0, u = dist['不可判'] || 0, N = Object.values(dist).reduce((x, y) => x + y, 0) || 1;
         const col = a >= 8 ? 'var(--critical)' : a >= 3 ? 'var(--warning)' : a ? 'var(--accent)' : 'var(--good)';
         return `<div class="sysc" role="button" tabindex="0" data-sys="${esc(sysSlug(s))}" style="cursor:pointer;border-left:3px solid ${col}"><div class="t"><b>${sysName(s)}</b></div>
@@ -733,11 +753,13 @@
     if (TAB === 'system') render();
   }
 
-  // ── 发电性能: 固定干净窗; 控制参数一致性 (m9) + 曲线欠发候选 + 七镜头 19 图 (/api/curves, 懒取) ──
-  const GEN = { curves: null, err: null };
+  // ── 发电性能: 按所选时间窗重算 (2026-09-21 用户令"发电性能随时间窗变化");
+  //     控制参数一致性 (m9) + 曲线欠发候选 + 七镜头 19 图 (/api/curves?win=, 懒取; 首次后台算 → building 轮询) ──
+  const GEN = { win: null, curves: null, err: null, building: false, tries: 0 };
   function generation() {
     const d = D, M9 = (d.m9 && !d.m9.err) ? d.m9 : null, F = d.control || null;
-    let h = `<section><div class="eyebrow">${t('gen.eyebrow')}</div><div class="panel mb small" style="border-left:3px solid var(--warning)"><b style="color:#9C6410">${t('gen.fixed_win')}</b> ${t('gen.win_range')}<div class="muted" style="margin-top:3px">${t('gen.win_why')}</div></div>`;
+    const sp = (d.sysmx_span || []).join(' ~ ') || '';
+    let h = `<section><div class="eyebrow">${t('gen.eyebrow')}</div><div class="panel mb small" style="border-left:3px solid var(--accent)"><b>${t('gen.fixed_win')}</b> ${sp ? t('gen.win_range', { span: sp }) : ''}<div class="muted" style="margin-top:3px">${t('gen.win_why')}</div></div>`;
     if (d.m9 && d.m9.err) h += `<div class="panel mb" style="border-left:3px solid var(--critical)"><p class="small">${t('flt.block_err', { k: 'm9', e: dv(d.m9.err) })}</p></div>`;
     if (M9) { const b9 = M9.β_schedule || {}, outs = b9.离群 || [];
       h += `<div class="panel mb"><h3>${t('gen.ctrl')} <span class="small muted" style="font-weight:400">${t('gen.ctrl_win', { win: dv(M9.窗) })}</span></h3><div style="display:flex;flex-wrap:wrap;gap:18px">
@@ -747,13 +769,22 @@
     if (F && F.curve) h += `<div class="panel mb"><h3>${t('gen.cands')} <span class="small muted" style="font-weight:400">${t('gen.cands_meta', { note: dv(F.curve.note || ''), s: F.sigma || F.curve.sigma })}</span></h3><p class="small">${(F.curve.cands || []).map(c => `<a href="/turbine/${c.t}">${unitNo(c.t)}</a> ${c.dev}% ${dv(c.判)}`).join(' | ') || t('gen.no_cands')}</p></div>`;
     h += `<div class="eyebrow">${t('gen.figs')}</div>`;
     if (GEN.err) h += `<div class="panel" style="border-left:3px solid var(--critical)"><p class="small">${esc(GEN.err)}</p></div>`;
+    else if (GEN.building) h += `<div class="panel" style="border-left:3px solid var(--accent)"><p class="small"><b>${t('gen.recomputing')}</b> ${dv((GEN.curves && GEN.curves.note) || '')}</p><div class="skel" style="width:60%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div></div>`;
     else if (!GEN.curves) h += `<div class="panel"><p class="small muted">${t('gen.loading')}</p><div class="skel" style="width:60%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div></div>`;
     else h += `<div class="grid2">` + (GEN.curves.figs || []).map(f => `<div class="panel"><h3>${dv(f.title || '')}${f.unit ? ` <span class="small muted" style="font-weight:400">(${dv(f.unit)})</span>` : ''}</h3>${CH.multiline(Object.assign({ zero: false }, f))}${f.note ? `<p class="small muted" style="margin-top:6px">${dv(f.note)}</p>` : ''}</div>`).join('') + `</div>`;
     return h + `</section>`;
   }
   async function bindGeneration() {
-    if (GEN.curves || GEN.err) return;
-    try { GEN.curves = await (await fetch(`/api/curves${EN ? '?lang=en&keep=1' : ''}`)).json(); } catch (e) { GEN.err = t('ask.error', { e: e.message }); }
+    // 按窗缓存: 换窗就重取; 服务端首次是后台算(building=true)→ 定时轮询直到出图
+    if (GEN.win !== WIN) { GEN.win = WIN; GEN.curves = null; GEN.err = null; GEN.building = false; GEN.tries = 0; }
+    if (GEN.curves && !GEN.building) return;
+    try {
+      const qs = `?win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`;
+      const d = await (await fetch(`/api/curves${qs}`)).json();
+      GEN.curves = d;
+      GEN.building = !!d.building;
+      if (GEN.building && (GEN.tries = (GEN.tries || 0) + 1) <= 40) setTimeout(() => { if (TAB === 'generation' && GEN.win === WIN) { GEN.curves = null; render(); bindGeneration(); } }, 6000);
+    } catch (e) { GEN.err = t('ask.error', { e: e.message }); }
     if (TAB === 'generation') render();
   }
 
@@ -1037,6 +1068,11 @@
     const r = await fetch(`/api/fleet?win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`);
     D = await r.json();
     renderTop(D); renderTabs(); render();
+    // ★判级矩阵按窗重算是后台算的(首次约 25 s;用户令 2026-09-21)⇒ 页面先出上一口径并**如实标注**,
+    //   然后轮询直到按窗结果就位(同一窗只算一次,服务端常驻缓存)。
+    if (D.sysmx_pending && (window.__PENDING_TRIES = (window.__PENDING_TRIES || 0) + 1) <= 40) {
+      setTimeout(() => { if (D && D.sysmx_pending) load().catch(() => {}); }, 6000);
+    } else if (!D.sysmx_pending) { window.__PENDING_TRIES = 0; }
   }
   window.addEventListener('hashchange', () => { const m = location.hash.match(/tab=([a-z]+)/); const sv = (location.hash.match(/sys=([^&]+)/) || [])[1]; SYSV = sv ? sysFromSlug(decodeURIComponent(sv)) : null; if (m && TABS.includes(m[1])) { TAB = m[1]; renderTabs(); render(); } });
   renderTop(null); renderTabs();

+ 3 - 1
src/windscada/ui/snapshot.py

@@ -150,7 +150,9 @@ def bake(lang: str, ctx: dict, peer: str = '', noindex: bool = False, slim: Opti
     active_wins = WINS[:1] if (en and slim) else WINS
     for w in active_wins:
         _bake(_nk('/api/fleet', **lp(win=w)), lambda w=w: ctx['fleet_view'](w))
-    _bake(_nk('/api/curves', **lp()), ctx['curve_view'])
+    # 曲线按窗重算(2026-09-21 用户令「发电性能随时间窗变化」):离线快照只烤**首个窗**
+    # (每窗 10~45 s,全烤 6 窗会让打包多等几分钟;页面选其它窗时按"未烤 ⇒ 走实时接口"回落)。
+    _bake(_nk('/api/curves', **lp(win=WINS[0])), lambda: ctx['curve_view'](WINS[0]))
     _bake(_nk('/api/maint_survey', **lp()), lambda: dict(rows=ctx['survey']()))
     _bake(_nk('/api/vibcms', **lp()), ctx['vibcms_results'])
     om = ctx['ont']

Деякі файли не було показано, через те що забагато файлів було змінено