Explorar o código

一条命令重算全部: scripts/rebuild_all.py + 修两处"假结论/误判失败"

用户问"能否一条命令重算全部" —— 原先不行(六七条命令, 顺序还不能错: 产物齐了本体层才跑得动,
本体层跑完才能拷随包件)。现在把它做成一个入口, 顺序/依赖/验收锚点全写死在脚本里。

## 新增 scripts/rebuild_all.py

    ① 放数据 place_raw_data --scope full (给了 --src 才跑)
    ② 三门台账 rebuild_from_raw             ③ SCADA 侧 --scada (约 15 分钟)
    ④ 月度派生件 windscada_monthly_build    ⑤ 补齐缺件 products_restore_missing (必须在 ⑥ 之前:
       本体 populate/chain_ingest 要吃这批 L1 产物)
    ⑥ 重启服务 (chain_ingest 要从 /api/fleet 取链盘)
    ⑦ 本体链 kb_ingest → populate → chain_ingest → trend_ingest → retrieval → refresh_params
    ⑧ 本体审计 (+ --with-verify 时加台账等价验收)

开关: `--skip-scada`(只换台账时的常用档) · `--src <现场包>` · `--no-restart` · `--with-verify` · `--dry-run`。
子进程环境带 PYTHONUTF8=1/PYTHONIOENCODING=utf-8(否则子脚本 print 里的 ✔ 在 GBK 控制台上会抛异常,
干成了却退出码 1); 每步幂等, 某步失败即停并打印每步 OK/FAIL 与耗时。

## 实测逮到并修掉的两处

1. **`guanlan.py serve` 在 degraded 时故意返回 1**(如本机 Ollama 没模型), 第一版编排器把它当"步骤失败"
   直接中断 —— 于是本体链与审计从未执行。改法: 该步标注 `tolerate=(1,)`, 并按"OK(rc=1)"记账,
   打印一句"degraded 是正常状态"。
2. **`windscada_monthly_build --verify` 会打出假结论**: 它只认 `outputs/…/_pre_rebuild_20260911/` 这一个
   目录, 而那是**台账验收基线(3 件)**, 不含 temp_monthly → 报"没有随包基线可比", 但结论仍打印
   "全部逐值一致"(ok_all 从未被置 False)。假结论比没结论更糟。
   改法: 逐件找标准答案(`baseline_for()`: 台账基线 → `_products_off*/**/windscada/` 随包整份产物),
   三种结果分开报(✅一致 / ❌有差异 / ?? 无法比对), 一件都没比对时返回 5 并明说"这不是通过"。
   实测: temp_monthly 的标准答案解析到 `_products_off_prev_20260912_085201/rudong/windscada/temp_monthly.parquet` ✅

## 一条命令的实测结果 (--skip-scada, 共 421 s)

  [OK] 三门台账 331.1s · 月度派生件 65.8s · 补齐缺件 0.2s · stop 8.4s · serve 6.4s(OK rc=1)
  [OK] 本体: 码表 2.1s · 铺开 1.8s · 决策链 2.7s · 趋势 0.3s · 检索 0.4s · 参数表 1.0s · 审计 1.2s
  → exit 0

末尾审计: **9702 对象 / 0 问题**(悬空引用 0 · 孤儿 0 · 判级对拍 3台×九系统 全一致 · 油样→部件链接 404/404);
页面: `/` 200 20,225,828 B · `/detail/` 200 · `/cms/` 200 414,141 B · `/sim/` `/sim/sys/` `/viewer/` 全 200 ·
healthz 6/7 ok(只剩本机模型 Ollama); 维护页: 报警 39211(末次 09:09) · 工单 5876(09:11) · 油样 404(09:11)。

手册 (§0) 已加"一条命令重算全部"一节, 含各开关与它按什么顺序做。
zhouyang.xie hai 4 semanas
pai
achega
7d45e66f8d
Modificáronse 3 ficheiros con 212 adicións e 13 borrados
  1. 41 0
      docs/重算操作手册_v0.1.md
  2. 130 0
      scripts/rebuild_all.py
  3. 41 13
      scripts/windscada_monthly_build.py

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

@@ -11,6 +11,47 @@
 
 ---
 
+## 0. 一条命令重算全部 (`scripts/rebuild_all.py`, 2026-09-12)
+
+前面那些步骤已经串成一个入口 —— **依赖顺序、验收锚点、子进程编码都写死在脚本里**, 不用记顺序:
+
+```bat
+cd /d <安装目录>
+:: 全套 (含 SCADA 侧 10 个构建器, 逐台读 ~14 GB, 约 15 分钟)
+.venv\Scripts\python.exe scripts\rebuild_all.py
+
+:: 常用档: 只换了台账类数据, 没动 scada_10min
+.venv\Scripts\python.exe scripts\rebuild_all.py --skip-scada
+
+:: 连"放数据"一起 (现场包目录)
+.venv\Scripts\python.exe scripts\rebuild_all.py --src F:\temp\如东风场数据
+
+:: 其它开关
+.venv\Scripts\python.exe scripts\rebuild_all.py --dry-run       :: 只打印计划, 不执行
+.venv\Scripts\python.exe scripts\rebuild_all.py --no-restart    :: 不碰服务(自己管启停)
+.venv\Scripts\python.exe scripts\rebuild_all.py --with-verify   :: 末尾加台账等价验收(需随包基线在位)
+```
+
+**它按什么顺序做**(为什么是这个顺序见脚本头部注释):
+
+| 步 | 做什么 | 脚本 |
+|---|---|---|
+| ① | 放数据(给了 `--src` 才跑) | `place_raw_data.py --scope full` |
+| ② | 三门台账(报警/工单/油样) | `rebuild_from_raw.py` |
+| ③ | SCADA 侧 10 个构建器 | `rebuild_from_raw.py --scada` |
+| ④ | 月度派生件 | `windscada_monthly_build.py` |
+| ⑤ | 补齐"包内没有生成端"的产物 | `products_restore_missing.py` |
+| ⑥ | 重启服务(**必须**: ⑦ 要吃 `/api/fleet`) | `guanlan.py stop` / `serve` |
+| ⑦ | 本体层: 码表→铺开→决策链→趋势→检索→参数表 | `-m src.ontology.*` |
+| ⑧ | 本体审计 + (可选)等价验收 | `-m src.ontology.audit` 等 |
+
+每步都是**幂等**的, 中途失败就按日志处理完再重跑同一条命令; 某步失败会立刻停下(后续步骤依赖它),
+最后打印每步的 `OK/FAIL` 与耗时。
+
+> 手工分步执行的完整版在 §1~§5b(换现场包、单跑某一环、或想知道每一步期望输出时看那些)。
+
+---
+
 ## 0. 先摸清现状(三条只读命令, 不改任何东西)
 
 ```bat

+ 130 - 0
scripts/rebuild_all.py

@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""一条命令重算全部 (2026-09-12) —— 把散在各脚本里的重算链按依赖顺序串起来。
+
+## 为什么要有它
+
+"从零重算"原本是六七条命令、顺序还不能错 (产物齐了本体层才跑得动, 本体层跑完才能拷随包件…),
+手工执行容易漏步、也说不清"到底重算了什么"。本脚本把顺序、依赖、验收锚点全写死在这里。
+
+## 顺序 (为什么是这个顺序)
+
+    ① 放数据      place_raw_data.py --scope full      (给了 --src 才跑; 同尺寸自动跳过)
+    ② 三门台账    rebuild_from_raw.py                 报警/工单/油样
+    ③ SCADA 侧    rebuild_from_raw.py --scada         10 个构建器, 逐台读 ~14 GB, 约 15 分钟
+    ④ 月度派生件  windscada_monthly_build.py          能从 raw 重算的月表(与随包件逐值对齐才落盘)
+    ⑤ 补齐缺件    products_restore_missing.py         包内没有生成端的那批(只补缺, 不覆盖重算件)
+       ↑ 必须在 ⑥ 之前: 本体层的 populate/chain_ingest 要吃这些 L1 产物
+    ⑥ 重启服务    guanlan.py stop/serve               chain_ingest 要从 /api/fleet 取链盘
+    ⑦ 本体层      kb_ingest → populate → chain_ingest → trend_ingest → retrieval → refresh_params
+    ⑧ 验收        ontology.audit + rebuild_from_raw --verify (可选) + 页面对照
+
+## 用法
+
+    python scripts/rebuild_all.py                      # 全套 (会跑 SCADA 那 15 分钟)
+    python scripts/rebuild_all.py --skip-scada         # 只换台账时的常用档
+    python scripts/rebuild_all.py --src F:\temp\如东风场数据   # 连"放数据"一起
+    python scripts/rebuild_all.py --no-restart         # 不碰服务(自己管)
+    python scripts/rebuild_all.py --with-verify        # 末尾加等价验收(需随包基线在位)
+    python scripts/rebuild_all.py --dry-run            # 只打印计划
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import pathlib
+import subprocess
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+PY = sys.executable
+
+
+def step_cmd(name, cmd, tolerate=()):
+    """tolerate: 允许的退出码 (不算失败)。"""
+    return (name, cmd, tuple(tolerate))
+
+
+def build_plan(a) -> list:
+    plan = []
+    if a.src:
+        plan.append(step_cmd('① 放数据', [PY, 'scripts/place_raw_data.py', '--src', a.src, '--scope', 'full']))
+    plan.append(step_cmd('② 三门台账', [PY, 'scripts/rebuild_from_raw.py']))
+    if not a.skip_scada:
+        plan.append(step_cmd('③ SCADA 侧 10 个构建器 (约 15 分钟)', [PY, 'scripts/rebuild_from_raw.py', '--scada']))
+    plan.append(step_cmd('④ 月度派生件', [PY, 'scripts/windscada_monthly_build.py']))
+    plan.append(step_cmd('⑤ 补齐"包内没有生成端"的产物', [PY, 'scripts/products_restore_missing.py']))
+    if not a.no_restart:
+        plan.append(step_cmd('⑥ 重启服务 (stop)', [PY, 'guanlan.py', 'stop']))
+        # guanlan.py serve 在"有模块 DOWN"时**故意返回 1**(degraded 是正常状态: 如本机 Ollama 没模型),
+        # 所以这一步单独允许退出码 1, 由 tolerate={...} 标注。
+        plan.append(step_cmd('⑥ 重启服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,)))
+    plan.append(step_cmd('⑦ 本体: 码表/手册/文档', [PY, '-m', 'src.ontology.kb_ingest']))
+    plan.append(step_cmd('⑦ 本体: 铺开判级与台账', [PY, '-m', 'src.ontology.populate']))
+    plan.append(step_cmd('⑦ 本体: 决策链进度', [PY, '-m', 'src.ontology.chain_ingest']))
+    plan.append(step_cmd('⑦ 本体: 在升/闭环证据', [PY, '-m', 'src.ontology.trend_ingest']))
+    plan.append(step_cmd('⑦ 本体: 检索索引', [PY, '-c', 'from src.ontology import retrieval as R; R.build(use_vec=False)']))
+    plan.append(step_cmd('⑦ 本体: 实机参数表', [PY, '-c', 'from src.ontology.maintenance import refresh_params as f; print(f())']))
+    plan.append(step_cmd('⑧ 本体审计 (期望 0 问题)', [PY, '-m', 'src.ontology.audit']))
+    if a.with_verify:
+        # 退出码 4 = 有"无法归类"的差异; 本包已知的差异是"油样 102 行源件不在现场包里"(属人工项),
+        # 所以容忍 4 但把完整报告打给用户看 —— 真出现新差异时报告里会点名。
+        plan.append(step_cmd('⑧ 台账等价验收 (rc=4 表示有需人工看的差异, 属已知)', 
+                             [PY, 'scripts/rebuild_from_raw.py', '--verify'], tolerate=(4,)))
+    return plan
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--src', default=None, help='现场数据包目录 (给了就先跑 place_raw_data --scope full)')
+    ap.add_argument('--skip-scada', action='store_true', help='跳过 SCADA 侧 10 个构建器 (没换 10min 数据时用)')
+    ap.add_argument('--no-restart', action='store_true', help='不自动重启服务')
+    ap.add_argument('--with-verify', action='store_true', help='末尾加等价验收')
+    ap.add_argument('--dry-run', action='store_true', help='只打印计划')
+    a = ap.parse_args()
+
+    plan = build_plan(a)
+    print(f'重算全部 · {len(plan)} 步 · 根目录 {ROOT}')
+    print(f'  跳过 SCADA: {a.skip_scada} | 重启服务: {not a.no_restart} | 等价验收: {a.with_verify}\n')
+    if a.dry_run:
+        for name, cmd, tol in plan:
+            print(f'  {name:34s} {" ".join(cmd[1:])[:90]}')
+        print('\n(dry-run, 未执行)')
+        return 0
+
+    # 子进程环境: 与 guanlan.py 给服务的一致 —— 中文 Windows 控制台是 GBK, 不设的话
+    # 子脚本 print 里的 ✔/✗ 会抛 UnicodeEncodeError, 明明干成了却以退出码 1 结束。
+    env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
+    results, t_all = [], time.time()
+    for i, (name, cmd, tol) in enumerate(plan, 1):
+        print(f'\n{"=" * 78}\n[{i}/{len(plan)}] {name}\n$ {" ".join(cmd)}\n{"=" * 78}', flush=True)
+        t0 = time.time()
+        rc = subprocess.run(cmd, cwd=str(ROOT), env=env).returncode
+        dt = time.time() - t0
+        ok = (rc == 0) or (rc in tol)
+        results.append((name, rc, dt, ok, tol))
+        if not ok:
+            print(f'\n[X] 第 {i} 步失败 (退出码 {rc}): {name} —— 后面的步骤依赖它, 先停下。')
+            break
+        if rc != 0:
+            print(f'\n    (退出码 {rc} 属预期: {name} 的 degraded 是正常状态 —— 例如本机模型未启动)')
+
+    print(f'\n{"=" * 78}\n汇总 (总耗时 {time.time() - t_all:.0f}s)')
+    for name, rc, dt, ok, tol in results:
+        tag = 'OK' if rc == 0 else (f'OK(rc={rc})' if ok else 'FAIL')
+        print(f'  [{tag:9s}] {name:34s} {dt:7.1f}s')
+    n_fail = sum(1 for *_, ok, _ in results if not ok)
+    if n_fail:
+        print(f'\n结论: {n_fail} 步失败 —— 按上面的日志处理后再重跑本命令 (各步都是幂等的)')
+        return 1
+    print('\n结论: 全部完成。核验: .venv\\Scripts\\python.exe -m src.ontology.maintenance '
+          '(数据层/机理层的条数与末次更新); 浏览器 http://127.0.0.1:28084/detail/')
+    return 0
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
+    sys.exit(main())

+ 41 - 13
scripts/windscada_monthly_build.py

@@ -45,12 +45,7 @@ GROUPS = ['A.功率', 'B.温度NBM']
 
 
 def baseline_dir():
-    """"标准答案"目录: 随包件在哪。
-
-    优先 `outputs/<场>/windscada/_pre_rebuild_20260911/`(rebuild_from_raw --verify 用的那份);
-    没有就回落到 `_products_off/**/windscada/`(products_state --off 挪走后的暂存区, 内容就是随包件)。
-    两处都没有 → None(只报"没法比", 不当成错)。
-    """
+    """"标准答案"目录: 随包件在哪 (整目录视角; 单件比对用 baseline_for)。"""
     inplace = P.store() / '_pre_rebuild_20260911'
     if inplace.is_dir():
         return inplace
@@ -60,6 +55,27 @@ def baseline_dir():
     return None
 
 
+def baseline_for(name: str):
+    """这一件产物的"标准答案"在哪。
+
+    注意两个暂存处**内容不同**, 必须逐件找:
+      · `outputs/<场>/windscada/_pre_rebuild_20260911/` = **台账验收基线**(3 件: alarms/workorders/oil_samples);
+      · `_products_off*/**/windscada/`                   = 随包整份产物(31 件 parquet, 含月度派生件)。
+    原先只认第一个目录, 于是 temp_monthly 会被报成"没有随包基线可比", 而结论却打印"全部逐值一致"
+    —— 2026-09-12 实测逮到(假结论比没结论更糟)。
+    """
+    cands = []
+    bd = baseline_dir()
+    if bd:
+        cands.append(bd / name)
+    for p in sorted((ROOT).glob('_products_off*/**/windscada')):
+        cands.append(p / name)
+    for c in cands:
+        if c.is_file():
+            return c
+    return None
+
+
 BASELINE = baseline_dir()
 
 
@@ -104,16 +120,23 @@ PRODUCTS = {
 
 
 def verify(cfg) -> int:
-    """逐件与随包件比对: 行数、键集合、逐值相等比例。"""
-    ok_all = True
+    """逐件与随包件比对: 行数、键集合、逐值相等比例。
+
+    三种结果分开报, 不许混:
+      ✅ 逐值一致  ❌ 有差异(规则需修正)  ?? 找不到标准答案(**不能**当成一致)
+    """
+    ok_all, compared, no_base = True, 0, []
     for name in PRODUCTS:
-        mine_p, base_p = P.store() / name, BASELINE / name
+        mine_p = P.store() / name
         if not mine_p.exists():
             print(f'  [ -- ] {name:34s} 本仓还没算 (跳过)')
             continue
-        if not base_p.exists():
-            print(f'  [ ?? ] {name:34s} 没有随包基线可比 (基线目录 {P.rel(BASELINE)})')
+        base_p = baseline_for(name)
+        if base_p is None:
+            no_base.append(name)
+            print(f'  [ ?? ] {name:34s} 找不到随包件可对比 (台账基线只有 3 件; 随包整份产物在 _products_off*/ 下)')
             continue
+        compared += 1
         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)
@@ -127,12 +150,17 @@ def verify(cfg) -> int:
         ok_all &= good
         mark = '✅' if good else '❌'
         print(f'  [{mark}] {name:34s} 基线 {len(a)} 行 / 本仓 {len(b)} 行; 逐值相等 {eq}/{n};'
-              f' 仅基线 {only_b}; 仅本仓 {only_m}')
+              f' 仅基线 {only_b}; 仅本仓 {only_m}   [标准答案: {P.rel(base_p)}]')
         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 '有差异 —— 规则还需修正, 别急着写盘')
+    if not compared:
+        print(f'\n结论: **无法比对**(没有一件找到随包件) —— 这不是"通过"。缺标准答案: {no_base}')
+        return 5
+    if no_base:
+        print(f'\n注意: 另有 {len(no_base)} 件找不到标准答案, 未比对: {no_base}')
+    print('\n结论:', '已比对的部分全部逐值一致' if ok_all else '有差异 —— 规则还需修正, 别急着写盘')
     return 0 if ok_all else 4