Prechádzať zdrojové kódy

2.9.0 补二:M9 控制参数按窗 + 远端'停不掉子进程'处置

- control.registry(span=…) 按窗重算(此前 M9 就摆在发电性能页上却钉死 2025-H2):
  build_store 支持 span(走 slim10min、不写盘),窄仓补 grd_wtc_ActPower_max / tur_wtc_GenRpm_max 两列;
  实测 ≈0.6 s/窗,P封顶中位 4190(干净窗) → 4181.7(2026年) / 4177.5(2026Q1)
- fleet 响应加 m9_pending / win_pending;v2 前端轮询改为 win_pending||sysmx_pending||m9_pending(5 s),
  控制参数块 pending 时挂'正在按窗重算…'小标
- 远端实逮: service_ctl.py stop 停不掉子进程(旧进程按端口占着 18033、命令行不带安装目录 ⇒
  按名字过滤的清理漏掉它)⇒ 新文件传了却仍跑旧代码;处置=按端口属主强杀再起服务
- 远端终检: fleet 两窗对比只剩元数据不变(all_months/note/*_pending),control/faults/fus/kpi/m8/m9/
  rel/sysdist/systems/watch 等 13 块全部随窗
zhouyang.xie 2 týždňov pred
rodič
commit
5a2ddea1ba

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
docs/版本记录.md


+ 2 - 0
scripts/scada_slim_build.py

@@ -47,6 +47,8 @@ GROUPS = ['A.功率', 'A.风况', 'A.转速', 'B.变桨', 'B.温度NBM', 'B.偏
 # 公共列子集 = 四个判级面 + 七镜头曲线实际取用的列(逐条对着源码点名,别用整组,整组 128 列太肥)
 COLS_CORE = [
     'grd_wtc_ActPower_mean',           # 功率: 五态/pbin/发电态门
+    'grd_wtc_ActPower_max',            # 封顶参数 M9: p_cap = 99.5 分位
+    'tur_wtc_GenRpm_max',              # 封顶参数 M9: w_cap
     'tur_wtc_PowerRef_endvalue',       # 限电命令面: perf.curtail.classify 的必需列 (缺它直接 KeyError)
     'tur_wtc_SecAnemo_mean',           # 机舱风: 曲线 X 轴 + 低风待机门 (活风源)
     'tur_wtc_GenRpm_mean', 'tur_wtc_MainSRpm_mean',                     # 转速: 曲线/转矩/λ

+ 19 - 4
scripts/windscada_serve.py

@@ -795,9 +795,9 @@ def span_of(win):
 # 页面轮询再取**。同一窗只算一次;算完常驻内存(进程内)。
 import threading as _thr
 
-_WIN_CACHE: dict = {'sysmx': {}, 'curves': {}}
-_WIN_BUSY: dict = {'sysmx': set(), 'curves': set()}
-_WIN_ERR: dict = {'sysmx': {}, 'curves': {}}
+_WIN_CACHE: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
+_WIN_BUSY: dict = {'sysmx': set(), 'curves': set(), 'm9': set()}
+_WIN_ERR: dict = {'sysmx': {}, 'curves': {}, 'm9': {}}
 _WIN_LOCK = _thr.Lock()
 # ★重活**串行**(2026-09-21 服务器实逮): 预热与请求各自起线程 ⇒ 同时跑两个按窗重算(各几百 MB 级
 #   中间帧),小内存机器上进程被系统杀掉(日志停在"按窗重算启动"两行、随后网关 503)。
@@ -892,6 +892,18 @@ def curves_of(win):
     st = _win_get('curves', win, lambda: _cv.build_store(CFG, span=(a, b), write=False))
     return st, st is None
 
+
+def m9_of(win):
+    """→ (控制参数一致性, pending)。按窗重算(走窄仓 ≈5 s/窗);干净窗直接用正式产物。"""
+    from src.windscada.perf import control as _cm
+    a, b = span_of(win)
+    if (a, b) == (_cm.WIN[0], '2025-12-31'):
+        return _cm.registry(CFG), False
+    v = _win_get('m9', win, lambda: _cm.registry(CFG, span=(a, b)))
+    if v is None:
+        return _cm.registry(CFG), True          # 未就位: 先给上一口径, 页面按 pending 标注
+    return v, False
+
 TS = {'报警': 0, '危险': 0, '良好': 1, '不可判': 2, '优秀': 3}
 
 # ---------- 图规格 (分册两型) ----------
@@ -1363,9 +1375,10 @@ def fleet_view(win):
     try:
         from src.windscada.perf import control as cmod
         if (ST / 'control_profile.parquet').exists():
-            m9 = cmod.registry()
+            m9, m9_pending = m9_of(win)          # ★按窗重算(用户令 2026-09-21)
     except Exception as e:
         m9 = dict(err=str(e)[:120])
+        m9_pending = False
     try:
         from src.windscada.perf import reliability as relmod
         rel = relmod.overview(CFG, span=span_of(win))
@@ -1550,6 +1563,8 @@ def fleet_view(win):
                 systems=systems, sysdist=sysdist, watch=watch, kpi=kpi, rel=rel, fus=fus, faults=faults, control=control, m8=m8, m9=m9,
                 # ★2026-09-21: 判级轴现在**按所选窗重算**;首次是后台算,这一份仍是旧口径 ⇒ 如实标 pending
                 sysmx_pending=bool(sysmx_pending),
+                m9_pending=bool(m9_pending),
+                win_pending=bool(sysmx_pending or m9_pending),
                 sysmx_span=list(span_of(win)),
                 note=('判级轴(变桨/偏航/蓄能/温度)按所选窗重算;曲线按所选窗重算;'
                       '故障统计/五态/温度月轨迹/停机台账=所选窗真窗'

+ 325 - 321
src/version.py

@@ -1,321 +1,325 @@
-# -*- coding: utf-8 -*-
-"""版本与安装信息的**唯一真源**(2026-09-17 用户令 1:安装时检查已装版本、提示差异、问是否重装)。
-
-为什么单开一个模块:以前版本号散在三处(`scripts/pack_dist.py` 的 `VERSION`、README 抬头、说明书标题),
-改一处忘一处就会出现"包说 0.4.0、安装记录说 0.2.0"这种对不上的事。现在:
-
-    src/version.py            ← 唯一真源(本文件)
-    scripts/pack_dist.py      ← 读它写进 dist-manifest.json 与默认包名
-    install.ps1 / install.sh  ← 读它写进 <安装目录>/install-info.json,并与已装的比对
-    guanlan.py / 各文档        ← 读它(文档里的人工版本号以它为准)
-
-`install-info.json` 落在安装根本身(不在 run/ 里):它是**交付物级别的安装记录**,
-卸载/重装/拷机都要跟着走,所以和 `configs/`、`dist-manifest.json` 同级。
-"""
-from __future__ import annotations
-
-import datetime as dt
-import json
-import pathlib
-
-NAME = '观澜·如东样板 v2'
-VERSION = '2.9.0'          # ★ 改版本只改这里
-EDITION = 'offline-single-package'
-PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
-
-INSTALL_INFO = 'install-info.json'      # 相对安装根
-SERVICE_NAME = 'guanlan'                # Windows 服务名 / systemd 单元名(用户令 1)
-SERVICE_DISPLAY = '观澜·如东样板 v2 (Guanlan Wind Asset Intelligence)'
-
-# ── 版本号规则(用户令 2026-09-17)────────────────────────────────────────────────
-#   编号:  v<大版本号>.<中版本号>.<小版本号>          例: v2.5.0
-#   定义:  大版本号 —— 系统解决方案、架构或核心功能改变
-#          中版本号 —— 非核心功能新增、减少、修改
-#          小版本号 —— 消缺完善
-#   包名:  打包文件名 = app_guanlang_v<版本号>.zip   例: app_guanlang_v2.5.0.zip
-#   用法:  用户/发布者定"这次算哪一级"→ VERSION 改一行 → 打包器与安装脚本自动跟着变
-#          (级别判据见 BUMP_RULE;一次发布混了几类就取最高那一级)
-RULE = 'v<大版本号>.<中版本号>.<小版本号>'
-LEVEL_MEANING = {
-    'major': '大版本号 —— 系统解决方案、架构或核心功能改变',
-    'minor': '中版本号 —— 非核心功能新增、减少、修改',
-    'patch': '小版本号 —— 消缺完善',
-}
-BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);'
-             '落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);'
-             '只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级')
-
-# 版本记录(人读的那份由 scripts/version_log.py 生成到 docs/版本记录.md;这里只有事实,不重复描述)
-#   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 全绿;'
-              '⑦ 远端部署(D:\\产品\\app,2026-09-21)实逮两处并已修: (a) 预热与请求各自起线程 ⇒ 同时算两份'
-              '按窗中间帧,改为**重活全局串行**(`_HEAVY_LOCK`)并打印可用内存;(b) 退化窗("近30日"=2026-09'
-              '只有 1 个节拍 ⇒ 比档件为空)时 `groupby().apply()` 返回空表 ⇒ `.rename("dev_K")` 抛 TypeError 把'
-              '整面判级打断,改为**如实"不可判(样本不足)"**;另: 远端进程须由 Windows 服务 `guanlan` 托管'
-              '(SSH 会话里起的进程会随会话关闭而死,实测全端口消失 ⇒ 已装服务并 RUNNING),'
-              '远端核验: fleet 两窗 pending→就位、变桨报警 15→7 / 偏航 15→3、rel 窗随窗、'
-              'curves 图注"窗 2026-07-01 ~ 2026-07-31 (随所选窗)"、v2 页含自定义起止控件'),
-    dict(version='2.8.2', date='2026-09-21', level='patch',
-         title='消缺:扫描器的"变化"判据被两类**非摄入件**常年占住 —— 现场按类目交付的月度通道组库'
-               '(`scada_mdb/<年>年/<月>月/<年>-<月>-<类>.mdb`)被当成"数据没人读·缺口",用户指定的按月提取件'
-               '(`<场站>/scada_10min_<YYYYMM>.csv`)被当成"未归类";两者每次扫描都计一次变化 ⇒ '
-               '`raw_scan --check` 永远 rc=4,`rebuild_all.py --auto` 与 raw_watch 会**每轮都以为来了新数据**',
-         note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
-              '① `CONSUME` 新增 `upstream` 口径(信息级):类目库是 10min 同台补充件的**上游**,'
-              '取数层不直接读它 ⇒ 不再算缺口、不计变化,改按"上游归档件(要先转换才被消费)"列出并写清转换口径; '
-              '② 新增 `IGNORE_PATTERNS`:`scada_\\d+min_\\d{6}\\.csv` 是按月提取/核对件(位置由用户指定、'
-              '不是摄入源)⇒ 不报未归类、不计变化;'
-              '③ 实测(2026-09-21 落位 7-8 月交付后):修前 `--check` rc=4 且每轮都报"2 处变化",'
-              '修后 `--check` rc=0「与上次快照一致,没有新数据」,`--selftest` 通过,24 件类目库如实以信息级列出; '
-              '④ 口径写进 `docs/输入数据放置指导_v0.1.md` §4.1(含 12 组中文目录↔类目码、9 类↔主件 595 通道一一对应、'
-              'float32+ISO 对齐、以及"类目库→同台补充件"的落位转换)'),
-    dict(version='2.8.1', date='2026-09-20', level='patch',
-         title='消缺:重算链末步「台账等价验收」在全新机器上必然 rc=5(交付包不含产物 ⇒ 没有随包基线),'
-               '原先把它判成失败 ⇒ 门户上写"重算失败"(实测服务器 2.8.0 一轮 24/24 步全 OK 却整轮 rc=1)',
-         note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
-              '① `rebuild_all.py` 的 ⑧ 台账等价验收由 `tolerate=(4,)` 改成 `tolerate=(4, 5)` —— '
-              'rc=5 = "找不到随包基线 ⇒ 没做验收",与 ④ 的口径对齐(④ 一直容忍 5 并写明"跳过等价验收≠通过");'
-              '步名与步说明都改写清楚"rc=4 = 有需人工看的差异 / rc=5 = 没基线 ⇒ 没验收(不是失败,也不是通过)",'
-              '并指路 `scripts/set_baseline.py`(用已核实的当前产物快照登记基线,之后这一项才会真比对); '
-              '② 远端复核(服务器 D:\\产品\\app): 该轮 24 个实质步骤全绿(①b 扫描 53.7s · ② 164s · ③ 1025s · '
-              '④ 99s · ④b 2118s · ④c 385s · ④d 41.6s · ④e 15.3s · ⑤b/⑤c/⑤a/⑤ · ⑥ · ⑦×6 · ⑦b 78s · ⑧ 7.1s · '
-              '⑧b 3s · ⑧c 9.5s),只有第 25 步判失败;且同批数据已真进产物:`temp_monthly` 21 个月、末四格 '
-              '2026-06/07/**08**/09、2026-08 = 1,026 行(服务器 `scada_10min` 也有 76 件含 38 个 `WTG??-B2.csv` '
-              '同台补充件,说明步骤 B 的同台多件合并已在服务器上生效); '
-              '③ 已把修好的 `scripts/rebuild_all.py`(+ `src/version.py` / `docs/版本记录.md`)单文件部署到服务器,'
-              '无需重装整包。交付包 app_guanlang_v2.8.1.zip'),
-    dict(version='2.8.0', date='2026-09-20', level='minor',
-         title='输入数据自动扫描识别:<安装目录>/data/raw 逐族指纹 → 发现新增/变化 → 指明该跑哪几步;'
-               '重算链加 ①b 步 + `rebuild_all.py --auto`(新数据不被 --skip-* 漏掉)+ 服务侧可选看门狗',
-         note='非核心功能新增 ⇒ 中版本 +1(无架构改动)。要点: '
-              '① 新增 `scripts/raw_scan.py`(用户令 2026-09-19「对 <安装目录>/data/raw 目录下的接入数据处理,'
-              '增加自动扫描识别机制,以能发现新增数据,并纳入重算」):逐族指纹 = 件数/体积/最新落盘时间/'
-              '清单摘要(`--deep` 再叠内容哈希),与快照 `outputs/<场>/_raw_scan.json` 比;'
-              'rc: 0 无变化 · 4 有新增/变化 · 5 还没有基线; `--write` 记基线, `--json` 给机器读, '
-              '`--selftest` 对账"族表覆盖反查族表里所有 raw 输入 + 步骤名都能在链上找到"(防两处漂移); '
-              '② 族表把每个 raw 子目录映射到链上步骤(故障报警/工单/油样→②;scada_10min→③④④c⑤b;'
-              'scada_1min→④c;scada_mdb→③④④c;windcms→④b④d④e;m5_cms_tcm→④b;西门子4.0技术资料→⑦),'
-              '`data/raw/<场>/` 下出现族表没有的目录则如实报"未归类、没有已知消费者"(不猜); '
-              '③ 进重算链作 **①b 步**(`--check --write`,容忍 rc=4/5,并在步说明里写清"4 = 有新数据不是失败"); '
-              '④ `rebuild_all.py --auto`:先扫一遍,新数据落在被 `--skip-scada`/`--skip-vib` 跳过的族里就'
-              '**自动取消跳过**(实测: scada_10min 新增 1 件 → 计划从 22 步变 24 步并打印取消原因); '
-              '⑤ 服务侧看门狗(`scripts/service_worker.py::raw_watch`,配置 `configs/serve.json` 的 `raw_watch`,'
-              '**默认关**):每 N 分钟扫一次,有新数据写 logs/service.log 并列出该跑的步;`auto_rebuild=true` '
-              '时顺手发起一次重算(走 ops 同一条路;`run/ops_job.json` 显示在跑则不重复发起)。'
-              '★2026-09-20 按用户令「对 data/raw(**含嵌套子目录**)下文件的增减做到监听;重算要按该目录'
-              '**最新的变化**算」逐条查证并补了三处: '
-              '⑥ 扫描改成**递归统计全部文件**(不限扩展名; 白名单命中的另记 matched, 差额单列为"信息,不计变化")'
-              '+ **子目录清单进指纹**(新建空目录也发现) + 未归类改成**全树逐件**找(不再只看一层); '
-              '⑦ **产物 = 当前源件的函数**(删/换源件后, 那些行不再产出并大声报出"哪几件不在盘、少多少行、'
-              '涉及哪些月"): 报警/工单/油样三个摄入器原先是"只替换本次涉及的件、其余原样保留" ⇒ 源件删了行还在; '
-              '⑧★报警台账改**键集合并集**去重(键=Name/Alarmcode/TimeOn, 归属取最窄的源件)—— 原先按"逐件累加、'
-              '没新键就跳过"的贪心判快照, 实测**删一个源件反而让总行数从 39,211 涨到 56,593**(被跳过的大快照件'
-              '整件摄入, 重复计数) ⇒ 既不是源集的函数也会虚增; 现在 39,211 键稳定、重复 3.6 万行如实去掉; '
-              '⑨ 振动窗: 同名窗已存在时原来一律改名 `_reimport`(被 EXCLUDE_DEFAULT 排除 ⇒ **补进来的 CMS 数据'
-              '根本进不了生产集**), 现在默认**替换同名窗**(旧窗留档 `_superseded_<时分>`, 已加入排除表; '
-              '要旧行为用 `--reimport-as-new`)。'
-              '★另按用户令「按你的建议执行 先 C 再 B」补完"同台多件 10min 进不来"这条链: '
-              '⑩ **步骤 C(让"放了没人读"当场可见)**:`raw_scan.py` 新增 `CONSUME` 表(逐族写清**消费者真实'
-              '取数口径**),判据从"扩展名白名单没命中"改成"**全部文件里没被消费者读的**"—— 实测 `data/raw/'
-              '如东/scada_10min/WTG01-B2.csv`(856 列 / 2026-08 数据)扩展名 `*.csv` 命中白名单,旧判据'
-              '**永远发现不了**它;现在报成**缺口级**(计入变化 rc=4 + ★[缺口] 块写明消费者口径与件例),'
-              '附件类(.rar/.jpg/厂商软件)仍只列信息;建议行也分流说明"缺口类重算也纳入不了"。'
-              '`raw_data_check.py` 同步: 同台补充件不再误报"文件名不是本场机组",改为 info 行。'
-              '⑪ **步骤 B(真正把新月份的 10min 件吃进来)**:`scada_source.load` 取数从"只认 `<台号>.csv`"'
-              '扩到 **`<台号>.csv` + 同台补充件 `<台号>-*.csv`/`<台号>_*.csv`**:按列名对齐(新导出把类型前缀'
-              '去掉了: `din_wtc_HydLevel_timeon` → `wtc_HydLevel_timeon`,实测老件 598 列里 597 列能这样对上)、'
-              '按时间戳排序去重(**主件优先**,重叠条数写进 `attrs.scada_overlap` 并打印)、来路件名与各件行数'
-              '写进 `attrs.scada_files/scada_rows`。实测 WTG01:主件 77,551 行 + 补充件 5,820 行 → 合并 83,353 行'
-              '(重叠 18 行去重),时间范围由 2026-07-07 延到 **2026-09-01**,2026-08 的 5,819 行关键通道'
-              '非空率 77%(**空白是源件自带的**:该月 144 行/日齐全但约 23% 行的通道为空,如实保留 NaN)。'
-              '交付包 app_guanlang_v2.8.0.zip'),
-    dict(version='2.7.0', date='2026-09-19', level='minor',
-         title='远程部署消缺:振动窗发布被杀软/索引占用不再打断整条重算链(+ --publish-only 恢复通道);'
-               '门户可对外监听(public_host,组件仍只本机);GBK 控制台打印不炸',
-         note='一次消缺 + 一项非核心功能新增(对外监听)⇒ 按规则取最高一级(minor)。要点: '
-              '① ★远端实逮(服务器 D:\\产品\\app):索引 719 s + 谱 894 s 落进 windows\\_staging_ingest 后,'
-              '`staging.rename(final)` 回 `PermissionError: [WinError 5] 拒绝访问`(360 实时扫描/索引器持着'
-              '刚写的 1710 件小文件的句柄,Windows 的目录改名要求树里无打开句柄)⇒ ④b 失败 ⇒ 链停在第 4 步,'
-              '页面表现为「需要关注 0 台 + 融合面缺件」。修法:发布改 `_move_tree()`(整目录 rename 退避重试 '
-              '→ 逐件搬,rename 不行就 copy+删 → 仍锁住的逐条如实报出);并加 `--publish-only`:已算完的暂存窗'
-              '可直接发布,不必白等 25 分钟重跑索引/谱; '
-              '② 用户令「观澜改为监听所有 IP」:`configs/serve.json` 增 `public_host`(空=跟 host=只本机;'
-              '设 0.0.0.0=所有网卡),**只有门户网关**用它,detail/cms/viewer/sim 仍绑 127.0.0.1 ⇒ 对外只有 '
-              '28084 一个入口;启动时打印对外地址并提示"页面无鉴权,请在防火墙侧限来源"; '
-              '③ 消缺: 新生成端在 GBK 控制台打印 m/s² / ⇒ 时抛 UnicodeEncodeError(远端 baseline_38 整件没出)'
-              '⇒ 四个生成端加 stdout 守卫;trend_ingest 的"台账止 2024-11"过期断言改实话;'
-              'CMS 三层基线卡的"健康期窗 N"重复措辞; '
-              '④ 部署口径: 用 SSH 起的服务在会话断开时会被一起收掉 ⇒ 现场改注册 Windows 服务(`guanlan`)'
-              '(`scripts/service_ctl.py install`),开机自启、SCM 崩溃重启。交付包 app_guanlang_v2.7.0.zip'),
-    dict(version='2.6.0', date='2026-09-19', level='minor',
-         title='「所有的计算均要形成观澜的源代码」:融合面/总览页/事实契约/发布层/掩码阈值/六层链两步/'
-               '变桨面/振动在升与三层基线 全部落成观澜自算;SCADA 接入兼容 CSV+MDB;'
-               '交付包带输入数据目录结构(不含文件)',
-         note='功能新增为主、夹带多项消缺 ⇒ 按规则取最高一级(minor)。要点: '
-              '① 六层链 model_run/fusion 两步按口径重建(报告的"融合级"列从此有值)+ energy_share 步补上 + '
-              '峰值拾取口径定案(观澜口径,四件 *_freq_scan 形式不再复刻); '
-              '② 融合面 handoff 与总览页 windscada/index.html 由观澜自算生成(原为"包内无生成端",'
-              '新机器上 /detail/v2 的"需要关注/全场状态"必空白); '
-              '③ 事实契约改为**从重算台账生成 claim**(门户结论段/问答/报告随重算刷新,/api/facts 不再 503); '
-              '④ 本体发布层 r1/r2、TCM 掩码阈值(观澜自算口径,非厂商)分别落成生成端; '
-              '⑤ SCADA 接入统一取数层兼容 CSV 与 MDB(含老库列位错位如实拒绝;37 个月库按修好的建库脚本'
-              '重建并逐分片对拍); '
-              '⑥ 变桨面 pitch/** 落成生成端(10min 开关量/压力锯齿 + 1min 桨距角;零位口径按用户令'
-              '**停机段/满发段分列**,并据实测反推补上**运行段同工况分档**这一真正的判据轴 —— 它能把'
-              '现场确诊的 19# 单独拎出来); '
-              '⑦ 振动 component_history.json(在升/换件闭环)与 baseline_38.json(自/机群/绝对三层基线)'
-              '落成生成端(窗数不足时如实空表并写明数据边界); '
-              '⑧ 打包:**带输入数据目录结构**(30 个空目录, 含 data/raw/西门子4.0技术资料)+ 放置说明文件随包, '
-              '仍不含数据文件; ⑨ 消缺: report 步踩可选包 tabulate 致整链中断、语言包闸拦下的 /v2 500、'
-              '振动页看不出数据区间与"3 月数据消失"(趋势按日聚合)、账目单位错(kWh/MW)、'
-              'Access 锁文件误报、日志保留策略等。交付包 app_guanlang_v2.6.0.zip'),
-    dict(version='2.5.0', date='2026-09-17', level='minor',
-         title='卸载闭环(uninstall.bat / uninstall.sh)+ 版本管理与打包命名规则 + 版本记录',
-         note='二代架构(大版本 2,与 NAME 里的 v2 对齐)的第 5 次功能性发布,无消缺项;'
-              '交付包 app_guanlang_v2.5.0.zip'),
-    dict(version='0.4.0', date='2026-09-17', level='legacy',
-         title='打包默认不含 输入数据/产物/日志/临时文件;无窗口启动(去掉 VBScript);统一配置与日志;'
-               '页面归口审计;输入数据放置检查;安装时版本检查与服务化',
-         note='旧编号体系;交付包 app_guanlan_v2_0.4.0.zip(= guanlan-v0.4.0_dist_20260917.zip)'),
-    dict(version='0.2.0', date='2026-09-01', level='legacy',
-         title='含产物的旧交付基线(历史上用作"包内无生成端"那批产物的补救源;该用途 2026-09-19 起已失效)',
-         note='旧编号体系;交付包 guanlan-rudong-v2_0.2.0_test_win64.zip —— ★2026-09-19 用户令从工作树'
-              '清理(943 MB):① 「所有的计算均要形成观澜的源代码」落地后无生成端件 = 0(反向呼应审计 '
-              '1,800 件全部 raw-derived)⇒ 它作为"补救源"的用途消失;② 该包仍留在 git 历史里'
-              '(blob 可随时 git 取回),需要时用 git show 恢复即可'),
-)
-
-
-def package_name(version: str | None = None, ext: str = 'zip') -> str:
-    """交付包文件名(用户令 2026-09-17 的命名规则):`app_guanlang_v<版本号>.zip`。"""
-    return f'{PACKAGE_STEM}_v{version or VERSION}.{ext}'
-
-
-def level_of(old: str, new: str = VERSION) -> str:
-    """两次版本之间是"哪一级"的变化 → major / minor / patch / same / ?(版本管理的机器判据)。"""
-    a, b = _parts(old), _parts(new)
-    if a == b:
-        return 'same'
-    if len(a) < 3 or len(b) < 3:
-        return '?'
-    if b[0] != a[0]:
-        return 'major'
-    if b[1] != a[1]:
-        return 'minor'
-    return 'patch'
-
-
-def rule_lines() -> list[str]:
-    """把版本规则印出来(README / 版本记录 / `guanlan.py version` 共用同一份措辞)。"""
-    return [f'编号: {RULE}   例: v{VERSION}',
-            *[f'  {LEVEL_MEANING[k]}' for k in ('major', 'minor', 'patch')],
-            f'包名: 打包文件名 = {package_name()}']
-
-
-def info_path(root: pathlib.Path) -> pathlib.Path:
-    return pathlib.Path(root) / INSTALL_INFO
-
-
-def read_installed(root: pathlib.Path) -> dict | None:
-    """→ 已安装记录 dict;没装过就是 None(`install-info.json` 缺失或坏了都算没装)。
-
-    ★ 用 `utf-8-sig` 读:本器写的是无 BOM 的 UTF-8,但**别的工具/手改**过这份 json 时可能带 BOM
-      (实测 PowerShell 的 `Set-Content -Encoding UTF8` 就带 BOM)。带 BOM 而按 utf-8 读会抛异常,
-      于是"明明装了却报没装过" —— 版本检查直接失灵,所以这里按最宽的方式读。
-    """
-    p = info_path(root)
-    if not p.is_file():
-        return None
-    try:
-        d = json.loads(p.read_text(encoding='utf-8-sig'))
-        return d if isinstance(d, dict) else None
-    except Exception:
-        return None
-
-
-def write_installed(root: pathlib.Path, python: str = '', mode: str = 'manual', extra: dict | None = None) -> dict:
-    """写安装记录(安装脚本在装完时调用)。mode: manual | windows-service | systemd | startup-entry。"""
-    root = pathlib.Path(root)
-    rec = dict(name=NAME, version=VERSION, edition=EDITION, installed_at=dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
-               python=python or '', mode=mode, host=str(root))
-    rec.update(extra or {})
-    info_path(root).write_text(json.dumps(rec, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
-    return rec
-
-
-def _parts(v: str) -> tuple:
-    out = []
-    for seg in str(v).strip().lstrip('vV').split('.'):
-        num = ''.join(ch for ch in seg if ch.isdigit())
-        out.append(int(num) if num else 0)
-    return tuple(out)
-
-
-def compare(installed: dict | None, pkg_version: str = VERSION) -> dict:
-    """安装检查的核心:已装版本 vs 本包版本 → 判断该提醒什么。
-
-    → dict(state, installed_version, pkg_version, message, action)
-       state: none(没装过)· same(同版本)· older(已装更旧,本包是升级)·
-              newer(已装更新,本包更旧 —— 要特别提醒别降级)· unknown(老安装没写版本记录)
-       action: install(照常装)· ask(问用户是否重装)· warn(要用户明确确认)
-    """
-    cur = (installed or {}).get('version') or ''
-    if not installed:
-        return dict(state='none', installed_version='', pkg_version=pkg_version,
-                    message=f'未检测到已安装的{NAME}(将全新安装 v{pkg_version})', action='install')
-    if not cur:
-        return dict(state='unknown', installed_version='', pkg_version=pkg_version,
-                    message=f'检测到安装目录已有部署,但没有版本记录(老版本安装)'
-                            f'—— 本包是 v{pkg_version},将按"重装/升级"处理', action='ask')
-    if _parts(cur) == _parts(pkg_version):
-        return dict(state='same', installed_version=cur, pkg_version=pkg_version,
-                    message=f'已安装 {NAME} v{cur},与本包 v{pkg_version} **版本相同**', action='ask')
-    if _parts(cur) < _parts(pkg_version):
-        return dict(state='older', installed_version=cur, pkg_version=pkg_version,
-                    message=f'已安装 v{cur} → 本包 v{pkg_version}(**升级**)', action='ask')
-    return dict(state='newer', installed_version=cur, pkg_version=pkg_version,
-                message=f'已安装 v{cur},而本包是 v{pkg_version}(**本包更旧 / 降级**)'
-                        f'—— 除非确知原因,不要用旧包覆盖新装', action='warn')
-
-
-def summary_lines(cmp_res: dict, installed: dict | None) -> list[str]:
-    """给人看的几行(安装脚本与 guanlan.py check 共用同一份措辞)。"""
-    out = [cmp_res['message']]
-    if installed:
-        out.append(f"  现有安装: 装在 {installed.get('installed_at', '?')}"
-                   + (f",运行方式 {installed.get('mode')}" if installed.get('mode') else '')
-                   + (f",解释器 {installed.get('python')}" if installed.get('python') else ''))
-    return out
-
-
-if __name__ == '__main__':
-    import sys
-    print(f'{NAME} v{VERSION} ({EDITION})')
-    print(f'服务名: {SERVICE_NAME} · 显示名: {SERVICE_DISPLAY}')
-    root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(__file__).resolve().parents[1]
-    ins = read_installed(root)
-    c = compare(ins)
-    print(f'安装记录: {info_path(root)}')
-    for ln in summary_lines(c, ins):
-        print('  ' + ln)
-    print(f"  → state={c['state']} action={c['action']}")
+# -*- coding: utf-8 -*-
+"""版本与安装信息的**唯一真源**(2026-09-17 用户令 1:安装时检查已装版本、提示差异、问是否重装)。
+
+为什么单开一个模块:以前版本号散在三处(`scripts/pack_dist.py` 的 `VERSION`、README 抬头、说明书标题),
+改一处忘一处就会出现"包说 0.4.0、安装记录说 0.2.0"这种对不上的事。现在:
+
+    src/version.py            ← 唯一真源(本文件)
+    scripts/pack_dist.py      ← 读它写进 dist-manifest.json 与默认包名
+    install.ps1 / install.sh  ← 读它写进 <安装目录>/install-info.json,并与已装的比对
+    guanlan.py / 各文档        ← 读它(文档里的人工版本号以它为准)
+
+`install-info.json` 落在安装根本身(不在 run/ 里):它是**交付物级别的安装记录**,
+卸载/重装/拷机都要跟着走,所以和 `configs/`、`dist-manifest.json` 同级。
+"""
+from __future__ import annotations
+
+import datetime as dt
+import json
+import pathlib
+
+NAME = '观澜·如东样板 v2'
+VERSION = '2.9.0'          # ★ 改版本只改这里
+EDITION = 'offline-single-package'
+PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
+
+INSTALL_INFO = 'install-info.json'      # 相对安装根
+SERVICE_NAME = 'guanlan'                # Windows 服务名 / systemd 单元名(用户令 1)
+SERVICE_DISPLAY = '观澜·如东样板 v2 (Guanlan Wind Asset Intelligence)'
+
+# ── 版本号规则(用户令 2026-09-17)────────────────────────────────────────────────
+#   编号:  v<大版本号>.<中版本号>.<小版本号>          例: v2.5.0
+#   定义:  大版本号 —— 系统解决方案、架构或核心功能改变
+#          中版本号 —— 非核心功能新增、减少、修改
+#          小版本号 —— 消缺完善
+#   包名:  打包文件名 = app_guanlang_v<版本号>.zip   例: app_guanlang_v2.5.0.zip
+#   用法:  用户/发布者定"这次算哪一级"→ VERSION 改一行 → 打包器与安装脚本自动跟着变
+#          (级别判据见 BUMP_RULE;一次发布混了几类就取最高那一级)
+RULE = 'v<大版本号>.<中版本号>.<小版本号>'
+LEVEL_MEANING = {
+    'major': '大版本号 —— 系统解决方案、架构或核心功能改变',
+    'minor': '中版本号 —— 非核心功能新增、减少、修改',
+    'patch': '小版本号 —— 消缺完善',
+}
+BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/小归 0);'
+             '落在"非核心功能的新增/减少/修改" → 中 +1(小归 0);'
+             '只是"消缺完善" → 小 +1;一次发布混了几类,取最高那一级')
+
+# 版本记录(人读的那份由 scripts/version_log.py 生成到 docs/版本记录.md;这里只有事实,不重复描述)
+#   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/*`(公共列子集,不裁剪行)作**按窗重算底座**:'
+              '一次全量扫≈2 分钟,把"每换一个窗重读 15 GB"降到秒级;已进重算链 ③b 并登记反向审计族表; '
+               '④b **M9 控制参数一致性**(就摆在发电性能页上)此前仍钉死 2025-H2 ⇒ `control.registry(span=…)` 按窗重算'
+               '(窄仓补 `grd_wtc_ActPower_max`/`tur_wtc_GenRpm_max` 两列 ⇒ 48 列;≈0.6 s/窗,P封顶中位 4190(干净窗) → 4181.7(2026年) / 4177.5(2026Q1)),'
+               'fleet 响应加 `m9_pending/win_pending` 并由页面轮询(5 s)—— 至此判级四轴 + 可靠性 + 控制参数 + 七镜头 + 7 张月度图**全部随窗**;'
+               '远端两窗对比只剩 all_months/note/*_pending 三个元数据键不变; '
+              '⑤ 按窗重算一律"后台算+进程缓存"(判级≈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 全绿;'
+              '⑦ 远端部署(D:\\产品\\app,2026-09-21)实逮两处并已修: (a) 预热与请求各自起线程 ⇒ 同时算两份'
+              '按窗中间帧,改为**重活全局串行**(`_HEAVY_LOCK`)并打印可用内存;(b) 退化窗("近30日"=2026-09'
+              '只有 1 个节拍 ⇒ 比档件为空)时 `groupby().apply()` 返回空表 ⇒ `.rename("dev_K")` 抛 TypeError 把'
+              '整面判级打断,改为**如实"不可判(样本不足)"**;另: 远端进程须由 Windows 服务 `guanlan` 托管'
+              '(SSH 会话里起的进程会随会话关闭而死,实测全端口消失 ⇒ 已装服务并 RUNNING),'
+              '远端核验: fleet 两窗 pending→就位、变桨报警 15→7 / 偏航 15→3、rel 窗随窗、'
+              'curves 图注"窗 2026-07-01 ~ 2026-07-31 (随所选窗)"、v2 页含自定义起止控件'),
+    dict(version='2.8.2', date='2026-09-21', level='patch',
+         title='消缺:扫描器的"变化"判据被两类**非摄入件**常年占住 —— 现场按类目交付的月度通道组库'
+               '(`scada_mdb/<年>年/<月>月/<年>-<月>-<类>.mdb`)被当成"数据没人读·缺口",用户指定的按月提取件'
+               '(`<场站>/scada_10min_<YYYYMM>.csv`)被当成"未归类";两者每次扫描都计一次变化 ⇒ '
+               '`raw_scan --check` 永远 rc=4,`rebuild_all.py --auto` 与 raw_watch 会**每轮都以为来了新数据**',
+         note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
+              '① `CONSUME` 新增 `upstream` 口径(信息级):类目库是 10min 同台补充件的**上游**,'
+              '取数层不直接读它 ⇒ 不再算缺口、不计变化,改按"上游归档件(要先转换才被消费)"列出并写清转换口径; '
+              '② 新增 `IGNORE_PATTERNS`:`scada_\\d+min_\\d{6}\\.csv` 是按月提取/核对件(位置由用户指定、'
+              '不是摄入源)⇒ 不报未归类、不计变化;'
+              '③ 实测(2026-09-21 落位 7-8 月交付后):修前 `--check` rc=4 且每轮都报"2 处变化",'
+              '修后 `--check` rc=0「与上次快照一致,没有新数据」,`--selftest` 通过,24 件类目库如实以信息级列出; '
+              '④ 口径写进 `docs/输入数据放置指导_v0.1.md` §4.1(含 12 组中文目录↔类目码、9 类↔主件 595 通道一一对应、'
+              'float32+ISO 对齐、以及"类目库→同台补充件"的落位转换)'),
+    dict(version='2.8.1', date='2026-09-20', level='patch',
+         title='消缺:重算链末步「台账等价验收」在全新机器上必然 rc=5(交付包不含产物 ⇒ 没有随包基线),'
+               '原先把它判成失败 ⇒ 门户上写"重算失败"(实测服务器 2.8.0 一轮 24/24 步全 OK 却整轮 rc=1)',
+         note='纯消缺(无功能变化)⇒ 小版本 +1。要点: '
+              '① `rebuild_all.py` 的 ⑧ 台账等价验收由 `tolerate=(4,)` 改成 `tolerate=(4, 5)` —— '
+              'rc=5 = "找不到随包基线 ⇒ 没做验收",与 ④ 的口径对齐(④ 一直容忍 5 并写明"跳过等价验收≠通过");'
+              '步名与步说明都改写清楚"rc=4 = 有需人工看的差异 / rc=5 = 没基线 ⇒ 没验收(不是失败,也不是通过)",'
+              '并指路 `scripts/set_baseline.py`(用已核实的当前产物快照登记基线,之后这一项才会真比对); '
+              '② 远端复核(服务器 D:\\产品\\app): 该轮 24 个实质步骤全绿(①b 扫描 53.7s · ② 164s · ③ 1025s · '
+              '④ 99s · ④b 2118s · ④c 385s · ④d 41.6s · ④e 15.3s · ⑤b/⑤c/⑤a/⑤ · ⑥ · ⑦×6 · ⑦b 78s · ⑧ 7.1s · '
+              '⑧b 3s · ⑧c 9.5s),只有第 25 步判失败;且同批数据已真进产物:`temp_monthly` 21 个月、末四格 '
+              '2026-06/07/**08**/09、2026-08 = 1,026 行(服务器 `scada_10min` 也有 76 件含 38 个 `WTG??-B2.csv` '
+              '同台补充件,说明步骤 B 的同台多件合并已在服务器上生效); '
+              '③ 已把修好的 `scripts/rebuild_all.py`(+ `src/version.py` / `docs/版本记录.md`)单文件部署到服务器,'
+              '无需重装整包。交付包 app_guanlang_v2.8.1.zip'),
+    dict(version='2.8.0', date='2026-09-20', level='minor',
+         title='输入数据自动扫描识别:<安装目录>/data/raw 逐族指纹 → 发现新增/变化 → 指明该跑哪几步;'
+               '重算链加 ①b 步 + `rebuild_all.py --auto`(新数据不被 --skip-* 漏掉)+ 服务侧可选看门狗',
+         note='非核心功能新增 ⇒ 中版本 +1(无架构改动)。要点: '
+              '① 新增 `scripts/raw_scan.py`(用户令 2026-09-19「对 <安装目录>/data/raw 目录下的接入数据处理,'
+              '增加自动扫描识别机制,以能发现新增数据,并纳入重算」):逐族指纹 = 件数/体积/最新落盘时间/'
+              '清单摘要(`--deep` 再叠内容哈希),与快照 `outputs/<场>/_raw_scan.json` 比;'
+              'rc: 0 无变化 · 4 有新增/变化 · 5 还没有基线; `--write` 记基线, `--json` 给机器读, '
+              '`--selftest` 对账"族表覆盖反查族表里所有 raw 输入 + 步骤名都能在链上找到"(防两处漂移); '
+              '② 族表把每个 raw 子目录映射到链上步骤(故障报警/工单/油样→②;scada_10min→③④④c⑤b;'
+              'scada_1min→④c;scada_mdb→③④④c;windcms→④b④d④e;m5_cms_tcm→④b;西门子4.0技术资料→⑦),'
+              '`data/raw/<场>/` 下出现族表没有的目录则如实报"未归类、没有已知消费者"(不猜); '
+              '③ 进重算链作 **①b 步**(`--check --write`,容忍 rc=4/5,并在步说明里写清"4 = 有新数据不是失败"); '
+              '④ `rebuild_all.py --auto`:先扫一遍,新数据落在被 `--skip-scada`/`--skip-vib` 跳过的族里就'
+              '**自动取消跳过**(实测: scada_10min 新增 1 件 → 计划从 22 步变 24 步并打印取消原因); '
+              '⑤ 服务侧看门狗(`scripts/service_worker.py::raw_watch`,配置 `configs/serve.json` 的 `raw_watch`,'
+              '**默认关**):每 N 分钟扫一次,有新数据写 logs/service.log 并列出该跑的步;`auto_rebuild=true` '
+              '时顺手发起一次重算(走 ops 同一条路;`run/ops_job.json` 显示在跑则不重复发起)。'
+              '★2026-09-20 按用户令「对 data/raw(**含嵌套子目录**)下文件的增减做到监听;重算要按该目录'
+              '**最新的变化**算」逐条查证并补了三处: '
+              '⑥ 扫描改成**递归统计全部文件**(不限扩展名; 白名单命中的另记 matched, 差额单列为"信息,不计变化")'
+              '+ **子目录清单进指纹**(新建空目录也发现) + 未归类改成**全树逐件**找(不再只看一层); '
+              '⑦ **产物 = 当前源件的函数**(删/换源件后, 那些行不再产出并大声报出"哪几件不在盘、少多少行、'
+              '涉及哪些月"): 报警/工单/油样三个摄入器原先是"只替换本次涉及的件、其余原样保留" ⇒ 源件删了行还在; '
+              '⑧★报警台账改**键集合并集**去重(键=Name/Alarmcode/TimeOn, 归属取最窄的源件)—— 原先按"逐件累加、'
+              '没新键就跳过"的贪心判快照, 实测**删一个源件反而让总行数从 39,211 涨到 56,593**(被跳过的大快照件'
+              '整件摄入, 重复计数) ⇒ 既不是源集的函数也会虚增; 现在 39,211 键稳定、重复 3.6 万行如实去掉; '
+              '⑨ 振动窗: 同名窗已存在时原来一律改名 `_reimport`(被 EXCLUDE_DEFAULT 排除 ⇒ **补进来的 CMS 数据'
+              '根本进不了生产集**), 现在默认**替换同名窗**(旧窗留档 `_superseded_<时分>`, 已加入排除表; '
+              '要旧行为用 `--reimport-as-new`)。'
+              '★另按用户令「按你的建议执行 先 C 再 B」补完"同台多件 10min 进不来"这条链: '
+              '⑩ **步骤 C(让"放了没人读"当场可见)**:`raw_scan.py` 新增 `CONSUME` 表(逐族写清**消费者真实'
+              '取数口径**),判据从"扩展名白名单没命中"改成"**全部文件里没被消费者读的**"—— 实测 `data/raw/'
+              '如东/scada_10min/WTG01-B2.csv`(856 列 / 2026-08 数据)扩展名 `*.csv` 命中白名单,旧判据'
+              '**永远发现不了**它;现在报成**缺口级**(计入变化 rc=4 + ★[缺口] 块写明消费者口径与件例),'
+              '附件类(.rar/.jpg/厂商软件)仍只列信息;建议行也分流说明"缺口类重算也纳入不了"。'
+              '`raw_data_check.py` 同步: 同台补充件不再误报"文件名不是本场机组",改为 info 行。'
+              '⑪ **步骤 B(真正把新月份的 10min 件吃进来)**:`scada_source.load` 取数从"只认 `<台号>.csv`"'
+              '扩到 **`<台号>.csv` + 同台补充件 `<台号>-*.csv`/`<台号>_*.csv`**:按列名对齐(新导出把类型前缀'
+              '去掉了: `din_wtc_HydLevel_timeon` → `wtc_HydLevel_timeon`,实测老件 598 列里 597 列能这样对上)、'
+              '按时间戳排序去重(**主件优先**,重叠条数写进 `attrs.scada_overlap` 并打印)、来路件名与各件行数'
+              '写进 `attrs.scada_files/scada_rows`。实测 WTG01:主件 77,551 行 + 补充件 5,820 行 → 合并 83,353 行'
+              '(重叠 18 行去重),时间范围由 2026-07-07 延到 **2026-09-01**,2026-08 的 5,819 行关键通道'
+              '非空率 77%(**空白是源件自带的**:该月 144 行/日齐全但约 23% 行的通道为空,如实保留 NaN)。'
+              '交付包 app_guanlang_v2.8.0.zip'),
+    dict(version='2.7.0', date='2026-09-19', level='minor',
+         title='远程部署消缺:振动窗发布被杀软/索引占用不再打断整条重算链(+ --publish-only 恢复通道);'
+               '门户可对外监听(public_host,组件仍只本机);GBK 控制台打印不炸',
+         note='一次消缺 + 一项非核心功能新增(对外监听)⇒ 按规则取最高一级(minor)。要点: '
+              '① ★远端实逮(服务器 D:\\产品\\app):索引 719 s + 谱 894 s 落进 windows\\_staging_ingest 后,'
+              '`staging.rename(final)` 回 `PermissionError: [WinError 5] 拒绝访问`(360 实时扫描/索引器持着'
+              '刚写的 1710 件小文件的句柄,Windows 的目录改名要求树里无打开句柄)⇒ ④b 失败 ⇒ 链停在第 4 步,'
+              '页面表现为「需要关注 0 台 + 融合面缺件」。修法:发布改 `_move_tree()`(整目录 rename 退避重试 '
+              '→ 逐件搬,rename 不行就 copy+删 → 仍锁住的逐条如实报出);并加 `--publish-only`:已算完的暂存窗'
+              '可直接发布,不必白等 25 分钟重跑索引/谱; '
+              '② 用户令「观澜改为监听所有 IP」:`configs/serve.json` 增 `public_host`(空=跟 host=只本机;'
+              '设 0.0.0.0=所有网卡),**只有门户网关**用它,detail/cms/viewer/sim 仍绑 127.0.0.1 ⇒ 对外只有 '
+              '28084 一个入口;启动时打印对外地址并提示"页面无鉴权,请在防火墙侧限来源"; '
+              '③ 消缺: 新生成端在 GBK 控制台打印 m/s² / ⇒ 时抛 UnicodeEncodeError(远端 baseline_38 整件没出)'
+              '⇒ 四个生成端加 stdout 守卫;trend_ingest 的"台账止 2024-11"过期断言改实话;'
+              'CMS 三层基线卡的"健康期窗 N"重复措辞; '
+              '④ 部署口径: 用 SSH 起的服务在会话断开时会被一起收掉 ⇒ 现场改注册 Windows 服务(`guanlan`)'
+              '(`scripts/service_ctl.py install`),开机自启、SCM 崩溃重启。交付包 app_guanlang_v2.7.0.zip'),
+    dict(version='2.6.0', date='2026-09-19', level='minor',
+         title='「所有的计算均要形成观澜的源代码」:融合面/总览页/事实契约/发布层/掩码阈值/六层链两步/'
+               '变桨面/振动在升与三层基线 全部落成观澜自算;SCADA 接入兼容 CSV+MDB;'
+               '交付包带输入数据目录结构(不含文件)',
+         note='功能新增为主、夹带多项消缺 ⇒ 按规则取最高一级(minor)。要点: '
+              '① 六层链 model_run/fusion 两步按口径重建(报告的"融合级"列从此有值)+ energy_share 步补上 + '
+              '峰值拾取口径定案(观澜口径,四件 *_freq_scan 形式不再复刻); '
+              '② 融合面 handoff 与总览页 windscada/index.html 由观澜自算生成(原为"包内无生成端",'
+              '新机器上 /detail/v2 的"需要关注/全场状态"必空白); '
+              '③ 事实契约改为**从重算台账生成 claim**(门户结论段/问答/报告随重算刷新,/api/facts 不再 503); '
+              '④ 本体发布层 r1/r2、TCM 掩码阈值(观澜自算口径,非厂商)分别落成生成端; '
+              '⑤ SCADA 接入统一取数层兼容 CSV 与 MDB(含老库列位错位如实拒绝;37 个月库按修好的建库脚本'
+              '重建并逐分片对拍); '
+              '⑥ 变桨面 pitch/** 落成生成端(10min 开关量/压力锯齿 + 1min 桨距角;零位口径按用户令'
+              '**停机段/满发段分列**,并据实测反推补上**运行段同工况分档**这一真正的判据轴 —— 它能把'
+              '现场确诊的 19# 单独拎出来); '
+              '⑦ 振动 component_history.json(在升/换件闭环)与 baseline_38.json(自/机群/绝对三层基线)'
+              '落成生成端(窗数不足时如实空表并写明数据边界); '
+              '⑧ 打包:**带输入数据目录结构**(30 个空目录, 含 data/raw/西门子4.0技术资料)+ 放置说明文件随包, '
+              '仍不含数据文件; ⑨ 消缺: report 步踩可选包 tabulate 致整链中断、语言包闸拦下的 /v2 500、'
+              '振动页看不出数据区间与"3 月数据消失"(趋势按日聚合)、账目单位错(kWh/MW)、'
+              'Access 锁文件误报、日志保留策略等。交付包 app_guanlang_v2.6.0.zip'),
+    dict(version='2.5.0', date='2026-09-17', level='minor',
+         title='卸载闭环(uninstall.bat / uninstall.sh)+ 版本管理与打包命名规则 + 版本记录',
+         note='二代架构(大版本 2,与 NAME 里的 v2 对齐)的第 5 次功能性发布,无消缺项;'
+              '交付包 app_guanlang_v2.5.0.zip'),
+    dict(version='0.4.0', date='2026-09-17', level='legacy',
+         title='打包默认不含 输入数据/产物/日志/临时文件;无窗口启动(去掉 VBScript);统一配置与日志;'
+               '页面归口审计;输入数据放置检查;安装时版本检查与服务化',
+         note='旧编号体系;交付包 app_guanlan_v2_0.4.0.zip(= guanlan-v0.4.0_dist_20260917.zip)'),
+    dict(version='0.2.0', date='2026-09-01', level='legacy',
+         title='含产物的旧交付基线(历史上用作"包内无生成端"那批产物的补救源;该用途 2026-09-19 起已失效)',
+         note='旧编号体系;交付包 guanlan-rudong-v2_0.2.0_test_win64.zip —— ★2026-09-19 用户令从工作树'
+              '清理(943 MB):① 「所有的计算均要形成观澜的源代码」落地后无生成端件 = 0(反向呼应审计 '
+              '1,800 件全部 raw-derived)⇒ 它作为"补救源"的用途消失;② 该包仍留在 git 历史里'
+              '(blob 可随时 git 取回),需要时用 git show 恢复即可'),
+)
+
+
+def package_name(version: str | None = None, ext: str = 'zip') -> str:
+    """交付包文件名(用户令 2026-09-17 的命名规则):`app_guanlang_v<版本号>.zip`。"""
+    return f'{PACKAGE_STEM}_v{version or VERSION}.{ext}'
+
+
+def level_of(old: str, new: str = VERSION) -> str:
+    """两次版本之间是"哪一级"的变化 → major / minor / patch / same / ?(版本管理的机器判据)。"""
+    a, b = _parts(old), _parts(new)
+    if a == b:
+        return 'same'
+    if len(a) < 3 or len(b) < 3:
+        return '?'
+    if b[0] != a[0]:
+        return 'major'
+    if b[1] != a[1]:
+        return 'minor'
+    return 'patch'
+
+
+def rule_lines() -> list[str]:
+    """把版本规则印出来(README / 版本记录 / `guanlan.py version` 共用同一份措辞)。"""
+    return [f'编号: {RULE}   例: v{VERSION}',
+            *[f'  {LEVEL_MEANING[k]}' for k in ('major', 'minor', 'patch')],
+            f'包名: 打包文件名 = {package_name()}']
+
+
+def info_path(root: pathlib.Path) -> pathlib.Path:
+    return pathlib.Path(root) / INSTALL_INFO
+
+
+def read_installed(root: pathlib.Path) -> dict | None:
+    """→ 已安装记录 dict;没装过就是 None(`install-info.json` 缺失或坏了都算没装)。
+
+    ★ 用 `utf-8-sig` 读:本器写的是无 BOM 的 UTF-8,但**别的工具/手改**过这份 json 时可能带 BOM
+      (实测 PowerShell 的 `Set-Content -Encoding UTF8` 就带 BOM)。带 BOM 而按 utf-8 读会抛异常,
+      于是"明明装了却报没装过" —— 版本检查直接失灵,所以这里按最宽的方式读。
+    """
+    p = info_path(root)
+    if not p.is_file():
+        return None
+    try:
+        d = json.loads(p.read_text(encoding='utf-8-sig'))
+        return d if isinstance(d, dict) else None
+    except Exception:
+        return None
+
+
+def write_installed(root: pathlib.Path, python: str = '', mode: str = 'manual', extra: dict | None = None) -> dict:
+    """写安装记录(安装脚本在装完时调用)。mode: manual | windows-service | systemd | startup-entry。"""
+    root = pathlib.Path(root)
+    rec = dict(name=NAME, version=VERSION, edition=EDITION, installed_at=dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
+               python=python or '', mode=mode, host=str(root))
+    rec.update(extra or {})
+    info_path(root).write_text(json.dumps(rec, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
+    return rec
+
+
+def _parts(v: str) -> tuple:
+    out = []
+    for seg in str(v).strip().lstrip('vV').split('.'):
+        num = ''.join(ch for ch in seg if ch.isdigit())
+        out.append(int(num) if num else 0)
+    return tuple(out)
+
+
+def compare(installed: dict | None, pkg_version: str = VERSION) -> dict:
+    """安装检查的核心:已装版本 vs 本包版本 → 判断该提醒什么。
+
+    → dict(state, installed_version, pkg_version, message, action)
+       state: none(没装过)· same(同版本)· older(已装更旧,本包是升级)·
+              newer(已装更新,本包更旧 —— 要特别提醒别降级)· unknown(老安装没写版本记录)
+       action: install(照常装)· ask(问用户是否重装)· warn(要用户明确确认)
+    """
+    cur = (installed or {}).get('version') or ''
+    if not installed:
+        return dict(state='none', installed_version='', pkg_version=pkg_version,
+                    message=f'未检测到已安装的{NAME}(将全新安装 v{pkg_version})', action='install')
+    if not cur:
+        return dict(state='unknown', installed_version='', pkg_version=pkg_version,
+                    message=f'检测到安装目录已有部署,但没有版本记录(老版本安装)'
+                            f'—— 本包是 v{pkg_version},将按"重装/升级"处理', action='ask')
+    if _parts(cur) == _parts(pkg_version):
+        return dict(state='same', installed_version=cur, pkg_version=pkg_version,
+                    message=f'已安装 {NAME} v{cur},与本包 v{pkg_version} **版本相同**', action='ask')
+    if _parts(cur) < _parts(pkg_version):
+        return dict(state='older', installed_version=cur, pkg_version=pkg_version,
+                    message=f'已安装 v{cur} → 本包 v{pkg_version}(**升级**)', action='ask')
+    return dict(state='newer', installed_version=cur, pkg_version=pkg_version,
+                message=f'已安装 v{cur},而本包是 v{pkg_version}(**本包更旧 / 降级**)'
+                        f'—— 除非确知原因,不要用旧包覆盖新装', action='warn')
+
+
+def summary_lines(cmp_res: dict, installed: dict | None) -> list[str]:
+    """给人看的几行(安装脚本与 guanlan.py check 共用同一份措辞)。"""
+    out = [cmp_res['message']]
+    if installed:
+        out.append(f"  现有安装: 装在 {installed.get('installed_at', '?')}"
+                   + (f",运行方式 {installed.get('mode')}" if installed.get('mode') else '')
+                   + (f",解释器 {installed.get('python')}" if installed.get('python') else ''))
+    return out
+
+
+if __name__ == '__main__':
+    import sys
+    print(f'{NAME} v{VERSION} ({EDITION})')
+    print(f'服务名: {SERVICE_NAME} · 显示名: {SERVICE_DISPLAY}')
+    root = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(__file__).resolve().parents[1]
+    ins = read_installed(root)
+    c = compare(ins)
+    print(f'安装记录: {info_path(root)}')
+    for ln in summary_lines(c, ins):
+        print('  ' + ln)
+    print(f"  → state={c['state']} action={c['action']}")

+ 1 - 0
src/windscada/lang.py

@@ -783,6 +783,7 @@ _add({
                        '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.recomputing_short': ('正在按窗重算…', 'recomputing for this window…'),
     'gen.ctrl':       ('控制参数一致性', 'Control-parameter consistency'),
     'gen.ctrl_win':   ('窗 {win}', 'window {win}'),
     'gen.cap':        ('封顶参数落位', 'Where the caps sit'),

+ 33 - 11
src/windscada/perf/control.py

@@ -14,13 +14,26 @@ from ..data import load_10min
 WIN = ('2025-07-01', '2026-01-01')
 PB = np.arange(0, 4200, 500)
 
-def build_store(cfg=None):
+def build_store(cfg=None, span=None, write=True):
+    """控制策略件(M9)。`span=(起, 止)` 给了就按**所选时间窗**(含两端)重算(用户令 2026-09-21)。
+
+    span 模式走 `slim10min` 窄仓(同一份 `load_10min` 的列子集),且 **不写盘**:
+    正式产物 `control_profile/schedule.parquet` 的口径是"限电前干净窗(2025-H2)",
+    按窗重算是服务时算给页面看的,不能覆盖它。
+    """
     cfg = cfg or farm()
     rows, sched = [], []
     for t in cfg['turbines']:
         try:
-            d = load_10min(t, cfg, groups=['A.功率', 'A.转速', 'B.变桨'])
-            d = d[(d.ts >= WIN[0]) & (d.ts < WIN[1])]
+            if span:
+                from .. import slim
+                d = slim.load(t, cfg, span=span,
+                              columns=['grd_wtc_ActPower_mean', 'grd_wtc_ActPower_max',
+                                       'tur_wtc_GenRpm_mean', 'tur_wtc_GenRpm_max',
+                                       'tur_wtc_PitcPosA_mean'])
+            else:
+                d = load_10min(t, cfg, groups=['A.功率', 'A.转速', 'B.变桨'])
+                d = d[(d.ts >= WIN[0]) & (d.ts < WIN[1])]
             op = d[d['grd_wtc_ActPower_mean'] > 100]
             rec = dict(turbine=t,
                        p_cap=float(op['grd_wtc_ActPower_max'].quantile(0.995)),
@@ -36,19 +49,28 @@ def build_store(cfg=None):
             g = op.groupby('pb')['tur_wtc_PitcPosA_mean'].median()
             for pb, v in g.items():
                 if v == v: sched.append(dict(turbine=t, pb=float(pb), pitch=float(v)))
-            print(t, flush=True)
+            if write:
+                print(t, flush=True)
         except Exception as e:
             print(t, 'ERR', str(e)[:60], flush=True)
-    pd.DataFrame(rows).to_parquet(pathlib.Path(cfg['store']) / 'control_profile.parquet')
-    pd.DataFrame(sched).to_parquet(pathlib.Path(cfg['store']) / 'control_schedule.parquet')
-    print('→ control_profile / control_schedule')
+    prof, sch = pd.DataFrame(rows), pd.DataFrame(sched)
+    if write:
+        prof.to_parquet(pathlib.Path(cfg['store']) / 'control_profile.parquet')
+        sch.to_parquet(pathlib.Path(cfg['store']) / 'control_schedule.parquet')
+        print('→ control_profile / control_schedule')
+    return prof, sch
 
-def registry(cfg=None):
+def registry(cfg=None, span=None):
+    """控制参数一致性。`span=(起, 止)` 给了就**按所选时间窗重算**(判据不变,只换数据切片)。"""
     cfg = cfg or farm()
     st = pathlib.Path(cfg['store'])
-    pr = pd.read_parquet(st / 'control_profile.parquet')
-    sc = pd.read_parquet(st / 'control_schedule.parquet')
-    out = dict(窗='2025-H2 (限电前干净窗, 与曲线判别窗一致)')
+    if span:
+        pr, sc = build_store(cfg, span=span, write=False)
+        out = dict(窗=f'{span[0]} ~ {span[1]} (随所选窗)')
+    else:
+        pr = pd.read_parquet(st / 'control_profile.parquet')
+        sc = pd.read_parquet(st / 'control_schedule.parquet')
+        out = dict(窗='2025-H2 (限电前干净窗, 与曲线判别窗一致)')
     # C1 双封顶
     for ax, col, unit, tol in (('P封顶', 'p_cap', 'kW', 25), ('ω封顶', 'w_cap', 'rpm', 8)):
         med = pr[col].median()

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

@@ -762,7 +762,7 @@
     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">
+      h += `<div class="panel mb"${d.m9_pending ? ' style="border-left:3px solid var(--accent)"' : ''}><h3>${t('gen.ctrl')} <span class="small muted" style="font-weight:400">${t('gen.ctrl_win', { win: dv(M9.窗) })}</span>${d.m9_pending ? ` <span class="chip">${t('gen.recomputing_short')}</span>` : ''}</h3><div style="display:flex;flex-wrap:wrap;gap:18px">
         <div style="flex:1 1 300px;min-width:280px"><div class="small" style="font-weight:600;margin-bottom:4px">${t('gen.cap')}</div>${CH.capAxis(M9.P封顶, M9.ω封顶)}<p class="small muted" style="margin-top:4px">${t('gen.cap_note')}</p></div>
         <div style="flex:1 1 300px;min-width:280px"><div class="small" style="font-weight:600;margin-bottom:4px">${t('gen.beta')}</div>${CH.betaBar(b9, unitNo)}<p class="small muted" style="margin-top:4px">${t('gen.beta_note', { units: outs.map(x => `<a href="/turbine/${x.t}">${unitNo(x.t)}</a>`).join(' / '), k: outs.length ? Math.round(Math.max(...outs.map(x => x.平均偏)) / Math.max(b9.全场同档差中位 || 0, .01)) : '—', v: dv(b9.判) })}</p>
           <div class="small" style="font-weight:600;margin:12px 0 4px">${t('gen.ruler')}</div>${CH.rulerBar(M9.尺子)}<p class="small muted" style="margin-top:4px">${t('gen.ruler_note')}</p></div></div></div>`; }
@@ -1068,11 +1068,12 @@
     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; }
+    // ★判级矩阵/控制参数按窗重算是后台算的(首次约 25 s / 1 s;用户令 2026-09-21)⇒ 页面先出上一口径并
+    //   **如实标注**,然后轮询直到按窗结果就位(同一窗只算一次,服务端常驻缓存)。
+    if ((D.win_pending || D.sysmx_pending || D.m9_pending)
+        && (window.__PENDING_TRIES = (window.__PENDING_TRIES || 0) + 1) <= 40) {
+      setTimeout(() => { if (D && (D.win_pending || D.sysmx_pending || D.m9_pending)) load().catch(() => {}); }, 5000);
+    } else if (!D.win_pending && !D.sysmx_pending && !D.m9_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();

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov