Просмотр исходного кода

A3: 现场数据按 A2 落位到 data/raw/如东/ (592 件 / 14.7 GB)

- 新增 scripts/place_raw_data.py: 把 GBK 名的现场大压缩包按**映射表**落到
  data/raw/<场站名称>/ 下; 目标目录从 src.windscada.config.raw_station_dir() 取
  (与维护页同一真源, 不另拼字符串); 支持 --dry-run 先看计划; 逐件比对写出大小与
  zip 元数据 (读到底会顺带校验 CRC)
- 落位明细:
    scada_10min   38 件 14.4 GB  10分钟数据.zip 的 WTG01..38.csv 平铺 (data.py 按
                                 <src_10min>/<turbine>.csv 读, 所以不能多套一层目录)
    故障报警      16 件  22.7 MB  报警数据/ 全部 + 数据收集/更新/(2)故障记录/ 的
                                 *年至今.xls (正是页面原先写的形态)
    风机故障记录  134 件 117.7 MB 工作/风机故障记录/ 全部 (2021…2026 年故障记录/、
                                 业主统计故障/、年度 .rar) + 大部件维修记录
    油样报告      404 件 168.1 MB 2025年油样/<台号>/<部件>/*.pdf (去掉年份那层,
                                 与维护页说明"按台号/部件分目录"一致)
- 故意不落并在脚本里写明理由:
    (3)现场检修记录/2025|2026年检修记录 —— 与 工作/风机故障记录/2025|2026年故障记录
      逐件同名同大小 (19/19), 是副本; 落两遍会让"按年目录"的摄入看到重复台账
      (2026-08-31 长停台账虚高 35 倍是同款成因: 快照重复必须归并, 不能叠加)
    (8)/振动分析报告 —— 属振动线 (CMS), 不在 A2 四项数据层内
- 现场散落件已核: 2026年05月故障记录汇总表 与已落位件 MD5 相同 (副本), 不重复落
- 未落 1分钟数据 (按本轮选择); data/raw/* 在 .gitignore 内, 数据本身不入库
- 验收: 契约门 155/164 通过; 四项 src 目录 exists=True;
  src.windscada.data.load_10min('WTG01', groups=['A.功率','A.风况'])
  → 77551 行 × 17 列, ts 2025-01-01 ~ 2026-07-07
zhouyang.xie 1 месяц назад
Родитель
Сommit
c98bbb0106
1 измененных файлов с 172 добавлено и 0 удалено
  1. 172 0
      scripts/place_raw_data.py

+ 172 - 0
scripts/place_raw_data.py

@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""把现场给的数据包按 A2 约定落位到 data/raw/<场站名称>/ 下 (2026-09-11)。
+
+## 为什么要有这个脚本
+
+A2 定了四项数据源的落位: data/raw/<场站名称>/{scada_10min, 故障报警, 风机故障记录, 油样报告}。
+现场拿到的却是几个 GBK 名的大压缩包 (10分钟数据.zip / 如东风场数据.zip), 内部目录名与
+落位目录名并不一一对应 (例如报警在 `报警数据/`, 工单在 `工作/风机故障记录/`,
+油样埋在 `数据收集/更新/(8)…/2025年油样/`)。手工拖拽容易拖错一层、也说不清依据什么。
+
+本脚本把映射**写成表**, 于是"哪个包里的哪个目录去了哪"是可复核的, 重复跑也不会走样。
+
+## 落位映射 (target 相对 data/raw/<场站名称>/)
+
+  scada_10min/    ← 10分钟数据.zip 全部 38 个 WTG*.csv (平铺; src/windscada/data.py 按
+                    <src_10min>/<turbine>.csv 读, 所以必须是文件本身不是再套一层目录)
+  故障报警/        ← 如东风场数据.zip: 报警数据/**           (年度/季度 .xls, XML 报警导出)
+                    如东风场数据.zip: 数据收集/更新/(2)故障记录(首发故障有标识)(2025.1-至今)/故障记录/**
+                                      (2025年全年故障记录.xls / 2026年至今.xls — 正是页面写的 *年至今.xls)
+  风机故障记录/    ← 如东风场数据.zip: 工作/风机故障记录/**   (2021…2026 年故障记录/ + 业主统计故障/ + 年度 .rar)
+                    如东风场数据.zip: 数据收集/更新/(3)现场检修记录(2025.1-至今)/大部件维修记录.*.xlsx
+  油样报告/        ← 如东风场数据.zip: 数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/2025年油样/**
+                                      (去掉"2025年油样"这一层, 直接落成 <台号>/<部件>/<pdf>,
+                                       与维护页说明"按台号/部件分目录"一致)
+
+## 故意不做的事
+
+  · 不落 `(3)现场检修记录/2025年检修记录` 与 `2026年检修记录`: 与 工作/风机故障记录/2025年故障记录、
+    2026年故障记录 **逐件同名同大小**(19/19 件), 是同一批月度汇总表的副本。落两遍会让"按年目录"
+    摄入看到两份重复台账 (2026-08-31 长停台账虚高 35 倍那类事故的同款成因: 快照重复必须归并, 不能叠加)。
+  · 不落 `(8)…/振动分析报告/`: 属振动线 (CMS), 不在 A2 四项数据层内。
+  · 不落 1分钟数据.zip / scada数据(如东)/ / 西门子4.0技术资料/ 等: A3 本轮范围是 A2 那四类。
+
+用法:
+    python scripts/place_raw_data.py --dry-run      # 只报要落什么, 不写盘
+    python scripts/place_raw_data.py                # 真落位 (已存在则覆盖)
+    python scripts/place_raw_data.py --src D:\\别的现场数据目录
+"""
+from __future__ import annotations
+
+import argparse
+import pathlib
+import shutil
+import sys
+import zipfile
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+DEFAULT_SRC = pathlib.Path(r'F:\temp\如东风场数据')
+ZIP_10MIN = '10分钟数据.zip'
+ZIP_FARM = '如东风场数据.zip'
+
+# (压缩包, 包内前缀, 目标子目录, 是否去掉前缀这一层)
+RULES = [
+    ('10分钟数据.zip', '', 'scada_10min', True),
+    ('如东风场数据.zip', '如东风场数据/报警数据/', '故障报警', True),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(2)故障记录(首发故障有标识)(2025.1-至今)/故障记录/', '故障报警', True),
+    ('如东风场数据.zip', '如东风场数据/工作/风机故障记录/', '风机故障记录', True),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/大部件维修记录.20240619143912557.xlsx', '风机故障记录', True),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/2025年油样/', '油样报告', True),
+]
+# 说明性的"故意不落", 只在报告里列出来
+SKIPPED = [
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/2025年检修记录/',
+     '与 工作/风机故障记录/2025年故障记录 逐件同名校验相同 (副本)'),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(3)现场检修记录(2025.1-至今)/2026年检修记录/',
+     '与 工作/风机故障记录/2026年故障记录 逐件同名校验相同 (副本)'),
+    ('如东风场数据.zip', '如东风场数据/数据收集/更新/(8)风机振动数据、油液分析记录(2025.1-至今)/振动分析报告/',
+     '属振动线 (CMS), 不在 A2 四项数据层内'),
+]
+
+
+def gbk_name(info: zipfile.ZipInfo) -> str:
+    """zip 条目名 → 真名。现场包用 GBK 存中文名, zipfile 按 cp437 解出来是乱码。"""
+    raw = info.filename
+    try:
+        return raw.encode('cp437').decode('gbk')
+    except Exception:
+        try:
+            return raw.encode('cp437').decode('utf-8')
+        except Exception:
+            return raw
+
+
+def human(n: float) -> str:
+    for unit in ('B', 'KB', 'MB', 'GB'):
+        if n < 1024 or unit == 'GB':
+            return f'{n:.1f} {unit}'
+        n /= 1024
+    return f'{n:.1f} GB'
+
+
+def plan(src: pathlib.Path, station: pathlib.Path):
+    """→ [(zip, 目标文件, 包内条目名, 解压后大小)]; 只读压缩包目录, 不解压。"""
+    out = []
+    for zname, prefix, target, strip in RULES:
+        zp = src / zname
+        if not zp.exists():
+            raise SystemExit(f'缺压缩包: {zp}')
+        with zipfile.ZipFile(zp) as zf:
+            for info in zf.infolist():
+                if info.is_dir():
+                    continue
+                name = gbk_name(info).replace('\\', '/')
+                if not name.startswith(prefix):
+                    continue
+                rest = name[len(prefix):] if strip else name
+                if not rest:
+                    continue
+                out.append((zname, station / target / rest, name, info.file_size))
+    return out
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--src', default=str(DEFAULT_SRC), help='现场数据目录 (默认 %(default)s)')
+    ap.add_argument('--dry-run', action='store_true', help='只报计划, 不写盘')
+    a = ap.parse_args()
+
+    src = pathlib.Path(a.src)
+    if not src.is_dir():
+        raise SystemExit(f'现场数据目录不存在: {src}')
+
+    from src.windscada.config import farm, raw_station_dir
+    station = pathlib.Path(raw_station_dir())
+    cfg = farm()
+    print(f'场站目录: {station}   (来自 src/windscada/config.py raw_station={cfg.get("raw_station")!r})')
+    print(f'现场数据: {src}\n')
+
+    items = plan(src, station)
+    by_target = {}
+    for zname, dst, entry, size in items:
+        t = dst.relative_to(station).parts[0]
+        d = by_target.setdefault(t, [0, 0])
+        d[0] += 1
+        d[1] += size
+    print('== 计划落位 ==')
+    for t in ('scada_10min', '故障报警', '风机故障记录', '油样报告'):
+        n, sz = by_target.get(t, (0, 0))
+        print(f'  {t:12s} {n:5d} 件  {human(sz):>10s}   → {station / t}')
+    print(f'  合计 {len(items)} 件, {human(sum(i[3] for i in items))}')
+
+    print('\n== 故意不落 ==')
+    for zname, prefix, why in SKIPPED:
+        print(f'  {prefix}\n      理由: {why}')
+
+    if a.dry_run:
+        print('\n(dry-run, 未写盘)')
+        return 0
+
+    print('\n== 落位 ==')
+    done = 0
+    for zname, dst, entry, size in items:
+        dst.parent.mkdir(parents=True, exist_ok=True)
+        with zipfile.ZipFile(src / zname) as zf:
+            info = next(i for i in zf.infolist() if gbk_name(i).replace('\\', '/') == entry)
+            with zf.open(info) as fsrc, open(dst, 'wb') as fdst:
+                shutil.copyfileobj(fsrc, fdst, 1024 * 1024 * 4)
+        got = dst.stat().st_size
+        if got != size:
+            raise SystemExit(f'写出大小不符: {dst} 期望 {size} 实得 {got}')
+        done += 1
+        if done % 10 == 0 or size > 50 * 1024 * 1024:
+            print(f'  [{done}/{len(items)}] {human(size):>10s}  {dst.relative_to(station)}', flush=True)
+    print(f'\n完成: {done} 件写入 {station}')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())