place_raw_data.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """把现场给的数据包按 A2 约定落位到 data/raw/<场站名称>/ 下 (2026-09-11)。
  4. ## 为什么要有这个脚本
  5. A2 定了四项数据源的落位: data/raw/<场站名称>/{scada_10min, 故障报警, 风机故障记录, 油样报告}。
  6. 现场拿到的却是几个 GBK 名的大压缩包 (10分钟数据.zip / 如东风场数据.zip), 内部目录名与
  7. 落位目录名并不一一对应 (例如报警在 `报警数据/`, 工单在 `工作/风机故障记录/`,
  8. 油样埋在 `数据收集/更新/(8)…/2025年油样/`)。手工拖拽容易拖错一层、也说不清依据什么。
  9. 本脚本把映射**写成表**, 于是"哪个包里的哪个目录去了哪"是可复核的, 重复跑也不会走样。
  10. ## 落位映射 (target 相对 data/raw/<场站名称>/)
  11. scada_10min/ ← 10分钟数据.zip 全部 38 个 WTG*.csv (平铺; src/windscada/data.py 按
  12. <src_10min>/<turbine>.csv 读, 所以必须是文件本身不是再套一层目录)
  13. 故障报警/ ← 如东风场数据.zip: 报警数据/** (年度/季度 .xls, XML 报警导出)
  14. 如东风场数据.zip: 数据收集/更新/(2)故障记录(首发故障有标识)(2025.1-至今)/故障记录/**
  15. (2025年全年故障记录.xls / 2026年至今.xls — 正是页面写的 *年至今.xls)
  16. 风机故障记录/ ← 如东风场数据.zip: 工作/风机故障记录/** (2021…2026 年故障记录/ + 业主统计故障/ + 年度 .rar)
  17. 如东风场数据.zip: 数据收集/更新/(3)现场检修记录(2025.1-至今)/大部件维修记录.*.xlsx
  18. 油样报告/ ← 如东风场数据.zip: 数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/2025年油样/**
  19. (去掉"2025年油样"这一层, 直接落成 <台号>/<部件>/<pdf>,
  20. 与维护页说明"按台号/部件分目录"一致)
  21. ## 两组范围 (--scope)
  22. `--scope a2`(默认) 只落上面那四类, 即 A3 当时的范围, 语义不变。
  23. `--scope mech` 落的是**"能重算的缺口"**(2026-09-11 用户令: 缺什么从现场包里抽)。它解决的是
  24. "从零重算时, 某些产物因为源件不在 data/raw 而算不出来"这件事, 因此只挑**有消费者**的源:
  25. <data/raw>/西门子4.0技术资料/ ← 如东风场数据.zip: 西门子4.0技术资料/** (317 件 2.8 GB)
  26. + 如东风场数据/广核如海风电场西门子风机故障代码中英文对译表.xlsx
  27. 依据: `src/ontology/kb_ingest.py` 里 `TECH = P.RAW_ROOT/'西门子4.0技术资料'`, 且 `rd()` 要读
  28. `故障处理/故障处理手册.xlsx`、`广核如海风电场西门子风机故障代码中英文对译表.xlsx`、
  29. `如海故障代码表.xlsx`、`维护相关/维护作业指导书.xlsx` 四个文件。前三个里**对译表在
  30. 技术资料目录里没有**(包里它在 `scada数据(如东)/` 与包根各有一份), 所以单独补一条规则。
  31. 效果: 本体层 `objects.json` 从"包内没有源件(⛔)"变成**可重算**。
  32. <场站>/scada_1min/ ← 1分钟数据.zip 全部 38 个 <台号>.csv (01E.csv…37B.csv)
  33. 依据: `src/windscada/config.py` 的 STATION_SUBDIRS 声明了这个子目录, `scan_stations.py`
  34. 报它"缺"; 表头带 `source_file=如海风机1分钟数据/如海测点_2025-01.csv`, 与场配置
  35. `src_farm_names=['如海','如东']` 同源(如海=如东项目)。**包内没有消费者**: 它补的是
  36. 数据层完整性, 不改变任何页面数值 —— 落它是因为"扫描辨识"会把它报成缺口。
  37. ## 振动侧 (--scope vib, 2026-09-12 用户令)
  38. 用户令: 「数据层里的「CMS 振动评估报告」应遵循 `<安装目录>\\data\\raw\\如东\\windcms`、
  39. 「振动线 handoff」应遵循 `<安装目录>\\data\\raw\\如东\\m5_cms_tcm` 存放; 修改系统支持振动数据参与
  40. 运行、重算; 自现场包提取相应振动数据存放至上述目录」。
  41. <场站>/windcms/CMS_RuDong_CGN_202603-04/measurement/<年>/<月>/<WTGxx>/*_decode.json
  42. ← CMS_RuDong_CGN_202603-04.zip 的 measurement/** 全部成员 (25,679 件, 解压 153.7 GB)
  43. 依据: 这就是 `src/windcms/pipeline.py` 的 `detect_input()` 认的 **`tcm_decoded_json`**
  44. (Brande TCM Enterprise 导出, SiteName=CGN Rudong, LocationName=WTGxx)。原样保留
  45. 包内 `measurement/` 这一层, 于是"哪个包来的哪一层"仍可追溯; 摄入按 rglob 找
  46. `*_decode.json`, 套不套这层都能吃。**这是振动侧唯一能重算的源**: 六层链全部
  47. 输入都是它 (索引/谱 → 扫线 → 能量占比 → 模型 → 融合 → 报告)。
  48. ★ 2026-09-11 版的 SKIPPED 里写着"振动分析报告是成品牌报告, windcms 要的测点索引
  49. 包里没有" —— 那句在当时成立; 2026-09-12 用户把 CMS 原始导出补进现场包后不再成立,
  50. 故本范围落地, 并在下面改注。
  51. <场站>/windcms/厂家报告/上海电气_月度/ ← 如东风场数据.zip:
  52. `数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/振动分析报告/**`
  53. (12 份月度用印版 PDF, 2025-06…2026-05) + 现场目录散装件
  54. `中广核如东海上风电场2026年07月振动分析报告_上海电气.docx`
  55. 依据: 数据层「CMS 振动评估报告」的**厂商评估报告**侧 (VDI3834 / NB/T 31129-2018 判级)。
  56. 12 份 PDF 是纯扫描件 (无文本层, 实测 /Font=0、pdftotext 类抽取为空) → 只作归档证据,
  57. 数值不参与判级; docx 有文本层, 由 scripts/vib_reports_build.py 摄入。
  58. <场站>/m5_cms_tcm/厂家报告/ ← 现场目录散装件
  59. `中广核如东海上风电场传动链振动分析报告_大生科技_20260311(1).docx`
  60. 依据: 数据层「振动线 handoff」的**TCM 侧深度分析报告** (依托机组自带 TCM M-system 数据)。
  61. 若干现场给了 handoff 正本 (`handoff_vibration_v2.json` / `component_history.json`),
  62. 也放本目录 → 摄入优先采用现场正本, 不再用包内 shipped 快照。
  63. 用法:
  64. python scripts/place_raw_data.py --dry-run # 只报要落什么, 不写盘 (默认 a2)
  65. python scripts/place_raw_data.py # 真落位 (已存在则覆盖)
  66. python scripts/place_raw_data.py --scope mech --dry-run # 看机理层/1min 会落什么
  67. python scripts/place_raw_data.py --scope vib # 落振动侧 (CMS 原始导出 153.7 GB + 厂家报告)
  68. python scripts/place_raw_data.py --scope vib --limit 300 # 冒烟: 只落 300 件 (试跑/校验用)
  69. python scripts/place_raw_data.py --scope full # 三组一起
  70. python scripts/place_raw_data.py --src D:\\别的现场数据目录
  71. ## 故意不做的事 (各范围共有的判断)
  72. · 不落 `(3)现场检修记录/2025年检修记录` 与 `2026年检修记录`: 与 工作/风机故障记录/2025年故障记录、
  73. 2026年故障记录 **逐件同名同大小**(19/19 件), 是同一批月度汇总表的副本。落两遍会让"按年目录"
  74. 摄入看到两份重复台账 (2026-08-31 长停台账虚高 35 倍那类事故的同款成因: 快照重复必须归并, 不能叠加)。
  75. · 振动报告只从 `(8)…/振动分析报告/` 落一遍 (不在 a2/mech 范围): 包内另有两处**同件副本** ——
  76. 根目录 `中广核如东海上风电场2026年5月振动分析报告用印版(2).pdf`(与 2026年05月 件同尺寸)
  77. 与嵌套包 `如东海上振动报告11份.zip`(其 11 份与 `振动分析报告/` 的 11 份逐件同尺寸)。
  78. 落三遍会得到 3 份同名报告, 摄入时会互相覆盖或重复计数 —— 故只在 vib 范围落目录里那一份。
  79. · 不落 `scada数据(如东)/**`(19 个月 × 12 个通道组 zip, 2.4 GB): 它是 `scada_10min/*.csv` 的**上游**
  80. 原始通道导出, 包内没有任何脚本读它(构建器读的是已经平铺好的 10min CSV) —— 落了也不参与重算。
  81. · 不落 `fastlog数据/`(4 件 WTG0x.xls): 全库搜 `fastlog` 只有 2 处**注释**提到它("运行态见证"),
  82. 没有读取代码。
  83. · 结论: 上面三项都是"有源件、无生成端/无消费者"。`genbearing_monthly`、`mblub_monthly`、
  84. `yaw_dynamic_monthly`、`yaw1min_liveness`、`sector_power`、`duty_monthly`、`pc_monthly_bins`、
  85. `thermal_monthly`、`structure.parquet`、`watch_channels_monthly` 这批产物同理 ——
  86. 全库只有读取方、**0 处写入方**, 属于 v0.2.0 未附构建脚本(见 docs §4)。
  87. """
  88. from __future__ import annotations
  89. try:
  90. from app_common.app_common_guanlan.api import install_root as _install_root
  91. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  92. from pathlib import Path as _P
  93. def _install_root(_f): return _P(_f).resolve().parents[3]
  94. import argparse
  95. import pathlib
  96. import os
  97. import shutil
  98. import sys
  99. import zipfile
  100. ROOT = _install_root(__file__) # 模块化后按标记找安装根(原 parents[1] 已不成立)
  101. sys.path.insert(0, str(ROOT))
  102. # 现场数据包所在目录: 用 --src 指定, 或 env GUANLAN_PLACE_SRC; 默认取安装目录下 data/_incoming
  103. DEFAULT_SRC = pathlib.Path(os.environ.get('GUANLAN_PLACE_SRC') or (ROOT / 'data' / '_incoming'))
  104. ZIP_10MIN = '10分钟数据.zip'
  105. ZIP_FARM = '如东风场数据.zip'
  106. # (压缩包, 包内前缀, 目标, 是否去掉前缀这一层, 目标根: station= data/raw/<场站>/, raw= data/raw/)
  107. RULES = [
  108. ('10分钟数据.zip', '', 'scada_10min', True, 'station'),
  109. ('如东风场数据.zip', '如东风场数据/报警数据/', '故障报警', True, 'station'),
  110. ('如东风场数据.zip', '如东风场数据/数据收集/更新/(2)故障记录(首发故障有标识)(2025.1-至今)/故障记录/', '故障报警', True, 'station'),
  111. ('如东风场数据.zip', '如东风场数据/工作/风机故障记录/', '风机故障记录', True, 'station'),
  112. ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/大部件维修记录.20240619143912557.xlsx', '风机故障记录', True, 'station'),
  113. ('如东风场数据.zip', '如东风场数据/数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/2025年油样/', '油样报告', True, 'station'),
  114. ]
  115. # 机理层与可选层 (--scope mech): 只挑**有消费者**的源 —— 目的是让"从零重算"能覆盖到本体层。
  116. RULES_MECH = [
  117. # 本体层源件: kb_ingest.py 的 TECH = data/raw/西门子4.0技术资料 (317 件, 含故障处理/维护相关/各类图纸)
  118. ('如东风场数据.zip', '如东风场数据/西门子4.0技术资料/', '西门子4.0技术资料', True, 'raw'),
  119. # …但它要读的对译表不在技术资料目录里, 包里在别处 → 单独补一条 (见本文件开头"两组范围")
  120. ('如东风场数据.zip', '如东风场数据/广核如海风电场西门子风机故障代码中英文对译表.xlsx', '西门子4.0技术资料', True, 'raw'),
  121. # 数据层声明里的 scada_1min (config.STATION_SUBDIRS); 包内无消费者, 补的是数据层完整性
  122. ('1分钟数据.zip', '', 'scada_1min', True, 'station'),
  123. ]
  124. # 振动侧 (--scope vib): 数据层两条振动行的源件 —— CMS 原始测量导出 + 厂商评估报告
  125. ZIP_CMS = 'CMS_RuDong_CGN_202603-04.zip'
  126. DIR_SA_REPORTS = '如东风场数据/数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/振动分析报告/'
  127. RULES_VIB = [
  128. # ① CMS 原始测量导出 (Brande TCM Enterprise, SiteName=CGN Rudong): 25,679 件 *_decode.json。
  129. # strip=False 保留包内 measurement/ 层 → <包名>/measurement/2026/03/WTGxx/*.json, 来源可追溯。
  130. (ZIP_CMS, 'measurement/', f'windcms/{ZIP_CMS[:-4]}', False, 'station'),
  131. # ② 上海电气月度振动分析报告 (12 份扫描件 PDF): 数据层「CMS 振动评估报告」的厂商报告侧
  132. ('如东风场数据.zip', DIR_SA_REPORTS, 'windcms/厂家报告/上海电气_月度', True, 'station'),
  133. ]
  134. # 现场目录下的散装件 (不是压缩包成员): (源文件名, 目标相对 data/raw/<场站>/ 的路径)
  135. RULES_VIB_LOOSE = [
  136. # 上海电气 2026年07月报告: 有文本层, 由 scripts/vib_reports_build.py 摄入成评估报告
  137. ('中广核如东海上风电场2026年07月振动分析报告_上海电气.docx',
  138. 'windcms/厂家报告/上海电气_月度'),
  139. # 大生科技传动链振动分析报告 (TCM M-system 数据): 数据层「振动线 handoff」的 TCM 侧
  140. ('中广核如东海上风电场传动链振动分析报告_大生科技_20260311(1).docx',
  141. 'm5_cms_tcm/厂家报告'),
  142. ]
  143. # 说明性的"故意不落", 只在报告里列出来
  144. SKIPPED = [
  145. ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/2025年检修记录/',
  146. '与 工作/风机故障记录/2025年故障记录 逐件同名校验相同 (副本)'),
  147. ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/2026年检修记录/',
  148. '与 工作/风机故障记录/2026年故障记录 逐件同名校验相同 (副本)'),
  149. ('如东风场数据.zip', '如东风场数据/数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/振动分析报告/',
  150. '※ 2026-09-12 起**已改为落位** (vib 范围, → windcms/厂家报告/上海电气_月度)。原判断"成品牌报告+'
  151. '包内没有测点索引"在当时成立; 用户补入 CMS_RuDong_CGN_202603-04.zip 后, 索引源件已具备, '
  152. '报告作为数据层证据一并落位。此处保留记录以免后人以为漏了'),
  153. ('如东风场数据.zip', '如东风场数据/中广核如东海上风电场2026年5月振动分析报告用印版(2).pdf',
  154. '与 振动分析报告/…2026年05月…用印版.pdf 同尺寸 (副本, 只落目录里那一份)'),
  155. ('如东风场数据.zip', '如东风场数据/如东海上振动报告11份.zip',
  156. '其 11 份与 振动分析报告/ 的 11 份逐件同尺寸 (副本包, 只落目录里那一份)'),
  157. ('如东风场数据.zip', '如东风场数据/scada数据(如东)/',
  158. 'scada_10min/*.csv 的上游原始通道导出 (2.4 GB), 包内无脚本读它 → 不参与重算'),
  159. ('如东风场数据.zip', '如东风场数据/fastlog数据/',
  160. '全库搜 fastlog 只有 2 处注释提到, 无读取代码'),
  161. ]
  162. def gbk_name(info: zipfile.ZipInfo) -> str:
  163. """zip 条目名 → 真名。现场包用 GBK 存中文名, zipfile 按 cp437 解出来是乱码。"""
  164. raw = info.filename
  165. try:
  166. return raw.encode('cp437').decode('gbk')
  167. except Exception:
  168. try:
  169. return raw.encode('cp437').decode('utf-8')
  170. except Exception:
  171. return raw
  172. def human(n: float) -> str:
  173. for unit in ('B', 'KB', 'MB', 'GB'):
  174. if n < 1024 or unit == 'GB':
  175. return f'{n:.1f} {unit}'
  176. n /= 1024
  177. return f'{n:.1f} GB'
  178. def plan(src: pathlib.Path, station: pathlib.Path, rules, loose_rules=(), limit: int = 0):
  179. """→ [(zip, 目标文件, 包内条目名, 解压后大小, 目标根, 目标名)]; 只读压缩包目录, 不解压。
  180. zip=None 表示散装件 (现场目录下的单个文件, 如两份 docx 振动报告不在任何压缩包里)。
  181. limit>0 时只取前 limit 件 (冒烟/试跑用; 大包 2.5 万件跑一遍要几十分钟, 得能小样验证)。"""
  182. out = []
  183. for zname, prefix, target, strip, root in rules:
  184. zp = src / zname
  185. if not zp.exists():
  186. raise SystemExit(f'缺压缩包: {zp}')
  187. base = station if root == 'station' else station.parent # 'raw' = data/raw (机理层不在场站目录下)
  188. with zipfile.ZipFile(zp) as zf:
  189. for info in zf.infolist():
  190. if info.is_dir():
  191. continue
  192. name = gbk_name(info).replace('\\', '/')
  193. if not name.startswith(prefix):
  194. continue
  195. rest = name[len(prefix):] if strip else name
  196. if not rest:
  197. # 前缀恰是条目本身 = 单文件规则 (如 '…/大部件维修记录.20240619143912557.xlsx')。
  198. # 旧写法在这里直接 continue, 于是**单文件规则从来没落过东西** —— 2026-09-11 逮到两例:
  199. # `大部件维修记录.20240619143912557.xlsx`(A2 规则, 从 A3 起就没落) 与
  200. # `广核如海…对译表.xlsx`(mech 规则)。只有"目录条目"(prefix 以 / 结尾) 才该跳过。
  201. if prefix.endswith('/'):
  202. continue
  203. rest = prefix.rstrip('/').rsplit('/', 1)[-1]
  204. out.append((zname, base / target / rest, name, info.file_size, root, target))
  205. for fname, target in loose_rules:
  206. fp = src / fname
  207. if not fp.exists():
  208. raise SystemExit(f'缺散装源件: {fp}')
  209. out.append((None, station / target / fname, fname, fp.stat().st_size, 'station', target))
  210. if limit:
  211. out = out[:limit]
  212. return out
  213. def main() -> int:
  214. ap = argparse.ArgumentParser()
  215. ap.add_argument('--src', default=str(DEFAULT_SRC), help='现场数据目录 (默认 %(default)s)')
  216. ap.add_argument('--dry-run', action='store_true', help='只报计划, 不写盘')
  217. ap.add_argument('--scope', choices=('a2', 'mech', 'vib', 'full'), default='a2',
  218. help='a2=四项数据层(A3 语义, 默认) · mech=机理层源件+1min · '
  219. 'vib=振动侧(CMS 原始导出+厂家报告) · full=三组一起')
  220. ap.add_argument('--limit', type=int, default=0, help='只落前 N 件 (冒烟/试跑; 默认 0=全落)')
  221. ap.add_argument('--check', action='store_true', default=True,
  222. help='落位前先跑输入数据体检 (默认开; 结构与命名违例/增量冲突会拦下)')
  223. ap.add_argument('--no-check', dest='check', action='store_false', help='跳过体检 (自己确知数据干净时用)')
  224. ap.add_argument('--force', action='store_true',
  225. help='体检报"同名不同大小(冲突)"时仍然落位 (会覆盖已摄入的源件; 想清楚再用)')
  226. a = ap.parse_args()
  227. src = pathlib.Path(a.src)
  228. if not src.is_dir():
  229. raise SystemExit(f'现场数据目录不存在: {src}')
  230. if a.check:
  231. # 用户令 3: 放置**前**先体检 —— 结构/命名不对就别落 (落错一层后面所有摄入都读不到),
  232. # 增量"同名不同大小"更要人来定夺 (覆盖已摄入的源件不可逆)。
  233. import importlib.util as _ilu
  234. _sp = _ilu.spec_from_file_location('_raw_check', ROOT / 'scripts' / 'raw_data_check.py')
  235. _chk = _ilu.module_from_spec(_sp)
  236. _sp.loader.exec_module(_chk)
  237. _res, _notes, _stat = _chk.check_tree(pathlib.Path(_chk.station_dir()), deep=False)
  238. _r2, _plan = _chk.increment_src(src, pathlib.Path(_chk.station_dir()))
  239. _res += _r2 + _chk.increment_findings(_plan, src.name)
  240. _hard = [x for x in _res if x[0] == '!' and x[3] in (_chk.RC_STRUCT, _chk.RC_CLASH)]
  241. print('== 落位前体检 (scripts/raw_data_check.py) ==')
  242. print(f' 新增 {len(_plan["new"]):,} · 相同 {len(_plan["same"]):,} · 冲突 {len(_plan["clash"]):,} '
  243. f'(原树 {_stat.get("files", 0):,} 件)')
  244. for x in _hard[:8]:
  245. print(f' [{x[0]}] {x[1]}: {x[2]}')
  246. if _hard:
  247. _clash = [x for x in _hard if x[3] == _chk.RC_CLASH]
  248. if _clash and a.force:
  249. print(' [!] 有冲突但给了 --force: 继续落位 (会覆盖)')
  250. else:
  251. print('\n[X] 体检不通过 —— 先按上面的提示整理现场包, 或明确加 --force 覆盖冲突件。')
  252. print(' 放置规范与示例见 docs/输入数据放置指导_v0.1.md')
  253. return _chk.RC_CLASH if _clash else _chk.RC_STRUCT
  254. print()
  255. from src.windscada.config import farm, raw_station_dir
  256. station = pathlib.Path(raw_station_dir())
  257. cfg = farm()
  258. print(f'场站目录: {station} (来自 src/windscada/config.py raw_station={cfg.get("raw_station")!r})')
  259. print(f'现场数据: {src} 范围: --scope {a.scope}\n')
  260. rules, loose = {
  261. 'a2': (RULES, ()),
  262. 'mech': (RULES_MECH, ()),
  263. 'vib': (RULES_VIB, RULES_VIB_LOOSE),
  264. 'full': (RULES + RULES_MECH + RULES_VIB, RULES_VIB_LOOSE),
  265. }[a.scope]
  266. items = plan(src, station, rules, loose, limit=a.limit)
  267. by_target = {}
  268. for zname, dst, entry, size, root, target in items:
  269. d = by_target.setdefault((root, target), [0, 0])
  270. d[0] += 1
  271. d[1] += size
  272. print(f'== 计划落位 (scope={a.scope}) ==')
  273. for (root, target), (n, sz) in sorted(by_target.items(), key=lambda kv: kv[0][1]):
  274. where = (station / target) if root == 'station' else (station.parent / target)
  275. print(f' {target:44s} {n:6d} 件 {human(sz):>10s} → {where}')
  276. print(f' 合计 {len(items)} 件, {human(sum(i[3] for i in items))}')
  277. if a.scope in ('vib', 'full'):
  278. print('\n== 故意不落 (振动侧同件副本) ==')
  279. for zname, prefix, why in SKIPPED:
  280. if '振动' in prefix or '报告11份' in prefix:
  281. print(f' {prefix}\n 理由: {why}')
  282. if a.dry_run:
  283. print('\n(dry-run, 未写盘)')
  284. return 0
  285. print('\n== 落位 ==')
  286. done = 0
  287. skipped = 0
  288. written = 0
  289. # 按包分组 + **每包只开一次**。旧写法在循环体里 with ZipFile(...) 且用 next(...) 线性找条目 ——
  290. # 对 2.5 万件的大包是 25,679 次开包 × 25,759 次名字比较 ≈ 6.6 亿次比较, 实测不可接受。
  291. # 改成: 每包一次性建 {条目名: ZipInfo} 映射, 顺序流式写出。
  292. by_zip = {}
  293. for it in items:
  294. by_zip.setdefault(it[0], []).append(it)
  295. for zname, group in by_zip.items():
  296. zf = zipfile.ZipFile(src / zname) if zname else None
  297. try:
  298. imap = {gbk_name(i).replace('\\', '/'): i for i in zf.infolist()} if zf else {}
  299. for _, dst, entry, size, root, target in group:
  300. # 已有同尺寸文件 = 已经是最新 → 跳过。重跑一次不该把 150 GB 原样再抄一遍
  301. # (vib 范围解压 153.7 GB; a2 14.7 GB / mech 15.5 GB; 2026-09-11 修单文件规则时
  302. # 就是靠这个避免整盘重写)。
  303. if dst.exists() and dst.stat().st_size == size:
  304. skipped += 1
  305. continue
  306. dst.parent.mkdir(parents=True, exist_ok=True)
  307. if zf is None:
  308. with open(src / entry, 'rb') as fsrc, open(dst, 'wb') as fdst:
  309. shutil.copyfileobj(fsrc, fdst, 1024 * 1024 * 4)
  310. else:
  311. with zf.open(imap[entry]) as fsrc, open(dst, 'wb') as fdst:
  312. shutil.copyfileobj(fsrc, fdst, 1024 * 1024 * 4)
  313. got = dst.stat().st_size
  314. if got != size:
  315. raise SystemExit(f'写出大小不符: {dst} 期望 {size} 实得 {got}')
  316. done += 1
  317. written += size
  318. # 大包进度: 每 500 件报一次 (2.5 万件逐件打印会把日志刷爆); 单件 >200 MB 也报
  319. if done % 500 == 0 or size > 200 * 1024 * 1024:
  320. print(f' [{done}/{len(items)}] 已写 {human(written)} {dst.name}', flush=True)
  321. finally:
  322. if zf is not None:
  323. zf.close()
  324. print(f'\n完成 (scope={a.scope}): 新写/更新 {done} 件 ({human(written)}), 已是最新跳过 {skipped} 件, 共 {len(items)} 件')
  325. return 0
  326. if __name__ == '__main__':
  327. sys.exit(main())