Selaa lähdekoodia

用户令「所有的计算均要形成观澜的源代码」第一批: 融合面 + 总览页 落成源代码

用户令 2026-09-19: 「所有的计算 均要形成 观澜的源代码,确保 观澜 在别的电脑安装 后,系统运行正常。」

先修用户当场的现象(/detail/v2 的「需要关注」「全场状态」下面没有计算输出), 根因与修法:
- 那两块取数走 /api/fleet 的 fus, 而 fus 的链盘(需要关注)与逐台矩阵(全场状态)来自 fusion.py 读的
  m5_cms_tcm/handoff_vibration_v2.json —— 该件此前是**振动线人工交证件, 全库 0 处写入方**
  ⇒ 别的电脑装完(放原始件+重算)它永远不在位, 两块只能空白。
- 新增 scripts/rudong_fusion_handoff.py: 由观澜自算生成**同结构件** ——
  输入全是重算产物(报告状态等级 + fusion_38.csv 融合裁决 + model_run_l6 过闸线 + 窗索引实算区间),
  meta.from 标明"观澜自算", 每条 verdict 由观澜判据给出(词表与 fusion.VERDICT_CLASSES 对齐:
  危险→定论 bad / 报警→准定论·预警 warn / 良好优秀→无新证 ok / 不可判→测量链不可判 meta),
  盘上若有**振动线正本**则不覆盖(正本含人工裁决, 优先; --force 只覆盖自算件)。
  复检: fus 链盘 38 行 · 需要关注 3 台(WTG09 齿轮箱 / WTG17·WTG27 发电机) · 矩阵 38 行 · gap=None。

- 同一类问题还逮到第二处: /detail/v2 的「全场状态总览」是 <iframe src="/static/index.html">,
  落到 outputs/<场>/windscada/index.html —— 同为"无生成端随包件", 清过产物后 iframe 只显示
  2KB 的"产物不在位"说明页。新增 scripts/windscada_overview_build.py: 用**组件同一个**快照烘焙器
  (src/windscada/ui/snapshot.py::bake, 即 /v2/snapshot 的那套)把 /api/fleet·curves·vibcms·本体响应
  烤成单文件页(离线可开) ⇒ 实测 3.78MB 真页面。

- 链与族表同步: ④b 链序补 fusion_handoff(report 之后); rebuild_all 新增 ⑦b 全场状态总览页(自算烘焙,
  必须在 ⑦ 本体之后); 族表拆出 windscada_overview(raw-derived, gen=windscada_overview_build.py)与
  vib_fusion_handoff(raw-derived, gen=rudong_fusion_handoff.py), windscada_pages 收窄为 turbines/review。
  "产物不在位"兜底页的文案同步改写(不再把这两件说成"包内没有生成端")。

- 新增 docs/源代码化清单_v0.1.md: 按族表机器可核地列出**还没落到源代码**的 9 族(变桨面 / component_history
  +baseline_38 / tcm_replay 掩码阈值 / ontology_releases / guanlan 契约+sop+paradigm / windcms 剩余件 /
  vib_figs / 静态逐台副本 / 人工交证件), 每族写明"喂谁·没源代码的后果·需要的生成端(输入→产物)·可行性",
  并列出两件需用户定口径的事(事实契约是否改机器生成; 变桨零位口径)与建议执行顺序。

- 顺带: v2 总览在融合面缺件时**如实写出缺件与补齐办法**(新增 lang 条目 ov.fus_gap_hint, 中文 + 英文),
  不再让两片空白被读成"没有需要关注的台"; 语言包闸(lang.check)通过(829+ 条)。

验证: 台账 refresh → 1778 件全 raw-derived; products_reverse_audit 检查 rc=0(未归类 0);
guanlan.py check 只剩三条已知 FAIL(人裁底稿两件 + Ollama 未启动); chain_gap_check rc=5(如实:
energy_share 脚本仍缺 + oem_scan 分子口径未定)。
zhouyang.xie 3 viikkoa sitten
vanhempi
commit
502697bf2a

+ 61 - 0
docs/源代码化清单_v0.1.md

@@ -0,0 +1,61 @@
+# 源代码化清单 v0.1(2026-09-19)
+
+用户令:**「所有的计算 均要形成 观澜的源代码,确保 观澜 在别的电脑安装后,系统运行正常。」**
+
+本清单是这条令的**可核对底稿**:把"页面/接口要用的每一件数据,是不是都由包内的源代码算出来的"逐族列清,
+列出**还没落到源代码**的那些族的现状、喂谁、缺什么、怎么补。
+
+判据来源(机器可读,不靠印象):`scripts/products_reverse_audit.py::FAMILIES` 的族表 ——
+`kind=shipped/human 且 gen=None` 的族就是"没有生成端";`scripts/products_reverse_audit.py --check`
+在交付前会把族表与盘上账目对一遍(当前 rc=0:1,778 件全部 raw-derived)。
+
+---
+
+## 0. 今天已经补上源代码的两件(本令的第一批)
+
+| 件 | 生成端(在包内) | 喂谁 | 复检 |
+|---|---|---|---|
+| `m5_cms_tcm/handoff_vibration_v2.json`(融合面) | **`scripts/rudong_fusion_handoff.py`**(观澜自算:报告状态等级 + `fusion_38.csv` + L6 过闸线 → 同结构件;`meta.from` 标"观澜自算",盘上若有振动线正本则不覆盖) | `/detail/v2` 的**「需要关注」+「全场状态」**(`/api/fleet` 的 `fus`) | 链盘 38 行 / 需要关注 **3 台**(WTG09 齿轮箱、WTG17/WTG27 发电机)/ 矩阵 38 行 ✔ |
+| `windscada/index.html`(总览「全场状态」页) | **`scripts/windscada_overview_build.py`**(用组件同一个快照烘焙器 `src/windscada/ui/snapshot.py::bake` 把 `/api/fleet`・`curves`・`vibcms`・本体响应烤进单文件页) | `/detail/v2` 的「全场状态总览」`<iframe src="/static/index.html">` | 3.78 MB 真页面(此前是 2 KB 的"产物不在位"说明页)✔ |
+
+两件都已进重算链:④b 里 `model_run → fusion → report → **fusion_handoff** → kb`;⑦ 之后新增 **⑦b 全场状态总览页(自算烘焙)**。
+
+---
+
+## 1. 还没落到源代码的族(按"装了新机器会不会看到空白"排序)
+
+| # | 族 | 件 | 喂谁(页面/接口) | 现在没源代码的后果 | 需要的生成端(输入 → 产物) | 可行性 |
+|---|---|---|---|---|---|---|
+| 1 | `pitch_shipped` | `pitch/pitch_daily.parquet`、`pitch_zero_monthly.parquet` | `/api/fleet` 的**变桨面**判级(逐台页/总览的"变桨系统"格) | 变桨面显示"缺件 ⇒ 不可判" | `scada_10min`(+`scada_1min`) 的桨距角/桨叶油压通道 → 日粒度零位/漂移表 | **中**:通道在数据里,需定"零位口径"(停机段中位?满发段中位?) |
+| 2 | `vib_handoff_and_scans` 其余件 | `m5_cms_tcm/component_history.json`、`baseline_38.json`、`oem_frequency_scan.parquet` 等扫描件 | 本体 `trend_ingest`(在升/闭环证据)、`vib_confidence`、`maintenance` 数据层页、报告附录 | 趋势/置信度面少一维;数据层页把这几件列成缺件 | `component_history`:由逐窗 L6+标量按部件聚合历史;`baseline_38`:由 `fleet_scalar_z`/标量算三层基线(自/机群/绝对) | **中**:结构简单,但"口径"要与消费端字段对齐(`trend_ingest.py` 读法已可参照) |
+| 3 | `tcm_replay` | `tcm_compatible_replay/model/mask_thresholds.tsv` 等 | CMS 报告/逐台页的**厂商黄红阈值线**;`data.load_masks` | 页面只画机群中位,阈值线缺失(现文案:"厂商未设阈值") | 由 `data/raw/如东/windcms` 导出件里的 `red_alarm/yellow_alarm/red_hys/…` 索引列反解出每 (测点,测量,工况) 的中位/黄/红标量 | **中偏低**:索引里确有这些列(54 列之一),口径需反推 + 抽台对拍 |
+| 4 | `ontology_releases` | `ontology/release_r1/*`、`release_r2/*` | 网关 `/release/*`(只读发布层)、门户"发布层"入口 | `/release/` 两件 503(如实报缺) | 由 `ontology/objects.json` + 层清单配置生成 r1/r2 层(`kb_ingest`/`populate` 之后跑一步) | **易**:纯搬运/投影,无判据 |
+| 5 | `guanlan_contract` + `sop_workspace` + `paradigm_r1` | `guanlan/facts_contract_v0.json` 及 `derived/*`;`sop/findings.json`;E3/E5/E8 底稿 | 门户「本场结论·契约生成」段、`/detail` 问答/报告页(`/api/facts` 503) | 门户结论段停在旧数;问答/报告面 503 | 新增"**从重算台账生成 claims**"的生成端(alarms/workorders/oil/temp/本体 → 结构化 claim:claim_id/裁决/时间窗/样本/证据引用),替代人裁底稿作为输入 | **难且涉及口径**:这一族原本是**人裁结论**(119 条),改成机器生成等于换口径 → 需你点头(见 §2) |
+| 6 | `windcms_pages` 剩余件 | `windcms/` 下厂家报告转录、kb 之外的其他件 | CMS 首页/工作台的少数入口 | 个别入口空 | 多数已由 `windcms.py report/kb` 覆盖;剩余是"厂家报告转录"(输入是现场 PDF,属外部正本) | **易/不适用** |
+| 7 | `vib_figs` | `m5_cms_tcm/figs/*` | 振动报告里的插图 | 报告少图 | 由谱库渲染 PNG(可复用 `src/windcms` 的 SVG/绘图) | **低优先** |
+| 8 | `windscada_pages` 剩余件 | `windscada/turbines/*`、`review/*` | 静态副本(运行期逐台页由组件动态出,不依赖它) | 无实际影响 | 若要静态副本:复用 `windscada_overview_build.py` 的烘焙(逐台 slim 版) | **易** |
+| 9 | `human_deliverables` | `windscada/mblub_alarm_cross.json`、`review_presented.json`、送审结论 | 本体 `populate` 的证据来源之一 | 少一条人工证据(本体仍可用) | **不应**由源代码生成(是人对人的交证件/回执) | **不适用**(族表已记 kind=human,不参与呼应校验) |
+
+---
+
+## 2. 需要你定的两件事(都不动手就永远不会自己好)
+
+1. **事实契约面(第 5 行)**:要不要让观澜**从重算台账生成 claims**(口径从"人裁结论"变成"机器判据 + 人复核")?
+   - 选"要":我按 `sop/findings.json` 的字段结构写生成端,门户结论段/问答/报告三处随之由重算驱动;
+   - 选"不要":门户那一段就按之前提的两条路处理(加"数据截至⋯、契约未随重算更新"横幅,或把该段摘掉)。
+2. **变桨面零位口径(第 1 行)**:`pitch_daily` 的"零位"用**停机段中位**还是**满发段中位**(或两者都出、分列)?
+   定了我就把生成端写进链(口径写进产物与台账)。
+
+## 3. 执行顺序(我建议)
+
+```
+① ontology_releases 生成端(易,先把 /release 两件点亮)
+② component_history / baseline_38 生成端(中,补趋势与置信度面)
+③ tcm_replay 掩码阈值反解(中偏低,页面阈值线回来)
+④ pitch 面生成端(中,需你先定零位口径)
+⑤ 事实契约生成端(难,需你先定口径)
+⑥ vib_figs / 静态逐台副本(低优先)
+```
+
+每补一族都走同一套纪律:**生成端进包 + 进重算链 + 写进族表(gen/input) + 产物自登记 + 复检页面**,
+并在 `docs/系统设计说明.md` §13.6 与族表里把该族从"无生成端"改成"已在重算链上(附口径与验证依据)"。

+ 23 - 3
scripts/products_reverse_audit.py

@@ -132,6 +132,14 @@ FAMILIES: list[dict] = [
          gen='scripts/rudong_fusion_run.py', input='m5_cms_tcm', pred=('rows',),
          why='2026-09-18 上链: 由 vib_raw_build.py 的 --with-report 分支按当次窗名调用, '
              '使 /api/fleet 的振动标量 z 面不再空白'),
+    dict(id='vib_fusion_handoff', glob='m5_cms_tcm/handoff_vibration_v2.json', kind='raw-derived',
+         func='融合面 handoff (观澜自算, 同结构于振动线手交件)',
+         algo='scripts/rudong_fusion_handoff.py(报告状态等级 + fusion_38 融合裁决 + L6 过闸线 → 同结构, '
+              'meta.from 标明"观澜自算")',
+         gen='scripts/rudong_fusion_handoff.py', input='m5_cms_tcm', pred=('rows',),
+         why='★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这件此前全库 0 处写入方(振动线人写), '
+             '新机器装完 `/detail/v2` 的「需要关注/全场状态」必然空白。现由观澜自算出同结构件; '
+             '盘上若有**振动线正本**, 生成端不覆盖(正本含人工裁决, 优先)'),
     dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
          kind='shipped',
          func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
@@ -141,9 +149,21 @@ FAMILIES: list[dict] = [
     dict(id='vib_figs', glob='m5_cms_tcm/figs/*', kind='shipped',
          func='振动线出图(谱图/趋势图)', algo='(无生成端)', gen=None, input=None, pred=(),
          why='随包快照(振动线出件)'),
-    dict(id='windscada_pages', glob='windscada/{index.html,turbines/*,review/*}', kind='shipped',
-         func='总览「全场状态」静态页 + 逐台页 + 评审记录', algo='(无生成端)',
-         gen=None, input=None, pred=(), why='页面件由振动线/历史分析出件,本包无写入方'),
+    # ★2026-09-19 拆族 (用户令「所有的计算均要形成观澜的源代码」): `windscada/index.html` 是
+    #   `/detail/v2` 的「全场状态总览」iframe 目标, 此前整族记为"无生成端" ⇒ 新机器上那块必然显示
+    #   "产物不在位"。现在它由 `scripts/windscada_overview_build.py` 用**组件同一个快照烘焙器**
+    #   (src/windscada/ui/snapshot.py::bake, /v2/snapshot 用的就是它)从重算产物生成 ⇒ 单列一族。
+    dict(id='windscada_overview', glob='windscada/index.html', kind='raw-derived',
+         func='总览「全场状态」页 (单文件: 接口响应烤进页面, 离线可开)',
+         algo='scripts/windscada_overview_build.py(ctx 取数函数与在线 /api/* 同一实现; 经 '
+              'src/windscada/ui/snapshot.py::bake 烘焙)',
+         gen='scripts/windscada_overview_build.py', input='scada_10min', pred=('rows',),
+         why='2026-09-19 用户令后上链: 该页此前是随包件(全库 0 处写入方), 清过产物的机器上 iframe 只能'
+             '显示"产物不在位"; 现在由观澜自算, 每次重算随数据更新'),
+    dict(id='windscada_pages', glob='windscada/{turbines/*,review/*}', kind='shipped',
+         func='逐台静态页 + 评审记录(当前由组件按请求动态出页, 静态件仍属随包快照)', algo='(无生成端)',
+         gen=None, input=None, pred=(), why='逐台页在运行期由 /turbine/<台> 动态生成, 静态副本无写入方; '
+                                             '运行期不从交付包补(用户令 2026-09-17)'),
     # ★2026-09-18 补: `windcms/cache/*` 是**读侧缓存**(标量按内容哈希落盘), 它不是随包快照 ——
     #   原先 `windcms_pages`(shipped) 先认领了它, 于是重算后新写的缓存被记成"包内无生成端"。
     #   写方在 src/windcms/data.py::load_scalars (窗索引 → 标量), 故单列一族并放在 windcms_pages 之前。

+ 6 - 0
scripts/rebuild_all.py

@@ -115,6 +115,12 @@ def build_plan(a) -> list:
     plan.append(step_cmd('⑦ 本体: 在升/闭环证据', [PY, '-m', 'src.ontology.trend_ingest']))
     plan.append(step_cmd('⑦ 本体: 检索索引', [PY, '-c', 'from src.ontology import retrieval as R; R.build(use_vec=False)']))
     plan.append(step_cmd('⑦ 本体: 实机参数表', [PY, '-c', 'from src.ontology.maintenance import refresh_params as f; print(f())']))
+    # ⑦b 总览「全场状态」页 (`outputs/<场>/windscada/index.html`): /detail/v2 的 iframe 目标。
+    #     用户令 2026-09-19「所有的计算均要形成观澜的源代码」——它此前是随包件(0 处写入方),
+    #     新机器上那块只能显示"产物不在位"。现在用组件同一个烘焙器从重算产物生成。
+    #     ★必须排在 ⑦ 之后: 烤进去的响应里有本体数据(objects.json)。
+    plan.append(step_cmd('⑦b 全场状态总览页 (自算烘焙)', [PY, 'scripts/windscada_overview_build.py'],
+                         note='烤的是组件同一套取数函数(见脚本头); 缺产物时会响亮报错, 不产出半张页'))
     plan.append(step_cmd('⑧ 本体审计 (期望 0 问题)', [PY, '-m', 'src.ontology.audit']))
     # ⑧b 重装门户: 门户正文里的"契约结论段"是**产物派生**的(由 guanlan_portal_inject_claims.py
     #     从 outputs/<场>/guanlan/… 渲染), 所以产物变了门户必须重装一遍, 否则页面挂着旧结论。

+ 234 - 0
scripts/rudong_fusion_handoff.py

@@ -0,0 +1,234 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""融合面 handoff 的**观澜自算**生成端(用户令 2026-09-19「所有的计算均要形成观澜的源代码」)。
+
+## 为什么需要它
+
+`/detail/v2` 的「需要关注」与「全场状态」两块,取数走 `/api/fleet` 的 `fus`,而 `fus` 的
+链盘行(需要关注)与逐台矩阵(全场状态)都来自 `src/windscada/subsys/fusion.py` 读的
+`outputs/<场>/m5_cms_tcm/handoff_vibration_v2.json`。那件此前是**振动线人工交证件**:
+全库 0 处写入方 ⇒ 清过产物、只从 data/raw 重算的机器上必然不在位 ⇒ 两块页面**空白**
+(不是报错,也不是没数据 —— 是那件没有生成端)。
+
+用户令把口径定死了:**所有计算都要落到观澜自己的源代码里**,别的电脑装完(放上原始件、重算)
+系统就要正常跑。所以本器把这份 handoff **按同一份结构自己算出来**:
+
+    输入(全部是观澜从 data/raw 重算出来的产物)
+      · m5_cms_tcm/model_run_l6.parquet    六层链 L6 过闸谱线(scripts/rudong_model_run.py)
+      · m5_cms_tcm/fusion_38.csv           逐台融合裁决(scripts/rudong_fusion_run.py)
+      · windcms/报告_CMS振动状态评估报告_*.md  逐台逐部件状态等级 + 证据状态(scripts/windcms.py report)
+      · m5_cms_tcm/windows/<窗>/index.parquet 窗内真实日期区间(claim_window 用实算值)
+    输出
+      · m5_cms_tcm/handoff_vibration_v2.json  与消费者同结构(meta / detectability_prior / per_turbine / open_items)
+
+## 与"正本"的关系(必须说清,不许冒充)
+
+原 handoff 是**人对人的交证件**(含人工裁决、校准更新、现场动作),本器产出的是**同结构的观澜自算件**:
+
+  · `meta.from` = 观澜自算(不是振动线),`meta.version` = `guanlan-selfcomputed-v1`;
+  · 每条 verdict 由观澜自己的判据给出(L6 过闸线 + 融合裁决 + 报告状态等级),**不含**任何人工裁决;
+  · `open_items` 首条如实声明"本件非振动线正本,正本到场应以正本为准";
+  · 若盘上已有**正本**(`meta.from` 里不含"观澜自算"),本器**不覆盖**(除非显式 `--force`)。
+
+用法:
+    python scripts/rudong_fusion_handoff.py                 # 有正本则跳过; 没有则生成
+    python scripts/rudong_fusion_handoff.py --status        # 只报现状
+    python scripts/rudong_fusion_handoff.py --force         # 覆盖(自算件之间可覆盖)
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import sys
+import time
+
+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
+
+HANDOFF = None          # 运行时按场站解析 (P.m5())
+VERSION = 'guanlan-selfcomputed-v1'
+# 状态等级 → verdict 文本(词表必须与 fusion.VERDICT_CLASSES 对得上, 否则整列落成 unknown):
+#   危险 → '定论' (bad) · 报警 → '准定论·预警' (warn) · 良好/优秀 → '无新证' (ok) · 不可判 → '测量链不可判' (meta)
+VERDICT_OF = {'危险': '定论', '报警': '准定论·预警', '良好': '无新证(本轮未见异常)',
+              '优秀': '无新证(本轮未见异常)', '不可判': '测量链不可判'}
+COMP_OF = {'发电机': '发电机', '齿轮箱': '齿轮箱', '主轴承前': '主轴承', '主轴承后': '主轴承'}
+
+
+def status(farm: str | None = None) -> int:
+    p = P.m5(farm) / 'handoff_vibration_v2.json'
+    if not p.is_file():
+        print(json.dumps(dict(path=P.rel(p), exists=False, kind=None,
+                              note='不在位 —— /detail/v2 的「需要关注/全场状态」会如实为空; '
+                                   '跑本器可从观澜自算产物生成'), ensure_ascii=False))
+        return 0
+    try:
+        h = json.loads(p.read_text(encoding='utf-8'))
+    except Exception as e:
+        print(json.dumps(dict(path=P.rel(p), exists=True, kind='unreadable', err=str(e)[:120])))
+        return 1
+    meta = h.get('meta') or {}
+    kind = 'self' if '观澜自算' in str(meta.get('from', '')) else 'primary'
+    n = sum(len(v or []) for v in (h.get('per_turbine') or {}).values())
+    print(json.dumps(dict(path=P.rel(p), exists=True, kind=kind, version=meta.get('version'),
+                          date=meta.get('date'), from_=meta.get('from'), per_turbine=n,
+                          note='正本(振动线交证件)' if kind == 'primary' else '观澜自算件(同结构)'),
+                     ensure_ascii=False))
+    return 0
+
+
+def _report_levels(farm: str | None) -> tuple:
+    """逐台逐部件的状态等级/证据状态 + 报告日期 + 数据窗日期 —— 直接取重算出的 CMS 报告(不重复判级)。"""
+    import re
+    cms = P.cms(farm)
+    reps = sorted(cms.glob('报告_CMS振动状态评估报告_*.md'))
+    if not reps:
+        return {}, None, None
+    rp = reps[-1]
+    md = rp.read_text(encoding='utf-8')
+    date = rp.stem.rsplit('_', 1)[-1]
+    # 表头: | 机组号 | 主轴承前 | 主轴承后 | 齿轮箱 | 发电机 | 综合 | 融合级(模型) | CMS 红/黄 | 行动 |
+    out = {}
+    if '## 附录 A' in md:
+        for l in md.split('## 附录 A')[1].splitlines():
+            if not l.startswith('| WTG'):
+                continue
+            c = [x.strip() for x in l.strip('|').split('|')]
+            if len(c) < 6:
+                continue
+            out[c[0]] = dict(主轴承前=c[1], 主轴承后=c[2], 齿轮箱=c[3], 发电机=c[4], 综合=c[5])
+    win = None
+    m = re.search(r'数据窗\s+(\S+)\s+(\d{4}-\d{2}-\d{2})\s*→\s*(\d{4}-\d{2}-\d{2})', md)
+    if m:
+        win = (m.group(2), m.group(3))
+    return out, date, win
+
+
+def build(farm: str | None = None, force: bool = False) -> int:
+    m5 = P.m5(farm)
+    dest = m5 / 'handoff_vibration_v2.json'
+    if dest.is_file() and not force:
+        try:
+            cur = json.loads(dest.read_text(encoding='utf-8'))
+            if '观澜自算' not in str((cur.get('meta') or {}).get('from', '')):
+                print(f'[跳过] {P.rel(dest)} 是**振动线正本**(非观澜自算) —— 不覆盖。'
+                      f'要强制覆盖用 --force(不建议:正本含人工裁决)')
+                return 0
+        except Exception:
+            pass
+    levels, rep_date, win = _report_levels(farm)
+    if not levels:
+        print('[X] 没有重算出的 CMS 报告(windcms/报告_CMS振动状态评估报告_*.md)—— '
+              '先跑 scripts/windcms.py report(或在重算链 ④b 里跑)')
+        return 2
+    f38 = m5 / 'fusion_38.csv'
+    fus = pd.read_csv(f38, dtype=str) if f38.is_file() else pd.DataFrame()
+    ts = pd.Timestamp.now()
+    w_lo, w_hi = (win or ('', ''))
+    if not w_lo:
+        from src.windcms import config as _wc, data as _wd
+        sp = _wd.window_spans(_wc.farm(str(farm or 'rudong')))
+        rows = [v for v in sp.values() if v.get('rows')]
+        if rows:
+            w_lo, w_hi = str(rows[0]['t_min'])[:10], str(rows[0]['t_max'])[:10]
+    window_txt = f'{w_lo}~{w_hi}' if w_lo else '未标'
+
+    per = {'v1_generator': [], 'v1_gearbox': [], 'new_2026_08_26': []}
+    by_comp = {'发电机': 'v1_generator', '齿轮箱': 'v1_gearbox', '主轴承': 'new_2026_08_26'}
+    n_bad = n_warn = 0
+    for tid, lv in sorted(levels.items()):
+        fu = fus[fus['台'] == tid].iloc[0].to_dict() if len(fus) and (fus['台'] == tid).any() else {}
+        for comp_cn, state in (('主轴承前', lv.get('主轴承前')), ('主轴承后', lv.get('主轴承后')),
+                               ('齿轮箱', lv.get('齿轮箱')), ('发电机', lv.get('发电机'))):
+            comp = COMP_OF.get(comp_cn, comp_cn)
+            verdict = VERDICT_OF.get(str(state), '测量链不可判')
+            if not str(state) or str(state) == '优秀':
+                continue                    # 优秀不必逐条登记(矩阵按"未列≠正常"渲染, 但全列 38×4 太吵)
+            if verdict == '定论':
+                n_bad += 1
+            elif verdict.startswith('准定论'):
+                n_warn += 1
+            per[by_comp.get(comp, 'new_2026_08_26')].append(dict(
+                id=f'{tid}-{comp_cn}', turbine=tid, component=comp,
+                title=f'{comp_cn} 状态等级 {state}',
+                verdict=verdict,
+                claim_window=f'{window_txt}(观澜自算, 窗内实算区间)',
+                source='观澜自算: m5_cms_tcm/model_run_l6.parquet + fusion_38.csv + windcms 报告',
+                fusion_grade=fu.get('融合'), cms=f"{fu.get('CMS', '')}", report_date=rep_date))
+    from src.windcms.data import span_text as _span                    # 单一实现: 窗区间口径
+    from src.windcms import config as _wc2
+    try:
+        span = _span(_wc2.farm(str(farm or 'rudong')))
+    except Exception:
+        span = window_txt
+    doc = dict(
+        meta=dict(version=VERSION, date=ts.strftime('%Y-%m-%d'),
+                  from_='观澜自算 (scripts/rudong_fusion_handoff.py)',
+                  to='windscada 融合面 (/api/fleet 的 fus: 需要关注 / 全场状态 / 振动融合)',
+                  supersedes='(无)', principle='判级全部来自观澜自己的确定性判据, 不含人工裁决; 与振动线正本同结构',
+                  reading_discipline='列内状态=判级转录; 「未列」≠正常; 相对判据必配同域绝对量; '
+                                     '缺件时如实为空, 不从交付包补齐(用户令 2026-09-17)',
+                  derived_from=['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/fusion_38.csv',
+                                'windcms/报告_CMS振动状态评估报告_*.md', 'm5_cms_tcm/windows/*/index.parquet'],
+                  data_window=span, n_verdicts=sum(len(v) for v in per.values()),
+                  n_bad=n_bad, n_warn=n_warn),
+        detectability_prior=dict(
+            purpose='振动面能力边界与盲区(观澜自算口径, 与振动线正本的可能不同)',
+            classes=dict(
+                spectral_line=dict(note='特征频率族: 六道前提闸(G1–G8) + 同域绝对锚 + 证据族; '
+                                        '不过闸的 x_fleet/选择性/趋势一律不进下游'),
+                broadband_level=dict(note='宽带层: CMS rms_200/Indicator; 未越限不能作正常依据'),
+                impact_time_domain=dict(note='冲击轴(时域六窗): 与谱线层互补, 盲区不同'),
+                iso_velocity=dict(note='ISO 速度当量: 唯一已校准的绝对锚(黄 4.5 / 红 11.0 mm/s)'),
+                data_quality=dict(note='L0 数据质量: 不过门则该测点任何结论不可采信')),
+            meas_crossing=dict(note='同一物理量的不同测量(原始谱/包络谱)不可跨域比绝对量'),
+            **{'known_blind_spots': [
+                '包络域无 ISO 绝对锚 ⇒ 该域结论封顶「候选」',
+                '主轴承前后 8–13 kHz 存在同路电气调幅成分, 该带不用于机械源定位',
+                '无正样本锚(实物/内窥)时不得出「定论」; 本件为**观澜自算**, 不具备跨线裁决权',
+                '单窗数据时"逐窗趋势"信息量有限(按日聚合后可见)']},
+            端到端闭环=dict(note='判级→行动→复测闭环在综合报告线, 本面只出证据与判级')),
+        per_turbine=per,
+        field_actions_v1=[],
+        calibration_updates_v1=[],
+        open_items=['★本件是**观澜自算**的融合面(非振动线 handoff 正本): 由 scripts/rudong_fusion_handoff.py '
+                    '从重算产物生成; 振动线正本到场时应以正本为准(把正本放回该路径即可, 本器不再覆盖正本)。',
+                    '峰值拾取仍为本器口径(目标频率 ±2 bin 取最大), 见 docs/振动六层链_接口规格与缺口_v0.1.md §3.1',
+                    f'数据窗 {span}'] if True else [])
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    dest.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding='utf-8')
+    print(f'已写 {P.rel(dest)}: 逐台条目 {doc["meta"]["n_verdicts"]} 条 '
+          f'(危险/报警 {n_bad} · 预警 {n_warn}) · 数据窗 {span}')
+    try:
+        from src import derived_manifest as DM
+        DM.record(P.out_root(farm), {dest.relative_to(P.out_root(farm)).as_posix():
+                  'scripts/rudong_fusion_handoff.py (观澜自算融合面: L6 过闸线+融合裁决+报告状态等级; 同结构于振动线手交件)'},
+                  by='rudong_fusion_handoff')
+        print('   已自登记 → _derived_manifest.json')
+    except Exception as e:
+        print(f'   [i] 自登记跳过: {type(e).__name__}: {e}')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='融合面 handoff 的观澜自算生成端(同结构于振动线手交件)')
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--status', action='store_true', help='只报现状(正本/自算件/不在位)')
+    ap.add_argument('--force', action='store_true', help='覆盖已有自算件(正本仍不覆盖)')
+    a = ap.parse_args()
+    if a.status:
+        return status(a.farm)
+    return build(a.farm, a.force)
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 4 - 0
scripts/vib_raw_build.py

@@ -270,6 +270,10 @@ def main() -> int:
         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)
+        # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码,确保别的电脑装完系统运行正常」:
+        #   融合面 handoff 此前是**振动线人工交证件**、全库 0 处写入方 ⇒ 新机器上 `/detail/v2` 的
+        #   「需要关注/全场状态」永远空着。现在由观澜自算生成同结构件(脚本在包内; 盘上若有正本则不覆盖)。
+        run('fusion_handoff', [PY, str(ROOT / 'scripts/rudong_fusion_handoff.py')], log)
         run('kb', [PY, str(ROOT / 'scripts/windcms.py'), 'kb', '--farm', a.farm], log)
         # 六层链 fusion 步里**唯一已被标准答案验证过**的那一件 (fleet_scalar_z): 窗索引 → 同工况族中位
         # → 稳健 z。上面那次 fusion 已经一并出它 (同一条命令: --window final.name)。

+ 101 - 0
scripts/windscada_overview_build.py

@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""`windscada/index.html`(总览「全场状态」页)的**观澜自算**生成端(用户令 2026-09-19)。
+
+## 为什么需要它
+
+`/detail/v2` 的「全场状态总览」是一个 `<iframe src="/static/index.html">`,而该路径直接落到
+`outputs/<场>/windscada/index.html`。这件此前是**随包件**(全库 0 处写入方)⇒ 清过产物、只从
+`data/raw` 重算的机器上它必然不在位,页面里那块就显示"产物不在位"的说明页 —— 用户报的
+"某一块没有计算输出"就有它。
+
+用户令「所有的计算均要形成观澜的源代码,确保别的电脑装完系统运行正常」⇒ 用**包内既有**的
+快照烘焙器把这一页由观澜自己算出来(同一套取数函数 + 同一套前端,不是另写一份页面):
+
+    src/windscada/ui/snapshot.py::bake()   ← 组件服务 `/v2/snapshot` 用的就是它
+    ctx 里的取数函数 = scripts/windscada_serve.py 的 fleet_view/curve_view/vibcms_results/…
+
+产出仍是一张**单文件**页面(接口响应烤进页面 + 运行时拦截 fetch 优先快照兜底),所以离线也能开。
+
+用法:
+    python scripts/windscada_overview_build.py            # 生成并自登记
+    python scripts/windscada_overview_build.py --status    # 只报现状
+    python scripts/windscada_overview_build.py --out <路径>
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(ROOT / 'scripts'))
+from src import paths as P                                              # noqa: E402
+
+
+def status(farm: str | None = None) -> int:
+    p = P.store(farm) / 'index.html'
+    if not p.is_file():
+        print(json.dumps(dict(path=P.rel(p), exists=False,
+                              note='不在位 —— /detail/v2 的「全场状态总览」iframe 会显示"产物不在位"; '
+                                   '跑本器可由观澜自算生成'), ensure_ascii=False))
+        return 0
+    st = p.stat()
+    print(json.dumps(dict(path=P.rel(p), exists=True, bytes=st.st_size,
+                          mtime=time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(st.st_mtime))),
+                     ensure_ascii=False))
+    return 0
+
+
+def build(farm: str | None = None, out: str | None = None) -> int:
+    import windscada_serve as S                    # 组件模块: 取数函数与在线页**同一实现**
+    from src.windscada.ui import snapshot as _snap
+    from src.ontology import mcp_server as _omc
+
+    def _codes():
+        db = json.loads(pathlib.Path(_omc.OBJ_PATH).read_text(encoding='utf-8'))
+        return sorted(k.split('/')[-1] for k in db if k.startswith('alarmcode/'))
+
+    ctx = dict(fleet_view=S.fleet_view, curve_view=S.curve_view, vibcms_results=S.vibcms_results,
+               survey=__import__('src.ontology.maintenance', fromlist=['survey']).survey,
+               ont=_omc, alarm_codes=_codes, turbine_problems=S.turbine_problems,
+               single_problem=S.single_problem, _redact=S._redact, _jclean=S._jclean,
+               _proj=S._proj, _REQ=S._REQ)
+    html, rep = _snap.bake('zh', ctx, noindex=False)
+    dest = pathlib.Path(out) if out else (P.store(farm) / 'index.html')
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    dest.write_text(html, encoding='utf-8')
+    print(f'已写 {P.rel(dest)}: {len(html):,} B · 烤入 {rep.get("keys", rep)}')
+    try:
+        from src import derived_manifest as DM
+        DM.record(P.out_root(farm), {dest.relative_to(P.out_root(farm)).as_posix():
+                  'scripts/windscada_overview_build.py (与组件 /v2/snapshot 同一烘焙器: '
+                  'fleet/curves/vibcms/survey/ontology 响应烤进单文件页)'},
+                  by='windscada_overview_build')
+        print('   已自登记 → _derived_manifest.json')
+    except Exception as e:
+        print(f'   [i] 自登记跳过: {type(e).__name__}: {e}')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='总览「全场状态」页 (windscada/index.html) 的观澜自算生成端')
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--out', default=None)
+    ap.add_argument('--status', action='store_true')
+    a = ap.parse_args()
+    if a.status:
+        return status(a.farm)
+    return build(a.farm, a.out)
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 12 - 10
scripts/windscada_serve.py

@@ -484,8 +484,11 @@ def _gap_page(path: str, missing: 'pathlib.Path | None' = None, what: str = '这
 
     原来 `/static/…`、`/turbines/…` 取不到文件就回一个裸 `404` 文本 —— 浏览器里表现为**一块空白**,
     用户看到的现象是"这个板块没有信息", 而不是"缺件"。最典型的就是总览页那个
-    `<iframe src="/static/index.html">`(全场状态总览页): 这件产物属"包内没有生成端"的随包件,
-    清过产物之后重算也生不出来 ⇒ iframe 里是空白, 页面上却找不出任何原因。
+    `<iframe src="/static/index.html">`(全场状态总览页)。
+
+    ★2026-09-19 更新 (用户令「所有的计算均要形成观澜的源代码」): 该页**已有生成端** ——
+    `scripts/windscada_overview_build.py`(用组件同一个快照烘焙器从重算产物生成), 重算链第 ⑦b 步会跑它,
+    所以现在"不在位"只意味着**还没重算**, 不再是"包内没有生成端"。本页说明文字已据此改写。
 
     现在改成结构化说明: 缺哪一件、它归哪条产物线、**怎么补**、去哪儿看全局状态。
     口径与 CMS 的"无产物"页一致 (docs §11.4: 无产物时必须还能打开页面, 且要写明缺件与补齐办法)。
@@ -508,16 +511,15 @@ def _gap_page(path: str, missing: 'pathlib.Path | None' = None, what: str = '这
             + (f'<p>缺的是 <code>{_h.escape(rel)}</code>(属 <code>outputs/&lt;场站&gt;/</code> 下的产物)。</p>'
                if rel else '')
             + '<p>两种情形分开补:</p><ul>'
-            '<li><b>有生成端的产物</b>(alarms / workorders / temp_monthly / 振动窗索引谱 …): 放好 '
+            '<li><b>有生成端的产物</b>(alarms / workorders / temp_monthly / 振动窗索引谱 / '
+            '<code>windscada/index.html</code> …): 放好 '
             '<code>data/raw/&lt;场站&gt;/</code> 后跑 <code>python scripts/rebuild_all.py</code>, '
             '或点运维控制台的「数据重算」。</li>'
-            '<li><b>包内没有生成端的随包件</b>(如本页 <code>windscada/index.html</code>、'
-            '<code>m5_cms_tcm/{handoff_vibration_v2,component_history,baseline_38}.json</code>、'
-            '<code>tcm_index.parquet</code>、<code>windcms/</code> 页面件、<code>sop/</code>、'
-            '<code>paradigm_r1/</code>): 这些件<b>无法由重算生出来</b>,而按用户令 2026-09-17 '
-            '**运行期也不从交付包补齐** —— 处置是"由研发补生成端"(逐族可逆性与可逆性判定见 '
-            '<code>docs/系统设计说明.md</code> §13.6),或由人离线补救。'
-            '在此之前,页面会如实显示缺件而不是装作有数。</li></ul>'
+            '<li><b>仍缺生成端的那几族</b>(如 <code>m5_cms_tcm/component_history.json</code>、'
+            '<code>baseline_38.json</code>、<code>tcm_index.parquet</code>、<code>sop/</code>、'
+            '<code>paradigm_r1/</code>): 按用户令 2026-09-19「所有的计算均要形成观澜的源代码」正在逐族补'
+            '(清单见 <code>docs/源代码化清单_v0.1.md</code>); 在此之前页面如实显示缺件, '
+            '且按用户令 2026-09-17 <b>运行期不从交付包补齐</b>。</li></ul>'
             '<p class="s">逐件来源见 <code>outputs/&lt;场站&gt;/_provenance.json</code>;'
             '缺口清单见 <code>docs/系统设计说明.md</code> §7 与 docs/重算缺口与补件清单_v0.1.md。</p>'
             '<p><a href="/v2" target="_top">← 返回工作台</a> · '

+ 8 - 0
src/windscada/lang.py

@@ -199,6 +199,14 @@ _add({
 # ── 总览 (设计基准 docs/design/guanlan_v2.html) ───────────────────
 _add({
     'ov.attention':      ('需要关注', 'Needs attention'),
+    # 融合面缺件时的说明(2026-09-19 用户报「需要关注/全场状态下面没有计算输出」后补):
+    # 缺件就该说清"缺哪件、怎么补",不让两片空白被读成"没有需要关注的台"。
+    'ov.fus_gap_hint':   ('这一面(需要关注 / 全场状态)取数来自融合面;缺件时如实为空,不从交付包补齐。'
+                          '在本机放好原始件后跑一次重算即会由观澜自己的生成端补上'
+                          '(scripts/rudong_fusion_handoff.py,见 docs 的「源代码化清单」)。',
+                          'This block (needs attention / fleet status) draws on the fusion layer. '
+                          'When that product is missing it stays honestly empty rather than being filled from a delivery package; '
+                          'after placing the raw data, one recompute run rebuilds it from source (scripts/rudong_fusion_handoff.py).'),
     'ov.attention_n':    ('{n} 台', '{n} units'),
     'ov.ranked_by':      ('按 设备状态 × 证据量 排序', 'Ranked by equipment state × evidence weight'),
     'ov.strip':          ('全场状态', 'Fleet status'),

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

@@ -171,7 +171,11 @@
     // 需要关注: 链盘 rows (已按 bad>warn>note 排), 取 bad/warn; 依据取 watch.why
     const why = {}; (d.watch || []).forEach(w => { why[w.t] = w.why; });
     const rows = (((F.链盘 || {}).rows) || []).filter(r => r.lvl === 'bad' || r.lvl === 'warn');
-    h += `<section><div class="sechead"><h2>${t('ov.attention')}</h2><span class="meta">${t('ov.attention_n', { n: rows.length })}</span><span class="meta">${t('ov.ranked_by')}</span></div><div class="cards">`;
+    h += `<section><div class="sechead"><h2>${t('ov.attention')}</h2><span class="meta">${t('ov.attention_n', { n: rows.length })}</span><span class="meta">${t('ov.ranked_by')}</span></div>`;
+    // 融合面缺件时**如实说明**(用 /api/fleet 的 fus.gap 原文),否则读者看到两片空白,
+    // 分不清"没有需要关注的台"与"这一面的数据没到位"(2026-09-19 用户报"下面没有计算输出")。
+    if (F.gap) h += `<div class="card mb" style="margin-bottom:10px"><p class="small" style="color:var(--warning)">${dv(F.gap)}</p><p class="small muted">${t('ov.fus_gap_hint')}</p></div>`;
+    h += `<div class="cards">`;
     rows.forEach(r => {
       const st = ST[r.ostate] || (r.lvl === 'bad' ? 'bad' : 'warn');
       const stuckKey = STEP[r.stuck] || String(r.stuck || '').toLowerCase();
@@ -192,7 +196,8 @@
     h += `<section><div class="sechead"><h2>${t('ov.strip')}</h2><span class="meta">${t('ov.strip_hint')}</span>
       <div class="legend">${['bad', 'warn', 'unk', 'ok', 'ex'].map(k => `<span><i style="background:var(--${{ bad: 'critical', warn: 'warning', unk: 'unknown', ok: 'good', ex: 'excellent' }[k]})"></i>${t(stKey(k))}</span>`).join('')}</div></div>
       <div class="strip" style="--n:${mat.length || 38}"><div class="cells">`
-      + mat.map(r => { const k = unitState(r); return `<div class="cell ${k} ${hot.has(r.turbine) ? 'hot' : ''}" data-u="${r.turbine}" title="${unitNo(r.turbine)} ${t(stKey(k))}"></div>`; }).join('')
+      + (mat.length ? mat.map(r => { const k = unitState(r); return `<div class="cell ${k} ${hot.has(r.turbine) ? 'hot' : ''}" data-u="${r.turbine}" title="${unitNo(r.turbine)} ${t(stKey(k))}"></div>`; }).join('')
+                       : `<div class="quiet" style="padding:10px">${esc(F.gap || t('ov.fus_gap_hint'))}</div>`)
       + `</div><div class="nums">` + mat.map(r => `<span class="${hot.has(r.turbine) ? 'hot' : ''}">${unitNo(r.turbine).replace('#', '')}</span>`).join('') + `</div>`
       + `<div class="quiet">${rows.length ? t('ov.quiet', { n: Math.max(0, (mat.length || 38) - rows.length) }) : t('ov.quiet_all', { n: mat.length || 38 })}</div></div></section>`;