rebuild_all.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""一条命令重算全部 (2026-09-12) —— 把散在各脚本里的重算链按依赖顺序串起来。
  4. ## 为什么要有它
  5. "从零重算"原本是六七条命令、顺序还不能错 (产物齐了本体层才跑得动, 本体层跑完才能拷随包件…),
  6. 手工执行容易漏步、也说不清"到底重算了什么"。本脚本把顺序、依赖、验收锚点全写死在这里。
  7. ## 顺序 (为什么是这个顺序)
  8. ① 放数据 place_raw_data.py --scope full (给了 --src 才跑; 同尺寸自动跳过)
  9. ② 三门台账 rebuild_from_raw.py 报警/工单/油样
  10. ③ SCADA 侧 rebuild_from_raw.py --scada 10 个构建器, 逐台读 ~14 GB, 约 15 分钟
  11. ④ 月度派生件 windscada_monthly_build.py 能从 raw 重算的月表(与随包件逐值对齐才落盘)
  12. ④b 振动侧 vib_raw_build.py 原始导出 → 窗索引/谱/逐台页/融合 z + CMS 报告
  13. ④c 变桨面 pitch_face_build.py 10min 液压/润滑/柱塞 + 1min 零位三口径 → pitch/**
  14. ④d/④e 振动历史与基线 component_history_build.py / baseline_38_build.py 窗索引 → 在升闭环 + 三层基线
  15. ⑤a 台账维护 products_restore_missing.py --refresh 账实相符(谁在位、谁有生成端); 只记账不搬件
  16. ⑤ 反向呼应审计 products_reverse_audit.py --check 逐件回溯"输出→生成端→输入"(用户令 2026-09-17 #1: 运行期不从交付包补齐)
  17. ↑ 必须在 ⑥ 之前: 本体层的 populate/chain_ingest 要吃这些 L1 产物
  18. ⑥ 重启服务 guanlan.py stop/serve chain_ingest 要从 /api/fleet 取链盘
  19. ⑦ 本体层 kb_ingest → populate → chain_ingest → trend_ingest → retrieval → refresh_params
  20. ⑧ 验收 ontology.audit + rebuild_from_raw --verify (可选) + 页面对照
  21. ## 用法
  22. python scripts/rebuild_all.py # 全套 (会跑 SCADA 那 15 分钟)
  23. python scripts/rebuild_all.py --skip-scada # 只换台账时的常用档
  24. python scripts/rebuild_all.py --src <现场包目录> # 连"放数据"一起
  25. python scripts/rebuild_all.py --no-restart # 不碰服务(自己管)
  26. python scripts/rebuild_all.py --with-verify # 末尾加等价验收(需随包基线在位)
  27. python scripts/rebuild_all.py --dry-run # 只打印计划
  28. """
  29. from __future__ import annotations
  30. try:
  31. from app_common.app_common_guanlan.api import install_root as _install_root
  32. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  33. from pathlib import Path as _P
  34. def _install_root(_f): return _P(_f).resolve().parents[3]
  35. import argparse
  36. import json
  37. import os
  38. import pathlib
  39. import subprocess
  40. import sys
  41. import time
  42. ROOT = _install_root(__file__) # 模块化后按标记找安装根(原 parents[1] 已不成立)
  43. PY = sys.executable
  44. def step_cmd(name, cmd, tolerate=(), note=''):
  45. """tolerate: 允许的退出码 (不算失败); note: 该步非零退出时打给人看的解释(按步, 别用一句通用话糊过去)。"""
  46. return (name, cmd, tuple(tolerate), note)
  47. def _scan_plan() -> tuple[list, str]:
  48. """跑一次输入数据扫描(`--json --check`)→ (该跑的步骤列表, 人读摘要)。扫不动就返回 ([], 原因)。
  49. 为什么要它 (用户令 2026-09-19「增加自动扫描识别机制, 以能发现新增数据, 并纳入重算」):
  50. `--auto` 靠它决定"人给的 --skip-* 该不该让路" —— 新数据落在被跳过的族里时自动取消跳过。
  51. """
  52. try:
  53. # ★必须显式给子进程 UTF-8 并按其解码 (2026-09-19 实逮: 子进程写 UTF-8、父进程按 GBK 解 ⇒
  54. # 步骤名一串乱码, `startswith('③')` 永远不匹配 —— 扫描明明发现新数据, 却没取消 --skip-*)
  55. env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
  56. r = subprocess.run([PY, 'scripts/raw_scan.py', '--json', '--check'], cwd=str(ROOT), env=env,
  57. capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=1800)
  58. d = json.loads(r.stdout or '{}')
  59. return list(d.get('steps') or []), (f'发现 {len(d.get("changes") or [])} 处新增/变化'
  60. + ('(' + ' / '.join(d.get('steps') or []) + ')' if d.get('steps') else ''))
  61. except Exception as e:
  62. return [], f'扫描没跑成({type(e).__name__}: {str(e)[:80]})—— 不影响重算, 但 --auto 无依据'
  63. def build_plan(a) -> list:
  64. plan = []
  65. if a.auto:
  66. # --auto: 先看"这回新来了什么", 再决定人给的 --skip-* 要不要让路 (用户令 2026-09-19)
  67. steps, why = _scan_plan()
  68. print(f' [auto] 输入数据扫描: {why}')
  69. if steps and a.skip_scada and any(s.startswith(('③', '④ ')) or s.startswith('④c') for s in steps):
  70. print(' [auto] 有新数据落在 SCADA/变桨族里 ⇒ **取消 --skip-scada**(新数据必须进重算)')
  71. a.skip_scada = False
  72. if steps and a.skip_vib and any(s.startswith('④b') for s in steps):
  73. print(' [auto] 有新数据落在振动族里 ⇒ **取消 --skip-vib**')
  74. a.skip_vib = False
  75. if a.src:
  76. plan.append(step_cmd('① 放数据', [PY, 'scripts/place_raw_data.py', '--src', a.src, '--scope', 'full']))
  77. # ①b 输入数据扫描 (用户令 2026-09-19): 逐族指纹 → 发现新增/变化 → 指明该跑哪几步, 并把本次指纹记成
  78. # 新基线(下次比的基准)。rc: 0 无变化 · 4 有新增/变化(**不是失败**) · 5 首次没有快照(顺手记一份)。
  79. plan.append(step_cmd('①b 输入数据扫描 (新增/变化 → 该跑哪些步)',
  80. [PY, 'scripts/raw_scan.py', '--check', '--write'], tolerate=(4, 5),
  81. note='rc=4 = 这次有新数据(不是失败)—— 按它列出的步骤重跑即纳入;'
  82. 'rc=5 = 还没有快照,本次顺手记一份基线'))
  83. plan.append(step_cmd('② 三门台账', [PY, 'scripts/rebuild_from_raw.py']))
  84. if not a.skip_scada:
  85. plan.append(step_cmd('③ SCADA 侧 10 个构建器 (约 15 分钟)', [PY, 'scripts/rebuild_from_raw.py', '--scada']))
  86. # ③b SCADA 10min **窄仓**(用户令 2026-09-21「判级/曲线随所选时间窗重算」的底座):
  87. # 一次全量扫(≈2 分钟)抽出约 46 列公共子集 ⇒ 之后任何窗的判级/镜头重算从"重读 15 GB"
  88. # 变成"窄仓上过滤+分组"(秒级)。数据换了必须重跑,否则按窗重算会用旧值。
  89. if not a.skip_scada:
  90. plan.append(step_cmd('③b SCADA 10min 窄仓 (按窗重算底座)', [PY, 'scripts/scada_slim_build.py']))
  91. # ④ 月度派生件: `windscada_monthly_build.py` 会拿随包基线**逐值比对**, 没有基线时它返回 5
  92. # ("无法比对 —— 这不是通过", 这是它的诚实口径, 不要改它的退出码)。
  93. # ★但"重算链"与"等价验收"是两件事: 基线只是**验收标准答案**, 缺它不该让整条链停在第④步
  94. # (2026-09-16 实逮: 基线目录被清理后, 从页面点"执行重算" 387 s 后 rc=1 断在这里, ⑦⑧ 全不执行)。
  95. # 故这里容忍 5 并写清"跳过了什么"; 要真做验收, 先把随包件恢复成暂存区/基线(见 products_state.py)。
  96. plan.append(step_cmd('④ 月度派生件', [PY, 'scripts/windscada_monthly_build.py'],
  97. tolerate=(5,),
  98. note='rc=5 = 找不到随包基线可比对 ⇒ **跳过等价验收**(不是通过)。'
  99. '要恢复验收能力: 把随包产物放回 _products_off*/<场>/ 或 '
  100. 'outputs/<场>/windscada/_pre_rebuild_20260911/'))
  101. if not a.skip_vib:
  102. # 振动侧 (2026-09-12 用户令"振动数据参与运行、重算"): 原始导出的 Brande TCM JSON
  103. # (data/raw/<场站>/windcms/**/*_decode.json) → 窗索引 + 谱库 **+ CMS 报告/逐台页**。
  104. # 消费者 (src/windcms/data.py) 自动发现新窗, 所以这一步之后 CMS 的谱图/标量/页面就带上了新数据。
  105. # ★2026-09-18 用户令(" /detail 各页面不许用旧版产出补, 必须基于输入数据重算")后**口径翻转**:
  106. # 原先默认不跑报告(`--vib-report` 显式开), 理由是"重生成会让 report.md 从 16,857 B 掉到
  107. # 467 B(融合级表空)" —— 那是拿**随包旧快照**当基准; 现在包里没有旧产物, 页面要么有自算内容
  108. # 要么是空的。实测从重算出的窗 w0316 一键出报告: 38 台判级齐(报警 3/优秀 35), 只缺
  109. # model_run/fusion 两步的**融合级列**(如实写 —), `/api/vibcms` 立即可用。
  110. # 故默认带报告, 要旧行为用 --no-vib-report。
  111. # 现场没有振动原始件时本步**空跑退出 0** (不是每台机器都有振动导出, 不该报失败)。
  112. plan.append(step_cmd('④b 振动侧摄入 + CMS 报告 + 标量 z (原始导出 → 窗索引/谱/逐台页/融合 z)',
  113. [PY, 'scripts/vib_raw_build.py'] + ([] if a.vib_report else ['--no-report']),
  114. note='没有振动原始件时空跑属正常; 若解析出错会以非零退出 (不静默)'))
  115. if not a.skip_scada:
  116. # ④c 变桨面 (用户令 2026-09-19「所有的计算 均要形成 观澜的源代码」): `pitch/**` 原来 kind=shipped、
  117. # 无生成端 ⇒ 换台机器重算后变桨面恒"缺件 ⇒ 不可判"。现在由 scripts/pitch_face_build.py 从
  118. # scada_10min(开关量 timeon/压力锯齿/运行状态) + scada_1min(三叶桨距角/压力/有功) 算出, 约 10 分钟
  119. # (逐台读 1min 全量 ≈ 14 GB)。零位两口径 (停机顺桨段 / 满发段) 按用户令分列, 口径与证据写进
  120. # pitch/pitch_face_manifest.json。
  121. # 与 ③ 同一档门槛: 现场没放 SCADA 原始件时它按缺件如实退出 4 (不假装算过) ⇒ 容忍 4 并写清原因。
  122. plan.append(step_cmd('④c 变桨面 (10min 液压/润滑 + 1min 零位三口径)',
  123. [PY, 'scripts/pitch_face_build.py'], tolerate=(4,),
  124. note='rc=4 = data/raw 下找不到 SCADA 原始件 ⇒ 变桨面按缺件如实报 (不是失败); '
  125. '放件后重跑本步即得 (scripts/place_raw_data.py)'))
  126. if not a.skip_vib:
  127. # ④d/④e 振动历史与基线 (用户令 2026-09-19「所有的计算 均要形成 观澜的源代码」): 这两件此前是振动线
  128. # 手交的随包快照(无生成端) ⇒ 新机器上 ⑦ 的 trend_ingest 报"源缺失"、CMS 报告的三层基线卡为空。
  129. # 现在由两个生成端从**窗索引**算: component_history(逐窗标量 x_self → 在升/换件闭环, 窗不足时
  130. # 如实为空并写明边界) / baseline_38(健康期分段中位 → 自基线 + fleet 基线 + ISO 绝对限)。
  131. # ★要排在 ④b 之后 (读窗索引), 且在 ⑦ trend_ingest 之前。
  132. plan.append(step_cmd('④d 振动在升/换件闭环 (窗索引 → component_history.json)',
  133. [PY, 'scripts/component_history_build.py'], tolerate=(4,),
  134. note='rc=4 = 本机没有振动窗索引 (没放 CMS 原始导出) ⇒ 本件按缺件如实报, 不是失败'))
  135. plan.append(step_cmd('④e 三层基线 (窗索引 → baseline_38.json)',
  136. [PY, 'scripts/baseline_38_build.py'], tolerate=(4,),
  137. note='rc=4 = 本机没有振动窗索引 ⇒ 本件按缺件如实报, 不是失败'))
  138. # ⑤b 事实契约的 claim 生成 (用户令 2026-09-19「要让观澜从重算台账生成 claim」):
  139. # 由重算产物(alarms/workorders/oil/temp_bins/powercurve_dev/loss_monthly/L6/fusion)算 claim,
  140. # 写成 sop/findings.json + paradigm_r1 三份底稿(与契约构建器同结构) ⇒ 契约/门户/问答三处随重算刷新。
  141. # ★必须排在 ⑤a 台账维护之前: 这两步产出的件要进同一轮台账与审计, 否则审计会按"溯源缺失"报 rc=6。
  142. plan.append(step_cmd('⑤b 事实契约: 由重算台账生成 claim', [PY, 'scripts/sop_findings_from_ledger.py'],
  143. note='从重算产物生成; 判据与缺证据逐条写在 claim 里(相对判据封顶「候选」)'))
  144. plan.append(step_cmd('⑤c 事实契约: 构建 + 渲染消费者', [PY, 'scripts/guanlan_facts_contract.py', 'build'],
  145. tolerate=(2,),
  146. note='rc=2 = 契约自检不过(sha/脱敏/枚举), 需要看输出; 门户结论段由 ⑧b 重装时刷新'))
  147. # ⑤a 台账维护 (用户令 2026-09-18: 页面只认"有来路的件" ⇒ 台账必须跟着重算一起更新)。
  148. # ★实逮: 2026-09-18 用户「清除产物」后从 raw 全量重算, `outputs/<场>/_provenance.json`
  149. # **整本丢了** —— 因为写它的唯一入口 (旧的第⑤步 products_restore_missing.py) 已按用户令
  150. # 从链上拿下, 而新的第⑤步只**读**台账不写。后果: 门户/页面的「逐件来源」、反向呼应审计的
  151. # 族匹配全部退化成"未归类", 而盘上 1,728 件其实件件有生成端。故把 `--refresh`(纯账实相符,
  152. # 不搬任何随包件) 作为**独立一步**放回链上, 位置在审计之前。
  153. plan.append(step_cmd('⑤a 逐件来源台账 (账实相符: 谁在位、谁有生成端)',
  154. [PY, 'scripts/products_restore_missing.py', '--refresh'],
  155. note='只维护台账, **不搬任何随包件**(用户令 2026-09-17: 运行期不从交付包补齐); '
  156. '盘上有而无人登记的件按族表归口, 归不上族的记 unregistered 并打出来'))
  157. # ⑤ 反向呼应审计 (用户令 2026-09-17 #1): **运行链不再从含产物的交付包补齐**。
  158. # 原先这一步跑 products_restore_missing.py —— 那等于"重算链依赖一个包", 与"目标机放数据自己重算"
  159. # 这条主线冲突: 交付包默认不含产物, 于是这一步在真机上要么报"找不到随包件来源"(rc=6), 要么
  160. # 被指到某个 zip 上(而那正是用户明令不要的)。改成**只报账、不搬运**: 逐件回溯"这件产物有没有
  161. # 生成端与输入", 把没有来路的件如实列出来, 供研发补生成端 (口径见 docs §13.6)。
  162. # rc=5 = 有未归类/判据失败(例如产物被清后族表匹配不到) —— 容忍, 别为它打断后面的本体链与审计。
  163. plan.append(step_cmd('⑤ 反向呼应审计 (输出→功能/算法→输入; 不从交付包补齐)',
  164. [PY, 'scripts/products_reverse_audit.py', '--check'],
  165. tolerate=(5,),
  166. note='rc=5 = 有未归类件或判据失败 ⇒ 停下看清单再决定(不打断整条链)。'
  167. '★ 运行期一律不由交付包补齐(用户令 2026-09-17): 缺件要么放原始件重算, '
  168. '要么由研发补生成端; 逐族可逆性见 docs §13.6'))
  169. if not a.no_restart:
  170. # 只重启**组件服务**, 网关留着 —— 两个理由:
  171. # ① 控制台页面是网关提供的, 连它一起停的话, 从页面点的重算会把自己的界面(甚至自己)弄没;
  172. # ② rebuild_all 自己可能就是从控制台/网关起的任务, `guanlan.py stop` 用 taskkill /T,
  173. # 连网关会**沿父子树把正在执行的重算一起杀掉**(2026-09-12 实测: 日志停在 stop 那行, 之后全无)。
  174. # 组件停掉再 serve 就够: chain_ingest 要的是 detail(18033) 重新加载新产物, 网关不缓存产物。
  175. plan.append(step_cmd('⑥ 重启组件服务 (保留网关, stop)', [PY, 'scripts/_ops_stop_keep_gateway.py']))
  176. plan.append(step_cmd('⑥ 重启组件服务 (serve)', [PY, 'guanlan.py', 'serve'], tolerate=(1,),
  177. note='serve 在"有模块 DOWN"时故意返回 1 —— degraded 是正常状态(例如本机模型未启动)'))
  178. plan.append(step_cmd('⑦ 本体: 码表/手册/文档', [PY, '-m', 'src.ontology.kb_ingest']))
  179. plan.append(step_cmd('⑦ 本体: 铺开判级与台账', [PY, '-m', 'src.ontology.populate']))
  180. plan.append(step_cmd('⑦ 本体: 决策链进度', [PY, '-m', 'src.ontology.chain_ingest']))
  181. plan.append(step_cmd('⑦ 本体: 在升/闭环证据', [PY, '-m', 'src.ontology.trend_ingest']))
  182. plan.append(step_cmd('⑦ 本体: 检索索引', [PY, '-c', 'from src.ontology import retrieval as R; R.build(use_vec=False)']))
  183. plan.append(step_cmd('⑦ 本体: 实机参数表', [PY, '-c', 'from src.ontology.maintenance import refresh_params as f; print(f())']))
  184. # ⑦b 总览「全场状态」页 (`outputs/<场>/windscada/index.html`): /detail/v2 的 iframe 目标。
  185. # 用户令 2026-09-19「所有的计算均要形成观澜的源代码」——它此前是随包件(0 处写入方),
  186. # 新机器上那块只能显示"产物不在位"。现在用组件同一个烘焙器从重算产物生成。
  187. # ★必须排在 ⑦ 之后: 烤进去的响应里有本体数据(objects.json)。
  188. # ★P13(2026-10-01):本步脚本已随旧页退役(门户重装 / 单文件烘焙页),不再入链
  189. plan.append(step_cmd('⑧ 本体审计 (期望 0 问题)', [PY, '-m', 'src.ontology.audit']))
  190. # ⑧b 重装门户: 门户正文里的"契约结论段"是**产物派生**的(由 guanlan_portal_inject_claims.py
  191. # 从 outputs/<场>/guanlan/… 渲染), 所以产物变了门户必须重装一遍, 否则页面挂着旧结论。
  192. # 装配是"先写临时文件再原子替换", 失败不会破坏正在被网关读的 portal.html (见 portal_build 注释)。
  193. # ★P13(2026-10-01):本步脚本已随旧页退役(门户重装 / 单文件烘焙页),不再入链
  194. # ⑧c 页面归口审计: 重算之后, 那些"把数据烘进页面"的页面件(见 configs/portal_pages.yaml)会不会陈旧。
  195. # rc=7 = 有页面陈旧(数据换了页面没换) —— 不打断整条链, 但必须报出来, 处置见 docs §10。
  196. # ★ rc=5 也容忍 (2026-09-17 实逮): rc=5 = "登记的文件/被引产物不在位", 而**包内没有生成端**的那批
  197. # 随包件(如 windscada/index.html、m5_cms_tcm/tcm_index.parquet、windcms 页面件)在
  198. # "清过产物 + 交付包不含产物"的机器上必然不在位 ⇒ 第 16 步判失败、整条链 rc=1, 而且把最后的
  199. # ⑧台账等价验收也跳过了(实测 2026-09-17: 用户在页面上点重算, 1367 s 后 rc=1 断在这里)。
  200. # 这与 ④/⑤/⑥/⑧b 的口径一致: 缺的是"随包件"这一路, 不是重算本身。缺件仍会由
  201. # `guanlan.py check` 报 FAIL(那是给人看的告警位), 本步只保证"链不白断"。
  202. plan.append(step_cmd('⑧c 页面归口审计 (页面陈旧检测)', [PY, 'scripts/pages_audit.py', '--check'],
  203. tolerate=(5, 6, 7),
  204. note='rc=7 = 有 data-derived 页面陈旧(内嵌快照的 source_sha256 与当前产物不一致): '
  205. '需要对应生成端重跑, 或在登记表里把它改成 frozen-delivery 并接受会过期; '
  206. 'rc=5 = 登记的文件/被引产物不在位(清过产物、且那批件没有生成端时属预期); '
  207. '★rc=6 也容忍 (2026-09-19 实逮): rc=6 = 溯源缺失(产物在盘上/登记里, 但台账查不到来路) '
  208. '—— 与 5 同属"缺的是随包件这一路", 例如 ④b 在 report 步崩掉时 '
  209. 'vib_raw_manifest.json 没写成, 审计就按"溯源缺失"报 6 并把整条链判失败; '
  210. '处置见 docs §7 与「数据重算」页的缺件提示; `guanlan.py check` 里仍有给人看的告警位'))
  211. # ⑧d 版本记录一致性 (2026-09-22 实逮, 远端 2.9.1 升级+重算后验收才暴露):
  212. # 那次升级的差异盘点只覆盖 src/ + scripts/ + configs/ —— **文档不在盘点面上**, 于是代码升到 2.9.1、
  213. # docs/版本记录.md 与 docs/系统设计说明.md 留在旧版; 而重算链此前只到 ⑧c, 没有版本门 ⇒
  214. # 24/24 步全 OK、控制台写"重算完成", 版本/文档漂移**没有任何自动拦截点**(只有人工 `guanlan.py check` 能逮)。
  215. # 与 ⑧c 同口径: tolerate=6 —— 缺的是"文档同步"这一路, 不是重算本身 ⇒ 报出来但不打断整条链。
  216. plan.append(step_cmd('⑧d 版本记录一致性 (docs 与 src/version.py)',
  217. [PY, 'scripts/version_log.py', '--check'], tolerate=(6,),
  218. note='rc=6 = docs/版本记录.md 的自动区块与 src/version.py 的 HISTORY 不一致 '
  219. '(升级只同步了代码、没同步文档即属此类) ⇒ 跑 python scripts/version_log.py --write '
  220. '重生成自动区块, 再把 docs/ 一并同步到目标机(升级差异盘点要含 docs/); '
  221. '`guanlan.py check` 里仍有给人看的 FAIL 位'))
  222. if a.with_verify:
  223. # ★2026-09-20 改 (远端实逮, 2.8.0 在服务器上一轮 24/24 步全 OK 却整轮 rc=1):
  224. # 原先 tolerate=(4,) —— rc=5("找不到随包基线 ⇒ 没验收") 被当成失败, 于是**交付包装出来的机器
  225. # (按用户令包内不含产物 ⇒ 基线必然不存在)每次重算都以 rc=1 结束, 门户上写"重算失败"**,
  226. # 而实质步骤全绿。这与 ④ 的口径自相矛盾(④ 容忍 5 并写明"跳过等价验收≠通过")。
  227. # 现在: rc=4(有需人工看的差异, 本包已知项: 油样 102 行源件不在现场包) 与 rc=5(没基线=没验收)
  228. # 都容忍, 但**都在汇总行里如实标 rc**、并在步说明里写清"这不是通过"; 想真做验收就让
  229. # `scripts/set_baseline.py` 建一份基线(用已核实的产物快照 + 写明来历), 之后这一项才会真比对。
  230. plan.append(step_cmd('⑧ 台账等价验收 (rc=4 = 有需人工看的差异; rc=5 = 没基线 ⇒ 没验收)',
  231. [PY, 'scripts/rebuild_from_raw.py', '--verify'], tolerate=(4, 5),
  232. note='rc=4 = 有需人工看的差异(本包已知项, 见 docs §7); '
  233. 'rc=5 = 找不到随包基线 ⇒ **没做验收**(不是失败, 也不是通过)。'
  234. '★ 交付包按用户令不含产物 ⇒ 全新机器上这一步必然 rc=5; 要恢复验收能力: '
  235. '用 python scripts/set_baseline.py 一条命令把当前已核实的产物快照登记成基线, '
  236. '再重算就能真比对'))
  237. return plan
  238. def main() -> int:
  239. ap = argparse.ArgumentParser()
  240. ap.add_argument('--src', default=None, help='现场数据包目录 (给了就先跑 place_raw_data --scope full)')
  241. ap.add_argument('--auto', action='store_true',
  242. help='先扫一遍输入数据(raw_scan.py),发现新数据落在被 --skip-* 跳过的族里时自动取消跳过'
  243. '(用户令 2026-09-19「发现新增数据并纳入重算」)')
  244. ap.add_argument('--skip-scada', action='store_true', help='跳过 SCADA 侧 10 个构建器 (没换 10min 数据时用)')
  245. ap.add_argument('--skip-vib', action='store_true', help='跳过振动侧摄入 (没换 CMS 原始导出时用)')
  246. ap.add_argument('--no-vib-report', dest='vib_report', action='store_false',
  247. help='振动侧只摄入(索引/谱), 不生成 CMS 报告/逐台页 —— ★默认**生成**(2026-09-18 口径'
  248. '翻转: 包里没有旧产物可参照, 页面要么有自算内容要么空白; 报告只缺 '
  249. 'model_run/fusion 的融合级列, 如实写 —)')
  250. ap.add_argument('--no-restart', action='store_true', help='不自动重启服务')
  251. ap.add_argument('--with-verify', action='store_true', help='末尾加等价验收')
  252. ap.add_argument('--dry-run', action='store_true', help='只打印计划')
  253. a = ap.parse_args()
  254. plan = build_plan(a)
  255. print(f'重算全部 · {len(plan)} 步 · 根目录 {ROOT}')
  256. print(f' 跳过 SCADA: {a.skip_scada} | 重启服务: {not a.no_restart} | 等价验收: {a.with_verify}\n')
  257. if a.dry_run:
  258. for name, cmd, tol, note in plan:
  259. print(f' {name:34s} {" ".join(cmd[1:])[:90]}')
  260. print('\n(dry-run, 未执行)')
  261. return 0
  262. # 子进程环境: 与 guanlan.py 给服务的一致 —— 中文 Windows 控制台是 GBK, 不设的话
  263. # 子脚本 print 里的 ✔/✗ 会抛 UnicodeEncodeError, 明明干成了却以退出码 1 结束。
  264. env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
  265. results, t_all = [], time.time()
  266. for i, (name, cmd, tol, note) in enumerate(plan, 1):
  267. print(f'\n{"=" * 78}\n[{i}/{len(plan)}] {name}\n$ {" ".join(cmd)}\n{"=" * 78}', flush=True)
  268. t0 = time.time()
  269. rc = subprocess.run(cmd, cwd=str(ROOT), env=env).returncode
  270. dt = time.time() - t0
  271. ok = (rc == 0) or (rc in tol)
  272. results.append((name, rc, dt, ok, tol))
  273. if not ok:
  274. print(f'\n[X] 第 {i} 步失败 (退出码 {rc}): {name} —— 后面的步骤依赖它, 先停下。')
  275. if note:
  276. print(f' 该步说明: {note}')
  277. break
  278. if rc != 0:
  279. print(f'\n (退出码 {rc} 属预期{(": " + note) if note else ""})')
  280. print(f'\n{"=" * 78}\n汇总 (总耗时 {time.time() - t_all:.0f}s)')
  281. for name, rc, dt, ok, tol in results:
  282. tag = 'OK' if rc == 0 else (f'OK(rc={rc})' if ok else 'FAIL')
  283. print(f' [{tag:9s}] {name:34s} {dt:7.1f}s')
  284. n_fail = sum(1 for *_, ok, _ in results if not ok)
  285. if n_fail:
  286. print(f'\n结论: {n_fail} 步失败 —— 按上面的日志处理后再重跑本命令 (各步都是幂等的)')
  287. return 1
  288. print('\n结论: 全部完成。核验: .venv\\Scripts\\python.exe -m src.ontology.maintenance '
  289. '(数据层/机理层的条数与末次更新); 浏览器 http://127.0.0.1:28084/detail/')
  290. return 0
  291. if __name__ == '__main__':
  292. for _s in (sys.stdout, sys.stderr):
  293. try: _s.reconfigure(errors='replace')
  294. except Exception: pass
  295. sys.exit(main())