Ver Fonte

变桨面 + 振动在升/三层基线 落成生成端 (用户令: 所有计算均要形成源代码)

新增三个生成端 (都进重算链、都自登记、都不静默造数):
- scripts/pitch_face_build.py (链 ④c, 约 12 分钟): 10min 开关量 timeon/压力锯齿/运行状态 +
  1min 三叶桨距角/压力/有功 -> pitch/pitch_daily.parquet (20,796 行/38 台, 14 列契约字段)
  + pitch_zero_monthly.parquet (零位三口径) + pitch_face_manifest.json (口径/阈值/证据)。
  零位三口径: 停机顺桨段(机械止挡位, 实测把 19# 读成 +0.11°) / 满发段(混风况, 参考) /
  运行段同工况分档(行业口径的零位: 把现场专项确诊的 19# 单独拎出 -0.46°, 其余台 ≈0) => 判据轴。
- scripts/component_history_build.py (链 ④d) 窗索引 -> m5_cms_tcm/component_history.json
  (114 部件行; 本机只 1 个导出色窗 => 在升/换件闭环如实为空 + 数据边界写进 meta)
- scripts/baseline_38_build.py (链 ④e) 窗索引 -> m5_cms_tcm/baseline_38.json
  (自基线 1042 条出数 / 22 条如实 insufficient; fleet 基线 28 键; ISO 20816-3 绝对限)

接线与守卫:
- rebuild_all.py 新增 ④c/④d/④e (容忍 rc=4 = 缺原始件时如实报缺, 不假装算过)
- products_reverse_audit.py 族表: pitch_shipped(shipped) -> pitch_face(raw-derived), 新增族
  vib_history_baseline (必须排在通配族 vib_handoff_and_scans 之前, 否则又被认领成无生成端)
- 产物自登记按正本口径 (P.out_root(farm), 键相对产物仓根): 原先写成 P.store()/P.rel() 会另建
  windscada/_derived_manifest.json, 审计当场报未归类 1 件 rc=5 -- 已修并复核 rc=0
- src/windscada/subsys/pitch.py: D3 零位按停机段与运行段取更不利 + 依据行三口径都写;
  over/under_rate 分母改运行分钟 (计数来自 1min); i18n_en 补 满发段/顺桨段/并列参考
- 文档: 源代码化清单(①②③ 完成)/数据目录结构/振动数据接入/输入数据放置/重算操作手册/
  系统设计说明 §13.6 与 §7 逐处从无生成端改成生成端+口径+数据边界; 版本记录随 HISTORY 重生成

复核: 反向呼应审计 1800 件全部 raw-derived、未归类 0 (rc=0); pages_audit / chain_gap_check /
version_log / detail_deps 全 rc=0; 变桨面 38 行判级已落页 (优秀 21/报警 10/良好 7); /api/vibcms 200;
CMS 报告与逐台页三层基线卡出数 (baseline_card_html WTG01 9630 B / 28 行)。
zhouyang.xie há 3 semanas atrás
pai
commit
c909b63656

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

@@ -6,7 +6,7 @@
 <!-- DETAIL-DEPS:BEGIN -->
 <!-- DETAIL-DEPS:BEGIN -->
 # /detail 页面依赖与重算台账 (自动生成, 禁手改)
 # /detail 页面依赖与重算台账 (自动生成, 禁手改)
 
 
-> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 1,780 件
+> 生成端: `python scripts/detail_deps.py --write`|场站 `rudong`|产物仓 `outputs/rudong`|盘上 1,802 件
 >
 >
 > 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, 数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。
 > 用户令 (2026-09-18): **/detail 各页面不许用旧版产出补**, 只能参照旧版产物的呈现样式与内容, 数值必须由 `data/raw` 重算。本表把每页的取数接口与产物件逐条列清, 缺的写明怎么补。
 
 
@@ -50,10 +50,10 @@
 |  | `windscada/powercurve_dev.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 |  | `windscada/powercurve_dev.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 | /api/fleet | `windscada/*.parquet` ○可选 | 17 | 2.0 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms·scada_derived·scada_oil·scada_monthly·scada_workorders) | 在位 |
 | /api/fleet | `windscada/*.parquet` ○可选 | 17 | 2.0 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms·scada_derived·scada_oil·scada_monthly·scada_workorders) | 在位 |
 |  | `ontology/turbine_params.parquet` ○可选 | 1 | 0.1 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 |  | `ontology/turbine_params.parquet` ○可选 | 1 | 0.1 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
-|  | `m5_cms_tcm/handoff_vibration_v2.json` ○可选 | 0 | 0.0 MB | — | — (vib_handoff_and_scans) | 缺(页面降级) |
+|  | `m5_cms_tcm/handoff_vibration_v2.json` ○可选 | 1 | 0.1 MB | raw-derived | scripts/rudong_fusion_handoff.py (vib_fusion_handoff) | 在位 |
 |  | `m5_cms_tcm/fleet_scalar_z.parquet` ○可选 | 1 | 0.7 MB | raw-derived | scripts/rudong_fusion_run.py (vib_fleet_scalar_z) | 在位 |
 |  | `m5_cms_tcm/fleet_scalar_z.parquet` ○可选 | 1 | 0.7 MB | raw-derived | scripts/rudong_fusion_run.py (vib_fleet_scalar_z) | 在位 |
-|  | `pitch/pitch_daily.parquet` ○可选 | 0 | 0.0 MB | — | — (pitch_shipped) | 缺(页面降级) |
-|  | `pitch/pitch_zero_monthly.parquet` ○可选 | 0 | 0.0 MB | — | — (pitch_shipped) | 缺(页面降级) |
+|  | `pitch/pitch_daily.parquet` ○可选 | 1 | 0.3 MB | raw-derived | scripts/pitch_face_build.py (pitch_face) | 在位 |
+|  | `pitch/pitch_zero_monthly.parquet` ○可选 | 1 | 0.0 MB | raw-derived | scripts/pitch_face_build.py (pitch_face) | 在位 |
 | /api/curves | `windscada/powercurve_bins.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 | /api/curves | `windscada/powercurve_bins.parquet` ✅必需 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 | /api/curves | `windscada/curve_lenses.parquet` ○可选 | 1 | 0.1 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 | /api/curves | `windscada/curve_lenses.parquet` ○可选 | 1 | 0.1 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 |  | `windscada/curve_liveness.parquet` ○可选 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
 |  | `windscada/curve_liveness.parquet` ○可选 | 1 | 0.0 MB | raw-derived | src/windscada/perf/powercurve.py (scada_derived) | 在位 |
@@ -65,8 +65,8 @@
 | /api/turbine | `windscada/alarms.parquet` ✅必需 | 1 | 0.9 MB | raw-derived | scripts/windscada_alarms_ingest.py (scada_alarms) | 在位 |
 | /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) | 在位 |
 | /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) | 在位 |
 |  | `windscada/workorders.parquet` ○可选 | 1 | 0.5 MB | raw-derived | scripts/windscada_workorder_ingest.py (scada_workorders) | 在位 |
-| /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) | 缺(页面降级) |
+| /api/vibcms | `windcms/报告_CMS振动状态评估报告_*.md` ✅必需 | 1 | 0.0 MB | raw-derived | src/windcms/report_std.py (windcms_report_std) | 在位 |
+| /api/vibcms | `m5_cms_tcm/handoff_vibration_v2.json` ○可选 | 1 | 0.1 MB | raw-derived | scripts/rudong_fusion_handoff.py (vib_fusion_handoff) | 在位 |
 |  | `windcms/cache/*` ○可选 | 1 | 2.2 MB | raw-derived | src/windcms/data.py (windcms_cache) | 在位 |
 |  | `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) | 在位 |
 | /api/maint_survey | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 | /api/maint_std | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 | /api/maint_std | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
@@ -74,23 +74,14 @@
 | /api/dq_findings | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 | /api/dq_findings | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 | /api/ont_chain | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 | /api/ont_chain | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 | /api/ontology | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
 | /api/ontology | `ontology/objects.json` ✅必需 | 1 | 4.3 MB | raw-derived | src/ontology/kb_ingest.py (ontology_core) | 在位 |
-| /api/facts | `guanlan/facts_contract_v0.json` ✅必需 | 0 | 0.0 MB | — | — (guanlan_contract) | **缺** |
-|  | `guanlan/derived/detail_cards.json` ✅必需 | 0 | 0.0 MB | — | — (guanlan_contract) | **缺** |
-| /api/facts | `guanlan/derived/qa_refs.json` ○可选 | 0 | 0.0 MB | — | — (guanlan_contract) | 缺(页面降级) |
-|  | `guanlan/derived/portal_claims.json` ○可选 | 0 | 0.0 MB | — | — (guanlan_contract) | 缺(页面降级) |
+| /api/facts | `guanlan/facts_contract_v0.json` ✅必需 | 1 | 0.0 MB | raw-derived | scripts/guanlan_facts_contract.py (guanlan_contract) | 在位 |
+|  | `guanlan/derived/detail_cards.json` ✅必需 | 1 | 0.0 MB | raw-derived | scripts/guanlan_facts_contract.py (guanlan_contract) | 在位 |
+| /api/facts | `guanlan/derived/qa_refs.json` ○可选 | 1 | 0.0 MB | raw-derived | scripts/guanlan_facts_contract.py (guanlan_contract) | 在位 |
+|  | `guanlan/derived/portal_claims.json` ○可选 | 1 | 0.0 MB | raw-derived | scripts/guanlan_facts_contract.py (guanlan_contract) | 在位 |
 
 
 ## 3 缺口与处置 (缺的件谁生成、能不能从 data/raw 重算)
 ## 3 缺口与处置 (缺的件谁生成、能不能从 data/raw 重算)
 
 
-| 接口 | 缺件 | 必需 | 生成端 | 能否由 data/raw 重算 |
-|---|---|---|---|---|
-| `/api/fleet` | `m5_cms_tcm/handoff_vibration_v2.json` | 否 | (无生成端) | **不能**: 振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ 运行期不从交付包补(用户令 2026-09-17 ⇒ 需研发补生成端 |
-| `/api/fleet` | `pitch/pitch_daily.parquet` | 否 | (无生成端) | **不能**: 无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), 页面降级; 要出件须研发补生成端 ⇒ 需研发补生成端 |
-| `/api/fleet` | `pitch/pitch_zero_monthly.parquet` | 否 | (无生成端) | **不能**: 无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), 页面降级; 要出件须研发补生成端 ⇒ 需研发补生成端 |
-| `/api/vibcms` | `m5_cms_tcm/handoff_vibration_v2.json` | 否 | (无生成端) | **不能**: 振动六层链四步脚本未随包(oem_frequency_scan 等)⇒ 运行期不从交付包补(用户令 2026-09-17 ⇒ 需研发补生成端 |
-| `/api/facts` | `guanlan/facts_contract_v0.json` | 是 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
-| `/api/facts` | `guanlan/derived/detail_cards.json` | 是 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
-| `/api/facts` | `guanlan/derived/qa_refs.json` | 否 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
-| `/api/facts` | `guanlan/derived/portal_claims.json` | 否 | (无生成端) | **不能**: 派生链的中间层: 它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳; 运行期不从交付包补( ⇒ 需研发补生成端 |
+无缺口: 表内每件产物都在位且来源已登记。
 
 
 ## 4 与输入数据的对应 (重算口径)
 ## 4 与输入数据的对应 (重算口径)
 
 
@@ -100,11 +91,11 @@
 | scada_alarms | 故障报警 | `scripts/windscada_alarms_ingest.py` |
 | scada_alarms | 故障报警 | `scripts/windscada_alarms_ingest.py` |
 | scada_derived | scada_10min | `src/windscada/perf/powercurve.py` |
 | scada_derived | scada_10min | `src/windscada/perf/powercurve.py` |
 | ontology_core | 西门子4.0技术资料 | `src/ontology/kb_ingest.py` |
 | ontology_core | 西门子4.0技术资料 | `src/ontology/kb_ingest.py` |
-| vib_handoff_and_scans | — | `—` |
+| vib_fusion_handoff | m5_cms_tcm | `scripts/rudong_fusion_handoff.py` |
 | vib_fleet_scalar_z | m5_cms_tcm | `scripts/rudong_fusion_run.py` |
 | vib_fleet_scalar_z | m5_cms_tcm | `scripts/rudong_fusion_run.py` |
-| pitch_shipped | — | `—` |
+| pitch_face | scada_10min | `scripts/pitch_face_build.py` |
 | scada_workorders | 风机故障记录 | `scripts/windscada_workorder_ingest.py` |
 | scada_workorders | 风机故障记录 | `scripts/windscada_workorder_ingest.py` |
-| guanlan_contract | — | `—` |
+| guanlan_contract | scada_10min | `scripts/guanlan_facts_contract.py` |
 
 
 ## 5 复现
 ## 5 复现
 
 
@@ -114,34 +105,3 @@ python scripts/products_restore_missing.py --refresh   # 重算后维护逐件
 python scripts/rebuild_all.py               # 全量重算 (链上含 ⑤a 台账维护 → ⑤ 反向呼应审计)
 python scripts/rebuild_all.py               # 全量重算 (链上含 ⑤a 台账维护 → ⑤ 反向呼应审计)
 ```
 ```
 <!-- DETAIL-DEPS:END -->
 <!-- DETAIL-DEPS:END -->
-
-<!-- DETAIL-PROBE:BEGIN -->
-## P 实测 (本次运行, 走网关 28084 → 组件 /v2)
-
-| 页签 | 接口 | 判定 | 实测 |
-|---|---|---|---|
-| 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 · 54,509 B |
-|  | `/api/vibcms` | 有数 | HTTP 200 · 6,752 B |
-| generation | `/api/curves` | 有数 | HTTP 200 · 23,913 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 · 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 · 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 · 54,509 B |
-|  | `/api/maint_survey` | 有数 | HTTP 200 · 7,498 B |
-
-判读口径: 只认响应**顶层**的 `err`/`error`/`no_products`/`no_report`; `/api/ask*` `/api/rpt_compose` 依赖本机模型, 未启动时记「不适用」不算缺件。
-<!-- DETAIL-PROBE:END -->

+ 5 - 2
docs/振动数据接入_v0.1.md

@@ -230,8 +230,11 @@ manifest 里的源件路径已改为相对安装根)。
   连带的后果是 **CMS 报告/页面不能重生成**(§3b 实测 −97%)。`vib_raw_manifest.json` 的
   连带的后果是 **CMS 报告/页面不能重生成**(§3b 实测 −97%)。`vib_raw_manifest.json` 的
   `missing_chain` 字段如实列着这四项;缺口台账里对应 **B5**。
   `missing_chain` 字段如实列着这四项;缺口台账里对应 **B5**。
 - **扫描件 PDF 的数值**取不出(无文本层, 本包不带 OCR)。
 - **扫描件 PDF 的数值**取不出(无文本层, 本包不带 OCR)。
-- `handoff_vibration_v2.json` / `component_history.json` 目前是**随包快照**:它们含大量人工裁决、
-  校准更新与开放项,不是能从测量数据直接算出来的东西 —— 现场给正本才改由现场件驱动。
+- `handoff_vibration_v2.json` / `component_history.json` 曾是**随包快照**(含人工裁决、校准更新与开放项)。
+  **2026-09-19 已补生成端**(用户令「所有的计算均要形成观澜的源代码」): `scripts/rudong_fusion_handoff.py`
+  (融合面,报告状态等级 + fusion_38 + L6 过闸线 → 同结构件)与 `scripts/component_history_build.py`
+  (逐窗标量 x_self → 在升/换件闭环;本机只有 1 个导出色窗 ⇒ 在升/闭环**如实为空**并把数据边界写进
+  `meta.★数据边界`,绝不用"看起来像"的常数填表)。现场给正本时**正本优先**(生成端不覆盖正本)。
 - 谱库的磁盘代价:整窗全转(`--meas ALL`)会显著吃盘(含 `Time_*` 波形, 单点 65,536/200,000);
 - 谱库的磁盘代价:整窗全转(`--meas ALL`)会显著吃盘(含 `Time_*` 波形, 单点 65,536/200,000);
   默认只转 `FFT_` 谱(本轮 42 万条谱 = 3.2 GB)。
   默认只转 `FFT_` 谱(本轮 42 万条谱 = 3.2 GB)。
 - 一个可选的后续:把纯 Python 的 `pypdf` 轮子并入 `wheels\`,让 `vib_reports_build.py` 能**离线**
 - 一个可选的后续:把纯 Python 的 `pypdf` 轮子并入 `wheels\`,让 `vib_reports_build.py` 能**离线**

+ 1 - 1
docs/数据目录结构与落位约定_v0.2.md

@@ -283,7 +283,7 @@ Move-Item <暂存目录> .\data\raw\如东
 | `outputs\<场>\sop\` | 210 件 15.8 MB | SOP 中间件/评审/台账 + 22 个**历史装配脚本** | `src\sop\farm_pipeline.py` 等 | ⛔(历史脚本含 mac 死路径,仅留档) | 判据链、事实契约的输入 |
 | `outputs\<场>\sop\` | 210 件 15.8 MB | SOP 中间件/评审/台账 + 22 个**历史装配脚本** | `src\sop\farm_pipeline.py` 等 | ⛔(历史脚本含 mac 死路径,仅留档) | 判据链、事实契约的输入 |
 | `outputs\<场>\paradigm_r1\` | 29 件 0.5 MB | 范式实验底稿(E3/E5/E8…) | 实验脚本 | ⛔ | 事实契约的 `source_refs` |
 | `outputs\<场>\paradigm_r1\` | 29 件 0.5 MB | 范式实验底稿(E3/E5/E8…) | 实验脚本 | ⛔ | 事实契约的 `source_refs` |
 | `outputs\<场>\guanlan\` | 5 件 0.5 MB | 事实契约 `facts_contract_v0.json` + `derived\` + `cloud\`(可上云面孔) | `scripts\guanlan_facts_contract.py` 等 | ⛔ | 问答引文、门户契约段 |
 | `outputs\<场>\guanlan\` | 5 件 0.5 MB | 事实契约 `facts_contract_v0.json` + `derived\` + `cloud\`(可上云面孔) | `scripts\guanlan_facts_contract.py` 等 | ⛔ | 问答引文、门户契约段 |
-| `outputs\<场>\pitch\` | 2 件 0.3 MB | 变桨零位月表 | 变桨线 | ⛔ | 变桨面 |
+| `outputs\<场>\pitch\` | 3 件 0.4 MB | 变桨面日粒度(液压/润滑/柱塞/运行段)+ 零位三口径月表 + 口径说明 | `scripts\pitch_face_build.py`(重算链 ④c;读 `data\raw\<场>\scada_10min` + `scada_1min`) | ✅(2026-09-19 起有生成端) | 变桨面判级(`src\windscada\subsys\pitch.py`) |
 | `release\` | — | 门户 `portal.html`(20.2 MB **装配产物**)、三维 `viewer\`、`sim_sys_server.py`、交付件 `如东\`、受管外壳 `portal_src\` | `scripts\portal_build.py`(拆 / 装) | ⛔ | 网关首页、三维页、交付 |
 | `release\` | — | 门户 `portal.html`(20.2 MB **装配产物**)、三维 `viewer\`、`sim_sys_server.py`、交付件 `如东\`、受管外壳 `portal_src\` | `scripts\portal_build.py`(拆 / 装) | ⛔ | 网关首页、三维页、交付 |
 | `logs\` `run\` | — | 运行日志、`pids.json` | 启动器/服务 | — | 排障 |
 | `logs\` `run\` | — | 运行日志、`pids.json` | 启动器/服务 | — | 排障 |
 
 

+ 50 - 20
docs/源代码化清单_v0.1.md

@@ -18,6 +18,7 @@
 | `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 行 ✔ |
 | `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`) | `/detail/v2` 的「全场状态总览」`<iframe src="/static/index.html">` | 3.78 MB 真页面(此前是 2 KB 的"产物不在位"说明页)✔ |
 | `windscada/index.html`(总览「全场状态」页) | **`scripts/windscada_overview_build.py`**(用组件同一个快照烘焙器 `src/windscada/ui/snapshot.py::bake`) | `/detail/v2` 的「全场状态总览」`<iframe src="/static/index.html">` | 3.78 MB 真页面(此前是 2 KB 的"产物不在位"说明页)✔ |
 | **事实契约面**:`sop/findings.json` + `paradigm_r1` 三份底稿 + `guanlan/**` | **`scripts/sop_findings_from_ledger.py`**(重算台账 → claim,按契约构建器要的字段结构写)+ **`scripts/guanlan_facts_contract.py build`**(原有生成端,输入换成自算件) | 门户「本场结论·契约生成」段、`/detail` 问答/报告面(`/api/facts`)、报告摘要 | `/api/facts` **200**(cards 6)· 门户结论段 2.6 KB 换成 `RD-LEDGER-*`(6 条: 4 候选 / 2 参考)· 契约自检 PASS ✔ |
 | **事实契约面**:`sop/findings.json` + `paradigm_r1` 三份底稿 + `guanlan/**` | **`scripts/sop_findings_from_ledger.py`**(重算台账 → claim,按契约构建器要的字段结构写)+ **`scripts/guanlan_facts_contract.py build`**(原有生成端,输入换成自算件) | 门户「本场结论·契约生成」段、`/detail` 问答/报告面(`/api/facts`)、报告摘要 | `/api/facts` **200**(cards 6)· 门户结论段 2.6 KB 换成 `RD-LEDGER-*`(6 条: 4 候选 / 2 参考)· 契约自检 PASS ✔ |
+| **变桨面**:`pitch/pitch_daily.parquet` + `pitch/pitch_zero_monthly.parquet` | **`scripts/pitch_face_build.py`**(重算链 **④c**:`scada_10min` 的开关量 timeon/压力锯齿/运行状态 + `scada_1min` 的三叶桨距角/压力/有功 → 日粒度表与零位三口径月表;口径/阈值/证据落在 `pitch/pitch_face_manifest.json`) | `/api/fleet` 的**变桨面**四维度判级(逐台页/总览的"变桨系统"格)+ 依据行 | 日粒度 **20,796 行/38 台** `2025-01-01 → 2026-07-07`(与旧随包件行数一致);零位 `zero_dev` 停机顺桨段 + `zero_dev_full` 满发段 + `zero_dev_run` 运行段分档(**判据轴**,能把 19# 拎出来);非零位列与旧件相对一致率 **99.9%** ✔ |
 
 
 链上:④b(`model_run → fusion → report → fusion_handoff → kb`)→ **⑤b 由重算台账生成 claim** → **⑤c 构建+渲染契约** → ⑤a 台账维护 → ⑤ 审计 → … → ⑦b 总览页自算烘焙 → ⑧b 重装门户。
 链上:④b(`model_run → fusion → report → fusion_handoff → kb`)→ **⑤b 由重算台账生成 claim** → **⑤c 构建+渲染契约** → ⑤a 台账维护 → ⑤ 审计 → … → ⑦b 总览页自算烘焙 → ⑧b 重装门户。
 
 
@@ -32,7 +33,7 @@
 
 
 | # | 族 | 件 | 喂谁(页面/接口) | 现在没源代码的后果 | 需要的生成端(输入 → 产物) | 可行性 |
 | # | 族 | 件 | 喂谁(页面/接口) | 现在没源代码的后果 | 需要的生成端(输入 → 产物) | 可行性 |
 |---|---|---|---|---|---|---|
 |---|---|---|---|---|---|---|
-| 1 | `pitch_shipped` | `pitch/pitch_daily.parquet`、`pitch_zero_monthly.parquet` | `/api/fleet` 的**变桨面**判级(逐台页/总览的"变桨系统"格) | 变桨面显示"缺件 ⇒ 不可判" | `scada_10min`(+`scada_1min`) 的桨距角/桨叶油压通道 → 日粒度零位/漂移表 | **中**:通道在数据里,需定"零位口径"(停机段中位?满发段中位?) |
+| ~~1~~ | ~~`pitch_shipped`~~ | ~~`pitch/pitch_daily.parquet`、`pitch_zero_monthly.parquet`~~ | — | — | **已解决(见 §0 / §2)**:`scripts/pitch_face_build.py`,族表已从 `pitch_shipped`(shipped) 改成 `pitch_face`(raw-derived) | ✅ 完成 |
 | 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` 读法已可参照) |
 | 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 列之一),口径需反推 + 抽台对拍 |
 | 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` 之后跑一步) | **易**:纯搬运/投影,无判据 |
 | 4 | `ontology_releases` | `ontology/release_r1/*`、`release_r2/*` | 网关 `/release/*`(只读发布层)、门户"发布层"入口 | `/release/` 两件 503(如实报缺) | 由 `ontology/objects.json` + 层清单配置生成 r1/r2 层(`kb_ingest`/`populate` 之后跑一步) | **易**:纯搬运/投影,无判据 |
@@ -44,46 +45,75 @@
 
 
 ---
 ---
 
 
-## 2. 变桨面(下一件,口径已定:**停机段中位 + 满发段中位两者分列出**)
+## 2. 变桨面(**已完成**;口径 = 用户令选定的**停机段中位 + 满发段中位两者分列出**)
+
+生成端:**`scripts/pitch_face_build.py`**(重算链 ④c,约 10 分钟:逐台读 10min + 1min 全量 ≈ 28 GB 中的
+两路所需列)。产物自登记进 `_derived_manifest.json`;族表 `pitch_face`(kind=raw-derived)。
 
 
 消费者字段契约(`src/windscada/subsys/pitch.py::load/registry`,改动需同步):
 消费者字段契约(`src/windscada/subsys/pitch.py::load/registry`,改动需同步):
 
 
 ```
 ```
 pitch/pitch_daily.parquet        date, turbine, n, hydlevel_timeon, hydfilt_timeon, hyd_band,
 pitch/pitch_daily.parquet        date, turbine, n, hydlevel_timeon, hydfilt_timeon, hyd_band,
                                  hyd_over_relief, hyd_under_pump, hublub_timeon, pitchpum_timeon,
                                  hyd_over_relief, hyd_under_pump, hublub_timeon, pitchpum_timeon,
-                                 pistA, pistB, pistC, n_op
-pitch/pitch_zero_monthly.parquet month, turbine, zero_dev            ← 消费端读这一列做并入判据
-                                 + zero_dev_stop, zero_dev_full      ← 本次要求的两口径**分列**
+                                 pistA, pistB, pistC, n_op, hyd_mean
+pitch/pitch_zero_monthly.parquet month, turbine, zero_dev            ← 消费端读这一列 (=停机顺桨段口径)
+                                 + zero_dev_stop / zero_dev_full      ← 用户令要求的两口径**分列**
+                                   (停机顺桨段 / 满发段)
+                                 + zero_dev_run, run_bins             ← ★第三口径: 运行段**同工况分档**
+                                   (判据轴; 反推旧件后补, 见下)
+                                 + stop_med, full_med, n_stop, n_stop_all, n_full,
+                                   spread_stop, spread_full, base_stop, base_full   ← 原始量与基准(可核对)
+pitch/pitch_face_manifest.json   口径 / 阈值 / 证据 / 跳过与未出值清单
 ```
 ```
 
 
-通道勘察(`data/raw/如东/scada_10min` 表头实测;`prs_wtc_*` 的压力列还需挑出"蓄能器压力"那一条):
+通道勘察(`data/raw/如东/scada_10min` 表头实测,38 件表头一致、597 列):
 
 
 ```
 ```
 din_wtc_HydLevel_timeon / din_wtc_HydrFilt_timeon      油位/滤网开关 on 秒 (=当日应测 n×600 − timeon → 报警秒)
 din_wtc_HydLevel_timeon / din_wtc_HydrFilt_timeon      油位/滤网开关 on 秒 (=当日应测 n×600 − timeon → 报警秒)
-din_wtc_LubPistA/B/C_counts|timeon                     三叶柱塞 (pistA/B/C)
-dot_wtc_HubLubPu_timeon                                轮毂润滑泵 (hublub_timeon)
-dot_wtc_PitchPum_timeon                                变桨泵 (pitchpum_timeon)
-prs_wtc_* / int_wtc_ValSuppV_*                         压力族 → 挑蓄能器压力做 hyd_band/越线/跌泵
+din_wtc_LubPistA/B/C_counts                             三叶柱塞动作次数 (pistA/B/C)
+dot_wtc_HubLubPu_timeon / dot_wtc_PitchPum_timeon       轮毂润滑泵 / 变桨泵 (段内秒数求和)
+prs_wtc_HydPress_min|max|mean                           液压系统压力 (蓄能器锯齿): 段内 (max−min) 中位 = 52bar
+flg_wtc_ScInOper_endvalue                               运行段判据 (n_op)
 ```
 ```
 
 
-零位两口径的取数(**1min** 数据,现已可由 `scada_source` 从 MDB 读):
-`pitch_angle_blade_1/2/3`;
-· **停机段中位** = 该月"停机"状态下三叶桨距角中位 − 月内全场/该台基准;
-· **满发段中位** = 满发(高功率档)状态下三叶桨距角中位 − 同一基准。
-两个口径都按月×台输出;`zero_dev` 取**停机段**(零位=静止参考位),另一口径并列可见(页面依据行两个数都写)。
+零位两口径的取数(**1min** 数据,经 `scada_source`:CSV 优先 / MDB 回落):
+`pitch_angle_blade_1/2/3` + `active_power` + `rotor_speed`(+ `液压站预充压力` 供越线计数);
+· **停机段中位** = 该月「顺桨停机」分钟(转速 < 0.5 rpm ∧ |有功| < 50 kW ∧ 三叶桨距角中位 ∈ 顺桨带
+  [85,92]°)三叶桨距角中位 − 同月**全场中位基准**;
+· **满发段中位** = 满发(有功 ≥ 95% 额定 = 3800 kW)分钟的三叶桨距角中位 − 同一基准。
+两个口径按月×台分列输出;`zero_dev` 取**停机段**,另一口径并列可见(依据行都写)。
+
+★**第三口径(反推旧件后补,是真正的判据轴)**:`zero_dev_run` = 运行段(转速 ≥ 0.5 rpm ∧ 有功
+100~3800 kW)按有功 500 kW 分档,逐档与同月全场同档中位比,再取该台跨档中位。为什么必须有它 ——
+拿**旧交付包里的随包件**与现场专项对拍(只作结构/量级对照,数值不搬):
+① 旧件 `zero_dev` 只有 **19# 是离群**(全期 −0.896°,其余 37 台中位 ≈0)⇒ 其口径是"**绝对**零位偏差",
+   与本器"相对同月全场中位"不同源,故零位一族不做逐值对拍;
+② 本器**停机顺桨段**口径把 19# 读成 **+0.11°**(顺桨止挡是机械硬基准,fleet 集中在 88.59~88.69°±0.7°,
+   差异被止挡抹平 ⇒ 量不出运行区零位差);**满发段** 19# = +0.61° 且台间离散大(混风况);
+③ **运行段分档**口径把 19# 单独拎出来(逐月持续为负、其余台 ≈0)—— 与现场「19# 零位 −0.87° 专项确诊」
+   定性一致。
+⇒ 结论:`pitch.py` D3 判据按"停机段与运行段**取更不利**"(§3.2 同一纪律),依据行三个数都写出来。
+
+★实测逮到的坑(写进生成端注释与 manifest):停机分钟的桨距角**是双峰** —— WTG01 2025-01 停机分钟的
+5/25/50/75/95 分位 = 28.7 / 31.2 / **31.79** / 88.59 / 88.63°(一半顺桨、一半停在 ~31.8° 未顺桨位)。
+不按带滤的话,中位会在两支间跳(2025-01 得 31.79°、2025-04 得 88.66°),基准就没了物理含义。
+
+★非零位列的对拍(同一次生成端复检里做的逐值比对):`pitchpum/hydlevel/hydfilt_timeon` 与 `pistA/B/C`
+**相对一致率 99.9%**、`hyd_band` 95.4%、`hyd_mean` 同量级(212.6 vs 212.6 bar)⇒ 10min 侧口径复现无误;
+`n_op` 旧件口径未知(WTG01 2025-01-01 旧 48 / 本器 61 运行段),只作量级对拍。
 
 
 ## 3. 需要你定的(其余口径)
 ## 3. 需要你定的(其余口径)
 
 
-(第 5 行的事实契约口径已按你的选择落地;第 1 行的变桨零位口径也已定为"两口径分列"。)
+(第 5 行的事实契约口径已按你的选择落地;第 1 行的变桨零位口径也已按你的选择"两口径分列"落地。)
 目前**没有**待你裁的口径项了;剩余各族按 §4 顺序推进即可。
 目前**没有**待你裁的口径项了;剩余各族按 §4 顺序推进即可。
 
 
 ## 4. 执行顺序(修订)
 ## 4. 执行顺序(修订)
 
 
 ```
 ```
-① ontology_releases 生成端(易,点亮 /release 两件)
-② component_history / baseline_38 生成端(中)
-③ tcm_replay 掩码阈值反解(中偏低)
-④ pitch 面生成端(中,口径已定;消费者字段契约见 §2)
+① ontology_releases 生成端(易,点亮 /release 两件)              ✅ 完成 (scripts/ontology_release_build.py)
+② tcm_replay 掩码阈值反解(中偏低)                              ✅ 完成 (scripts/tcm_mask_thresholds_build.py)
+③ pitch 面生成端(中,口径已定;消费者字段契约见 §2)             ✅ 完成 (scripts/pitch_face_build.py)
+④ component_history / baseline_38 生成端(中)                   ← 下一件
 ⑤ vib_figs / 静态逐台副本(低优先)
 ⑤ vib_figs / 静态逐台副本(低优先)
 ```
 ```
 
 

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 1
docs/版本记录.md


+ 35 - 45
docs/系统设计说明.md

@@ -35,21 +35,21 @@
 > `outputs/<场>/_provenance.json`(逐件来源台账)+ `outputs/<场>/_derived_manifest.json`(构建脚本自登记)。
 > `outputs/<场>/_provenance.json`(逐件来源台账)+ `outputs/<场>/_derived_manifest.json`(构建脚本自登记)。
 
 
 <!-- INVENTORY:BEGIN (由 scripts/inventory_products.py --write-doc 生成, 勿手改) -->
 <!-- INVENTORY:BEGIN (由 scripts/inventory_products.py --write-doc 生成, 勿手改) -->
-*自动生成于 2026-09-16 16:26;数据源: `outputs/rudong/_provenance.json` + `_derived_manifest.json` + 实际文件*
+*自动生成于 2026-09-19 16:07;数据源: `outputs/rudong/_provenance.json` + `_derived_manifest.json` + 实际文件*
 
 
 | 产物仓 | 输出路径 (相对安装根) | 功用 | 生成端 | 消费端 | 件数 | 大小 | 来源(raw 重算/随包) |
 | 产物仓 | 输出路径 (相对安装根) | 功用 | 生成端 | 消费端 | 件数 | 大小 | 来源(raw 重算/随包) |
 |---|---|---|---|---|---|---|---|
 |---|---|---|---|---|---|---|---|
-| `windscada` | `outputs/rudong/windscada/` | L0 标准仓: SCADA/台账/派生分析的全部 parquet (页面主取数处) | rebuild_from_raw.py (三门台账) + --scada (10 个构建器) + windscada_monthly_build.py | scripts/windscada_serve.py 各视图 · src/windscada/taxonomy · subsys/fusion | 115 | 4.5 MB | 17 / 98 |
-| `ontology` | `outputs/rudong/ontology/` | 本体对象库: 码表/手册/工单展开/失效树 + 检索索引 + 实机参数 | python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest → retrieval.build | 脚本 windscada_serve.py 本体页/问答 · scripts/guanlan_facts_contract.py | 25 | 82.9 MB | 3 / 22 |
-| `windcms` | `outputs/rudong/windcms/` | CMS 振动诊断产物: 状态评估报告/逐台页/工作台页/知识库 + 厂家报告转录 | scripts/windcms.py report/kb · scripts/vib_reports_build.py | 自服务 :18020 (每请求现读) · taxonomy.system_matrix (转录设备状态) | 56 | 30.1 MB | 2 / 54 |
-| `m5_cms_tcm` | `outputs/rudong/m5_cms_tcm/` | 振动线出件与窗级分析: handoff 接口 + 窗索引/谱库 + TCM 兼容件 | scripts/vib_raw_build.py (窗索引/谱) · 振动线出件 (handoff, 随包快照) | src/windscada/subsys/fusion.py · src/windcms/data.py · scripts/windcms.py report | 1808 | 3329.7 MB | 1718 / 90 |
-| `tcm_compatible_replay` | `outputs/rudong/tcm_compatible_replay/` | TCM 兼容链回放资产 (模型表/掩码阈值/裁决记录) | 随包快照 (无生成端) | src/windcms/report*.py · config.mask_thresholds | 59 | 32.7 MB | 0 / 59 |
-| `sop` | `outputs/rudong/sop/` | SOP 中间件/评审/台账与事实契约底稿 | 随包快照 (无生成端) | scripts/guanlan_facts_contract.py · 门户结论段 | 210 | 15.8 MB | 0 / 210 |
-| `guanlan` | `outputs/rudong/guanlan/` | 事实契约与对外派生 (可上云面孔) | scripts/guanlan_facts_contract.py | 门户 #findings · /api/facts | 5 | 0.5 MB | 0 / 5 |
-| `pitch` | `outputs/rudong/pitch/` | 变桨侧派生件 (零位/日粒度) | 随包快照 + rebuild_from_raw --scada | 脚本 windscada_serve.py 变桨面 | 2 | 0.3 MB | 0 / 2 |
-| `paradigm_r1` | `outputs/rudong/paradigm_r1/` | 范式实验件 (E3/E5/E8 底稿, 事实契约输入) | 随包快照 (无生成端) | scripts/guanlan_facts_contract.py | 29 | 0.5 MB | 0 / 29 |
-
-合计 2309 件 / 3496.9 MB;其中 raw 重算 1740 件、随包补齐 571 件。
+| `windscada` | `outputs/rudong/windscada/` | L0 标准仓: SCADA/台账/派生分析的全部 parquet (页面主取数处) | rebuild_from_raw.py (三门台账) + --scada (10 个构建器) + windscada_monthly_build.py | scripts/windscada_serve.py 各视图 · src/windscada/taxonomy · subsys/fusion | 18 | 6.9 MB | 18 / 0 |
+| `ontology` | `outputs/rudong/ontology/` | 本体对象库: 码表/手册/工单展开/失效树 + 检索索引 + 实机参数 | python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest → retrieval.build | 脚本 windscada_serve.py 本体页/问答 · scripts/guanlan_facts_contract.py | 8 | 14.9 MB | 8 / 0 |
+| `windcms` | `outputs/rudong/windcms/` | CMS 振动诊断产物: 状态评估报告/逐台页/工作台页/知识库 + 厂家报告转录 | scripts/windcms.py report/kb · scripts/vib_reports_build.py | 自服务 :18020 (每请求现读) · taxonomy.system_matrix (转录设备状态) | 47 | 15.2 MB | 47 / 0 |
+| `m5_cms_tcm` | `outputs/rudong/m5_cms_tcm/` | 振动线出件与窗级分析: handoff 接口 + 窗索引/谱库 + TCM 兼容件 | scripts/vib_raw_build.py (窗索引/谱) · 振动线出件 (handoff, 随包快照) | src/windscada/subsys/fusion.py · src/windcms/data.py · scripts/windcms.py report | 1713 | 3278.9 MB | 1713 / 0 |
+| `tcm_compatible_replay` | `outputs/rudong/tcm_compatible_replay/` | TCM 兼容链回放资产 (模型表/掩码阈值/裁决记录) | 随包快照 (无生成端) | src/windcms/report*.py · config.mask_thresholds | 2 | 0.6 MB | 2 / 0 |
+| `sop` | `outputs/rudong/sop/` | SOP 中间件/评审/台账与事实契约底稿 | 随包快照 (无生成端) | scripts/guanlan_facts_contract.py · 门户结论段 | 1 | 0.0 MB | 1 / 0 |
+| `guanlan` | `outputs/rudong/guanlan/` | 事实契约与对外派生 (可上云面孔) | scripts/guanlan_facts_contract.py | 门户 #findings · /api/facts | 5 | 0.0 MB | 5 / 0 |
+| `pitch` | `outputs/rudong/pitch/` | 变桨侧派生件 (日粒度液压/润滑/柱塞 + 零位三口径月表) | scripts/pitch_face_build.py (重算链 ④c; scada_10min + scada_1min) | 脚本 windscada_serve.py 变桨面 · src/windscada/subsys/pitch.py | 3 | 0.4 MB | 3 / 0 |
+| `paradigm_r1` | `outputs/rudong/paradigm_r1/` | 范式实验件 (E3/E5/E8 底稿, 事实契约输入) | 随包快照 (无生成端) | scripts/guanlan_facts_contract.py | 3 | 0.0 MB | 3 / 0 |
+
+合计 1800 件 / 3316.8 MB;其中 raw 重算 1800 件、随包补齐 0 件。
 <!-- INVENTORY:END -->
 <!-- INVENTORY:END -->
 
 
 ### 2.1 逐件来源口径(`_provenance.json`)
 ### 2.1 逐件来源口径(`_provenance.json`)
@@ -255,13 +255,13 @@
 |---|---|---|---|
 |---|---|---|---|
 | 振动六层链四步脚本未随包(`rudong_tcm_oem_scan` / `rudong_line_energy_share` / `rudong_model_run` / `rudong_fusion_run`) | `oem_frequency_scan`、`gear_freq_scan`、`blade_1p_*`、`model_run_l6.parquet`、`fusion_38.csv` 等 | `outputs/<场>/m5_cms_tcm/vib_raw_manifest.json` 的 `missing_chain` | 给脚本或口径;源件已在 `data/raw/<场>/windcms/` |
 | 振动六层链四步脚本未随包(`rudong_tcm_oem_scan` / `rudong_line_energy_share` / `rudong_model_run` / `rudong_fusion_run`) | `oem_frequency_scan`、`gear_freq_scan`、`blade_1p_*`、`model_run_l6.parquet`、`fusion_38.csv` 等 | `outputs/<场>/m5_cms_tcm/vib_raw_manifest.json` 的 `missing_chain` | 给脚本或口径;源件已在 `data/raw/<场>/windcms/` |
 | 厂商月度报告 12 份 PDF 是**纯扫描件** | 「CMS 振动评估报告」的厂商侧 | pypdf 实测 12 页 12 图、`extract_text()` 长度 0 | 现场给电子件(docx/xlsx),或上 OCR(本包不装) |
 | 厂商月度报告 12 份 PDF 是**纯扫描件** | 「CMS 振动评估报告」的厂商侧 | pypdf 实测 12 页 12 图、`extract_text()` 长度 0 | 现场给电子件(docx/xlsx),或上 OCR(本包不装) |
-| `handoff_vibration_v2.json` / `component_history.json` 是随包快照 | 融合面判级、`/cms/` | 该件含人工裁决/校准更新,不是测量数据的函数 | 现场给正本,放 `data/raw/<场>/m5_cms_tcm/` |
+| `handoff_vibration_v2.json` / `component_history.json` 是随包快照 | 融合面判级、`/cms/` | 该件含人工裁决/校准更新,不是测量数据的函数 | 现场给正本,放 `data/raw/<场>/m5_cms_tcm/`。★**2026-09-19(用户令「所有的计算均要形成观澜的源代码」)已补生成端**:`component_history.json` ← `scripts/component_history_build.py`(重算链 ④d,逐窗标量 x_self → 在升/换件闭环;窗数不足时如实空表 + 写数据边界)、`handoff_vibration_v2.json` ← `scripts/rudong_fusion_handoff.py`;**现场正本仍在位时优先**(生成端不覆盖正本) |
 | 随包件"暂存区"**按设计不再存在**(2026-09-16 用户令"清除产物不留备份") | 第⑤步"补齐随包件"固定返回 6 跳过 | 旧设计把清掉的产物挪到 `_products_off/` 以便还原;现口径 = 真删除 | 要补回"包内没有生成端"的随包件: `python scripts/products_restore_missing.py --stash <交付包.zip>`(从交付件按需补齐, **不在安装目录里留备份**)。`products_state.py --off` 需显式 `--yes`;`--on` 已移除 |
 | 随包件"暂存区"**按设计不再存在**(2026-09-16 用户令"清除产物不留备份") | 第⑤步"补齐随包件"固定返回 6 跳过 | 旧设计把清掉的产物挪到 `_products_off/` 以便还原;现口径 = 真删除 | 要补回"包内没有生成端"的随包件: `python scripts/products_restore_missing.py --stash <交付包.zip>`(从交付件按需补齐, **不在安装目录里留备份**)。`products_state.py --off` 需显式 `--yes`;`--on` 已移除 |
 | 台账等价验收基线 `outputs/<场>/windscada/_pre_rebuild_20260911/` 曾缺失 | 第④步与 `rebuild_from_raw.py --verify` 的逐值比对(缺基线时 ④ 返回 5) | 该目录在打包时被"清除产物"挪进了暂存区, 而暂存区随后被清掉 ⇒ 机器上无标准答案 | **2026-09-16 已重建**: 从当天 dist 包里取 4 件(`alarms/workorders/oil_samples_index/temp_monthly`)+ `_来源说明.json` 标明来历。★链已加固: ④ 容忍 rc=5 —— **缺基线只跳过"等价验收", 不再打断整条重算链**。注意: 「清除产物」会把这个目录一并删掉(它在 `windscada/` 里), 届时需按同样办法重建 |
 | 台账等价验收基线 `outputs/<场>/windscada/_pre_rebuild_20260911/` 曾缺失 | 第④步与 `rebuild_from_raw.py --verify` 的逐值比对(缺基线时 ④ 返回 5) | 该目录在打包时被"清除产物"挪进了暂存区, 而暂存区随后被清掉 ⇒ 机器上无标准答案 | **2026-09-16 已重建**: 从当天 dist 包里取 4 件(`alarms/workorders/oil_samples_index/temp_monthly`)+ `_来源说明.json` 标明来历。★链已加固: ④ 容忍 rc=5 —— **缺基线只跳过"等价验收", 不再打断整条重算链**。注意: 「清除产物」会把这个目录一并删掉(它在 `windscada/` 里), 届时需按同样办法重建 |
 | 油样 2026-07 批 102 行(华标合并报告) | 数据层「油液化验」 | 源件 `BG-2026-07-YP013 ….pdf` 不在现场包 | 补那份 PDF 后 `rebuild_from_raw.py --verify` 无人工项 |
 | 油样 2026-07 批 102 行(华标合并报告) | 数据层「油液化验」 | 源件 `BG-2026-07-YP013 ….pdf` 不在现场包 | 补那份 PDF 后 `rebuild_from_raw.py --verify` 无人工项 |
-| 无生成端的组级产物(`pc_monthly_bins` / `duty_monthly` / `thermal_monthly` / `sector_power` / `yaw_*` / `genbearing_monthly` / `mblub_monthly` / `pitch_daily` 等) | 趋势件、热链、扇区、偏航/润滑面 | 全库只有读取方、0 处写入方 | 研发补口径(照 `temp_monthly` 的办法反推 + 逐值验证) |
+| 无生成端的组级产物(`pc_monthly_bins` / `duty_monthly` / `thermal_monthly` / `sector_power` / `yaw_*` / `genbearing_monthly` / `mblub_monthly` 等;~~`pitch_daily`~~ 已补) | 趋势件、热链、扇区、偏航/润滑面 | 全库只有读取方、0 处写入方 | 研发补口径(照 `temp_monthly` 的办法反推 + 逐值验证)。★**2026-09-19 进展**:`pitch/**` 已补生成端(`scripts/pitch_face_build.py`,重算链 ④c,零位三口径 + 日粒度液压/润滑/柱塞),其余各件仍在清单上(`docs/源代码化清单_v0.1.md`) |
 | `configs/farms/*.yaml` 无代码读者;`available()` 只认 `*.json` | 多场部署 | `src/windscada/config.py::available()` | 二选一(见 §3.3) |
 | `configs/farms/*.yaml` 无代码读者;`available()` 只认 `*.json` | 多场部署 | `src/windscada/config.py::available()` | 二选一(见 §3.3) |
-| **清过产物 + 包不随产物 ⇒ "无生成端"的那批产物不可恢复** | `pitch_daily`/`pc_monthly_bins`/`duty_monthly`/`thermal_monthly`/`sector_power`/`yaw_*`/`genbearing_monthly`/`mblub_monthly`/`structure.parquet`/`watch_channels_monthly`、振动 `handoff_vibration_v2.json`/`component_history.json`、CMS 页面件等 | 2026-09-17 实测:13:05 执行「清除产物」(真删、不留备份),随后重算产出 1,727 件(**有生成端**的那批,锚点 39,211/5,876/404/19,494 全部对齐、呼应校验正常),但这批无生成端的件**没有任何来源可补** —— 交付包按用户令不含产物,本机也没有旧包 | 需要这批件时**打一个带产物的包**(`scripts/pack_dist.py --with-products`)留档,再用 `products_restore_missing.py --stash <该包.zip>` 补回;或由研发补生成端(`docs/重算缺口与补件清单_v0.1.md`) |
+| **清过产物 + 包不随产物 ⇒ "无生成端"的那批产物不可恢复** | `pc_monthly_bins`/`duty_monthly`/`thermal_monthly`/`sector_power`/`yaw_*`/`genbearing_monthly`/`mblub_monthly`/`structure.parquet`/`watch_channels_monthly`、CMS 页面件等(~~`pitch_daily`~~、~~`handoff_vibration_v2.json`~~、~~`component_history.json`~~ 2026-09-19 起已有生成端) | 2026-09-17 实测:13:05 执行「清除产物」(真删、不留备份),随后重算产出 1,727 件(**有生成端**的那批,锚点 39,211/5,876/404/19,494 全部对齐、呼应校验正常),但这批无生成端的件**没有任何来源可补** —— 交付包按用户令不含产物,本机也没有旧包。★**2026-09-19 复核**:本包已无 shipped/human 件(反向呼应审计 1,800 件全部 raw-derived、未归类 0),上表"无生成端"的那几族也逐族在补(见 `docs/源代码化清单_v0.1.md`) | 需要这批件时**打一个带产物的包**(`scripts/pack_dist.py --with-products`)留档,再用 `products_restore_missing.py --stash <该包.zip>` 补回;或由研发补生成端(`docs/重算缺口与补件清单_v0.1.md`) |
 | 重算链在**缺这批件**时曾整步中断 | ⑦ 本体层 | 2026-09-17 实测:`pitch_daily` 缺 → `pitch.registry()` 抛 `FileNotFoundError`;`handoff` 缺 → `fusion.load_handoff()` 按设计抛错("不可静默降级")⇒ `populate` rc=1,⑧ 全不执行 | **已修**:`pitch.load/registry`、`taxonomy.system_matrix`(新增 `_cell()` 逐格兜底 + `_缺件说明` 写进结果)、`fusion.load_handoff/fusion_table` 改为**响亮降级**(空结构 + 缺件说明,判级不造数);实测缺这两件时 ⑦ 全部步骤跑完、`ontology.audit` **rc=0**(对象 9,613,含缺件说明) |
 | 重算链在**缺这批件**时曾整步中断 | ⑦ 本体层 | 2026-09-17 实测:`pitch_daily` 缺 → `pitch.registry()` 抛 `FileNotFoundError`;`handoff` 缺 → `fusion.load_handoff()` 按设计抛错("不可静默降级")⇒ `populate` rc=1,⑧ 全不执行 | **已修**:`pitch.load/registry`、`taxonomy.system_matrix`(新增 `_cell()` 逐格兜底 + `_缺件说明` 写进结果)、`fusion.load_handoff/fusion_table` 改为**响亮降级**(空结构 + 缺件说明,判级不造数);实测缺这两件时 ⑦ 全部步骤跑完、`ontology.audit` **rc=0**(对象 9,613,含缺件说明) |
 | 门户/交付件引用的产物树 **不在本包**:`cleaned/`、`gearbox_life/`、`yaw_verify/`、`structured/` | 传动链回放面板、四系统判据面板、控制律仪表台、如东取数单 | `scripts/pages_audit.py` 报 19 条「引用悬空(已知)」;本包在位的是 `outputs/<场>/{windscada,m5_cms_tcm,sop,ontology,paradigm_r1,pitch,guanlan,tcm_compatible_replay,windcms}` | 六层链/结构化产物随包,**或**把页面里的引用改到本包在位路径(需研发确认是同一件东西;不改交付件正文)。★**2026-09-17 用户裁决「确定无用的才删」→ 本项不删**:四棵目录逐个 `Test-Path` = False,即引用**确实**悬空,记账是实话;被引的页面又都是客户手里的冻结交付件/门户内嵌件(删了门户裂口、交付缺件),而登记表的 `known_gap` 一旦删掉,这些引用就变成"没人管的悬空引用"(`pages_audit` 反而会按 rc=5 报出来)。⇒ **保留记账,待该产物线随包后复检**;`configs/portal_pages.yaml` 的 5 条 `known_gap.why` 已逐条写上裁决语 |
 | 门户/交付件引用的产物树 **不在本包**:`cleaned/`、`gearbox_life/`、`yaw_verify/`、`structured/` | 传动链回放面板、四系统判据面板、控制律仪表台、如东取数单 | `scripts/pages_audit.py` 报 19 条「引用悬空(已知)」;本包在位的是 `outputs/<场>/{windscada,m5_cms_tcm,sop,ontology,paradigm_r1,pitch,guanlan,tcm_compatible_replay,windcms}` | 六层链/结构化产物随包,**或**把页面里的引用改到本包在位路径(需研发确认是同一件东西;不改交付件正文)。★**2026-09-17 用户裁决「确定无用的才删」→ 本项不删**:四棵目录逐个 `Test-Path` = False,即引用**确实**悬空,记账是实话;被引的页面又都是客户手里的冻结交付件/门户内嵌件(删了门户裂口、交付缺件),而登记表的 `known_gap` 一旦删掉,这些引用就变成"没人管的悬空引用"(`pages_audit` 反而会按 rc=5 报出来)。⇒ **保留记账,待该产物线随包后复检**;`configs/portal_pages.yaml` 的 5 条 `known_gap.why` 已逐条写上裁决语 |
 | 2 件数据派生页面**没有生成端**:`release/如东/全场状态一览_脱敏_20260825.html`(1.5 MB 场站状态快照)、`release/如东/如东取数单_2026-08-21.html` | 交付文档页里这两个子页 | 全仓搜不到生成端;页面里也没有 `source`/指纹 ⇒ 无法做陈旧检测(§10) | ★**2026-09-17 已裁决(用户令"确定无用的才删")**:两页都是**有真实内容的交付件**,不是无用件 ⇒ **不删**,改按**冻结交付件**管:`kind: frozen-delivery` + `version:`(与"`release/如东/**/*.html` 一律冻结交付"的统一口径对齐,消除"同一页既是 data-derived 又是 frozen-delivery"的自相矛盾),并把"性质是数据派生、无指纹无生成端、重算链不会刷新它、会随时间变旧"如实留在 `known_gap.why` 里。若将来要它们随数据变,再补生成端 + `source_sha256`(机制已就绪,见 §10 的 `data-derived` 规则) |
 | 2 件数据派生页面**没有生成端**:`release/如东/全场状态一览_脱敏_20260825.html`(1.5 MB 场站状态快照)、`release/如东/如东取数单_2026-08-21.html` | 交付文档页里这两个子页 | 全仓搜不到生成端;页面里也没有 `source`/指纹 ⇒ 无法做陈旧检测(§10) | ★**2026-09-17 已裁决(用户令"确定无用的才删")**:两页都是**有真实内容的交付件**,不是无用件 ⇒ **不删**,改按**冻结交付件**管:`kind: frozen-delivery` + `version:`(与"`release/如东/**/*.html` 一律冻结交付"的统一口径对齐,消除"同一页既是 data-derived 又是 frozen-delivery"的自相矛盾),并把"性质是数据派生、无指纹无生成端、重算链不会刷新它、会随时间变旧"如实留在 `known_gap.why` 里。若将来要它们随数据变,再补生成端 + `source_sha256`(机制已就绪,见 §10 的 `data-derived` 规则) |
@@ -1102,7 +1102,7 @@ scripts/version_log.py             ← 由 HISTORY 生成 docs/版本记录.md
 <!-- REVERSE-AUDIT:BEGIN -->
 <!-- REVERSE-AUDIT:BEGIN -->
 ### 13.6 反向呼应审计:自输出 → 功能与算法 → 输入(自动生成,勿手改)
 ### 13.6 反向呼应审计:自输出 → 功能与算法 → 输入(自动生成,勿手改)
 
 
-台账 2236 件产物逐件回溯:**呼应成立 1742 件** · **不成立(无生成端)285 件** · 无法验证 0 件 · 未归类 0 件。判据:① 生成端在位 ② 输入在位 ③ 跨度 ⊆ 输入 / 机组集 ⊆ 输入 / 行数 > 0 / 计数与清单一致。
+台账 1800 件产物逐件回溯:**呼应成立 1800 件** · **不成立(无生成端)0 件** · 无法验证 0 件 · 未归类 0 件。判据:① 生成端在位 ② 输入在位 ③ 跨度 ⊆ 输入 / 机组集 ⊆ 输入 / 行数 > 0 / 计数与清单一致。
 
 
 | 输出族(产物 glob) | 件数 | 反向判定 | 功能 | 算法 / 生成端 | 输入(data/raw/<场>/) | 判据 | 说明 |
 | 输出族(产物 glob) | 件数 | 反向判定 | 功能 | 算法 / 生成端 | 输入(data/raw/<场>/) | 判据 | 说明 |
 |---|---:|---|---|---|---|---|---|
 |---|---:|---|---|---|---|---|---|
@@ -1113,22 +1113,24 @@ scripts/version_log.py             ← 由 HISTORY 生成 docs/版本记录.md
 | `windscada/workorders.parquet` | 1 | ✓ 呼应成立 | 三门台账 · 工单 | scripts/windscada_workorder_ingest.py(检修台账 → 工单表) | 风机故障记录 | span+rows | 产物起点 2020-01-03 早于输入起点 2021-01(输入为年粒度, 只作参考: 台账可能含更早的历史行);跨度 2020-01-03~2026-07-08 ⊆ 输入 2021-01~2026-12;件内数据行合计 5,876 |
 | `windscada/workorders.parquet` | 1 | ✓ 呼应成立 | 三门台账 · 工单 | scripts/windscada_workorder_ingest.py(检修台账 → 工单表) | 风机故障记录 | span+rows | 产物起点 2020-01-03 早于输入起点 2021-01(输入为年粒度, 只作参考: 台账可能含更早的历史行);跨度 2020-01-03~2026-07-08 ⊆ 输入 2021-01~2026-12;件内数据行合计 5,876 |
 | `windscada/oil_samples_index.parquet` | 1 | ✓ 呼应成立 | 三门台账 · 油样 | scripts/windscada_watch_channels_build.py(油样 PDF → 索引) | 油样报告 | span+rows | 跨度 2024-11-19~2025-08-29 ⊆ 输入 2024-11-19~2025-08-29;件内数据行合计 404 |
 | `windscada/oil_samples_index.parquet` | 1 | ✓ 呼应成立 | 三门台账 · 油样 | scripts/windscada_watch_channels_build.py(油样 PDF → 索引) | 油样报告 | span+rows | 跨度 2024-11-19~2025-08-29 ⊆ 输入 2024-11-19~2025-08-29;件内数据行合计 404 |
 | `windscada/temp_monthly.parquet` | 1 | ✓ 呼应成立 | 月度派生件 | scripts/windscada_monthly_build.py(逐值对齐随包件 19,494/19,494) | scada_10min | span+rows | 跨度 2025-01-01~2026-07-01 ⊆ 输入 2025-01-01 00:00:00~2026-07-07 00:00:00;件内数据行合计 19,494 |
 | `windscada/temp_monthly.parquet` | 1 | ✓ 呼应成立 | 月度派生件 | scripts/windscada_monthly_build.py(逐值对齐随包件 19,494/19,494) | scada_10min | span+rows | 跨度 2025-01-01~2026-07-01 ⊆ 输入 2025-01-01 00:00:00~2026-07-07 00:00:00;件内数据行合计 19,494 |
-| `windscada/*.parquet` | 27 | ✓ 呼应成立 | SCADA 派生分析(功率曲线/损失/曲线/控制/停机/温度/偏航/液压/热链/系统辅助) | src/windscada/perf/{powercurve,availability,curves,control,faults} + subsys/{temp_nbm,yaw,hydraulic,thermal_chain} + taxonomy.py | scada_10min | rows | 件内数据行合计 70,283 |
+| `windscada/*.parquet` | 13 | ✓ 呼应成立 | SCADA 派生分析(功率曲线/损失/曲线/控制/停机/温度/偏航/液压/热链/系统辅助) | src/windscada/perf/{powercurve,availability,curves,control,faults} + subsys/{temp_nbm,yaw,hydraulic,thermal_chain} + taxonomy.py | scada_10min | rows | 件内数据行合计 40,095 |
 | `ontology/{objects.json,retrieval_index.json,turbine_params.parquet}` | 3 | ✓ 呼应成立 | 本体层 · 码表/对象库/检索索引/实机参数 | python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest;src.ontology.retrieval.build;src.ontology.maintenance.refresh_params | 西门子4.0技术资料 | rows | 件内数据行合计 1,708 |
 | `ontology/{objects.json,retrieval_index.json,turbine_params.parquet}` | 3 | ✓ 呼应成立 | 本体层 · 码表/对象库/检索索引/实机参数 | python -m src.ontology.kb_ingest → populate → chain_ingest → trend_ingest;src.ontology.retrieval.build;src.ontology.maintenance.refresh_params | 西门子4.0技术资料 | rows | 件内数据行合计 1,708 |
-| `['ontology/_id_alias.json', 'ontology/retrieval_vec.npy']` | 2 | ✓ 呼应成立 | 本体层 · 别名表与向量索引缓存 | src.ontology.kb_ingest(别名)/ src.ontology.retrieval.build(use_vec=True)(向量) | 西门子4.0技术资料 | rows | 件内数据行合计 2 |
-| `['ontology/oil_2026H2_huabiao.json', 'ontology/parts_cost_public.json', 'ontology/scenario_29_repair.json']` | 2 | ✗ 无生成端 + ◆ 人工件 1 | 本体层 · 领域数据件(油样台账/备件价格/场景修复) | (无生成端, 领域正本出件) | —(无生成端) | — | 领域正本随包发来, 不由 data/raw 推导 |
-| `m5_cms_tcm/*.{json,parquet,csv,md,txt}` | 36 | ✗ 无生成端 + ◆ 人工件 35 | 振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账) | (无生成端) | —(无生成端) | — | 振动六层链四步脚本未随包(oem_frequency_scan 等),现场正本才可重出;现只能从交付包补齐 |
-| `m5_cms_tcm/figs/*` | 15 | ✗ 无生成端 | 振动线出图(谱图/趋势图) | (无生成端) | —(无生成端) | — | 随包快照(振动线出件) |
-| `windscada/{index.html,turbines/*,review/*}` | 39 | ✗ 无生成端 + ◆ 人工件 8 | 总览「全场状态」静态页 + 逐台页 + 评审记录 | (无生成端) | —(无生成端) | — | 页面件由振动线/历史分析出件,本包无写入方 |
-| `windcms/**` | 45 | ✗ 无生成端 + ◆ 人工件 7 | CMS 振动评估报告页/逐台页/缓存 | scripts/windcms.py report(本包缺 model_run/fusion 产物, 重生成会掉内容 −97%, 故**按设计跳过**) | —(无生成端) | — | 生成端在包内但输入不足 ⇒ 现状按随包件冻结 |
-| `sop/**` | 92 | ✗ 无生成端 + ◆ 人工件 110 | SOP 中间件/评审/事实契约底稿 | (无生成端) | —(无生成端) | — | 评审过程件, 属人工作业留痕 |
-| `tcm_compatible_replay/**` | 43 | ✗ 无生成端 + ◆ 人工件 15 | TCM 兼容链回放资产(模型表/掩码阈值/裁决记录) | (无生成端) | —(无生成端) | — | 随包快照 |
-| `paradigm_r1/**` | 8 | ✗ 无生成端 + ◆ 人工件 21 | 范式实验件(E3/E5/E8 底稿) | (无生成端) | —(无生成端) | — | 实验底稿, 事实契约的输入 |
-| `pitch/**` | 2 | ✗ 无生成端 | 变桨侧派生件(零位/日粒度) | (无生成端;rebuild_from_raw --scada 只覆盖其中一部分) | —(无生成端) | — | 随包快照 |
-| `ontology/release_*/*` | 2 | ✗ 无生成端 + ◆ 人工件 3 | 本体发布层 r1/r2(只读暴露给网关 /release/) | (无生成端) | —(无生成端) | — | 发布层清单, 由研发出件 |
-| `guanlan/**` | 1 | ✗ 无生成端 + ◆ 人工件 4 | 事实契约与对外派生(门户结论段/取数) | scripts/guanlan_facts_contract.py(读者在包内, 输入是 ontology+m5+sop 三处**产物**) | —(无生成端) | — | 派生链的中间层:它的"输入"本身是产物而非 data/raw ⇒ 与 raw 之间隔着不止一跳 |
-| `windscada/release-manifest.json` | 1 | ✓ 呼应成立 | 发布清单快照(`/healthz` 与版本信息读它) | scripts/guanlan_baseline_manifest.py(从 src/windscada/ui 源重建 zh 页 → 摘要 {css_tokens, nav, dom, sha256, source blobs}) | —(无生成端) | — |  |
-| `['windscada/mblub_alarm_cross.json', 'windscada/review_presented.json', 'windscada/给振动线_温度轴回复_20260826.json', 'windscada/给振动线_温度轴回复_20260826.md', 'windscada/送审_系统结论与判定_20260828.md']` | 5 | ◆ 人工件 | **人工正本/交证件**:与振动线的跨线通知、送审结论、发布评审快照 | (人写的, 不是算出来的) | —(无生成端) | — | 这几件是"人对人的交证件/回执", 内容由分析结论抄写与人判断坐实而成; mblub_alarm_cross.json 还被 src/ontology/populate.py 当作**证据来源**引用 ⇒ 必须随包留在原位, 但**不参与呼应校验**(它本来就没有生成端, 也不该有) |
+| `['m5_cms_tcm/model_run_l6.parquet', 'm5_cms_tcm/model_run_summary.json']` | 2 | ✓ 呼应成立 | 六层链 model_run 步 · L6 过闸谱线表 + 层小结 | 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) | m5_cms_tcm | rows | 件内数据行合计 15 |
+| `m5_cms_tcm/fusion_38.csv` | 1 | ✓ 呼应成立 | 六层链 fusion 步 · 逐台融合表(报告"融合级"列的来源) | scripts/rudong_fusion_run.py::fusion_table(模型侧=model_run_l6 过闸线; CMS 侧=窗索引 RedMask/YellowMask; 裁决=src/sop/fusion_diag.py::fuse) | m5_cms_tcm | rows | 件内数据行合计 1 |
+| `m5_cms_tcm/line_energy_share.parquet` | 1 | ✓ 呼应成立 | 六层链 energy_share 步 · 线能量占比 | scripts/rudong_line_energy_share.py(±2bin 带内能量 ÷ 全谱能量 ×100; 候选线与分母同 oem_scan_plan) | m5_cms_tcm | rows | 件内数据行合计 1,026 |
+| `['tcm_compatible_replay/model/mask_thresholds.tsv', 'tcm_compatible_replay/model/mask_thresholds.说明.json']` | 2 | ✓ 呼应成立 | TCM 掩码阈值件 (逐台逐测点逐工况的黄/红线) | scripts/tcm_mask_thresholds_build.py(中位 + 3/6×MAD 自适应基线, 基线先净化, 样本门 n≥30; 调 src/sop/discriminators.py::adaptive_baseline_threshold 单一实现) | m5_cms_tcm | rows | 件内数据行合计 2 |
+| `ontology/release_*/*` | 5 | ✓ 呼应成立 | 本体发布层 r1/r2(网关 /release/ 只读暴露) | scripts/ontology_release_build.py(对象库投影: r1 契约层 = objects.json 快照 + manifest; r2 = 同快照 + layering.json 分层覆盖声明, 不合并主库) | 西门子4.0技术资料 | rows | 件内数据行合计 5 |
+| `m5_cms_tcm/fleet_scalar_z.parquet` | 1 | ✓ 呼应成立 | 振动线 · 全场标量 z 值 (fusion 步出件) | scripts/rudong_fusion_run.py(逆向工程实现: 窗索引 scalar_value → 同 (传感器,测量,工况) 族中位 → 稳健 z=(val−med)/(1.4826·MAD); 与随包样件 8,887 行逐值对拍 val 列 100% 一致) | m5_cms_tcm | rows | 件内数据行合计 32,580 |
+| `m5_cms_tcm/handoff_vibration_v2.json` | 1 | ✓ 呼应成立 | 融合面 handoff (观澜自算, 同结构于振动线手交件) | scripts/rudong_fusion_handoff.py(报告状态等级 + fusion_38 融合裁决 + L6 过闸线 → 同结构, meta.from 标明"观澜自算") | m5_cms_tcm | rows | 件内数据行合计 1 |
+| `['m5_cms_tcm/component_history.json', 'm5_cms_tcm/baseline_38.json']` | 2 | ✓ 呼应成立 | 振动在升/换件闭环历史 + 三层基线(自 / 机群 / 绝对) | scripts/component_history_build.py(逐窗标量 x_self → 末三窗/早三窗在升 + 换件前后闭环; 窗不足时判据不可算则如实空表)+ scripts/baseline_38_build.py(健康期分段中位 + 正常带 + relMAD → 自基线, 机群同段中位 → fleet 基线, ISO 20816-3 → 绝对限) | windcms | rows | 件内数据行合计 2 |
+| `windscada/index.html` | 1 | ✓ 呼应成立 | 总览「全场状态」页 (单文件: 接口响应烤进页面, 离线可开) | scripts/windscada_overview_build.py(ctx 取数函数与在线 /api/* 同一实现; 经 src/windscada/ui/snapshot.py::bake 烘焙) | scada_10min | rows | 件内数据行合计 1 |
+| `windcms/cache/*` | 1 | ✓ 呼应成立 | CMS 报告侧 · 标量缓存 | src/windcms/data.py::load_scalars(窗索引 → 标量数组, 按内容哈希命名落 cache/) | windcms | rows | 件内数据行合计 168,880 |
+| `['windcms/报告_CMS振动状态评估报告_*.md', 'windcms/报告_CMS振动状态评估报告_*.docx', 'windcms/登记_三轴_38台.csv']` | 3 | ✓ 呼应成立 | CMS 标准模版评估报告 (三轴: 状态等级 × 证据状态 × 行动等级建议) | src/windcms/report_std.py::build(结构化判级 → 报告 md + docx + 登记 csv; 由 scripts/windcms.py report 调用) | windcms | rows | 件内数据行合计 3 |
+| `['windcms/report.md', 'windcms/index.html', 'windcms/index_eng.html', 'windcms/overview.html', 'windcms/turbines/*']` | 42 | ✓ 呼应成立 | CMS 诊断总览页 + 逐台页 + 结构化层报告 | src/windcms/report.py::build(逐台 turbines/<台>.html + 总览 overview/index.html + report.md; 融合级列要 model_run/fusion 出件, 缺则如实写"—") | windcms | rows | 件内数据行合计 42 |
+| `windcms/kb.json` | 1 | ✓ 呼应成立 | CMS 知识库索引 (问答检索用) | src/windcms/knowledge.py::build(由 scripts/windcms.py kb 调用; 离线无模型时 0 chunks 也算跑成) | windcms | rows | 件内数据行合计 1 |
+| `['sop/findings.json', 'paradigm_r1/experiments/*/底稿*.json']` | 4 | ✓ 呼应成立 | 事实契约的 claim 本体与底稿 (观澜自算, 替代人裁底稿) | scripts/sop_findings_from_ledger.py(六族 claim: 报警集中/重复检修/停机损失/温度离群/功率曲线偏离/振动过闸线; 每条带 coverage·缺证据·证伪判据, 相对判据封顶「候选」) | scada_10min | rows | 件内数据行合计 4 |
+| `pitch/**` | 3 | ✓ 呼应成立 | 变桨侧派生件(日粒度液压/润滑/柱塞 + 零位三口径月表) | scripts/pitch_face_build.py(10min 开关量 timeon/压力锯齿 + 1min 停机顺桨段/满发段/运行段分档 三叶桨距角中位 → 相对同月全场中位的零位偏差;口径/阈值/证据见同目录 pitch_face_manifest.json) | scada_10min | rows | 件内数据行合计 21,472 |
+| `guanlan/**` | 5 | ✓ 呼应成立 | 事实契约与对外派生(门户结论段/取数) | scripts/guanlan_facts_contract.py(build→render: 契约 + portal_claims/detail_cards/qa_refs/report_summary; 输入=上游族 ledger_claims 自算的 sop/findings.json + 三份底稿) | scada_10min | rows | 件内数据行合计 5 |
 
 
 #### 反向「可逆性」:没有呼应关系的那批,能否从原始件推出来(用户令 2026-09-17 #2)
 #### 反向「可逆性」:没有呼应关系的那批,能否从原始件推出来(用户令 2026-09-17 #2)
 
 
@@ -1136,20 +1138,8 @@ scripts/version_log.py             ← 由 HISTORY 生成 docs/版本记录.md
 
 
 | 输出族 | 件数 | 可逆性 | 依据 |
 | 输出族 | 件数 | 可逆性 | 依据 |
 |---|---:|---|---|
 |---|---:|---|---|
-| `ontology_domain` | 2 | 半可逆(需人工裁定) | 抽样里 33% 的件含人工判断字段 ⇒ 机器部分可推、判断部分要人定;人工判断字段抽样命中 33%(如 ontology/parts_cost_public.json) |
-| `vib_handoff_and_scans` | 36 | 混合(测量件可反推 / 文本件需人定) | (23 件测量形态 / 13 件文本·判断件)测量形态的件可由 data/raw 反推口径 + 逐值对拍; 文本/判断件(评审、裁决、回复)推不出来;人工判断字段抽样命中 33%(如 m5_cms_tcm/OEM特征频率复跑_2026-08-17.md) |
-| `vib_figs` | 15 | 需人工裁定 | 既无生成端、形态也不是测量函数 ⇒ 需研发给口径或领域正本 |
-| `windscada_pages` | 39 | 不可逆(人工判断) | 抽样 6 件里都写着人工判断字段(如 windscada/review/_sealed_predictions_20260826.md) ⇒ 不是输入的纯函数, 逆向工程推不出来;人工判断字段抽样命中 85%(如 windscada/review/_sealed_predictions_20260826.md) |
-| `windcms_pages` | 45 | 可逆(页面可再生) | 这一族主要是**页面**(.html): 页面是产物的渲染, 不是独立数据 ⇒ 接上在包的页面生成端重出即可(未必逐字节同, 能力与数据一致);人工判断字段抽样命中 50%(如 windcms/kb.json) |
-| `sop_workspace` | 92 | 半可逆(需人工裁定) | 抽样里 37% 的件含人工判断字段 ⇒ 机器部分可推、判断部分要人定;人工判断字段抽样命中 37%(如 sop/_dual_review_20260726_payload脱敏.md) |
-| `tcm_replay` | 43 | 不可逆(人工判断) | 抽样 3 件里都写着人工判断字段(如 tcm_compatible_replay/claude_handoff_20260823/Codex回复_rms200_mask_20260823.md) ⇒ 不是输入的纯函数, 逆向工程推不出来;人工判断字段抽样命中 75%(如 tcm_compatible_replay/claude_handoff_20260823/Codex回复_rms200_mask_20260823.md) |
-| `paradigm_r1` | 8 | 不可逆(人工判断) | 抽样 5 件里都写着人工判断字段(如 paradigm_r1/experiments/E3_candidate_closure/plan.md) ⇒ 不是输入的纯函数, 逆向工程推不出来;人工判断字段抽样命中 100%(如 paradigm_r1/experiments/E3_candidate_closure/plan.md) |
-| `pitch_shipped` | 2 | 可逆(需反推口径) | 测量形态的件是测量数据的函数 ⇒ 可从 data/raw 反推口径 + 逐值对拍(做法同 temp_monthly: 反推 → 逐值一致才敢用) |
-| `ontology_releases` | 2 | 不可逆(人工判断) | 抽样 3 件里都写着人工判断字段(如 ontology/release_r1/manifest.json) ⇒ 不是输入的纯函数, 逆向工程推不出来;人工判断字段抽样命中 60%(如 ontology/release_r1/manifest.json) |
-| `guanlan_contract` | 1 | 不可逆(人工判断) | 抽样 4 件里都写着人工判断字段(如 guanlan/derived/detail_cards.json) ⇒ 不是输入的纯函数, 逆向工程推不出来;人工判断字段抽样命中 80%(如 guanlan/derived/detail_cards.json) |
-| `human_deliverables` | 5 | 人工件(正本/交证) | 人写的, 不参与呼应校验; 随包留在原位 |
-
-**按件数汇总**:已在重算链上 = 1742 件 · 半可逆(需人工裁定) = 94 件 · 不可逆(人工判断) = 93 件 · 可逆(页面可再生) = 45 件 · 混合(测量件可反推 / 文本件需人定) = 36 件 · 需人工裁定 = 15 件 · 人工件(正本/交证) = 5 件 · 可逆(需反推口径) = 2 件
+
+**按件数汇总**:已在重算链上 = 1800 件
 
 
 **人工件**:含人工判断字段的件已逐件登记在 outputs/&lt;场&gt;/_human_artifacts.json(用户令 2026-09-17 #3),它们**不参与**呼应校验(人写成/坐实的东西不是任何输入的纯函数);生成方式:python scripts/products_reverse_audit.py --write-human-manifest。
 **人工件**:含人工判断字段的件已逐件登记在 outputs/&lt;场&gt;/_human_artifacts.json(用户令 2026-09-17 #3),它们**不参与**呼应校验(人写成/坐实的东西不是任何输入的纯函数);生成方式:python scripts/products_reverse_audit.py --write-human-manifest。
 
 

+ 2 - 2
docs/输入数据放置指导_v0.1.md

@@ -41,7 +41,7 @@
 | `风机故障记录` | `.xls` `.xlsx` `.rar` `.jpg` `.png` | 年目录 + 月度表 | 后缀不在约定内 |
 | `风机故障记录` | `.xls` `.xlsx` `.rar` `.jpg` `.png` | 年目录 + 月度表 | 后缀不在约定内 |
 | `油样报告` | `.pdf` | **两级** `<台号>/<部件>/*.pdf` | PDF 直接躺在源类根下(维护页按台/部件分组,会分不出来) |
 | `油样报告` | `.pdf` | **两级** `<台号>/<部件>/*.pdf` | PDF 直接躺在源类根下(维护页按台/部件分组,会分不出来) |
 | `windcms` | `.json` `.pdf` `.docx` | 含 `*_decode.json`(可套 `<包名>/measurement/…` 层) | 没有 `*_decode.json` ⇒ 振动侧无源可算 |
 | `windcms` | `.json` `.pdf` `.docx` | 含 `*_decode.json`(可套 `<包名>/measurement/…` 层) | 没有 `*_decode.json` ⇒ 振动侧无源可算 |
-| `m5_cms_tcm` | `.json` `.docx` `.pdf` `.md` | handoff 正本(`handoff_vibration_v2.json` / `component_history.json`)+ `厂家报告/` | 缺正本 ⇒ 融合判级只能用随包快照(docs §7 已知缺口) |
+| `m5_cms_tcm` | `.json` `.docx` `.pdf` `.md` | handoff 正本(`handoff_vibration_v2.json` / `component_history.json`)+ `厂家报告/` | 缺正本时**由观澜自算件顶上**(`scripts/rudong_fusion_handoff.py` / `scripts/component_history_build.py`);现场有正本则以正本为准 |
 
 
 ## 3. 增量放置(重点)
 ## 3. 增量放置(重点)
 
 
@@ -104,7 +104,7 @@ python scripts\raw_data_check.py --deep
   - `风机故障记录/`:135 件 / 142.3 MB
   - `风机故障记录/`:135 件 / 142.3 MB
 
 
 · 结构与命名检查:**无不一致**(提示见下)
 · 结构与命名检查:**无不一致**(提示见下)
-· `i` data/raw/如东/m5_cms_tcm:缺 handoff 正本 (handoff_vibration_v2.json / component_history.json) —— 融合面判级与 /cms/ 只能用随包快照 (docs §7 的已知缺口)
+· `i` data/raw/如东/m5_cms_tcm:缺 handoff 正本 (handoff_vibration_v2.json / component_history.json) —— 融合面判级与 /cms/ 改用**观澜自算**件(`scripts/rudong_fusion_handoff.py` / `scripts/component_history_build.py`,链上 ④b/④d);现场给了正本则正本优先
 · `?` data/raw/如东:源件合计 177.6 GB —— 摄入与重算会很慢, 建议分批
 · `?` data/raw/如东:源件合计 177.6 GB —— 摄入与重算会很慢, 建议分批
 
 
 **放置后该做什么**:
 **放置后该做什么**:

+ 3 - 3
docs/重算操作手册_v0.1.md

@@ -369,10 +369,10 @@ Rename-Item outputs\rudong_off outputs\rudong      :: 还原
 | 现象 | 原因 | 怎么办 |
 | 现象 | 原因 | 怎么办 |
 |---|---|---|
 |---|---|---|
 | `/cms/` 503 | CMS 的源是**振动线 handoff(测点索引)**, 现场包里只有成品牌报告 PDF | 找振动线要测点导出件 |
 | `/cms/` 503 | CMS 的源是**振动线 handoff(测点索引)**, 现场包里只有成品牌报告 PDF | 找振动线要测点导出件 |
-| `/detail/api/query|turbine|fleet` 回 `err=no_products` | 它们依赖**没有生成端**的 `temp_monthly.parquet` 等 L1 底座 | 见 `docs\重算缺口与补件清单_v0.1.md` |
+| `/detail/api/query|turbine|fleet` 回 `err=no_products` | 它们依赖**没有生成端**的 L1 底座件(`temp_monthly.parquet` 那批多数已补,见清单) | 见 `docs\重算缺口与补件清单_v0.1.md` |
 | 油样只有 404 行(出厂 506) | 差的那 102 行来自一份**合并报告**, 该 PDF 不在现场包里 | 把该批报告放进 `油样报告\` 后重跑 ① |
 | 油样只有 404 行(出厂 506) | 差的那 102 行来自一份**合并报告**, 该 PDF 不在现场包里 | 把该批报告放进 `油样报告\` 后重跑 ① |
-| 门户没有"契约结论段"、`/api/facts` 503 | 契约要 `sop\findings.json` + `paradigm_r1` 底稿, 两者本身没有生成端 | 找研发要 |
-| `populate` / `audit` 退出 1 | 卡在 `pitch\pitch_daily.parquet`(变桨产物无生成端) | 同上 |
+| 门户没有"契约结论段"、`/api/facts` 503 | ~~契约要人裁底稿~~ 2026-09-19 起 claim 由重算台账生成(`scripts\sop_findings_from_ledger.py`,链上 ⑤b/⑤c) | 跑一次重算(⑤b → ⑤c)即得 |
+| `populate` / `audit` 退出 1 | ~~卡在 `pitch\pitch_daily.parquet`(变桨产物无生成端)~~ 变桨面 2026-09-19 起有生成端;若仍退 1,先看该件在不在(链上 ④c) | 跑 `python scripts\pitch_face_build.py`(读 `data\raw\如东\{scada_10min,scada_1min}`,约 12 分钟)或整链 `rebuild_all.py` |
 | `/local-ai/` 503 | 本机 Ollama 未运行或没有模型(环境项, 与数据无关) | `ollama pull` 见 `configs\models.json` |
 | `/local-ai/` 503 | 本机 Ollama 未运行或没有模型(环境项, 与数据无关) | `ollama pull` 见 `configs\models.json` |
 
 
 ## 8. 三个必须知道的坑
 ## 8. 三个必须知道的坑

+ 453 - 0
scripts/baseline_38_build.py

@@ -0,0 +1,453 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""三层基线生成端 —— 补上"包内没有生成端"的 `m5_cms_tcm/baseline_38.json` (用户令 2026-09-19)。
+
+## 为什么要写它
+
+用户令:「**所有的计算 均要形成 观澜的源代码**,确保 观澜 在别的电脑安装后,系统运行正常。」
+`baseline_38.json` 是**随包快照**(族表 kind=shipped、gen=None,见
+`scripts/products_reverse_audit.py::HUMAN_SNAPSHOTS`),旧件的 self/absolute 两层由人工坐实。
+它是 `src/windcms/report.py::baseline_card_html()` 的**唯一取数处** —— 件不在位, 报告页第 5 张卡
+(三层基线对照)直接返回空串。本脚本把它**从 CMS 窗标量 + 场内实物锚算出来**。
+
+## 产物与消费者 (字段契约)
+
+    outputs/<场>/m5_cms_tcm/baseline_38.json
+      meta             built / min_windows / windows / segments_kind / self_stat / layers / caveat /
+                       usage_limit / anchor_guard_days / ★数据边界
+      absolute_limits  iso_rms_vel, Rms_Vel → {unit:'mm/s', yellow:7.1, red:11.0, source:ISO 20816-3 ...}
+                       ★ 标准常数, 不是测量值, 故写死为模块常量 (改进 ISO 不靠重算)
+      fleet_baseline   {'<sensor>|<meas>': {段: 全场中位}}   ← 页面查 fleet_baseline[f'{sensor}|{meas}'][latest_window]
+      self_baseline    {台: {测点: {标量: 条目}}}
+
+    条目字段 (report.py 逐个读): self_baseline, normal_band[2], latest, latest_window, x_self,
+      threshold_self, in_normal_band, n_windows_healthy, n_windows_total, unit, insufficient
+      另存 (消费者暂不读, 供人核对): self_baseline_stat, rel_mad, threshold_note, healthy_windows,
+      excluded (剔掉的段名) / excluded_detail (剔的理由), recovered_after_anchor, trend_flag, spread, latest_n
+
+    ★ 自基线样本不足 ⇒ `self_baseline: null` + `insufficient: '健康期窗 N < 3, 不给数 (禁用 fleet 值顶替)'`
+      —— **绝不**用 fleet 值顶替 (report.py 专门把这条不足列出来, 就是防这个)。
+
+## 单位口径 (与随包旧件一致, ★容易踩)
+
+页面按 `absolute_limits[meas]['unit'] == 'mm/s'` **乘 1000** 显示。CMS 窗索引 (`y_unit` 列) 实测:
+页面用的 5 个标量里 `Peak/Rms_HP/rms_200` = `m/s²`、`Rms_Vel/iso_rms_vel` = `m/s` (Kurtosis 页面不展示, y_unit=`1`)
+⇒ 本器**存原值** (速度存 m/s), 单位标签按 y_unit 分给: `m/s²` / `mm/s`。
+即 0.00282 m/s 存进文件、页面显示 2.82 mm/s。
+
+## 分段 (segment) 口径 —— 与 `scripts/component_history_build.py` **同一套** (两件必须同段)
+
+    len(windows) >= 3   segments_kind='window'  段 = 导出窗 (参考口径: 健康期窗中位, ±锚 120 天护栏)
+    len(windows) <  3   segments_kind='week'    段 = 窗内 ISO 周 (本机实况: 只有 w0316 一窗)
+
+★ 本机 (2026-09-19 实测) 只有 1 个窗 `w0316` (2026-03-16 17:27 → 2026-04-21 10:18) ⇒ 走周段 (6 段)。
+  **周段散布 ≠ 多窗散布**: 周与周之间工况/负荷更接近 ⇒ 散布更小 ⇒ `threshold_self` 更容易被现实超出。
+  这一条写进 `meta.usage_limit`, 不假装等于多窗口径。多窗分支本机**未取数验证**。
+
+## 健康期与锚护栏
+
+健康期段 = 全部段 **减去** 实物锚 ±`ANCHOR_GUARD_DAYS`(=120) 天覆盖的段。
+锚来自 `reference/rudong/positive_anchors.json` (场内实物证据: 内窥镜/金相/拆检/闭环), 按
+`component_class` 映射到测点 (`gearbox_hs_bearing`→两个高速轴测点, `generator_bearing`→发电机两测点,
+`main_bearing`→主轴承两测点)。★ 实测有 4 条锚**没有日期** (WTG03/WTG08/WTG28 主轴, WTG05/WTG32 发电机)
+⇒ 无法做时间护栏, 逐条记进 `meta.锚.无日期` 而不是默认忽略。
+实测后果: WTG09 (锚 2026-01-23) / WTG27 (2026-01-27) / WTG24 (2025-12-15) 三台的对应测点健康段不足 3
+⇒ 出 `insufficient`, 不给数; WTG02 (锚 2025-12-04) 只余 3 个健康段 ⇒ 出数但 `excluded` 里写明剔了哪 3 段。
+
+## 用法
+
+    python scripts/baseline_38_build.py                 # 算并写盘 + 自登记
+    python scripts/baseline_38_build.py --dry-run       # 只算不写, 打印漏斗与不足清单
+    python scripts/baseline_38_build.py --farm rudong
+"""
+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))
+
+import numpy as np                                            # noqa: E402
+import pandas as pd                                           # noqa: E402
+import pyarrow.parquet as pq                                  # noqa: E402
+
+from src import paths as P                                    # noqa: E402
+from src.derived_manifest import record as dm_record           # noqa: E402
+from src.windscada.config import farm as ws_farm               # noqa: E402
+from src.windcms import data as CD                             # noqa: E402
+from src.windcms.config import SENSORS, SENSOR_CN              # noqa: E402
+from src.windcms.config import farm as cms_farm                # noqa: E402
+
+# ── 页面要的标量集: 与 src/windcms/report.py::_BL_MEAS **逐字一致** (顺序即报告卡里的行序) ──
+# 有绝对标准的速度量在前 (iso_rms_vel/Rms_Vel), 冲击量次之; Kurtosis/CrestFactor 判别力弱, 页面不展示。
+MEAS_BL = ['iso_rms_vel', 'Rms_Vel', 'Peak', 'Rms_HP', 'rms_200']
+
+# ── 绝对限值: ISO 20816-3 标准常数 (不是测量值 ⇒ 写死; 改进标准号才需要动这里) ──
+ISO_YELLOW, ISO_RED = 7.1, 11.0
+ISO_SRC = 'ISO 20816-3 (机组 >300kW 刚性安装) 区界 B/C 与 C/D'
+ABSOLUTE_LIMITS = {
+    'iso_rms_vel': dict(unit='mm/s', yellow=ISO_YELLOW, red=ISO_RED, source=ISO_SRC),
+    'Rms_Vel': dict(unit='mm/s', yellow=ISO_YELLOW, red=ISO_RED,
+                    source='同 iso_rms_vel (同为速度 RMS; 频带口径以厂商配置为准)'),
+}
+# y_unit → 存值单位标签 (见 docstring「单位口径」); 速度类标 mm/s (页面 ×1000 显示), 与旧件同约定
+UNIT_OF_YUNIT = {'m/s': 'mm/s', 'm/s²': 'm/s²', 'm/s2': 'm/s²', '1': '-', '': '-'}
+
+MIN_WINDOWS = 3                    # 健康期段数门 (旧件 min_windows=3)
+ANCHOR_GUARD_DAYS = 120            # 实物锚 ±120 天不进健康期 (旧件 anchor_guard_days=120)
+FLEET_MIN_TURBINES = 5             # fleet 中位至少要有几台贡献 (不足则该段不出 fleet 值)
+RECOVER_FRAC = 0.5                 # recovered_after_anchor: 锚后中位 ≤ 锚前中位 × 0.5 判"已恢复"
+CONTAMINATED_FLAG = '自基线可能已污染 (健康期窗单调上升, 基线中心被抬高)'   # 旧件同措辞
+THRESHOLD_NOTE = ('max(center×max(1+2.0×relMAD, 1.3), 健康期最大×1.05) — 由该台自身散布定非拍脑袋倍数, '
+                  '且保证历史正常波动不自触发')
+INS_TMPL = '健康期窗 {n} < 3, 不给数 (禁用 fleet 值顶替)'
+WANT_COLS = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'scalar_value', 'ds_size', 'y_unit']
+
+# 锚的部件类 → 测点 (只映射该部件类**自己的**测点, 不扩大到整机)
+ANCHOR_CLASS_SENSORS = {
+    'gearbox_hs_bearing': ('Gear_HS_rotor_side', 'Gear_HS_generator_side'),
+    'gearbox_planet_bearing': ('Gear_planet',),
+    'gearbox_ims_bearing': ('Gear_IMS',),
+    'generator_bearing': ('Generator_DE', 'Generator_NDE'),
+    'main_bearing': ('Main_bearing_front', 'Main_bearing_rear'),
+}
+
+
+def _r(v, nd=6):
+    if v is None:
+        return None
+    v = float(v)
+    return float(f'{v:.{nd}g}') if np.isfinite(v) else None
+
+
+def _round(v, nd):
+    if v is None:
+        return None
+    v = float(v)
+    return round(v, nd) if np.isfinite(v) else None
+
+
+def _py(o):
+    """numpy 标量 → python; NaN/Inf → None (JSON 严格可解析)。"""
+    if isinstance(o, dict):
+        return {k: _py(v) for k, v in o.items()}
+    if isinstance(o, (list, tuple)):
+        return [_py(v) for v in o]
+    if isinstance(o, (np.integer,)):
+        return int(o)
+    if isinstance(o, (np.floating, float)):
+        f = float(o)
+        return f if np.isfinite(f) else None
+    if isinstance(o, (np.bool_,)):
+        return bool(o)
+    return o
+
+
+def load_scalars(cfg, meas):
+    """各 CMS 窗的标量子集 (口径同 `src.windcms/data.py`, 但不落缓存); 附带 y_unit 用于定单位标签。"""
+    ws = CD.windows(cfg)
+    spans = CD.window_spans(cfg)
+    parts, used = [], {}
+    for w, info in ws.items():
+        p = pathlib.Path(info['index'])
+        names = set(pq.ParquetFile(p).schema_arrow.names)
+        cols = [c for c in WANT_COLS if c in names]
+        d = pd.read_parquet(p, columns=cols)
+        for c in ('ds_size', 'scalar_value'):
+            if c not in d.columns:
+                d[c] = np.nan
+        if 'y_unit' not in d.columns:
+            d['y_unit'] = ''
+        d = d[(d.ds_size == 1) & d.meas_name.isin(meas) & d.sensor_name.isin(SENSORS)].copy()
+        d['window'] = w
+        parts.append(d)
+        sp = spans.get(w) or {}
+        used[w] = dict(index=P.rel(p), rows_scalar=int(len(d)), rows_all=int(sp.get('rows') or 0),
+                       span_min=str(sp.get('t_min') or '')[:10], span_max=str(sp.get('t_max') or '')[:10],
+                       turbines=int(d['turbine'].nunique()))
+    if not parts:
+        return pd.DataFrame(columns=WANT_COLS + ['window', 't', 'val']), used
+    d = pd.concat(parts, ignore_index=True)
+    d['t'] = pd.to_datetime(d['trigger_time'], errors='coerce')
+    d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
+    d = d[d['val'].notna() & d['t'].notna()].copy()
+    return d, used
+
+
+def mark_segments(d, n_win, span_min=None):
+    """加 `seg` 列 → (kind, {段: 段日起日}, 段序, {段: 段结束日})。口径见 docstring。"""
+    span_min = span_min or {}
+    if n_win >= 3:
+        d['seg'] = d['window'].astype(str)
+        kind = 'window'
+    else:
+        iso = d['t'].dt.isocalendar()
+        d['seg'] = iso['year'].astype(int).astype(str) + '-W' + iso['week'].astype(int).astype(str).str.zfill(2)
+        kind = 'week'
+    g = d.groupby('seg')['t'].min()
+    order = [s for s, _ in sorted(g.items(), key=lambda kv: kv[1])]
+    if kind == 'week':
+        seg_date = {s: str(pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1).date()) for s in order}
+        seg_end = {s: str((pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1) + pd.Timedelta(days=6)).date())
+                   for s in order}
+    else:
+        seg_date = {s: str(span_min.get(s) or str(g[s])[:10])[:10] for s in order}
+        seg_end = {s: str(d[d.seg == s]['t'].max())[:10] for s in order}
+    return kind, seg_date, order, seg_end
+
+
+def load_anchors(cfg):
+    """场内实物锚 → (逐条锚表, 说明)。缺件/无日期都如实记, 不默认忽略。"""
+    p = cfg.get('anchors')
+    if not p or not pathlib.Path(p).exists():
+        return pd.DataFrame(columns=['turbine', 'date', 'sensors']), dict(path=P.rel(p) if p else None, exists=False,
+                                                                         note='锚件不在位 ⇒ 无护栏 (健康期=全部段)')
+    a = json.loads(pathlib.Path(p).read_text(encoding='utf-8'))
+    recs, nodate = [], []
+    for x in a.get('anchors', []):
+        cls = str(x.get('component_class') or '')
+        sens = ANCHOR_CLASS_SENSORS.get(cls)
+        if sens is None:                                     # 未知类: 不猜, 记下来
+            nodate.append(dict(turbine=x.get('turbine'), component_class=cls, why='部件类未登记 → 未映射测点'))
+            continue
+        dt_ = x.get('date')
+        if not dt_:
+            nodate.append(dict(turbine=x.get('turbine'), component_class=cls, why='锚无日期 → 无法做时间护栏'))
+            continue
+        recs.append(dict(turbine=x.get('turbine'), date=str(dt_)[:10], sensors=list(sens),
+                         component_class=cls, tier=x.get('tier')))
+    info = dict(path=P.rel(p), exists=True, n_anchors=len(a.get('anchors', [])), guard_days=ANCHOR_GUARD_DAYS,
+                n_used=len(recs), 无日期=len(nodate), tiers=sorted({str(x.get('tier')) for x in a.get('anchors', [])}),
+                未用锚=nodate)
+    return pd.DataFrame(recs), info
+
+
+def healthy_segments(order, seg_date, seg_end, anchors, turbine, sensor):
+    """该 (台, 测点) 的健康期段 + 被剔段 (锚日 ±guard_days 覆盖到的段)。"""
+    ex = []
+    ad = anchors[(anchors['turbine'] == turbine) & (anchors['sensors'].map(lambda L: sensor in L))] \
+        if len(anchors) else anchors
+    for _, a in (ad.iterrows() if len(ad) else []):
+        t = pd.Timestamp(a['date'])
+        lo = str((t - pd.Timedelta(days=ANCHOR_GUARD_DAYS)).date())
+        hi = str((t + pd.Timedelta(days=ANCHOR_GUARD_DAYS)).date())
+        for s in order:                                      # 段与 [lo,hi] 有交叠即剔 (段是闭区间)
+            if not (seg_end[s] < lo or seg_date[s] > hi):
+                ex.append((s, f'{a["date"]}±{ANCHOR_GUARD_DAYS}d({a["component_class"]})'))
+    ex_map = dict(ex)
+    keep = [s for s in order if s not in ex_map]
+    return keep, ex_map
+
+
+def build(cfg, farm_name='rudong', dry=False):
+    m5 = P.m5(farm_name)
+    store = P.store(farm_name)
+    t0 = time.time()
+    d, used = load_scalars(cfg, MEAS_BL)
+    if not len(d):
+        print('[X] 一个 CMS 窗索引都没读到 —— 先放数据并跑重算链 (scripts/rebuild_all.py); 本器不写空产物。')
+        return None, 4
+    kind, seg_date, order, seg_end = mark_segments(d, len(used), {w: v['span_min'] for w, v in used.items()})
+    # 单位标签: 按每个 (测点, 标量) 在窗索引里的 y_unit 定 (见 docstring「单位口径」)
+    yunit = d.groupby(['sensor_name', 'meas_name'])['y_unit'].agg(lambda s: s.dropna().astype(str).mode().iloc[0]
+                                                                 if s.notna().any() else '')
+    sg = d.groupby(['turbine', 'sensor_name', 'meas_name', 'seg'])['val'].agg(['median', 'size']).reset_index()
+    sg = sg.rename(columns={'median': 'med', 'size': 'n'})
+    anchors, anchor_info = load_anchors(cfg)
+    turbines = [t for t in ws_farm(farm_name).get('turbines', [])] or sorted(d['turbine'].unique())
+    # 段跨度用**数据实际覆盖**区间 (周段按日历算出的段末日会到 04-26, 而数据止 04-21 ——
+    # 前者只用于"工单落在哪一段"的判定, 报告里说跨度必须说数据真到的日子)。
+    span = [min(v['span_min'] for v in used.values()), max(v['span_max'] for v in used.values())]
+    # ── fleet: 每个 (测点, 标量) 每段的**全场中位** (先全算一遍; 早期版本按台循环里数段行数,
+    #    每段至多 1 行 ⇒ 计数永远 <5 ⇒ fleet 键全空, 页面 ×fleet 恒为 —) ──
+    fleet = {}
+    for (sen, ms), sub in sg.groupby(['sensor_name', 'meas_name']):
+        for s in order:
+            v = sub[sub.seg == s]['med'].to_numpy(dtype=float)
+            if len(v) >= FLEET_MIN_TURBINES:
+                fleet.setdefault(f'{sen}|{ms}', {})[s] = _r(float(np.median(v)))
+    print(f'三层基线生成端 · 场={farm_name}')
+    print(f'  窗 {len(used)} 个: ' + '; '.join(
+        f'{w}({v["span_min"]}→{v["span_max"]}, 全表 {v["rows_all"]}行; 标量 {v["rows_scalar"]}行)'
+        for w, v in used.items()))
+    print(f'  标量 (ds_size==1 ∧ {len(MEAS_BL)} 标量 ∧ {len(SENSORS)} 测点): {len(d)} 行 · 台 {d.turbine.nunique()}')
+    print(f'  分段: segments_kind={kind} · {len(order)} 段 {order[0]}…{order[-1]} ({span[0]}→{span[1]})')
+    print(f'  单位 (y_unit→标签): ' + '; '.join(
+        f'{s}|{m}:{yunit.get((s, m), "") or "(该测点无此标量)"}→{UNIT_OF_YUNIT.get(str(yunit.get((s, m), "")), "-")}'
+        for s, m in [('Main_bearing_front', 'Peak'), ('Main_bearing_front', 'Rms_Vel'),
+                     ('Main_bearing_front', 'rms_200'), ('Generator_DE', 'iso_rms_vel'),
+                     ('Gear_planet', 'iso_rms_vel')]))
+    print(f'  锚: {anchor_info.get("path")} 共 {anchor_info.get("n_anchors", 0)} 条 · 带日期可用 {anchor_info.get("n_used", 0)} '
+          f'· 护栏 ±{ANCHOR_GUARD_DAYS}d · 未用 {anchor_info.get("无日期", 0)} 条')
+    for x in anchor_info.get('未用锚', []):
+        print(f'    [锚未用] {x.get("turbine")} {x.get("component_class")} — {x.get("why")}')
+
+    self_bl, ins_rows = {}, []
+    n_entries = n_ok = 0
+    for t in turbines:
+        self_bl[t] = {}
+        for sen in SENSORS:
+            self_bl[t][sen] = {}
+            for ms in MEAS_BL:
+                sub = sg[(sg.turbine == t) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
+                if not len(sub):
+                    continue                                  # 该测点无此标量 (如 齿轮箱无 iso_rms_vel): 整条不出现
+                n_entries += 1
+                ser = sub.set_index('seg')['med'].reindex(order).dropna()
+                nser = sub.set_index('seg')['n'].reindex(order).fillna(0)
+                healthy, ex_map = healthy_segments(order, seg_date, seg_end, anchors, t, sen)
+                hv = [s for s in healthy if s in ser.index]
+                unit = UNIT_OF_YUNIT.get(str(yunit.get((sen, ms), '')), '-')
+                base = dict(n_windows_total=int(len(ser)), n_windows_healthy=int(len(hv)),
+                            excluded=[s for s in order if s in ex_map],           # 剔掉的段名 (旧件同字段名)
+                            excluded_detail=[dict(seg=s, why=ex_map[s]) for s in order if s in ex_map],
+                            unit=unit, healthy_windows=hv, latest_window=str(ser.index[-1]),
+                            latest=_r(float(ser.iloc[-1])), latest_n=int(nser.get(ser.index[-1], 0)))
+                if len(hv) < MIN_WINDOWS:
+                    self_bl[t][sen][ms] = _py(dict(base, self_baseline=None, self_baseline_stat=None,
+                                                   normal_band=None, rel_mad=None, threshold_self=None,
+                                                   threshold_note=None, spread=None, x_self=None,
+                                                   in_normal_band=None, recovered_after_anchor=None,
+                                                   trend_flag=None,
+                                                   insufficient=INS_TMPL.format(n=len(hv))))
+                    ins_rows.append(dict(turbine=t, sensor=sen, meas=ms, n_healthy=len(hv),
+                                         n_total=int(len(ser)), why='锚护栏后健康段不足 3' if ex_map else '总段数不足 3'))
+                    continue
+                vals = ser.loc[hv].to_numpy(dtype=float)
+                center = float(np.median(vals))
+                lo, hi = float(vals.min()), float(vals.max())
+                mad = float(np.median(np.abs(vals - center)))
+                rel_mad = (mad / center) if center else None
+                thr = max(center * max(1 + 2.0 * (rel_mad or 0.0), 1.3), hi * 1.05) if center else None
+                latest = float(ser.iloc[-1])
+                # 锚后是否恢复: 需要该 (台,测点) 有锚且段跨度内锚前/锚后各有段 (本机多窗口径才可能成立)
+                rec = None
+                ad = anchors[(anchors['turbine'] == t) & (anchors['sensors'].map(lambda L: sen in L))] \
+                    if len(anchors) else anchors
+                for _, a in (ad.iterrows() if len(ad) else []):
+                    pre = [s for s in ser.index if seg_end[s] < a['date']]
+                    post = [s for s in ser.index if seg_date[s] > a['date']]
+                    if pre and post:
+                        pv, qv = float(np.median(ser.loc[pre])), float(np.median(ser.loc[post]))
+                        rec = bool(qv <= RECOVER_FRAC * pv) if pv > 0 else None
+                        break
+                trend = CONTAMINATED_FLAG if (len(vals) >= 3 and np.all(np.diff(vals) > 0)) else None
+                self_bl[t][sen][ms] = _py(dict(
+                    base, self_baseline=_r(center), self_baseline_stat=f'中位 of 健康期{"窗" if kind == "window" else "段"}中位',
+                    normal_band=[_r(lo), _r(hi)], rel_mad=_round(rel_mad, 4), threshold_self=_r(thr),
+                    threshold_note=THRESHOLD_NOTE, spread=_round(hi / lo, 4) if lo > 0 else None,
+                    x_self=_round(latest / center, 3) if center else None,
+                    in_normal_band=bool(lo <= latest <= hi), recovered_after_anchor=rec, trend_flag=trend))
+                n_ok += 1
+    # (fleet 已在上面按 (测点, 标量, 段) 全场算完 —— 不在这里按台累加, 见那一处注释)
+
+    fleet_bl = {k: dict(sorted(v.items(), key=lambda kv: order.index(kv[0]))) for k, v in sorted(fleet.items())}
+    n_fleet_keys = len(fleet_bl)
+    missing_fleet = sorted({f'{s}|{m}' for t in self_bl.values() for s, mm in t.items() for m in mm} - set(fleet_bl))
+    seg_kind_txt = '导出窗' if kind == 'window' else f'窗内 ISO 周 (本机窗数 {len(used)} < 3 ⇒ 降级; 多窗时自动改回)'
+    meta = dict(
+        built=time.strftime('%Y-%m-%d'), farm=farm_name, by='scripts/baseline_38_build.py',
+        min_windows=MIN_WINDOWS,
+        windows={w: v['span_min'] for w, v in used.items()},
+        windows_detail={w: dict(rows_all=v['rows_all'], rows_scalar=v['rows_scalar'], span_min=v['span_min'],
+                                span_max=v['span_max'], turbines=v['turbines']) for w, v in used.items()},
+        segments_kind=kind, segments=seg_date, n_segments=len(order),
+        anchor_guard_days=ANCHOR_GUARD_DAYS,
+        self_stat=(f'中位 of 健康期{"窗" if kind == "window" else "段"}中位 (+正常带[min,max]+relMAD); '
+                   f'健康期 = 全部段 减去 实物锚 ±{ANCHOR_GUARD_DAYS} 天覆盖的段。'
+                   f'本机段 = {seg_kind_txt}, 共 {len(order)} 段 ({span[0]}→{span[1]}); '
+                   f'段中位 = 该段该 (台,测点,标量) 全部标量记录的中位 (ds_size==1, 每时间戳一条)。'),
+        layers='self (变了没) / fleet (比别人高没) / abs (严重不严重) — 三者独立, 只报一个会误导',
+        caveat=('自基线假设健康期段代表正常; 慢性劣化台该假设失效 → 见 recovered_after_anchor / trend_flag。'
+                f'★ 本机用周段 ⇒ 正常带/relMAD 是**周与周之间**的散布 (周间工况更接近, 散布偏小), '
+                f'不等于随包旧件那种"跨月导出窗"的散布; 且 `latest` 是最后一个周段的段中位, '
+                f'该段样本可能很薄 (逐条给出 latest_n)。'),
+        unit_note=('存值单位 = CMS 窗索引原值 (y_unit): Peak/Rms_HP/rms_200 = m/s², Rms_Vel/iso_rms_vel = m/s, '
+                   'Kurtosis = 1; 速度类标签写 mm/s 而页面按 absolute_limits.unit=="mm/s" 乘 1000 显示 '
+                   '—— 与随包旧件同一约定 (0.00282 m/s 存值 ↔ 2.82 mm/s 显示)。'),
+        usage_limit=(
+            '★ 本基线**不作自动报警闸**: 随包旧件 (多窗口径, 2026-08-26 参数扫描) 记录的 ROC 结论是 '
+            '误报率压到 6.3% 时检出率仅 14.3% (6/42 锚单元且集中在 20# 一台), 加持续性要求(连续2窗)检出率归零 '
+            '—— 多数锚台只有 1~2 个锚前窗。该回测**未在本机重跑** (本机只有 1 窗, 跑不出多窗 ROC), '
+            '此处照录以免被当成"已验证的报警线"。'
+            f'★ 本机为周段口径 ⇒ 上列阈值由**周段散布**定, 散布比多窗口径更小, 更容易被现实超出; '
+            f'正确用法 = ①量化描述(报 x_self 而非"偏高"这类模糊词) ②人工研判输入 ③趋势跟踪积累段数。'
+            '自动报警须等段数够做持续性检验。'),
+        fleet_min_turbines=FLEET_MIN_TURBINES,
+        inputs=dict(scalars=[v['index'] for v in used.values()], anchors=anchor_info.get('path')),
+        锚=anchor_info,
+        未出fleet键=missing_fleet,
+        自基线不足=ins_rows,
+    )
+    meta['★数据边界'] = (
+        f'① 本机只有 {len(used)} 个 CMS 导出色窗 ('
+        + '; '.join(f'{w} {v["span_min"]}→{v["span_max"]}' for w, v in used.items())
+        + f') ⇒ 分段降级为窗内 ISO 周 ({len(order)} 段, {span[0]}→{span[1]}); '
+          f'多窗口径 (健康期"窗"中位) 不可算, 本件 meta.segments_kind 写明实际用的是哪种。'
+        f'② 份数: 自基线条目 {n_entries} 条 (台 × 测点 × 该测点**实际存在**的标量), 出数 {n_ok} 条, '
+        f'不给数 {len(ins_rows)} 条 (健康期段 < {MIN_WINDOWS}, 明细见 meta.自基线不足; 一律 null + insufficient, '
+        f'**不用 fleet 值顶替**)。★ 页面遍历的 5 个标量里, iso_rms_vel 只有发电机两测点有、rms_200 只有主轴承两测点有 '
+        f'(y_unit 实测) ⇒ 其余测点这两条**不存在**, 页面 `if not r: continue` 跳过, 本件在 meta.未覆盖标量 里写明。'
+        f'③ 锚护栏: {anchor_info.get("n_used", 0)} 条带日期锚已按 ±{ANCHOR_GUARD_DAYS} 天剔段, '
+        f'{anchor_info.get("无日期", 0)} 条锚**无日期/类未登记** ⇒ 未做护栏 (逐条见 meta.锚.未用锚)。'
+        f'④ fleet 中位要求该段 ≥{FLEET_MIN_TURBINES} 台贡献, 不足则该段不出值 (页面 ×fleet 显示 —)。'
+        f'⑤ 本件全部数字由本脚本从 outputs/<场>/m5_cms_tcm/windows/*/index.parquet (data/raw 重算产物) 与 '
+        f'reference/<场>/positive_anchors.json 现算, 不含随包旧件的数值搬运。')
+    meta['未覆盖标量'] = {s: [m for m in MEAS_BL if m not in {mm for t in self_bl.values() for mm in t.get(s, {})}]
+                          for s in SENSORS}
+    meta['未覆盖标量'] = {s: v for s, v in meta['未覆盖标量'].items() if v}
+
+    payload = dict(meta=meta, absolute_limits=ABSOLUTE_LIMITS, fleet_baseline=fleet_bl, self_baseline=self_bl)
+    txt = json.dumps(payload, ensure_ascii=False, indent=1, allow_nan=False, default=str)
+    out = m5 / 'baseline_38.json'
+    print(f'\n  条目 {n_entries} 条 · 出数 {n_ok} · 不给数 {len(ins_rows)} ({len({r["turbine"] for r in ins_rows})} 台) '
+          f'· fleet 键 {n_fleet_keys} (未出 {len(missing_fleet)})')
+    print(f'  self_baseline 台 {len([t for t in self_bl if any(self_bl[t][s] for s in self_bl[t])])} 台 × 测点 {len(SENSORS)} '
+          f'· 正常带/阈值样例见下')
+    for t, sen, ms in (('WTG01', 'Main_bearing_front', 'Peak'), ('WTG01', 'Gear_HS_rotor_side', 'Rms_Vel'),
+                       ('WTG01', 'Generator_DE', 'iso_rms_vel'), ('WTG02', 'Generator_DE', 'Peak')):
+        e = (self_bl.get(t) or {}).get(sen, {}).get(ms)
+        if e and e.get('self_baseline'):
+            print(f'    {t} {SENSOR_CN.get(sen, sen)}|{ms}: 自基线 {e["self_baseline"]} 带 {e["normal_band"]} '
+                  f'relMAD {e["rel_mad"]} 阈 {e["threshold_self"]} 末段 {e["latest_window"]}(n={e["latest_n"]}) '
+                  f'×自基线 {e["x_self"]} 带内={e["in_normal_band"]} 健康段 {e["n_windows_healthy"]}/{e["n_windows_total"]}'
+                  f' 剔 {len(e["excluded"])}')
+        elif e:
+            print(f'    {t} {SENSOR_CN.get(sen, sen)}|{ms}: 不给数 — {e.get("insufficient")} '
+                  f'(健康段 {e["n_windows_healthy"]}/{e["n_windows_total"]}, 剔 {len(e["excluded"])})')
+    if ins_rows:
+        print('  不给数明细: ' + '; '.join(f'{r["turbine"]} {SENSOR_CN.get(r["sensor"], r["sensor"])}|{r["meas"]} '
+                                          f'{r["n_healthy"]}/{r["n_total"]} ({r["why"]})' for r in ins_rows[:8])
+              + (' …' if len(ins_rows) > 8 else ''))
+    print(f'  产物体积预估 {len(txt.encode("utf-8")) / 1024:.0f} KB ({P.rel(out)}) · 用时 {time.time() - t0:.1f}s')
+    if dry:
+        print('\n[dry-run] 不写盘、不登记。')
+        return payload, 0
+    m5.mkdir(parents=True, exist_ok=True)
+    out.write_text(txt, encoding='utf-8')
+    # ★自登记台账的落点与键 (2026-09-19 实逮): 正本是 `P.out_root(farm)/_derived_manifest.json`,
+    #   键必须**相对产物仓根**(其它生成端同一口径)。用 P.store()/P.rel() 会另建一本
+    #   `windscada/_derived_manifest.json` 且键带 `outputs/rudong/` 前缀 ⇒ 台账读不到、审计报"未归类"。
+    dm_record(P.out_root(farm_name), {out.relative_to(P.out_root(farm_name)).as_posix():'scripts/baseline_38_build.py (CMS 窗标量 → 自基线/fleet/ISO 绝对三层; '
+                                 f'段口径 segments_kind={kind}, 实物锚 ±{ANCHOR_GUARD_DAYS}d 护栏, 不足不给数)'},
+              by='scripts/baseline_38_build.py')
+    print(f'\n[OK] 已写 {P.rel(out)} ({out.stat().st_size / 1024:.0f} KB) · 已自登记')
+    return payload, 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='三层基线生成端 (m5_cms_tcm/baseline_38.json)')
+    ap.add_argument('--farm', default='rudong')
+    ap.add_argument('--dry-run', action='store_true', help='只算不写盘 (打印漏斗与不给数清单)')
+    args = ap.parse_args()
+    cfg = cms_farm(args.farm)
+    _, rc = build(cfg, args.farm, dry=args.dry_run)
+    return rc
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 627 - 0
scripts/component_history_build.py

@@ -0,0 +1,627 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""部件历史生成端 —— 补上"包内没有生成端"的 `m5_cms_tcm/component_history.json` (用户令 2026-09-19)。
+
+## 为什么要写它
+
+用户令:「**所有的计算 均要形成 观澜的源代码**,确保 观澜 在别的电脑安装后,系统运行正常。」
+`component_history.json` 是**随包快照**(族表 kind=shipped、gen=None,见
+`scripts/products_reverse_audit.py::HUMAN_SNAPSHOTS`),随包件里的"在升/闭环/换件日期"含人工裁决与
+厂家锚,**不是测量数据的函数**。于是另一台机器上装完、放数据、重算之后:
+`src/ontology/trend_ingest.py` 拿不到在升/闭环证据(图谱里两类 Evidence 全缺)、
+`scripts/windscada_serve.py` 的决策链盘「振动在升/闭环」两格永远空。
+本脚本把它**从已有产物算出来**(CMS 窗索引 + 工单台账),口径写在下面与产物 meta 里。
+
+## 产物与消费者 (字段契约)
+
+    outputs/<场>/m5_cms_tcm/component_history.json
+
+    meta                              built / windows / segments / segments_kind / 口径 / ★数据边界
+    components[]                      turbine, component, n_windows, n_replace, series, events
+    summary['★当前在升 (末三窗/早三窗 ≥1.3)']        ← src/ontology/trend_ingest.py 逐行读
+                                        turbine, component, ratio, early, late, n_events, 工单可对
+    summary['★换件闭环 (检出→换件→恢复, 算法端到端验证)'] ← 同上 + scripts/windscada_serve.py 链盘
+                                        turbine, component, ratio, pre, post, replace_date, title, verdict
+    summary[非★键]                    消费端只认 ★ 前缀的键 ⇒ 非★键是给人/给诊断看的, 不被误读
+
+★ `component` 必须是 `src/ontology/trend_ingest.py::COMP_MAP` 里的中文名 (齿轮箱/发电机/主轴承/变桨/偏航/变流器),
+  否则该行被消费者跳过。本器只出**齿轮箱/发电机/主轴承**三类 —— CMS 索引里只有这三类的测点
+  (Main_bearing_* / Gear_* / Generator_*),变桨/偏航/变流器在本产物无振动测点,不臆造。
+
+## 分段 (segment) 口径 —— 两种模式, 自动选
+
+参考口径是**导出窗**为段 (末三窗/早三窗; 健康期窗中位)。本机实况 (2026-09-19 实测):
+`src/windcms/config.py::FARMS['rudong']['windows']` 只解析出**一个**窗 `w0316`
+(`outputs/rudong/m5_cms_tcm/windows/w0316/index.parquet`,2,066,686 行 × 54 列,
+trigger_time 2026-03-16 17:27 → 2026-04-21 10:18)。窗数 < 3 ⇒ 自动降为
+**窗内周段** (ISO 周, 2026-W12 … 2026-W17 共 6 段)。两种模式都在本文件里实现:
+
+    len(windows) >= 3   segments_kind='window'   段名=窗名, 段日=该窗 trigger_time 最早日
+    len(windows) <  3   segments_kind='week'     段名=ISO 周 (2026-W12), 段日=该周周一
+
+**多窗口径未在本机取数验证**(本机只有 1 窗 ⇒ 该分支只有单测/纸面正确性,不给"已验证"的说法)。
+
+## 数据现实与诚实边界 (不造数)
+
+* **`★当前在升` 本机必然是空表**:判据要求"末三窗/早三窗"且**多窗口径**;本机 1 窗 ⇒ 不可算。
+  ★ 不降门槛、不换判据去凑一行。作为补充, 出**非★**键
+  `窗内周段趋势 (单窗补充口径, 仅参考不作判据)`:同一条比值算式 (末三段/早三段, 要求 6 段以取到
+  **不相交**的早/晚两组) 在周段上跑出来是什么, 就写什么。
+* **`★换件闭环` 本机为空**:闭环要求"该台该部件的更换/换件工单,且段跨度里**既有段在换件日之前、
+  又有段在其后**"(前段须**结束**在工单日之前、后段须**开始**在工单日之后 —— 工单所在那一段两边都不算,
+  否则换后数据会混进"换前",实测污染过 3 条)。工单台账 (`outputs/<场>/windscada/workorders.parquet`,
+  5,876 行) 实测跨 **2020-01-03 → 2026-07-08**,其中传动链件 903 行(去重后)/换件类 12 条 ——
+  但**全部落在段跨度 (2026-03-16~04-21) 之外** (换件工单最晚 2025-10-14),窗内 0 条 ⇒ 无"换件前段"可比
+  ⇒ 空表。空表本身就是结论。
+* 台账有**逐字重复行**(实测 2026-01-03 环网柜那条同一台同一分钟 7 行;换件类里 WTG23 2024-09-12
+  发电机轴承更换 4 行)⇒ 本器按 (台,日,标题,部件) 去重后再计数,原始/去重两个数都写进 meta.台账。
+* 段内样本数一并写进 series 的 `n` —— 周段只有 2~142 个标量记录 (中位 14), 中位数据此散布, 读者可自行判断。
+
+## 用法
+
+    python scripts/component_history_build.py                  # 算并写盘 + 自登记
+    python scripts/component_history_build.py --dry-run        # 只算不写, 打印漏斗
+    python scripts/component_history_build.py --farm rudong    # 场名 (默认 rudong)
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import re
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+import numpy as np                                            # noqa: E402
+import pandas as pd                                           # noqa: E402
+import pyarrow.parquet as pq                                  # noqa: E402
+
+from src import paths as P                                    # noqa: E402
+from src.derived_manifest import record as dm_record           # noqa: E402
+from src.windscada.config import farm as ws_farm               # noqa: E402  (场名/台数: windscada 侧)
+from src.windcms import data as CD                             # noqa: E402  (窗发现: 唯一取用口)
+from src.windcms.config import KEY_SCALARS, SENSORS, SENSOR_CN  # noqa: E402
+from src.windcms.config import farm as cms_farm                # noqa: E402  (windcms 场配置: FARMS[name])
+
+# ── 标量集 (bounded): KEY_SCALARS 去掉 CrestFactor ────────────────────────────────
+# 为什么去 CrestFactor: `src/windcms/report.py` 明写"Kurtosis/CrestFactor 判别力弱(回测实证, 不展示)";
+# 且随包旧件的 fleet 键集合恰好 = {Kurtosis, Peak, Rms_HP, Rms_Vel} ∪ {iso_rms_vel (发电机),
+# rms_200 (主轴承)} —— 本器沿用同一集合, 便于与旧件对拍。
+MEAS_SERIES = [m for m in KEY_SCALARS if m != 'CrestFactor']
+
+# ── 部件 ↔ 测点 ────────────────────────────────────────────────────────────────
+# 依据 `src/windcms/config.py`: 8 测点 = 2 主轴承 + 4 齿轮箱 + 2 发电机。
+# `System Monitor` (磁盘/内存/健康度) 不是部件测点 ⇒ 排除 (它连 SENSORS 都不在)。
+COMP_OF_SENSOR = {
+    'Main_bearing_front': '主轴承', 'Main_bearing_rear': '主轴承',
+    'Gear_planet': '齿轮箱', 'Gear_IMS': '齿轮箱',
+    'Gear_HS_rotor_side': '齿轮箱', 'Gear_HS_generator_side': '齿轮箱',
+    'Generator_DE': '发电机', 'Generator_NDE': '发电机',
+}
+# 段数门: 单窗口径下"末三/早三"需要 ≥3 段 (与旧件判据同名同门槛); 周段补充口径另要求 ≥6 段
+# (早三/末三**不相交**, 否则末三段里含早三段的中间段, 比值被自己稀释)。
+MIN_SEG_WINDOW = 3
+MIN_SEG_WEEK = 6
+RATIO_HI = 1.3                    # 在升判据 (与旧件同名同值): 末/早 ≥ 1.3
+RECOVER_HI = 0.5                  # 闭环"恢复"判据: 换件后/换件前 ≤ 0.5
+FLAT_LO, FLAT_HI = 0.9, 1.1       # 负面对照分桶: 持平带
+
+# ── 工单 → (部件, 是否更换) 规则 (见模块 docstring「诚实边界」) ─────────────────────
+WO_COMP_KEYS = (('齿轮箱', ('齿轮箱', '齿圈', '行星', '高速轴', '中间轴', '齿轮油')),
+                ('主轴承', ('主轴承', '主轴')),
+                ('发电机', ('发电机',)))
+PART_PAT = re.compile(r'轴承|齿圈|行星|高速轴|中间轴|箱体|发电机本体')
+# 耗材类词: 出现即**不算换件** (油/油脂/加注/油泵/滤芯/密封/传感器 —— 换了也不该改变振动)
+CONSUMABLE_PAT = re.compile(r'油脂|润滑|油位|油泵|滤芯|密封|加注|油管')
+REPLACE_PAT = re.compile(r'更换|替换|换件')
+EVENT_CAP = 8                     # 每 (台, 部件) 落进 events 的最多工单条数 (最近 N 条), 总数另存 n_events
+
+WANT_COLS = ['turbine', 'sensor_name', 'meas_name', 'trigger_time', 'scalar_value', 'ds_size']
+
+
+# ── 工具 ───────────────────────────────────────────────────────────────────────
+def _r6(v):
+    """6 位有效数字的浮点 (0.0028182 → 0.0028182; 4.630123 → 4.63012)。"""
+    if v is None:
+        return None
+    v = float(v)
+    if not np.isfinite(v):
+        return None
+    return float(f'{v:.6g}')
+
+
+def _r3(v):
+    if v is None:
+        return None
+    v = float(v)
+    return float(f'{v:.3g}') if np.isfinite(v) else None
+
+
+def _py(o):
+    """numpy 标量 → python 标量; NaN/Inf → None (JSON 严格可解析, 见 json.dumps(allow_nan=False))。"""
+    if isinstance(o, dict):
+        return {k: _py(v) for k, v in o.items()}
+    if isinstance(o, (list, tuple)):
+        return [_py(v) for v in o]
+    if isinstance(o, (np.integer,)):
+        return int(o)
+    if isinstance(o, (np.floating, float)):
+        f = float(o)
+        return f if np.isfinite(f) else None
+    if isinstance(o, (np.bool_,)):
+        return bool(o)
+    return o
+
+
+def load_scalars(cfg, meas):
+    """各 CMS 窗的标量子集 —— 口径同 `src/windcms/data.py::_read_index/load_scalars`
+    (只取 ds_size==1 的标量行, 只取 SENSORS 内的测点), 但**不落缓存**: 本器只读, 不在产物仓里另生成件。
+
+    窗的覆盖区间另取 `src.windcms.data.window_spans` (逐窗读 trigger_time; 页面同源) ——
+    本器只筛了 6 个标量 × 8 测点, 单看筛后行会把窗起点说晚 (w0316 全表起 03-16 17:27, 筛后起 03-17)。
+    """
+    ws = CD.windows(cfg)
+    spans = CD.window_spans(cfg)
+    parts, used = [], {}
+    for w, info in ws.items():
+        p = pathlib.Path(info['index'])
+        names = set(pq.ParquetFile(p).schema_arrow.names)
+        cols = [c for c in WANT_COLS if c in names]
+        d = pd.read_parquet(p, columns=cols)
+        for c in ('ds_size', 'scalar_value'):
+            if c not in d.columns:
+                d[c] = np.nan
+        d = d[(d.ds_size == 1) & d.meas_name.isin(meas) & d.sensor_name.isin(SENSORS)].copy()
+        d['window'] = w
+        parts.append(d)
+        t = pd.to_datetime(d['trigger_time'], errors='coerce')
+        sp = spans.get(w) or {}
+        used[w] = dict(index=P.rel(p), rows_scalar=int(len(d)), rows_all=int(sp.get('rows') or 0),
+                       span_min=str(sp.get('t_min') or '')[:10], span_max=str(sp.get('t_max') or '')[:10],
+                       seg_min=str(t.min())[:10], seg_max=str(t.max())[:10],
+                       turbines=int(d['turbine'].nunique()))
+    if not parts:
+        return pd.DataFrame(columns=WANT_COLS + ['window', 't', 'val']), used
+    d = pd.concat(parts, ignore_index=True)
+    d['t'] = pd.to_datetime(d['trigger_time'], errors='coerce')
+    d['val'] = pd.to_numeric(d['scalar_value'], errors='coerce')
+    d = d[d['val'].notna() & d['t'].notna()].copy()
+    return d, used
+
+
+def mark_segments(d, n_win, span_min=None):
+    """加 `seg` 列 + 返回 (kind, {段名: 段日起日}, 段序)。见 docstring「分段口径」。
+
+    段日: 多窗口径 = 该窗全表覆盖起点 (`window_spans`, 与页面同源); 单窗口径 = 该 ISO 周的**周一**
+    (取周一而不是"该周首条记录日", 是让段日只依赖日历, 不随哪个测点先出数而漂)。
+    """
+    span_min = span_min or {}
+    if n_win >= 3:                                   # 多窗口径: 段 = 导出窗
+        d['seg'] = d['window'].astype(str)
+        kind = 'window'
+    else:                                            # 单窗口径: 段 = 窗内 ISO 周
+        iso = d['t'].dt.isocalendar()
+        d['seg'] = iso['year'].astype(int).astype(str) + '-W' + iso['week'].astype(int).astype(str).str.zfill(2)
+        kind = 'week'
+    g = d.groupby('seg')['t'].min()
+    order = [s for s, _ in sorted(g.items(), key=lambda kv: kv[1])]
+    span_max = str(d['t'].max())[:10]
+    if kind == 'week':
+        seg_date = {s: str(pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1).date()) for s in order}
+        seg_end = {s: str((pd.Timestamp.fromisocalendar(int(s[:4]), int(s[6:]), 1) + pd.Timedelta(days=6)).date())
+                   for s in order}
+    else:
+        seg_date = {s: str(span_min.get(s) or str(g[s])[:10])[:10] for s in order}
+        seg_end = {s: str(d[d.seg == s]['t'].max())[:10] for s in order}
+    return kind, seg_date, order, seg_end
+
+
+def seg_table(d):
+    """(台, 测点, 标量, 段) 的段内中位/样本数。"""
+    g = d.groupby(['turbine', 'sensor_name', 'meas_name', 'seg'])['val'].agg(['median', 'size'])
+    g = g.reset_index().rename(columns={'median': 'med', 'size': 'n'})
+    return g
+
+
+def trend_table(sg, order, k=3, min_seg=3):
+    """(台, 测点, 标量) 的「末 K 段中位 / 早 K 段中位」比 —— 只对段数 ≥ min_seg 的组合出数。"""
+    rows = []
+    idx = {s: i for i, s in enumerate(order)}
+    for (t, sen, meas), sub in sg.groupby(['turbine', 'sensor_name', 'meas_name']):
+        m = sub.assign(_i=sub['seg'].map(idx)).dropna(subset=['_i']).sort_values('_i')
+        if len(m) < min_seg:
+            continue
+        vals = m['med'].to_numpy(dtype=float)
+        early, late = float(np.median(vals[:k])), float(np.median(vals[-k:]))
+        if not (early > 0):
+            continue
+        rows.append(dict(turbine=t, sensor=sen, component=COMP_OF_SENSOR.get(sen, ''), measure=meas,
+                         early=_r6(early), late=_r6(late), ratio=_r3(late / early),
+                         n_seg=int(len(m)), segs=list(m['seg'])))
+    return pd.DataFrame(rows)
+
+
+# ── 工单台账 ───────────────────────────────────────────────────────────────────
+def load_workorders(store):
+    """工单台账 → (逐行事件表, 台账范围说明)。台账不存在 ⇒ 空表 + 如实说明 (不造事件)。"""
+    p = pathlib.Path(store) / 'workorders.parquet'
+    if not p.exists():
+        return pd.DataFrame(columns=['turbine', 'date', 'title', 'replaced', 'comps', 'raw']), \
+            dict(path=P.rel(p), exists=False, rows=0, note='台账不在位 ⇒ 全部 events 为空数组, 不是"无工单"'),
+    w = pd.read_parquet(p)
+    w['t_report'] = pd.to_datetime(w.get('t_report'), errors='coerce')
+    txt = (w.get('故障名称', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('故障描述', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('故障位置二级', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('故障位置三级', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('排查项目', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('故障原因', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('维修对象', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('元器件名称', pd.Series('', index=w.index)).fillna(''))
+    act = w.get('维修动作', pd.Series('', index=w.index)).fillna('').astype(str)
+    obj = (w.get('故障名称', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('维修对象', pd.Series('', index=w.index)).fillna('') + '|'
+           + w.get('元器件名称', pd.Series('', index=w.index)).fillna(''))
+    recs = []
+    for i, s in txt.items():
+        comps = [c for c, keys in WO_COMP_KEYS if any(k in s for k in keys)]
+        if not comps:
+            continue
+        o = str(obj.iloc[i] if hasattr(obj, 'iloc') else '')
+        repl = bool(REPLACE_PAT.search(act.iloc[i])) and bool(PART_PAT.search(o)) and not CONSUMABLE_PAT.search(o)
+        title = str(w.get('故障名称', pd.Series('', index=w.index)).iloc[i] or '').strip()
+        recs.append(dict(turbine=str(w['turbine'].iloc[i]), date=str(w['t_report'].iloc[i])[:10],
+                         title=title[:60], replaced=repl, comps=comps,
+                         act=str(act.iloc[i]), obj=o[:60]))
+    ev = pd.DataFrame(recs)
+    # ★ 台账里有**逐字重复行** (实测: 2026-01-03 环网柜 V 柜出线电缆接头三相击穿 同一台同一分钟 7 行;
+    #   换件类里 WTG23 2024-09-12 发电机轴承更换 4 行)。不去重会让 n_replace / n_events 虚高。
+    n_raw = int(len(ev))
+    if n_raw:
+        ev['_k'] = ev['turbine'] + '|' + ev['date'] + '|' + ev['title'] + '|' + ev['comps'].map(lambda L: ','.join(L))
+        ev = ev.drop_duplicates(subset=['_k']).drop(columns=['_k']).reset_index(drop=True)
+    tv = w['t_report'].dropna()
+    info = dict(path=P.rel(p), exists=True, rows=int(len(w)),
+                span=[str(tv.min())[:10], str(tv.max())[:10]] if len(tv) else ['', ''],
+                n_transmission=int(len(ev)), n_transmission_raw=n_raw,
+                n_replace=int(ev['replaced'].sum()) if len(ev) else 0,
+                replace_span=[str(pd.to_datetime(ev[ev.replaced].date).min())[:10],
+                              str(pd.to_datetime(ev[ev.replaced].date).max())[:10]] if len(ev) and ev.replaced.any() else None)
+    return ev, info
+
+
+def events_of(ev, turbine, comp):
+    """该 (台, 部件) 的工单事件 (按台/部件关键字映射) → (最近 EVENT_CAP 条明细, 总条数, 其中换件条数)。
+
+    ★ 总条数与换件条数在**全量**匹配上数 (明细只是最近的 EVENT_CAP 条): 早期版本用明细数当总数,
+      一旦该台该部件工单超过 8 条, n_replace 就会**少报**。
+    """
+    if not len(ev) or 'comps' not in ev.columns:
+        return [], 0, 0
+    m = ev[ev['comps'].map(lambda L: comp in L) & (ev['turbine'] == turbine)].sort_values('date')
+    out = [dict(turbine=turbine, comp=comp, date=r['date'], src='工单', title=r['title'], replaced=bool(r['replaced']))
+           for _, r in m.tail(EVENT_CAP).iterrows()]
+    return out, int(len(m)), int(m['replaced'].sum())
+
+
+def build(cfg, farm_name='rudong', dry=False):
+    m5 = P.m5(farm_name)
+    store = P.store(farm_name)
+    t0 = time.time()
+    meas = MEAS_SERIES
+    d, used = load_scalars(cfg, meas)
+    if not len(d):
+        print('[X] 一个 CMS 窗索引都没读到 —— 先放数据并跑重算链 (scripts/rebuild_all.py); 本器不写空产物。')
+        return None, 4
+    kind, seg_date, order, seg_end = mark_segments(d, len(used), {w: v['span_min'] for w, v in used.items()})
+    sg = seg_table(d)
+    ev, wo_info = load_workorders(store)
+    # 台名单以场配置为准 (38 台), 数据里没有的台如实留空 series (不静默丢台)
+    turbines = [t for t in ws_farm(farm_name).get('turbines', [])] or sorted(d['turbine'].unique())
+    no_data = [t for t in turbines if t not in set(d['turbine'].unique())]
+    # 数据覆盖区间取**全表** (src.windcms.data.window_spans, 与页面同源): 只筛 6 标量 × 8 测点会把窗起点说晚一天
+    span = [min(v['span_min'] for v in used.values()), max(v['span_max'] for v in used.values())]
+    print(f'部件历史生成端 · 场={farm_name}')
+    print(f'  窗 {len(used)} 个: ' + '; '.join(
+        f'{w}(全表 {v["span_min"]}→{v["span_max"]}, {v["rows_all"]}行; 标量 {v["rows_scalar"]}行)'
+        for w, v in used.items()))
+    print(f'  标量行 (ds_size==1 ∧ {len(meas)} 标量 ∧ {len(SENSORS)} 测点): {len(d)}'
+          + (f' · 场配置 {len(turbines)} 台, 无数据的台: {no_data}' if no_data else f' · 台数 {len(turbines)}'))
+    print(f'  分段口径: segments_kind={kind} · 段 {len(order)} 个: {order[0]}…{order[-1]} '
+          f'(段日起 {seg_date[order[0]]}→{seg_date[order[-1]]}; 数据覆盖 {span[0]}→{span[1]})')
+    print(f'  段内样本 (台×测点×标量×段) 中位: {int(sg["n"].median())} · 最少 {int(sg["n"].min())} · 最多 {int(sg["n"].max())}')
+    print(f'  工单台账: {wo_info["path"]} 行={wo_info["rows"]} 跨度={wo_info.get("span")} '
+          f'传动链件={wo_info.get("n_transmission")} 换件={wo_info.get("n_replace")} '
+          f'换件跨度={wo_info.get("replace_span")}')
+    if len(ev):
+        print('  换件类工单 (全量, 判据 candidate): ' + '; '.join(
+            f'{r["turbine"]} {"/".join(r["comps"])} {r["date"]} {r["title"][:18]}'
+            for _, r in ev[ev['replaced']].sort_values('date').iterrows()))
+
+    # ── trend: 单窗口径的末三/早三 (判据) 与 周段的末三/早三 (补充, 不相交) ──
+    if kind == 'window':
+        tw = trend_table(sg, order, k=3, min_seg=MIN_SEG_WINDOW)
+    else:
+        tw = trend_table(sg, order, k=3, min_seg=MIN_SEG_WEEK)
+    tw = tw.sort_values('ratio', ascending=False) if len(tw) else tw
+    wo_ok = bool(wo_info.get('exists') and wo_info.get('span') and wo_info['span'][0]
+                 and wo_info['span'][0] <= span[1] and wo_info['span'][1] >= span[0])
+
+    # ── components ──
+    comps_out = []
+    for t in turbines:
+        for comp in ('主轴承', '齿轮箱', '发电机'):
+            sens = [s for s, c in COMP_OF_SENSOR.items() if c == comp]
+            series = {}
+            n_seg_c = 0
+            for sen in sens:
+                for ms in meas:
+                    sub = sg[(sg.turbine == t) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
+                    if not len(sub):
+                        continue
+                    m = sub.set_index('seg')['med'].reindex(order).dropna()
+                    if not len(m):
+                        continue
+                    base = float(np.median(m.to_numpy(dtype=float)))
+                    if not (base > 0):
+                        continue
+                    entries = []
+                    for s in order:
+                        if s not in m.index:
+                            continue
+                        v = float(m.loc[s])
+                        n = int(sub.loc[sub.seg == s, 'n'].iloc[0])
+                        entries.append(dict(seg=s, med=_r6(v), x_self=_r3(v / base), n=n))
+                    series[f'{sen}|{ms}'] = entries
+                    n_seg_c = max(n_seg_c, len(entries))
+            e_list, n_ev, n_repl = events_of(ev, t, comp)
+            comps_out.append(dict(turbine=t, component=comp, n_windows=int(n_seg_c),
+                                  n_events=n_ev, n_replace=n_repl,
+                                  events_capped=bool(n_ev > len(e_list)),
+                                  series=series, events=e_list))
+
+    # ── summary ──
+    # ★判据一: 只认多窗口径 (本机必然空) —— 不降门槛凑数
+    win_trend = trend_table(sg, order, k=3, min_seg=MIN_SEG_WINDOW) if kind == 'window' else pd.DataFrame()
+    rising = []
+    if kind == 'window' and len(win_trend):
+        for (t, comp), sub in win_trend.groupby(['turbine', 'component']):
+            w = sub.sort_values('ratio', ascending=False).iloc[0]
+            if w['ratio'] >= RATIO_HI:
+                _, n_ev, _ = events_of(ev, t, comp)
+                rising.append(dict(turbine=t, component=comp, ratio=float(w['ratio']), early=float(w['early']),
+                                   late=float(w['late']), n_events=n_ev, 工单可对=wo_ok,
+                                   sensor=w['sensor'], measure=w['measure'], n_windows=int(w['n_seg'])))
+        rising = sorted(rising, key=lambda r: -r['ratio'])
+
+    # 补充诊断 (非★, 消费者忽略): 周段末三/早三 —— 逐行带段内最小样本数, 让"薄段噪声"可见
+    diag = []
+    if len(tw):
+        nmin = sg.groupby(['turbine', 'sensor_name', 'meas_name'])['n'].min()
+        for _, r in tw[tw['ratio'] >= RATIO_HI].iterrows():
+            _, n_ev, _ = events_of(ev, r['turbine'], r['component'])
+            diag.append(dict(turbine=r['turbine'], component=r['component'], sensor=r['sensor'],
+                             measure=r['measure'], early=float(r['early']), late=float(r['late']),
+                             ratio=float(r['ratio']), n_seg=int(r['n_seg']),
+                             seg_n_min=int(nmin.get((r['turbine'], r['sensor'], r['measure']), 0)),
+                             n_events=n_ev, 工单可对=wo_ok))
+    diag_all = len(diag)
+    diag = diag[:20]
+
+    # ★判据二: 换件闭环 —— 需要"换件日之前有段 ∧ 之后有段"
+    loop, loop_skip, repl_pairs = [], [], []
+    repl = ev[ev['replaced']] if len(ev) and 'replaced' in ev.columns else ev
+    fusion = {}
+    fp = m5 / 'fusion_38.csv'
+    if fp.exists():                                    # 检出候选旗标 (融合/CMS 面), 只作 verdict 的佐证
+        fu = pd.read_csv(fp)
+        for _, r in fu.iterrows():
+            fusion[str(r.get('台'))] = dict(融合=str(r.get('融合') or ''), CMS=str(r.get('CMS') or ''))
+    for _, r in (repl.iterrows() if len(repl) else []):
+        for comp in r['comps']:
+            repl_pairs.append((r['turbine'], comp, r['date']))
+            pre_segs = [s for s in order if seg_end[s] < r['date']]      # 段**结束**在工单日之前
+            post_segs = [s for s in order if seg_date[s] > r['date']]    # 段**开始**在工单日之后
+            if not pre_segs or not post_segs:
+                loop_skip.append(dict(turbine=r['turbine'], component=comp, date=r['date'], title=r['title'],
+                                      why=f'段跨度 {span[0]}~{span[1]} 内'
+                                          + ('无换件前段' if not pre_segs else '无换件后段')))
+                continue
+            sens = [s for s, c in COMP_OF_SENSOR.items() if c == comp]
+            cand = []
+            for sen in sens:
+                for ms in meas:
+                    sub = sg[(sg.turbine == r['turbine']) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
+                    if not len(sub):
+                        continue
+                    a = sub[sub.seg.isin(pre_segs)]['med']
+                    b = sub[sub.seg.isin(post_segs)]['med']
+                    if len(a) and len(b) and float(np.median(a)) > 0:
+                        cand.append((ms, sen, float(np.median(a)), float(np.median(b))))
+            if not cand:
+                loop_skip.append(dict(turbine=r['turbine'], component=comp, date=r['date'], title=r['title'],
+                                      why='换件前后段内该部件无可用标量'))
+                continue
+            ms, sen, pre, post = max(cand, key=lambda c: c[2])      # 取换前幅值最大者 (最不利/最有分辨力)
+            ratio = post / pre
+            f = fusion.get(r['turbine']) or {}
+            hit = bool(f) and (f.get('融合') not in ('', '正常') or '红' in f.get('CMS', '') and '红0' not in f.get('CMS', ''))
+            if ratio <= RECOVER_HI:
+                verdict = '恢复' if hit else '回落 (非检出台, 不作端到端实证)'
+            elif ratio < FLAT_LO:
+                verdict = '部分回落'
+            elif ratio <= FLAT_HI:
+                verdict = '持平'
+            else:
+                verdict = '未降/上升'
+            loop.append(dict(turbine=r['turbine'], component=comp, ratio=_r3(ratio), pre=_r6(pre), post=_r6(post),
+                             replace_date=r['date'], title=r['title'], verdict=verdict,
+                             sensor=sen, measure=ms, 检出候选=hit,
+                             融合=(f.get('融合') or None), pre_segs=pre_segs, post_segs=post_segs))
+    loop = sorted(loop, key=lambda r: r['ratio'])
+
+    # 负面对照: 工单后振动未降/持平 (非换件的传动链工单, 换前后各有段)
+    # 不可算的原因**分桶计数**: 单看一个 1512 的总数会把"工单在窗外"和"窗内但无前后段"混成一团。
+    neg_rows, neg_noc = [], dict(工单在段跨度外=0, 窗内但无前段或后段=0, 该部件无标量=0)
+    nonrepl = ev[~ev['replaced']] if len(ev) and 'replaced' in ev.columns else ev
+    for _, r in (nonrepl.iterrows() if len(nonrepl) else []):
+        for comp in r['comps']:
+            # ★ 前/后段都**不含工单所在那一段**: 段界线按"段结束 < 工单日"与"段开始 > 工单日"判,
+            #   否则工单落在段中间时, 该段自己会被当成"换前", 把换后的数据混进 pre (实测污染过 3 条)。
+            pre_segs = [s for s in order if seg_end[s] < r['date']]
+            post_segs = [s for s in order if seg_date[s] > r['date']]
+            if r['date'] < seg_date[order[0]] or r['date'] > seg_end[order[-1]]:
+                neg_noc['工单在段跨度外'] += 1
+                continue
+            if not pre_segs or not post_segs:
+                neg_noc['窗内但无前段或后段'] += 1
+                continue
+            sens = [s for s, c in COMP_OF_SENSOR.items() if c == comp]
+            cand = []
+            for sen in sens:
+                for ms in meas:
+                    sub = sg[(sg.turbine == r['turbine']) & (sg.sensor_name == sen) & (sg.meas_name == ms)]
+                    if not len(sub):
+                        continue
+                    a = sub[sub.seg.isin(pre_segs)]['med']
+                    b = sub[sub.seg.isin(post_segs)]['med']
+                    if len(a) and len(b) and float(np.median(a)) > 0:
+                        cand.append((ms, sen, float(np.median(a)), float(np.median(b))))
+            if not cand:
+                neg_noc['该部件无标量'] += 1
+                continue
+            ms, sen, pre, post = max(cand, key=lambda c: c[2])
+            neg_rows.append(dict(turbine=r['turbine'], component=comp, date=r['date'], title=r['title'],
+                                 sensor=sen, measure=ms, pre=_r6(pre), post=_r6(post), ratio=_r3(post / pre)))
+    bucket = dict(下降=sum(1 for r in neg_rows if r['ratio'] < FLAT_LO),
+                  持平=sum(1 for r in neg_rows if FLAT_LO <= r['ratio'] <= FLAT_HI),
+                  上升=sum(1 for r in neg_rows if r['ratio'] > FLAT_HI))
+    neg_noc_total = sum(neg_noc.values())
+
+    n_comp_rows = sum(1 for c in comps_out for _ in c['series'])
+    win_txt = '; '.join(f'{w} {v["span_min"]}~{v["span_max"]}' for w, v in used.items())
+    n_pair_inspan = sum(1 for _, _, dt in repl_pairs if span[0] <= dt <= span[1])
+    判读 = (
+        f'分段口径 segments_kind={kind}: 本机只解析出 {len(used)} 个导出色窗 ({win_txt}) '
+        f'⇒ 以窗内 ISO 周段为分段 ({len(order)} 段 {order[0]}…{order[-1]}, {span[0]}~{span[1]}); '
+        f'多窗 (≥3) 时自动改回多窗口径。'
+        f'★当前在升 (末三窗/早三窗 ≥{RATIO_HI}) 本机{("有 " + str(len(rising)) + " 行") if rising else "不可算 ⇒ 空表"}: '
+        f'该判据要求多窗口径且 ≥{MIN_SEG_WINDOW} 窗, 本机 {len(used)} 窗。'
+        f'★换件闭环 本机{("有 " + str(len(loop)) + " 行") if loop else "不可算 ⇒ 空表"}: 要求换件工单在段跨度内'
+        f'且其前后各有至少一段; 台账 {wo_info.get("span")} 共 {wo_info.get("n_transmission")} 条传动链工单, '
+        f'其中换件类 {wo_info.get("n_replace")} 条 (跨度 {wo_info.get("replace_span")}) 展开为 {len(repl_pairs)} 个 '
+        f'(台,部件) 组合, 段跨度内 {n_pair_inspan} 个 ⇒ 成闭环 {len(loop)} 行, 其余 {len(loop_skip)} 个因'
+        f'"无换件前段/后段"逐条记入 meta.未成闭环。'
+        f'补充口径 (非判据): 周段末三/早三比值 ≥{RATIO_HI} 的候选 {diag_all} 条 (列出前 {len(diag)} 条); '
+        f'周段每 (台,测点,标量) 只有 {int(sg["n"].min())}~{int(sg["n"].max())} 条标量记录/段 '
+        f'(中位 {int(sg["n"].median())}) ⇒ 该比值只说明窗内走向, 不作在升判据 (明细逐行给出 seg_n_min)。'
+        f'负面对照: 传动链非换件工单里换前后各有段的 {len(neg_rows)} 条可算 (下降 {bucket["下降"]} · 持平 {bucket["持平"]} '
+        f'· 上升 {bucket["上升"]}), 其余 {neg_noc_total} 条不可算 ({neg_noc})。')
+
+    seg_kind_txt = '导出窗' if kind == 'window' else f'窗内 ISO 周 (本机窗数 {len(used)} < 3 ⇒ 降级; 多窗时自动改回)'
+    meta = dict(
+        built=time.strftime('%Y-%m-%d'), farm=farm_name, by='scripts/component_history_build.py',
+        windows={w: v['span_min'] for w, v in used.items()},
+        windows_detail={w: dict(rows_all=v['rows_all'], rows_scalar=v['rows_scalar'],
+                                span_min=v['span_min'], span_max=v['span_max'],
+                                seg_min=v['seg_min'], seg_max=v['seg_max'], turbines=v['turbines'])
+                        for w, v in used.items()},
+        segments_kind=kind,
+        segments=seg_date,
+        n_segments=len(order),
+        来源声明=('本文件由 scripts/component_history_build.py **现算**写成: 输入 = '
+                 'outputs/<场>/m5_cms_tcm/windows/*/index.parquet (data/raw 重算产物, 只读 ds_size==1 标量行) '
+                 '+ outputs/<场>/windscada/workorders.parquet (工单台账) '
+                 '+ outputs/<场>/m5_cms_tcm/fusion_38.csv (仅作"检出候选"佐证)。'
+                 '不含随包旧件 (人工裁决/厂家锚) 的数值搬运; 算不出来的 (在升/闭环) 一律空表 + 边界说明, 不填估值。'
+                 'components[].n_windows = 该部件的**段数** (segments_kind="week" 时即窗内周段数, 不是导出窗数)。'),
+        口径=(f'标量: CMS 窗索引 ds_size==1 行, 标量集 = KEY_SCALARS 去 CrestFactor ({", ".join(meas)}); '
+             f'测点 → 部件: Main_bearing_*→主轴承, Gear_*→齿轮箱, Generator_*→发电机 (System Monitor 非部件测点, 排除)。'
+             f'段: {seg_kind_txt}。'
+             f'series 值 = 该段该 (测点|标量) 标量**中位**; 归一 x_self = 段中位 / 该台该测点该标量**全段中位数** '
+             f'(随包旧件同口径, 旧件写作 x_self; 本件不出 x_fleet —— 那需要跨台同工况对照)。'
+             f'在升判据 = 末三窗中位 / 早三窗中位 ≥ {RATIO_HI}, 需多窗口径且 ≥{MIN_SEG_WINDOW} 窗。'
+             f'闭环判据 = 换件工单日之前有段 ∧ 之后有段; pre/post = 该部件换前/后段中位 (取换前幅值最大的测点|标量), '
+             f'verdict: ratio ≤ {RECOVER_HI} 且该台为检出候选 ⇒ 恢复, 否则按 部分回落/持平/未降 如实记。'
+             f'工单口径: 部件由关键词映射 (齿轮箱/齿圈/行星/高速轴/中间轴/齿轮油 → 齿轮箱; 主轴承/主轴 → 主轴承; '
+             f'发电机 → 发电机); replaced = 维修动作含 更换|替换|换件 ∧ 目标文本含部件词 '
+             f'(轴承|齿圈|行星|高速轴|中间轴|箱体|发电机本体) ∧ 目标文本无耗材词 '
+             f'(油脂|润滑|油位|油泵|滤芯|密封|加注|油管) —— 换油/换传感器不算换件。'),
+        inputs=dict(scalars=[v['index'] for v in used.values()], workorders=wo_info.get('path'),
+                    fusion=P.rel(fp) if fp.exists() else None),
+        未成闭环=loop_skip[:40], 未成闭环总数=len(loop_skip),
+        台账=wo_info,
+    )
+    meta['★数据边界'] = (
+        f'① 本机只有 {len(used)} 个 CMS 导出色窗 ({win_txt}) ⇒ 在升 (末三窗/早三窗) 与 多窗口径的健康期分段'
+        f'**都不可算** ⇒ summary["★当前在升…"] 为空表; 本件以窗内周段 ({len(order)} 段) 作补充诊断, '
+        f'只描述窗内走向, 不作判据。'
+        f'② 换件闭环需要"换件日前后各有段": 工单台账 {wo_info.get("path")} 跨度 {wo_info.get("span")} '
+        f'(传动链件 {wo_info.get("n_transmission")} 条, 其中换件类 {wo_info.get("n_replace")} 条, '
+        f'跨度 {wo_info.get("replace_span")}), 与段跨度 {seg_date[order[0]]}~{seg_date[order[-1]]} **无交叠** '
+        f'⇒ summary["★换件闭环…"] 为空表。★ 这两张空表是数据边界, 不是"没有在升机组/没有闭环案例"; '
+        f'站点需 (a) 补导 ≥3 个 CMS 色窗 (尤其换件日前后各一窗), (b) 补 2026 年传动链换件工单, '
+        f'二者到位后本脚本自动出数。'
+        f'③ 段内样本薄: 周段每 (台,测点,标量) 仅 {int(sg["n"].min())}~{int(sg["n"].max())} 条标量记录 '
+        f'(中位 {int(sg["n"].median())}), 中位数据此散布 —— series 的 n 逐条给出, 读者自行判断可信度。')
+    meta['★换件断点'] = '换件前后不可混算趋势; 本表在 events 里标 replaced=true, 读者须自行分段读 series'
+
+    payload = dict(
+        meta=meta,
+        components=_py(comps_out),
+        summary=_py({
+            '★换件闭环 (检出→换件→恢复, 算法端到端验证)': loop,
+            '★当前在升 (末三窗/早三窗 ≥1.3)': rising,
+            '窗内周段趋势 (单窗补充口径, 仅参考不作判据)': diag,
+            '窗内周段趋势·候选总数': diag_all,
+            '工单后振动未降/持平 (多为油位/传感器/加注类, 非轴承更换 ⇒ 振动本不该变)': dict(
+                可算=len(neg_rows), 不可算=neg_noc_total, 不可算原因=neg_noc, 分桶=bucket,
+                口径='逐条传动链非换件工单: 换件日前后各有至少一段 ⇒ 可算; 指标取该部件换前幅值最大的 (测点|标量) 的 '
+                     f'post/pre 比值; 分桶 下降<{FLAT_LO} · 持平 {FLAT_LO}~{FLAT_HI} · 上升>{FLAT_HI}',
+                明细=sorted(neg_rows, key=lambda r: -r['ratio'])[:12]),
+            '判读': 判读}),
+    )
+    txt = json.dumps(payload, ensure_ascii=False, indent=1, allow_nan=False, default=str)
+    out = m5 / 'component_history.json'
+    print(f'\n  段表行 {len(sg)} · 部件行 {len(comps_out)} · series 键 {n_comp_rows} · '
+          f'events {sum(len(c["events"]) for c in comps_out)} 条')
+    print(f'  summary: ★换件闭环 {len(loop)} 行 · ★当前在升 {len(rising)} 行 · '
+          f'周段候选 {diag_all} 条(列 {len(diag)}) · 负面对照可算 {len(neg_rows)}/不可算 {neg_noc_total} {neg_noc}')
+    if neg_rows:
+        print('  负面对照明细: ' + '; '.join(f'{r["turbine"]} {r["component"]} {r["date"]} {r["title"][:16]} '
+                                            f'{r["sensor"]}|{r["measure"]} {r["ratio"]}×' for r in
+                                            sorted(neg_rows, key=lambda r: -r['ratio'])))
+    if diag:
+        print('  周段候选 top: ' + '; '.join(f'{r["turbine"]} {r["component"]} {r["sensor"]}|{r["measure"]} '
+                                            f'{r["ratio"]}× ({r["early"]}→{r["late"]}, {r["n_seg"]}段)' for r in diag[:5]))
+    if loop_skip:
+        print(f'  换件工单未成闭环 {len(loop_skip)} 条, 例: '
+              + '; '.join(f'{s["turbine"]} {s["component"]} {s["date"]} {s["why"]}' for s in loop_skip[:3]))
+    print(f'  产物体积预估 {len(txt.encode("utf-8")) / 1024:.0f} KB ({P.rel(out)}) · 用时 {time.time() - t0:.1f}s')
+    if dry:
+        print('\n[dry-run] 不写盘、不登记。')
+        return payload, 0
+    m5.mkdir(parents=True, exist_ok=True)
+    out.write_text(txt, encoding='utf-8')
+    dm_record(P.out_root(farm_name), {out.relative_to(P.out_root(farm_name)).as_posix():'scripts/component_history_build.py (CMS 窗标量 ds_size==1: 段中位/x_self 序列 + '
+                                 '工单台账事件; 在升/闭环判据与空表情形见产物 meta.★数据边界)'},
+              by='scripts/component_history_build.py')
+    print(f'\n[OK] 已写 {P.rel(out)} ({out.stat().st_size / 1024:.0f} KB) · 已自登记')
+    return payload, 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='部件历史生成端 (m5_cms_tcm/component_history.json)')
+    ap.add_argument('--farm', default='rudong')
+    ap.add_argument('--dry-run', action='store_true', help='只算不写盘 (打印漏斗与体积预估)')
+    args = ap.parse_args()
+    cfg = cms_farm(args.farm)
+    _, rc = build(cfg, args.farm, dry=args.dry_run)
+    return rc
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 9 - 5
scripts/inventory_products.py

@@ -61,8 +61,9 @@ STORES = OrderedDict([
                         producer='随包快照 (无生成端)', consumer='scripts/guanlan_facts_contract.py · 门户结论段')),
                         producer='随包快照 (无生成端)', consumer='scripts/guanlan_facts_contract.py · 门户结论段')),
     ('guanlan',    dict(helper='P.guanlan()',          purpose='事实契约与对外派生 (可上云面孔)',
     ('guanlan',    dict(helper='P.guanlan()',          purpose='事实契约与对外派生 (可上云面孔)',
                         producer='scripts/guanlan_facts_contract.py', consumer='门户 #findings · /api/facts')),
                         producer='scripts/guanlan_facts_contract.py', consumer='门户 #findings · /api/facts')),
-    ('pitch',      dict(helper='P.pitch()',            purpose='变桨侧派生件 (零位/日粒度)',
-                        producer='随包快照 + rebuild_from_raw --scada', consumer='脚本 windscada_serve.py 变桨面')),
+    ('pitch',      dict(helper='P.pitch()',            purpose='变桨侧派生件 (日粒度液压/润滑/柱塞 + 零位三口径月表)',
+                        producer='scripts/pitch_face_build.py (重算链 ④c; scada_10min + scada_1min)',
+                        consumer='脚本 windscada_serve.py 变桨面 · src/windscada/subsys/pitch.py')),
     ('paradigm_r1', dict(helper='(无助手: P.out_root()/paradigm_r1)', purpose='范式实验件 (E3/E5/E8 底稿, 事实契约输入)',
     ('paradigm_r1', dict(helper='(无助手: P.out_root()/paradigm_r1)', purpose='范式实验件 (E3/E5/E8 底稿, 事实契约输入)',
                         producer='随包快照 (无生成端)', consumer='scripts/guanlan_facts_contract.py')),
                         producer='随包快照 (无生成端)', consumer='scripts/guanlan_facts_contract.py')),
 ])
 ])
@@ -74,9 +75,12 @@ INPUTS = OrderedDict([
                          feeds=['windscada/powercurve*.parquet', 'windscada/loss_monthly.parquet', 'windscada/temp_monthly.parquet',
                          feeds=['windscada/powercurve*.parquet', 'windscada/loss_monthly.parquet', 'windscada/temp_monthly.parquet',
                                 'windscada/temp_bins.parquet', 'windscada/stop_events.parquet', 'windscada/yaw_daily.parquet',
                                 'windscada/temp_bins.parquet', 'windscada/stop_events.parquet', 'windscada/yaw_daily.parquet',
                                 'windscada/hydraulic_accum.parquet', 'windscada/thermal_chain.parquet',
                                 'windscada/hydraulic_accum.parquet', 'windscada/thermal_chain.parquet',
-                                'windscada/curve_*.parquet', 'windscada/control_*.parquet', 'windscada/system_aux.parquet'])),
-    ('scada_1min', dict(what='1min 导出 (逐台)', span='csv_col', time_col='occur_time',
-                        feeds=['windscada/watch_channels_monthly.parquet (watch 月度)'])),
+                                'windscada/curve_*.parquet', 'windscada/control_*.parquet', 'windscada/system_aux.parquet',
+                                'pitch/pitch_daily.parquet (日粒度液压/润滑/柱塞)'])),
+    ('scada_1min', dict(what='1min 导出 (逐台内部台号 01E.csv…38B.csv)', span='csv_col', time_col='occur_time',
+                        feeds=['windscada/watch_channels_monthly.parquet (watch 月度)',
+                               'pitch/pitch_daily.parquet (越卸荷/跌启泵分钟计数)',
+                               'pitch/pitch_zero_monthly.parquet (停机顺桨段/满发段零位两口径)'])),
     ('故障报警',   dict(what='报警事件导出 (SpreadsheetML *.xls)', span='fname_year',
     ('故障报警',   dict(what='报警事件导出 (SpreadsheetML *.xls)', span='fname_year',
                         feeds=['windscada/alarms.parquet'])),
                         feeds=['windscada/alarms.parquet'])),
     ('风机故障记录', dict(what='检修工单台账 (*.xls/xlsx)', span='fname_year',
     ('风机故障记录', dict(what='检修工单台账 (*.xls/xlsx)', span='fname_year',

+ 507 - 0
scripts/pitch_face_build.py

@@ -0,0 +1,507 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""变桨面生成端 —— 补上"包内没有生成端"的 `pitch/**` 两件 (用户令 2026-09-19)。
+
+## 为什么要写它
+
+用户令:「**所有的计算 均要形成 观澜的源代码**,确保 观澜 在别的电脑安装后,系统运行正常。」
+`pitch/pitch_daily.parquet` 与 `pitch/pitch_zero_monthly.parquet` 是**随包快照**(族表里 kind=shipped、
+gen=None),于是另一台机器上装完、放数据、重算之后,变桨面永远只能显示"缺件 ⇒ 不可判"。
+本脚本把它**从 `data/raw` 算出来**(与其它 20 多个构建器同一套纪律:生成端进包 + 进重算链 + 产物自登记)。
+
+## 产物与消费者 (字段契约见 docs/源代码化清单_v0.1.md §2)
+
+    pitch/pitch_daily.parquet        date, turbine, n, hydlevel_timeon, hydfilt_timeon, hyd_band,
+                                     hyd_over_relief, hyd_under_pump, hublub_timeon, pitchpum_timeon,
+                                     pistA, pistB, pistC, n_op, hyd_mean
+                                     → src/windscada/subsys/pitch.py::registry 四维度判级
+    pitch/pitch_zero_monthly.parquet month, turbine, zero_dev, n_rows + 两口径分列/佐证列
+                                     → 同上 D3「执行与位置反馈」(零位轴)
+    pitch/pitch_face_manifest.json   口径/阈值/证据/计数 (人可核对;也是"不静默造数"的凭据)
+
+## 口径 (用户令 2026-09-19:「零位取停机段中位、满发段中位两者**分列**出」)
+
+**来源分工**(实测:两组通道互不重叠):
+
+    scada_10min/<WTGnn>.csv   开关量 timeon / 压力分位 / 运行状态 → 日粒度一行 (n×600 = 当日应测秒)
+    scada_1min/<台号>.csv     三叶桨距角 / 有功 / 转速 / 液压压力 → 零位两口径 + 越线分钟计数
+
+`pitch_daily` 各列(**每列都是"当日"聚合**,不是窗内均值):
+
+    n                 当日 10min 记录数 (满日 144;×600 = 应测秒 —— 消费者按 `n*600 - timeon` 算报警秒)
+    n_op              当日**运行段数** (判据 flg_wtc_ScInOper_endvalue==1;缺列则退 tur_wtc_GenRpm_mean>0.5)
+                      ★消费者的不可判门是 `窗内 nop < 144`(≈不足 1 天等效) ⇒ n_op 必须与 n 同粒度
+    hyd_mean          当日 prs_wtc_HydPress_mean 均值 (bar)
+    hyd_band          当日**运行段**段内 (HydPress_max − HydPress_min) 的**中位** = 蓄能器锯齿带宽
+    hyd_over_relief   当日运行**分钟**中 系统压力 ≥ 卸荷阈(240bar) 的分钟数
+    hyd_under_pump    当日运行**分钟**中 系统压力 ≤ 启泵阈(195bar) 的分钟数
+    hublub_timeon     当日 dot_wtc_HubLubPu_timeon 求和 (s/日)   ← 轮毂润滑泵
+    pitchpum_timeon   当日 dot_wtc_PitchPum_timeon 求和 (s/日)   ← 变桨泵
+    hydlevel_timeon   当日 din_wtc_HydLevel_timeon 求和 (s/日)   ← 油位开关(1=正常 ⇒ 报警秒=应测−timeon)
+    hydfilt_timeon    当日 din_wtc_HydrFilt_timeon 求和 (s/日)   ← 滤网开关
+    pistA/B/C         当日 din_wtc_LubPist{A,B,C}_counts 求和 (次/日) ← 三叶柱塞动作次数(不对称轴)
+
+**阈值出处(不是拍的,是现场数据上读出来的)**:该场系统压力锯齿的下沿中位 **194 bar**、
+上沿中位 **245~246 bar**(10min 段内 max/min 的 fleet 中位);1min 运行分钟压力 **P5 = 195 / P95 = 240**。
+取整定为 **卸荷阈 240 bar / 启泵阈 195 bar**:① 与"压力越过正常锯齿上下沿"的物理含义一致;
+② 计数量级与旧随包件同(旧件 over 36~130 次/日、under 0~40 次/日)。
+★ 与旧件的差异(如实标注,不假装复刻):旧件 `n_op` 口径未知(实测旧件 WTG01 2025-01-01
+n_op=48,本器同日在位运行段 61 段),故**旧件只作量级对拍、不作逐值验收**。
+
+`pitch_zero_monthly` 两口径(**1min** 数据,月 × 台):
+
+    停机段中位  该月「顺桨停机」分钟(转速 < 0.5 rpm ∧ |有功| < 50 kW ∧ 三叶桨距角中位 ∈ 顺桨带 [85,92]°)
+                三叶桨距角中位 − 同月**全场中位基准**
+    满发段中位  该月「满发」分钟(有功 ≥ 95% 额定 = 3800 kW)三叶桨距角中位 − 同月**全场中位基准**
+    运行段分档  该月「运行」分钟(转速 ≥ 0.5 rpm ∧ 有功 100~3800 kW)按有功 500kW 分档, 逐档比同月全场
+                同档中位, 再取该台跨档中位 (可比档 ≥4 档、单档 ≥30 分钟) —— **判据轴**
+
+    物理含义与为什么是**三**个口径(第三口径是 2026-09-19 反推旧件后补的, 有实测依据):
+    ① 停机顺桨段 = 叶片压在**机械顺桨止挡**上的读数差。止挡是机械硬基准, fleet 顺桨位集中在
+       88.59~88.69°(±0.7°) ⇒ 这个口径量的是"止挡/编码器在该位的差", **量不出运行区零位差**:
+       实测它把 19#(现场 −0.87° 专项确诊) 读成 **+0.11°**。
+    ② 满发段 = 守住额定功率所加的桨角差; 受风况/空气密度影响大(台间离散大), 只作**并列参考**。
+    ③ 运行段分档 = 同工况下桨距角读数差, 即行业口径的"零位偏差"; 分档是为了不让风资源混进来
+       (风大的台长期在高桨距档 ⇒ 中位天然偏高)。实测它**把 19# 单独拎出来**(逐月持续为负, 其余台 ≈0),
+       与现场专项确诊断性一致 ⇒ `zero_dev_run` 作判据轴, 另两口径并列可见。
+    `zero_dev` 保留=停机段(用户令选定的列), 消费者 `pitch.py` 按"两口径取更不利"用。
+    旧随包件的 `zero_dev` 是"绝对零位偏差"(实测只有 19# 离群 −0.896、其余台 ≈0), 与本器"相对同月全场中位"
+    不同源 ⇒ 零位一族**不做逐值对拍**, 只作定性和量级对照(见 manifest 证据「零位三口径」)。
+
+    有效门(不静默造数):该台该月顺桨停机分钟 < 50 ⇒ 不出值;同月全场有效台 < 5 ⇒ 该月无基准,
+    整月不出值;运行段可比档 < 4 ⇒ `zero_dev_run` 留空(不拿 fleet 值顶替)。三叶极差中位
+    (`spread_stop/full`) 一并存下(叶间不平衡证据,消费者暂未用)。
+
+## 用法
+
+    python scripts/pitch_face_build.py                     # 算并写盘 (38 台全量, 约 10 分钟)
+    python scripts/pitch_face_build.py --turbines WTG01,WTG19   # 只算几台 (调试)
+    python scripts/pitch_face_build.py --dry-run           # 只报会写什么、源件在哪、能不能算
+    python scripts/pitch_face_build.py --check-map         # 复核 1min 台号 → WTG 台号 的映射 (对拍功率)
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import re
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+import numpy as np                                            # noqa: E402
+import pandas as pd                                           # noqa: E402
+
+from src import paths as P                                    # noqa: E402
+from src.derived_manifest import record as dm_record           # noqa: E402
+from src.windscada.config import farm                          # noqa: E402
+from src.windscada import scada_source as SS                   # noqa: E402
+
+# ── 阈值与门 (出处见模块 docstring「阈值出处」) ──────────────────────────────────
+HYDR_UNLOAD_BAR = 240.0      # 越卸荷阈 (bar): 1min 运行分钟压力 P95 = 240
+HYDR_PUMP_BAR = 195.0        # 跌启泵阈 (bar): 1min 运行分钟压力 P5  = 195
+STOP_RPM = 0.5               # 停机判据: 转速 < 0.5 rpm
+STOP_KW = 50.0               # 停机判据: |有功| < 50 kW
+FULL_FRAC = 0.95             # 满发判据: 有功 ≥ 95% 额定 (4000 kW → 3800 kW)
+# 顺桨停机带 (°): 「停机」分钟里三叶桨距角**是双峰的** —— 实测 2025-01 停机分钟的 5/25/50/75/95 分位 =
+# 28.7 / 31.2 / **31.79** / 88.59 / 88.63: 一半停在顺桨位(≈88.6°), 一半停在 ~31.8°(未顺桨/维护位)。
+# 零位基准只能取**压住机械顺桨止挡**的那一支, 故按带滤: fleet 顺桨位实测 88.59~88.69°(±0.7°),
+# 带取 85~92°(±3.5° 容差) —— 既滤掉 31.8° 那一支, 又给真实零位误差留 ±3.4° 的可见余量。
+FEATHER_BAND = (85.0, 92.0)
+MIN_STOP_MIN = 50            # 台月顺桨停机段最少分钟数
+MIN_BASE_TURBINES = 5        # 同月基准至少要有几台有效台
+# ── 运行段(细桨距)零位口径 —— **工况分档归一** (第三个口径, 2026-09-19 反推旧件后补) ──
+# 零位偏差的行业含义是"**同一工况**下桨距角读数差"; 不分档会被风资源混入 (风大的台长期在高桨距档
+# ⇒ 中位天然偏高)。故按有功 500kW 分档, 逐档与全场同月中位比, 再取该台跨档中位 = `zero_dev_run`。
+# 为什么必须有它 (实测): 停机顺桨段口径把 19# 读成 +0.11° (顺桨止挡是**机械基准**, 与运行区零位
+# 不是同一个量; 且 fleet 顺桨位 88.59~88.69° 极紧, 差异被止挡抹平), 满发段口径 19# = +0.61° 且台间
+# 离散大; 而**运行段口径把 19# 单独拎出来** (逐月持续为负, 其余台 ≈ 0) —— 与现场
+# "19# 零位 −0.87° 专项确诊" 定性一致 ⇒ 判据轴不能只靠停机段那一个口径。
+RUN_MIN_KW = 100.0           # 运行段下界 (kW): 低于此视为停机/待机
+RUN_BIN_KW = 500.0           # 工况分档宽 (kW)
+MIN_RUN_BIN_N = 30           # 单档单月最少分钟数
+MIN_RUN_BINS = 4             # 台月至少要有几档可比
+
+# ── 列清单 (源件列名 → 本器内部名) ─────────────────────────────────────────────
+C10 = dict(
+    state='flg_wtc_ScInOper_endvalue', rpm='tur_wtc_GenRpm_mean',
+    hp_min='prs_wtc_HydPress_min', hp_max='prs_wtc_HydPress_max', hp_mean='prs_wtc_HydPress_mean',
+    lvl='din_wtc_HydLevel_timeon', filt='din_wtc_HydrFilt_timeon',
+    hublub='dot_wtc_HubLubPu_timeon', pitchpum='dot_wtc_PitchPum_timeon',
+    pA='din_wtc_LubPistA_counts', pB='din_wtc_LubPistB_counts', pC='din_wtc_LubPistC_counts')
+TS10 = 'TimeStamp'
+C1 = dict(ts='time_stamp', pw='active_power', rpm='rotor_speed', pr='液压站预充压力',
+          b1='pitch_angle_blade_1', b2='pitch_angle_blade_2', b3='pitch_angle_blade_3')
+
+DAILY_COLS = ['date', 'turbine', 'n', 'n_op', 'hyd_mean', 'hyd_band', 'hyd_over_relief', 'hyd_under_pump',
+              'hublub_timeon', 'pitchpum_timeon', 'hydlevel_timeon', 'hydfilt_timeon',
+              'pistA', 'pistB', 'pistC']
+ZERO_COLS = ['month', 'turbine', 'zero_dev', 'n_rows', 'zero_dev_stop', 'zero_dev_full',
+             'zero_dev_run', 'run_bins', 'run_med_bin',
+             'stop_med', 'full_med', 'n_stop', 'n_stop_all', 'n_full', 'spread_stop', 'spread_full',
+             'base_stop', 'base_full']
+
+
+def turbine_of_1min(stem: str) -> str | None:
+    """1min 导出件的**内部台号** → 场配置台号: `01E.csv` → `WTG01`。
+
+    现场 1min 导出用内部代号 (`01E.csv … 38B.csv`,`scripts/place_raw_data.py`);数字前缀 = 台号。
+    实测佐证 (`--check-map`): 4 组对拍 (01E→WTG01 / 02E→WTG02 / 19C→WTG19 / 38B→WTG38) 的 10min 功率
+    与 1min 功率**相关系数 0.982~0.986、均值差 < 1 kW**;跨台对拍则为不相关 ⇒ 映射按数字前缀成立。
+    """
+    m = re.match(r'^(\d+)', stem)
+    if not m:
+        return None
+    return f'WTG{int(m.group(1)):02d}'
+
+
+def _paths(cfg):
+    csv10 = pathlib.Path(str(cfg['src_10min']))
+    csv1, _ = SS.dirs_of('1min', cfg)
+    return csv10, csv1
+
+
+def read_10min(wtg: str, cfg) -> pd.DataFrame:
+    """10min 取一台 (CSV 优先 / MDB 回落, 见 src/windscada/scada_source.py)。"""
+    cols = [TS10] + list(C10.values())
+    d = SS.load('10min', wtg, columns=cols, cfg=cfg)
+    have = {v: k for k, v in C10.items()}
+    missing = [v for v in C10.values() if v not in d.columns]
+    if TS10 not in d.columns and len(d.columns):
+        d = d.rename(columns={d.columns[0]: TS10})
+    if missing:
+        d.attrs['pitch_missing'] = missing
+    return d.rename(columns=have)
+
+
+def read_1min(code: str, cfg) -> pd.DataFrame:
+    """1min 取一台 (CSV 优先 / MDB 回落)。列名同 `C1` 的值;缺列由调用方判缺。"""
+    cols = list(C1.values())
+    d = SS.load('1min', code, columns=cols, cfg=cfg)
+    if C1['ts'] not in d.columns and len(d.columns):
+        d = d.rename(columns={d.columns[0]: C1['ts']})
+    return d
+
+
+def _num(s) -> pd.Series:
+    return pd.to_numeric(s, errors='coerce')
+
+
+def daily_of(wtg: str, d10: pd.DataFrame) -> pd.DataFrame:
+    """10min 表 → 日粒度一行 (列见 DAILY_COLS)。"""
+    d = d10.copy()
+    d['ts'] = pd.to_datetime(d[TS10], errors='coerce')
+    d['date'] = d['ts'].dt.normalize()
+    for k in ('hp_min', 'hp_max', 'hp_mean', 'lvl', 'filt', 'hublub', 'pitchpum', 'pA', 'pB', 'pC', 'rpm'):
+        if k in d:
+            d[k] = _num(d[k])
+    if 'state' in d:
+        run = _num(d['state']) == 1
+    else:                                     # 兜底: 无运行状态列时按发电机转速判运行
+        run = d['rpm'] > 0.5
+    d['_run'] = run.fillna(False)
+    d['_band'] = (d['hp_max'] - d['hp_min']).where(d['_run']) if {'hp_max', 'hp_min'} <= set(d.columns) else np.nan
+    g = d.groupby('date')
+    spec = dict(n=('ts', 'size'), n_op=('_run', 'sum'), hyd_band=('_band', 'median'))
+    for src, dst, how in (('hp_mean', 'hyd_mean', 'mean'), ('lvl', 'hydlevel_timeon', 'sum'),
+                          ('filt', 'hydfilt_timeon', 'sum'), ('hublub', 'hublub_timeon', 'sum'),
+                          ('pitchpum', 'pitchpum_timeon', 'sum'), ('pA', 'pistA', 'sum'),
+                          ('pB', 'pistB', 'sum'), ('pC', 'pistC', 'sum')):
+        if src in d.columns:                                  # 缺列 = NaN (不静默造数; 缺列记进 manifest)
+            spec[dst] = (src, (lambda s: s.sum(min_count=1)) if how == 'sum' else how)
+    out = g.agg(**spec)
+    out.insert(0, 'turbine', wtg)
+    return out.reset_index()
+
+
+def minute_counts_of(wtg: str, d1: pd.DataFrame, rated_kw: float) -> pd.DataFrame:
+    """1min 表 → (日 × 台) 的越卸荷/跌启泵**分钟数** + (月 × 台) 的零位两口径原料。"""
+    d = d1.copy()
+    d['ts'] = pd.to_datetime(d[C1['ts']], errors='coerce')
+    d['date'] = d['ts'].dt.normalize()
+    d['month'] = d['ts'].dt.strftime('%Y-%m')
+    pw, rpm = _num(d[C1['pw']]), _num(d[C1['rpm']])
+    pr = _num(d[C1['pr']]) if C1['pr'] in d else pd.Series(np.nan, index=d.index)
+    run = (pw.abs() >= STOP_KW) | (rpm >= STOP_RPM)
+    d['_over'] = (run & (pr >= HYDR_UNLOAD_BAR)).astype('int64')
+    d['_under'] = (run & (pr <= HYDR_PUMP_BAR)).astype('int64')
+    day = d.groupby('date').agg(hyd_over_relief=('_over', 'sum'), hyd_under_pump=('_under', 'sum')).reset_index()
+    day.insert(0, 'turbine', wtg)
+    # 零位两口径 (逐月)
+    blades = [_num(d[C1[b]]) for b in ('b1', 'b2', 'b3') if C1[b] in d]
+    pm = sum(blades) / len(blades) if blades else pd.Series(np.nan, index=d.index)
+    spread = (pd.concat(blades, axis=1).max(axis=1) - pd.concat(blades, axis=1).min(axis=1)) if len(blades) == 3 \
+        else pd.Series(np.nan, index=d.index)
+    stop_all = (rpm < STOP_RPM) & (pw.abs() < STOP_KW)
+    stop = stop_all & pm.between(*FEATHER_BAND)     # 只取压住顺桨止挡的那一支 (见 FEATHER_BAND 注释)
+    full = pw >= FULL_FRAC * rated_kw
+    run = (rpm >= STOP_RPM) & (pw >= RUN_MIN_KW) & (pw < FULL_FRAC * rated_kw)
+    recs = []
+    for m, idx in d.groupby('month').groups.items():
+        i = pd.Index(idx)
+        s, f = stop.loc[i], full.loc[i]
+        recs.append(dict(
+            month=m, turbine=wtg,
+            n_stop=int(s.sum()), n_stop_all=int(stop_all.loc[i].sum()), n_full=int(f.sum()),
+            stop_med=float(pm.loc[i][s].median()) if int(s.sum()) else np.nan,
+            full_med=float(pm.loc[i][f].median()) if int(f.sum()) else np.nan,
+            spread_stop=float(spread.loc[i][s].median()) if int(s.sum()) else np.nan,
+            spread_full=float(spread.loc[i][f].median()) if int(f.sum()) else np.nan,
+            run_med=float(pm.loc[i][run.loc[i]].median()) if int(run.loc[i].sum()) else np.nan))
+    # 运行段按有功分档 (逐月 × 档): 只有足够样本的档才参与 (见 RUN_BIN_KW 注释)
+    rb = pd.DataFrame(dict(month=d['month'], bin=(pw // RUN_BIN_KW), pm=pm))[run]
+    rb = rb.dropna(subset=['pm'])
+    bins = (rb.groupby(['month', 'bin'])['pm'].agg(['median', 'size'])
+              .reset_index().rename(columns={'median': 'med', 'size': 'n'}))
+    bins = bins[bins['n'] >= MIN_RUN_BIN_N]
+    bins.insert(1, 'turbine', wtg)
+    return day, pd.DataFrame(recs), bins
+
+
+def run_dev(run_bins: pd.DataFrame) -> pd.DataFrame:
+    """运行段分档 → 台月 `zero_dev_run` (逐档比全场同月中位, 再取跨档中位)。
+
+    为什么要分档 (见 RUN_BIN_KW 注释): 不分档时"桨距角中位"混了风资源 (风大的台长期在高桨距档);
+    分档后每档内工况可比, 差值才归因于零位/标定。台月可比档数 < MIN_RUN_BINS ⇒ 不出值。
+    """
+    if not len(run_bins):
+        return pd.DataFrame(columns=['month', 'turbine', 'zero_dev_run', 'run_bins', 'run_med_bin'])
+    b = run_bins.copy()
+    b['fleet'] = b.groupby(['month', 'bin'])['med'].transform('median')
+    b['dev'] = b['med'] - b['fleet']
+    g = b.groupby(['month', 'turbine'])['dev'].agg(['median', 'size']).reset_index()
+    g = g[g['size'] >= MIN_RUN_BINS].rename(columns={'median': 'zero_dev_run', 'size': 'run_bins'})
+    return g
+
+
+def zero_monthly(raw: pd.DataFrame, rdev: pd.DataFrame) -> tuple[pd.DataFrame, list]:
+    """逐月原料 → 三口径分列 (相对**同月全场中位基准**的偏差)。返回 (表, 被剔记录)。"""
+    rejected = []
+    if not len(raw):
+        return pd.DataFrame(columns=ZERO_COLS), rejected
+    df = raw.copy()
+    df['stop_ok'] = (df['n_stop'] >= MIN_STOP_MIN) & df['stop_med'].between(*FEATHER_BAND)
+    df['full_ok'] = df['n_full'] >= MIN_STOP_MIN
+    bad = df[(df['n_stop'] >= MIN_STOP_MIN) & ~df['stop_ok']]
+    for _, r in bad.iterrows():
+        rejected.append(dict(month=r['month'], turbine=r['turbine'], why='停机段中位不在顺桨带',
+                             stop_med=round(float(r['stop_med']), 3), n_stop=int(r['n_stop'])))
+    short = df[df['n_stop'] < MIN_STOP_MIN]
+    n_short = int(len(short))
+    if n_short:
+        rejected.append(dict(why='台月顺桨停机分钟不足 (不出值)',
+                             min_minutes=MIN_STOP_MIN, n_turbine_months=n_short,
+                             example=[dict(month=r['month'], turbine=r['turbine'], n_stop=int(r['n_stop']),
+                                           n_stop_all=int(r['n_stop_all'])) for _, r in short.head(5).iterrows()]))
+    base_stop = df[df['stop_ok']].groupby('month')['stop_med'].agg(['median', 'size'])
+    base_full = df[df['full_ok']].groupby('month')['full_med'].agg(['median', 'size'])
+    ok_months = set(base_stop[base_stop['size'] >= MIN_BASE_TURBINES].index) & \
+        set(base_full[base_full['size'] >= MIN_BASE_TURBINES].index)
+    for m in sorted(set(df['month']) - ok_months):
+        rejected.append(dict(month=m, why='同月有效台不足或无基准', min_base=MIN_BASE_TURBINES))
+    df = df[df['month'].isin(ok_months) & df['stop_ok'] & df['full_ok']].copy()
+    if not len(df):
+        return pd.DataFrame(columns=ZERO_COLS), rejected
+    df['base_stop'] = df['month'].map(base_stop['median'])
+    df['base_full'] = df['month'].map(base_full['median'])
+    df['zero_dev_stop'] = (df['stop_med'] - df['base_stop']).round(4)
+    df['zero_dev_full'] = (df['full_med'] - df['base_full']).round(4)
+    df['zero_dev'] = df['zero_dev_stop']                 # 消费者读这一列 (口径=停机段, 见 docstring)
+    df['n_rows'] = df['n_stop']                          # 旧件同名列: 样本数
+    if len(rdev):                                        # 第三口径 (运行段分档): 可比档不足 ⇒ NaN (不造数)
+        df = df.merge(rdev, on=['month', 'turbine'], how='left')
+        df['zero_dev_run'] = df['zero_dev_run'].round(4)
+    for c in ('zero_dev_run', 'run_bins', 'run_med_bin'):
+        if c not in df.columns:
+            df[c] = np.nan
+    df = df[ZERO_COLS].sort_values(['turbine', 'month']).reset_index(drop=True)
+    return df, rejected
+
+
+def check_map(cfg, n: int = 4) -> int:
+    """复核 1min 台号 → WTG 台号 的映射 (功率对拍)。返回 rc。"""
+    csv10, csv1 = _paths(cfg)
+    codes = sorted(f.stem for f in csv1.glob('*.csv'))
+    picks = [c for c in codes if turbine_of_1min(c) in ('WTG01', 'WTG02', 'WTG19', 'WTG38')][:n]
+    ok = 0
+    print(f'映射复核 (1min 台号 → 场台号): 抽 {len(picks)} 组, 判据 = 10min 功率与 1min 功率相关系数 > 0.9')
+    for code in picks:
+        wtg = turbine_of_1min(code)
+        a = read_1min(code, cfg)
+        a['ts'] = pd.to_datetime(a[C1['ts']], errors='coerce').dt.floor('10min')
+        g = _num(a[C1['pw']]).groupby(a['ts']).mean().rename('p1')
+        b = SS.load('10min', wtg, columns=[TS10, 'grd_wtc_ActPower_mean'], cfg=cfg)
+        b['ts'] = pd.to_datetime(b[TS10], errors='coerce')
+        m = pd.merge(g.reset_index(), b.rename(columns={'grd_wtc_ActPower_mean': 'p10'}), on='ts').dropna()
+        r = float(np.corrcoef(m['p1'], _num(m['p10']))[0, 1]) if len(m) > 100 else float('nan')
+        good = r > 0.9
+        ok += good
+        print(f'  [{"OK" if good else "X "}] {code}.csv → {wtg}: 重叠 {len(m)} 段  r={r:.4f}  '
+              f'1min 均值 {m["p1"].mean():.1f} kW / 10min 均值 {_num(m["p10"]).mean():.1f} kW')
+    return 0 if ok == len(picks) and picks else 1
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='变桨面生成端 (pitch_daily + pitch_zero_monthly)')
+    ap.add_argument('--farm', default='rudong')
+    ap.add_argument('--turbines', default=None, help='逗号分隔, 只算这几台 (调试)')
+    ap.add_argument('--dry-run', action='store_true', help='只报源件/口径, 不写盘')
+    ap.add_argument('--check-map', action='store_true', help='复核 1min 台号 → WTG 台号 映射')
+    args = ap.parse_args()
+
+    cfg = farm(args.farm)
+    if args.check_map:
+        return check_map(cfg)
+
+    rated = float(cfg.get('rated_kw') or 4000)
+    csv10, csv1 = _paths(cfg)
+    store, pitch_dir = P.store(args.farm), P.pitch(args.farm)
+    turb_cfg = [f'WTG{i:02d}' for i in range(1, int(cfg.get('n_turbines') or 38) + 1)]
+    want = [t.strip() for t in args.turbines.split(',')] if args.turbines else turb_cfg
+    print(f'变桨面生成端 · 场={args.farm} 额定={rated:.0f}kW 台数={len(want)}')
+    print(f'  10min 源: {csv10}  ({len(list(csv10.glob("*.csv")))} 件 CSV, MDB 回落见 scada_source)')
+    print(f'  1min  源: {csv1}  ({len(list(csv1.glob("*.csv")))} 件 CSV)')
+    code_of = {}
+    for f in sorted(csv1.glob('*.csv')):
+        t = turbine_of_1min(f.stem)
+        if t:
+            code_of[t] = f.stem
+
+    daily, counts, zeros, zbins, skipped, t0 = [], [], [], [], [], time.time()
+    for i, wtg in enumerate(want, 1):
+        try:
+            d10 = read_10min(wtg, cfg)
+        except Exception as e:                                  # 该台源件缺失: 如实记, 不造数
+            skipped.append(dict(turbine=wtg, why=f'10min 取数失败: {type(e).__name__}: {e}'[:200]))
+            print(f'  [{i:2d}/{len(want)}] {wtg}: 跳过 — {skipped[-1]["why"][:80]}', flush=True)
+            continue
+        if len(d10):
+            daily.append(daily_of(wtg, d10))
+        for c in (d10.attrs.get('pitch_missing') or []):
+            skipped.append(dict(turbine=wtg, why=f'10min 缺列 {c}'))
+        code = code_of.get(wtg)
+        n1 = 0
+        if code:
+            try:
+                d1 = read_1min(code, cfg)
+                day1, z1, b1_ = minute_counts_of(wtg, d1, rated)
+                n1 = len(d1)
+                counts.append(day1)
+                zeros.append(z1)
+                if len(b1_):
+                    zbins.append(b1_)
+            except Exception as e:
+                skipped.append(dict(turbine=wtg, why=f'1min ({code}) 取数失败: {type(e).__name__}: {e}'[:200]))
+        else:
+            skipped.append(dict(turbine=wtg, why='1min 无该台导出件 (越线计数/零位三口径按缺)'))
+        print(f'  [{i:2d}/{len(want)}] {wtg}: 10min {len(d10):6d} 行'
+              + (f' · 1min {n1:7d} 行 ({code})' if code else ' · 1min 缺件')
+              + f'  已用 {time.time() - t0:.0f}s', flush=True)
+
+    if not daily:
+        print('[X] 一件日粒度都没算出来 —— 先放原始件 (scripts/place_raw_data.py) 再跑')
+        return 4
+    dl = pd.concat(daily, ignore_index=True)
+    dl['date'] = pd.to_datetime(dl['date'])
+    if counts:                                  # 越卸荷/跌启泵计数来自 1min 侧: 按 (台, 日) 左连接
+        cc = pd.concat(counts, ignore_index=True)
+        cc['date'] = pd.to_datetime(cc['date'])
+        dl = dl.merge(cc, on=['turbine', 'date'], how='left')
+    for c in DAILY_COLS:
+        if c not in dl.columns:
+            dl[c] = np.nan
+    dl['n'] = dl['n'].fillna(0).astype('int64')
+    dl['n_op'] = dl['n_op'].fillna(0).astype('int64')
+    dl = dl[DAILY_COLS].sort_values(['turbine', 'date']).reset_index(drop=True)
+    zr = pd.concat(zeros, ignore_index=True) if zeros else pd.DataFrame()
+    rb_all = pd.concat(zbins, ignore_index=True) if zbins else pd.DataFrame()
+    zm, rejected = zero_monthly(zr, run_dev(rb_all))
+
+    span = (str(dl['date'].min().date()), str(dl['date'].max().date())) if len(dl) else ('', '')
+    print(f'\n日粒度 {len(dl)} 行 · 台 {dl["turbine"].nunique()} · 跨度 {span[0]} → {span[1]}')
+    print(f'零位月表 {len(zm)} 行 · 台 {zm["turbine"].nunique() if len(zm) else 0} · '
+          f'月 {sorted(zm["month"].unique())[:3]}…{sorted(zm["month"].unique())[-2:] if len(zm) else ""}')
+    if len(zm):
+        idx = zm.set_index(['turbine', 'month'])
+        for t in ('WTG19', 'WTG31', 'WTG01'):
+            if t not in idx.index.get_level_values(0):
+                continue
+            r = idx.loc[t]
+            print(f'  锚点 {t}: 停机顺桨段 {r["zero_dev_stop"].median():+.3f}° · 满发段 {r["zero_dev_full"].median():+.3f}° · '
+                  f'运行段分档 {r["zero_dev_run"].median():+.3f}° (可比档中位 {r["run_bins"].median():.0f})')
+        for c, tag in (('zero_dev', '停机顺桨段'), ('zero_dev_full', '满发段'), ('zero_dev_run', '运行段分档')):
+            s = zm[c].dropna()
+            if len(s):
+                print(f'  口径 {tag:8s}: |偏差| 中位 {s.abs().median():.3f}° · P95 {s.abs().quantile(.95):.3f}° · '
+                      f'最大 {s.abs().max():.3f}° · 有值 {len(s)} 台月')
+    if skipped:
+        print(f'  跳过/缺件 {len(skipped)} 条: ' + '; '.join(f'{s["turbine"]} {s["why"][:40]}' for s in skipped[:3])
+              + (' …' if len(skipped) > 3 else ''))
+    if rejected:
+        print(f'  未出值(口径门) {len(rejected)} 条: '
+              + '; '.join(f'{r.get("month", "—")} {r.get("turbine", "")} {r["why"]}' for r in rejected[:3])
+              + (' …' if len(rejected) > 3 else ''))
+    if args.dry_run:
+        print('\n[dry-run] 不写盘。')
+        return 0
+
+    pitch_dir.mkdir(parents=True, exist_ok=True)
+    f_daily, f_zero = pitch_dir / 'pitch_daily.parquet', pitch_dir / 'pitch_zero_monthly.parquet'
+    dl.to_parquet(f_daily, index=False)
+    zm.to_parquet(f_zero, index=False)
+    man = dict(
+        meta=dict(built=time.strftime('%Y-%m-%d %H:%M:%S'), farm=args.farm, by='scripts/pitch_face_build.py',
+                  version='pitch-face-v1', turbines=int(dl['turbine'].nunique()),
+                  span=dict(from_=span[0], to=span[1]), rows=dict(daily=int(len(dl)), zero_monthly=int(len(zm)))),
+        口径=dict(
+            n='当日 10min 记录数 (×600 = 应测秒)', n_op='当日运行段数 (flg_wtc_ScInOper_endvalue==1)',
+            hyd_mean='当日 HydPress_mean 均值 (bar)', hyd_band='当日运行段段内 (max−min) 中位 (bar)',
+            hyd_over_relief=f'当日运行分钟中 压力 ≥ {HYDR_UNLOAD_BAR:.0f}bar 的分钟数',
+            hyd_under_pump=f'当日运行分钟中 压力 ≤ {HYDR_PUMP_BAR:.0f}bar 的分钟数',
+            pistA_B_C='当日 LubPist{A,B,C}_counts 求和 (三叶柱塞动作次数)',
+            zero_dev=f'顺桨停机段中位 − 同月全场中位基准 (停机=转速<{STOP_RPM}rpm ∧ |有功|<{STOP_KW:.0f}kW '
+                     f'∧ 桨距角中位 ∈ 顺桨带 {FEATHER_BAND[0]:.0f}~{FEATHER_BAND[1]:.0f}°)',
+            zero_dev_full=f'满发段中位 − 同月全场中位基准 (满发=有功≥{FULL_FRAC:.0%}额定={FULL_FRAC * rated:.0f}kW)',
+            zero_dev_run=f'运行段按有功 {RUN_BIN_KW:.0f}kW 分档, 逐档比同月全场同档中位, 再取跨档中位 '
+                         f'(判据轴; 运行段=转速≥{STOP_RPM}rpm ∧ 有功 {RUN_MIN_KW:.0f}~{FULL_FRAC * rated:.0f}kW; '
+                         f'可比档 ≥{MIN_RUN_BINS} 档、单档 ≥{MIN_RUN_BIN_N} 分钟)'),
+        阈值=dict(卸荷阈bar=HYDR_UNLOAD_BAR, 启泵阈bar=HYDR_PUMP_BAR, 停机转速rpm=STOP_RPM, 停机功率kW=STOP_KW,
+                  满发门kW=FULL_FRAC * rated, 顺桨带=list(FEATHER_BAND),
+                  停机段最少分钟=MIN_STOP_MIN, 同月基准最少台=MIN_BASE_TURBINES,
+                  运行段下界kW=RUN_MIN_KW, 工况档宽kW=RUN_BIN_KW, 单档最少分钟=MIN_RUN_BIN_N,
+                  最少可比档=MIN_RUN_BINS),
+        证据=dict(
+            压力锯齿='10min 段内 min 中位 194bar / max 中位 245-246bar; 1min 运行分钟 P5=195 / P95=240',
+            顺桨位='顺桨停机分钟三叶桨距角中位 88.6° (fleet, ±0.7°); 停机分钟双峰(另一支 ≈31.8° 未顺桨) '
+                   '⇒ 零位基准只取顺桨带内那一支',
+            台号映射='1min 内部台号数字前缀 = 场台号 (4 组功率对拍 r=0.982~0.986); 复核: --check-map',
+            与旧随包件='旧件 n_op 口径未知(WTG01 2025-01-01: 旧 48 / 本器 61), 故只作量级对拍不逐值验收; '
+                       '旧件 zero_dev 只有 19# 是离群(全期 −0.896°, 其余台 ≈0) ⇒ 其口径是"绝对零位偏差", '
+                       '与本器的"相对同月全场中位"不同源, 故零位一族不做逐值对拍',
+            零位三口径='2026-09-19 反推: 停机顺桨段把 19# 读成 +0.11°(机械止挡抹平差异), 满发段 19# +0.61° '
+                       '且台间离散大; 运行段分档口径能把 19# 单独拎出(逐月持续为负, 其余台 ≈0) —— '
+                       '与现场「19# 零位 −0.87° 专项确诊」定性一致, 故判据轴取 running 口径'),
+        来源=dict(_10min='csv 优先 / mdb 回落 (src/windscada/scada_source.py)',
+                  _1min='csv 优先 / mdb 回落', inputs=[str(csv10), str(csv1)]),
+        跳过=skipped, 未出值=rejected)
+    f_man = pitch_dir / 'pitch_face_manifest.json'
+    f_man.write_text(json.dumps(man, ensure_ascii=False, indent=1), encoding='utf-8')
+    # ★自登记台账的落点与键 (2026-09-19 实逮): 台账正本是 `P.out_root(farm)/_derived_manifest.json`
+    #   (与其它 8 个生成端一致), 键必须是**相对产物仓根**的路径。此处若写成 P.store()/P.rel() 则会
+    #   另建一本 `windscada/_derived_manifest.json` 且键带 `outputs/rudong/` 前缀 —— 台账读不到,
+    #   反向呼应审计还会把那本杂账报成"未归类 1 件"(实测 rc=5)。
+    out_root = P.out_root(args.farm)
+    dm_record(out_root, {(pitch_dir / n).relative_to(out_root).as_posix(): note for n, note in (
+        (f_daily.name, 'scripts/pitch_face_build.py (10min 液压/润滑/柱塞 + 1min 越线计数 → 日粒度)'),
+        (f_zero.name, 'scripts/pitch_face_build.py (1min 三叶桨距角 → 停机顺桨段/满发段/运行段分档 三口径零位)'),
+        (f_man.name, 'scripts/pitch_face_build.py (口径/阈值/证据/跳过清单)'))},
+        by='scripts/pitch_face_build.py')
+    print(f'\n[OK] 已写 {P.rel(f_daily)} ({f_daily.stat().st_size / 1024:.0f} KB) · '
+          f'{P.rel(f_zero)} ({f_zero.stat().st_size / 1024:.0f} KB) · {P.rel(f_man)} · 已自登记')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 26 - 4
scripts/products_reverse_audit.py

@@ -163,6 +163,21 @@ FAMILIES: list[dict] = [
          why='★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这件此前全库 0 处写入方(振动线人写), '
          why='★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这件此前全库 0 处写入方(振动线人写), '
              '新机器装完 `/detail/v2` 的「需要关注/全场状态」必然空白。现由观澜自算出同结构件; '
              '新机器装完 `/detail/v2` 的「需要关注/全场状态」必然空白。现由观澜自算出同结构件; '
              '盘上若有**振动线正本**, 生成端不覆盖(正本含人工裁决, 优先)'),
              '盘上若有**振动线正本**, 生成端不覆盖(正本含人工裁决, 优先)'),
+    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 这两件此前 kind=shipped(振动线人交手交件),
+    #   新机器上 ⑦ 的 `src.ontology.trend_ingest` 报"源缺失"、CMS 报告的三层基线卡为空。
+    #   现由两个生成端从**窗索引**算: component_history(逐窗标量 x_self → 在升/换件闭环; 窗不足时如实为空
+    #   并把数据边界逐条写进 meta) / baseline_38(健康期分段中位 → 自基线 + fleet 基线 + ISO 绝对限)。
+    #   ★必须排在通配族 `vib_handoff_and_scans` **之前** (族表首个命中生效), 否则又被认领成"无生成端"。
+    dict(id='vib_history_baseline',
+         glob=['m5_cms_tcm/component_history.json', 'm5_cms_tcm/baseline_38.json'],
+         kind='raw-derived',
+         func='振动在升/换件闭环历史 + 三层基线(自 / 机群 / 绝对)',
+         algo='scripts/component_history_build.py(逐窗标量 x_self → 末三窗/早三窗在升 + 换件前后闭环; '
+              '窗不足时判据不可算则如实空表)+ scripts/baseline_38_build.py(健康期分段中位 + 正常带 + '
+              'relMAD → 自基线, 机群同段中位 → fleet 基线, ISO 20816-3 → 绝对限)',
+         gen='scripts/component_history_build.py', input='windcms', pred=('rows',),
+         why='输入是 CMS 原始导出的**重算产物**(窗索引), 其 raw 祖先是 data/raw/<场>/windcms; '
+             '旧随包件是振动线手交件(全库 0 处写入方); 两件都在重算链 ④d/④e, 口径与数据边界写进件内 meta'),
     dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
     dict(id='vib_handoff_and_scans', glob='m5_cms_tcm/*.{json,parquet,csv,md,txt}',
          kind='shipped',
          kind='shipped',
          func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
          func='振动线出件与专项扫描(handoff / 历史 / 基线 / 各类 freq scan / 判级台账)', algo='(无生成端)',
@@ -247,10 +262,17 @@ FAMILIES: list[dict] = [
     dict(id='paradigm_r1', glob='paradigm_r1/**', kind='shipped',
     dict(id='paradigm_r1', glob='paradigm_r1/**', kind='shipped',
          func='范式实验件(E3/E5/E8 底稿)', algo='(无生成端)', gen=None, input=None, pred=(),
          func='范式实验件(E3/E5/E8 底稿)', algo='(无生成端)', gen=None, input=None, pred=(),
          why='实验底稿, 事实契约的输入'),
          why='实验底稿, 事实契约的输入'),
-    dict(id='pitch_shipped', glob='pitch/**', kind='shipped',
-         func='变桨侧派生件(零位/日粒度)', algo='(无生成端;rebuild_from_raw --scada 只覆盖其中一部分)',
-         gen=None, input=None, pred=(), why='无生成端 ⇒ 运行期不从交付包补(用户令 2026-09-17), '
-                                            '页面降级; 要出件须研发补生成端'),
+    # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」: 变桨面两件原来 kind=shipped(无生成端)
+    #   ⇒ 换台机器重算后必然缺件、变桨面恒"不可判"。现由 scripts/pitch_face_build.py 从 data/raw 的
+    #   scada_10min(液压/润滑/柱塞/运行状态) + scada_1min(三叶桨距角/压力/有功) 算出, 并进重算链 ④c。
+    dict(id='pitch_face', glob='pitch/**', kind='raw-derived',
+         func='变桨侧派生件(日粒度液压/润滑/柱塞 + 零位三口径月表)',
+         algo='scripts/pitch_face_build.py(10min 开关量 timeon/压力锯齿 + 1min 停机顺桨段/满发段/运行段分档 '
+              '三叶桨距角中位 → 相对同月全场中位的零位偏差;口径/阈值/证据见同目录 pitch_face_manifest.json)',
+         gen='scripts/pitch_face_build.py', input='scada_10min', pred=('rows',),
+         why='输入是 10min 与 1min 两路 SCADA 原始导出件(scada_source: CSV 优先/MDB 回落);'
+             '零位按用户令两口径分列(停机顺桨段/满发段),并据实测反推补上**运行段同工况分档**这一判据轴'
+             '(它才能把现场确诊的 19# 单独拎出来)'),
     # (2026-09-19: `ontology_releases` 已移到前面按 raw-derived 记账 —— 现在由
     # (2026-09-19: `ontology_releases` 已移到前面按 raw-derived 记账 —— 现在由
     #  scripts/ontology_release_build.py 从对象库投影生成, 不再是"由研发出件"的随包层)
     #  scripts/ontology_release_build.py 从对象库投影生成, 不再是"由研发出件"的随包层)
     # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」+「从重算台账生成 claim」:
     # ★2026-09-19 用户令「所有的计算均要形成观澜的源代码」+「从重算台账生成 claim」:

+ 26 - 0
scripts/rebuild_all.py

@@ -13,6 +13,9 @@ r"""一条命令重算全部 (2026-09-12) —— 把散在各脚本里的重算
     ② 三门台账    rebuild_from_raw.py                 报警/工单/油样
     ② 三门台账    rebuild_from_raw.py                 报警/工单/油样
     ③ SCADA 侧    rebuild_from_raw.py --scada         10 个构建器, 逐台读 ~14 GB, 约 15 分钟
     ③ SCADA 侧    rebuild_from_raw.py --scada         10 个构建器, 逐台读 ~14 GB, 约 15 分钟
     ④ 月度派生件  windscada_monthly_build.py          能从 raw 重算的月表(与随包件逐值对齐才落盘)
     ④ 月度派生件  windscada_monthly_build.py          能从 raw 重算的月表(与随包件逐值对齐才落盘)
+    ④b 振动侧     vib_raw_build.py                     原始导出 → 窗索引/谱/逐台页/融合 z + CMS 报告
+    ④c 变桨面     pitch_face_build.py                  10min 液压/润滑/柱塞 + 1min 零位三口径 → pitch/**
+    ④d/④e 振动历史与基线 component_history_build.py / baseline_38_build.py  窗索引 → 在升闭环 + 三层基线
     ⑤a 台账维护   products_restore_missing.py --refresh  账实相符(谁在位、谁有生成端); 只记账不搬件
     ⑤a 台账维护   products_restore_missing.py --refresh  账实相符(谁在位、谁有生成端); 只记账不搬件
     ⑤ 反向呼应审计 products_reverse_audit.py --check   逐件回溯"输出→生成端→输入"(用户令 2026-09-17 #1: 运行期不从交付包补齐)
     ⑤ 反向呼应审计 products_reverse_audit.py --check   逐件回溯"输出→生成端→输入"(用户令 2026-09-17 #1: 运行期不从交付包补齐)
        ↑ 必须在 ⑥ 之前: 本体层的 populate/chain_ingest 要吃这些 L1 产物
        ↑ 必须在 ⑥ 之前: 本体层的 populate/chain_ingest 要吃这些 L1 产物
@@ -78,6 +81,29 @@ def build_plan(a) -> list:
         plan.append(step_cmd('④b 振动侧摄入 + CMS 报告 + 标量 z (原始导出 → 窗索引/谱/逐台页/融合 z)',
         plan.append(step_cmd('④b 振动侧摄入 + CMS 报告 + 标量 z (原始导出 → 窗索引/谱/逐台页/融合 z)',
                              [PY, 'scripts/vib_raw_build.py'] + ([] if a.vib_report else ['--no-report']),
                              [PY, 'scripts/vib_raw_build.py'] + ([] if a.vib_report else ['--no-report']),
                              note='没有振动原始件时空跑属正常; 若解析出错会以非零退出 (不静默)'))
                              note='没有振动原始件时空跑属正常; 若解析出错会以非零退出 (不静默)'))
+    if not a.skip_scada:
+        # ④c 变桨面 (用户令 2026-09-19「所有的计算 均要形成 观澜的源代码」): `pitch/**` 原来 kind=shipped、
+        #    无生成端 ⇒ 换台机器重算后变桨面恒"缺件 ⇒ 不可判"。现在由 scripts/pitch_face_build.py 从
+        #    scada_10min(开关量 timeon/压力锯齿/运行状态) + scada_1min(三叶桨距角/压力/有功) 算出, 约 10 分钟
+        #    (逐台读 1min 全量 ≈ 14 GB)。零位两口径 (停机顺桨段 / 满发段) 按用户令分列, 口径与证据写进
+        #    pitch/pitch_face_manifest.json。
+        #    与 ③ 同一档门槛: 现场没放 SCADA 原始件时它按缺件如实退出 4 (不假装算过) ⇒ 容忍 4 并写清原因。
+        plan.append(step_cmd('④c 变桨面 (10min 液压/润滑 + 1min 零位三口径)',
+                             [PY, 'scripts/pitch_face_build.py'], tolerate=(4,),
+                             note='rc=4 = data/raw 下找不到 SCADA 原始件 ⇒ 变桨面按缺件如实报 (不是失败); '
+                                  '放件后重跑本步即得 (scripts/place_raw_data.py)'))
+    if not a.skip_vib:
+        # ④d/④e 振动历史与基线 (用户令 2026-09-19「所有的计算 均要形成 观澜的源代码」): 这两件此前是振动线
+        #    手交的随包快照(无生成端) ⇒ 新机器上 ⑦ 的 trend_ingest 报"源缺失"、CMS 报告的三层基线卡为空。
+        #    现在由两个生成端从**窗索引**算: component_history(逐窗标量 x_self → 在升/换件闭环, 窗不足时
+        #    如实为空并写明边界) / baseline_38(健康期分段中位 → 自基线 + fleet 基线 + ISO 绝对限)。
+        #    ★要排在 ④b 之后 (读窗索引), 且在 ⑦ trend_ingest 之前。
+        plan.append(step_cmd('④d 振动在升/换件闭环 (窗索引 → component_history.json)',
+                             [PY, 'scripts/component_history_build.py'], tolerate=(4,),
+                             note='rc=4 = 本机没有振动窗索引 (没放 CMS 原始导出) ⇒ 本件按缺件如实报, 不是失败'))
+        plan.append(step_cmd('④e 三层基线 (窗索引 → baseline_38.json)',
+                             [PY, 'scripts/baseline_38_build.py'], tolerate=(4,),
+                             note='rc=4 = 本机没有振动窗索引 ⇒ 本件按缺件如实报, 不是失败'))
     # ⑤b 事实契约的 claim 生成 (用户令 2026-09-19「要让观澜从重算台账生成 claim」):
     # ⑤b 事实契约的 claim 生成 (用户令 2026-09-19「要让观澜从重算台账生成 claim」):
     #     由重算产物(alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6/fusion)算 claim,
     #     由重算产物(alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6/fusion)算 claim,
     #     写成 sop/findings.json + paradigm_r1 三份底稿(与契约构建器同结构) ⇒ 契约/门户/问答三处随重算刷新。
     #     写成 sop/findings.json + paradigm_r1 三份底稿(与契约构建器同结构) ⇒ 契约/门户/问答三处随重算刷新。

+ 9 - 5
scripts/windscada_serve.py

@@ -515,11 +515,15 @@ def _gap_page(path: str, missing: 'pathlib.Path | None' = None, what: str = '这
             '<code>windscada/index.html</code> …): 放好 '
             '<code>windscada/index.html</code> …): 放好 '
             '<code>data/raw/&lt;场站&gt;/</code> 后跑 <code>python scripts/rebuild_all.py</code>, '
             '<code>data/raw/&lt;场站&gt;/</code> 后跑 <code>python scripts/rebuild_all.py</code>, '
             '或点运维控制台的「数据重算」。</li>'
             '或点运维控制台的「数据重算」。</li>'
-            '<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>'
+            '<li><b>仍缺生成端的那几族</b>(如 <code>sop/</code>、<code>paradigm_r1/</code> 里的人工底稿、'
+            '<code>windcms/</code> 的厂家报告转录): 按用户令 2026-09-19「所有的计算均要形成观澜的源代码」'
+            '正在逐族补(清单见 <code>docs/源代码化清单_v0.1.md</code>); 在此之前页面如实显示缺件, '
+            '且按用户令 2026-09-17 <b>运行期不从交付包补齐</b>。'
+            '(2026-09-19 已补上生成端的: 变桨面 <code>pitch/**</code> ← <code>scripts/pitch_face_build.py</code>; '
+            '振动在升/闭环 <code>m5_cms_tcm/component_history.json</code>、三层基线 <code>baseline_38.json</code> '
+            '← <code>scripts/component_history_build.py</code> / <code>scripts/baseline_38_build.py</code>; '
+            '事实契约 <code>guanlan/**</code> ← 重算台账生成 claim; '
+            '发布层 <code>ontology/release_r1|r2</code> ← <code>scripts/ontology_release_build.py</code>。)</li></ul>'
             '<p class="s">逐件来源见 <code>outputs/&lt;场站&gt;/_provenance.json</code>;'
             '<p class="s">逐件来源见 <code>outputs/&lt;场站&gt;/_provenance.json</code>;'
             '缺口清单见 <code>docs/系统设计说明.md</code> §7 与 docs/重算缺口与补件清单_v0.1.md。</p>'
             '缺口清单见 <code>docs/系统设计说明.md</code> §7 与 docs/重算缺口与补件清单_v0.1.md。</p>'
             '<p><a href="/v2" target="_top">← 返回工作台</a> · '
             '<p><a href="/v2" target="_top">← 返回工作台</a> · '

+ 12 - 5
src/version.py

@@ -50,8 +50,9 @@ BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/
 #   旧编号体系(0.x,未按本规则),仅作历史对账用。
 #   旧编号体系(0.x,未按本规则),仅作历史对账用。
 HISTORY: tuple[dict, ...] = (
 HISTORY: tuple[dict, ...] = (
     dict(version='2.6.0', date='2026-09-19', level='minor',
     dict(version='2.6.0', date='2026-09-19', level='minor',
-         title='「所有的计算均要形成观澜的源代码」:融合面/总览页/事实契约/发布层/掩码阈值/六层链两步 '
-               '全部落成观澜自算;SCADA 接入兼容 CSV+MDB;交付包带输入数据目录结构(不含文件)',
+         title='「所有的计算均要形成观澜的源代码」:融合面/总览页/事实契约/发布层/掩码阈值/六层链两步/'
+               '变桨面/振动在升与三层基线 全部落成观澜自算;SCADA 接入兼容 CSV+MDB;'
+               '交付包带输入数据目录结构(不含文件)',
          note='功能新增为主、夹带多项消缺 ⇒ 按规则取最高一级(minor)。要点: '
          note='功能新增为主、夹带多项消缺 ⇒ 按规则取最高一级(minor)。要点: '
               '① 六层链 model_run/fusion 两步按口径重建(报告的"融合级"列从此有值)+ energy_share 步补上 + '
               '① 六层链 model_run/fusion 两步按口径重建(报告的"融合级"列从此有值)+ energy_share 步补上 + '
               '峰值拾取口径定案(观澜口径,四件 *_freq_scan 形式不再复刻); '
               '峰值拾取口径定案(观澜口径,四件 *_freq_scan 形式不再复刻); '
@@ -59,9 +60,15 @@ HISTORY: tuple[dict, ...] = (
               '新机器上 /detail/v2 的"需要关注/全场状态"必空白); '
               '新机器上 /detail/v2 的"需要关注/全场状态"必空白); '
               '③ 事实契约改为**从重算台账生成 claim**(门户结论段/问答/报告随重算刷新,/api/facts 不再 503); '
               '③ 事实契约改为**从重算台账生成 claim**(门户结论段/问答/报告随重算刷新,/api/facts 不再 503); '
               '④ 本体发布层 r1/r2、TCM 掩码阈值(观澜自算口径,非厂商)分别落成生成端; '
               '④ 本体发布层 r1/r2、TCM 掩码阈值(观澜自算口径,非厂商)分别落成生成端; '
-              '⑤ SCADA 接入统一取数层兼容 CSV 与 MDB(含老库列位错位如实拒绝); '
-              '⑥ 打包:**带输入数据目录结构**(30 个空目录, 含 data/raw/西门子4.0技术资料)+ 放置说明文件随包, '
-              '仍不含数据文件; ⑦ 消缺: report 步踩可选包 tabulate 致整链中断、语言包闸拦下的 /v2 500、'
+              '⑤ SCADA 接入统一取数层兼容 CSV 与 MDB(含老库列位错位如实拒绝;37 个月库按修好的建库脚本'
+              '重建并逐分片对拍); '
+              '⑥ 变桨面 pitch/** 落成生成端(10min 开关量/压力锯齿 + 1min 桨距角;零位口径按用户令'
+              '**停机段/满发段分列**,并据实测反推补上**运行段同工况分档**这一真正的判据轴 —— 它能把'
+              '现场确诊的 19# 单独拎出来); '
+              '⑦ 振动 component_history.json(在升/换件闭环)与 baseline_38.json(自/机群/绝对三层基线)'
+              '落成生成端(窗数不足时如实空表并写明数据边界); '
+              '⑧ 打包:**带输入数据目录结构**(30 个空目录, 含 data/raw/西门子4.0技术资料)+ 放置说明文件随包, '
+              '仍不含数据文件; ⑨ 消缺: report 步踩可选包 tabulate 致整链中断、语言包闸拦下的 /v2 500、'
               '振动页看不出数据区间与"3 月数据消失"(趋势按日聚合)、账目单位错(kWh/MW)、'
               '振动页看不出数据区间与"3 月数据消失"(趋势按日聚合)、账目单位错(kWh/MW)、'
               'Access 锁文件误报、日志保留策略等。交付包 app_guanlang_v2.6.0.zip'),
               'Access 锁文件误报、日志保留策略等。交付包 app_guanlang_v2.6.0.zip'),
     dict(version='2.5.0', date='2026-09-17', level='minor',
     dict(version='2.5.0', date='2026-09-17', level='minor',

+ 3 - 0
src/windscada/i18n_en.py

@@ -265,6 +265,9 @@ TERMS.update({
     '发生': 'occurrence', '平均': 'mean', '根因': 'root cause', '不作': 'not used as',
     '发生': 'occurrence', '平均': 'mean', '根因': 'root cause', '不作': 'not used as',
     '顶部': 'top', '特性': 'characteristic', '期望': 'expected', '佐证': 'corroborating',
     '顶部': 'top', '特性': 'characteristic', '期望': 'expected', '佐证': 'corroborating',
     '满发': 'full load', '合计': 'total', '统计': 'statistics', '万元': ' k CNY',
     '满发': 'full load', '合计': 'total', '统计': 'statistics', '万元': ' k CNY',
+    # 2026-09-19 变桨面零位两口径 (用户令「停机段中位、满发段中位两者分列出」) 的用词
+    '满发段': 'full-load segment', '顺桨停机段': 'feathered stop segment', '顺桨段': 'feathered segment',
+    '顺桨': 'feathered', '并列参考': 'for reference',
     '天气': 'weather', '对象': 'object', '读纪律': 'reading discipline', '分支': 'branch',
     '天气': 'weather', '对象': 'object', '读纪律': 'reading discipline', '分支': 'branch',
     '失效': 'failure', '全程': 'full window', '入口': 'entry point', '不同': 'different',
     '失效': 'failure', '全程': 'full window', '入口': 'entry point', '不同': 'different',
     '转录': 'transcribed', '强度': 'severity', '管理': 'management', '行动': 'action',
     '转录': 'transcribed', '强度': 'severity', '管理': 'management', '行动': 'action',

+ 49 - 13
src/windscada/subsys/pitch.py

@@ -1,6 +1,14 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 """变桨系统四维度判级 (v1, 2026-08-24). 判据规范出处: 变桨分册 V2.0 (四维度/§3.2归并) + 零位专项 20260821 口径.
 """变桨系统四维度判级 (v1, 2026-08-24). 判据规范出处: 变桨分册 V2.0 (四维度/§3.2归并) + 零位专项 20260821 口径.
-全部确定性; 模型无判级通道. 阈值: 分册场标定优先, 无者 fleet 相对 [暂行标注]. 数据止 2026-06-30 (边界)."""
+全部确定性; 模型无判级通道. 阈值: 分册场标定优先, 无者 fleet 相对 [暂行标注]. 数据止 2026-06-30 (边界).
+
+★2026-09-19 (用户令「所有的计算均要形成观澜的源代码」): 本面的输入 `pitch/pitch_daily.parquet` 与
+`pitch/pitch_zero_monthly.parquet` 过去是**随包快照**(无生成端) ⇒ 换台机器重算后必然缺件、本面恒"不可判"。
+现在由 `scripts/pitch_face_build.py`(重算链第 ④c 步)从 `data/raw` 的 `scada_10min` + `scada_1min` 算出,
+口径与证据写在同目录 `pitch_face_manifest.json`。零位轴按用户令**分列**: `zero_dev`(停机顺桨段, 机械止挡位)、
+`zero_dev_full`(满发段, 并列参考)、`zero_dev_run`(运行段同工况分档 = 零位偏差的行业口径, 实测能把 19# 拎出来);
+D3 判据按"停机段与运行段**取更不利**"(§3.2 同一纪律), 依据行三个数都写。
+"""
 from ... import paths as P
 from ... import paths as P
 import numpy as np, pandas as pd, pathlib, json
 import numpy as np, pandas as pd, pathlib, json
 from ..config import ROOT
 from ..config import ROOT
@@ -15,13 +23,19 @@ def _cur_win(d, days=60):
     end = d['date'].max()
     end = d['date'].max()
     return d[d['date'] >= (pd.Timestamp(end) - pd.Timedelta(days=days)).date()]
     return d[d['date'] >= (pd.Timestamp(end) - pd.Timedelta(days=days)).date()]
 
 
+
+def _rate(v) -> str:
+    """越线率排版: 缺 1min 计数来源时是 NaN ⇒ 写「—」, 不写成 0.000 (那是"没有越线"的意思)。"""
+    return f"{v:.3f}" if v == v else '—'
+
 def load():
 def load():
     """读变桨面产物 —— **缺件时返回空表而不是抛异常** (2026-09-17, 见下)。
     """读变桨面产物 —— **缺件时返回空表而不是抛异常** (2026-09-17, 见下)。
 
 
-    为什么必须容缺: `pitch_daily.parquet` 属于"**包内没有生成端**"的那批产物(docs §7) ——
-    交付包按用户令**不再随产物**, 目标机(或执行过「清除产物」的机器)上它必然缺失。
-    原先这里直接 `pd.read_parquet` ⇒ `FileNotFoundError` 一路上抛到本体层 `taxonomy.system_matrix()`,
-    把"整条重算链"卡在第 ⑦ 步 (2026-09-17 实测: 清产物后重算, 2267 s 后 rc=1 断在这里)。
+    生成端: `scripts/pitch_face_build.py` (重算链 ④c, 输入 = `data/raw/<场站>/{scada_10min,scada_1min}`)。
+    为什么仍要容缺: ① 目标机上"放了原始件但还没重算"是常态; ② 现场没给 1min 导出件时, 零位两口径
+    与越线计数会按缺 —— 缺件就该显示缺件, 不能造数。原先这里直接 `pd.read_parquet` ⇒ `FileNotFoundError`
+    一路上抛到本体层 `taxonomy.system_matrix()`, 把"整条重算链"卡在第 ⑦ 步
+    (2026-09-17 实测: 清产物后重算, 2267 s 后 rc=1 断在这里)。
     现在缺件返回空表, 下游按"该面无数据"降级(页面显示缺件, 不静默造数)。
     现在缺件返回空表, 下游按"该面无数据"降级(页面显示缺件, 不静默造数)。
     """
     """
     p = PITCH_DIR / 'pitch_daily.parquet'
     p = PITCH_DIR / 'pitch_daily.parquet'
@@ -80,10 +94,14 @@ def registry(cfg=None):
                                        lvl=('lvl_alarm', 'sum'), filt=('filt_alarm', 'sum'),
                                        lvl=('lvl_alarm', 'sum'), filt=('filt_alarm', 'sum'),
                                        pa=('pistA', 'mean'), pb=('pistB', 'mean'), pc=('pistC', 'mean'),
                                        pa=('pistA', 'mean'), pb=('pistB', 'mean'), pc=('pistC', 'mean'),
                                        nd=('n', 'count'), nop=('n_op', 'sum'))
                                        nd=('n', 'count'), nop=('n_op', 'sum'))
-    fleet['over_rate'] = fleet['over'] / fleet['nop'].clip(lower=1)   # 越线/运行10min段 (工况归一)
-    fleet['under_rate'] = fleet['under'] / fleet['nop'].clip(lower=1)
+    fleet['over_rate'] = fleet['over'] / (fleet['nop'] * 10).clip(lower=1)   # 越线**分钟**/运行分钟 (工况归一)
+    fleet['under_rate'] = fleet['under'] / (fleet['nop'] * 10).clip(lower=1)
     med = fleet.median()
     med = fleet.median()
     # 零位: 精度门逐月 (全场逐台偏差 std ≤0.25° 才可判)
     # 零位: 精度门逐月 (全场逐台偏差 std ≤0.25° 才可判)
+    # ★2026-09-19: 输入件现由 scripts/pitch_face_build.py 自算, 零位有**三个口径**(见该脚本 docstring):
+    #   停机顺桨段 `zero_dev`(机械止挡位, 量不出运行区零位差) / 满发段 `zero_dev_full`(混风况, 参考) /
+    #   运行段分档 `zero_dev_run`(同工况读数差 = 行业口径的零位, 分档归一后能把 19# 这类台单独拎出来)。
+    #   判据按"**取更不利**"(§3.2 同一纪律): 停机段与运行段谁偏得多用谁, 依据行三个数都写出来。
     zsum = {}
     zsum = {}
     if len(zero):
     if len(zero):
         mstd = zero.groupby('month')['zero_dev'].std()
         mstd = zero.groupby('month')['zero_dev'].std()
@@ -91,8 +109,15 @@ def registry(cfg=None):
         zok = zero[zero['month'].isin(ok_months)]
         zok = zero[zero['month'].isin(ok_months)]
         for t, g in zok.groupby('turbine'):
         for t, g in zok.groupby('turbine'):
             g = g.sort_values('month')
             g = g.sort_values('month')
+            _full = float(g['zero_dev_full'].median()) if 'zero_dev_full' in g.columns else float('nan')
+            _run = float(g['zero_dev_run'].median()) if 'zero_dev_run' in g.columns else float('nan')
+            _n_alarm = int((g['zero_dev'].abs() >= ZERO_ALARM).sum())
+            if _run == _run:
+                _n_alarm = int(((g['zero_dev'].abs() >= ZERO_ALARM)
+                                | (g['zero_dev_run'].abs() >= ZERO_ALARM)).sum())
             zsum[t] = dict(months=int(len(g)), dev_med=float(g['zero_dev'].median()), last=float(g['zero_dev'].iloc[-1]),
             zsum[t] = dict(months=int(len(g)), dev_med=float(g['zero_dev'].median()), last=float(g['zero_dev'].iloc[-1]),
-                           n_alarm=int((g['zero_dev'].abs() >= ZERO_ALARM).sum()), last_month=str(g['month'].iloc[-1]))
+                           dev_full_med=_full, dev_run_med=_run, n_alarm=_n_alarm,
+                           last_month=str(g['month'].iloc[-1]))
     win_end = daily['date'].max(); win_start = pd.Timestamp(win_end) - pd.Timedelta(days=60)
     win_end = daily['date'].max(); win_start = pd.Timestamp(win_end) - pd.Timedelta(days=60)
     ac = _alarm_counts(win_start, win_end, store=(cfg or {}).get('store') if cfg else None)
     ac = _alarm_counts(win_start, win_end, store=(cfg or {}).get('store') if cfg else None)
     rows, detail = [], {}
     rows, detail = [], {}
@@ -103,7 +128,8 @@ def registry(cfg=None):
         band_x = r['band'] / max(med['band'], 1e-9)
         band_x = r['band'] / max(med['band'], 1e-9)
         st1, why1 = '优秀', f"带宽中位 {r['band']:.0f}bar ({band_x:.2f}×fleet, 正常参考≈{NORM_BAND:.0f}bar 1min口径)"
         st1, why1 = '优秀', f"带宽中位 {r['band']:.0f}bar ({band_x:.2f}×fleet, 正常参考≈{NORM_BAND:.0f}bar 1min口径)"
         if r['band'] >= 2.0 * med['band']:
         if r['band'] >= 2.0 * med['band']:
-            st1 = '报警'; why1 += f"; 越卸荷率{r['over_rate']:.3f}/跌启泵率{r['under_rate']:.3f}/运行段作佐证 [暂行: 带宽≥2×单证报警; 蓄能失效佐证=锯齿失稳]"
+            st1 = '报警'; why1 += (f"; 越卸荷 {_rate(r['over_rate'])}/跌启泵 {_rate(r['under_rate'])}"
+                                  f"(运行分钟占比)作佐证 [暂行: 带宽≥2×单证报警; 蓄能失效佐证=锯齿失稳]")
         elif r['band'] >= 1.4 * med['band']:
         elif r['band'] >= 1.4 * med['band']:
             st1 = '良好'
             st1 = '良好'
         # D2 润滑
         # D2 润滑
@@ -120,12 +146,22 @@ def registry(cfg=None):
         st3, why3 = '优秀', ''
         st3, why3 = '优秀', ''
         z = zsum.get(t)
         z = zsum.get(t)
         if z:
         if z:
-            why3 = f"零位偏差 {z['dev_med']:+.2f}°中位/{z['last']:+.2f}°末月 (可判月 {z['months']}, 精度门0.25°)"
-            if z['n_alarm'] >= 3 and abs(z['dev_med']) >= ZERO_ALARM:
+            _stop, _full, _run = z['dev_med'], z.get('dev_full_med'), z.get('dev_run_med')
+            _mag = abs(_stop)
+            _lead = '停机顺桨段'
+            if _run == _run and abs(_run) > _mag:
+                _mag, _lead = abs(_run), '运行段'
+            why3 = f"零位偏差 停机顺桨段 {_stop:+.2f}°"
+            if _full == _full:
+                why3 += f" / 满发段 {_full:+.2f}°"
+            if _run == _run:
+                why3 += f" / 运行段(同工况) {_run:+.2f}°"
+            why3 += f" (判据轴取更不利的 {_lead}, 可判月 {z['months']}, 精度门0.25°)"
+            if z['n_alarm'] >= 3 and _mag >= ZERO_ALARM:
                 st3 = '报警'; why3 += f" ≥{ZERO_ALARM}°持续{z['n_alarm']}月 → 偏差确诊·成因待诊 [专项口径]"
                 st3 = '报警'; why3 += f" ≥{ZERO_ALARM}°持续{z['n_alarm']}月 → 偏差确诊·成因待诊 [专项口径]"
-            elif abs(z['dev_med']) >= ZERO_WATCH:
+            elif _mag >= ZERO_WATCH:
                 st3 = '良好'
                 st3 = '良好'
-            if abs(z['dev_med']) >= ZERO_ALARM and abs(z['last']) < ZERO_WATCH:
+            if _mag >= ZERO_ALARM and abs(z['last']) < ZERO_WATCH:
                 st3 = '良好'; why3 += ' (末月已归零→已闭环观察)'
                 st3 = '良好'; why3 += ' (末月已归零→已闭环观察)'
         else:
         else:
             why3 = '零位: 可判月不足 (精度门/样本)'
             why3 = '零位: 可判月不足 (精度门/样本)'

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff