derived_manifest.py 4.0 KB

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