Преглед на файлове

振动面从输入数据重算上链 + 缺 handoff 时不再报程序错

同一条用户令(2026-09-18, /detail 各页面只能基于输入数据重算)的第二批: 把能重算的振动面接回链上,
并把"缺件被报成程序坏了"的三处补齐。

实测(本机, 清过产物只从 data/raw 重算):
- windcms.py report 从重算出的窗 w0316 一键出件: 38 台判级齐(报警 3: WTG09/17/27; 优秀 35)
  + 38 张逐台页 + 总览 + 标准模版报告(md/docx) + 登记 csv。只缺 model_run/fusion 两步的
  融合级列(如实写 —)。
- /detail/api/vibcms 从 list index out of range 变成有数: date 2026-09-18 / action 3 / grades 38。
- fleet_scalar_z.parquet 由 rudong_fusion_run.py --window w0316 从窗索引重算(32,580 行), 自登记修好。

修正:
- vib_raw_build: --with-report 默认开(--no-report 可关), 并加跑 fusion 的 fleet_scalar_z;
  rebuild_all 第 4b 步同步(--no-vib-report)。口径翻转的理由写在文件头: 旧默认依据是"比随包快照掉内容",
  而用户令之后包里根本没有旧产物可参照。
- src/windcms/config.py: 窗集合只在索引真在位时收 w0127(缺则进 windows_missing) —— 报告正文此前会写
  "数据窗 w0127, w0316", 替一个不在位的窗作证。
- src/windscada/subsys/fusion.py: 缺 handoff 时 matrix()/capability()/fusion_table() 三处都改成如实
  降级(此前直接 KeyError); windscada_serve 的 fmeta 下标取值全改 .get(缺件时原会回 err='振动窗' 那一类)。
  实测: /detail/api/fleet 的 fus 从 err='per_turbine' 变成 fus.err=None + fus.gap 写明缺哪件。
- 族表: windcms 拆出 windcms_report_std / windcms_report_overview / windcms_kb 三族(gen 在包内,
  input=data/raw/场站/windcms), 不再整族记"无生成端"。

验证: 台账 refresh 后 1773 件全 raw-derived(shipped 0 / unregistered 0); products_reverse_audit 检查
rc=0(未归类 0, 判据失败 0); detail_deps 检查 rc=0; ontology.audit rc=0; 组件重启后 5 端口全通。
zhouyang.xie преди 3 седмици
родител
ревизия
460191b15f

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

@@ -6,7 +6,7 @@
 <!-- DETAIL-DEPS:BEGIN -->
 # /detail 页面依赖与重算台账 (自动生成, 禁手改)
 
-> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 1,728 件
+> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 1,775 件
 >
 > 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, 数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。
 
@@ -51,7 +51,7 @@
 | /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) | 在位 |
 |  | `ontology/turbine_params.parquet` ○可选 | 1 | 0.1 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 |  | `m5_cms_tcm/handoff_vibration_v2.json` ○可选 | 0 | 0.0 MB | — | — (vib_handoff_and_scans) | 缺(页面降级) |
-|  | `m5_cms_tcm/fleet_scalar_z.parquet` ○可选 | 0 | 0.0 MB | — | — (vib_fleet_scalar_z) | 缺(页面降级) |
+|  | `m5_cms_tcm/fleet_scalar_z.parquet` ○可选 | 1 | 0.7 MB | raw-derived | scripts/rudong_fusion_run.py (vib_fleet_scalar_z) | 在位 |
 |  | `pitch/pitch_daily.parquet` ○可选 | 0 | 0.0 MB | — | — (pitch_shipped) | 缺(页面降级) |
 |  | `pitch/pitch_zero_monthly.parquet` ○可选 | 0 | 0.0 MB | — | — (pitch_shipped) | 缺(页面降级) |
 | /api/curves | `windscada/powercurve_bins.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
@@ -65,7 +65,7 @@
 | /api/turbine | `windscada/alarms.parquet` ✅必需 | 1 | 0.9 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms) | 在位 |
 | /api/turbine | `ontology/objects.json` ○可选 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 |  | `windscada/workorders.parquet` ○可选 | 1 | 0.5 MB | raw-derived | scripts/windscada_workorder_ingest.py (scada_workorders) | 在位 |
-| /api/vibcms | `windcms/报告_CMS振动状态评估报告_*.md` ✅必需 | 0 | 0.0 MB | — | — (—) | **缺** |
+| /api/vibcms | `windcms/报告_CMS振动状态评估报告_*.md` ✅必需 | 1 | 0.0 MB | raw-derived | src/windcms/report_std.py (windcms_report_std) | 在位 |
 | /api/vibcms | `m5_cms_tcm/handoff_vibration_v2.json` ○可选 | 0 | 0.0 MB | — | — (vib_handoff_and_scans) | 缺(页面降级) |
 |  | `windcms/cache/*` ○可选 | 1 | 2.2 MB | raw-derived | src/windcms/data.py (windcms_cache) | 在位 |
 | /api/maint_survey | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
@@ -84,10 +84,8 @@
 | 接口 | 缺件 | 必需 | 生成端 | 能否由 data/raw 重算 |
 |---|---|---|---|---|
 | `/api/fleet` | `m5_cms_tcm/handoff_vibration_v2.json` | 否 | (无生成端) | **不能**: 振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ 运行期不从交付包补(用户令 2026-09-17 ⇒ 需研发补生成端 |
-| `/api/fleet` | `m5_cms_tcm/fleet_scalar_z.parquet` | 否 | `scripts/rudong_fusion_run.py` | 能: 放原始件到 data/raw 后 `rebuild_all.py` |
 | `/api/fleet` | `pitch/pitch_daily.parquet` | 否 | (无生成端) | **不能**: 无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), 页面降级; 要出件须研发补生成端 ⇒ 需研发补生成端 |
 | `/api/fleet` | `pitch/pitch_zero_monthly.parquet` | 否 | (无生成端) | **不能**: 无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), 页面降级; 要出件须研发补生成端 ⇒ 需研发补生成端 |
-| `/api/vibcms` | `windcms/报告_CMS振动状态评估报告_*.md` | 是 | (无生成端) | **不能**: 生成端在包内但输入不足(model_run/fusion 未随包) ⇒ 运行期不从交付包补(用户令 2026-09-17 ⇒ 需研发补生成端 |
 | `/api/vibcms` | `m5_cms_tcm/handoff_vibration_v2.json` | 否 | (无生成端) | **不能**: 振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ 运行期不从交付包补(用户令 2026-09-17 ⇒ 需研发补生成端 |
 | `/api/facts` | `guanlan/facts_contract_v0.json` | 是 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
 | `/api/facts` | `guanlan/derived/detail_cards.json` | 是 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
@@ -103,7 +101,7 @@
 | scada_derived | scada_10min | `src/windscada/perf/powercurve.py` |
 | ontology_core | 西门子4.0技术资料 | `src/ontology/kb_ingest.py` |
 | vib_handoff_and_scans | — | `—` |
-| vib_fleet_scalar_z | m5_cms_tcm/windows/*/index.parquet | `scripts/rudong_fusion_run.py` |
+| vib_fleet_scalar_z | m5_cms_tcm | `scripts/rudong_fusion_run.py` |
 | pitch_shipped | — | `—` |
 | scada_workorders | 风机故障记录 | `scripts/windscada_workorder_ingest.py` |
 | guanlan_contract | — | `—` |
@@ -122,28 +120,28 @@ python scripts/rebuild_all.py               # 全量重算 (链上含 ⑤a 台
 
 | 页签 | 接口 | 判定 | 实测 |
 |---|---|---|---|
-| overview | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
-| energy | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
-| component | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+| overview | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
+| energy | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
+| component | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
 |  | `/api/problem` | 有数 | HTTP 200 · 16 B |
-|  | `/api/turbine` | 有数 | HTTP 200 · 13,107 B |
-| vibration | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
-|  | `/api/vibcms` | 报错 | HTTP 200 · windcms 报告解析失败: list index out of range |
+|  | `/api/turbine` | 有数 | HTTP 200 · 14,417 B |
+| vibration | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
+|  | `/api/vibcms` | 有数 | HTTP 200 · 6,797 B |
 | generation | `/api/curves` | 有数 | HTTP 200 · 23,913 B |
-| fault | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
-| decision | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+| fault | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
+| decision | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
 |  | `/api/ont_chain` | 有数 | HTTP 200 · 288 B |
-| assistant | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+| assistant | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
 |  | `/api/ask` | 有数 | HTTP 404 · 3 B |
 |  | `/api/ask_status` | 有数 | HTTP 200 · 173 B |
 |  | `/api/ask_models` | 有数 | HTTP 200 · 137 B |
 |  | `/api/facts` | 报错 | HTTP 503 · facts derived 缺失: [Errno 2] No such file or directory: 'F:\\temp\\app\ |
 |  | `/api/facts/claim` | — | — |
-| report | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
+| report | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
 |  | `/api/facts` | 报错 | HTTP 503 · facts derived 缺失: [Errno 2] No such file or directory: 'F:\\temp\\app\ |
 |  | `/api/rpt_compose` | 不适用(本机模型未启动) | HTTP 200 · 本地模型不可达: <urlopen error [WinError 10061] 由于目标计算机积极拒绝,无法连接。> |
-| system | `/api/fleet` | 有数 | HTTP 200 · 33,282 B |
-|  | `/api/maint_survey` | 有数 | HTTP 200 · 7,440 B |
+| system | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
+|  | `/api/maint_survey` | 有数 | HTTP 200 · 7,498 B |
 
 判读口径: 只认响应**顶层**的 `err`/`error`/`no_products`/`no_report`; `/api/ask*` `/api/rpt_compose` 依赖本机模型, 未启动时记「不适用」不算缺件。
 <!-- DETAIL-PROBE:END -->

+ 36 - 7
scripts/products_reverse_audit.py

@@ -112,8 +112,12 @@ FAMILIES: list[dict] = [
     dict(id='vib_fleet_scalar_z', glob='m5_cms_tcm/fleet_scalar_z.parquet', kind='raw-derived',
          func='振动线 · 全场标量 z 值 (fusion 步出件)',
          algo='scripts/rudong_fusion_run.py(逆向工程实现: 窗索引 scalar_value → 同 (传感器,测量,工况) 族中位 '
-              '→ 稳健 z=(val−med)/(1.4826·MAD); 与随包样件 8,887 行逐值对拍 val 列 100% 一致)',
-         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm/windows/*/index.parquet', pred=('rows',)),
+              '→ 稳健 z=(val−med)/(1.4826·MAD); 与随包样件 8,887 行逐值对拍 val 列 100% 一致)',
+         # input 写**原始件目录名**(族表口径: data/raw/<场>/<input>); 真实取数经窗索引一跳,
+         # 而窗索引本身是 raw-derived(vib_window_index 族) —— 写 windows/_*.parquet 会让审计判"输入不在位"。
+         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='2026-09-18 上链: 由 vib_raw_build.py 的 --with-report 分支按当次窗名调用, '
+             '使 /api/fleet 的振动标量 z 面不再空白'),
     dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
          kind='shipped',
          func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
@@ -132,12 +136,37 @@ FAMILIES: list[dict] = [
     dict(id='windcms_cache', glob='windcms/cache/*', kind='raw-derived',
          func='CMS 报告侧 · 标量缓存', algo='src/windcms/data.py::load_scalars(窗索引 → 标量数组, '
               '按内容哈希命名落 cache/)', gen='src/windcms/data.py', input='windcms', pred=('rows',)),
+    # ★2026-09-18 补 (用户令"页面只能基于输入数据重算"之后): `windcms/**` 整族原先一律按
+    #   "无生成端"记账 —— 但**报告两步的生成端就在包内**(scripts/windcms.py report → src/windcms/
+    #   report.py + report_std.py), 只要 data/raw/<场>/windcms 里有 decode 导出就出得来。
+    #   实测(2026-09-18): 从重算出的窗 w0316 一键跑出标准模版报告 + 38 台逐台页 + 总览, 判级 38 台
+    #   (报警 3 / 优秀 35), 缺的只有 model_run/fusion 两步的融合级列(如实写 —)。故拆成两族按 raw-derived 记。
+    dict(id='windcms_report_std', glob=['windcms/报告_CMS振动状态评估报告_*.md',
+                                        'windcms/报告_CMS振动状态评估报告_*.docx',
+                                        'windcms/登记_三轴_38台.csv'], kind='raw-derived',
+         func='CMS 标准模版评估报告 (三轴: 状态等级 × 证据状态 × 行动等级建议)',
+         algo='src/windcms/report_std.py::build(结构化判级 → 报告 md + docx + 登记 csv; '
+              '由 scripts/windcms.py report 调用)',
+         gen='src/windcms/report_std.py', input='windcms', pred=('rows',),
+         why='★同名件有两种来源: 观澜自算(本族: 从 data/raw/<场>/windcms 的 *_decode.json 算) 与 '
+             '厂家报告转录(scripts/vib_reports_build.py, 输入是现场振动分析报告 PDF)。按名字认领不了'
+             '这层区别 ⇒ 以"是否由本步生成"为准, 判据看 m5_cms_tcm/vib_raw_manifest.json 的 steps'),
+    dict(id='windcms_report_overview', glob=['windcms/report.md', 'windcms/index.html',
+                                             'windcms/index_eng.html', 'windcms/overview.html',
+                                             'windcms/turbines/*'], kind='raw-derived',
+         func='CMS 诊断总览页 + 逐台页 + 结构化层报告',
+         algo='src/windcms/report.py::build(逐台 turbines/<台>.html + 总览 overview/index.html + '
+              'report.md; 融合级列要 model_run/fusion 出件, 缺则如实写"—")',
+         gen='src/windcms/report.py', input='windcms', pred=('rows',)),
+    dict(id='windcms_kb', glob='windcms/kb.json', kind='raw-derived',
+         func='CMS 知识库索引 (问答检索用)',
+         algo='src/windcms/knowledge.py::build(由 scripts/windcms.py kb 调用; 离线无模型时 0 chunks 也算跑成)',
+         gen='src/windcms/knowledge.py', input='windcms', pred=('rows',)),
     dict(id='windcms_pages', glob='windcms/**', kind='shipped',
-         func='CMS 振动评估报告页/逐台页', algo='src/windcms/report_std.py(自算报告)+ scripts/windcms.py report;'
-              '两者都要六层链的 model_run/fusion 出件, 本包没有 ⇒ 跑不出来',
-         gen=None, input=None, pred=(),
-         why='生成端在包内但输入不足(model_run/fusion 未随包) ⇒ 运行期不从交付包补(用户令 2026-09-17), '
-             '`/api/vibcms` 如实回 no_report; 要出件须研发先补 model_run/fusion 两步'),
+         func='windcms 里其余件 (厂家报告转录 / 知识库出件等)',
+         algo='(无随包生成端)', gen=None, input=None, pred=(),
+         why='上述两族与 cache 之外剩下的 windcms 件: 运行期不从交付包补(用户令 2026-09-17), '
+             '要出件须研发补生成端'),
     dict(id='sop_workspace', glob='sop/**', kind='shipped',
          func='SOP 中间件/评审/事实契约底稿', algo='(无生成端)', gen=None, input=None, pred=(),
          why='评审过程件, 属人工作业留痕'),

+ 14 - 10
scripts/rebuild_all.py

@@ -66,14 +66,17 @@ def build_plan(a) -> list:
                               'outputs/<场>/windscada/_pre_rebuild_20260911/'))
     if not a.skip_vib:
         # 振动侧 (2026-09-12 用户令"振动数据参与运行、重算"): 原始导出的 Brande TCM JSON
-        # (data/raw/<场站>/windcms/**/*_decode.json) → 窗索引 + 谱库。消费者 (src/windcms/data.py)
-        # 自动发现新窗, 所以这一步之后 CMS 的谱图/标量/页面就带上了新数据。
-        # ★**不跑** `windcms.py report` —— 本包缺六层链的 model_run/fusion 产物, 重生成会用残缺输入
-        #   覆盖随包快照 (report.md 16,857 B → 467 B, overview.html −15%; 实测见 vib_raw_build.py 文件头)。
-        #   六层链补齐后应把该步改为带 --with-report。
+        # (data/raw/<场站>/windcms/**/*_decode.json) → 窗索引 + 谱库 **+ CMS 报告/逐台页**。
+        # 消费者 (src/windcms/data.py) 自动发现新窗, 所以这一步之后 CMS 的谱图/标量/页面就带上了新数据。
+        # ★2026-09-18 用户令(" /detail 各页面不许用旧版产出补, 必须基于输入数据重算")后**口径翻转**:
+        #   原先默认不跑报告(`--vib-report` 显式开), 理由是"重生成会让 report.md 从 16,857 B 掉到
+        #   467 B(融合级表空)" —— 那是拿**随包旧快照**当基准; 现在包里没有旧产物, 页面要么有自算内容
+        #   要么是空的。实测从重算出的窗 w0316 一键出报告: 38 台判级齐(报警 3/优秀 35), 只缺
+        #   model_run/fusion 两步的**融合级列**(如实写 —), `/api/vibcms` 立即可用。
+        #   故默认带报告, 要旧行为用 --no-vib-report。
         # 现场没有振动原始件时本步**空跑退出 0** (不是每台机器都有振动导出, 不该报失败)。
-        plan.append(step_cmd('④b 振动侧摄入 (原始导出 → 窗索引/谱)',
-                             [PY, 'scripts/vib_raw_build.py'] + (['--with-report'] if a.vib_report else []),
+        plan.append(step_cmd('④b 振动侧摄入 + CMS 报告 + 标量 z (原始导出 → 窗索引/谱/逐台页/融合 z)',
+                             [PY, 'scripts/vib_raw_build.py'] + ([] if a.vib_report else ['--no-report']),
                              note='没有振动原始件时空跑属正常; 若解析出错会以非零退出 (不静默)'))
     # ⑤a 台账维护 (用户令 2026-09-18: 页面只认"有来路的件" ⇒ 台账必须跟着重算一起更新)。
     #    ★实逮: 2026-09-18 用户「清除产物」后从 raw 全量重算, `outputs/<场>/_provenance.json`
@@ -150,9 +153,10 @@ def main() -> int:
     ap.add_argument('--src', default=None, help='现场数据包目录 (给了就先跑 place_raw_data --scope full)')
     ap.add_argument('--skip-scada', action='store_true', help='跳过 SCADA 侧 10 个构建器 (没换 10min 数据时用)')
     ap.add_argument('--skip-vib', action='store_true', help='跳过振动侧摄入 (没换 CMS 原始导出时用)')
-    ap.add_argument('--vib-report', action='store_true',
-                    help='振动侧额外重生成 CMS 报告/知识库 —— ★默认关: 本包缺六层链的 model_run/fusion '
-                         '产物, 重生成会掉内容 (report.md −97%), 详见 scripts/vib_raw_build.py 文件头')
+    ap.add_argument('--no-vib-report', dest='vib_report', action='store_false',
+                    help='振动侧只摄入(索引/谱), 不生成 CMS 报告/逐台页 —— ★默认**生成**(2026-09-18 口径'
+                         '翻转: 包里没有旧产物可参照, 页面要么有自算内容要么空白; 报告只缺 '
+                         'model_run/fusion 的融合级列, 如实写 —)')
     ap.add_argument('--no-restart', action='store_true', help='不自动重启服务')
     ap.add_argument('--with-verify', action='store_true', help='末尾加等价验收')
     ap.add_argument('--dry-run', action='store_true', help='只打印计划')

+ 9 - 3
scripts/rudong_fusion_run.py

@@ -135,11 +135,17 @@ def main() -> int:
     df.to_parquet(out, index=False)
     print(f'已写 {P.rel(out)}: {len(df)} 行 × {len(df.columns)} 列(窗 {a.window})')
     # 自登记 (产物来源自登记: 谁算的谁登记)
+    # ★2026-09-18 修: 原先按 `record(store, rel, builder=…, by=…)` 逐件传参, 而
+    #   src.derived_manifest.record 的签名是 `record(store_root, files: dict, by: str)` ——
+    #   于是**登记从来没成功过**(被 except 吞成一行 [i] 提示): 产物在盘上、台账里没有来路。
+    #   与 vib_raw_build.py 的调用形态对齐(那处传的是 dict)。
     try:
         from src import derived_manifest as DM
-        DM.record(P.out_root(), out.relative_to(P.out_root()).as_posix(),
-                  builder='scripts/rudong_fusion_run.py (val=median(scalar); z=(val-fleet_med)/(1.4826*MAD))',
-                  by='rudong_fusion_run', note='逆向工程口径, 与随包样件逐值对拍通过')
+        DM.record(P.out_root(),
+                  {out.relative_to(P.out_root()).as_posix():
+                   'scripts/rudong_fusion_run.py (val=median(scalar); z=(val-fleet_med)/(1.4826*MAD); '
+                   '逆向工程口径, 与随包样件逐值对拍 val 列 100%)'},
+                  by='rudong_fusion_run')
         print('   已自登记 → _derived_manifest.json')
     except Exception as e:
         print(f'   [i] 自登记跳过: {type(e).__name__}: {e}')

+ 21 - 20
scripts/vib_raw_build.py

@@ -26,29 +26,26 @@
         src/windcms/data.py::spectrum()     → 谱图能取到 (此前 spectra 库整个缺失, 谱图是死的)
         scripts/windcms.py report / kb      → 报告与知识库重生成
 
-## ★ 为什么"重生成 CMS 报告"默认**不跑** (2026-09-12 实测)
+## ★ 报告默认**跑**了 (2026-09-18 用户令后口径翻转)
 
-跑一次 `scripts/windcms.py report` 在本包会**让产物变差**, 不是变好:
+2026-09-12 的旧口径是"默认不跑": 那时拿**随包快照**当基准, 重生成会让 `windcms/report.md`
+从 16,857 B 掉到 467 B (−97%), 因为 `data.load_model()` 要读的 `m5/model_run_l6.parquet` 与
+`m5/fusion_38.csv` 属六层链 `model_run`/`fusion` 两步, 那两步脚本没随包。
 
-| 件 | 随包快照 | 本包重生成后 | 变化 |
-|---|---|---|---|
-| `windcms/report.md` | 16,857 B (含逐台融合级表 + L4 过闸谱线) | **467 B** (融合级表空、L4 写"无") | **−97.2%** |
-| `windcms/overview.html` | 646,218 B | 548,466 B | −15.1% |
-| `windcms/index_eng.html` | 443,841 B | 408,112 B | −8.0% |
+用户令 2026-09-18「`/detail` 各页面不能用旧版产出补, 必须基于输入数据重算」把这条基准**换掉了**:
+交付包里根本没有旧产物可比, 缺的那个"完整快照"也不该再出现在页面上。于是:
 
-根因不在数据, 在**缺件**: `src/windcms/data.py::load_model()` 要读
-`m5/model_run_l6.parquet` 与 `m5/fusion_38.csv`, 而这两件属六层链的 `model_run` / `fusion` 两步 ——
-**那四步脚本没随包**(见 `missing_chain`), 产物也不在。于是重生成 = 用残缺输入覆盖完整快照。
-
-所以: 摄入(索引/谱)默认跑, **报告/知识库默认不跑**; 要跑得显式 `--with-report`, 并且跑之前**先备份**
-`outputs/<场>/windcms/`。六层链补齐后这个默认值应当翻过来 (那时重生成才会≥快照)。
+  · 报告里少的只有**融合级**那一列(如实写 `—`, 因为 model_run/fusion 两步确实没跑),
+    其余判级(状态等级/证据状态/行动等级/逐台页/总览)全部由 `data/raw/<场站>/windcms` 的
+    `*_decode.json` 现算 —— 实测 2026-09-18: 38 台判级出来(报警 3 / 优秀 35), `/api/vibcms` 立即可用;
+  · 故本脚本**默认**带报告与知识库 (`--no-report` 可关), `rebuild_all.py` 的 ④b 同步。
 
 ## 用法
 
-    python scripts/vib_raw_build.py --farm rudong                     # 摄入(索引+谱), 不动报告
+    python scripts/vib_raw_build.py --farm rudong                     # 摄入(索引+谱) + 报告/知识库
     python scripts/vib_raw_build.py --window w0316                    # 指定窗名
     python scripts/vib_raw_build.py --jobs 12 --meas FFT_             # 并行/测量过滤
-    python scripts/vib_raw_build.py --with-report                     # 额外重生成 CMS 报告/知识库(见上, 慎用)
+    python scripts/vib_raw_build.py --no-report                       # 只摄入, 不动报告/知识库
     python scripts/vib_raw_build.py --dry-run                         # 只报会做什么
 """
 from __future__ import annotations
@@ -138,9 +135,9 @@ def main() -> int:
     ap.add_argument('--jobs', type=int, default=min(8, (os.cpu_count() or 4)))
     ap.add_argument('--meas', default='FFT_', help="谱摄入的测量过滤 (默认 FFT_; ALL=全转)")
     ap.add_argument('--skip-spectra', action='store_true', help='只做索引 (谱很占盘)')
-    ap.add_argument('--with-report', action='store_true',
-                    help='额外跑 windcms.py report/kb —— ★默认关: 本包缺六层链的 model_run/fusion 产物, '
-                         '重生成会让 report.md/overview.html 掉内容 (见文件头实测表); 跑前先备份 windcms\\')
+    ap.add_argument('--no-report', dest='with_report', action='store_false',
+                    help='只摄入(索引+谱), 不跑 windcms.py report/kb')
+    ap.set_defaults(with_report=True)   # ★2026-09-18 口径翻转: 见文件头"报告默认跑了"
     ap.add_argument('--register-only', action='store_true',
                     help='不摄入, 只把**已存在**的窗体件补进来源自登记 (幂等修复: 例如先前的摄入跑在自登记'
                          '功能之前, 台账就漏记了那些件)')
@@ -225,9 +222,13 @@ def main() -> int:
     if a.with_report:
         run('report', [PY, str(ROOT / 'scripts/windcms.py'), 'report', '--farm', a.farm], log)
         run('kb', [PY, str(ROOT / 'scripts/windcms.py'), 'kb', '--farm', a.farm], log)
+        # 六层链 fusion 步里**唯一已被标准答案验证过**的那一件 (fleet_scalar_z): 窗索引 → 同工况族中位
+        # → 稳健 z。放在这里是因为它按窗算, 而本脚本刚知道这窗叫什么名字 (2026-09-18 用户令后上链:
+        # 页面的振动标量 z 面此前完全空白)。
+        run('fusion_fleet_z', [PY, str(ROOT / 'scripts/rudong_fusion_run.py'),
+                               '--window', final.name], log)
     else:
-        print('(跳过 CMS 报告/知识库重生成 —— 本包缺 model_run/fusion 产物, 重生成会掉内容; '
-              '要跑用 --with-report, 且先备份 windcms\\)')
+        print('(按 --no-report 跳过 CMS 报告/知识库重生成 —— 页面上"振动评估"一栏会显示为无产物)')
 
     man = dict(farm=a.farm, window=final.name, window_dir=str(final.relative_to(ROOT)),
                # 源件路径写**相对安装根**的形态: 这文件会随包分发, 绝对路径换机后就是死链

+ 3 - 3
scripts/windscada_serve.py

@@ -1342,11 +1342,11 @@ def fleet_view(win):
                    矩阵=mrows, 列窗=mheads, kpi=fkpi,
                    事件=ev, 事件边界=ev_bounds,
                    能力=dict(classes=cap['classes'], blind=[str(b) for b in cap['blind']], loop=cap.get('loop', {})),
-                   窗=dict(振动=fmeta['振动窗'], SCADA=fmeta['SCADA窗'], 油样=fmeta['油样窗']),
+                   窗=dict(振动=fmeta.get('振动窗', '—'), SCADA=fmeta.get('SCADA窗', ''), 油样=fmeta.get('油样窗', '')),
                    色标=fmeta.get('色标', {}), 数据时点=fmeta.get('数据时点', ''), handoff日期=fmeta.get('handoff日期', ''),
                    证据窗末=fmeta.get('证据窗末', ''),
-                   纪律=str(fmeta['纪律']), 盲区=[str(b) for b in fmeta['盲区']],
-                   open_items=[str(o) for o in fmeta['open_items']])
+                   纪律=str(fmeta.get('纪律', '')), 盲区=[str(b) for b in fmeta.get('盲区', [])],
+                   open_items=[str(o) for o in fmeta.get('open_items', [])], gap=fmeta.get('gap'))
     except Exception as e:
         fus = dict(err=str(e)[:160])
     kpi = dict(报警台=n_alarm_t, 关注台=len(watch), 全场=len(CFG['turbines']))

+ 7 - 1
src/windcms/config.py

@@ -70,8 +70,14 @@ FARMS = {
         'm5': P.m5(),
         # 窗集合动态发现 (2026-08-24): 写死清单会让新导入窗静默不进分析集 (w0308 实逮, C1 空结果类).
         # w0127 = 首窗特例 (index 在 m5 根); 其余扫 windows/w????/index.parquet (w_wavetest 等测试窗不匹配四位数字自然排除)
-        'windows': {'w0127': P.m5() / 'tcm_index.parquet',
+        # ★2026-09-18 修正 (用户令"页面只能基于输入数据重算"后实逮): 这里原来**无条件**把 w0127 挂上,
+        #   于是清过产物、只从 data/raw 重算出一窗(w0316)的机器上, 报告正文仍写"数据窗:w0127, w0316"
+        #   —— 报告替一个**不在位的窗**作证。改成: 只在索引真在位时收进来, 缺的记进
+        #   `windows_missing` 供消费方如实说明(不静默、也不冒充有数据)。
+        'windows': {**({'w0127': P.m5() / 'tcm_index.parquet'}
+                       if (P.m5() / 'tcm_index.parquet').is_file() else {}),
                     **{p.parent.name: p for p in sorted((P.m5() / 'windows').glob('w[0-9][0-9][0-9][0-9]/index.parquet'))}},
+        'windows_missing': [] if (P.m5() / 'tcm_index.parquet').is_file() else ['w0127'],
         'spectra_dir': P.m5() / 'spectra',
         'findings': P.m5() / 'findings.json',
         'fusion': P.m5() / 'fusion_38.csv',

+ 33 - 6
src/windscada/subsys/fusion.py

@@ -41,8 +41,9 @@ def load_handoff():
     的状态为"不可判", 依据写明缺件。
     """
     if not HANDOFF.exists():
-        GAP_HANDOFF[0] = (f'融合面缺件: {HANDOFF} (属"包内无生成端"的件, docs §7) —— '
-                          f'从旧交付包补齐: scripts/products_restore_missing.py --stash <包.zip>')
+        GAP_HANDOFF[0] = (f'融合面缺件: {HANDOFF} (属"包内无生成端"的件, docs §13.6) —— '
+                          f'★用户令 2026-09-17/18: 运行期**不从交付包补齐**, 页面如实显示"不可判"; '
+                          f'要出这件必须由研发补生成端(振动六层链 fusion 步), 或放原始件重算')
         return {}
     return json.loads(HANDOFF.read_text(encoding='utf-8'))   # 中文 JSON: 缺 encoding 会按系统 locale(cp936) 读
 
@@ -89,9 +90,17 @@ def fusion_table(cfg=None, all_turbines=False):
     cfg = cfg or farm()
     h = load_handoff()
     if not h or not (h.get('per_turbine') or {}):
-        # 缺 handoff (交付包不随产物 ⇒ 目标机必然缺): 返回**空表但列齐**, 上层把
+        # 缺 handoff (交付包不随产物 ⇒ 目标机必然缺): 返回**空表但列齐 + 列头齐全**, 上层把
         # GAP_HANDOFF[0] 那句"缺什么/怎么补"写进结果 —— 判级不造数, 页面显示缺件 (2026-09-17)。
-        return (pd.DataFrame(columns=['turbine', '部件', '振动结论', '温度', '柱塞', '油样窗', '依据']), {})
+        # ★2026-09-18 补: meta 原来给的是**空 dict**, 而 fleet_view 会取 meta['振动窗']/['纪律']/…
+        #   ⇒ 上层又变成 KeyError 报给用户(实测 `/api/fleet` 的 fus 回 `{"err": "'振动窗'"}` 那一类)。
+        #   缺件就把"缺"写进 meta 的每个键, 页面照常渲染并显示缺件原因。
+        gap = GAP_HANDOFF[0] or (f'融合面缺件: {HANDOFF} (包内无生成端, docs §13.6) — '
+                                 '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端')
+        return (pd.DataFrame(columns=['turbine', '部件', '振动结论', '温度', '柱塞', '油样窗', '依据']),
+                dict(数据时点='缺件', handoff版本='—', handoff日期='', 证据窗末='',
+                     振动窗='缺件 (需 handoff_vibration_v2.json)', SCADA窗='', 油样窗='',
+                     纪律=gap, 盲区=[], open_items=[], 色标={}, gap=gap))
     oil = oil_window(cfg)
     from . import temp_nbm
     wp = pathlib.Path(cfg['store']) / 'watch_channels_monthly.parquet'
@@ -372,7 +381,16 @@ def matrix(cfg=None, months=None):
     tdf = treg[0] if isinstance(treg, tuple) else treg
 
     # 振动: 逐台聚合 handoff verdicts (一台多部件取最重级)
+    # ★2026-09-18 修: 这里原来直接取 `h['per_turbine']` —— 而 `load_handoff()` 在缺件时返回的
+    #   正是**空 dict**(2026-09-17 改成响亮降级, 不再 raise), 于是清过产物、重算后没有
+    #   handoff_vibration_v2.json 的机器上, `/api/fleet` 的融合面回的是 `{"err": "'per_turbine'"}`
+    #   —— 一个 KeyError 被当成"这一面坏了"显示给用户, 而不是"缺哪件"。缺件就走空表 + gap 说明。
     vib = {}
+    if not h or not (h.get('per_turbine') or {}):
+        # 缺件就走**空表 + 列头说明**(与 fusion_table 同一口径): 页面显示"缺哪件", 不显示 KeyError。
+        gap = GAP_HANDOFF[0] or (f'融合面缺件: {HANDOFF} (包内无生成端, docs §13.6) —— '
+                                 '运行期不从交付包补齐(用户令 2026-09-17); 要出件须研发补生成端')
+        return [], dict(振动=f'缺件: {HANDOFF.name}', 温度='', 润滑='', 油液='', months=[], gap=gap)
     v1 = [(r, '发电机') for r in h['per_turbine'].get('v1_generator', [])] + \
          [(r, '齿轮箱') for r in h['per_turbine'].get('v1_gearbox', [])]
     new = [(r, r.get('component', '?')) for r in h['per_turbine'].get('new_2026_08_26', [])]
@@ -510,9 +528,18 @@ RENDERED_DP_KEYS = {'purpose', 'classes', 'meas_crossing', 'known_blind_spots',
 def capability():
     """§3 振动能力边界: **全量透传 + 白名单渲染** (2026-08-27 双线约定, 替代白名单透传 —
     v2.2 "端到端闭环"新键曾静默漏消费, 教训='读同路径自动吃到'只对既有字段成立).
-    生产方新增顶层键 → unrendered 自动列出提示接线, 不再依赖交接消息记得说."""
+    生产方新增顶层键 → unrendered 自动列出提示接线, 不再依赖交接消息记得说.
+
+    ★2026-09-18 修 (同 matrix 的教训): 这里原来直接取 `h['detectability_prior']` —— 缺 handoff 时
+    `load_handoff()` 返回空 dict, 于是 `KeyError: 'detectability_prior'` 被 fleet_view 包成
+    `fus={"err": "'per_turbine'"}` 之类的**程序错**显示给人。缺件就该说"缺件"。
+    """
     h = load_handoff()
-    dp = h['detectability_prior']
+    dp = h.get('detectability_prior') or {}
+    if not dp:
+        return dict(classes={}, blind=[], meas={}, purpose='', loop={}, unrendered=[],
+                    gap=GAP_HANDOFF[0] or (f'振动能力边界需 {HANDOFF.name} (包内无生成端, docs §13.6); '
+                                           '本机该件不在位 ⇒ 如实为空'))
     return dict(classes=dp.get('classes', {}), blind=dp.get('known_blind_spots', []),
                 meas=dp.get('meas_crossing', {}), purpose=dp.get('purpose', ''),
                 loop=dp.get('端到端闭环', {}),