Ver Fonte

products_state: --off 加防嵌套闸 + --archive-old; 手册补"清掉产物 / 清完再重算"两节

## 隐患 (实测确认过)

`--off` 的落点是 `_products_off/<场>/<目录>`; 而暂存区里**已经有上一代产物**时, `shutil.move` 的语义是
"目的地是已存在目录 → 把源挪进去", 于是变成 `_products_off/rudong/windscada/windscada/…`;
随后 `--on` 把 `_products_off/rudong/windscada` 整个搬回 → **新旧两代混在一起还原**。
(本轮之前是"挪走一次 + 补齐脚本回拷", 暂存区一直在, 所以这个坑随时会踩。)

## 改法

  · `do_off` 先检查落点是否已存在: 有冲突且没给 `--archive-old` → **拒绝执行**并打印两条出路
    (加 `--archive-old`, 或手工把 `_products_off` 改名);
  · 新增 `--archive-old`: 把上一代场站目录与 `manifest.json` 存到 `_products_off_prev_<时间戳>/`,
    **保留 `_baseline_kept/` 在原地**(rebuild_from_raw --verify 要用)。

## 实测 (整条来回都跑过)

  1. `--off`(不带 archive) → 被拦, 退出码 1, 提示清晰 ✅
  2. `--off --archive-old` → 上一代存档到 `_products_off_prev_20260912_085201/`, 10 项挪走;
     重启后: `/` 200(门户在 release/, 不是产物)、**`/cms/` 503**、`/api/fleet` = `err=no_products`、
     `outputs/rudong` 0 件 —— 即"空状态" ✅
  3. `--on` → 592 件还原(589 产物 + 3 件随包基线); 重启后 `/cms/` **200 (414,141 B)**、
     `/` 20,225,828 B、`/detail/` 229,643 B、`/sim/` 50,998 B、`/sim/sys/` 123,455 B、`/viewer/` 4,986 B
     —— 与清空前逐字节一致 ✅

## 手册

`docs/重算操作手册_v0.1.md` 新增 §5b「清掉产物 / 再重算(完整操作)」: 清空(prepare+开关+手工两种)、
空状态该看到什么、还原、以及清完从零重算的六步(放数据 → 台账+SCADA → 月度派生 → 本体链 → 补齐脚本 → 重启核验),
并写明"只想要页面能打开的最小集 = 台账 + 月度派生 + 补齐三步"。
zhouyang.xie há 1 mês atrás
pai
commit
42a5e71fda
2 ficheiros alterados com 112 adições e 2 exclusões
  1. 74 0
      docs/重算操作手册_v0.1.md
  2. 38 2
      scripts/products_state.py

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

@@ -170,6 +170,80 @@ rmdir /S /Q "outputs\rudong\windscada\_pre_rebuild_20260911"
 
 期望: `状态 degraded offline  模块 5/7 ok`, 并列出 DOWN 的项。
 
+## 5b. 清掉产物 / 再重算(完整操作, 2026-09-12 实测)
+
+### 清掉产物(人工检查空状态用)
+
+```bat
+:: ① 清掉 (整体挪走, 不是删除 —— 一条命令能还原)
+.venv\Scripts\python.exe scripts\products_state.py --off --archive-old
+::    暂存区里已有上一代产物时**必须加 --archive-old**: 否则会被拦住(见下), 因为挪走的落点是
+::    _products_off\<场>\<目录>, 那里已有同名目录时 shutil.move 会嵌套成 windscada\windscada\,
+::    之后 --on 就把两代混在一起还原。加了它 = 自动把上一代存档到 _products_off_prev_<时间戳>\
+::    (随包基线 _baseline_kept\ 留在原地, rebuild_from_raw --verify 还要用)。
+
+:: ② 重启服务 (产物没了必须重启, CMS 等是启动时加载数据)
+.venv\Scripts\python.exe guanlan.py stop
+.venv\Scripts\python.exe guanlan.py serve
+
+:: ③ 看空状态: / 门户仍 200 (门户在 release\, 不是产物); /cms/ 变 503;
+::    /detail/api/fleet 回 err=no_products; 维护页「数据层」各项 条数=0 / 存在=False
+
+:: ④ 还原
+.venv\Scripts\python.exe scripts\products_state.py --on
+.venv\Scripts\python.exe guanlan.py stop ; .venv\Scripts\python.exe guanlan.py serve
+```
+
+**不想用开关也行**(整目录搬走, 最直观):
+
+```bat
+Move-Item outputs\rudong F:\temp\products_off_20260912       :: 清
+.venv\Scripts\python.exe guanlan.py stop ; .venv\Scripts\python.exe guanlan.py serve
+Move-Item F:\temp\products_off_20260912 outputs\rudong       :: 还原
+```
+
+> 清的是 `outputs\<场>\`(产物仓), **不动** `data\raw\`(现场数据)、`release\`(门户外壳与交付件)、
+> `logs\`/`run\`(运行期)。所以清完门户首页照旧打开 —— 那是静态交付页, 本来就不读产物。
+
+### 清完再重算(从零)
+
+```bat
+:: ① 放数据 (现场包 → 约定目录; 同尺寸自动跳过)
+.venv\Scripts\python.exe scripts\place_raw_data.py --src F:\temp\如东风场数据 --scope full
+
+:: ② 台账 + SCADA 侧 10 个构建器 (后者约 15 分钟)
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py --scada
+
+:: ③ 月度派生件 (能从 raw 重算的那批; --verify 先看与随包件逐值是否一致)
+.venv\Scripts\python.exe scripts\windscada_monthly_build.py --verify
+.venv\Scripts\python.exe scripts\windscada_monthly_build.py
+
+:: ④ 本体层 (技术资料须已放好)
+.venv\Scripts\python.exe -m src.ontology.kb_ingest
+.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
+.venv\Scripts\python.exe -c "from src.ontology import retrieval as R; R.build(use_vec=False)"
+.venv\Scripts\python.exe -c "from src.ontology.maintenance import refresh_params as f; print(f())"
+.venv\Scripts\python.exe -m src.ontology.audit             :: 期望 0 问题
+
+:: ⑤ "包内没有生成端"的那批: 从随包件补齐(只补缺件, 不动上面重算出来的), 并写来源台账
+.venv\Scripts\python.exe scripts\products_restore_missing.py --dry-run
+.venv\Scripts\python.exe scripts\products_restore_missing.py
+
+:: ⑥ 重启 + 核验
+.venv\Scripts\python.exe guanlan.py stop ; .venv\Scripts\python.exe guanlan.py serve
+.venv\Scripts\python.exe -m src.ontology.maintenance       :: 数据层/机理层的条数与末次更新
+```
+
+**只想要"能打开页面"的最小集**: ② 的 `rebuild_from_raw.py` + ③ + ⑤ 三步即可
+(⑤ 把没有生成端的件补上, 页面就有数); ① 只在换了现场包时需要。
+
+**验收锚点**: `outputs\rudong\windscada\alarms.parquet` 39211 行 ·
+`temp_monthly.parquet` 19494 行(与随包件逐值一致) · 本体 `objects.json` 9701 对象 / audit 0 问题 ·
+`_provenance.json` 记着每件是 raw 重算还是随包补齐。
+
 ## 6. 怎么看"数据真的进去了"
 
 **命令行(不开浏览器)**:

+ 38 - 2
scripts/products_state.py

@@ -60,12 +60,46 @@ def count(p: pathlib.Path) -> tuple[int, float]:
     return len(fs), sum(f.stat().st_size for f in fs) / 1024 / 1024
 
 
-def do_off() -> int:
+def archive_old() -> bool:
+    """把**上一代**暂存区改名存档, 给这一次挪走腾出干净的落点。
+
+    为什么必须做: 挪走的落点是 `_products_off/<场>/<目录>`; 若该目录已存在(shutil.move 的语义是
+    "目的地是已存在目录 → 把源挪进去"), 会嵌套成 `_products_off/rudong/windscada/windscada/…`,
+    随后 `--on` 就会把新旧两代混在一起还原 —— 2026-09-12 实逮这个隐患。
+    存档时**保留** `_baseline_kept/`(随包基线, `rebuild_from_raw.py --verify` 要用), 只动场站目录与清单。
+    """
+    arch = ROOT / f'_products_off_prev_{time.strftime("%Y%m%d_%H%M%S")}'
+    moved = []
+    for fam in farms():
+        src = OFF / fam.name
+        if src.exists():
+            arch.mkdir(parents=True, exist_ok=True)
+            shutil.move(str(src), str(arch / fam.name))
+            moved.append(fam.name)
+    if MANIFEST.exists():
+        arch.mkdir(parents=True, exist_ok=True)
+        shutil.move(str(MANIFEST), str(arch / 'manifest.json'))
+    if not moved and not arch.exists():
+        return False
+    print(f'  上一代暂存区已存档 → {P.rel(arch)}/  (场站: {", ".join(moved) or "无"}; 基线 _baseline_kept 留在原地)')
+    return True
+
+
+def do_off(archive=False) -> int:
     items = plan()
     if not items:
         print('产物已经全部挪走了 (用 --status 看清单)')
         return 0
     OFF.mkdir(parents=True, exist_ok=True)
+    conflicts = [dst for _, dst in items if dst.exists()]
+    if conflicts:
+        if not archive:
+            print(f'[X] 暂存区里已经有同名产物 ({len(conflicts)} 个, 例: {P.rel(conflicts[0])}) —— 直接挪会嵌套成'
+                  f' <目录>/<目录>/, 之后 --on 会把两代混在一起。\n'
+                  f'    二选一: ① 加 --archive-old 把上一代存档后重跑 (推荐);\n'
+                  f'           ② 手工把整个 {P.rel(OFF)} 改名后再跑 (例: Move-Item _products_off _products_off_旧)。')
+            return 1
+        archive_old()
     man = []
     # 随包基线备份藏在 windscada 里面: 先摘出来, 它不是产物 (rebuild_from_raw.py --verify 要用)
     baseline = P.store() / KEEP[0]
@@ -150,5 +184,7 @@ if __name__ == '__main__':
     g.add_argument('--off', action='store_true', help='挪走全部产物')
     g.add_argument('--on', action='store_true', help='全部挪回')
     g.add_argument('--status', action='store_true', help='看当前状态')
+    ap.add_argument('--archive-old', action='store_true',
+                    help='--off 前把上一代暂存区存档到 _products_off_prev_<时间戳>/ (暂存区已有同名产物时必须加)')
     a = ap.parse_args()
-    sys.exit(do_off() if a.off else do_on() if a.on else do_status())
+    sys.exit(do_off(archive=a.archive_old) if a.off else do_on() if a.on else do_status())