# -*- coding: utf-8 -*- """产物来源**自登记**: 构建脚本落盘后把"这一件是我从 data/raw 算出来的"记进 `outputs/<场>/_derived_manifest.json`。 ## 为什么要有它 `outputs/<场>/_provenance.json` 是**逐件来源台账**(raw-derived = 由 data/raw 重算 / shipped = 包内无生成端, 用随包件补齐)。它由 `scripts/products_restore_missing.py` 生成, 而那个脚本是按**随包快照**逐件走一遍的 —— 于是**新造的、快照里根本没有的产物**不会自动进台账 (既不算 raw-derived 也不算 shipped)。 早期的做法是在 `products_restore_missing.py` 里维护一张 `RAW_DERIVED` 精确路径表。对"件数少、名字固定" 的产物够用; 但振动侧的产物是 `<窗>/index.parquet` + `<窗>/spectra/*.npz`(分片名带序号) —— 窗名与分片数 都随数据变, 写不进精确表, 而**按名字通配**又会误伤同名旧件 (例如 `报告_CMS振动状态评估报告_*.md` 既有随包/自产的、也有厂家报告转录的, 名字形态一样)。 所以改成**自登记**: 谁算的谁登记, 台账只认这份登记。名字对不上不是问题, 因为登记的是**相对路径本身**。 用法 (构建脚本内): from src.derived_manifest import record record(P.out_root('rudong'), {rel: 'scripts/rudong_tcm_index.py (54 列, 与包内 tcm_index.parquet 同构)'}, by='scripts/vib_raw_build.py') """ from __future__ import annotations import json import pathlib import time FILENAME = '_derived_manifest.json' def path_of(store_root) -> pathlib.Path: return pathlib.Path(store_root) / FILENAME def load(store_root) -> dict: p = path_of(store_root) if not p.exists(): return {} try: return json.loads(p.read_text(encoding='utf-8')) except Exception: return {} def prune(store_root) -> int: """删掉**登记了但盘上已不存在**的条目, 返回删除数。 为什么需要 (2026-09-16 实逮): 振动摄入对同一批数据重跑时会落 `<窗>_reimport_<时分>` 窗 (设计如此, 该窗被 `data.EXCLUDE_DEFAULT` 排除在生产集外), 而登记是**追加式**的 —— 只补不删。重算几次后 `_derived_manifest.json` 里就攒了成百上千条指向已删目录的条目, `_provenance.json` 的 raw-derived 计数随之虚增 (实测 1,740 → 3,444, 而盘上并没有多出这些件)。 台账是本包的"来源正本", 虚高等于说假话 ⇒ 每次生成台账前先 prune。 """ store_root = pathlib.Path(store_root) cur = load(store_root) files = cur.get('files') or {} keep = {rel: v for rel, v in files.items() if (store_root / rel).exists()} gone = len(files) - len(keep) if gone: cur['files'] = keep cur['pruned'] = f'{time.strftime("%Y-%m-%d %H:%M")} 清理 {gone} 条不在盘的登记' path_of(store_root).write_text(json.dumps(cur, ensure_ascii=False, indent=1), encoding='utf-8') return gone def record(store_root, files: dict, by: str) -> pathlib.Path: """把 {相对产物仓的路径: 构建器说明} 合并进登记 (幂等: 同路径后写覆盖先写)。 幂等很关键 —— 重跑摄入不该让登记无限膨胀; 同时**不删**别的构建器登记的条目 (振动摄入与厂家报告摄入是两个脚本, 各登各的)。""" store_root = pathlib.Path(store_root) cur = load(store_root) entries = cur.get('files') or {} for rel, builder in files.items(): entries[pathlib.Path(rel).as_posix()] = dict(builder=builder, by=by, at=time.strftime('%Y-%m-%d %H:%M:%S')) cur = dict(note='产物来源自登记: 由构建脚本落盘后写入; _provenance.json 生成时把这些件记为 raw-derived', at=time.strftime('%Y-%m-%d %H:%M:%S'), files=entries) p = path_of(store_root) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(json.dumps(cur, ensure_ascii=False, indent=1), encoding='utf-8') return p