Переглянути джерело

2.58.1 重算链安全清理(⑦b/⑧b 按最小包含语句删, 28->26 步) + 逐件台账再生(products_restore_missing --refresh: _provenance.json::files 恢复 4170 条) => pages_audit 0 项要处理, 全门禁通过

zhouyang.xie 1 тиждень тому
батько
коміт
4630df6f32
1 змінених файлів з 636 додано та 321 видалено
  1. 636 321
      app_ETL/app_ETL_guanlan/builders/rebuild_all.py

+ 636 - 321
app_ETL/app_ETL_guanlan/builders/rebuild_all.py

@@ -1,321 +1,636 @@
-#!/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 重算的月表(与随包件逐值对齐才落盘)

-    ④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  账实相符(谁在位、谁有生成端); 只记账不搬件

-    ⑤ 反向呼应审计 products_reverse_audit.py --check   逐件回溯"输出→生成端→输入"(用户令 2026-09-17 #1: 运行期不从交付包补齐)

-       ↑ 必须在 ⑥ 之前: 本体层的 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 <现场包目录>   # 连"放数据"一起

-    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

-

-try:

-    from app_common.app_common_guanlan.api import install_root as _install_root

-except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳

-    from pathlib import Path as _P

-    def _install_root(_f): return _P(_f).resolve().parents[3]

-

-import argparse

-import json

-import os

-import pathlib

-import subprocess

-import sys

-import time

-

-ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)

-PY = sys.executable

-

-

-def step_cmd(name, cmd, tolerate=(), note=''):

-    """tolerate: 允许的退出码 (不算失败); note: 该步非零退出时打给人看的解释(按步, 别用一句通用话糊过去)。"""

-    return (name, cmd, tuple(tolerate), note)

-

-

-def _scan_plan() -> tuple[list, str]:

-    """跑一次输入数据扫描(`--json --check`)→ (该跑的步骤列表, 人读摘要)。扫不动就返回 ([], 原因)。

-

-    为什么要它 (用户令 2026-09-19「增加自动扫描识别机制, 以能发现新增数据, 并纳入重算」):

-    `--auto` 靠它决定"人给的 --skip-* 该不该让路" —— 新数据落在被跳过的族里时自动取消跳过。

-    """

-    try:

-        # ★必须显式给子进程 UTF-8 并按其解码 (2026-09-19 实逮: 子进程写 UTF-8、父进程按 GBK 解 ⇒

-        #   步骤名一串乱码, `startswith('③')` 永远不匹配 —— 扫描明明发现新数据, 却没取消 --skip-*)

-        env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')

-        r = subprocess.run([PY, 'scripts/raw_scan.py', '--json', '--check'], cwd=str(ROOT), env=env,

-                           capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=1800)

-        d = json.loads(r.stdout or '{}')

-        return list(d.get('steps') or []), (f'发现 {len(d.get("changes") or [])} 处新增/变化'

-                                            + ('(' + ' / '.join(d.get('steps') or []) + ')' if d.get('steps') else ''))

-    except Exception as e:

-        return [], f'扫描没跑成({type(e).__name__}: {str(e)[:80]})—— 不影响重算, 但 --auto 无依据'

-

-

-def build_plan(a) -> list:

-    plan = []

-    if a.auto:

-        # --auto: 先看"这回新来了什么", 再决定人给的 --skip-* 要不要让路 (用户令 2026-09-19)

-        steps, why = _scan_plan()

-        print(f'  [auto] 输入数据扫描: {why}')

-        if steps and a.skip_scada and any(s.startswith(('③', '④ ')) or s.startswith('④c') for s in steps):

-            print('  [auto] 有新数据落在 SCADA/变桨族里 ⇒ **取消 --skip-scada**(新数据必须进重算)')

-            a.skip_scada = False

-        if steps and a.skip_vib and any(s.startswith('④b') for s in steps):

-            print('  [auto] 有新数据落在振动族里 ⇒ **取消 --skip-vib**')

-            a.skip_vib = False

-    if a.src:

-        plan.append(step_cmd('① 放数据', [PY, 'scripts/place_raw_data.py', '--src', a.src, '--scope', 'full']))

-    # ①b 输入数据扫描 (用户令 2026-09-19): 逐族指纹 → 发现新增/变化 → 指明该跑哪几步, 并把本次指纹记成

-    #    新基线(下次比的基准)。rc: 0 无变化 · 4 有新增/变化(**不是失败**) · 5 首次没有快照(顺手记一份)。

-    plan.append(step_cmd('①b 输入数据扫描 (新增/变化 → 该跑哪些步)',

-                         [PY, 'scripts/raw_scan.py', '--check', '--write'], tolerate=(4, 5),

-                         note='rc=4 = 这次有新数据(不是失败)—— 按它列出的步骤重跑即纳入;'

-                              'rc=5 = 还没有快照,本次顺手记一份基线'))

-    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']))

-    # ③b SCADA 10min **窄仓**(用户令 2026-09-21「判级/曲线随所选时间窗重算」的底座):

-    #   一次全量扫(≈2 分钟)抽出约 46 列公共子集 ⇒ 之后任何窗的判级/镜头重算从"重读 15 GB"

-    #   变成"窄仓上过滤+分组"(秒级)。数据换了必须重跑,否则按窗重算会用旧值。

-    if not a.skip_scada:

-        plan.append(step_cmd('③b SCADA 10min 窄仓 (按窗重算底座)', [PY, 'scripts/scada_slim_build.py']))

-    # ④ 月度派生件: `windscada_monthly_build.py` 会拿随包基线**逐值比对**, 没有基线时它返回 5

-    #    ("无法比对 —— 这不是通过", 这是它的诚实口径, 不要改它的退出码)。

-    #    ★但"重算链"与"等价验收"是两件事: 基线只是**验收标准答案**, 缺它不该让整条链停在第④步

-    #    (2026-09-16 实逮: 基线目录被清理后, 从页面点"执行重算" 387 s 后 rc=1 断在这里, ⑦⑧ 全不执行)。

-    #    故这里容忍 5 并写清"跳过了什么"; 要真做验收, 先把随包件恢复成暂存区/基线(见 products_state.py)。

-    plan.append(step_cmd('④ 月度派生件', [PY, 'scripts/windscada_monthly_build.py'],

-                         tolerate=(5,),

-                         note='rc=5 = 找不到随包基线可比对 ⇒ **跳过等价验收**(不是通过)。'

-                              '要恢复验收能力: 把随包产物放回 _products_off*/<场>/ 或 '

-                              'outputs/<场>/windscada/_pre_rebuild_20260911/'))

-    if not a.skip_vib:

-        # 振动侧 (2026-09-12 用户令"振动数据参与运行、重算"): 原始导出的 Brande TCM JSON

-        # (data/raw/<场站>/windcms/**/*_decode.json) → 窗索引 + 谱库 **+ CMS 报告/逐台页**。

-        # 消费者 (src/windcms/data.py) 自动发现新窗, 所以这一步之后 CMS 的谱图/标量/页面就带上了新数据。

-        # ★2026-09-18 用户令(" /detail 各页面不许用旧版产出补, 必须基于输入数据重算")后**口径翻转**:

-        #   原先默认不跑报告(`--vib-report` 显式开), 理由是"重生成会让 report.md 从 16,857 B 掉到

-        #   467 B(融合级表空)" —— 那是拿**随包旧快照**当基准; 现在包里没有旧产物, 页面要么有自算内容

-        #   要么是空的。实测从重算出的窗 w0316 一键出报告: 38 台判级齐(报警 3/优秀 35), 只缺

-        #   model_run/fusion 两步的**融合级列**(如实写 —), `/api/vibcms` 立即可用。

-        #   故默认带报告, 要旧行为用 --no-vib-report。

-        # 现场没有振动原始件时本步**空跑退出 0** (不是每台机器都有振动导出, 不该报失败)。

-        plan.append(step_cmd('④b 振动侧摄入 + CMS 报告 + 标量 z (原始导出 → 窗索引/谱/逐台页/融合 z)',

-                             [PY, 'scripts/vib_raw_build.py'] + ([] if a.vib_report else ['--no-report']),

-                             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」):

-    #     由重算产物(alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6/fusion)算 claim,

-    #     写成 sop/findings.json + paradigm_r1 三份底稿(与契约构建器同结构) ⇒ 契约/门户/问答三处随重算刷新。

-    #     ★必须排在 ⑤a 台账维护之前: 这两步产出的件要进同一轮台账与审计, 否则审计会按"溯源缺失"报 rc=6。

-    plan.append(step_cmd('⑤b 事实契约: 由重算台账生成 claim', [PY, 'scripts/sop_findings_from_ledger.py'],

-                         note='从重算产物生成; 判据与缺证据逐条写在 claim 里(相对判据封顶「候选」)'))

-    plan.append(step_cmd('⑤c 事实契约: 构建 + 渲染消费者', [PY, 'scripts/guanlan_facts_contract.py', 'build'],

-                         tolerate=(2,),

-                         note='rc=2 = 契约自检不过(sha/脱敏/枚举), 需要看输出; 门户结论段由 ⑧b 重装时刷新'))

-    # ⑤a 台账维护 (用户令 2026-09-18: 页面只认"有来路的件" ⇒ 台账必须跟着重算一起更新)。

-    #    ★实逮: 2026-09-18 用户「清除产物」后从 raw 全量重算, `outputs/<场>/_provenance.json`

-    #      **整本丢了** —— 因为写它的唯一入口 (旧的第⑤步 products_restore_missing.py) 已按用户令

-    #      从链上拿下, 而新的第⑤步只**读**台账不写。后果: 门户/页面的「逐件来源」、反向呼应审计的

-    #      族匹配全部退化成"未归类", 而盘上 1,728 件其实件件有生成端。故把 `--refresh`(纯账实相符,

-    #      不搬任何随包件) 作为**独立一步**放回链上, 位置在审计之前。

-    plan.append(step_cmd('⑤a 逐件来源台账 (账实相符: 谁在位、谁有生成端)',

-                         [PY, 'scripts/products_restore_missing.py', '--refresh'],

-                         note='只维护台账, **不搬任何随包件**(用户令 2026-09-17: 运行期不从交付包补齐); '

-                              '盘上有而无人登记的件按族表归口, 归不上族的记 unregistered 并打出来'))

-    # ⑤ 反向呼应审计 (用户令 2026-09-17 #1): **运行链不再从含产物的交付包补齐**。

-    #    原先这一步跑 products_restore_missing.py —— 那等于"重算链依赖一个包", 与"目标机放数据自己重算"

-    #    这条主线冲突: 交付包默认不含产物, 于是这一步在真机上要么报"找不到随包件来源"(rc=6), 要么

-    #    被指到某个 zip 上(而那正是用户明令不要的)。改成**只报账、不搬运**: 逐件回溯"这件产物有没有

-    #    生成端与输入", 把没有来路的件如实列出来, 供研发补生成端 (口径见 docs §13.6)。

-    #    rc=5 = 有未归类/判据失败(例如产物被清后族表匹配不到) —— 容忍, 别为它打断后面的本体链与审计。

-    plan.append(step_cmd('⑤ 反向呼应审计 (输出→功能/算法→输入; 不从交付包补齐)',

-                         [PY, 'scripts/products_reverse_audit.py', '--check'],

-                         tolerate=(5,),

-                         note='rc=5 = 有未归类件或判据失败 ⇒ 停下看清单再决定(不打断整条链)。'

-                              '★ 运行期一律不由交付包补齐(用户令 2026-09-17): 缺件要么放原始件重算, '

-                              '要么由研发补生成端; 逐族可逆性见 docs §13.6'))

-    if not a.no_restart:

-        # 只重启**组件服务**, 网关留着 —— 两个理由:

-        #  ① 控制台页面是网关提供的, 连它一起停的话, 从页面点的重算会把自己的界面(甚至自己)弄没;

-        #  ② rebuild_all 自己可能就是从控制台/网关起的任务, `guanlan.py stop` 用 taskkill /T,

-        #     连网关会**沿父子树把正在执行的重算一起杀掉**(2026-09-12 实测: 日志停在 stop 那行, 之后全无)。

-        # 组件停掉再 serve 就够: chain_ingest 要的是 detail(18033) 重新加载新产物, 网关不缓存产物。

-        plan.append(step_cmd('⑥ 重启组件服务 (保留网关, stop)', [PY, 'scripts/_ops_stop_keep_gateway.py']))

-        plan.append(step_cmd('⑥ 重启组件服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,),

-                             note='serve 在"有模块 DOWN"时故意返回 1 —— degraded 是正常状态(例如本机模型未启动)'))

-    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())']))

-    # ⑦b 总览「全场状态」页 (`outputs/<场>/windscada/index.html`): /detail/v2 的 iframe 目标。

-    #     用户令 2026-09-19「所有的计算均要形成观澜的源代码」——它此前是随包件(0 处写入方),

-    #     新机器上那块只能显示"产物不在位"。现在用组件同一个烘焙器从重算产物生成。

-    #     ★必须排在 ⑦ 之后: 烤进去的响应里有本体数据(objects.json)。

-    plan.append(step_cmd('⑦b 全场状态总览页 (自算烘焙)', [PY, 'scripts/windscada_overview_build.py'],

-                         note='烤的是组件同一套取数函数(见脚本头); 缺产物时会响亮报错, 不产出半张页'))

-    plan.append(step_cmd('⑧ 本体审计 (期望 0 问题)', [PY, '-m', 'src.ontology.audit']))

-    # ⑧b 重装门户: 门户正文里的"契约结论段"是**产物派生**的(由 guanlan_portal_inject_claims.py

-    #     从 outputs/<场>/guanlan/… 渲染), 所以产物变了门户必须重装一遍, 否则页面挂着旧结论。

-    #     装配是"先写临时文件再原子替换", 失败不会破坏正在被网关读的 portal.html (见 portal_build 注释)。

-    plan.append(step_cmd('⑧b 重装门户 (把新产物灌进门户结论段)', [PY, 'scripts/portal_build.py'],

-                         tolerate=(1,),

-                         note='rc=1 = 缺契约产物(产物被清除时属预期) ⇒ 门户保留原样, 不是装配坏了'))

-    # ⑧c 页面归口审计: 重算之后, 那些"把数据烘进页面"的页面件(见 configs/portal_pages.yaml)会不会陈旧。

-    #     rc=7 = 有页面陈旧(数据换了页面没换) —— 不打断整条链, 但必须报出来, 处置见 docs §10。

-    #     ★ rc=5 也容忍 (2026-09-17 实逮): rc=5 = "登记的文件/被引产物不在位", 而**包内没有生成端**的那批

-    #       随包件(如 windscada/index.html、m5_cms_tcm/tcm_index.parquet、windcms 页面件)在

-    #       "清过产物 + 交付包不含产物"的机器上必然不在位 ⇒ 第 16 步判失败、整条链 rc=1, 而且把最后的

-    #       ⑧台账等价验收也跳过了(实测 2026-09-17: 用户在页面上点重算, 1367 s 后 rc=1 断在这里)。

-    #       这与 ④/⑤/⑥/⑧b 的口径一致: 缺的是"随包件"这一路, 不是重算本身。缺件仍会由

-    #       `guanlan.py check` 报 FAIL(那是给人看的告警位), 本步只保证"链不白断"。

-    plan.append(step_cmd('⑧c 页面归口审计 (页面陈旧检测)', [PY, 'scripts/pages_audit.py', '--check'],

-                         tolerate=(5, 6, 7),

-                         note='rc=7 = 有 data-derived 页面陈旧(内嵌快照的 source_sha256 与当前产物不一致): '

-                              '需要对应生成端重跑, 或在登记表里把它改成 frozen-delivery 并接受会过期; '

-                              'rc=5 = 登记的文件/被引产物不在位(清过产物、且那批件没有生成端时属预期); '

-                              '★rc=6 也容忍 (2026-09-19 实逮): rc=6 = 溯源缺失(产物在盘上/登记里, 但台账查不到来路) '

-                              '—— 与 5 同属"缺的是随包件这一路", 例如 ④b 在 report 步崩掉时 '

-                              'vib_raw_manifest.json 没写成, 审计就按"溯源缺失"报 6 并把整条链判失败; '

-                              '处置见 docs §7 与「数据重算」页的缺件提示; `guanlan.py check` 里仍有给人看的告警位'))

-    # ⑧d 版本记录一致性 (2026-09-22 实逮, 远端 2.9.1 升级+重算后验收才暴露):

-    #     那次升级的差异盘点只覆盖 src/ + scripts/ + configs/ —— **文档不在盘点面上**, 于是代码升到 2.9.1、

-    #     docs/版本记录.md 与 docs/系统设计说明.md 留在旧版; 而重算链此前只到 ⑧c, 没有版本门 ⇒

-    #     24/24 步全 OK、控制台写"重算完成", 版本/文档漂移**没有任何自动拦截点**(只有人工 `guanlan.py check` 能逮)。

-    #     与 ⑧c 同口径: tolerate=6 —— 缺的是"文档同步"这一路, 不是重算本身 ⇒ 报出来但不打断整条链。

-    plan.append(step_cmd('⑧d 版本记录一致性 (docs 与 src/version.py)',

-                         [PY, 'scripts/version_log.py', '--check'], tolerate=(6,),

-                         note='rc=6 = docs/版本记录.md 的自动区块与 src/version.py 的 HISTORY 不一致 '

-                              '(升级只同步了代码、没同步文档即属此类) ⇒ 跑 python scripts/version_log.py --write '

-                              '重生成自动区块, 再把 docs/ 一并同步到目标机(升级差异盘点要含 docs/); '

-                              '`guanlan.py check` 里仍有给人看的 FAIL 位'))

-    if a.with_verify:

-        # ★2026-09-20 改 (远端实逮, 2.8.0 在服务器上一轮 24/24 步全 OK 却整轮 rc=1):

-        #   原先 tolerate=(4,) —— rc=5("找不到随包基线 ⇒ 没验收") 被当成失败, 于是**交付包装出来的机器

-        #   (按用户令包内不含产物 ⇒ 基线必然不存在)每次重算都以 rc=1 结束, 门户上写"重算失败"**,

-        #   而实质步骤全绿。这与 ④ 的口径自相矛盾(④ 容忍 5 并写明"跳过等价验收≠通过")。

-        #   现在: rc=4(有需人工看的差异, 本包已知项: 油样 102 行源件不在现场包) 与 rc=5(没基线=没验收)

-        #   都容忍, 但**都在汇总行里如实标 rc**、并在步说明里写清"这不是通过"; 想真做验收就让

-        #   `scripts/set_baseline.py` 建一份基线(用已核实的产物快照 + 写明来历), 之后这一项才会真比对。

-        plan.append(step_cmd('⑧ 台账等价验收 (rc=4 = 有需人工看的差异; rc=5 = 没基线 ⇒ 没验收)',

-                             [PY, 'scripts/rebuild_from_raw.py', '--verify'], tolerate=(4, 5),

-                             note='rc=4 = 有需人工看的差异(本包已知项, 见 docs §7); '

-                                  'rc=5 = 找不到随包基线 ⇒ **没做验收**(不是失败, 也不是通过)。'

-                                  '★ 交付包按用户令不含产物 ⇒ 全新机器上这一步必然 rc=5; 要恢复验收能力: '

-                                  '用 python scripts/set_baseline.py 一条命令把当前已核实的产物快照登记成基线, '

-                                  '再重算就能真比对'))

-    return plan

-

-

-def main() -> int:

-    ap = argparse.ArgumentParser()

-    ap.add_argument('--src', default=None, help='现场数据包目录 (给了就先跑 place_raw_data --scope full)')

-    ap.add_argument('--auto', action='store_true',

-                    help='先扫一遍输入数据(raw_scan.py),发现新数据落在被 --skip-* 跳过的族里时自动取消跳过'

-                         '(用户令 2026-09-19「发现新增数据并纳入重算」)')

-    ap.add_argument('--skip-scada', action='store_true', help='跳过 SCADA 侧 10 个构建器 (没换 10min 数据时用)')

-    ap.add_argument('--skip-vib', action='store_true', help='跳过振动侧摄入 (没换 CMS 原始导出时用)')

-    ap.add_argument('--no-vib-report', dest='vib_report', action='store_false',

-                    help='振动侧只摄入(索引/谱), 不生成 CMS 报告/逐台页 —— ★默认**生成**(2026-09-18 口径'

-                         '翻转: 包里没有旧产物可参照, 页面要么有自算内容要么空白; 报告只缺 '

-                         'model_run/fusion 的融合级列, 如实写 —)')

-    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, note 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, note) 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} —— 后面的步骤依赖它, 先停下。')

-            if note:

-                print(f'    该步说明: {note}')

-            break

-        if rc != 0:

-            print(f'\n    (退出码 {rc} 属预期{(": " + note) if note else ""})')

-

-    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())

+#!/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 重算的月表(与随包件逐值对齐才落盘)
+
+    ④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  账实相符(谁在位、谁有生成端); 只记账不搬件
+
+    ⑤ 反向呼应审计 products_reverse_audit.py --check   逐件回溯"输出→生成端→输入"(用户令 2026-09-17 #1: 运行期不从交付包补齐)
+
+       ↑ 必须在 ⑥ 之前: 本体层的 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 <现场包目录>   # 连"放数据"一起
+
+    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
+
+
+
+try:
+
+    from app_common.app_common_guanlan.api import install_root as _install_root
+
+except ImportError:                     # 理论不可达;包结构异常时回退到按位置上跳
+
+    from pathlib import Path as _P
+
+    def _install_root(_f): return _P(_f).resolve().parents[3]
+
+
+
+import argparse
+
+import json
+
+import os
+
+import pathlib
+
+import subprocess
+
+import sys
+
+import time
+
+
+
+ROOT = _install_root(__file__)      # 模块化后按标记找安装根(原 parents[1] 已不成立)
+
+PY = sys.executable
+
+
+
+
+
+def step_cmd(name, cmd, tolerate=(), note=''):
+
+    """tolerate: 允许的退出码 (不算失败); note: 该步非零退出时打给人看的解释(按步, 别用一句通用话糊过去)。"""
+
+    return (name, cmd, tuple(tolerate), note)
+
+
+
+
+
+def _scan_plan() -> tuple[list, str]:
+
+    """跑一次输入数据扫描(`--json --check`)→ (该跑的步骤列表, 人读摘要)。扫不动就返回 ([], 原因)。
+
+
+
+    为什么要它 (用户令 2026-09-19「增加自动扫描识别机制, 以能发现新增数据, 并纳入重算」):
+
+    `--auto` 靠它决定"人给的 --skip-* 该不该让路" —— 新数据落在被跳过的族里时自动取消跳过。
+
+    """
+
+    try:
+
+        # ★必须显式给子进程 UTF-8 并按其解码 (2026-09-19 实逮: 子进程写 UTF-8、父进程按 GBK 解 ⇒
+
+        #   步骤名一串乱码, `startswith('③')` 永远不匹配 —— 扫描明明发现新数据, 却没取消 --skip-*)
+
+        env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
+
+        r = subprocess.run([PY, 'scripts/raw_scan.py', '--json', '--check'], cwd=str(ROOT), env=env,
+
+                           capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=1800)
+
+        d = json.loads(r.stdout or '{}')
+
+        return list(d.get('steps') or []), (f'发现 {len(d.get("changes") or [])} 处新增/变化'
+
+                                            + ('(' + ' / '.join(d.get('steps') or []) + ')' if d.get('steps') else ''))
+
+    except Exception as e:
+
+        return [], f'扫描没跑成({type(e).__name__}: {str(e)[:80]})—— 不影响重算, 但 --auto 无依据'
+
+
+
+
+
+def build_plan(a) -> list:
+
+    plan = []
+
+    if a.auto:
+
+        # --auto: 先看"这回新来了什么", 再决定人给的 --skip-* 要不要让路 (用户令 2026-09-19)
+
+        steps, why = _scan_plan()
+
+        print(f'  [auto] 输入数据扫描: {why}')
+
+        if steps and a.skip_scada and any(s.startswith(('③', '④ ')) or s.startswith('④c') for s in steps):
+
+            print('  [auto] 有新数据落在 SCADA/变桨族里 ⇒ **取消 --skip-scada**(新数据必须进重算)')
+
+            a.skip_scada = False
+
+        if steps and a.skip_vib and any(s.startswith('④b') for s in steps):
+
+            print('  [auto] 有新数据落在振动族里 ⇒ **取消 --skip-vib**')
+
+            a.skip_vib = False
+
+    if a.src:
+
+        plan.append(step_cmd('① 放数据', [PY, 'scripts/place_raw_data.py', '--src', a.src, '--scope', 'full']))
+
+    # ①b 输入数据扫描 (用户令 2026-09-19): 逐族指纹 → 发现新增/变化 → 指明该跑哪几步, 并把本次指纹记成
+
+    #    新基线(下次比的基准)。rc: 0 无变化 · 4 有新增/变化(**不是失败**) · 5 首次没有快照(顺手记一份)。
+
+    plan.append(step_cmd('①b 输入数据扫描 (新增/变化 → 该跑哪些步)',
+
+                         [PY, 'scripts/raw_scan.py', '--check', '--write'], tolerate=(4, 5),
+
+                         note='rc=4 = 这次有新数据(不是失败)—— 按它列出的步骤重跑即纳入;'
+
+                              'rc=5 = 还没有快照,本次顺手记一份基线'))
+
+    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']))
+
+    # ③b SCADA 10min **窄仓**(用户令 2026-09-21「判级/曲线随所选时间窗重算」的底座):
+
+    #   一次全量扫(≈2 分钟)抽出约 46 列公共子集 ⇒ 之后任何窗的判级/镜头重算从"重读 15 GB"
+
+    #   变成"窄仓上过滤+分组"(秒级)。数据换了必须重跑,否则按窗重算会用旧值。
+
+    if not a.skip_scada:
+
+        plan.append(step_cmd('③b SCADA 10min 窄仓 (按窗重算底座)', [PY, 'scripts/scada_slim_build.py']))
+
+    # ④ 月度派生件: `windscada_monthly_build.py` 会拿随包基线**逐值比对**, 没有基线时它返回 5
+
+    #    ("无法比对 —— 这不是通过", 这是它的诚实口径, 不要改它的退出码)。
+
+    #    ★但"重算链"与"等价验收"是两件事: 基线只是**验收标准答案**, 缺它不该让整条链停在第④步
+
+    #    (2026-09-16 实逮: 基线目录被清理后, 从页面点"执行重算" 387 s 后 rc=1 断在这里, ⑦⑧ 全不执行)。
+
+    #    故这里容忍 5 并写清"跳过了什么"; 要真做验收, 先把随包件恢复成暂存区/基线(见 products_state.py)。
+
+    plan.append(step_cmd('④ 月度派生件', [PY, 'scripts/windscada_monthly_build.py'],
+
+                         tolerate=(5,),
+
+                         note='rc=5 = 找不到随包基线可比对 ⇒ **跳过等价验收**(不是通过)。'
+
+                              '要恢复验收能力: 把随包产物放回 _products_off*/<场>/ 或 '
+
+                              'outputs/<场>/windscada/_pre_rebuild_20260911/'))
+
+    if not a.skip_vib:
+
+        # 振动侧 (2026-09-12 用户令"振动数据参与运行、重算"): 原始导出的 Brande TCM JSON
+
+        # (data/raw/<场站>/windcms/**/*_decode.json) → 窗索引 + 谱库 **+ CMS 报告/逐台页**。
+
+        # 消费者 (src/windcms/data.py) 自动发现新窗, 所以这一步之后 CMS 的谱图/标量/页面就带上了新数据。
+
+        # ★2026-09-18 用户令(" /detail 各页面不许用旧版产出补, 必须基于输入数据重算")后**口径翻转**:
+
+        #   原先默认不跑报告(`--vib-report` 显式开), 理由是"重生成会让 report.md 从 16,857 B 掉到
+
+        #   467 B(融合级表空)" —— 那是拿**随包旧快照**当基准; 现在包里没有旧产物, 页面要么有自算内容
+
+        #   要么是空的。实测从重算出的窗 w0316 一键出报告: 38 台判级齐(报警 3/优秀 35), 只缺
+
+        #   model_run/fusion 两步的**融合级列**(如实写 —), `/api/vibcms` 立即可用。
+
+        #   故默认带报告, 要旧行为用 --no-vib-report。
+
+        # 现场没有振动原始件时本步**空跑退出 0** (不是每台机器都有振动导出, 不该报失败)。
+
+        plan.append(step_cmd('④b 振动侧摄入 + CMS 报告 + 标量 z (原始导出 → 窗索引/谱/逐台页/融合 z)',
+
+                             [PY, 'scripts/vib_raw_build.py'] + ([] if a.vib_report else ['--no-report']),
+
+                             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」):
+
+    #     由重算产物(alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6/fusion)算 claim,
+
+    #     写成 sop/findings.json + paradigm_r1 三份底稿(与契约构建器同结构) ⇒ 契约/门户/问答三处随重算刷新。
+
+    #     ★必须排在 ⑤a 台账维护之前: 这两步产出的件要进同一轮台账与审计, 否则审计会按"溯源缺失"报 rc=6。
+
+    plan.append(step_cmd('⑤b 事实契约: 由重算台账生成 claim', [PY, 'scripts/sop_findings_from_ledger.py'],
+
+                         note='从重算产物生成; 判据与缺证据逐条写在 claim 里(相对判据封顶「候选」)'))
+
+    plan.append(step_cmd('⑤c 事实契约: 构建 + 渲染消费者', [PY, 'scripts/guanlan_facts_contract.py', 'build'],
+
+                         tolerate=(2,),
+
+                         note='rc=2 = 契约自检不过(sha/脱敏/枚举), 需要看输出; 门户结论段由 ⑧b 重装时刷新'))
+
+    # ⑤a 台账维护 (用户令 2026-09-18: 页面只认"有来路的件" ⇒ 台账必须跟着重算一起更新)。
+
+    #    ★实逮: 2026-09-18 用户「清除产物」后从 raw 全量重算, `outputs/<场>/_provenance.json`
+
+    #      **整本丢了** —— 因为写它的唯一入口 (旧的第⑤步 products_restore_missing.py) 已按用户令
+
+    #      从链上拿下, 而新的第⑤步只**读**台账不写。后果: 门户/页面的「逐件来源」、反向呼应审计的
+
+    #      族匹配全部退化成"未归类", 而盘上 1,728 件其实件件有生成端。故把 `--refresh`(纯账实相符,
+
+    #      不搬任何随包件) 作为**独立一步**放回链上, 位置在审计之前。
+
+    plan.append(step_cmd('⑤a 逐件来源台账 (账实相符: 谁在位、谁有生成端)',
+
+                         [PY, 'scripts/products_restore_missing.py', '--refresh'],
+
+                         note='只维护台账, **不搬任何随包件**(用户令 2026-09-17: 运行期不从交付包补齐); '
+
+                              '盘上有而无人登记的件按族表归口, 归不上族的记 unregistered 并打出来'))
+
+    # ⑤ 反向呼应审计 (用户令 2026-09-17 #1): **运行链不再从含产物的交付包补齐**。
+
+    #    原先这一步跑 products_restore_missing.py —— 那等于"重算链依赖一个包", 与"目标机放数据自己重算"
+
+    #    这条主线冲突: 交付包默认不含产物, 于是这一步在真机上要么报"找不到随包件来源"(rc=6), 要么
+
+    #    被指到某个 zip 上(而那正是用户明令不要的)。改成**只报账、不搬运**: 逐件回溯"这件产物有没有
+
+    #    生成端与输入", 把没有来路的件如实列出来, 供研发补生成端 (口径见 docs §13.6)。
+
+    #    rc=5 = 有未归类/判据失败(例如产物被清后族表匹配不到) —— 容忍, 别为它打断后面的本体链与审计。
+
+    plan.append(step_cmd('⑤ 反向呼应审计 (输出→功能/算法→输入; 不从交付包补齐)',
+
+                         [PY, 'scripts/products_reverse_audit.py', '--check'],
+
+                         tolerate=(5,),
+
+                         note='rc=5 = 有未归类件或判据失败 ⇒ 停下看清单再决定(不打断整条链)。'
+
+                              '★ 运行期一律不由交付包补齐(用户令 2026-09-17): 缺件要么放原始件重算, '
+
+                              '要么由研发补生成端; 逐族可逆性见 docs §13.6'))
+
+    if not a.no_restart:
+
+        # 只重启**组件服务**, 网关留着 —— 两个理由:
+
+        #  ① 控制台页面是网关提供的, 连它一起停的话, 从页面点的重算会把自己的界面(甚至自己)弄没;
+
+        #  ② rebuild_all 自己可能就是从控制台/网关起的任务, `guanlan.py stop` 用 taskkill /T,
+
+        #     连网关会**沿父子树把正在执行的重算一起杀掉**(2026-09-12 实测: 日志停在 stop 那行, 之后全无)。
+
+        # 组件停掉再 serve 就够: chain_ingest 要的是 detail(18033) 重新加载新产物, 网关不缓存产物。
+
+        plan.append(step_cmd('⑥ 重启组件服务 (保留网关, stop)', [PY, 'scripts/_ops_stop_keep_gateway.py']))
+
+        plan.append(step_cmd('⑥ 重启组件服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,),
+
+                             note='serve 在"有模块 DOWN"时故意返回 1 —— degraded 是正常状态(例如本机模型未启动)'))
+
+    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())']))
+
+    # ⑦b 总览「全场状态」页 (`outputs/<场>/windscada/index.html`): /detail/v2 的 iframe 目标。
+
+    #     用户令 2026-09-19「所有的计算均要形成观澜的源代码」——它此前是随包件(0 处写入方),
+
+    #     新机器上那块只能显示"产物不在位"。现在用组件同一个烘焙器从重算产物生成。
+
+    #     ★必须排在 ⑦ 之后: 烤进去的响应里有本体数据(objects.json)。
+
+    # ★P13(2026-10-01):本步脚本已随旧页退役(门户重装 / 单文件烘焙页),不再入链
+
+    plan.append(step_cmd('⑧ 本体审计 (期望 0 问题)', [PY, '-m', 'src.ontology.audit']))
+
+    # ⑧b 重装门户: 门户正文里的"契约结论段"是**产物派生**的(由 guanlan_portal_inject_claims.py
+
+    #     从 outputs/<场>/guanlan/… 渲染), 所以产物变了门户必须重装一遍, 否则页面挂着旧结论。
+
+    #     装配是"先写临时文件再原子替换", 失败不会破坏正在被网关读的 portal.html (见 portal_build 注释)。
+
+    # ★P13(2026-10-01):本步脚本已随旧页退役(门户重装 / 单文件烘焙页),不再入链
+
+    # ⑧c 页面归口审计: 重算之后, 那些"把数据烘进页面"的页面件(见 configs/portal_pages.yaml)会不会陈旧。
+
+    #     rc=7 = 有页面陈旧(数据换了页面没换) —— 不打断整条链, 但必须报出来, 处置见 docs §10。
+
+    #     ★ rc=5 也容忍 (2026-09-17 实逮): rc=5 = "登记的文件/被引产物不在位", 而**包内没有生成端**的那批
+
+    #       随包件(如 windscada/index.html、m5_cms_tcm/tcm_index.parquet、windcms 页面件)在
+
+    #       "清过产物 + 交付包不含产物"的机器上必然不在位 ⇒ 第 16 步判失败、整条链 rc=1, 而且把最后的
+
+    #       ⑧台账等价验收也跳过了(实测 2026-09-17: 用户在页面上点重算, 1367 s 后 rc=1 断在这里)。
+
+    #       这与 ④/⑤/⑥/⑧b 的口径一致: 缺的是"随包件"这一路, 不是重算本身。缺件仍会由
+
+    #       `guanlan.py check` 报 FAIL(那是给人看的告警位), 本步只保证"链不白断"。
+
+    plan.append(step_cmd('⑧c 页面归口审计 (页面陈旧检测)', [PY, 'scripts/pages_audit.py', '--check'],
+
+                         tolerate=(5, 6, 7),
+
+                         note='rc=7 = 有 data-derived 页面陈旧(内嵌快照的 source_sha256 与当前产物不一致): '
+
+                              '需要对应生成端重跑, 或在登记表里把它改成 frozen-delivery 并接受会过期; '
+
+                              'rc=5 = 登记的文件/被引产物不在位(清过产物、且那批件没有生成端时属预期); '
+
+                              '★rc=6 也容忍 (2026-09-19 实逮): rc=6 = 溯源缺失(产物在盘上/登记里, 但台账查不到来路) '
+
+                              '—— 与 5 同属"缺的是随包件这一路", 例如 ④b 在 report 步崩掉时 '
+
+                              'vib_raw_manifest.json 没写成, 审计就按"溯源缺失"报 6 并把整条链判失败; '
+
+                              '处置见 docs §7 与「数据重算」页的缺件提示; `guanlan.py check` 里仍有给人看的告警位'))
+
+    # ⑧d 版本记录一致性 (2026-09-22 实逮, 远端 2.9.1 升级+重算后验收才暴露):
+
+    #     那次升级的差异盘点只覆盖 src/ + scripts/ + configs/ —— **文档不在盘点面上**, 于是代码升到 2.9.1、
+
+    #     docs/版本记录.md 与 docs/系统设计说明.md 留在旧版; 而重算链此前只到 ⑧c, 没有版本门 ⇒
+
+    #     24/24 步全 OK、控制台写"重算完成", 版本/文档漂移**没有任何自动拦截点**(只有人工 `guanlan.py check` 能逮)。
+
+    #     与 ⑧c 同口径: tolerate=6 —— 缺的是"文档同步"这一路, 不是重算本身 ⇒ 报出来但不打断整条链。
+
+    plan.append(step_cmd('⑧d 版本记录一致性 (docs 与 src/version.py)',
+
+                         [PY, 'scripts/version_log.py', '--check'], tolerate=(6,),
+
+                         note='rc=6 = docs/版本记录.md 的自动区块与 src/version.py 的 HISTORY 不一致 '
+
+                              '(升级只同步了代码、没同步文档即属此类) ⇒ 跑 python scripts/version_log.py --write '
+
+                              '重生成自动区块, 再把 docs/ 一并同步到目标机(升级差异盘点要含 docs/); '
+
+                              '`guanlan.py check` 里仍有给人看的 FAIL 位'))
+
+    if a.with_verify:
+
+        # ★2026-09-20 改 (远端实逮, 2.8.0 在服务器上一轮 24/24 步全 OK 却整轮 rc=1):
+
+        #   原先 tolerate=(4,) —— rc=5("找不到随包基线 ⇒ 没验收") 被当成失败, 于是**交付包装出来的机器
+
+        #   (按用户令包内不含产物 ⇒ 基线必然不存在)每次重算都以 rc=1 结束, 门户上写"重算失败"**,
+
+        #   而实质步骤全绿。这与 ④ 的口径自相矛盾(④ 容忍 5 并写明"跳过等价验收≠通过")。
+
+        #   现在: rc=4(有需人工看的差异, 本包已知项: 油样 102 行源件不在现场包) 与 rc=5(没基线=没验收)
+
+        #   都容忍, 但**都在汇总行里如实标 rc**、并在步说明里写清"这不是通过"; 想真做验收就让
+
+        #   `scripts/set_baseline.py` 建一份基线(用已核实的产物快照 + 写明来历), 之后这一项才会真比对。
+
+        plan.append(step_cmd('⑧ 台账等价验收 (rc=4 = 有需人工看的差异; rc=5 = 没基线 ⇒ 没验收)',
+
+                             [PY, 'scripts/rebuild_from_raw.py', '--verify'], tolerate=(4, 5),
+
+                             note='rc=4 = 有需人工看的差异(本包已知项, 见 docs §7); '
+
+                                  'rc=5 = 找不到随包基线 ⇒ **没做验收**(不是失败, 也不是通过)。'
+
+                                  '★ 交付包按用户令不含产物 ⇒ 全新机器上这一步必然 rc=5; 要恢复验收能力: '
+
+                                  '用 python scripts/set_baseline.py 一条命令把当前已核实的产物快照登记成基线, '
+
+                                  '再重算就能真比对'))
+
+    return plan
+
+
+
+
+
+def main() -> int:
+
+    ap = argparse.ArgumentParser()
+
+    ap.add_argument('--src', default=None, help='现场数据包目录 (给了就先跑 place_raw_data --scope full)')
+
+    ap.add_argument('--auto', action='store_true',
+
+                    help='先扫一遍输入数据(raw_scan.py),发现新数据落在被 --skip-* 跳过的族里时自动取消跳过'
+
+                         '(用户令 2026-09-19「发现新增数据并纳入重算」)')
+
+    ap.add_argument('--skip-scada', action='store_true', help='跳过 SCADA 侧 10 个构建器 (没换 10min 数据时用)')
+
+    ap.add_argument('--skip-vib', action='store_true', help='跳过振动侧摄入 (没换 CMS 原始导出时用)')
+
+    ap.add_argument('--no-vib-report', dest='vib_report', action='store_false',
+
+                    help='振动侧只摄入(索引/谱), 不生成 CMS 报告/逐台页 —— ★默认**生成**(2026-09-18 口径'
+
+                         '翻转: 包里没有旧产物可参照, 页面要么有自算内容要么空白; 报告只缺 '
+
+                         'model_run/fusion 的融合级列, 如实写 —)')
+
+    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, note 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, note) 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} —— 后面的步骤依赖它, 先停下。')
+
+            if note:
+
+                print(f'    该步说明: {note}')
+
+            break
+
+        if rc != 0:
+
+            print(f'\n    (退出码 {rc} 属预期{(": " + note) if note else ""})')
+
+
+
+    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())
+