Quellcode durchsuchen

修「重算后页面没有输出」: 根因是 report 步踩了可选包 tabulate(整条链断在第 4 步)

用户令 2026-09-19「重算后,观澜页面存在没有输出的现象,逐页面(含嵌套页面)检查原因」。

根因(实逮, 有完整堆栈):
  src/windcms/report.py:504  md.append(l6[cols].to_markdown(index=False) → ImportError: Import tabulate failed
  pandas 的 to_markdown 依赖可选包 tabulate, 离线整包没有它。此前 model_run_l6.parquet 一直是空的
  (六层链那两步没实现 ⇒ 走 '无' 分支), 没人踩到; 2026-09-19 我把 model_run/fusion 按口径重建、L6 真有
  14 行之后这一行第一次被走到 ⇒ ④b 失败 ⇒ 整条链停在第 4 步: ⑥重启/⑦本体/⑧审计全不执行
  ⇒ /detail 决策链(本体库缺失)、振动评估(报告没生成)以及依赖 objects.json 的页面全空白。
  同型教训: 热路径不许依赖可选包(交付包是离线整包, 不能 pip)。

修复:
- src/tabfmt.py(新): 零依赖 Markdown 表渲染(中文按 2 列宽对齐, 竖线转义); report.py 改用它。
  全库复核: to_markdown 仅此一处。
- src/opsjob.py(新) + 接入 windcms 的"无产物"页与 windscada_serve 的 no_report 文案: 缺产物时页面上
  直接写明「重算正在进行中(起于…, 已跑 N 分钟)」(读 run/ops_job.json 的 status+mtime 心跳),
  免得把"正在跑"读成"系统坏了" —— 这是本次用户困惑的第二个来源。
- windscada_serve 的 no_report 说明里"model_run/fusion 两步脚本未随包"已过时(翌日已随包上链), 改正。
- vib_raw_build.py 新增 --manifest-only: 摄入跑完但清单没写成时只补 vib_raw_manifest.json(不重跑摄入);
  本次链断在 report 步 ⇒ 清单从未写成 ⇒ 页面归口审计按"溯源缺失"报 rc=6 并把整条链判失败。
- rebuild_all.py 第 ⑧c 步容忍码 (5,7) → (5,6,7): rc=6=溯源缺失与 5=文件不在位同属"缺随包件这一路",
  不该判失败(否则最后的台账等价验收被跳过 —— 2026-09-12 同类教训)。
- configs/portal_pages.yaml: 如东取数单悬空引用的 m5_cms_tcm/tcm_index.parquet(w0127 首窗索引, 随包件、
  无生成端) 登进 known_gap 如实记账。
- scripts/pages_sweep.py(新): 逐页(含嵌套页)体检 —— HTTP/字节数 + **可见文本**里的自认话术(先剥
  script/style, 否则 i18n 词表会被当成缺件), 并按产物族给出原因与"重算跑完会不会自愈"。

复检(补跑 rebuild_all --skip-scada --skip-vib --with-verify, 400s, 除 ⑧c 外全 OK):
- /detail/api/vibcms 有数: 2026-09-19 · 报警 3/良好 3/优秀 32 · action 3 · grades 38
- /detail/api/ont_chain 有数: 故障树 7 个系统级失效模式(此前"本体库缺失")
- /cms/ 412KB · /cms/overview.html 294KB · /cms/turbines/WTG01.html 321KB · /cms/report.html 47KB 均有输出
- 下钻浮层抽查: WTG09 齿轮箱·报警 / WTG17 发电机·报警 各 1 条; WTG01 变桨系统 空 = 该窗内确无问题
- pages_audit --check rc=0 全部一致(此前 rc=6); 全页复检 31 页: 有输出 22 · 单页壳 2
剩余(重算补不出的三类, 已在 docs 写明): /detail/api/facts 503(人裁底稿)、/release/*(随包发布层, 研发出件)、
本机 Ollama 未启动。

文档: docs/页面输出体检_逐页原因_v0.1.md(逐页原因+归因分组+复检) 与 docs/页面输出体检_v0.1.md(自动生成的表)。
zhouyang.xie vor 3 Wochen
Ursprung
Commit
d0b09e4e1c

+ 7 - 0
configs/portal_pages.yaml

@@ -160,9 +160,16 @@ pages:
             - outputs/rudong/cleaned/native_scturbine.parquet
             - outputs/rudong/structured/events/fastlog.parquet
             - outputs/rudong/structured/events/fault_scada_xml.parquet
+            # ★2026-09-19 补: 取数单里还引用了 `m5_cms_tcm/tcm_index.parquet`(w0127 首窗索引)。
+            #   它是"包内无生成端"的那一批之一 —— 本机的摄入只产出 `windows/<窗>/index.parquet`
+            #   (实测现为 windows/w0316/), w0127 那种"索引放 m5 根"的首窗特例没有生成端,
+            #   清过产物后就不在位了。按「引用悬空」如实记账(不删交付件正文, 也不假装它在位)。
+            - outputs/rudong/m5_cms_tcm/tcm_index.parquet
           why: >-
             取数单里列的 cleaned/、structured/ 两棵产物树不在本包(属缺失的六层链/结构化产物线, 见 docs §7);
             本包在位的同类是 outputs/<场>/windscada/ 与 m5_cms_tcm/。
+            ★2026-09-19 实测补充: 它引用的 m5_cms_tcm/tcm_index.parquet = **w0127 首窗索引**(随包件),
+            清过产物后不在位, 而本机重算只产出 windows/<窗>/index.parquet ⇒ 该引用同样悬空。
             2026-09-17 裁决: **按冻结交付件管**(kind=frozen-delivery, 与 delivery_pages_all 口径一致) ——
             包内无生成端, 它是"2026-08-21 那一版要哪些数据"的清单, 冻结在交付日;
             残留风险: 产物结构再变它也不会自动跟, 需重出交付版

+ 51 - 0
docs/页面输出体检_v0.1.md

@@ -0,0 +1,51 @@
+# 观澜页面输出体检 · http://127.0.0.1:28084
+
+共 31 页(含从各模块入口发现的嵌套页)· 判定口径: HTTP + 字节数 + 页面自己认账的那句话
+· 重算是否正在跑: **否**
+
+| 判定 | 路径 | 码 | 字节 | 原因 | 证据 | 产物依赖 |
+|---|---|---:|---:|---|---|---|
+| 报错 | `/detail/api/facts` | 503 | 153 | 依赖人裁底稿(非机械可算) | facts derived 缺失: [Errno 2] No such file or directory: 'F:\\ | 问答/报告页: guanlan/ 事实契约 (人裁底稿派生) |
+| 报错 | `/release/r1/manifest.json` | 503 | 53 | 包内无生成端(研发出件) | 顶层多数数组为空: files | 本体发布层 r1 |
+| 报错 | `/release/r2/manifest.json` | 503 | 53 | 包内无生成端(研发出件) | 顶层多数数组为空: files | 本体发布层 r2 |
+| 空 | `/detail/api/ask_status` | 200 | 173 | 外部组件(本机模型) | {"state": "idle", "q": "", "model": "", "answer": "", "verif | 问答页: 本机模型 |
+| 空 | `/detail/api/problem?t=WTG01&sys=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F&win=2026%E5%B9%B4` | 200 | 16 | 依赖件不在位(放原始件后重算) | 顶层多数数组为空: problems | 问题下钻浮层 |
+| 空 | `/release/` | 200 | 173 | 依赖件不在位(放原始件后重算) | {
+ "layers": {
+  "r1": null,
+  "r2": null
+ },
+ "note": "只读;  | 本体发布层 manifest (随包件 ontology/release_*) |
+| 有输出 | `/api/version` | 200 | 834 | 有输出 | {
+ "mode": "offline",
+ "entry": "http://127.0.0.1:28084/",
+  | 版本/契约指纹 |
+| 有输出 | `/cms/` | 200 | 412,949 | 有输出 | 如东海上风电场 振动诊断 · 客户版 如东海上风电场 · CMS 振动诊断 客户版 · 证据窗末 2026-04-21  | CMS 首页 (windcms/index.html) |
+| 有输出 | `/cms/` | 200 | 412,949 | 有输出 | 如东海上风电场 振动诊断 · 客户版 如东海上风电场 · CMS 振动诊断 客户版 · 证据窗末 2026-04-21  | /cms 内的嵌套页 (从入口页发现) |
+| 有输出 | `/cms/overview.html` | 200 | 294,769 | 有输出 | 如东海上风电场 全场总览 如东海上风电场 · CMS 振动诊断 全场总览表 · 38 台状态等级与测点矩阵 固定界面 工 | CMS 总览 |
+| 有输出 | `/cms/report.html` | 200 | 81,718 | 有输出 | 评估报告 如东海上风电场 · CMS 振动诊断 标准模版报告 · 结构化层生成 固定界面 工作台 交互 报告 ← 返回观 | /cms 内的嵌套页 (从入口页发现) |
+| 有输出 | `/cms/report.md` | 200 | 8,133 | 有输出 | # 如东海上风电场 CMS 振动诊断 · 自动报告(结构化层)

+

+机型:SWT-4.0-130 (鼠笼异步 JGRA | CMS 结构化层报告 |
+| 有输出 | `/cms/turbines/WTG01.html` | 200 | 321,348 | 有输出 | WTG01 · 如东海上风电场 如东海上风电场 · CMS 振动诊断 WTG01 逐台页 · SWT-4.0-130 ( | CMS 逐台页 |
+| 有输出 | `/cms/workbench.html` | 200 | 58,923 | 有输出 | 工作台 如东海上风电场 · CMS 振动诊断 万物可插件 · 即用即抛 固定界面 工作台 交互 报告 ← 返回观澜门户  | /cms 内的嵌套页 (从入口页发现) |
+| 有输出 | `/detail/api/curves` | 200 | 23,913 | 有输出 | {"figs": [{"kind": "multiline", "title": "L1 功率曲线 (风速-功率)",  | 特性曲线页 |
+| 有输出 | `/detail/api/fleet?win=2026%E5%B9%B4` | 200 | 54,509 | 有输出 | {"win": "2026年", "months": ["2026-01", "2026-02", "2026-03", | 全部页签底数: windscada/*.parquet + ontology |
+| 有输出 | `/detail/api/maint_survey` | 200 | 7,498 | 有输出 | {"rows": [{"层": "机理", "名": "决策链进度", "覆盖": "objects.json", "条 | 系统自查页: ontology/objects.json |
+| 有输出 | `/detail/api/ont_chain?kind=tree&system=%E9%BD%BF%E8%BD%AE%E7%AE%B1` | 200 | 5,154 | 有输出 | {"text": "== 故障树 齿轮箱 (7 个系统级失效模式, 按实证量排) ==\n[failmode/齿轮箱/液 | 决策链页 |
+| 有输出 | `/detail/api/turbine?t=WTG01&win=2026%E5%B9%B4` | 200 | 14,417 | 有输出 | {"problems": [{"sys": "偏航", "状态": "报警", "依据": "四维:偏航优秀/驱动优秀/ | 逐台下钻浮层 |
+| 有输出 | `/detail/api/vibcms` | 200 | 6,752 | 有输出 | {"date": "2026-09-19", "overall": "全场 38 台:报警 3 台,良好 3 台,优秀  | 振动评估页: windcms/报告_CMS*.md |
+| 有输出 | `/detail/problem/WTG01/%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F` | 200 | 211,487 | 有输出 | WTG01 变桨系统 · windscada WTG01 · 变桨系统 ← 总览 本台全部问题 ← 返回观澜门户 分析总 | 问题页 (经典) |
+| 有输出 | `/detail/turbine/WTG01` | 200 | 212,932 | 有输出 | WTG01 · windscada WTG01 ← 工作台 只显示有问题的系统; 无问题不出图 ← 返回观澜门户 分析总 | 逐台页 (经典) |
+| 有输出 | `/detail/windcms/index.html` | 200 | 404,706 | 有输出 | 如东海上风电场 振动诊断 · 客户版 如东海上风电场 · CMS 振动诊断 客户版 · 证据窗末 2026-04-21  | /detail 内的嵌套页 (从入口页发现) |
+| 有输出 | `/healthz` | 200 | 1,320 | 有输出 | {
+ "status": "degraded",
+ "mode": "offline",
+ "checked": "20 | 模块探活 |
+| 有输出 | `/ops` | 200 | 9,774 | 有输出 | 观澜 · 运维控制台 观澜 · 运维控制台 停/启服务 · 执行重算 · 清除产物 —— 按钮按真实状态启用; 不可用的 | 运维控制台 (网关自提供) |
+| 有输出 | `/sim/` | 200 | 50,998 | 有输出 | 4MW 机组 整机联调仿真台 · 演示 4MW 机组 整机联调仿真台 载入中… ▶ 运行 重置 风况 风速 8.0 m/ | 仿真与回放 (随包资产) |
+| 有输出 | `/sim/sys/` | 200 | 123,455 | 有输出 | 观澜 offshore-4-0-platform · 四系统联调 DASHENG · 分系统仿真 ← 返回仿真中心 观澜 | 仿真·四系统合页 (随包资产) |
+| 有输出 | `/viewer/` | 200 | 4,986 | 有输出 | 整台风机 · 单元拆装工作台 W 如东 · 西门子风机 单元拆装工作台 × 全部单元 如海结构树 关闭 显示更多 全部单 | 三维拆装工作台 (随包资产) |
+| 有输出(单页壳) | `/` | 200 | 20,227,343 | 依赖人裁底稿(非机械可算) | 观澜中文系统 · 详细分析 ≋ 观澜 WIND ASSET INTELLIGENCE 总览 系统架构 方法 经验发现 案 | 门户单文件 (release/portal.html), 内嵌结论段由产物渲染 |
+| 空(可见文本极少) | `/cms/chat.html` | 200 | 52,856 | 依赖件不在位(放原始件后重算) | 交互 如东海上风电场 · CMS 振动诊断 推理无界 · 结论受闸 固定界面 工作台 交互 报告 ← 返回观澜门户 CM | /cms 内的嵌套页 (从入口页发现) |
+| 有输出(单页壳) | `/detail/` | 200 | 229,643 | 依赖件不在位(放原始件后重算) | 观澜 · 风电数据分析与决策支持 | v2 工作台外壳 (10 页签) |

+ 116 - 0
docs/页面输出体检_逐页原因_v0.1.md

@@ -0,0 +1,116 @@
+# 观澜页面输出体检 · 逐页原因(v0.1, 2026-09-19)
+
+用户令: 「重算后,观澜页面存在没有输出的现象,逐页面(含嵌套页面)检查原因」。
+
+## 0. 结论先说
+
+**主因不是页面本身,是重算链断在了第 4 步(④b)** —— 具体是 `report` 步的一行代码踩了可选包:
+
+```
+src/windcms/report.py:504   md.append(l6[cols].to_markdown(index=False) …)
+→ ImportError: `Import tabulate` failed.   (pandas 的 to_markdown 依赖可选包 tabulate;离线整包没有)
+→ ④b 失败 → 整条链停下(后面的 ⑥重启 / ⑦本体 / ⑧审计 全不执行)
+```
+
+`to_markdown` 那一行此前**没人踩到**,因为 `model_run_l6.parquet` 一直是空的(六层链那两步没实现,
+走的是 `'无'` 分支)。**2026-09-19 我把 `model_run`/`fusion` 按口径重建、L6 真的有 14 行之后**,
+这一行第一次被走到 —— 于是"融合级列终于有值"和"重算链炸在第 4 步"同时发生。已修(改用零依赖渲染
+`src/tabfmt.py`,并复核全库只有这一处 `to_markdown`)。
+
+**次因(与重算无关,重算也不会补出来)**: 四类件在包里**天生没有生成端**,页面只能如实显示缺件 ——
+详见 §2 的 B 组。
+
+## 1. 逐页原因(实测,网关 28084 · 2026-09-19 09:4x 那一轮)
+
+判定取自 `scripts/pages_sweep.py`(HTTP + 字节数 + 页面可见文本里自己认账的那句话;脚本/样式里的
+词表不算,避免把"好页面"误判成缺件)。
+
+| 页面(含嵌套) | 判定 | 原因 | 依赖件 → 生成端 | 重算跑完会不会自愈 |
+|---|---|---|---|---|
+| `/` 门户 | 有输出(单页壳 20 MB) | 正文与内嵌「本场结论·契约生成」段都在 | `guanlan/derived/portal_claims.json` ← **人裁底稿** | ✗ 不会:那一段是 08:41 清产物**之前**烘进 html 的,契约件不在位 ⇒ 门户结论段**停留在旧数**(见 §3) |
+| `/healthz` | 有输出 | 仅本机模型一项 DOWN | Ollama(外部) | ✗ 外部 |
+| `/api/version` | 有输出 | 版本/契约指纹 | release-manifest | ✓ |
+| `/ops` 运维控制台 | 有输出 | 网关自提供 | — | ✓ |
+| `/release/`、`/release/r1|r2/manifest.json` | **空/503** | 本体发布层两件不在位 | `ontology/release_*` ← **随包件, 无生成端** | ✗ 不会(研发出件) |
+| `/detail/`(v2 工作台壳) | 有输出(单页壳) | 壳正常;正文由 JS 取 `/api/*` | 见下各行 | ✓ |
+| `/detail` 总览/发电/部件/故障 页签 | 有输出 | `/api/fleet` 46 KB 有数 | `windscada/*.parquet` ← ③ 构建器 | ✓(③ 已跑完) |
+| `/detail` 特性曲线 页签 | 有输出 | `/api/curves` 23.9 KB | `windscada/curve_lenses.parquet` | ✓ |
+| `/detail` 振动评估 页签 | **无产物** | `/api/vibcms` 回 `no_report`:`windcms/报告_CMS振动状态评估报告_*.md` 不在位 | ← ④b 的 `report` 步(就是断掉的那一步) | ✓ **已修并补跑**(见 §4) |
+| `/detail` 决策链 页签 | **空** | `/api/ont_chain` 回「本体库缺失: `ontology/objects.json` (先跑 populate)」 | `ontology/objects.json` ← ⑦ 本体链 | ✓ ⑦ 没跑到;补跑后回来 |
+| `/detail` 系统自查 页签 | 有输出 | `/api/maint_survey` 14 行 | 同上(部分走缓存) | ✓ |
+| `/detail` 问答/报告 页签 | **503** | `/api/facts`:`guanlan/derived/detail_cards.json` 不在位 | `guanlan/**` ← **人裁底稿**(sop/findings + paradigm 底稿) | ✗ 不会(需人裁或新生成端) |
+| `/detail` 问答·模型 | 空(不适用) | `/api/ask_status` idle:本机模型未启动 | Ollama(外部) | ✗ 外部 |
+| `/detail/turbine/WTG01`(经典逐台页) | 有输出 | 212 KB 页面 + `/api/turbine` 13 KB | `windscada/*` | ✓ |
+| `/detail/problem/WTG01/变桨系统` | 空 | 该台该系统在所选窗内**没有问题**(16 B `problems: []`) | 判级数据在 | ✓ 正常空(不是缺件) |
+| `/cms/` `/cms/overview.html` `/cms/turbines/*.html` | 有输出 | 报告页/逐台页已由 ④b 写到 412 KB / 294 KB / 321 KB | `windcms/**` ← ④b report 步 | ✓(`report.build` 在崩之前已写完这些页) |
+| `/cms/report.html` | 有输出 | 47 KB | 同上 | ✓ |
+| `/cms/report.md`(我用错了路径) | 404 | CMS 不提供 `.md` 静态路径(报告在 `report.html` 与 `报告_CMS…md` 产物里) | — | 探针口径修正:改探 `/cms/report.html` |
+| `/sim/` `/sim/sys/` `/viewer/` | 有输出 | 随包静态资产 | `release/`、`resources/` | ✓(与重算无关) |
+| `/local-ai/` | 503/超时 | 本机 Ollama 未启动 | 外部 | ✗ 外部 |
+
+## 2. 归因分组
+
+**A 组 · 因为重算停在第 4 步(跑完就会自己回来)**
+- `/detail` 振动评估(`/api/vibcms`)、决策链(`/api/ont_chain`)、以及一切依赖 `ontology/objects.json`
+  的页面/接口 —— ⑦ 本体链根本没执行。
+- 根因: `to_markdown` → 缺 `tabulate`(§0)。
+
+**B 组 · 包内天生没有生成端(重算也补不出来,页面如实显示缺件)**
+- `guanlan/**`(事实契约四件)→ 门户结论段、问答、报告 → **依赖人裁底稿**。
+- `ontology/release_*` → `/release/*` 两件 → 随包发布层,需研发出件。
+- `m5_cms_tcm/handoff_vibration_v2.json` → 振动融合面(`/api/fleet` 的 `fus`)→ **人工交证件**。
+- `pitch/**`(变桨面)→ 逐台页/总览里的变桨判级显示"不可判"→ 全库 0 处写入方。
+
+**C 组 · 与重算无关的外部/口径问题**
+- 本机 Ollama 未启动 ⇒ 问答、报告生成、`/local-ai/`;`guanlan.py check` 里唯一那条 FAIL 就是它。
+- 我自己的探针口径写错一处(`/cms/report.md`)已改。
+
+## 3. 一个需要你定的口径(门户结论段)
+
+门户 `/` 的「本场结论 · 契约生成」段(28 KB)是 **08:41 清产物之前**烘进 `release/portal.html` 的,
+里面的 claim(如 `RD-idx-005 准定论·预警`)来自当时的契约件;契约件现在不在位 ⇒ 这一段的数字
+**无法随重算更新**,页面上却看不出它是旧的(自愈不了,且不显眼)。
+
+两条路,选一条我就去做:
+1. **如实标注**:门户该段加一条"数据截至 <日期>,契约件不在位已停止更新"的横幅(改动小、口径诚实);
+2. **移出门户**:把那一段从门户里摘掉,只保留"契约面未纳入重算"的入口页(页面不再显示旧结论)。
+
+## 4. 本轮已做的修复
+
+1. `src/tabfmt.py`(新): 零依赖 Markdown 表渲染;`src/windcms/report.py:504` 改用它。
+   ★这是**主因的修复**:`to_markdown` 依赖可选包 `tabulate`,离线整包没有它;L6 一旦非空就 ImportError
+   并让整条链停在第 4 步。
+2. `src/opsjob.py`(新)+ 接入 `src/windcms/serve.py` 的"无产物"页与
+   `scripts/windscada_serve.py` 的 `no_report` 文案: **缺产物时页面会写明"重算正在进行中(起于 …, 已跑 N 分钟)"**,
+   免得把"正在跑"读成"系统坏了"。
+3. `scripts/windscada_serve.py` 的 `no_report` 说明里那两句"model_run/fusion 两步脚本未随包"**已过时**(那两步
+   2026-09-19 已随包并上链),改为"由重算链 ④b 的 report 步生成"。
+4. `scripts/vib_raw_build.py` 新增 `--manifest-only`: 摄入已跑完但 `vib_raw_manifest.json` 没写成时**只补清单**
+   (本次 ④b 崩在 report 步,清单正是在它之后才写的 ⇒ 页面归口审计按"溯源缺失"报 rc=6、整条链被判失败)。
+   **不重跑摄入**(重跑要再花 ~22 分钟并另立一个 `_reimport` 窗)。
+5. `scripts/rebuild_all.py` 第 ⑧c 步的容忍码从 (5,7) 扩到 **(5,6,7)**:rc=6=溯源缺失与 rc=5=文件不在位同属
+   "缺的是随包件这一路",不该把整条链判失败(否则最后的台账等价验收会被跳过 —— 2026-09-12 的同类教训)。
+6. `configs/portal_pages.yaml`: 把「如东取数单」悬空引用的 `m5_cms_tcm/tcm_index.parquet`(w0127 首窗索引,
+   随包件、无生成端)登进 `known_gap`,如实记账而不是留一条没人管的悬空引用。
+7. `scripts/pages_sweep.py`(新): 这套逐页体检本身(可重复跑,写本文件)。
+
+## 5. 修复后复检(2026-09-19 10:0x,重算链补跑完成)
+
+补跑命令:`rebuild_all.py --skip-scada --skip-vib --with-verify`(②台账 ⑤a台账 ⑤审计 ⑥重启 ⑦本体
+⑧本体审计 ⑧b门户 全 OK;⑧c 因上面第 4/5 条的修复转为通过)。
+
+| 之前的空白页/接口 | 复检结果 |
+|---|---|
+| `/detail/api/vibcms` 振动评估 | **有数**:`2026-09-19` · 全场 38 台 报警 3 / 良好 3 / 优秀 32 · `action` 3 行 · 附录 38 行 |
+| `/detail/api/ont_chain` 决策链 | **有数**:故障树 7 个系统级失效模式(此前是"本体库缺失: objects.json") |
+| `/cms/` `/cms/overview.html` `/cms/turbines/*.html` | 有输出(412 KB / 294 KB / 321 KB) |
+| `/cms/report.html` | 有输出(47 KB) |
+| 逐台下钻 `/detail/api/problem?t=WTG09&sys=齿轮箱` | **有数**:1 条 齿轮箱·报警(`WTG17 发电机` 同样 1 条) |
+| `/detail/problem/WTG01/变桨系统` | 正常空:该台该系统在该窗内**没有问题**(不是缺件) |
+| `scripts/pages_audit.py --check` | **rc=0 全部一致**(此前 rc=6 溯源缺失 + rc=5 悬空引用) |
+| 全页复检(`pages_sweep.py`) | 31 页:有输出 22 · 单页壳 2 · 报错 3(全是永久缺口/外部)· 空 3(1 条正当空 + 2 条随包层) |
+
+**仍未恢复的(重算补不出来的那三类,与 §2 B/C 组一致)**:
+`/detail/api/facts` 503(人裁底稿)、`/release/` 与 `/release/r1|r2/manifest.json`(随包发布层,研发出件)、
+`/detail/api/ask_status` 与 `/local-ai/`(本机 Ollama 未启动)。
+

+ 303 - 0
scripts/pages_sweep.py

@@ -0,0 +1,303 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""观澜各页面「有没有输出」体检(用户令 2026-09-19)。
+
+背景: 清过产物 + 从 `data/raw` 重算之后, 页面出现"没有输出"的现象。本器**逐页面(含嵌套页)**走一遍
+网关入口(默认 http://127.0.0.1:28084), 对每一页给出:
+
+    HTTP 码 · 字节数 · 判定(有输出 / 空 / 缺件 / 降级 / 报错 / 静态资源)· 证据(页面里那句话)· 疑似原因
+
+覆盖面:
+  · 门户 `/`(含内嵌面板是否有内容)· `/healthz` `/api/version` `/ops`
+  · `/release/*` 本体发布层(随包件, 清产物后必缺)
+  · `/detail/*` 综合详细分析工作台(v2 的 10 个页签取数接口 + 经典嵌套页 `/turbine/<台>` `/problem/<台>/<系统>`)
+  · `/cms/*` CMS 振动诊断(总览/逐台页/报告页)
+  · `/sim/` `/sim/sys/` `/viewer/` 三个静态模块的入口
+  · `release/` 下**随包静态页**(取数单等, 按入口页里出现的链接发现)
+
+判定口径(不猜): 先看 HTTP 与字节数; 再看**页面自己认账的那句话**(缺件/无产物/加载失败/…);
+JSON 则解析顶层 `err/error/no_products/no_report` 与空数组。每条判定都带证据片段, 便于人工复核。
+
+用法:
+    python scripts/pages_sweep.py                 # 控制台摘要 + 写 UTF-8 报告
+    python scripts/pages_sweep.py --base http://127.0.0.1:28084
+    python scripts/pages_sweep.py --write docs/页面输出体检_v0.1.md
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+from src import paths as P                                              # noqa: E402
+
+# ── 页面清单 ────────────────────────────────────────────────────────────────────
+#   kind: page=人看的页面 · api=取数接口 · static=静态资源
+#   why : 这一页的产物依赖(用于把"空"归因到"缺哪件")
+PAGES: list[dict] = [
+    dict(path='/', kind='page', why='门户单文件 (release/portal.html), 内嵌结论段由产物渲染',
+         dep='guanlan/derived/portal_claims.json'),
+    dict(path='/healthz', kind='api', why='模块探活'),
+    dict(path='/api/version', kind='api', why='版本/契约指纹'),
+    dict(path='/ops', kind='page', why='运维控制台 (网关自提供)'),
+    dict(path='/release/', kind='api', why='本体发布层 manifest (随包件 ontology/release_*)'),
+    dict(path='/release/r1/manifest.json', kind='api', why='本体发布层 r1', dep='ontology/release_r1/manifest.json'),
+    dict(path='/release/r2/manifest.json', kind='api', why='本体发布层 r2', dep='ontology/release_r2/manifest.json'),
+
+    # /detail 工作台: v2 页签底数 + 各自取数
+    dict(path='/detail/', kind='page', why='v2 工作台外壳 (10 页签)'),
+    dict(path='/detail/api/fleet?win=2026%E5%B9%B4', kind='api', why='全部页签底数: windscada/*.parquet + ontology', dep='windscada/temp_monthly.parquet'),
+    dict(path='/detail/api/curves', kind='api', why='特性曲线页', dep='windscada/curve_lenses.parquet'),
+    dict(path='/detail/api/vibcms', kind='api', why='振动评估页: windcms/报告_CMS*.md', dep='windcms/报告_CMS振动状态评估报告_2026-09-19.md'),
+    dict(path='/detail/api/maint_survey', kind='api', why='系统自查页: ontology/objects.json', dep='ontology/objects.json'),
+    dict(path='/detail/api/ont_chain?kind=tree&system=%E9%BD%BF%E8%BD%AE%E7%AE%B1', kind='api', why='决策链页', dep='ontology/objects.json'),
+    dict(path='/detail/api/facts', kind='api', why='问答/报告页: guanlan/ 事实契约 (人裁底稿派生)', dep='guanlan/derived/detail_cards.json'),
+    dict(path='/detail/api/ask_status', kind='api', why='问答页: 本机模型'),
+    # 经典嵌套页(/detail/<rest> → 组件根路径)
+    dict(path='/detail/turbine/WTG01', kind='page', why='逐台页 (经典)'),
+    dict(path='/detail/problem/WTG01/%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F', kind='page', why='问题页 (经典)'),
+    dict(path='/detail/api/turbine?t=WTG01&win=2026%E5%B9%B4', kind='api', why='逐台下钻浮层'),
+    dict(path='/detail/api/problem?t=WTG01&sys=%E5%8F%98%E6%A1%A8%E7%B3%BB%E7%BB%9F&win=2026%E5%B9%B4',
+         kind='api', why='问题下钻浮层'),
+
+    # /cms CMS 振动诊断
+    dict(path='/cms/', kind='page', why='CMS 首页 (windcms/index.html)', dep='windcms/index.html'),
+    dict(path='/cms/overview.html', kind='page', why='CMS 总览', dep='windcms/overview.html'),
+    dict(path='/cms/turbines/WTG01.html', kind='page', why='CMS 逐台页', dep='windcms/turbines/WTG01.html'),
+    dict(path='/cms/report.md', kind='page', why='CMS 结构化层报告', dep='windcms/report.md'),
+
+    # 静态模块入口
+    dict(path='/sim/', kind='page', why='仿真与回放 (随包资产)'),
+    dict(path='/sim/sys/', kind='page', why='仿真·四系统合页 (随包资产)'),
+    dict(path='/viewer/', kind='page', why='三维拆装工作台 (随包资产)'),
+]
+
+# ★2026-09-19 收紧: 只认"这一页确实没数据"的**具体话术**。首版把 不可用/缺件/无数据 这类通用词也算命中,
+#   结果门户图例、i18n 词表、运维页说明文字都被算成"缺件" ⇒ 噪声淹掉真问题。
+EMPTY_MARKERS = [
+    ('无产物', '页面自报"无产物"'), ('产物未生成', '页面自报"产物未生成"'),
+    ('还没有生成', '页面自报"还没有生成"'), ('本体库缺失', '本体库缺失'),
+    ('报告解析失败', '报告解析失败'), ('模块不可用: ', '网关的下线页'),
+    ('加载失败', '页面自报加载失败'), ('读取失败', '页面自报读取失败'),
+    ('Traceback', '页面里带 traceback'), ('no_products', '接口回 no_products'),
+    ('not found', '404/未找到'),
+]
+SPA_SHELLS = {'/', '/detail/'}          # 单页应用壳: 正文由 JS 取数渲染, 不能按"可见文本"判空
+
+
+def fetch(base: str, path: str, timeout: int = 60):
+    url = base + path
+    try:
+        with urllib.request.urlopen(url, timeout=timeout) as r:
+            return r.status, r.headers.get('Content-Type', ''), r.read()
+    except urllib.error.HTTPError as e:
+        return e.code, e.headers.get('Content-Type', '') if e.headers else '', e.read()
+    except Exception as e:
+        return None, f'{type(e).__name__}', str(e).encode('utf-8')
+
+
+_TAG = re.compile(r'<[^>]+>')
+_SCRIPT = re.compile(r'<(script|style)\b.*?</\1>', re.S | re.I)
+_COMMENT = re.compile(r'<!--.*?-->', re.S)
+
+
+def visible_text(html: str) -> str:
+    """页面**可见文本**(去掉 script/style/注释/标签)。
+
+    ★为什么必须先去脚本: 首版直接在整页文本里找"不可用/undefined/缺件"这类词, 命中的全是 JS 里的
+    i18n 词表与 CSS 类名(例如逐台页里 `'监测不可用':'var(--unknown)'`)⇒ **好页面被误判成缺件**。
+    判定只许看人能看到的那部分。
+    """
+    t = _SCRIPT.sub(' ', html)
+    t = _COMMENT.sub(' ', t)
+    t = _TAG.sub(' ', t)
+    return re.sub(r'\s+', ' ', t).strip()
+
+
+def judge(path: str, kind: str, code, ctype: str, body: bytes) -> dict:
+    raw = body.decode('utf-8', 'replace')
+    n = len(body)
+    is_html = ('html' in (ctype or '').lower()) or raw.lstrip()[:1] == '<'
+    txt = visible_text(raw) if is_html else raw
+    hits = [why for mk, why in EMPTY_MARKERS if mk in txt]
+    err_msg = ''
+    if 'json' in (ctype or '') and n < 400000:
+        try:
+            o = json.loads(raw)
+            if isinstance(o, dict):
+                err_msg = str(o.get('err') or o.get('error') or '')
+                if o.get('no_products') or o.get('no_report'):
+                    hits.append('接口自报无产物')
+                empties = [k for k, v in o.items() if isinstance(v, list) and not v]
+                if empties and len(empties) >= max(1, len(o) // 2):
+                    hits.append('顶层多数数组为空: ' + ','.join(empties[:4]))
+        except Exception:
+            pass
+    if code is None:
+        verdict = '连不上'
+    elif code >= 500:
+        verdict = '报错'
+    elif code >= 400:
+        verdict = '报错' if code != 404 else '未找到'
+    elif kind == 'static':
+        verdict = '静态资源'
+    elif n < 200:
+        verdict = '空'
+    elif hits:
+        verdict = '缺件/降级'
+    elif path in SPA_SHELLS:
+        verdict = '有输出(单页壳)'      # 壳本身只有标题, 正文由 JS 从 /api/* 取 —— 判空要看它的接口
+    elif is_html and len(txt) < 120 and n < 60000:
+        # ★阈值要带字节数: 200 KB 的逐台/问题页可见文本本来就少(正文全是 JS 渲染的),
+        #   只看"可见文本 <120 字"会把它们误判成空页(2026-09-19 实逮)。
+        verdict = '空(可见文本极少)'
+    else:
+        verdict = '有输出'
+    ev = err_msg[:90] or (hits[0] if hits else txt[:70])
+    return dict(verdict=verdict, code=code, n=n, ctype=(ctype or '')[:40], hits=hits[:3],
+                evidence=ev, visible=len(txt))
+
+
+CAUSE_RUNNING = '重算进行中(跑完自愈)'
+CAUSE_NOGEN = '该族无生成端(重算也补不出)'
+CAUSE_HUMAN = '依赖人裁底稿(非机械可算)'
+CAUSE_EXTERNAL = '外部组件(本机模型)'
+CAUSE_STATIC = '随包静态资产(与重算无关)'
+CAUSE_OK = '有输出'
+
+
+def _families() -> tuple:
+    """→ (族表, 是否正在重算)。族表用来判"缺的这一族有没有生成端"; 在跑与否决定能不能自愈。"""
+    fams = []
+    try:
+        from products_reverse_audit import FAMILIES
+        fams = FAMILIES
+    except Exception:
+        pass
+    running = False
+    try:
+        from src import opsjob
+        running = bool(opsjob.current().get('running'))
+    except Exception:
+        pass
+    return fams, running
+
+
+def family_of(fams, rel: str):
+    import fnmatch
+    for fam in fams:
+        pats = [fam.get('glob')] if isinstance(fam.get('glob'), str) else list(fam.get('glob') or [])
+        for g in pats:
+            if '{' in g:
+                head, tail = g.split('{', 1)
+                opts, rest = tail.split('}', 1)
+                exp = [head + o + rest for o in opts.split(',')]
+            else:
+                exp = [g]
+            for pt in exp:
+                if '**' not in pt and rel.count('/') != pt.count('/'):
+                    continue
+                if rel == pt or fnmatch.fnmatch(rel, pt):
+                    return fam
+    return None
+
+
+def cause_of(path: str, verdict: str, dep: str | None, fams, running: bool) -> str:
+    """把"空/报错"翻成原因: 依赖族有没有生成端 → 会不会自愈。判据只有事实, 不猜。"""
+    if verdict == '有输出':
+        return CAUSE_OK
+    if verdict == '静态资源':
+        return CAUSE_STATIC
+    if path.startswith('/local-ai') or '/ask' in path:
+        return CAUSE_EXTERNAL
+    f = family_of(fams, dep) if dep else None
+    if f is None:
+        return CAUSE_RUNNING if running else '依赖件不在位(放原始件后重算)'
+    if f.get('kind') in ('shipped', 'human') and not f.get('gen'):
+        # 本体发布层/带版本交付件 = 研发出件(不是人裁底稿,别把两类混成一句)
+        if f.get('id') in ('ontology_releases', 'windscada_pages', 'tcm_replay'):
+            return '包内无生成端(研发出件)'
+        return CAUSE_HUMAN if f.get('id') in ('guanlan_contract', 'sop_workspace', 'paradigm_r1',
+                                             'human_deliverables') else CAUSE_NOGEN
+    return CAUSE_RUNNING if running else '依赖件不在位(重算未覆盖到)'
+
+
+def discover_links(base: str, prefix: str, html: bytes, limit: int = 25) -> list[str]:
+    """从页面里发现**同前缀**的嵌套页(href/src),用于"含嵌套页面"的覆盖。"""
+    txt = html.decode('utf-8', 'replace')
+    got, seen = [], set()
+    for m in re.finditer(r'(?:href|src)=["\']([^"\'#?]+)["\']', txt):
+        u = m.group(1)
+        if not u.startswith('/') or not u.startswith(prefix):
+            continue
+        if u.endswith(('.js', '.css', '.png', '.jpg', '.svg', '.woff', '.woff2', '.ico', '.map')):
+            continue
+        if u in seen or '${' in u:          # JS 模板字面量不是页面
+            continue
+        seen.add(u)
+        got.append(u)
+        if len(got) >= limit:
+            break
+    return got
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--base', default='http://127.0.0.1:28084')
+    ap.add_argument('--write', default=None)
+    ap.add_argument('--links', type=int, default=12, help='每个模块再抽几个嵌套页')
+    a = ap.parse_args()
+
+    tasks = list(PAGES)
+    # 嵌套页: 先抓每个模块入口, 从中发现同前缀链接
+    for prefix in ('/detail', '/cms', '/sim', '/sim/sys', '/viewer', '/ops'):
+        code, ct, body = fetch(a.base, prefix + '/')
+        if not body:
+            continue
+        for u in discover_links(a.base, prefix, body, a.links):
+            tasks.append(dict(path=u, kind='page', why=f'{prefix} 内的嵌套页 (从入口页发现)'))
+
+    fams, running = _families()
+    with ThreadPoolExecutor(max_workers=8) as ex:
+        res = list(ex.map(lambda t: (t, fetch(a.base, t['path'])), tasks))
+    rows = []
+    for t, (code, ctype, body) in res:
+        j = judge(t['path'], t['kind'], code, ctype, body)
+        j.update(path=t['path'], kind=t['kind'], why=t['why'], dep=t.get('dep'))
+        j['cause'] = cause_of(t['path'], j['verdict'], t.get('dep'), fams, running)
+        rows.append(j)
+
+    order = {'报错': 0, '未找到': 1, '空': 2, '缺件/降级': 3, '连不上': 4, '有输出': 5, '静态资源': 6}
+    rows.sort(key=lambda r: (order.get(r['verdict'], 9), r['path']))
+    L = [f'# 观澜页面输出体检 · {a.base}', '',
+         f'共 {len(rows)} 页(含从各模块入口发现的嵌套页)· 判定口径: HTTP + 字节数 + 页面自己认账的那句话',
+         f'· 重算是否正在跑: **{"是(产物会随步骤长回来)" if running else "否"}**', '',
+         '| 判定 | 路径 | 码 | 字节 | 原因 | 证据 | 产物依赖 |', '|---|---|---:|---:|---|---|---|']
+    for r in rows:
+        L.append(f"| {r['verdict']} | `{r['path']}` | {r['code']} | {r['n']:,} | {r['cause']} | "
+                 f"{str(r['evidence'])[:60]} | {r['why']} |")
+    rep = '\n'.join(L)
+    if a.write:
+        pathlib.Path(a.write).write_text(rep + '\n', encoding='utf-8')
+    from collections import Counter
+    cnt = Counter(r['verdict'] for r in rows)
+    print('判定分布: ' + ' · '.join(f'{k}={v}' for k, v in cnt.most_common()))
+    for r in rows:
+        if r['verdict'] not in ('有输出', '静态资源'):
+            print(f"  [{r['verdict']}] {r['path']}  HTTP {r['code']}  {r['n']:,}B  {str(r['evidence'])[:70]}")
+    if a.write:
+        print(f'已写 {a.write}')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 6 - 3
scripts/rebuild_all.py

@@ -131,11 +131,14 @@ def build_plan(a) -> list:
     #       这与 ④/⑤/⑥/⑧b 的口径一致: 缺的是"随包件"这一路, 不是重算本身。缺件仍会由
     #       `guanlan.py check` 报 FAIL(那是给人看的告警位), 本步只保证"链不白断"。
     plan.append(step_cmd('⑧c 页面归口审计 (页面陈旧检测)', [PY, 'scripts/pages_audit.py', '--check'],
-                         tolerate=(5, 7),
+                         tolerate=(5, 6, 7),
                          note='rc=7 = 有 data-derived 页面陈旧(内嵌快照的 source_sha256 与当前产物不一致): '
                               '需要对应生成端重跑, 或在登记表里把它改成 frozen-delivery 并接受会过期; '
-                              'rc=5 = 登记的文件/被引产物不在位(清过产物、且那批件没有生成端时属预期) '
-                              '⇒ 不打断整条链, 补齐办法见 docs §7 与「数据重算」页的缺件提示'))
+                              'rc=5 = 登记的文件/被引产物不在位(清过产物、且那批件没有生成端时属预期); '
+                              '★rc=6 也容忍 (2026-09-19 实逮): rc=6 = 溯源缺失(产物在盘上/登记里, 但台账查不到来路) '
+                              '—— 与 5 同属"缺的是随包件这一路", 例如 ④b 在 report 步崩掉时 '
+                              'vib_raw_manifest.json 没写成, 审计就按"溯源缺失"报 6 并把整条链判失败; '
+                              '处置见 docs §7 与「数据重算」页的缺件提示; `guanlan.py check` 里仍有给人看的告警位'))
     if a.with_verify:
         # 只有 4 = "有需人工看的差异"(本包已知项: 油样 102 行的源件不在现场包里) 才容忍;
         # 5 = 找不到基线(等于没验收) 必须当失败 —— 2026-09-12 就是因为放过了它, 出现过"验收没做却报通过"。

+ 318 - 275
scripts/vib_raw_build.py

@@ -1,275 +1,318 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""振动侧一键摄入: data/raw/<场站>/{windcms,m5_cms_tcm} 的原始件 → 窗索引/谱库 → (可选)CMS 报告与知识库.
-
-## 为什么需要它 (2026-09-12 用户令)
-
-用户令: 「数据层里的「CMS 振动评估报告」应遵循 `<安装目录>\\data\\raw\\如东\\windcms`、
-「振动线 handoff」应遵循 `<安装目录>\\data\\raw\\如东\\m5_cms_tcm` 存放; 修改系统支持振动数据参与
-运行、重算」。此前振动侧只有**产物**(`outputs/<场>/m5_cms_tcm` 90 件 + `outputs/<场>/windcms` 53 件),
-源件不在 data/raw, 生成端脚本 (振动线分支的 rudong_tcm_*.py 七件) 也没随包 ⇒ 从零重算时振动侧是断的。
-
-两个事实把这件事限定得很清楚:
-  · `src/windcms/pipeline.py` 认的输入就是"含 `*_decode.json` 的目录"(Brande TCM Enterprise 导出),
-    这一步**必须能跑**, 否则数据进了 data/raw 也只是躺着;
-  · 同分支的六层链 (oem_scan / energy_share / model_run / fusion) 脚本仍未随包 —— 本脚本**不假装**
-    能把那几步跑出来: 它只做"索引 + 谱 + (可选)报告/知识库", 其余在报告里如实写"缺失"。
-
-## 这一步跑完, 振动数据就真的参与了运行
-
-    data/raw/<场站>/windcms/.../*_decode.json
-        → outputs/<场>/m5_cms_tcm/windows/<窗>/index.parquet     (54 列, 与包内 tcm_index.parquet 同构)
-        → outputs/<场>/m5_cms_tcm/windows/<窗>/spectra/*.npz + spectra_meta.parquet
-    消费者 (无需改一行代码, 窗是自动发现的):
-        src/windcms/data.py::windows()      → 新窗进分析集
-        src/windcms/data.py::load_scalars() → 标量进 CMS 报告/页面
-        src/windcms/data.py::spectrum()     → 谱图能取到 (此前 spectra 库整个缺失, 谱图是死的)
-        scripts/windcms.py report / kb      → 报告与知识库重生成
-
-## ★ 报告默认**跑**了 (2026-09-18 用户令后口径翻转)
-
-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` 两步, 那两步脚本没随包。
-
-用户令 2026-09-18「`/detail` 各页面不能用旧版产出补, 必须基于输入数据重算」把这条基准**换掉了**:
-交付包里根本没有旧产物可比, 缺的那个"完整快照"也不该再出现在页面上。于是:
-
-  · 报告里少的只有**融合级**那一列(如实写 `—`, 因为 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 --window w0316                    # 指定窗名
-    python scripts/vib_raw_build.py --jobs 12 --meas FFT_             # 并行/测量过滤
-    python scripts/vib_raw_build.py --no-report                       # 只摄入, 不动报告/知识库
-    python scripts/vib_raw_build.py --dry-run                         # 只报会做什么
-"""
-from __future__ import annotations
-
-import argparse
-import json
-import os
-import pathlib
-import shutil
-import subprocess
-import sys
-import time
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src.console import soft  # noqa: E402
-
-soft()
-PY = sys.executable
-
-
-def human(n):
-    for u in ('B', 'KB', 'MB', 'GB'):
-        if n < 1024 or u == 'GB':
-            return f'{n:.1f} {u}'
-        n /= 1024
-
-
-def find_roots(station: pathlib.Path):
-    """→ 振动侧原始件根列表 (含 *_decode.json 的目录或导出 zip)。"""
-    roots = []
-    for name in ('windcms', 'm5_cms_tcm'):
-        d = station / name
-        if not d.is_dir():
-            continue
-        for z in sorted(d.rglob('*.zip')):
-            if z.name.upper().startswith('CMS') or 'decode' in z.name.lower():
-                roots.append(z)
-        for sub in sorted({p.parent for p in d.rglob('*_decode.json')}):
-            # 取**最上层**那个含 decode 文件的目录 (measurement/), 避免每台机组一个 root
-            top = sub
-            while top.parent != d and any(top.parent.rglob('*_decode.json')):
-                top = top.parent
-            if top not in roots:
-                roots.append(top)
-    return roots
-
-
-def run(step, cmd, log):
-    t0 = time.time()
-    print(f'[{step}] {" ".join(str(c) for c in cmd)}', flush=True)
-    p = subprocess.run([str(c) for c in cmd], cwd=str(ROOT), capture_output=True, text=True,
-                       encoding='utf-8', errors='replace')
-    tail = (p.stdout or '') + (p.stderr or '')
-    rec = dict(step=step, cmd=' '.join(str(c) for c in cmd), rc=p.returncode,
-               seconds=round(time.time() - t0, 1), tail=tail[-1200:])
-    log.append(rec)
-    print(f'    rc={p.returncode}  {rec["seconds"]}s', flush=True)
-    if p.returncode != 0:
-        print(tail[-2000:], flush=True)
-        raise SystemExit(f'步骤 {step} 失败 (rc={p.returncode})')
-    return rec
-
-
-def _rels_of_window(win: pathlib.Path, m5: pathlib.Path) -> dict:
-    """某窗的产物 → {相对产物仓的路径: 构建器说明}; 供正常摄入与 --register-only 共用 (单一实现)。"""
-    rels = {f'm5_cms_tcm/windows/{win.name}/index.parquet':
-            'scripts/rudong_tcm_index.py (54 列, 与包内 tcm_index.parquet 同列名列序)',
-            f'm5_cms_tcm/windows/{win.name}/spectra_meta.parquet':
-            'scripts/rudong_tcm_spectra.py',
-            'm5_cms_tcm/vib_raw_manifest.json': 'scripts/vib_raw_build.py'}
-    sp = win / 'spectra'
-    if sp.is_dir():
-        for p in sp.rglob('*.npz'):
-            rels[f'm5_cms_tcm/windows/{win.name}/spectra/{p.relative_to(sp).as_posix()}'] = \
-                'scripts/rudong_tcm_spectra.py (npz 分片: DataSets.DataSet.Values 空格串 → float 数组)'
-        # 谱库目录里那份 meta 是同一内容的两条读取路径之一, 也登记
-        rels[f'm5_cms_tcm/windows/{win.name}/spectra/spectra_meta.parquet'] = \
-            'scripts/rudong_tcm_spectra.py (与窗根同名件同内容: 兼顾两种读取约定)'
-    return rels
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser()
-    ap.add_argument('--farm', default='rudong')
-    ap.add_argument('--window', default=None, help='窗名 (默认按数据起始日推 wMMDD)')
-    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('--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='不摄入, 只把**已存在**的窗体件补进来源自登记 (幂等修复: 例如先前的摄入跑在自登记'
-                         '功能之前, 台账就漏记了那些件)')
-    ap.add_argument('--limit', type=int, default=0, help='冒烟: 只摄入前 N 个文件')
-    ap.add_argument('--dry-run', action='store_true')
-    a = ap.parse_args()
-
-    from src.windscada.config import farm, raw_station_dir
-    from src import paths as P
-    cfg = farm(a.farm)
-    station = pathlib.Path(raw_station_dir(a.farm))
-    m5 = P.m5(a.farm)
-    roots = find_roots(station)
-    print(f'场站原始件目录: {station}')
-    print(f'振动侧产物目录: {m5}')
-
-    if a.register_only:
-        # 只补登记: 找已存在的窗 (--window 指定, 否则取名字最大的那个), 逐件登记。
-        # 放在"源件存在性检查"之前 —— 补登记不需要重新读原始件。
-        cands = sorted(p for p in (m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]')
-                       if (p / 'index.parquet').exists())
-        if a.window:
-            cands = [p for p in cands if p.name == a.window]
-        if not cands:
-            print('没有已存在的窗可登记 (先正常跑一次摄入)')
-            return 0
-        win = cands[-1]
-        rels = _rels_of_window(win, m5)
-        from src.derived_manifest import record as _rec
-        p = _rec(P.out_root(a.farm), rels, by='scripts/vib_raw_build.py')
-        print(f'已登记 {len(rels)} 件 (窗 {win.name}) → {p}')
-        return 0
-
-    if not roots:
-        print('没找到振动原始件 (*_decode.json 目录或 CMS 导出 zip)。'
-              '先用 scripts/place_raw_data.py --scope vib 从现场包落位。')
-        return 0
-    for r in roots:
-        n = len(list(r.rglob('*_decode.json'))) if r.is_dir() else '(zip)'
-        sz = sum(p.stat().st_size for p in r.rglob('*_decode.json')) if r.is_dir() else r.stat().st_size
-        print(f'  源件根: {r}   {n} 个 decode 文件, {human(sz)}')
-    if a.dry_run:
-        print('(dry-run, 未摄入)')
-        return 0
-
-    windows_dir = m5 / 'windows'
-    staging = windows_dir / '_staging_ingest'          # 'test'/'_reimport' 之外的名字, 会被 data.windows() 看见 → 立即改名
-    if staging.exists():
-        shutil.rmtree(staging)
-    staging.mkdir(parents=True, exist_ok=True)
-
-    log = []
-    t0 = time.time()
-    root = roots[0] if len(roots) == 1 else station / 'windcms'   # 多个根时交给各自目录 (index 支持 rglob)
-    idx = staging / 'index.parquet'
-    run('index', [PY, str(ROOT / 'scripts/rudong_tcm_index.py'), '--root', str(root), '--out', str(idx),
-                  '--jobs', str(a.jobs)] + (['--limit', str(a.limit)] if a.limit else []), log)
-
-    n_spectra = 0
-    if not a.skip_spectra:
-        run('spectra', [PY, str(ROOT / 'scripts/rudong_tcm_spectra.py'), '--root', str(root),
-                        '--out', str(staging / 'spectra'), '--meas', a.meas, '--jobs', str(a.jobs)]
-            + (['--limit', str(a.limit)] if a.limit else []), log)
-        sm = staging / 'spectra' / 'spectra_meta.parquet'
-        if sm.exists():
-            import pandas as pd
-            n_spectra = len(pd.read_parquet(sm))
-
-    # 窗名: 用数据自身的起始日 (wMMDD), 与既有 w0127/w0707/w0811 同口径
-    import pandas as pd
-    d = pd.read_parquet(idx)
-    tt = pd.to_datetime(d.trigger_time, errors='coerce')
-    tmin, tmax = tt.min(), tt.max()
-    win = a.window or f'w{tmin:%m%d}'
-    final = windows_dir / win
-    if final.exists():
-        final = windows_dir / f'{win}_reimport_{time.strftime("%m%d%H%M")}'
-        print(f'⚠ 窗 {win} 已存在 → 本次摄入落到 {final.name} (data.EXCLUDE_DEFAULT 会排除 _reimport 窗, 不污染生产集)')
-    staging.rename(final)
-    print(f'\n窗: {final.name}   数据窗 {tmin} → {tmax}   行 {len(d)}   谱 {n_spectra}')
-
-    if a.with_report:
-        # ★2026-09-19 补链序: 六层链的业务顺序是 oem_scan → energy_share → **model_run → fusion** → report,
-        #   报告要读 model_run_l6.parquet 与 fusion_38.csv 才有"融合级"那一列。原先这里直接跑 report,
-        #   于是清过产物的机器上报告永远缺融合级(整列 '—')。现在按序补齐(两步都是**按口径重建**, 无标准答案
-        #   对拍 —— 口径与来路写在各自产物与台账里, 见 docs/振动六层链_接口规格与缺口_v0.1.md §3)。
-        run('model_run', [PY, str(ROOT / 'scripts/rudong_model_run.py'), '--window', final.name], log)
-        run('fusion', [PY, str(ROOT / 'scripts/rudong_fusion_run.py'), '--window', final.name], log)
-        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。上面那次 fusion 已经一并出它 (同一条命令: --window final.name)。
-    else:
-        print('(按 --no-report 跳过 CMS 报告/知识库重生成 —— 页面上"振动评估"一栏会显示为无产物)')
-
-    man = dict(farm=a.farm, window=final.name, window_dir=str(final.relative_to(ROOT)),
-               # 源件路径写**相对安装根**的形态: 这文件会随包分发, 绝对路径换机后就是死链
-               # (check_transferable.py 会把 outputs 下的绝对路径算作"机器相关路径")
-               sources=[str(r.relative_to(ROOT)) if str(r).startswith(str(ROOT)) else str(r) for r in roots],
-               sources_note='路径相对<安装目录>', rows=int(len(d)), spectra=int(n_spectra),
-               time_min=str(tmin), time_max=str(tmax),
-               turbines=int(d.turbine.nunique()), sensors=int(d.sensor_name.nunique()),
-               meas_names=int(d.meas_name.nunique()),
-               steps=[{k: v for k, v in r.items() if k != 'tail'} for r in log],
-               finished=time.strftime('%Y-%m-%d %H:%M:%S'), seconds=round(time.time() - t0, 1),
-               missing_chain=['rudong_tcm_oem_scan', 'rudong_line_energy_share', 'rudong_model_run',
-                              'rudong_fusion_run'],
-               missing_note='六层链的四步脚本未随包 (振动线分支 claude/vibration-data-diagnosis-32b69e); '
-                            '本脚本只做索引/谱/报告/知识库, 不冒充跑过那四步')
-    (m5 / 'vib_raw_manifest.json').write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
-
-    # 来源自登记: 让 _provenance.json 把这些件记成 raw-derived (见 src/derived_manifest.py 的说明)
-    try:
-        from src.derived_manifest import record as _rec
-        rels = _rels_of_window(final, m5)
-        if a.with_report:
-            for p in (P.cms(a.farm)).glob('报告_CMS振动状态评估报告_*.md'):
-                # 只登记"今天生成的"这一件 (随包/厂家转录的那些不冒充自算)
-                if time.strftime('%Y-%m-%d') in p.name:
-                    rels[f'windcms/{p.name}'] = 'scripts/windcms.py report (数据窗: ' + final.name + ')'
-        _rec(P.out_root(a.farm), rels, by='scripts/vib_raw_build.py')
-        print(f'  自登记 {len(rels)} 件产物 → {P.out_root(a.farm) / "_derived_manifest.json"}')
-    except Exception as _e:
-        print(f'  ⚠ 来源自登记失败 ({type(_e).__name__}: {_e}) —— 台账会漏记这些件, 但不影响产物本身')
-
-    print(f'\n完成: 用时 {time.time() - t0:.0f}s; 清单 → {m5 / "vib_raw_manifest.json"}')
-    print(f'  新窗 {final.name} 已进分析集: windcms 的谱图/标量/报告页会自动收录它 '
-          f'(消费者读取时现扫 m5\\windows\\w????\\index.parquet, 无需改代码)')
-    print('  windscada 融合面读 handoff (m5_cms_tcm/handoff_vibration_v2.json) —— 那是振动线出件, 本次不动它')
-    print('  看效果: python scripts/windcms.py serve --port 8020  → http://127.0.0.1:8020/')
-    return 0
-
-
-if __name__ == '__main__':
-    sys.exit(main())
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""振动侧一键摄入: data/raw/<场站>/{windcms,m5_cms_tcm} 的原始件 → 窗索引/谱库 → (可选)CMS 报告与知识库.
+
+## 为什么需要它 (2026-09-12 用户令)
+
+用户令: 「数据层里的「CMS 振动评估报告」应遵循 `<安装目录>\\data\\raw\\如东\\windcms`、
+「振动线 handoff」应遵循 `<安装目录>\\data\\raw\\如东\\m5_cms_tcm` 存放; 修改系统支持振动数据参与
+运行、重算」。此前振动侧只有**产物**(`outputs/<场>/m5_cms_tcm` 90 件 + `outputs/<场>/windcms` 53 件),
+源件不在 data/raw, 生成端脚本 (振动线分支的 rudong_tcm_*.py 七件) 也没随包 ⇒ 从零重算时振动侧是断的。
+
+两个事实把这件事限定得很清楚:
+  · `src/windcms/pipeline.py` 认的输入就是"含 `*_decode.json` 的目录"(Brande TCM Enterprise 导出),
+    这一步**必须能跑**, 否则数据进了 data/raw 也只是躺着;
+  · 同分支的六层链 (oem_scan / energy_share / model_run / fusion) 脚本仍未随包 —— 本脚本**不假装**
+    能把那几步跑出来: 它只做"索引 + 谱 + (可选)报告/知识库", 其余在报告里如实写"缺失"。
+
+## 这一步跑完, 振动数据就真的参与了运行
+
+    data/raw/<场站>/windcms/.../*_decode.json
+        → outputs/<场>/m5_cms_tcm/windows/<窗>/index.parquet     (54 列, 与包内 tcm_index.parquet 同构)
+        → outputs/<场>/m5_cms_tcm/windows/<窗>/spectra/*.npz + spectra_meta.parquet
+    消费者 (无需改一行代码, 窗是自动发现的):
+        src/windcms/data.py::windows()      → 新窗进分析集
+        src/windcms/data.py::load_scalars() → 标量进 CMS 报告/页面
+        src/windcms/data.py::spectrum()     → 谱图能取到 (此前 spectra 库整个缺失, 谱图是死的)
+        scripts/windcms.py report / kb      → 报告与知识库重生成
+
+## ★ 报告默认**跑**了 (2026-09-18 用户令后口径翻转)
+
+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` 两步, 那两步脚本没随包。
+
+用户令 2026-09-18「`/detail` 各页面不能用旧版产出补, 必须基于输入数据重算」把这条基准**换掉了**:
+交付包里根本没有旧产物可比, 缺的那个"完整快照"也不该再出现在页面上。于是:
+
+  · 报告里少的只有**融合级**那一列(如实写 `—`, 因为 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 --window w0316                    # 指定窗名
+    python scripts/vib_raw_build.py --jobs 12 --meas FFT_             # 并行/测量过滤
+    python scripts/vib_raw_build.py --no-report                       # 只摄入, 不动报告/知识库
+    python scripts/vib_raw_build.py --dry-run                         # 只报会做什么
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src.console import soft  # noqa: E402
+
+soft()
+PY = sys.executable
+
+
+def human(n):
+    for u in ('B', 'KB', 'MB', 'GB'):
+        if n < 1024 or u == 'GB':
+            return f'{n:.1f} {u}'
+        n /= 1024
+
+
+def find_roots(station: pathlib.Path):
+    """→ 振动侧原始件根列表 (含 *_decode.json 的目录或导出 zip)。"""
+    roots = []
+    for name in ('windcms', 'm5_cms_tcm'):
+        d = station / name
+        if not d.is_dir():
+            continue
+        for z in sorted(d.rglob('*.zip')):
+            if z.name.upper().startswith('CMS') or 'decode' in z.name.lower():
+                roots.append(z)
+        for sub in sorted({p.parent for p in d.rglob('*_decode.json')}):
+            # 取**最上层**那个含 decode 文件的目录 (measurement/), 避免每台机组一个 root
+            top = sub
+            while top.parent != d and any(top.parent.rglob('*_decode.json')):
+                top = top.parent
+            if top not in roots:
+                roots.append(top)
+    return roots
+
+
+def run(step, cmd, log):
+    t0 = time.time()
+    print(f'[{step}] {" ".join(str(c) for c in cmd)}', flush=True)
+    p = subprocess.run([str(c) for c in cmd], cwd=str(ROOT), capture_output=True, text=True,
+                       encoding='utf-8', errors='replace')
+    tail = (p.stdout or '') + (p.stderr or '')
+    rec = dict(step=step, cmd=' '.join(str(c) for c in cmd), rc=p.returncode,
+               seconds=round(time.time() - t0, 1), tail=tail[-1200:])
+    log.append(rec)
+    print(f'    rc={p.returncode}  {rec["seconds"]}s', flush=True)
+    if p.returncode != 0:
+        print(tail[-2000:], flush=True)
+        raise SystemExit(f'步骤 {step} 失败 (rc={p.returncode})')
+    return rec
+
+
+def _rels_of_window(win: pathlib.Path, m5: pathlib.Path) -> dict:
+    """某窗的产物 → {相对产物仓的路径: 构建器说明}; 供正常摄入与 --register-only 共用 (单一实现)。"""
+    rels = {f'm5_cms_tcm/windows/{win.name}/index.parquet':
+            'scripts/rudong_tcm_index.py (54 列, 与包内 tcm_index.parquet 同列名列序)',
+            f'm5_cms_tcm/windows/{win.name}/spectra_meta.parquet':
+            'scripts/rudong_tcm_spectra.py',
+            'm5_cms_tcm/vib_raw_manifest.json': 'scripts/vib_raw_build.py'}
+    sp = win / 'spectra'
+    if sp.is_dir():
+        for p in sp.rglob('*.npz'):
+            rels[f'm5_cms_tcm/windows/{win.name}/spectra/{p.relative_to(sp).as_posix()}'] = \
+                'scripts/rudong_tcm_spectra.py (npz 分片: DataSets.DataSet.Values 空格串 → float 数组)'
+        # 谱库目录里那份 meta 是同一内容的两条读取路径之一, 也登记
+        rels[f'm5_cms_tcm/windows/{win.name}/spectra/spectra_meta.parquet'] = \
+            'scripts/rudong_tcm_spectra.py (与窗根同名件同内容: 兼顾两种读取约定)'
+    return rels
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--farm', default='rudong')
+    ap.add_argument('--window', default=None, help='窗名 (默认按数据起始日推 wMMDD)')
+    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('--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('--manifest-only', action='store_true',
+                    help='只补写 vib_raw_manifest.json(依已存在的窗; 不重跑摄入)')
+    ap.add_argument('--register-only', action='store_true',
+                    help='不摄入, 只把**已存在**的窗体件补进来源自登记 (幂等修复: 例如先前的摄入跑在自登记'
+                         '功能之前, 台账就漏记了那些件)')
+    ap.add_argument('--limit', type=int, default=0, help='冒烟: 只摄入前 N 个文件')
+    ap.add_argument('--dry-run', action='store_true')
+    a = ap.parse_args()
+
+    from src.windscada.config import farm, raw_station_dir
+    from src import paths as P
+    cfg = farm(a.farm)
+    station = pathlib.Path(raw_station_dir(a.farm))
+    m5 = P.m5(a.farm)
+    roots = find_roots(station)
+    print(f'场站原始件目录: {station}')
+    print(f'振动侧产物目录: {m5}')
+
+    if a.register_only:
+        # 只补登记: 找已存在的窗 (--window 指定, 否则取名字最大的那个), 逐件登记。
+        # 放在"源件存在性检查"之前 —— 补登记不需要重新读原始件。
+        cands = sorted(p for p in (m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]')
+                       if (p / 'index.parquet').exists())
+        if a.window:
+            cands = [p for p in cands if p.name == a.window]
+        if not cands:
+            print('没有已存在的窗可登记 (先正常跑一次摄入)')
+            return 0
+        win = cands[-1]
+        rels = _rels_of_window(win, m5)
+        from src.derived_manifest import record as _rec
+        p = _rec(P.out_root(a.farm), rels, by='scripts/vib_raw_build.py')
+        print(f'已登记 {len(rels)} 件 (窗 {win.name}) → {p}')
+        return 0
+
+    if a.manifest_only:
+        # 2026-09-19: 摄入已跑完、清单却没写成的情形(实测: ④b 在 report 步崩掉 → 本脚本在写清单**之前**
+        # 就退出 ⇒ `m5_cms_tcm/vib_raw_manifest.json` 缺失, 页面归口审计按"溯源缺失"报 rc=6、整条链报失败)。
+        # 本模式**只补写清单**, 不重跑摄入(重跑会再花 ~22 分钟并另立一个 _reimport 窗)。
+        cands = sorted(p for p in (m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]')
+                       if (p / 'index.parquet').exists())
+        if a.window:
+            cands = [p for p in cands if p.name == a.window]
+        if not cands:
+            print('没有已存在的窗可写清单 (先正常跑一次摄入)')
+            return 0
+        win = cands[-1]
+        import pandas as _pd
+        ix = _pd.read_parquet(win / 'index.parquet',
+                              columns=[c for c in ('turbine', 'sensor_name', 'meas_name', 'trigger_time')
+                                       if c in _pd.read_parquet(win / 'index.parquet').columns])
+        sm = win / 'spectra_meta.parquet'
+        n_spec = int(len(_pd.read_parquet(sm, columns=['shard']))) if sm.is_file() else 0
+        ts = _pd.to_datetime(ix['trigger_time'], errors='coerce') if 'trigger_time' in ix else None
+        man = dict(farm=a.farm, window=win.name, window_dir=str(win.relative_to(ROOT)),
+                   sources=[str(r.relative_to(ROOT)) if str(r).startswith(str(ROOT)) else str(r) for r in roots],
+                   sources_note='路径相对<安装目录>; ★本清单由 --manifest-only 依**已存在的窗**补写 '
+                                '(2026-09-19), 不是摄入当次的原始记录 —— steps 留空, 时间取窗首末',
+                   rows=int(len(ix)), spectra=n_spec,
+                   time_min=(str(ts.min()) if ts is not None and len(ts) else ''), time_max=(str(ts.max()) if ts is not None and len(ts) else ''),
+                   turbines=int(ix.turbine.nunique()) if 'turbine' in ix else 0,
+                   sensors=int(ix.sensor_name.nunique()) if 'sensor_name' in ix else 0,
+                   meas_names=int(ix.meas_name.nunique()) if 'meas_name' in ix else 0,
+                   steps=[], finished=time.strftime('%Y-%m-%d %H:%M:%S'), seconds=None,
+                   missing_chain=[], missing_note='')
+        (m5 / 'vib_raw_manifest.json').write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+        print(f'已补写 {P.rel(m5 / "vib_raw_manifest.json")}: 窗 {win.name} · {len(ix)} 行 · {n_spec} 谱')
+        try:
+            from src.derived_manifest import record as _rec
+            rels = _rels_of_window(win, m5)
+            _rec(P.out_root(a.farm), rels, by='scripts/vib_raw_build.py (manifest-only)')
+            print(f'  已自登记 {len(rels)} 件')
+        except Exception as _e:
+            print(f'  [i] 自登记跳过: {type(_e).__name__}: {_e}')
+        return 0
+
+    if not roots:
+        print('没找到振动原始件 (*_decode.json 目录或 CMS 导出 zip)。'
+              '先用 scripts/place_raw_data.py --scope vib 从现场包落位。')
+        return 0
+    for r in roots:
+        n = len(list(r.rglob('*_decode.json'))) if r.is_dir() else '(zip)'
+        sz = sum(p.stat().st_size for p in r.rglob('*_decode.json')) if r.is_dir() else r.stat().st_size
+        print(f'  源件根: {r}   {n} 个 decode 文件, {human(sz)}')
+    if a.dry_run:
+        print('(dry-run, 未摄入)')
+        return 0
+
+    windows_dir = m5 / 'windows'
+    staging = windows_dir / '_staging_ingest'          # 'test'/'_reimport' 之外的名字, 会被 data.windows() 看见 → 立即改名
+    if staging.exists():
+        shutil.rmtree(staging)
+    staging.mkdir(parents=True, exist_ok=True)
+
+    log = []
+    t0 = time.time()
+    root = roots[0] if len(roots) == 1 else station / 'windcms'   # 多个根时交给各自目录 (index 支持 rglob)
+    idx = staging / 'index.parquet'
+    run('index', [PY, str(ROOT / 'scripts/rudong_tcm_index.py'), '--root', str(root), '--out', str(idx),
+                  '--jobs', str(a.jobs)] + (['--limit', str(a.limit)] if a.limit else []), log)
+
+    n_spectra = 0
+    if not a.skip_spectra:
+        run('spectra', [PY, str(ROOT / 'scripts/rudong_tcm_spectra.py'), '--root', str(root),
+                        '--out', str(staging / 'spectra'), '--meas', a.meas, '--jobs', str(a.jobs)]
+            + (['--limit', str(a.limit)] if a.limit else []), log)
+        sm = staging / 'spectra' / 'spectra_meta.parquet'
+        if sm.exists():
+            import pandas as pd
+            n_spectra = len(pd.read_parquet(sm))
+
+    # 窗名: 用数据自身的起始日 (wMMDD), 与既有 w0127/w0707/w0811 同口径
+    import pandas as pd
+    d = pd.read_parquet(idx)
+    tt = pd.to_datetime(d.trigger_time, errors='coerce')
+    tmin, tmax = tt.min(), tt.max()
+    win = a.window or f'w{tmin:%m%d}'
+    final = windows_dir / win
+    if final.exists():
+        final = windows_dir / f'{win}_reimport_{time.strftime("%m%d%H%M")}'
+        print(f'⚠ 窗 {win} 已存在 → 本次摄入落到 {final.name} (data.EXCLUDE_DEFAULT 会排除 _reimport 窗, 不污染生产集)')
+    staging.rename(final)
+    print(f'\n窗: {final.name}   数据窗 {tmin} → {tmax}   行 {len(d)}   谱 {n_spectra}')
+
+    if a.with_report:
+        # ★2026-09-19 补链序: 六层链的业务顺序是 oem_scan → energy_share → **model_run → fusion** → report,
+        #   报告要读 model_run_l6.parquet 与 fusion_38.csv 才有"融合级"那一列。原先这里直接跑 report,
+        #   于是清过产物的机器上报告永远缺融合级(整列 '—')。现在按序补齐(两步都是**按口径重建**, 无标准答案
+        #   对拍 —— 口径与来路写在各自产物与台账里, 见 docs/振动六层链_接口规格与缺口_v0.1.md §3)。
+        run('model_run', [PY, str(ROOT / 'scripts/rudong_model_run.py'), '--window', final.name], log)
+        run('fusion', [PY, str(ROOT / 'scripts/rudong_fusion_run.py'), '--window', final.name], log)
+        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。上面那次 fusion 已经一并出它 (同一条命令: --window final.name)。
+    else:
+        print('(按 --no-report 跳过 CMS 报告/知识库重生成 —— 页面上"振动评估"一栏会显示为无产物)')
+
+    man = dict(farm=a.farm, window=final.name, window_dir=str(final.relative_to(ROOT)),
+               # 源件路径写**相对安装根**的形态: 这文件会随包分发, 绝对路径换机后就是死链
+               # (check_transferable.py 会把 outputs 下的绝对路径算作"机器相关路径")
+               sources=[str(r.relative_to(ROOT)) if str(r).startswith(str(ROOT)) else str(r) for r in roots],
+               sources_note='路径相对<安装目录>', rows=int(len(d)), spectra=int(n_spectra),
+               time_min=str(tmin), time_max=str(tmax),
+               turbines=int(d.turbine.nunique()), sensors=int(d.sensor_name.nunique()),
+               meas_names=int(d.meas_name.nunique()),
+               steps=[{k: v for k, v in r.items() if k != 'tail'} for r in log],
+               finished=time.strftime('%Y-%m-%d %H:%M:%S'), seconds=round(time.time() - t0, 1),
+               missing_chain=['rudong_tcm_oem_scan', 'rudong_line_energy_share', 'rudong_model_run',
+                              'rudong_fusion_run'],
+               missing_note='六层链的四步脚本未随包 (振动线分支 claude/vibration-data-diagnosis-32b69e); '
+                            '本脚本只做索引/谱/报告/知识库, 不冒充跑过那四步')
+    (m5 / 'vib_raw_manifest.json').write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+
+    # 来源自登记: 让 _provenance.json 把这些件记成 raw-derived (见 src/derived_manifest.py 的说明)
+    try:
+        from src.derived_manifest import record as _rec
+        rels = _rels_of_window(final, m5)
+        if a.with_report:
+            for p in (P.cms(a.farm)).glob('报告_CMS振动状态评估报告_*.md'):
+                # 只登记"今天生成的"这一件 (随包/厂家转录的那些不冒充自算)
+                if time.strftime('%Y-%m-%d') in p.name:
+                    rels[f'windcms/{p.name}'] = 'scripts/windcms.py report (数据窗: ' + final.name + ')'
+        _rec(P.out_root(a.farm), rels, by='scripts/vib_raw_build.py')
+        print(f'  自登记 {len(rels)} 件产物 → {P.out_root(a.farm) / "_derived_manifest.json"}')
+    except Exception as _e:
+        print(f'  ⚠ 来源自登记失败 ({type(_e).__name__}: {_e}) —— 台账会漏记这些件, 但不影响产物本身')
+
+    print(f'\n完成: 用时 {time.time() - t0:.0f}s; 清单 → {m5 / "vib_raw_manifest.json"}')
+    print(f'  新窗 {final.name} 已进分析集: windcms 的谱图/标量/报告页会自动收录它 '
+          f'(消费者读取时现扫 m5\\windows\\w????\\index.parquet, 无需改代码)')
+    print('  windscada 融合面读 handoff (m5_cms_tcm/handoff_vibration_v2.json) —— 那是振动线出件, 本次不动它')
+    print('  看效果: python scripts/windcms.py serve --port 8020  → http://127.0.0.1:8020/')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 16 - 7
scripts/windscada_serve.py

@@ -540,18 +540,27 @@ def vibcms_results():
     取最新一期报告md; '融合级(模型)/CMS红黄'两列=模型输出, 不出结果层; 分析功能留独立cms.
 
     ★2026-09-18 用户令"页面只能基于输入数据重算、不许用旧版产出补"之后实逮: 清过产物再重算的机器上
-    `windcms/` 里没有 `报告_CMS振动状态评估报告_*.md`(它是振动六层链 report 步的出件, 而 model_run/
-    fusion 两步脚本仍未随包), 于是 `sorted(glob)[-1]` 抛 **IndexError: list index out of range**,
-    页面看到的是"windcms 报告解析失败: list index out of range" —— 与 `ProductsMissing` 的教训同一个病:
-    **缺产物被报成了程序坏了**。现在按缺产物如实回结构化的 `no_report`, 并写清它由谁生成、怎么补。
+    `windcms/` 里没有 `报告_CMS振动状态评估报告_*.md`, 于是 `sorted(glob)[-1]` 抛
+    **IndexError: list index out of range**, 页面看到的是"windcms 报告解析失败: list index out of range"
+    —— 与 `ProductsMissing` 的教训同一个病: **缺产物被报成了程序坏了**。现在按缺产物如实回结构化的
+    `no_report`, 并写清它由谁生成。★2026-09-19 再修: 报告步的生成端(`scripts/windcms.py report`)与它的
+    上游(`rudong_model_run.py`/`rudong_fusion_run.py`)都已随包并上链, 缺件只剩"还没重算到那一步"这一个原因,
+    故这里再附一句"重算是否正在跑"(读 run/ops_job.json), 免得把"正在跑"读成"系统坏了"。
     """
     reps = sorted(ST.parent.glob('windcms/报告_CMS振动状态评估报告_*.md'))
     if not reps:
+        # ★2026-09-19 修: 原话还写着"本包六层链的 model_run/fusion 两步脚本未随包" —— 那两步已在
+        #   2026-09-19 按口径重建并上链(scripts/rudong_model_run.py / rudong_fusion_run.py), 话说反了。
+        #   真实原因只有两种: ① 重算还没走到 ④b 的报告步(清过产物后这一步要跑很久); ② windcms 产物被清且未重算。
+        try:
+            from src import opsjob as _oj
+            _run = (' ' + _oj.running_text()) if _oj.running_text() else ''
+        except Exception:
+            _run = ''
         return dict(date=None, overall='', action=[], grades=[], diff=dict(only_cms=[], only_handoff=[]),
                     no_report=True,
-                    note_zh='本机没有 CMS 振动状态评估报告 (由振动六层链的 report 步生成; 本包六层链的 '
-                            'model_run/fusion 两步脚本未随包 ⇒ 该报告无法从 data/raw 重算)。'
-                            '如实为空, 不用旧版产出补。',
+                    note_zh='本机没有 CMS 振动状态评估报告 (由重算链 ④b 的 report 步生成: '
+                            'python scripts/windcms.py report)。如实为空, 不用旧版产出补。' + _run,
                     err='无产物: windcms/报告_CMS振动状态评估报告_*.md 不在位')
     try:
         rp = reps[-1]

+ 57 - 0
src/opsjob.py

@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+r"""运维动作(重算/清产物)的**当前状态**读取口(单一实现)。
+
+为什么需要它: 2026-09-19 用户报"重算后页面没有输出"。实况是——门户里点了「清除产物 → 执行重算」,
+产物被清掉后重算要跑很久(本次 ④b 振动侧摄入要处理 150 GB 的 CMS 导出), 期间各页面只能显示
+"无产物"。但那句话现在只说"要么还没放数据, 要么刚清过产物", **没说"重算正在跑"** ——
+于是同一现象被读成"系统坏了"。
+
+状态真源: `scripts/_ops_run.py` 写的 `run/ops_job.json`(每 15 s 心跳更新 mtime)。
+判定"在跑"= `status == 'running'` **且** 文件 mtime 在 `fresh_s` 秒内(只看 pid 会被复用骗过)。
+"""
+from __future__ import annotations
+
+import json
+import pathlib
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+JOB = ROOT / 'run' / 'ops_job.json'
+
+
+def current(fresh_s: int = 90) -> dict:
+    """→ dict(running=bool, kind=, cmd=, started=, elapsed_s=, age_s=, stale=bool, note=)。读不到就 running=False。"""
+    out = dict(running=False, kind=None, cmd=None, started=None, elapsed_s=None, age_s=None,
+               stale=False, note=None, job_file=str(JOB))
+    if not JOB.is_file():
+        return out
+    try:
+        j = json.loads(JOB.read_text(encoding='utf-8'))
+    except Exception as e:
+        return {**out, 'note': f'任务文件读不出 ({type(e).__name__})'}
+    age = time.time() - JOB.stat().st_mtime
+    out.update(kind=j.get('kind'), cmd=j.get('cmd'), started=j.get('started'), age_s=round(age),
+               status=j.get('status'), rc=j.get('rc'))
+    running = (j.get('status') == 'running') and age < fresh_s
+    out['running'] = bool(running)
+    out['stale'] = (j.get('status') == 'running') and age >= fresh_s
+    if out.get('started'):
+        try:
+            t0 = time.mktime(time.strptime(out['started'], '%Y-%m-%d %H:%M:%S'))
+            out['elapsed_s'] = round(time.time() - t0)
+        except Exception:
+            pass
+    if out['stale']:
+        out['note'] = (f"任务文件写着 running 但已 {round(age / 60, 1)} 分钟没心跳 —— 可能是被强杀, "
+                       f"不是正在跑")
+    return out
+
+
+def running_text() -> str:
+    """给人看的一句话(页面/接口用): 在跑就写明"起点 + 已跑多久", 否则空串。"""
+    s = current()
+    if not s['running']:
+        return ''
+    mins = (s['elapsed_s'] or 0) / 60
+    return (f"⚠ 检测到**重算正在进行中**({s['kind']},起于 {s['started']},已跑 {mins:.0f} 分钟)—— "
+            f"产物是一步步长出来的,页面会在对应步骤跑完后自己回来(无需重启服务)。")

+ 60 - 0
src/tabfmt.py

@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+r"""Markdown 表格渲染(**零可选依赖**)。
+
+为什么需要它(2026-09-19 实逮,一次真实事故):
+`src/windcms/report.py` 用 `DataFrame.to_markdown()` 渲染"L4 过闸谱线"表 —— 而 pandas 的
+`to_markdown` 依赖**可选包 `tabulate`**,本交付包没有它。此前 `model_run_l6.parquet` 是空的
+(六层链那两步没实现),`len(l6)` 为 0 走的是 `'无'` 分支 ⇒ **没人踩到**;2026-09-19 我把
+`model_run` 按口径重建、L6 真的有 14 行之后,重算链在 `report` 步当场炸:
+
+    ImportError: `Import tabulate` failed.  Use pip or conda to install the tabulate package.
+        at src/windcms/report.py:504  l6[cols].to_markdown(index=False)
+
+后果不是"少一张表",而是**整条重算链停在第 4 步**:后面的 ⑥重启 / ⑦本体(`ontology/objects.json` 等)
+全不执行 ⇒ 页面上"决策链/本体/报告"一片空白。教训与 memory `optional-dep-in-hot-path` 同型:
+**热路径不许依赖可选包**,何况交付包是离线整包(不能 pip)。
+
+这里用纯 Python 渲染同样的 Markdown 表(列宽按显示宽度补空格,中文按 2 列宽算)。
+"""
+from __future__ import annotations
+
+
+def _w(s) -> int:
+    """显示宽度: CJK 与全角标点算 2, 其余算 1(对齐用, 不追求像素级)。"""
+    n = 0
+    for ch in str(s):
+        n += 2 if ('\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f'
+                   or '\uff00' <= ch <= '\uffef') else 1
+    return n
+
+
+def to_md(rows: list[list], header: list[str] | None = None) -> str:
+    """[[单元格…], …] → Markdown 表(首行可作表头)。空数据返回 ''。"""
+    if not rows:
+        return ''
+    esc = lambda x: ('' if x is None else str(x)).replace('|', '\\|')      # 竖线要转义, 否则表被拆列
+    body = [[esc(c) for c in r] for r in rows]
+    if header:
+        cols = [str(h) for h in header]
+        body = [cols] + body
+    ncol = max(len(r) for r in body)
+    body = [r + [''] * (ncol - len(r)) for r in body]
+    width = [max(_w(r[i]) for r in body) for i in range(ncol)]
+    out = []
+    for i, r in enumerate(body):
+        out.append('| ' + ' | '.join(str(c) + ' ' * (width[j] - _w(c)) for j, c in enumerate(r)) + ' |')
+        if i == 0:
+            out.append('|' + '|'.join('-' * (width[j] + 2) for j in range(ncol)) + '|')
+    return '\n'.join(out)
+
+
+def df_to_md(df, cols=None, index: bool = False) -> str:
+    """DataFrame → Markdown 表(`to_markdown(index=False)` 的零依赖替代)。"""
+    if df is None or len(df) == 0:
+        return ''
+    d = df[cols] if cols else df
+    head = ([d.index.name or ''] if index else []) + [str(c) for c in d.columns]
+    rows = []
+    for idx, r in zip(d.index, d.itertuples(index=False)):
+        rows.append(([idx] if index else []) + list(r))
+    return to_md(rows, header=head)

+ 5 - 1
src/windcms/report.py

@@ -501,7 +501,11 @@ def build(cfg, turbines=None, verbose=True):
             md.append(f'| {r["台"]} | {r["融合"]} | {r["CMS"]} | {r["模型"]} | {r["机制"]} | {r["模型依据"]} |')
     md.append('\n## L4 过闸谱线\n')
     cols = [c for c in ['台', '测点', '线', 'hz', '域', '绝对量', '单位', 'xfleet', '选择性', '占比pct', '绝对锚', '定级'] if c in l6.columns]
-    md.append(l6[cols].to_markdown(index=False) if len(l6) else '无')
+    # ★2026-09-19 事故修: 原为 `l6[cols].to_markdown(index=False)`, 而 pandas 的 to_markdown 依赖
+    #   可选包 tabulate(本离线包没有)⇒ L6 一旦非空(模型重建出 14 行), 这一行就 ImportError,
+    #   **整条重算链停在第 4 步**(后面 ⑦本体/⑧审计全不跑, 页面一片空白)。改用零依赖渲染。
+    from src import tabfmt as _tf
+    md.append(_tf.df_to_md(l6, cols) if len(l6) else '无')
     md.append('\n## 证据边界\n- 见 index.html 证据边界卡;正本 `收敛报告_如东TCM振动诊断_v5.md`。')
     (out / 'report.md').write_text('\n'.join(md), encoding='utf-8')
     if verbose:

+ 14 - 2
src/windcms/serve.py

@@ -384,12 +384,24 @@ def run(cfg, port=8030, model=None):
                                   'text/html; charset=utf-8', code=500)
 
         def _send_no_products(self, detail: str = ''):
-            """无产物页: 说清"哪件产物缺了、怎么补", 并给回门户/去重算的入口。"""
+            """无产物页: 说清"哪件产物缺了、怎么补", **并说明重算是否正在跑**, 给回门户/重算入口。
+
+            ★2026-09-19 用户报"重算后页面没有输出"之后补的那一句: 实况是「清除产物 → 执行重算」要跑很久
+            (本次 ④b 振动侧摄入处理 150 GB CMS 导出), 期间页面只能显示无产物; 但原话只说"要么还没放数据,
+            要么刚清过产物" ⇒ 同一现象被读成"系统坏了"。现在读 `run/ops_job.json` 如实写明在跑与已跑多久。
+            """
+            try:
+                from src import opsjob as _oj
+                _run_note = _oj.running_text()
+            except Exception:
+                _run_note = ''
             body = (
                 '<div class="card"><h3>无产物: 这份 CMS 页面还没有生成</h3>'
                 f'<p class="small">找不到 <code>{ui.esc(str(out / "index.html"))}</code>'
                 + (f' —— {ui.esc(detail)}' if detail else '') + '</p>'
-                '<p>页面的数来自 <code>outputs/&lt;场站&gt;/windcms/</code> 下的产物, 产物由 <code>data/raw</code> '
+                + (f'<p style="background:#FFF7E6;border:1px solid #F0D8A8;border-radius:6px;padding:8px 10px">'
+                   f'{ui.esc(_run_note)}</p>' if _run_note else '')
+                + '<p>页面的数来自 <code>outputs/&lt;场站&gt;/windcms/</code> 下的产物, 产物由 <code>data/raw</code> '
                 '里的原始件重算出来。当前这台机器上<b>要么还没放数据, 要么刚执行过「清除产物」</b>。</p>'
                 '<p>下一步:</p>'
                 '<div class="kv"><span><b>① 放数据</b><code>scripts/place_raw_data.py --src &lt;现场包目录&gt; --scope vib</code></span>'