Explorar el Código

现场收资接入: 新增 extract_site_data.py(现场 zip → data/raw, 零 pip 依赖) + 对账证明 raw 输入可追溯; cage_slip 判据形态追测

用户令两件:

① 建议②: 追 cage_slip_scan 的"稳态段筛选"。实测(用 w0127 索引 + 样件 249 个键):
   · 保留键在索引里的记录数 112~1426、时间跨度中位 5,072 min; 未保留键只有 1~218 条、跨度中位 752 min
     ⇒ 判据形态是"**该分箱是常态工况**"(记录足够多、跨得足够久), 不是按单条记录挑。
   · 但样本 n_rec 只有 2~7, 与该分箱记录数完全不是一回事 ⇒ n_rec 统计的是某种"可用记录数"
     (疑为窄转速带内的稳态记录数)。判据形态已明确, 具体阈值/定义仍需口径。已记进 docs §10。

② 现场收资接入(新能力): 新增 scripts/extract_site_data.py —— 纯标准库 + 系统 ODBC, 不新增 pip 依赖。
   实测 F:\temp\如东风场数据 的结构:
   · 10分钟数据.zip(3,760 MB): 38 件 WTG01..WTG38.csv, 表头 TimeStamp,WTG,StationId,cnt_wtc_…
   · 1分钟数据.zip (1,548 MB): 38 件 01E.csv…38B.csv(机组代号), 中文列表头
   · 25年.zip / 26年.zip: <年>/<月>/<年-月-类>.zip → .mdb(Access); 每月 12 类 cnt din dot flg grd
     int prs scd std sum tmp tur; std.mdb 只有表 tblGrid(23 列: TimeStamp/Station/Current L1-L3/
     Voltage L1-L3/ActivePower/…) ⇒ 10 分钟节拍 + Station 机组号(91/92)。
   能力:
   · --verify-raw 对账(不解压即可验): **10分钟数据.zip → data/raw/如东/scada_10min 38/38 同大小;
     1分钟数据.zip → data/raw/如东/scada_1min 38/38 同大小** ⇒ 现有 raw 输入与现场 zip 逐件一致、可追溯。
   · --extract-csv [--only-missing] 解到 data/raw(幂等: 同名同大小即跳过)
   · --list / --extract(年度 MDB, 支持 --year/--month) / --probe(读取能力 + MDB 表)
   · --to-csv --class std …: MDB → data/raw/如东/<类>/WTG<NN>.csv(Station 90 起映射 WTG01, 时间列统一 Time)
   MDB 读取路线: Windows = .NET System.Data.Odbc + 系统 Access ODBC 驱动(本机实测可用);
   Linux = mdbtools。刻意不把 pyodbc 塞进包(驱动是系统级前提, 装包也解决不了) —— 改为"探针 + 明确补救 + rc=4"。
   顺带修自己一处错: 第一版按 P.RAW_ROOT/farm 拼 raw 路径 ⇒ 对着 data/raw/rudong 找, 报"38 件全缺";
   场站目录名与 farm 键不同(键 rudong / 目录 如东), 必须走 raw_station_dir() 唯一取用口。

新增 docs/现场收资接入_v0.1.md(结构/命令/读取路线/映射/仍缺的输入如实列)。
自检: --verify-raw rc=0(38/38×2); --list/--probe 正常; 入口引用闭合与编码守则无问题。
zhouyang.xie hace 3 semanas
padre
commit
14724aefb0

+ 6 - 0
docs/振动六层链_接口规格与缺口_v0.1.md

@@ -312,6 +312,12 @@ option 串 → oem_bearing_freqs.json 条目:
 1. **能由数据推(已验证)**:`bearing_freq_scan` / `gear_freq_scan` 的行骨架 = 窗 × 机组 × 4 个
    (component, sensor) 组合,由索引里的 `turbine`/`sensor_name` 直接给出 —— w0127 上 **74/74 逐键一致**。
    (两张表都是"每 (窗,机组) 恰好 4 行",窗集合 6 个、每窗 35~37 台。)
+2. **要口径(已定量界住,2026-09-17 又追了一轮)**:`cage_slip_scan` 的键是索引的**子集**。
+   追加实测(建议②那一轮):**保留键**在索引里的记录数 **112~1426**、时间跨度中位 **5,072 min**;
+   **未保留键**只有 **1~218** 条、跨度中位 **752 min** ⇒ 判据形态是"**该分箱是常态工况**"(记录足够多、
+   跨得足够久),而不是按单条记录挑。但样本 `n_rec` 只有 **2~7**,与该分箱的记录数**完全不是一回事**
+   ⇒ `n_rec` 统计的是**某种"可用记录数"**(疑为窄转速带内的稳态记录数)。结论:判据形态已明确,
+   具体阈值/定义仍需口径。以下是原描述:
 2. **要口径(已定量界住)**:`cage_slip_scan` 的键是索引的**子集**(w0127:样件 83 键/部件 vs 索引 282),
    少掉的由**稳态段筛选**决定 —— 且样本自己的 `n_rec` 只有 **2~7**,与"该 (机组,测点,分箱) 在索引里的
    记录数 47~1426"**完全不是一回事** ⇒ `n_rec` 统计的不是分箱记录数,而是某种"可用记录数"。

+ 72 - 0
docs/现场收资接入_v0.1.md

@@ -0,0 +1,72 @@
+# 现场收资接入(v0.1,2026-09-17)
+
+> 面向:现场交来的 **`F:\temp\如东风场数据`** 这一类目录 → 观澜的 `data/raw/<场站>/`。
+> 工具:`scripts/extract_site_data.py`(纯标准库 + 系统 ODBC,**不新增任何 pip 依赖**)。
+
+---
+
+## 1. 现场交来的东西长什么样(2026-09-17 实测)
+
+| 文件 | 体量 | 内容 | 用途 |
+|---|---|---|---|
+| `10分钟数据.zip` | 3,760 MB | 38 件 `WTG01.csv … WTG38.csv`,表头 `TimeStamp,WTG,StationId,cnt_wtc_…` | **10 分钟 SCADA**(观澜 `scada_10min` 的正源) |
+| `1分钟数据.zip` | 1,548 MB | 38 件 `01E.csv … 38B.csv`(机组代号命名),表头 `time_stamp,3#桨电机温度,…`(中文列) | **1 分钟 SCADA**(观澜 `scada_1min` 的正源) |
+| `25年.zip` | 1,071 MB | `25年/<月>/2025-<月>-<类>.zip` → **`.mdb`(Access)** | 分类深度源(每月 12 类) |
+| `26年.zip` | 961 MB | `26年/<月>/2026-<月>-<类>.zip` → `.mdb` | 同上 |
+
+**年度 zip 内的类目**(每月一套):`cnt din dot flg grd int prs scd std sum tmp tur`。
+实测 `std.mdb` 只有一张表 `tblGrid`:
+
+```
+TimeStamp | Station | WPSStatus | TimestampStation | CurrentL1..L3 | VoltageL1..L3 |
+ActivePower | ReActivePower | ActivePowerExport | ReActivePowerExport | …(23 列)
+```
+`TimeStamp` 为 10 分钟节拍,`Station` 为机组号(如 91/92)。
+
+## 2. 接入观澜(命令)
+
+```bash
+# ① 对账:现场 zip ↔ 现有 data/raw(逐件比大小,不解压十几 GB 就能验,幂等)
+python scripts/extract_site_data.py --src "F:\temp\如东风场数据" --verify-raw
+#    实测: 10分钟数据.zip → data/raw/如东/scada_10min  38/38 同大小
+#          1分钟数据.zip → data/raw/如东/scada_1min    38/38 同大小  ⇒ 现有 raw 输入可追溯
+
+# ② 提取(幂等;已有同大小文件即跳过;--only-missing 只补缺件)
+python scripts/extract_site_data.py --src "F:\temp\如东风场数据" --extract-csv --only-missing
+
+# ③ 年度 MDB(深度源)—— 只看结构 / 解出 / 探针
+python scripts/extract_site_data.py --src "F:\temp\如东风场数据" --list
+python scripts/extract_site_data.py --src "F:\temp\如东风场数据" --extract --year 26 --month 1
+python scripts/extract_site_data.py --src "F:\temp\如东风场数据" --probe --class std
+
+# ④ 接上观澜既有链路(体检 → 重算)
+python scripts/raw_data_check.py            # 结构/增量/时间空洞体检(R1–R8)
+python scripts/rebuild_all.py               # 或门户「数据重算」
+```
+
+## 3. 读 Access `.mdb` 的路线(离线、零 pip 依赖)
+
+| 平台 | 用什么 | 前提 |
+|---|---|---|
+| Windows | **.NET `System.Data.Odbc` + 系统 `Microsoft Access Driver (*.mdb, *.accdb)`** | 装了 Office 或 Access Runtime 的机器自带;**本机实测可用** |
+| Linux | `mdbtools` 的 `mdb-export` / `mdb-tables` | `apt/yum install mdbtools` |
+
+两条路本器都会**先探针**:`--probe` 缺能力时给补救办法并返回 rc=4,**不静默、不猜**。
+(刻意不把 `pyodbc` 之类的包塞进交付包:ODBC 驱动是系统级前提,装了包也解决不了;改成"探针 + 明确补救"更诚实。)
+
+## 4. 从 MDB 转 CSV(`--to-csv`)
+
+`python scripts/extract_site_data.py --src <…> --to-csv --class std [--year 26 --month 1] [--turbines WTG01,WTG02]`
+
+- 落点按类目映射:`CLASS_TO_RAW`(`std` → `scada_10min`;其余类目**待现场口径确认后才接**,未确认前不硬接);
+- 机组号映射:`Station` 起点 90 ⇒ `WTG01`(实测 `std.mdb` 里 `Station=91/92`);
+- 时间列统一改名 `Time`,逐机组落 `data/raw/如东/<类>/WTG<NN>.csv`,追加式写入(同一机组多月叠加)。
+
+## 5. 还没有的输入(如实列)
+
+| 观澜要的类 | 现状 | 缺什么 |
+|---|---|---|
+| `故障报警` | 16 件 `2025Q1.xls …`(已有) | — |
+| `风机故障记录` | 10 件(`*.rar` / `*.xlsx`) | — |
+| `油样报告` | **0 件** | 现场 PDF 报告 |
+| `windcms` / `m5_cms_tcm` | **0 件** | 振动侧 CMS 导出件(取料单第 7、8 项) |

+ 393 - 0
scripts/extract_site_data.py

@@ -0,0 +1,393 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""现场收资接入:从场站交来的年度 zip(内层是每月每类 `.mdb`)提取并转成观澜 `data/raw/<场站>/`。
+
+## 场站交来的东西长什么样(2026-09-17 实测 `F:\temp\如东风场数据`)
+
+```
+25年.zip (1,022 MB) / 26年.zip (961 MB)
+  └ 26年/1月/2026-01-cnt.zip → 2026-01-cnt.mdb   (52.7 MB)
+            2026-01-std.zip → 2026-01-std.mdb   ( 2.3 MB)   ← tblGrid: 10 分钟电网/机组量测
+            2026-01-tur.zip → 2026-01-tur.mdb   (47.6 MB)
+            2026-01-scd.zip → 2026-01-scd.mdb   ( 5.6 MB)
+            … 每月 12 类:cnt din dot flg grd int prs scd std sum tmp tur
+```
+
+即 **Access 数据库按月分类**(不是 CSV)。实测 `std.mdb` 里是单表 `tblGrid`:
+
+```
+TimeStamp | Station | WPSStatus | TimestampStation | CurrentL1..L3 | VoltageL1..L3 |
+ActivePower | ReActivePower | ActivePowerExport | ReActivePowerExport | …
+```
+`TimeStamp` 是 10 分钟节拍,`Station` 是机组号(如 91/92 —— 与 WTG 号的对应关系见 §映射)。
+
+## 读 MDB 的路线(离线、零 pip 依赖)
+
+Windows 上用 **.NET `System.Data.Odbc` + 系统自带的 `Microsoft Access Driver (*.mdb, *.accdb)`**
+(本机实测可用;驱动来自 Office/Access Runtime,纯服务器上可能没有 ⇒ 本器会**先探针、缺了就响亮报并给补救**)。
+Linux 上用 `mdbtools` 的 `mdb-export`(本机没有)。两条路都不需要往包里塞新 pip 包。
+
+## 用法
+
+    python scripts/extract_site_data.py --src <年度zip所在目录> --list
+    python scripts/extract_site_data.py --src <…> --extract [--year 26] [--month 1] [--out data/raw/如东/scada_mdb]
+    python scripts/extract_site_data.py --src <…> --probe [--class std]
+    python scripts/extract_site_data.py --src <…> --to-csv --class std [--year 26 --month 1] [--turbines WTG01,WTG02]
+
+退出码: 0 成功 · 2 来源/参数不齐 · 4 平台无 MDB 读取能力(已给补救办法) · 5 转换/校验失败
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import pathlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import zipfile
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                              # noqa: E402
+
+WIN32 = sys.name == 'nt' if hasattr(sys, 'name') else False
+WIN32 = sys.platform.startswith('win')
+DRIVER = 'Microsoft Access Driver (*.mdb, *.accdb)'
+# 类目 → 观澜侧的落点(先按已知的上线:只有 std 与 sum 是 10min 量测;其余待现场口径确认)
+CLASS_TO_RAW = {
+    'std': 'scada_10min',      # 实测: tblGrid = 10 分钟电网/机组量测(TimeStamp/Station/ActivePower/…)
+    'sum': 'scada_10min',      # 待确认(体量最大, 疑为统计汇总)
+    'cnt': 'scada_10min',      # 待确认
+    'tur': 'scada_10min',      # 待确认(47 MB, 疑为机组状态)
+}
+
+
+# 现场交来的**已导出 CSV** zip → 观澜 raw 目录(2026-09-17 实测:与 data/raw 现有 38 件逐名对应)
+ZIP_TO_RAW = {
+    '10分钟数据': 'scada_10min',     # WTG01.csv … WTG38.csv(38 件 / 14.4 GB)
+    '1分钟数据': 'scada_1min',       # 01E.csv … 38B.csv(38 件 / 12.8 GB,机组代号命名)
+}
+
+
+def verify_raw(src: pathlib.Path, farm: str | None = None) -> int:
+    """对账:现场 zip 里的 CSV ↔ `data/raw/<场>/<类>/` 现有文件(逐件比大小)。
+
+    这一步回答的是"**现有的输入数据到底是不是从这两个 zip 来的**" —— 不去解压十几 GB 就能验。
+    """
+    farm = farm or P.farm()
+    zips = [z for z in find_year_zips(src) if z.stem in ZIP_TO_RAW]
+    if not zips:
+        print(f'[X] {src} 下没有 {"/".join(ZIP_TO_RAW)}.zip')
+        return 2
+    print(f'== 现场 zip ↔ data/raw/{farm}/ 对账 ==')
+    bad = 0
+    for z in zips:
+        d = station_dir(farm) / ZIP_TO_RAW[z.stem]
+        with zipfile.ZipFile(z) as zf:
+            ents = [e for e in zf.infolist() if not e.is_dir()]
+        same = miss = diff = 0
+        miss_names, diff_names = [], []
+        for e in ents:
+            tgt = d / pathlib.Path(e.filename).name
+            if not tgt.is_file():
+                miss += 1
+                miss_names.append(tgt.name)
+            elif tgt.stat().st_size == e.file_size:
+                same += 1
+            else:
+                diff += 1
+                diff_names.append(f'{tgt.name}(zip {e.file_size:,} vs 盘 {tgt.stat().st_size:,})')
+        bad += miss + diff
+        print(f'   {z.name} → {P.rel(d)}: zip {len(ents)} 件 · 逐字节同大小 {same} · 缺 {miss} · 大小不同 {diff}')
+        for n in miss_names[:4]:
+            print(f'      缺: {n}')
+        for n in diff_names[:4]:
+            print(f'      异: {n}')
+    print(f'   结论: {"现有 raw 输入与现场 zip 逐件一致(可追溯)" if bad == 0 else str(bad) + " 件需要重新提取"}  '
+          f'(缺件提取: --extract-csv --only-missing)')
+    return 0 if bad == 0 else 5
+
+
+def extract_csv(src: pathlib.Path, farm: str | None = None, only_missing: bool = False) -> int:
+    """把"已导出 CSV"的现场 zip 解到 `data/raw/<场>/<类>/`(幂等:同名同大小即跳过)。"""
+    farm = farm or P.farm()
+    zips = [z for z in find_year_zips(src) if z.stem in ZIP_TO_RAW]
+    if not zips:
+        print(f'[X] {src} 下没有 {"/".join(ZIP_TO_RAW)}.zip')
+        return 2
+    done = skipped = 0
+    for z in zips:
+        d = station_dir(farm) / ZIP_TO_RAW[z.stem]
+        d.mkdir(parents=True, exist_ok=True)
+        print(f'   {z.name} → {P.rel(d)}')
+        with zipfile.ZipFile(z) as zf:
+            for e in zf.infolist():
+                if e.is_dir():
+                    continue
+                tgt = d / pathlib.Path(e.filename).name
+                if tgt.is_file() and tgt.stat().st_size == e.file_size:
+                    skipped += 1
+                    continue
+                if only_missing and tgt.is_file():
+                    skipped += 1
+                    continue
+                print(f'      + {tgt.name}  {e.file_size / 1e6:.1f} MB')
+                with zf.open(e) as fh, open(tgt, 'wb') as out:
+                    shutil.copyfileobj(fh, out, 1024 * 1024 * 8)
+                done += 1
+    print(f'   完成: 新解 {done} 件 · 跳过(已同大小){skipped} 件')
+    print('   接下去: python scripts/raw_data_check.py(体检)→ python scripts/rebuild_all.py(重算)')
+    return 0
+
+
+def station_dir(farm: str | None = None) -> pathlib.Path:
+    """`data/raw/<场站>/` —— ★场站**目录名**可与 farm 键不同(键 = rudong,目录 = 如东),
+    所以必须走唯一取用口 `raw_station_dir()`;第一版按 `P.RAW_ROOT / farm` 拼,于是"38 件全缺"
+    (对着 data/raw/rudong 找, 而东西在 data/raw/如东)。"""
+    from src.windscada.config import raw_station_dir
+    return pathlib.Path(raw_station_dir(farm or P.farm()))
+
+
+def find_year_zips(src: pathlib.Path) -> list[pathlib.Path]:
+
+    return sorted([p for p in src.glob('*.zip') if p.is_file()])
+
+
+def list_structure(src: pathlib.Path) -> int:
+    zips = find_year_zips(src)
+    if not zips:
+        print(f'[X] {src} 下没有年度 zip')
+        return 2
+    print(f'== 现场收资结构 · {src} ==')
+    for z in zips:
+        with zipfile.ZipFile(z) as zf:
+            names = zf.namelist()
+        months = sorted({n.split('/')[1] for n in names if n.count('/') >= 1 and n.split('/')[1]})
+        classes = sorted({n.split('/')[-1].split('-')[-1].replace('.zip', '')
+                          for n in names if n.lower().endswith('.zip')})
+        print(f'   {z.name}  {z.stat().st_size / 1e6:.0f} MB · {len(names)} 条目')
+        print(f'      月份 {len(months)} 个: {", ".join(months)}')
+        print(f'      类目 {len(classes)} 个: {", ".join(classes)}')
+    return 0
+
+
+def extract(src: pathlib.Path, out: pathlib.Path, year: str | None, month: str | None) -> int:
+    zips = [z for z in find_year_zips(src) if (not year or z.stem.startswith(year))]
+    if not zips:
+        print('[X] 没有匹配的年度 zip')
+        return 2
+    out.mkdir(parents=True, exist_ok=True)
+    man_p = out / '_manifest.json'
+    man = json.loads(man_p.read_text(encoding='utf-8')) if man_p.is_file() else dict(zips=[], files=[])
+    done = 0
+    for z in zips:
+        with zipfile.ZipFile(z) as zf:
+            inner = [n for n in zf.namelist() if n.lower().endswith('.zip')
+                     and (not month or f'/{month}月/' in n)]
+            print(f'   {z.name}: 内层 {len(inner)} 个分类 zip' + (f'(限 {month} 月)' if month else ''))
+            for n in inner:
+                parts = n.split('/')                       # 26年/1月/2026-01-std.zip
+                rel = pathlib.Path(*parts[1:]) if len(parts) > 1 else pathlib.Path(parts[0])
+                dst_dir = out / rel.parent
+                dst_dir.mkdir(parents=True, exist_ok=True)
+                with zf.open(n) as fh, zipfile.ZipFile(fh) as inner_zf:
+                    for m in inner_zf.namelist():
+                        tgt = dst_dir / pathlib.Path(m).name
+                        if tgt.is_file() and tgt.stat().st_size == inner_zf.getinfo(m).file_size:
+                            continue
+                        tgt.write_bytes(inner_zf.read(m))
+                        h = hashlib.sha256(tgt.read_bytes()).hexdigest()[:16]
+                        man['files'] = [x for x in man['files'] if x['path'] != str(tgt.relative_to(out))]
+                        man['files'].append(dict(path=str(tgt.relative_to(out)), bytes=tgt.stat().st_size,
+                                                 sha256_16=h, from_zip=f'{z.name}:{n}'))
+                        done += 1
+                        print(f'      + {tgt.relative_to(out)}  {tgt.stat().st_size / 1e6:.1f} MB  sha256:{h}')
+    man['zips'] = sorted({*man.get('zips', []), *[z.name for z in zips]})
+    man_p.write_text(json.dumps(man, ensure_ascii=False, indent=1) + '\n', encoding='utf-8')
+    print(f'   完成: 新解 {done} 个 MDB → {P.rel(out)}(清单 {P.rel(man_p)})')
+    return 0
+
+
+def have_mdb_route() -> tuple[bool, str]:
+    if WIN32:
+        ps = ('$d=(Get-OdbcDriver | Where-Object { $_.Name -like "*Access Driver (*.mdb*" }).Count;'
+              'if($d -gt 0){"ok"}else{"no"}')
+        try:
+            r = subprocess.run(['powershell', '-NoProfile', '-Command', ps], capture_output=True, text=True,
+                               errors='replace', timeout=120)
+            ok = 'ok' in (r.stdout or '')
+            return ok, 'Windows + .NET System.Data.Odbc + 系统 Access ODBC 驱动' if ok else '缺 Access ODBC 驱动'
+        except Exception as e:
+            return False, f'探测失败: {type(e).__name__}'
+    ok = bool(shutil.which('mdb-export') or shutil.which('mdb-tables'))
+    return ok, 'mdbtools (mdb-export)' if ok else '缺 mdbtools'
+
+
+def mdb_tables(mdb: pathlib.Path) -> list[str]:
+    if WIN32:
+        ps = (f'$cs="Driver={{{DRIVER}}};Dbq={mdb};";'
+              '$cn=New-Object System.Data.Odbc.OdbcConnection($cs);$cn.Open();'
+              '$cn.GetSchema("Tables") | Where-Object { $_.TABLE_TYPE -eq "TABLE" } | '
+              'ForEach-Object { $_.TABLE_NAME };$cn.Close()')
+        r = subprocess.run(['powershell', '-NoProfile', '-Command', ps], capture_output=True, text=True,
+                           errors='replace', timeout=300)
+        if r.returncode != 0:
+            raise RuntimeError((r.stderr or r.stdout or '').strip()[:300])
+        return [x.strip() for x in (r.stdout or '').splitlines() if x.strip()]
+    r = subprocess.run(['mdb-tables', '-1', str(mdb)], capture_output=True, text=True, errors='replace', timeout=300)
+    return [x.strip() for x in (r.stdout or '').splitlines() if x.strip()]
+
+
+def mdb_to_csv(mdb: pathlib.Path, table: str, out_csv: pathlib.Path, where: str = '') -> int:
+    """MDB 单表 → CSV(UTF-8)。Windows 走 PowerShell/.NET Odbc;Linux 走 mdb-export。"""
+    out_csv.parent.mkdir(parents=True, exist_ok=True)
+    if WIN32:
+        q = f'SELECT * FROM [{table}]' + (f' WHERE {where}' if where else '')
+        ps = f'''$cs="Driver={{{DRIVER}}};Dbq={mdb};"
+$cn=New-Object System.Data.Odbc.OdbcConnection($cs);$cn.Open()
+$cmd=$cn.CreateCommand();$cmd.CommandText="{q}"
+$da=New-Object System.Data.Odbc.OdbcDataAdapter($cmd)
+$dt=New-Object System.Data.DataTable;[void]$da.Fill($dt)
+$dt | Export-Csv -Path "{out_csv}" -NoTypeInformation -Encoding UTF8
+"rows=$($dt.Rows.Count)"
+$cn.Close()'''
+        tf = pathlib.Path(tempfile.gettempdir()) / '_mdb_dump.ps1'
+        tf.write_text(ps, encoding='utf-8-sig')
+        r = subprocess.run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', str(tf)],
+                           capture_output=True, text=True, errors='replace', timeout=3600)
+        print('      ' + (r.stdout or '').strip().replace('\n', ' ')[:80])
+        if r.returncode != 0:
+            print('      [X] ' + (r.stderr or r.stdout or '').strip()[:200])
+            return 5
+        return 0
+    cmd = ['mdb-export', str(mdb), table] + (['-q', where] if where else [])
+    with open(out_csv, 'w', encoding='utf-8', newline='') as fh:
+        r = subprocess.run(cmd, stdout=fh, stderr=subprocess.PIPE, text=True, errors='replace', timeout=3600)
+    if r.returncode != 0:
+        print('      [X] ' + (r.stderr or '')[:200])
+        return 5
+    return 0
+
+
+def probe(out: pathlib.Path, cls: str | None) -> int:
+    ok, route = have_mdb_route()
+    print(f'== MDB 读取能力 ==\n   {route}: {"可用" if ok else "不可用"}')
+    if not ok:
+        print('   补救: Windows 装 Access Runtime(或 Office)后自带 Access ODBC 驱动; '
+              'Linux 装 mdbtools(apt/yum install mdbtools); 或让现场把 MDB 导出成 CSV。')
+        return 4
+    mdbs = sorted(out.rglob('*.mdb'))
+    mdbs = [m for m in mdbs if (not cls or f'-{cls}.' in m.name)]
+    if not mdbs:
+        print(f'   [i] {P.rel(out)} 下还没有 MDB —— 先跑 --extract')
+        return 0
+    print(f'   已解出 MDB {len(mdbs)} 个(限类目 {cls or "全部"})')
+    for m in mdbs[:8]:
+        try:
+            t = mdb_tables(m)
+            print(f'   {m.relative_to(out)}: 表 {t}')
+        except Exception as e:
+            print(f'   {m.relative_to(out)}: [X] {type(e).__name__}: {str(e)[:120]}')
+    return 0
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='现场收资(年度 zip → 每月每类 MDB)提取与接入')
+    ap.add_argument('--src', default=str(pathlib.Path('F:/temp/如东风场数据')),
+                    help='年度 zip 所在目录(默认按现场交来的目录名)')
+    ap.add_argument('--out', default=str(P.RAW_ROOT / P.farm() / 'scada_mdb'),
+                    help='MDB 落点(默认 data/raw/<场站>/scada_mdb)')
+    ap.add_argument('--list', action='store_true', help='只列结构(年度 zip / 月份 / 类目)')
+    ap.add_argument('--extract', action='store_true', help='解压出 MDB(幂等, 带清单)')
+    ap.add_argument('--probe', action='store_true', help='探针: 读取能力 + 已解出 MDB 的表')
+    ap.add_argument('--verify-raw', action='store_true', help='对账: 现场 CSV zip ↔ data/raw 现有文件(逐件比大小)')
+    ap.add_argument('--extract-csv', action='store_true', help='把现场 CSV zip 解到 data/raw(幂等)')
+    ap.add_argument('--only-missing', action='store_true', help='与 --extract-csv 合用: 只补缺件, 不覆盖已存在的')
+    ap.add_argument('--to-csv', action='store_true', help='MDB → 观澜 data/raw 的 CSV(按类目映射)')
+    ap.add_argument('--class', dest='cls', default=None, help='类目: cnt/sum/std/tur/…')
+    ap.add_argument('--year', default=None, help='年度前缀(如 26)')
+    ap.add_argument('--month', default=None, help='月份数字(如 1)')
+    ap.add_argument('--table', default=None, help='指定表名(默认取该 MDB 的第一张表)')
+    ap.add_argument('--turbines', default=None, help='只导这些机组(逗号分隔, 如 WTG01,WTG02)')
+    a = ap.parse_args()
+    src, out = pathlib.Path(a.src), pathlib.Path(a.out)
+    if a.list:
+        return list_structure(src)
+    if a.extract:
+        return extract(src, out, a.year, a.month)
+    if a.verify_raw:
+        return verify_raw(src)
+    if a.extract_csv:
+        return extract_csv(src, only_missing=a.only_missing)
+    if a.probe:
+        return probe(out, a.cls)
+    if a.to_csv:
+        return to_csv(out, a.cls, a.year, a.month, a.table, a.turbines)
+    ap.print_help()
+    return 2
+
+
+def to_csv(out: pathlib.Path, cls: str | None, year: str | None, month: str | None,
+           table: str | None, turbines: str | None) -> int:
+    ok, route = have_mdb_route()
+    if not ok:
+        print(f'[X] 本机没有 MDB 读取能力({route})—— 见 --probe 的补救办法')
+        return 4
+    if not cls:
+        print('[X] --to-csv 需要 --class(如 std)')
+        return 2
+    mdbs = [m for m in sorted(out.rglob(f'*-{cls}.mdb')) if (not year or f'{year}年' in str(m))
+            and (not month or f'{month}月' in str(m))]
+    if not mdbs:
+        print(f'[X] 没找到 *-{cls}.mdb(先 --extract)')
+        return 2
+    raw_dir = station_dir() / CLASS_TO_RAW.get(cls, f'scada_{cls}')
+    print(f'== MDB → CSV · 类目 {cls} · {len(mdbs)} 个月 · 落点 {P.rel(raw_dir)} ==')
+    want = {t.strip().upper() for t in turbines.split(',')} if turbines else None
+    for m in mdbs:
+        try:
+            tbls = mdb_tables(m)
+        except Exception as e:
+            print(f'   {m.name}: [X] {type(e).__name__}: {str(e)[:120]}')
+            continue
+        tbl = table or (tbls[0] if tbls else None)
+        if not tbl:
+            print(f'   {m.name}: 没有表')
+            continue
+        tmp_csv = pathlib.Path(tempfile.gettempdir()) / f'_{m.stem}_{tbl}.csv'
+        rc = mdb_to_csv(m, tbl, tmp_csv)
+        if rc:
+            continue
+        import pandas as pd
+        df = pd.read_csv(tmp_csv, dtype=str)
+        print(f'   {m.name} / {tbl}: {len(df)} 行 × {len(df.columns)} 列 → {list(df.columns)[:8]}')
+        # 现场口径: Station = 机组号(91→WTG01), 每个机组一个文件、时间列名统一成 Time
+        st = next((c for c in df.columns if c.lower() in ('station', 'stationid', 'wtg')), None)
+        ts = next((c for c in df.columns if 'time' in c.lower() and 'station' not in c.lower()), None)
+        if not st or not ts:
+            print(f'      [i] 没识别出 Station/TimeStamp 列 —— 该月份先原样留存 {P.rel(tmp_csv)}')
+            continue
+        base = df[st].astype(str).str.extract(r'(\d+)')[0].astype('Int64')
+        df['_wtg'] = 'WTG' + (base - (90 if base.min() and base.min() >= 90 else 0)).astype('Int64').astype(str).str.zfill(2)
+        for w, g in df.groupby('_wtg'):
+            if want and w.upper() not in want:
+                continue
+            g2 = g.drop(columns=['_wtg']).rename(columns={ts: 'Time'})
+            dst = raw_dir / f'{w}.csv'
+            head = not dst.is_file()
+            g2.to_csv(dst, mode='a' if not head else 'w', header=head, index=False, encoding='utf-8')
+            if head:
+                print(f'      → {P.rel(dst)}  {len(g2)} 行(Time 列 = {ts})')
+    print('   完成。接下去: python scripts/raw_data_check.py(体检)→ python scripts/rebuild_all.py')
+    return 0
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())