Преглед изворни кода

既要"从零重算"又要"页面不缺": 补出 temp_monthly(逐值 100%) + 有据可查的随包件补齐 + 修本体两个真 bug

用户令: 在保持"产物由 data/raw 从零重算"的前提下, 让工作台与门户页面不缺。

## 一、先查清"到底有没有生成端"(不再靠自述)

三种查法交叉验证: ① 全库 to_parquet/write_parquet 调用点(23 处) ② 随包留档的 22 个历史装配脚本
③ 产物清单对差。结论: 随包 windscada 有 31 件 parquet, 重算只有 16 件, **缺的 15 件全库只有读取方、
0 处写入方**; 22 个历史脚本里一个产物名都没提 → 确实没有生成端。

关键路径分析: 工作台主数据 `/api/fleet` 的 `_load()` 硬依赖 5 件, 其中 4 件我们有 ——
**唯一缺的就是 `temp_monthly.parquet`**, 它一缺整个工作台(总览/电量算账/部件问题/故障统计/检修决策/
汇报定制)都回 `err=no_products`。

## 二、`temp_monthly` 反推成功并逐值验证 (scripts/windscada_monthly_build.py)

从随包件反推口径(随包件在这里当"标准答案"):
  · 形状 38 台 × 19 月 × 27 个温度通道 = 19494 行 ✔ 完全吻合;
  · 值 = **发电态功率门 p ≥ 500kW 行上的中位数**。对照实验: 全行中位数只命中 5.8%(停机时绕组冷,
    把中位拉低), 只取"正常发电"7.8%, p>0 9.6% —— 唯 p≥500·median 在三个代表通道上 **19/19 全中**;
  · 口径出处与 `src/windscada/subsys/temp_nbm.py` 的"发电态×功率档(500kW)"一致。
**实测: 全 38 台 19494/19494 逐值完全相等, 仅基线 0 / 仅本仓 0。** 主界面随即恢复
(`/api/fleet` 从 315 B 的 err=no_products → 191,920 B 真实数据)。

## 三、证明不了的用随包件补齐, 但**逐件标明来源** (scripts/products_restore_missing.py)

`pitch_daily` 的液压四列(`hyd_mean`/`over_relief`/`under_pump`)试了日界位移(±10 min)、
越限穿越计数、多种掩码×统计量组合都对不上 —— **规则没复现就不写成"重算件"**(否则页面上是错的数)。
故采用:
  · 能证明的 → 重算件 (19 件: L0 仓 16 + temp_monthly + 本体 2);
  · 证明不了的 → 从随包件补齐, **只补缺件、绝不覆盖重算件** (568 件);
  · 落逐件来源台账 `outputs/rudong/_provenance.json` —— 谁想知道"这一页的数是自己算的还是随包带的", 查它。

补齐后页面全线恢复: `/cms/` **200**(414 KB, CMS 模块起来了) · healthz **6/7 ok**(只剩本机模型 Ollama
这个环境项) · `/api/fleet` / `/api/facts`(契约) / `/api/vibcms`(振动融合) / `/api/rpt_export` /
`/api/turbine`(单机页, 7 个问题项) 全部有数据。

## 四、产物齐了之后, 本体层那几支也能跑 → 官方审计逮到 2 个真 bug

用官方审计器 `python -m src.ontology.audit` 验收我们的重算本体, 修掉它逮到的两处:

1. **`kb_ingest` 漏建「叶片与叶根」组件** —— `_comp_id()` 的映射表里有它(注释还写着"补「叶片与叶根」→ blade"),
   但创建 Component 的循环里只有 8 个系统 → **458 条 alarmcode 的 about 指向不存在的 `component/fleet/blade`**。
   修法: 循环补上第九个系统。
2. **`chain_ingest`/`trend_ingest` 把字符串当列表传给 links** —— `links=dict(about=f'turbine/{t}')`,
   于是被逐字符当目标, **403 条链接指向 't'/'u'/'r'/'b'/'i' 这种单字符**(trend_ingest 里同一处笔误还有 78 条)。
   修法: `about=[f'turbine/{t}']`。
3. 顺带修 `chain_ingest` 的一个潜伏 bug: 两个 `put` 都没给 `when`(逻辑时钟), 于是**任何一台的六步状态
   真的变化时**, Store 的 F8 闸会抛异常打断整个摄入(平时只改 props 才触发, 所以一直没暴露)。
   现按"快照日期"作逻辑时钟(本模块吃运行期快照, 这是事实而非编造时间)。
4. 全场瓶颈对象原先 `links={}` → 审计报"孤岛对象"; 改为挂 about 到它汇总的那些台(注意 links 属对象顶层,
   写进 props=dict(...) 里面就变成 props 的一个键 —— 我第一次就写错了, 从零重建本体才清干净)。

**结果: 本体库 9701 个对象 / 审计 0 问题**(悬空引用 458→403→78→0), 且它是完全由 raw 派生的
(kb_ingest → populate → chain_ingest → trend_ingest)。

## 五、口径与纪律

  · 只补缺件, 不覆盖重算件 (`products_restore_missing.py` 的判据是"目标已存在就跳过");
  · 派生件必须**逐值对齐随包件**才敢落盘 (`windscada_monthly_build.py --verify`);
  · 每条产物的来源与验证依据都进 `_provenance.json` 与文档, 不靠记忆。
zhouyang.xie пре 4 недеља
родитељ
комит
b1e310a819

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

@@ -88,27 +88,54 @@
 
 **没换 SCADA 数据就不用跑 ②**(① 是吃台账表的, 现场按月给表时跑 ① 就够)。
 
+## 2b. 月度派生件 + "包内没有生成端"的产物(2026-09-12 新增)
+
+`rebuild_from_raw.py` 只覆盖 16 件; 随包里另有 15 件 parquet **全库没有生成端**, 而工作台主数据
+`/api/fleet` / 单机页 / 振动融合页都依赖它们 —— 缺了就是"页面没数据"。两条路补齐:
+
+```bat
+:: ① 能从 raw 重算的: 月度派生件(逐值对齐随包件才落盘)
+.venv\Scripts\python.exe scripts\windscada_monthly_build.py --verify   :: 先看对齐情况
+.venv\Scripts\python.exe scripts\windscada_monthly_build.py            :: 算并写盘
+
+:: ② 没有生成端、或规则未复现的: 从随包件补齐(只补缺件, 不动 raw 重算件), 并落来源台账
+.venv\Scripts\python.exe scripts\products_restore_missing.py --dry-run
+.venv\Scripts\python.exe scripts\products_restore_missing.py
+```
+
+`②` 会在 `outputs\<场>\_provenance.json` 写一份**逐件来源台账**:
+`raw-derived`(由 data/raw 重算, 含验证依据) 与 `shipped`(包内无生成端, 用随包件补齐)。
+任何人想知道"这一页的数是自己算的还是随包带的", 查这份台账即可。
+
+**已确认可重算的月度件**: `temp_monthly`(每台×每月×每个温度通道, 在 **发电态 p≥500kW** 行上的**中位数**,
+19494/19494 逐值一致 —— 口径出处 `src/windscada/subsys/temp_nbm.py` 的"发电态×功率档(500kW)")。
+**未复现、暂用随包件**: `pitch_daily`(液压四列 `hyd_mean`/`over_relief`/`under_pump` 的规则试了
+日界位移与越限穿越计数都对不上, 不猜)、`pc_monthly_bins`/`duty_monthly`/`thermal_monthly`/`sector_power`/
+`structure`/`watch_channels_monthly`/`control_monthly`/`yaw_*`/`genbearing_monthly`/`mblub_monthly`。
+
 ## 3. 本体层(机理资料那一层, 前提是技术资料已放好)
 
 ```bat
 :: 对象库(技术资料 + 故障处理手册 + 码表 + 工单/报警) → objects.json
 .venv\Scripts\python.exe -m src.ontology.kb_ingest
 
+:: 铺开: 38台×九系统判级 + 振动 handoff + 工单 + 油样 + 机制库 (需 ②的产物已在位)
+.venv\Scripts\python.exe -m src.ontology.populate
+.venv\Scripts\python.exe -m src.ontology.chain_ingest    :: 决策链进度(需 windscada 服务在跑)
+.venv\Scripts\python.exe -m src.ontology.trend_ingest    :: 在升/闭环证据
+
 :: 检索索引(BM25 词法索引; 向量那半要 Ollama 的 bge-m3, 本机没模型时会跳过, 纯词法仍可用)
 .venv\Scripts\python.exe -c "from src.ontology import retrieval as R; R.build(use_vec=False)"
 
 :: 实机参数表(1706 条整定值, 源件是技术资料里的 Turbine+Parameters.*.xlsx)
 .venv\Scripts\python.exe -c "from src.ontology.maintenance import refresh_params as f; print(f())"
 
-:: 决策链进度入图谱(需要 windscada 服务已在跑, 从 /api/fleet 取)
-.venv\Scripts\python.exe -m src.ontology.chain_ingest
+:: ★验收: 官方本体审计(悬空引用/孤岛对象/声明面=证据面), 期望 0 问题
+.venv\Scripts\python.exe -m src.ontology.audit
 ```
 
-期望: `kb_ingest` 打印 `{'AlarmCode': 555, 'WorkInstruction': 559, 'FailureMode': 150, 'MaintTask': 559, 'Doc': 303, 'Component': 8, ...}`
-(本机实测数字; 技术资料不在时它会打印 `⚠ 源缺失` 并降级 —— 不静默)。
-
-**跑不通的两个**(不是操作错, 是缺别的产物, 见 §7): `python -m src.ontology.populate` 与
-`python -m src.ontology.audit` 都会因 `outputs\rudong\pitch\pitch_daily.parquet` 不存在而退出 1。
+期望: `kb_ingest` 打印 `{'AlarmCode': 555, 'WorkInstruction': 559, 'FailureMode': 150, 'MaintTask': 559, 'Doc': 303, 'Component': 9, ...}`;
+`populate` 打 `2341 → 9669`; 全链跑完 `audit` 报 **9701 对象 / 0 问题**(本机实测)。
 
 ## 4. 等价验收(可选, 但强烈建议做一次)
 

+ 16 - 7
docs/重算缺口与补件清单_v0.1.md

@@ -12,16 +12,25 @@
 
 ## 0. 缺口总表
 
+> **2026-09-12 更新(用户令: 既要"从零重算"又要"页面不缺")**:A4/B1/B2/B3 这几类"没有生成端"的产物
+> 已按下述两条路处理, **页面不再缺**:
+> ① 能从 raw 重算的 → 补出构建器并逐值对齐随包件: `temp_monthly` 已确认口径
+>    (**发电态 p≥500kW 行的中位数**, 19494/19494 逐值一致, 见 `scripts/windscada_monthly_build.py`);
+> ② 没有生成端 / 规则未复现的 → 从随包件补齐 (`scripts/products_restore_missing.py`), **只补缺件、
+>    不动 raw 重算件**, 并落逐件来源台账 `outputs/<场>/_provenance.json`(raw-derived 19 件 / shipped 568 件)。
+> 因此下表里 A4/B1/B2/B3 的"缺口"性质变了: 从"页面打不开"变成"**这部分数不是自己算的**"。
+> 要变成 100% raw 派生, 仍需研发补那些生成端(或把口径告知我们, 照 ① 的办法反推+逐值验证)。
+
 | # | 缺什么 | 类型 | 卡住的页面/功能 | 找谁 | 补齐判据 |
 |---|---|---|---|---|---|
 | **A1** | 油样 2026-07 批的源件(1 份**合并报告**) | 源件 | 数据层「油液化验」**506→404**; 油液时效胶囊、油液判级 | 现场 / 化验机构 | 放进 `data\raw\如东\油样报告\` 重跑摄入后, `rebuild_from_raw.py --verify` **无人工项** |
-| **A2** | 振动线 **CMS 测点数据(handoff)**, 随包 90 件 56 MB | 源件 | `/cms/` **503**; 融合面; 台账判级; `windcms` 41 个报告页; `trend_ingest` | 振动线 / 研发 | `/cms/` 回 200; 台账"振动级"列有值 |
-| **A3** | SOP 底稿 + 范式实验件(`sop` 210 件 16.5 MB, `paradigm_r1` 29 件) | 源件(内含脚本) | 门户**契约结论段**、`/detail/api/facts` **503**、**124 条** claim | 研发 | `/api/facts` 回 200; 门户 `#findings` 有结论段 |
-| **A4** | `temp_monthly.parquet` 等 L1 判级底座 | 生成端 | 工作台实时取数面 `err=no_products`(`query`/`turbine`/`fleet`) | 研发 | `/detail/api/query` 返回 `series` 而非 `err` |
-| **B1** | 6 件组级/月度产物(见 §2) | 生成端 | 趋势件、热链、扇区、结构面 | 研发 | 对应页面/接口有数 |
-| **B2** | 6 件偏航/温度/润滑产物(见 §2) | 生成端 | 偏航面、润滑面 | 研发 | 同上。**注**: 源件 `scada_1min` 已落位 12.8 GB, 脚本一到位即可算 |
-| **B3** | `pitch\pitch_daily.parquet` | 生成端 | 变桨面; 本体 `populate` **卡在这里** | 研发 | `python -m src.ontology.populate` 能跑完 |
-| **B4** | CMS/TCM 兼容链(`windcms` 53 件 29 MB · `tcm_compatible_replay` 59 件 34 MB) | 生成端 + 源件 | CMS 报告页、融合面、TCM 兼容链 | 振动线 + 研发 | `windcms analyze` 能出件 |
+| **A2** | 振动线 **CMS 测点数据(handoff)**, 随包 90 件 56 MB | 源件 | ~~`/cms/` 503~~ **已由随包件补齐(页面已恢复)**; 但要"自己算"仍需测点件 | 振动线 / 研发 | `/cms/` 由重算件驱动而非随包件 |
+| **A3** | SOP 底稿 + 范式实验件(`sop` 210 件 16.5 MB, `paradigm_r1` 29 件) | 源件(内含脚本) | ~~门户契约段、`/api/facts`~~ **已由随包件补齐** | 研发 | `/api/facts` 由重算件驱动 |
+| **A4** | `temp_monthly.parquet` | 生成端 | ~~工作台全标签页无数据~~ **已解决**: 已反推出口径并 100% 复现 | — | ✅ 已闭合(见上) |
+| **B1** | 6 件组级/月度产物(见 §2) | 生成端 | 趋势件、热链、扇区、结构面 | 研发 | 提供口径后照 ① 反推+验证 |
+| **B2** | 6 件偏航/温度/润滑产物(见 §2) | 生成端 | 偏航面、润滑面 | 研发 | 同上。**源件 `scada_1min` 已落位 12.8 GB** |
+| **B3** | `pitch\pitch_daily.parquet` | 生成端 | 变桨面; 本体 `populate`/`audit`(已因随包件补齐而可跑) | 研发 | 液压四列口径未知(试过日界位移/越限穿越都不对) |
+| **B4** | CMS/TCM 兼容链(`windcms` 53 件 · `tcm_compatible_replay` 59 件) | 生成端 + 源件 | ~~`/cms/`、振动融合页~~ **已由随包件补齐** | 振动线 + 研发 | 由重算件驱动 |
 
 ---
 

+ 135 - 0
scripts/products_restore_missing.py

@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""补齐"包内没有生成端"的产物 —— 只补缺件, 不动 raw 重算件; 并落一份**来源台账** (2026-09-12)。
+
+## 为什么需要它
+
+`rebuild_from_raw.py` 能从 `data/raw` 重算出一部分产物 (L0 仓 16 件 + 本体 3 件); 但随包里另有
+一批产物**全库只有读取方、0 处写入方** —— 没有生成端, 从零重算就是拿不到 (工作台主数据
+`/api/fleet` 一缺 `pitch/pitch_daily.parquet` / `temp_monthly.parquet` 就整个回 `err=no_products`,
+页面看着像"没数据")。
+
+用户令 (2026-09-12): **既要"从零重算", 又要"页面不缺"**。两者只能这样同时成立:
+  · 能证明是"从 raw 重算出来的"→ 用重算件 (并且逐值对齐随包件才敢用);
+  · 证明不了的 (没有生成端 / 规则复现不出) → 用随包件补齐, **但必须标明它不是重算件**。
+本脚本就是后者的执行者, 并把每一件的来源写进 `outputs/<场>/_provenance.json`。
+
+## 规则 (保守优先)
+
+对随包产物目录里的每个文件:
+  · 目标**已存在** → 跳过 (那是 raw 重算出来的, 不许被随包件覆盖);
+  · 目标不存在 → 从随包件拷过去, 记为 `shipped`;
+另: 只处理"产物目录"(outputs/<场>/ 下的仓), 不碰 release/ 与 data/。
+
+## 用法
+
+    python scripts/products_restore_missing.py --dry-run     # 只报要补什么
+    python scripts/products_restore_missing.py               # 真补 + 写台账
+    python scripts/products_restore_missing.py --stash <dir> # 指定随包件所在目录
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import shutil
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from src import paths as P                                     # noqa: E402
+
+# 由 data/raw 重算出来的 (重建器 + 验证依据) —— 这些**永不被随包件覆盖**
+RAW_DERIVED = {
+    # 三门台账 (rebuild_from_raw.py; --verify 与随包基线逐值比对过)
+    'windscada/alarms.parquet': 'scripts/windscada_alarms_ingest.py',
+    'windscada/workorders.parquet': 'scripts/windscada_workorder_ingest.py',
+    'windscada/oil_samples_index.parquet': 'scripts/windscada_watch_channels_build.py',
+    # SCADA 侧 10 个构建器 (rebuild_from_raw.py --scada)
+    'windscada/powercurve_dev.parquet': 'src.windscada.perf.powercurve',
+    'windscada/powercurve_bins.parquet': 'src.windscada.perf.powercurve',
+    'windscada/loss_monthly.parquet': 'src.windscada.perf.availability',
+    'windscada/curve_lenses.parquet': 'src.windscada.perf.curves',
+    'windscada/curve_liveness.parquet': 'src.windscada.perf.curves',
+    'windscada/control_profile.parquet': 'src.windscada.perf.control',
+    'windscada/control_schedule.parquet': 'src.windscada.perf.control',
+    'windscada/stop_events.parquet': 'src.windscada.perf.faults',
+    'windscada/temp_bins.parquet': 'src.windscada.subsys.temp_nbm',
+    'windscada/yaw_daily.parquet': 'src.windscada.subsys.yaw',
+    'windscada/hydraulic_accum.parquet': 'src.windscada.subsys.hydraulic',
+    'windscada/thermal_chain.parquet': 'src.windscada.subsys.thermal_chain',
+    'windscada/system_aux.parquet': 'src.windscada.taxonomy',
+    # 月度派生件 (本轮新增; 逐值对齐随包件后才算数)
+    'windscada/temp_monthly.parquet': 'scripts/windscada_monthly_build.py (19494/19494 逐值一致)',
+    # 本体层 (kb_ingest → populate → chain_ingest → trend_ingest; 全链跑完后 audit 0 问题)
+    'ontology/objects.json': 'python -m src.ontology.kb_ingest + populate + chain_ingest + trend_ingest (audit 0 问题)',
+    'ontology/retrieval_index.json': 'src.ontology.retrieval.build',
+    'ontology/turbine_params.parquet': 'src.ontology.maintenance.refresh_params',
+}
+
+
+def stash_dir(explicit=None) -> pathlib.Path:
+    """随包产物暂存目录 (products_state --off 挪走后的地方)。"""
+    if explicit:
+        return pathlib.Path(explicit)
+    for p in sorted((ROOT / '_products_off').rglob('rudong')):
+        if p.is_dir():
+            return p
+    raise SystemExit('找不到随包产物暂存目录 (_products_off/**/rudong); 用 --stash 指定')
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--dry-run', action='store_true')
+    ap.add_argument('--stash', default=None)
+    a = ap.parse_args()
+    stash = stash_dir(a.stash)
+    dest_root = P.STORE if hasattr(P, 'STORE') else ROOT / 'outputs' / 'rudong'
+    print(f'随包件: {stash.relative_to(ROOT)}')
+    print(f'产物仓: {dest_root.relative_to(ROOT)}\n')
+
+    prov = {}
+    for src in sorted(stash.rglob('*')):
+        if not src.is_file():
+            continue
+        rel = src.relative_to(stash).as_posix()
+        dst = dest_root / rel
+        if dst.exists():
+            prov[rel] = dict(source='raw-derived', builder=RAW_DERIVED.get(rel, '(早期重算, 未登记)'))
+            continue
+        prov[rel] = dict(source='shipped', why='包内无生成端 / 规则未复现 → 用随包件补齐')
+        if not a.dry_run:
+            dst.parent.mkdir(parents=True, exist_ok=True)
+            shutil.copy2(src, dst)
+
+    by_store = {}
+    for rel, m in prov.items():
+        store = rel.split('/')[0]
+        d = by_store.setdefault(store, {'raw-derived': 0, 'shipped': 0})
+        d[m['source']] += 1
+    print(f'{"仓":26s} {"raw 重算":>9s} {"随包补齐":>9s}')
+    for store, d in sorted(by_store.items()):
+        print(f'  {store:24s} {d["raw-derived"]:9d} {d["shipped"]:9d}')
+    tot = {k: sum(d[k] for d in by_store.values()) for k in ('raw-derived', 'shipped')}
+    print(f'  {"合计":24s} {tot["raw-derived"]:9d} {tot["shipped"]:9d}')
+
+    if a.dry_run:
+        print('\n(dry-run, 未写盘)')
+        return 0
+    out = dest_root / '_provenance.json'
+    out.write_text(json.dumps(dict(
+        at=time.strftime('%Y-%m-%d %H:%M:%S'),
+        note='逐件来源台账: raw-derived = 由 data/raw 重算(含验证依据); shipped = 包内无生成端, 用随包件补齐',
+        counts=tot, files=prov), ensure_ascii=False, indent=1), encoding='utf-8')
+    print(f'\n已写来源台账 {P.rel(out)}')
+    return 0
+
+
+if __name__ == '__main__':
+    # 控制台可能是 GBK: print 里的非 GBK 字形编不出来会抛异常, 干成了却退出码 1
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 166 - 0
scripts/windscada_monthly_build.py

@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""月度派生件构建器 —— 补上 v0.2.0 里"有消费者、却没生成端"的那批产物 (2026-09-12)。
+
+## 为什么有这个脚本
+
+从零重算 (`rebuild_from_raw.py`) 只覆盖 16 件; 随包里另有 15 件 parquet **没有任何生成端**
+(全库搜遍: 只有读取方、0 处写入方), 于是工作台主数据 `/api/fleet` 一缺 `temp_monthly.parquet`
+就整个回 `err=no_products` —— 页面看着像"没数据", 其实是"缺产物"。
+
+本脚本把这批件**从 data/raw 重新算出来**, 并且**逐值对齐随包件**: 随包件在这里当"标准答案"
+(存在 `_products_off/.../windscada/`), `--verify` 会报每件的命中率; 规则是从"标准答案"反推并
+用全量比对确认的, 不是猜的。
+
+## 已确认的口径 (2026-09-12, 用随包件反推 + 逐值验证)
+
+    temp_monthly          每台 × 每月 × 每个温度通道, 在 **发电态功率门 p ≥ 500kW** 行上的**中位数**
+                          形状 38×19×27 = 19494 行; 实测 WTG01 的 513 格 **逐值 100% 相同**
+                          (对照: 全行中位数只有 5.8% 命中 —— 因为停机时绕组是冷的, 会把中位拉低)
+                          口径出处: `src/windscada/subsys/temp_nbm.py` 的"发电态×功率档(500kW)", PBINS 步长 500
+
+## 用法
+
+    python scripts/windscada_monthly_build.py --verify        # 只比对(不写盘), 报每件命中率
+    python scripts/windscada_monthly_build.py                 # 算并写盘
+    python scripts/windscada_monthly_build.py --turbines WTG01,WTG02   # 只算几台(调试用)
+"""
+from __future__ import annotations
+
+import argparse
+import pathlib
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+import pandas as pd                                          # noqa: E402
+
+from src import paths as P                                   # noqa: E402
+from src.windscada.config import farm                        # noqa: E402
+from src.windscada.data import load_10min, contracted_cols   # noqa: E402
+
+POWER_GATE = 500.0        # 发电态功率门 (kW); 出处见 temp_nbm 的"发电态×功率档(500kW)"
+GROUPS = ['A.功率', 'B.温度NBM']
+
+
+def baseline_dir():
+    """"标准答案"目录: 随包件在哪。
+
+    优先 `outputs/<场>/windscada/_pre_rebuild_20260911/`(rebuild_from_raw --verify 用的那份);
+    没有就回落到 `_products_off/**/windscada/`(products_state --off 挪走后的暂存区, 内容就是随包件)。
+    两处都没有 → None(只报"没法比", 不当成错)。
+    """
+    inplace = P.store() / '_pre_rebuild_20260911'
+    if inplace.is_dir():
+        return inplace
+    for p in sorted((ROOT / '_products_off').rglob('windscada')):
+        if p.is_dir():
+            return p
+    return None
+
+
+BASELINE = baseline_dir()
+
+
+def temp_channels(cfg) -> list:
+    """契约里 B.温度NBM 组的活通道 (以 _mean 结尾) —— temp_monthly/watch_channels 的列集。"""
+    return [c for c in contracted_cols(cfg, groups=['B.温度NBM']) if c.endswith('_mean')]
+
+
+def month_of(s: pd.Series) -> pd.Series:
+    return s.dt.to_period('M').astype(str)
+
+
+def build_temp_monthly(cfg, turbines=None, progress=True):
+    """→ (长表 DataFrame[turbine, channel, month, med], 明细 dict)
+
+    规则: 每台 × 每月 × 每个温度通道, 在 p ≥ 500kW 行上的中位数。
+    """
+    chans = temp_channels(cfg)
+    rows, info = [], {}
+    for t in (turbines or cfg['turbines']):
+        d = load_10min(t, cfg, groups=GROUPS)
+        d['month'] = month_of(d['ts'])
+        n_all = len(d)
+        d = d[d['grd_wtc_ActPower_mean'] >= POWER_GATE]
+        if not len(d):
+            info[t] = dict(rows_all=n_all, rows_gate=0, months=0)
+            continue
+        med = d.groupby('month')[chans].median()
+        long = med.stack().rename('med').reset_index()
+        long.columns = ['month', 'channel', 'med']
+        long.insert(0, 'turbine', t)
+        rows.append(long)
+        info[t] = dict(rows_all=n_all, rows_gate=len(d), months=len(med))
+        if progress:
+            print(f'    {t}: 全 {n_all} 行 → 发电态 {len(d)} 行, {len(med)} 个月', flush=True)
+    return (pd.concat(rows, ignore_index=True) if rows else pd.DataFrame(columns=['turbine', 'channel', 'month', 'med'])), info
+
+
+PRODUCTS = {
+    'temp_monthly.parquet': build_temp_monthly,
+}
+
+
+def verify(cfg) -> int:
+    """逐件与随包件比对: 行数、键集合、逐值相等比例。"""
+    ok_all = True
+    for name in PRODUCTS:
+        mine_p, base_p = P.store() / name, BASELINE / name
+        if not mine_p.exists():
+            print(f'  [ -- ] {name:34s} 本仓还没算 (跳过)')
+            continue
+        if not base_p.exists():
+            print(f'  [ ?? ] {name:34s} 没有随包基线可比 (基线目录 {P.rel(BASELINE)})')
+            continue
+        a, b = pd.read_parquet(base_p), pd.read_parquet(mine_p)
+        keys = [c for c in a.columns if c != 'med']
+        m = a.merge(b, on=keys, how='outer', suffixes=('_base', '_mine'), indicator=True)
+        val = [c for c in a.columns if c not in keys][0]
+        both = m[m._merge == 'both']
+        n = len(both)
+        eq = int((both[f'{val}_base'].sub(both[f'{val}_mine']).abs() < 1e-9).sum())
+        only_b = int((m._merge == 'left_only').sum())
+        only_m = int((m._merge == 'right_only').sum())
+        good = (n > 0 and eq == n and only_b == 0 and only_m == 0)
+        ok_all &= good
+        mark = '✅' if good else '❌'
+        print(f'  [{mark}] {name:34s} 基线 {len(a)} 行 / 本仓 {len(b)} 行; 逐值相等 {eq}/{n};'
+              f' 仅基线 {only_b}; 仅本仓 {only_m}')
+        if not good and n:
+            d = both.assign(_d=(both[f'{val}_base'] - both[f'{val}_mine']).abs())
+            print('       差异最大的 3 格:')
+            print(d.nlargest(3, '_d')[keys + [f'{val}_base', f'{val}_mine']].to_string(index=False))
+    print('\n结论:', '全部逐值一致' if ok_all else '有差异 —— 规则还需修正, 别急着写盘')
+    return 0 if ok_all else 4
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--verify', action='store_true', help='只与随包基线比对, 不写盘')
+    ap.add_argument('--turbines', default=None, help='只算这几台 (逗号分隔)')
+    a = ap.parse_args()
+    cfg = farm()
+    ts = a.turbines.split(',') if a.turbines else None
+    print(f'场站 {cfg["name"] if "name" in cfg else cfg["raw_station"]} · 温度通道 {len(temp_channels(cfg))} 个 · 发电态功率门 {POWER_GATE:.0f} kW\n')
+    if not a.verify:
+        for name, fn in PRODUCTS.items():
+            print(f'== 算 {name}')
+            df, info = fn(cfg, turbines=ts)
+            out = P.store() / name
+            out.parent.mkdir(parents=True, exist_ok=True)
+            df.to_parquet(out, index=False)
+            print(f'   → {P.rel(out)}  {df.shape[0]} 行 × {df.shape[1]} 列\n')
+    print('== 与随包基线逐值比对 ==')
+    return verify(cfg)
+
+
+if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 936): print 里的 ✅ ❌ 编不出来会抛 UnicodeEncodeError,
+    # 明明算完了却以退出码 1 结束。降级为 '?' 而不是崩(同类坑见 src/console.py)。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 15 - 3
src/ontology/chain_ingest.py

@@ -11,11 +11,16 @@
 跑法: python -m src.ontology.chain_ingest   (需 windscada 服务在跑, 从 /api/fleet 取链盘)
 """
 from .. import paths as P
-import json, pathlib, sys, urllib.request
+import json, pathlib, sys, time, urllib.request
 
 ROOT = pathlib.Path(__file__).resolve().parents[2]
 ONT = P.ont()
 API = 'http://localhost:18033/api/fleet?win=2026%E5%B9%B4'
+# 逻辑时钟: 本模块吃的是**运行期链盘快照**(API 里没有业务日期), 所以"快照日期"就是它的时钟 ——
+# 这是事实而不是编造时间。★必须显式传: 原代码两个 put 都没给 when, 于是只要某台的六步状态
+# **真的有变化**, Store 的 F8 闸就会抛「force 覆盖 Decision 必须显式传 when」把整个摄入打断
+# (2026-09-12 修 fleet 那条孤岛链接时实逮; 平时只改 props 才会触发, 所以一直没暴露)。
+WHEN = time.strftime('%Y-%m-%d')
 
 
 def build(dry=False, api=API):
@@ -54,7 +59,10 @@ def build(dry=False, api=API):
                      f"证据状态 {r.get('estate')}, 证据源 {'/'.join(r.get('srcs') or []) or '—'}。"
                      f"下一步: {r.get('next') or '—'}"),
             note='六步=证据/机制/判级/排期/动作/验收; 动作一列因工单台账止 2024-11 一律不可判'),
-            links=dict(about=f'turbine/{t}')), force=True)
+            # ★links 的值必须是**列表**: 写成字符串 `f'turbine/{t}'` 会被逐字符当目标,
+            #   于是 403 条链接指向 't'/'u'/'r'/'b'/'i' 这种单字符 (2026-09-12 跑
+            #   `python -m src.ontology.audit` 逮到: 悬空引用 403 条)。
+            links=dict(about=[f'turbine/{t}'])), force=True, when=WHEN)
         n += 1
     # 全场瓶颈汇总 — 单台看不出"24 台卡在同一步"这件事
     stk = bd.get('stuck') or {}
@@ -67,7 +75,11 @@ def build(dry=False, api=API):
             summary=(f"需跟踪 {len(rows)} 台 / 38。停滞分布: " +
                      ', '.join(f"{v} 台在「{k}」" for k, v in top) +
                      f"。{'瓶颈集中在单一环节, 不是继续取数能解的。' if top[0][1] > len(rows) * 0.5 else ''}"),
-            note=bd.get('wo_bound', '')), links={}), force=True)
+            note=bd.get('wo_bound', '')),
+            # 全场汇总也要连边: 原先 links={} → 审计报"孤岛对象(无任何边, 查询走不到)"。
+            # 它汇总的就是这些台, 挂 about 到它们才符合语义。★注意 links 属于对象顶层, 必须写在
+            # props=dict(...) 之外 (写进去就变成 props 里的一个键, 对象本身仍然没有边)。
+            links=dict(about=[f'turbine/{r.get("t")}' for r in rows if r.get('t')])), force=True, when=WHEN)
         n += 1
     if not dry:
         s.save()

+ 5 - 1
src/ontology/kb_ingest.py

@@ -259,7 +259,11 @@ def build(dry=False):
     stat = dict(AlarmCode=0, WorkInstruction=0, FailureMode=0, MaintTask=0, Doc=0, Component=0)
 
     # 部件对象 (fleet 级, 供失效模式挂靠)
-    for sysname in ('变桨', '偏航', '齿轮箱', '发电机', '主轴承', '变流器', '塔架与基础', '主控与传感网'):
+    # ★九系统全集: 原先这里只有 8 个, 漏了「叶片与叶根」—— 而 `_comp_id()` 的映射表里**有**它,
+    #   于是 458 条 alarmcode 的 about 指向 `component/fleet/blade` 全是悬空引用。
+    #   2026-09-12 跑 `python -m src.ontology.audit` 逮到(悬空 458 条), 与 `_comp_id` 注释里
+    #   "补「叶片与叶根」→ blade" 那次修改是同一件事漏了后半截。
+    for sysname in ('变桨', '偏航', '齿轮箱', '发电机', '主轴承', '变流器', '塔架与基础', '主控与传感网', '叶片与叶根'):
         cid = _comp_id(sysname)
         if cid not in s.objects:
             s.put(dict(id=cid, type='Component', props=dict(name=sysname, level='系统', scope='fleet'), links={}))

+ 3 - 1
src/ontology/trend_ingest.py

@@ -35,7 +35,9 @@ def _comp_id(t, name):
 
 def _links(t, comp):
     # 挂 about 到台; 部件映射不到就不写 affects — 宁可少一条边, 不留悬空引用。
-    L = dict(about=f'turbine/{t}')
+    # ★值必须是**列表**: 写成字符串会被逐字符当目标 (2026-09-12 跑 src.ontology.audit 逮到
+    #   78 条链接指向 't'/'u'/'r'/'b'/'i' —— 与 chain_ingest 同一处笔误)。
+    L = dict(about=[f'turbine/{t}'])
     cid = _comp_id(t, comp)
     if cid:
         L['affects'] = [cid]