Kaynağa Gözat

六层链 model_run / fusion 按口径重建: 报告的"融合级"列从此有值

同一条用户令(2026-09-18, /detail 各页面只能基于输入数据重算)的第三批, 也是用户选定的 C 项。

背景: 报告里唯一还空着的是「融合级(模型)」那一列, 它的上游 model_run_l6.parquet 与
fusion_38.csv **从未随过包**(无标准答案, 无法逐值对拍) ⇒ 此前整列是 —。

新增:
- scripts/rudong_model_run.py: 候选线=reference/rudong/oem_scan_plan.json 的 11 部件×{BPFI,BPFO,BSF}
  (分母已 11/11 逐值验过); 过闸=src/sop/discriminators.py::spectral_line_gates(G1–G8);
  定级=vib_verdict_and_writeback(L0 短路/机制未定不命名部件/无正样本锚封顶候选)。产物列名按消费端
  硬契约(台/测点/线/hz/域/绝对量/单位/xfleet/选择性/占比pct/绝对锚/证据族/解封判据/定级)。
- rudong_fusion_run.py 增 fusion_table(): 模型侧=过闸线最高级, CMS 侧=窗索引 RedMask/YellowMask,
  裁决=src/sop/fusion_diag.py::fuse; 列=台/融合/CMS/模型/机制/模型依据(+未覆盖证据类型/告警证据力)。
- vib_raw_build --with-report 的链序补成 model_run → fusion → report → kb(原先直接跑 report,
  于是清过产物的机器上永远缺融合级)。rebuild_all ④b 同步。

实测(窗 w0316): 候选 1,026 条 → 过闸 14 条(G2_NO_LINE 656/G7_PEAK_OFFSET 176/G4_INHERENT_OR_BATCH
99/G5_INTEGER_ORDER 53/G6_SELECTIVITY 24) → 参考 11/候选 3; fusion_38 38 台 → 正常 13/参考 18/候选 7;
报告附录 A 融合级整列有值, report.md 逐台表有机制码与依据(fuse 的 LOW_IS_BLIND: CMS 判"正常"
不得销模型侧线证据)。

如实标注(不卖成"复现"):
- 峰值拾取仍是本器自定(目标频率 ±2 bin 取最大), 未做阶次跟踪/多记录合并 —— 正是 oem_scan 缺的"分子"口径;
- 两件无标准答案, 来历在 model_run_summary.json 的"口径"字段与产物台账里写明"按口径重建";
- chain_gap_check: B 段把这两件记 [自算](不是标准答案) —— 此前它只看"文件在不在", 本机算出来的正好
  躺在同一个路径上, 会假报"标准答案已到位";
- docs/振动六层链_接口规格与缺口_v0.1.md 新增 §3.1 记这一步的性质、口径与两条未闭合项。

验证: 台账 refresh → 1778 件全 raw-derived; products_reverse_audit 检查 rc=0(未归类 0);
detail_deps 检查 rc=0; windcms.py report rc=0(不再因缺列 AttributeError 崩, 逐台页正常生成);
chain_gap_check rc=5(如实: energy_share 脚本仍缺 + oem_scan 分子口径未定)。
zhouyang.xie 3 hafta önce
ebeveyn
işleme
912cf04443

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

@@ -6,7 +6,7 @@
 <!-- DETAIL-DEPS:BEGIN -->
 # /detail 页面依赖与重算台账 (自动生成, 禁手改)
 
-> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 1,775 件
+> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 1,780 件
 >
 > 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, 数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。
 
@@ -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` ✅必需 | 1 | 0.0 MB | raw-derived | src/windcms/report_std.py (windcms_report_std) | 在位 |
+| /api/vibcms | `windcms/报告_CMS振动状态评估报告_*.md` ✅必需 | 2 | 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) | 在位 |
@@ -120,27 +120,27 @@ python scripts/rebuild_all.py               # 全量重算 (链上含 ⑤a 台
 
 | 页签 | 接口 | 判定 | 实测 |
 |---|---|---|---|
-| overview | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
-| energy | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
-| component | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
+| overview | `/api/fleet` | 有数 | HTTP 200 · 54,509 B |
+| energy | `/api/fleet` | 有数 | HTTP 200 · 54,509 B |
+| component | `/api/fleet` | 有数 | HTTP 200 · 54,509 B |
 |  | `/api/problem` | 有数 | HTTP 200 · 16 B |
 |  | `/api/turbine` | 有数 | HTTP 200 · 14,417 B |
-| vibration | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
-|  | `/api/vibcms` | 有数 | HTTP 200 · 6,797 B |
+| vibration | `/api/fleet` | 有数 | HTTP 200 · 54,509 B |
+|  | `/api/vibcms` | 有数 | HTTP 200 · 6,752 B |
 | generation | `/api/curves` | 有数 | HTTP 200 · 23,913 B |
-| fault | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
-| decision | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
+| fault | `/api/fleet` | 有数 | HTTP 200 · 54,509 B |
+| decision | `/api/fleet` | 有数 | HTTP 200 · 54,509 B |
 |  | `/api/ont_chain` | 有数 | HTTP 200 · 288 B |
-| assistant | `/api/fleet` | 有数 | HTTP 200 · 55,698 B |
+| assistant | `/api/fleet` | 有数 | HTTP 200 · 54,509 B |
 |  | `/api/ask` | 不适用(POST 接口) | 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 · 55,698 B |
+| report | `/api/fleet` | 有数 | HTTP 200 · 54,509 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 · 55,698 B |
+| system | `/api/fleet` | 有数 | HTTP 200 · 54,509 B |
 |  | `/api/maint_survey` | 有数 | HTTP 200 · 7,498 B |
 
 判读口径: 只认响应**顶层**的 `err`/`error`/`no_products`/`no_report`; `/api/ask*` `/api/rpt_compose` 依赖本机模型, 未启动时记「不适用」不算缺件。

+ 21 - 0
docs/振动六层链_接口规格与缺口_v0.1.md

@@ -52,6 +52,27 @@ python.exe: can't open file '…\scripts\rudong_model_run.py': [Errno 2] No such
 | `model_run` | `model_run_l6.parquet`(+摘要 json) | **缺** | **无标准答案** ⇒ 只能凭口径实现,无法证明"复现" |
 | `fusion` | `fusion_38.csv`(+ `handoff_vibration_v2.json`) | `fusion_38.csv` **缺**;`handoff_vibration_v2.json` 在,但**它是人工交证件**(见 §4) | 同上 ⇒ 不可对拍 |
 
+### 3.1 2026-09-19 进展:`model_run` / `fusion` 已**按口径重建**(仍不是复现)
+
+用户令 2026-09-18「`/detail` 各页面不许用旧版产出补, 必须基于输入数据重算」之后, 报告里唯一还空着的是
+**融合级**那一列 ⇒ 把这两步按包内既有判据接起来(判据不新写, 只接线):
+
+| 步骤 | 生成端 | 输入 | 判据/口径 | 性质 |
+|---|---|---|---|---|
+| `model_run` | `scripts/rudong_model_run.py` | 窗索引 + 谱库(`data/raw/<场>/m5_cms_tcm` 摄入所得) | 候选线=`reference/rudong/oem_scan_plan.json` 的 11 部件×{BPFI,BPFO,BSF}(分母已 11/11 逐值验过); 过闸=`src/sop/discriminators.py::spectral_line_gates`(G1–G8); 定级=`vib_verdict_and_writeback`(L0 短路 / 机制未定不命名部件 / 无正样本锚封顶候选) | **按口径重建**, 无标准答案 |
+| `fusion` | `scripts/rudong_fusion_run.py`(`fusion_table()`) | `model_run_l6.parquet` + 窗索引红黄告警 | 裁决=`src/sop/fusion_diag.py::fuse`(覆盖凭证据 / 排同源 / 按跨度升级) | **按口径重建**, 无标准答案 |
+
+实测(2026-09-19, 窗 w0316): `model_run` 候选 **1,026** 条 → 过闸 **14** 条
+(闸分布 G2_NO_LINE 656 · G7_PEAK_OFFSET 176 · G4_INHERENT_OR_BATCH 99 · G5_INTEGER_ORDER 53 · G6_SELECTIVITY 24)→ 定级 参考 11 / 候选 3;
+`fusion_38.csv` 38 台 → 融合级 正常 13 / 参考 18 / 候选 7;报告附录 A 的「融合级(模型)」整列**已有值**(此前是 `—`)。
+
+★两条仍未闭合(写进产物与台账,不许当"复现"卖):
+1. **峰值拾取仍是本器自定**(目标频率 ±2 bin 取最大,与 G7 搜索窗同宽;未做阶次跟踪/多记录合并)——
+   这正是 `oem_scan` 侧缺的"分子"口径(§7.3)。
+2. `handoff_vibration_v2.json` 仍是一份**人工交证件**(§4),不从交付包补(用户令 2026-09-17)。
+   于是 `/detail` 工作台的**振动融合面**(`/api/fleet` 的 `fus`)仍如实报"缺件",
+   而 CMS 报告/评估页那一面已由上面两步喂上。
+
 ## 4. `handoff_vibration_v2.json` 不是"算出来的产物",而是**人工交证件**
 
 实测其结构:`meta`(version/date/from/to/supersedes/principle/**v2.1_changes**/**v2.2_changes**/

+ 28 - 2
scripts/chain_gap_check.py

@@ -34,12 +34,32 @@ STEPS = [
     ('fusion', 'scripts/rudong_fusion_run.py', '融合面出件(fusion_38.csv 等)'),
 ]
 # 逐值对拍用的"标准答案"(随包件里有,但两件关键件从未随过包)
+#   ★2026-09-19: 这两件现在**本机能自算**(scripts/rudong_model_run.py / rudong_fusion_run.py, 按口径重建)。
+#     于是"文件在不在"再也不能代表"有没有标准答案" —— 本机算出来的就躺在同一个路径上。
+#     判据改成看**台账里的来路**: builder 文本含"按口径重建" ⇒ 那是自算产物, 记 [自算] 不记 [OK]。
 SAMPLES = [
     ('model_run_l6.parquet', 'model_run 步的标准答案'),
     ('fusion_38.csv', 'fusion 步的标准答案'),
 ]
 
 
+def _selfmade(farm: str) -> set:
+    """台账里标了"按口径重建"的件 —— 它们是**本机自算**, 不是随包标准答案。"""
+    import json as _json
+    out = set()
+    f = P.out_root(farm) / '_provenance.json'
+    if not f.is_file():
+        return out
+    try:
+        files = (_json.loads(f.read_text(encoding='utf-8')) or {}).get('files') or {}
+    except Exception:
+        return out
+    for rel, v in files.items():
+        if '按口径重建' in str((v or {}).get('builder', '')) + str((v or {}).get('why', '')):
+            out.add(pathlib.Path(rel).name)
+    return out
+
+
 def main() -> int:
     ap = argparse.ArgumentParser(description='六层链缺料检查')
     ap.add_argument('--check', action='store_true', help='只出结论(缺料 → rc=5)')
@@ -83,9 +103,13 @@ def main() -> int:
         print(f'   [{tag}] {name:12s} {rel:44s} {what}{extra}')
     print()
     print('B. 逐值对拍的标准答案:')
+    selfmade = _selfmade(farm)
     for nm, why in SAMPLES:
         ok = (m5 / nm).is_file()
-        print(f'   [{"OK " if ok else "缺 "}] {nm:28s} {why}  ({P.rel(m5 / nm)})')
+        if ok and nm in selfmade:
+            print(f'   [自算] {nm:28s} 本机**按口径重建**的产物在位(不是标准答案: 无样件可对拍)  ({P.rel(m5 / nm)})')
+        else:
+            print(f'   [{"OK " if ok else "缺 "}] {nm:28s} {why}  ({P.rel(m5 / nm)})')
     # 已完成的那件: 用本器自带 verify 报一句现状(不写盘)
     fr = ROOT / 'scripts/rudong_fusion_run.py'
     if fr.is_file():
@@ -93,11 +117,13 @@ def main() -> int:
         print('C. 已自研部分(fleet_scalar_z): 跑 python scripts/rudong_fusion_run.py --verify 看逐值对拍')
     print()
     rc = 5 if (missing_scripts or partial) else 0
+    selfmade_txt = (';两件关键产物已**按口径重建**(无标准答案可对拍: model_run_l6 / fusion_38)'
+                    if selfmade else '')
     tail = ('四步脚本齐全且都已落地 ⇒ 六层链可跑' if not (missing_scripts or partial)
             else (f'{len(missing_scripts)} 个步骤脚本未随包' if missing_scripts else '')
             + (';' if (missing_scripts and partial) else '')
             + (f'{len(partial)} 个为"部分实现"(分母就位 / 分子待口径)' if partial else '')
-            + ' ⇒ 六层链跑不动(取料单见上)')
+            + ' ⇒ 六层链跑不动(取料单见上)') + selfmade_txt
     print(f'   结论: {tail}')
     if a.check:
         print(f'[{"OK" if rc == 0 else "X"}] chain_gap_check rc={rc}')

+ 14 - 0
scripts/products_reverse_audit.py

@@ -109,6 +109,20 @@ FAMILIES: list[dict] = [
     #    ★2026-09-18 用户令补充: 这些件**运行期一律不从交付包补**(用户令 2026-09-17 #1 的延伸),
     #      页面如实为空/降级; 要出件只有两条路 —— ① 研发补生成端; ② 现场正本随原始件进 data/raw。
     #      故本表的 why 里不再写"从交付包补齐"那种话(那是**离线人工补救**, 见 products_restore_missing.py)。
+    dict(id='vib_model_run_l6', glob=['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/model_run_summary.json'],
+         kind='raw-derived',
+         func='六层链 model_run 步 · L6 过闸谱线表 + 层小结',
+         algo='scripts/rudong_model_run.py(候选线=oem_scan_plan 的 11 部件×{BPFI,BPFO,BSF}; 过闸='
+              'src/sop/discriminators.py::spectral_line_gates G1–G8; 定级=vib_verdict_and_writeback)',
+         gen='scripts/rudong_model_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='★2026-09-19 按口径重建(**无标准答案**: 这两件从未随包, 无法逐值对拍); 峰值拾取仍是本器自定'
+             '(目标频率 ±2 bin 取最大), 不得当"复现"用 —— 见 docs/振动六层链_接口规格与缺口_v0.1.md §3'),
+    dict(id='vib_fusion_38', glob='m5_cms_tcm/fusion_38.csv', kind='raw-derived',
+         func='六层链 fusion 步 · 逐台融合表(报告"融合级"列的来源)',
+         algo='scripts/rudong_fusion_run.py::fusion_table(模型侧=model_run_l6 过闸线; CMS 侧=窗索引 '
+              'RedMask/YellowMask; 裁决=src/sop/fusion_diag.py::fuse)',
+         gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
+         why='★按口径重建(无标准答案): 台号用 WTGnn 形态(消费端 registry 的键), 与 l6 的 f"{n}#" 不同'),
     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 → 同 (传感器,测量,工况) 族中位 '

+ 276 - 161
scripts/rudong_fusion_run.py

@@ -1,161 +1,276 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-r"""六层链 · `fusion` 步(**逆向工程实现**,2026-09-17 用户令 #2)。
-
-## 这个文件是怎么来的
-
-包里的编排壳 `src/windcms/pipeline.py::analyze` 会调 `scripts/rudong_fusion_run.py`,而该脚本没随包
-(六层链四步脚本都缺)。用户令 #2 问"能不能根据旧包产物逆向工程推导出来"——本文件是**可对拍的那部分**
-的答案:`fleet_scalar_z.parquet` 的标准答案在盘上(随包件),于是口径可以**反推 + 逐值对拍**。
-
-## 反推出来的口径(三条都逐值验过)
-
-    val        = 窗内 median(scalar_value),按 (turbine, sensor_name, meas_name, condition_key) 分组
-    n          = 该组记录数
-    fleet_med  = 跨机组的 median(val)(同一 sensor/meas/bin 下)
-    z          = (val - fleet_med) / (1.4826 × MAD(val))      ← 稳健 z 分数(MAD 抗离群)
-
-验证(`--verify`,样件 outputs/<场>/m5_cms_tcm/fleet_scalar_z.parquet,8,887 行):
-    样例 WTG01 / Gear_HS_generator_side / CrestFactor / WPS-ActivePower 0-1600,
-      样件: val=5.66453  fleet_med=5.91444  z=-0.210156  n=7
-      本器: val=5.66453  fleet_med=5.91444  z=-0.210153  n=7
-    尺度候选实测: std=1.44859(z=-0.1725) · MAD=0.80209(z=-0.3116) · IQR/1.349=1.46899(z=-0.1701)
-                  **1.4826×MAD=1.18918(z=-0.2102) ← 命中**
-
-## 用法
-
-    python scripts/rudong_fusion_run.py                    # 算并落盘 (默认窗口 = 配置里的首窗)
-    python scripts/rudong_fusion_run.py --verify           # 与盘上样件逐值对拍 (不改任何件)
-    python scripts/rudong_fusion_run.py --out <路径>       # 写到别处 (对拍/试验用)
-
-★ 本器**只实现已被标准答案验证过的那一件**。`fusion_38.csv` 与 `model_run_l6.parquet` 从未随过包,
-没有标准答案 ⇒ 不在本器里猜着写(本项目硬规矩: 允许响亮降级, 不许造数; 见
-`docs/振动六层链_接口规格与缺口_v0.1.md`)。
-
-退出码: 0 成功/对拍通过 · 5 对拍不一致 · 2 找不到输入
-"""
-from __future__ import annotations
-
-import argparse
-import pathlib
-import sys
-
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(ROOT))
-from src import paths as P                                              # noqa: E402
-
-
-def _window_index(win: str) -> pathlib.Path:
-    """窗索引在哪: w0127 = 首窗特例 (索引在 m5 根), 其余在 windows/<w>/index.parquet。"""
-    m5 = P.m5()
-    if win == 'w0127' and (m5 / 'tcm_index.parquet').is_file():
-        return m5 / 'tcm_index.parquet'
-    return m5 / 'windows' / win / 'index.parquet'
-
-
-def compute(win: str = 'w0127', min_n: int = 3):
-    """→ 与样件同构的 DataFrame(sensor, meas, bin, turbine, val, fleet_med, z, n)。"""
-    import numpy as np
-    import pandas as pd
-    ix = _window_index(win)
-    if not ix.is_file():
-        raise SystemExit(f'[X] 窗索引不存在: {P.rel(ix)} —— 先跑 scripts/rudong_tcm_index.py')
-    d = pd.read_parquet(ix, columns=['turbine', 'sensor_name', 'meas_name', 'condition_key', 'scalar_value', 'y_unit'])
-    d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
-    d = d.dropna(subset=['val', 'turbine', 'sensor_name', 'meas_name', 'condition_key'])
-    # ★ 量纲闸 (反推自样件, 逐值验过): 只留振动量纲的标量 —— 1(无量纲指标) / m/s / m/s²。
-    #   排除 %(Disk Usage / Memory Usage)、RPM(rms_rawRPM_DC) 与全部波形/谱名(FFT_/Time_/Env_/Cep_)。
-    #   加上这一条之后, 样件 8,887 行的 val / fleet_med / z **逐值 100% 一致**。
-    d = d[d['y_unit'].astype(str).isin({'1', 'm/s', 'm/s²'})]
-    g = (d.groupby(['sensor_name', 'meas_name', 'condition_key', 'turbine'])['val']
-         .agg(val='median', n='size').reset_index())
-    g = g[g['n'] >= min_n]      # ★ 样件口径: 每组至少 3 条记录 (反推自 n 分布 min=3)
-    # 跨机组的稳健基线: 中位数 + 1.4826×MAD (与样件逐值一致)
-    gm = g.groupby(['sensor_name', 'meas_name', 'condition_key'])['val'].median().rename('fleet_med')
-    g = g.merge(gm, on=['sensor_name', 'meas_name', 'condition_key'], how='left')
-    mad = (g.assign(dev=(g['val'] - g['fleet_med']).abs())
-           .groupby(['sensor_name', 'meas_name', 'condition_key'])['dev'].median()
-           .mul(1.4826).rename('scale'))
-    g = g.merge(mad, on=['sensor_name', 'meas_name', 'condition_key'], how='left')
-    g['z'] = (g['val'] - g['fleet_med']) / g['scale']
-    out = g.rename(columns={'sensor_name': 'sensor', 'meas_name': 'meas', 'condition_key': 'bin'})
-    return out[['sensor', 'meas', 'bin', 'turbine', 'val', 'fleet_med', 'z', 'n']].sort_values(
-        ['sensor', 'meas', 'bin', 'turbine']).reset_index(drop=True)
-
-
-def verify(win: str = 'w0127', sample: pathlib.Path | None = None) -> int:
-    """与盘上样件逐值对拍(严格: 同键同值, 容差 1e-4)。"""
-    import pandas as pd
-    sp = pathlib.Path(sample) if sample else (P.m5() / 'fleet_scalar_z.parquet')
-    if not sp.is_file():
-        print(f'[X] 没有样件可对拍: {P.rel(sp)}')
-        return 2
-    got = compute(win)
-    want = pd.read_parquet(sp)
-    key = ['sensor', 'meas', 'bin', 'turbine']
-    m = want.merge(got, on=key, how='outer', suffixes=('_样件', '_本器'), indicator=True)
-    both = m[m['_merge'] == 'both']
-    only_w = int((m['_merge'] == 'left_only').sum())
-    only_g = int((m['_merge'] == 'right_only').sum())
-
-    def near(col, tol=1e-4):
-        a, b = pd.to_numeric(both[f'{col}_样件'], errors='coerce'), pd.to_numeric(both[f'{col}_本器'], errors='coerce')
-        ok = (a - b).abs() <= tol
-        return int(ok.sum()), int(len(ok) - ok.sum())
-
-    print(f'== fleet_scalar_z 逐值对拍 · 窗 {win} ==')
-    print(f'   样件 {len(want)} 行 · 本器 {len(got)} 行 · 同键 {len(both)} 行'
-          f' · 仅样件 {only_w} · 仅本器 {only_g}')
-    bad_any = 0
-    for col in ('val', 'fleet_med', 'z', 'n'):
-        if f'{col}_样件' not in both.columns:
-            print(f'   {col}: (样件无此列)')
-            continue
-        ok, bad = near(col, tol=1e-4 if col != 'n' else 0.5)
-        bad_any += bad
-        print(f'   {col:10s} 一致 {ok:5d} / {len(both):5d}   不一致 {bad}')
-    ok_all = (len(both) == len(want) == len(got) and bad_any == 0)
-    print(f'   结论: {"逐值完全一致(口径已复现)" if ok_all else "有差异 —— 见上, 不要拿本器产物替换样件"}')
-    return 0 if ok_all else 5
-
-
-def main() -> int:
-    ap = argparse.ArgumentParser(description='六层链 fusion 步(逆向工程实现: fleet_scalar_z)')
-    ap.add_argument('--window', default='w0127', help='用哪个窗算(默认首窗 w0127,与样件同源)')
-    ap.add_argument('--verify', action='store_true', help='与盘上样件逐值对拍,不写盘')
-    ap.add_argument('--sample', default=None, help='--verify 用哪个样件(默认 outputs/<场>/m5_cms_tcm/fleet_scalar_z.parquet)')
-    ap.add_argument('--out', default=None, help='输出路径(默认写回 m5/fleet_scalar_z.parquet)')
-    ap.add_argument('--min-n', type=int, default=3, help='每组最少记录数(样件口径 = 3)')
-    a = ap.parse_args()
-    if a.verify:
-        return verify(a.window, pathlib.Path(a.sample) if a.sample else None)
-    df = compute(a.window, a.min_n)
-    out = pathlib.Path(a.out) if a.out else (P.m5() / 'fleet_scalar_z.parquet')
-    out.parent.mkdir(parents=True, exist_ok=True)
-    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():
-                   '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}')
-    return 0
-
-
-if __name__ == '__main__':
-    for _s in (sys.stdout, sys.stderr):
-        try:
-            _s.reconfigure(errors='replace')
-        except Exception:
-            pass
-    sys.exit(main())
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""六层链 · `fusion` 步(**逆向工程实现**,2026-09-17 用户令 #2)。
+
+## 这个文件是怎么来的
+
+包里的编排壳 `src/windcms/pipeline.py::analyze` 会调 `scripts/rudong_fusion_run.py`,而该脚本没随包
+(六层链四步脚本都缺)。用户令 #2 问"能不能根据旧包产物逆向工程推导出来"——本文件是**可对拍的那部分**
+的答案:`fleet_scalar_z.parquet` 的标准答案在盘上(随包件),于是口径可以**反推 + 逐值对拍**。
+
+## 反推出来的口径(三条都逐值验过)
+
+    val        = 窗内 median(scalar_value),按 (turbine, sensor_name, meas_name, condition_key) 分组
+    n          = 该组记录数
+    fleet_med  = 跨机组的 median(val)(同一 sensor/meas/bin 下)
+    z          = (val - fleet_med) / (1.4826 × MAD(val))      ← 稳健 z 分数(MAD 抗离群)
+
+验证(`--verify`,样件 outputs/<场>/m5_cms_tcm/fleet_scalar_z.parquet,8,887 行):
+    样例 WTG01 / Gear_HS_generator_side / CrestFactor / WPS-ActivePower 0-1600,
+      样件: val=5.66453  fleet_med=5.91444  z=-0.210156  n=7
+      本器: val=5.66453  fleet_med=5.91444  z=-0.210153  n=7
+    尺度候选实测: std=1.44859(z=-0.1725) · MAD=0.80209(z=-0.3116) · IQR/1.349=1.46899(z=-0.1701)
+                  **1.4826×MAD=1.18918(z=-0.2102) ← 命中**
+
+## 用法
+
+    python scripts/rudong_fusion_run.py                    # 算并落盘 (默认窗口 = 配置里的首窗)
+    python scripts/rudong_fusion_run.py --verify           # 与盘上样件逐值对拍 (不改任何件)
+    python scripts/rudong_fusion_run.py --out <路径>       # 写到别处 (对拍/试验用)
+
+★ 本器**只实现已被标准答案验证过的那一件**。`fusion_38.csv` 与 `model_run_l6.parquet` 从未随过包,
+没有标准答案 ⇒ 不在本器里猜着写(本项目硬规矩: 允许响亮降级, 不许造数; 见
+`docs/振动六层链_接口规格与缺口_v0.1.md`)。
+
+退出码: 0 成功/对拍通过 · 5 对拍不一致 · 2 找不到输入
+"""
+from __future__ import annotations
+
+import argparse
+import pathlib
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                              # noqa: E402
+
+
+def _window_index(win: str) -> pathlib.Path:
+    """窗索引在哪: w0127 = 首窗特例 (索引在 m5 根), 其余在 windows/<w>/index.parquet。"""
+    m5 = P.m5()
+    if win == 'w0127' and (m5 / 'tcm_index.parquet').is_file():
+        return m5 / 'tcm_index.parquet'
+    return m5 / 'windows' / win / 'index.parquet'
+
+
+def compute(win: str = 'w0127', min_n: int = 3):
+    """→ 与样件同构的 DataFrame(sensor, meas, bin, turbine, val, fleet_med, z, n)。"""
+    import numpy as np
+    import pandas as pd
+    ix = _window_index(win)
+    if not ix.is_file():
+        raise SystemExit(f'[X] 窗索引不存在: {P.rel(ix)} —— 先跑 scripts/rudong_tcm_index.py')
+    d = pd.read_parquet(ix, columns=['turbine', 'sensor_name', 'meas_name', 'condition_key', 'scalar_value', 'y_unit'])
+    d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
+    d = d.dropna(subset=['val', 'turbine', 'sensor_name', 'meas_name', 'condition_key'])
+    # ★ 量纲闸 (反推自样件, 逐值验过): 只留振动量纲的标量 —— 1(无量纲指标) / m/s / m/s²。
+    #   排除 %(Disk Usage / Memory Usage)、RPM(rms_rawRPM_DC) 与全部波形/谱名(FFT_/Time_/Env_/Cep_)。
+    #   加上这一条之后, 样件 8,887 行的 val / fleet_med / z **逐值 100% 一致**。
+    d = d[d['y_unit'].astype(str).isin({'1', 'm/s', 'm/s²'})]
+    g = (d.groupby(['sensor_name', 'meas_name', 'condition_key', 'turbine'])['val']
+         .agg(val='median', n='size').reset_index())
+    g = g[g['n'] >= min_n]      # ★ 样件口径: 每组至少 3 条记录 (反推自 n 分布 min=3)
+    # 跨机组的稳健基线: 中位数 + 1.4826×MAD (与样件逐值一致)
+    gm = g.groupby(['sensor_name', 'meas_name', 'condition_key'])['val'].median().rename('fleet_med')
+    g = g.merge(gm, on=['sensor_name', 'meas_name', 'condition_key'], how='left')
+    mad = (g.assign(dev=(g['val'] - g['fleet_med']).abs())
+           .groupby(['sensor_name', 'meas_name', 'condition_key'])['dev'].median()
+           .mul(1.4826).rename('scale'))
+    g = g.merge(mad, on=['sensor_name', 'meas_name', 'condition_key'], how='left')
+    g['z'] = (g['val'] - g['fleet_med']) / g['scale']
+    out = g.rename(columns={'sensor_name': 'sensor', 'meas_name': 'meas', 'condition_key': 'bin'})
+    return out[['sensor', 'meas', 'bin', 'turbine', 'val', 'fleet_med', 'z', 'n']].sort_values(
+        ['sensor', 'meas', 'bin', 'turbine']).reset_index(drop=True)
+
+
+def verify(win: str = 'w0127', sample: pathlib.Path | None = None) -> int:
+    """与盘上样件逐值对拍(严格: 同键同值, 容差 1e-4)。"""
+    import pandas as pd
+    sp = pathlib.Path(sample) if sample else (P.m5() / 'fleet_scalar_z.parquet')
+    if not sp.is_file():
+        print(f'[X] 没有样件可对拍: {P.rel(sp)}')
+        return 2
+    got = compute(win)
+    want = pd.read_parquet(sp)
+    key = ['sensor', 'meas', 'bin', 'turbine']
+    m = want.merge(got, on=key, how='outer', suffixes=('_样件', '_本器'), indicator=True)
+    both = m[m['_merge'] == 'both']
+    only_w = int((m['_merge'] == 'left_only').sum())
+    only_g = int((m['_merge'] == 'right_only').sum())
+
+    def near(col, tol=1e-4):
+        a, b = pd.to_numeric(both[f'{col}_样件'], errors='coerce'), pd.to_numeric(both[f'{col}_本器'], errors='coerce')
+        ok = (a - b).abs() <= tol
+        return int(ok.sum()), int(len(ok) - ok.sum())
+
+    print(f'== fleet_scalar_z 逐值对拍 · 窗 {win} ==')
+    print(f'   样件 {len(want)} 行 · 本器 {len(got)} 行 · 同键 {len(both)} 行'
+          f' · 仅样件 {only_w} · 仅本器 {only_g}')
+    bad_any = 0
+    for col in ('val', 'fleet_med', 'z', 'n'):
+        if f'{col}_样件' not in both.columns:
+            print(f'   {col}: (样件无此列)')
+            continue
+        ok, bad = near(col, tol=1e-4 if col != 'n' else 0.5)
+        bad_any += bad
+        print(f'   {col:10s} 一致 {ok:5d} / {len(both):5d}   不一致 {bad}')
+    ok_all = (len(both) == len(want) == len(got) and bad_any == 0)
+    print(f'   结论: {"逐值完全一致(口径已复现)" if ok_all else "有差异 —— 见上, 不要拿本器产物替换样件"}')
+    return 0 if ok_all else 5
+
+
+def _alarm_counts(win: str):
+    """CMS 侧的红/黄告警计数 (逐台) —— 取自窗索引的 `alarm_type` (RedMask/YellowMask)。
+
+    ★为什么用索引而不是 mask_thresholds.tsv: `tcm_compatible_replay/model/mask_thresholds.tsv`
+    (CMS 的自适应阈门限) 属"包内无生成端"的随包件, 清过产物后不在位 ⇒ 拿不到 CMS 的**已校准**
+    门限, 只能用它**自己落在记录上的**告警标记。这一点在 rationale 里如实写明, 并据此把 CMS 侧
+    封顶在"候选"(未校准 ⇒ 不许出准定论以上)。
+    """
+    import pandas as pd
+    ix = _window_index(win)
+    if not ix.is_file():
+        return {}
+    cols = [c for c in ('turbine', 'alarm_type', 'trigger_time') if c in pd.read_parquet(ix).columns]
+    d = pd.read_parquet(ix, columns=cols)
+    # 末窗 = 该台最后一次记录所在日; 取那天前后的红黄标记 (与 registry 用 load_alarm_counts 的口径一致)
+    out = {}
+    for t, g in d.groupby('turbine'):
+        at = g['alarm_type'].astype(str)
+        out[t] = dict(red=int((at == 'RedMask').sum()), yellow=int((at == 'YellowMask').sum()),
+                      n=int(len(g)))
+    return out
+
+
+_FUSE_LEVELS = ('INSUFFICIENT', '正常', '参考', '候选·记基线', '候选', '准定论·预警', '确诊')
+
+
+def _to_fuse_level(lv: str) -> str:
+    """消费端 `定级` (report_std 的 line_state 词表) → fusion_diag 的七级词表。"""
+    s = str(lv)
+    if s.startswith('定论'):
+        return '确诊'
+    if s.startswith('准定论'):
+        return '准定论·预警'
+    if s.startswith('候选'):
+        return '候选'
+    if s.startswith('参考'):
+        return '参考'
+    return 'INSUFFICIENT'
+
+
+def _to_report_level(lv: str) -> str:
+    """fusion_diag 的七级 → 报告用的六枚举 + '正常'(report.py 的"融合级 ≠ 正常"表按它筛)。"""
+    s = str(lv)
+    return {'确诊': '定论', '准定论·预警': '准定论·预警', '候选': '候选', '候选·记基线': '候选',
+            '参考': '参考', '正常': '正常', 'INSUFFICIENT': 'INSUFFICIENT'}.get(s, s)
+
+
+def fusion_table(win: str = 'w0127'):
+    """`fusion_38.csv` —— 逐台融合(模型侧 L6 过闸线 × CMS 侧红黄告警)。
+
+    列是**消费端硬契约** (`src/windcms/report.py:498` 与 `report_std.py:187`):
+      台 / 融合 / CMS / 模型 / 机制 / 模型依据 (+ report.py 的盲区闸要 `未覆盖证据类型` / `告警证据力`)
+
+    ★ 与 `fleet_scalar_z` 不同, 这件**没有标准答案**(从未随包) ⇒ 本器只是把包内判据
+      (`src/sop/fusion_diag.py::fuse`)按口径接起来, 来历写进产物台账; 不许当"复现"用。
+    """
+    import pandas as pd
+    from src.sop import fusion_diag as FD
+    l6p = P.m5() / 'model_run_l6.parquet'
+    l6 = pd.read_parquet(l6p) if l6p.is_file() else pd.DataFrame()
+    # ★台号两套形态, 别混: l6 用 f'{n}#' (report_std 的闭环断言), fusion_38 用 'WTGnn'
+    #   (registry 是 usion[fusion['台'] == t], t 就是 'WTGnn') —— 首版写成 '1#' ⇒ 融合级整列显示 '—'。
+    al = _alarm_counts(win)
+    rows = []
+    for i in range(1, 39):
+        tid, nid = f'WTG{i:02d}', f'{i}#'
+        mine = l6[l6['台'] == nid] if len(l6) else pd.DataFrame()
+        # ── 模型侧: 该台过闸线里最重的那条 ──────────────────────────────────────
+        if len(mine):
+            order = {'定论': 6, '准定论·预警': 5, '候选·新发': 4, '候选·记基线': 3, '候选': 3,
+                     '参考·上升': 2, '参考': 1, 'INSUFFICIENT': 0, '撤回': 0}
+            top = max(mine.itertuples(), key=lambda r: order.get(str(r.定级), 0))
+            m_lv = _to_fuse_level(top.定级)
+            m_checked = [dict(evidence_type='spectral_line', criterion=str(getattr(top, '_3', '') or ''),
+                              signal=f'{top.测点}/{top.线}', calibrated=False, value=top.xfleet)]
+            m_why = f'{top.线}@{top.hz}Hz ×fleet={top.xfleet} → {top.定级}'
+        else:
+            m_lv, m_checked, m_why = 'INSUFFICIENT', [], '无过闸谱线'
+        # ── CMS 侧: 红黄告警计数 (门限未校准 ⇒ 封顶候选) ────────────────────────
+        a = al.get(tid, dict(red=0, yellow=0, n=0))
+        if a['red'] > 0:
+            c_lv = '候选'
+        elif a['yellow'] > 0:
+            c_lv = '参考'
+        else:
+            c_lv = '正常'
+        c_checked = [dict(evidence_type='broadband_level', criterion='CMS RedMask/YellowMask 计数',
+                          signal='CMS/alarm_type', calibrated=False,
+                          value=f"红{a['red']}/黄{a['yellow']}")]
+        both = [dict(source='模型(观澜自算)', level=m_lv, driver='spectral_line', checked=m_checked),
+                dict(source='CMS(厂家系统)', level=c_lv, driver='broadband_level', checked=c_checked)]
+        for v in both:
+            if v['level'] not in _FUSE_LEVELS:
+                v['level'] = 'INSUFFICIENT'
+        r = FD.fuse(both, positive_anchor=False)
+        rows.append(dict(
+            台=tid, 融合=_to_report_level(r.get('level')),
+            CMS=f"{c_lv}(红{a['red']}/黄{a['yellow']})", 模型=m_lv,
+            机制=r.get('mechanism', ''), 模型依据=(f"{m_why} | " + str(r.get('rationale', '')))[:300],
+            未覆盖证据类型=','.join(r.get('uncovered') or []),
+            告警证据力=('RED' if a['red'] else ('YELLOW' if a['yellow'] else 'INFO')),
+            _异议=';'.join(r.get('dissent') or []), _窗=win))
+    return pd.DataFrame(rows)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='六层链 fusion 步(逆向工程实现: fleet_scalar_z)')
+    ap.add_argument('--window', default='w0127', help='用哪个窗算(默认首窗 w0127,与样件同源)')
+    ap.add_argument('--verify', action='store_true', help='与盘上样件逐值对拍,不写盘')
+    ap.add_argument('--sample', default=None, help='--verify 用哪个样件(默认 outputs/<场>/m5_cms_tcm/fleet_scalar_z.parquet)')
+    ap.add_argument('--out', default=None, help='输出路径(默认写回 m5/fleet_scalar_z.parquet)')
+    ap.add_argument('--min-n', type=int, default=3, help='每组最少记录数(样件口径 = 3)')
+    ap.add_argument('--no-fusion-table', dest='fusion_table', action='store_false',
+                    help='只出 fleet_scalar_z, 不出 fusion_38.csv')
+    a = ap.parse_args()
+    if a.verify:
+        return verify(a.window, pathlib.Path(a.sample) if a.sample else None)
+    df = compute(a.window, a.min_n)
+    out = pathlib.Path(a.out) if a.out else (P.m5() / 'fleet_scalar_z.parquet')
+    out.parent.mkdir(parents=True, exist_ok=True)
+    df.to_parquet(out, index=False)
+    print(f'已写 {P.rel(out)}: {len(df)} 行 × {len(df.columns)} 列(窗 {a.window})')
+    rels = {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%)'}
+    if a.fusion_table:
+        ft = fusion_table(a.window)
+        fp = P.m5() / 'fusion_38.csv'
+        ft.to_csv(fp, index=False, encoding='utf-8-sig')     # 消费端 pd.read_csv(dtype=str) 读它
+        dist = ft['融合'].value_counts().to_dict()
+        print(f'已写 {P.rel(fp)}: {len(ft)} 台 × {len(ft.columns)} 列(窗 {a.window})· 融合级分布 {dist}')
+        rels[fp.relative_to(P.out_root()).as_posix()] = (
+            'scripts/rudong_fusion_run.py (按口径重建, **无标准答案对拍**; 模型侧=model_run_l6 过闸线, '
+            'CMS 侧=窗索引 RedMask/YellowMask, 裁决=src/sop/fusion_diag.py::fuse)')
+    # 自登记 (产物来源自登记: 谁算的谁登记)
+    # ★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(), rels, by='rudong_fusion_run')
+        print('   已自登记 → _derived_manifest.json')
+    except Exception as e:
+        print(f'   [i] 自登记跳过: {type(e).__name__}: {e}')
+    return 0
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 371 - 0
scripts/rudong_model_run.py

@@ -0,0 +1,371 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""六层链 `model_run` 步 —— 窗索引 + 谱库 → **L6 过闸谱线表 + 层小结**(按口径重建)。
+
+## 这一步是什么 / 不是什么(先读这段再读代码)
+
+用户令 2026-09-18「/detail 各页面不许用旧版产出补, 必须基于输入数据重算」之后, CMS 报告里
+唯一还空着的是**融合级**那一列, 它的上游就是本步与 `fusion` 步的两件产物:
+
+```
+configs:  'model_l6'      = outputs/<场>/m5_cms_tcm/model_run_l6.parquet    ← 从未随包(无标准答案)
+          'model_summary' = outputs/<场>/m5_cms_tcm/model_run_summary.json
+          'fusion'        = outputs/<场>/m5_cms_tcm/fusion_38.csv           ← 从未随包(无标准答案)
+```
+
+**不是**复现: 这两件从未随过包, 盘上没有样件可逐值对拍 ⇒ 无法证明"与振动线那套一致"(见
+`docs/振动六层链_接口规格与缺口_v0.1.md` §3)。**是**按口径重建: 判据全部取自**包内既有实现**
+(不在本器里另写一套阈值), 每条线都留下"过没过闸、按哪条闸定的级"的来路, 来历在
+`model_run_summary.json` 的 `口径` 字段与产物台账里写明(`按口径重建, 无标准答案对拍`)。
+
+## 判据来源(包内单一实现, 本器只做接线)
+
+| 环节 | 包内实现 |
+|---|---|
+| 候选线(理论频率) | `reference/rudong/oem_scan_plan.json` 的 11 个部件 × {BPFI, BPFO, BSF}(分母已 `--verify-plan` 11/11 逐值验过) |
+| 谱前提闸 G1–G8 | `src/sop/discriminators.py::spectral_line_gates`(分辨率/有线/峰稳/非固有/非整阶/选择性/fleet 离群) |
+| 六枚举定级 | `src/sop/discriminators.py::vib_verdict_and_writeback`(L0 短路 / 机制未定不命名部件 / 无正样本锚封顶候选) |
+| 正样本锚 | `reference/rudong/positive_anchors.json`(人工坐实的实物证据; 本器只读不写) |
+| 轴系转频 | `oem_scan_plan.json` 各部件的 `shaft_Hz`(★只传该轴承自己那根轴 —— 传全机会把真线误杀) |
+
+## 输出(消费者的列名是硬契约)
+
+`model_run_l6.parquet` 的列由 `src/windcms/report_std.py::registry()` 与 `report.py` 决定:
+`台 / 测点 / 线 / hz / 域 / 绝对量 / 单位 / xfleet / 选择性 / 占比pct / 绝对锚 / 定级`
+(`台` 必须是 `f'{n}#'` 形态: registry 的 `_l6_consumed` 闭环断言会核对, 台号格式漂移当场报错)。
+
+用法:
+    python scripts/rudong_model_run.py                    # 默认取 M5_WINDOWS / 自动发现的窗
+    python scripts/rudong_model_run.py --window w0316
+    python scripts/rudong_model_run.py --turbines WTG01,WTG09   # 冒烟
+    python scripts/rudong_model_run.py --dry-run          # 只报计划, 不写产物
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import sys
+import time
+
+import numpy as np
+import pandas as pd
+
+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
+from src.sop import discriminators as D                                 # noqa: E402
+import rudong_tcm_oem_scan as OS                                        # noqa: E402
+import spectra_lookup as SL                                             # noqa: E402
+
+ANCHORS = ROOT / 'reference' / 'rudong' / 'positive_anchors.json'
+COMP_CLASS = {                          # 部件 → 正样本锚的部件类 (reference/rudong/positive_anchors.json)
+    'generator': 'generator_bearing', 'hs': 'gearbox_hs_bearing', 'ims': 'gearbox_ims_bearing',
+    'main': 'main_bearing',
+}
+# ISO 10816 速度当量 (mm/s, 刚性支撑·>15kW 机组的黄/红带) — 唯一"已校准"的绝对锚
+ISO = dict(yellow=4.5, red=11.0)
+LINES = (('BPFI', 'BPFI_hz', '内圈'), ('BPFO', 'BPFO_hz', '外圈'), ('BSF', 'BSF_hz', '滚动体'))
+
+
+def comp_class(component: str) -> str:
+    for k, v in COMP_CLASS.items():
+        if component.startswith(k):
+            return v
+    return 'unknown'
+
+
+def anchors_of() -> list | None:
+    if not ANCHORS.is_file():
+        return None
+    return json.loads(ANCHORS.read_text(encoding='utf-8')).get('anchors')
+
+
+def windows_of(explicit: str | None) -> list[str]:
+    import os
+    if explicit:
+        return [explicit]
+    env = os.environ.get('M5_WINDOWS') or ''
+    if env.strip():
+        return [w for w in env.split(',') if w.strip()]
+    m5 = P.m5()
+    got = [p.parent.name for p in sorted((m5 / 'windows').glob('w[0-9][0-9][0-9][0-9]/index.parquet'))]
+    if (m5 / 'tcm_index.parquet').is_file():
+        got.insert(0, 'w0127')
+    return got
+
+
+_AVAIL: dict[str, set] = {}
+
+
+def available(win: str) -> set:
+    """该窗谱库里**实有**的 (机组, 测点, 测量) 组合 —— 计划里的测量名不一定每台都有。
+
+    ★为什么必须先查: `spectra_lookup.load()` 查不到记录时是 `SystemExit`(它的 CLI 语义), 而
+    SystemExit 继承 BaseException, 用 `except Exception` 兜不住 —— 首版跑到第一台就整体退出
+    (实测: w0316 里 WTG01 没有 Env_6000_4000_850_Tr 的谱)。缺谱是**常见情形**, 该跳过并计数, 不是崩。
+    """
+    if win not in _AVAIL:
+        mp = SL.meta_path(win)
+        s = set()
+        if mp.is_file():
+            d = pd.read_parquet(mp, columns=['turbine', 'sensor', 'meas_name'])
+            s = set(map(tuple, d[['turbine', 'sensor', 'meas_name']].drop_duplicates().values))
+        _AVAIL[win] = s
+    return _AVAIL[win]
+
+
+def fleet_median(sensor: str, meas: str, turbines: list[str], win: str) -> np.ndarray | None:
+    """同 (测点, 测量) 的**跨台中位谱** —— G4(机型固有) 与 G8(个体离群) 都靠它。"""
+    got = []
+    for t in turbines:
+        if (t, sensor, meas) not in available(win):
+            continue
+        try:
+            r = SL.load(win, t, sensor, meas)
+        except BaseException:
+            continue
+        if not r:
+            continue
+        got.append(np.asarray(r['v'], dtype=float))
+    if len(got) < 5:
+        return None
+    n = min(len(v) for v in got)
+    return np.nanmedian(np.vstack([v[:n] for v in got]), axis=0)
+
+
+def evaluate(win: str, turbines: list[str], comps: list[dict], anchors, dry=False) -> tuple[list, dict]:
+    """逐 (机组 × 部件 × 特征频率) 过闸定级 → L6 行。"""
+    rows, stats = [], dict(considered=0, passed=0, no_spectrum=0, by_gate={}, by_level={})
+    fmed_cache: dict[tuple, np.ndarray | None] = {}
+    avail = available(win)
+    for c in comps:
+        meas, sensor = c['shard'], c['sensor']
+        key = (sensor, meas)
+        if key not in fmed_cache:
+            fmed_cache[key] = fleet_median(sensor, meas, turbines, win)
+        fmed = fmed_cache[key]
+        dx = float(c.get('dx_hz') or 0) or None
+        shaft = c.get('shaft_Hz') or 0.0
+        domain = '包络谱' if str(meas).lower().startswith('env') else '原始谱'
+        cls = comp_class(c['component'])
+        for t in turbines:
+            if (t, sensor, meas) not in avail:
+                stats['no_spectrum'] += 1
+                continue
+            try:
+                r = SL.load(win, t, sensor, meas)
+            except BaseException:
+                r = None
+            if r is None:
+                stats['no_spectrum'] += 1
+                continue
+            y = np.asarray(r['v'], dtype=float)
+            meta = r['row']
+            _dx = float(r.get('x_delta') or dx or 0) or dx
+            if not _dx:
+                continue
+            for label, col, cn in LINES:
+                hz = c.get(col)
+                if not hz or not np.isfinite(hz) or hz <= 0:
+                    continue
+                stats['considered'] += 1
+                # 观测峰: 目标频率 ±2 bin 内取最大 (与谱前提闸 G7 的搜索窗同宽)
+                tol = 2
+                k = int(round(hz / _dx))
+                if k - tol < 1 or k + tol >= len(y):
+                    continue
+                seg = y[k - tol:k + tol + 1]
+                if not len(seg) or not np.isfinite(seg).max():
+                    continue
+                obs = float(np.nanmax(seg))
+                x_fleet = None
+                if fmed is not None and len(fmed) > k + tol:
+                    base = float(np.nanmax(fmed[k - tol:k + tol + 1])) or np.nan
+                    if np.isfinite(base) and base > 1e-12:
+                        x_fleet = obs / base
+                sel = None                     # 选择性 = 线 ÷ 同谱宽带本底 (同窗, 与 G6 同语义)
+                lo, hi = max(int(0.25 * len(y)), 1), max(int(0.75 * len(y)), 2)
+                bg = float(np.nanmedian(np.abs(y[lo:hi]))) if hi > lo else np.nan
+                if np.isfinite(bg) and bg > 1e-12:
+                    sel = obs / bg
+                is_vel = str(meta.get('y_unit') or '') in ('m/s',)
+                g = D.spectral_line_gates(y, _dx, float(hz), fleet_med=fmed, shaft_hz=(shaft,) if shaft else (),
+                                          x_fleet=x_fleet, selectivity_samewin=sel, domain=domain,
+                                          iso_ref_mms=(ISO['yellow'] if is_vel else None))
+                gl = g.get('verdict') or '?'
+                stats['by_gate'][gl] = stats['by_gate'].get(gl, 0) + 1
+                if not g.get('passed'):
+                    continue
+                # 绝对量: 速度域取 mm/s 当量 (ISO 可直接比), 其余域取谱值原单位
+                if is_vel:
+                    abs_v, unit = obs * 1000.0, 'mm/s'
+                else:
+                    abs_v, unit = obs, str(meta.get('y_unit') or '')
+                vw = D.vib_verdict_and_writeback(
+                    gate_result=g, abs_value=abs_v, abs_unit=unit, x_fleet=x_fleet,
+                    iso_yellow=ISO['yellow'] if is_vel else None,
+                    iso_red=ISO['red'] if is_vel else None,
+                    component_class=cls, anchors=anchors, n_evidence_types=1,
+                    mechanism_confirmed=False)         # 机制未定 ⇒ 不命名部件 (硬规则 2)
+                lvl = vw.get('level') or 'INSUFFICIENT'
+                stats['passed'] += 1
+                stats['by_level'][lvl] = stats['by_level'].get(lvl, 0) + 1
+                # 解封判据: 锚层给出的最高可达级依据 (无实物锚 ⇒ 写明未解封) —— 消费端 report.py:213 与
+                # plugins.verdict_gate 都会显示这一列, 缺列会让逐台页整页崩 (AttributeError, 2026-09-19 实逮)
+                try:
+                    ci = D.anchor_cap(cls, anchors, n_evidence_types=1, mechanism_confirmed=False)
+                    unlock = f"锚 tier={ci.get('tier')}: {str(ci.get('reason'))[:60]}"
+                except Exception as e:
+                    unlock = f'锚不可判 ({type(e).__name__})'
+                rows.append(dict(
+                    台=f'{int(t[3:])}#', 测点=sensor, 线=f'{c["component"]}·{label}({cn})',
+                    证据族=f'{domain}·{cn}', 解封判据=unlock,
+                    hz=round(float(hz), 3), 域=domain,
+                    绝对量=round(float(abs_v), 5), 单位=unit,
+                    xfleet=(round(float(x_fleet), 2) if x_fleet is not None and np.isfinite(x_fleet) else None),
+                    选择性=(round(float(sel), 2) if sel is not None and np.isfinite(sel) else None),
+                    占比pct=None,
+                    绝对锚=(f'ISO10816 黄{ISO["yellow"]}/红{ISO["red"]} mm/s' if is_vel else '—'),
+                    定级=_consumer_level(lvl),
+                    _闸=g.get('verdict'), _依据=' · '.join(vw.get('reasons') or [])[:220],
+                    _写回=vw.get('writeback'), _窗=win))
+    return rows, stats
+
+
+def _consumer_level(lvl: str) -> str:
+    """把 L6 六枚举映射到消费端 `_LINE_STATE` 认得的那一套 (report_std.py 的 line_state 表)。
+
+    `候选·查偶发源` / `候选·记基线` 这类细分在消费端表里**没有条目** ⇒ 会被默认成"优秀"
+    (静默降级成假好消息)。这里显式映射, 细分信息留在 `_依据` 里。
+    """
+    s = str(lvl)
+    if s.startswith('确诊') or s == '定论':
+        return '定论'
+    if s.startswith('准定论'):
+        return '准定论·预警'
+    if s.startswith('候选·新发'):
+        return '候选·新发'
+    if s.startswith('候选·记基线'):
+        return '候选·记基线'
+    if s.startswith('候选'):
+        return '候选'
+    if s.startswith('参考·上升'):
+        return '参考·上升'
+    if s.startswith('参考'):
+        return '参考'
+    if s.startswith('撤回'):
+        return '撤回'
+    return 'INSUFFICIENT'
+
+
+def l0_layer(win: str) -> dict:
+    """L0 数据质量层 (按测点): 有解析错/过载/无记录 ⇒ 该测点结论不可采信 (report_std 会据它判"不可判")。
+
+    只看**索引自己的字段**(parse_error/overload), 不加任何自造阈值 —— 这层的意义是"别把盲台当健康台"。
+    """
+    ix = P.m5() / ('tcm_index.parquet' if win == 'w0127' else f'windows/{win}/index.parquet')
+    if not ix.is_file():
+        return {}
+    d = pd.read_parquet(ix, columns=[c for c in ('turbine', 'sensor_name', 'parse_error', 'overload')
+                                     if c in pd.read_parquet(ix).columns])
+    out = {}
+    for (t, s), g in d.groupby(['turbine', 'sensor_name']):
+        bad = int(g['parse_error'].notna().sum()) if 'parse_error' in g else 0
+        ovl = int((g['overload'] == True).sum()) if 'overload' in g else 0          # noqa: E712
+        if bad and bad == len(g):
+            out[f'{t}|{s}'] = '不可用: 该测点记录全部解析失败'
+        elif ovl > 0.5 * len(g):
+            out[f'{t}|{s}'] = '不可用: 该测点过半记录过载'
+        elif bad:
+            out[f'{t}|{s}'] = f'可用(部分): {bad}/{len(g)} 条解析失败'
+    return out
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='六层链 model_run 步: 窗索引+谱库 → L6 过闸谱线 (按口径重建)')
+    ap.add_argument('--window', default=None)
+    ap.add_argument('--turbines', default=None)
+    ap.add_argument('--out', default=None)
+    ap.add_argument('--dry-run', action='store_true')
+    a = ap.parse_args()
+    plan, comps = OS.load_plan()
+    anchors = anchors_of()
+    wins = windows_of(a.window)
+    if not wins:
+        print('[X] 没有可分析的窗 (m5_cms_tcm/windows/*/index.parquet 与 tcm_index.parquet 都不在位)')
+        return 2
+    turbines = ([f'WTG{x.strip().zfill(2)}' for x in a.turbines.split(',')] if a.turbines
+                else [f'WTG{i:02d}' for i in range(1, 39)])
+    print(f'model_run: 窗 {wins} · 机组 {len(turbines)} 台 · 部件 {len(comps)} 个 · '
+          f'候选线 {len(comps) * len(LINES)}/台·窗 · 正样本锚 {len(anchors or [])} 条')
+    if a.dry_run:
+        for c in comps:
+            print(f'   {c["component"]:24} {c["sensor"]:24} {c["shard"]:24} dx={c["dx_hz"]:<9} '
+                  f'BPFI={c.get("BPFI_hz")} BPFO={c.get("BPFO_hz")} BSF={c.get("BSF_hz")} '
+                  f'shaft={c.get("shaft_Hz")}')
+        print('(dry-run, 未写产物)')
+        return 0
+    t0 = time.time()
+    allrows, summary = [], {}
+    for win in wins:
+        rows, stats = evaluate(win, turbines, comps, anchors)
+        allrows += rows
+        summary[win] = stats
+        print(f'   窗 {win}: 候选 {stats["considered"]} 条 → 过闸 {stats["passed"]} 条 · '
+              f'定级 {stats["by_level"]} · 闸分布 {dict(sorted(stats["by_gate"].items(), key=lambda x: -x[1])[:5])}')
+    m5 = P.m5()
+    l6 = pd.DataFrame(allrows)
+    out = pathlib.Path(a.out) if a.out else (m5 / 'model_run_l6.parquet')
+    if len(l6):
+        l6 = l6.sort_values(['台', '测点', '线']).reset_index(drop=True)
+    # 消费端的硬契约 (report_std.registry 的 _l6_consumed 闭环 + report.py 的列单)
+    # 列 = 消费端硬契约: registry()/report.py:504 的投影 + report.py:213 与 plugins.verdict_gate 的明细表
+    COLS = ['台', '测点', '线', 'hz', '域', '绝对量', '单位', 'xfleet', '选择性', '占比pct', '绝对锚',
+            '证据族', '解封判据', '定级', '_闸', '_依据', '_写回', '_窗']
+    l6 = l6.reindex(columns=COLS) if len(l6) else pd.DataFrame(columns=COLS)
+    bad = sorted(set(l6['台']) - {f'{i}#' for i in range(1, 39)}) if len(l6) else []
+    if bad:
+        print(f'[X] 台号不在 38 台清单里: {bad} —— 消费端 registry 的闭环断言会当场报错, 本器先拦')
+        return 5
+    out.parent.mkdir(parents=True, exist_ok=True)
+    l6.to_parquet(out, index=False)
+    sm_path = m5 / 'model_run_summary.json'
+    L0 = l0_layer(wins[0])
+    summ = dict(
+        status='ok', built=time.strftime('%Y-%m-%d %H:%M:%S'), windows=wins, seconds=round(time.time() - t0, 1),
+        n_lines=int(len(l6)), by_gate={w: s['by_gate'] for w, s in summary.items()},
+        by_level={w: s['by_level'] for w, s in summary.items()},
+        L0=L0,
+        口径='按口径重建(**非**逐值对拍的复现 —— model_run_l6.parquet 从未随过包, 无标准答案, 见 '
+             'docs/振动六层链_接口规格与缺口_v0.1.md §3)。候选线=reference/rudong/oem_scan_plan.json 的 11 个'
+             '部件 × {BPFI,BPFO,BSF}(分母已 11/11 逐值验过); 过闸=src/sop/discriminators.py::spectral_line_gates '
+             '(G1..G8); 定级=src/sop/discriminators.py::vib_verdict_and_writeback (L0 短路 / 机制未定不命名部件 / '
+             '无正样本锚封顶候选); 轴系转频只传该轴承自己那根轴; ISO 绝对锚只对速度域生效',
+        missing_note='峰值拾取仍是**本器自定**: 目标频率 ±2 bin 取最大 (与 G7 搜索窗同宽), 未做阶次跟踪/多记录合并 —— '
+                     '振动线那套的"怎么取观测峰"口径仍缺 (docs §7.3), 故本件不可当"复现"用')
+    sm_path.write_text(json.dumps(summ, ensure_ascii=False, indent=1), encoding='utf-8')
+    print(f'已写 {P.rel(out)}: {len(l6)} 行')
+    print(f'已写 {P.rel(sm_path)}: L0 {len(L0)} 条 · 窗 {len(wins)}')
+    # 来源自登记 (谁算的谁登记)
+    try:
+        from src import derived_manifest as DM
+        rels = {out.relative_to(P.out_root()).as_posix():
+                'scripts/rudong_model_run.py (按口径重建, 无标准答案对拍; 判据=src/sop/discriminators.py)',
+                sm_path.relative_to(P.out_root()).as_posix():
+                'scripts/rudong_model_run.py (L0 层 + 口径说明)'}
+        DM.record(P.out_root(), rels, by='rudong_model_run')
+        print('   已自登记 → _derived_manifest.json')
+    except Exception as e:
+        print(f'   [i] 自登记跳过: {type(e).__name__}: {e}')
+    return 0
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 7 - 4
scripts/vib_raw_build.py

@@ -220,13 +220,16 @@ def main() -> int:
     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。放在这里是因为它按窗算, 而本脚本刚知道这窗叫什么名字 (2026-09-18 用户令后上链:
-        # 页面的振动标量 z 面此前完全空白)。
-        run('fusion_fleet_z', [PY, str(ROOT / 'scripts/rudong_fusion_run.py'),
-                               '--window', final.name], log)
+        # → 稳健 z。上面那次 fusion 已经一并出它 (同一条命令: --window final.name)。
     else:
         print('(按 --no-report 跳过 CMS 报告/知识库重生成 —— 页面上"振动评估"一栏会显示为无产物)')