Переглянути джерело

步骤 B: 真正把新月份的 10min 补充件吃进来 (用户令 2026-09-20)

现场补的件命名与主件不同: `WTG01-B2.csv`(856 列 / 2026-08-01~09-01 数据)。原取数层只认
`<台号>.csv`(WTG01.csv) ⇒ 这批数据从未被读过, 重算后时间窗仍止于 2026-07。

改动:
- src/windscada/scada_source.py:
  · 新增 files_of(): 本台取数件 = **主件 `<台号>.csv` 在前** + 同台补充件 `<台号>-*.csv`/`<台号>_*.csv`
  · 新增 _align_columns(): 按列名对齐新旧导出 —— 新件把"类型前缀族"去掉了
    (din_wtc_HydLevel_timeon → wtc_HydLevel_timeon), 实测老件 598 列里 597 列能这样对上(只剩 WTG)
  · load(): 逐件按"原名/去前缀名"取列 → 合并 → 按时间戳排序 → **同时间戳去重(主件优先)**
    → 件名/各件行数/重叠数写进 attrs(scada_files/scada_rows/scada_overlap) 并打印一行说明
  · 时间列改为显式识别(TimeStamp/…): 补充件首列是 StationId, 老代码按"首列"当时间会错
- scripts/raw_scan.py: CONSUME 的 scada_10min/1min 口径改成"主件+同台补充件" ⇒ 步骤 C 的缺口提示
  对这两族自动转为"已被消费"(闭环: C 报出来的问题, B 修完就不再报)
- scripts/raw_data_check.py: 同台补充件不再误报"文件名不是本场机组", 改为 info 行说明将被一并读入
- docs/输入数据放置指导_v0.1.md §4: 加了"同台补充件"一行(口径: 列名对齐/时间戳去重/主件优先)

实测(单台): WTG01 主件 77,551 行 + 补充件 5,820 行 → 合并 83,353 行(重叠 18 行去重),
时间范围 2025-01-01 → **2026-09-01**; 2026-08 5,819 行、关键通道非空率 77%
(**空白是源件自带的**: 该月 144 行/日齐全但约 23% 行通道为空, 如实保留 NaN 不补数)。
zhouyang.xie 3 тижнів тому
батько
коміт
e74631b9c9

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
docs/版本记录.md


+ 1 - 0
docs/输入数据放置指导_v0.1.md

@@ -91,6 +91,7 @@ python scripts\rebuild_all.py --auto    # 先扫一遍再重算: 新数据落在
 | 新增的源类 | 该跑的重算 | 预计 |
 |---|---|---|
 | `scada_10min` / `scada_1min` | `python scripts\rebuild_all.py` | 含 10 个构建器,约 15 分钟 |
+| **同台补充件**(如 `WTG01-B2.csv` 这种与主件命名不同的新月份件) | 同上 —— 2026-09-20 起取数层会**一并读入**:按列名对齐(新导出去掉了类型前缀 `din_wtc_X`→`wtc_X`)+ 按时间戳去重(主件优先);件名/各件行数/重叠数会打印并记在 `attrs` 里 | 同 ③ |
 | `故障报警` / `风机故障记录` / `油样报告` | `python scripts\rebuild_all.py --skip-scada` | 约 2 分钟 |
 | `windcms` / `m5_cms_tcm` | 同上(重算链第 ④b 步自动摄入索引/谱 + 报告/在升闭环/三层基线) | 按数据量(153 GB 导出约 25 分钟索引+谱) |
 | `scada_mdb`(现场年度归档库) | 同 `scada_10min`(CSV 缺失时取数层会回落到 MDB) | 同 ③/④/④c |

+ 14 - 5
scripts/raw_data_check.py

@@ -173,15 +173,24 @@ def check_tree(st: pathlib.Path, deep=False) -> tuple[list, list, dict]:
             # ★ 两类命名都对: scada_10min 用显示机组号 (WTG01.csv); scada_1min 的现场导出用的是
             #   **内部台号** (01E.csv…37B.csv, 见 place_raw_data.py 的映射说明) —— 后者按"台数一致"判完整,
             #   不再按名字判 (2026-09-17 实测: 按 WTG 名字判会把 38/38 齐全的 1min 目录误报成"缺 38 台")。
+            # ★2026-09-20 用户令「把新月份的 10min 件吃进来」: 同台**补充件**(`WTG01-B2.csv`)现在由
+            #   取数层一并读入(按列名对齐 + 时间戳去重)⇒ 台号判定要按"主件名"归一, 否则 38 个补充件
+            #   会被报成"文件名不是本场机组"(假告警), 而它们恰恰是要吃的数据。
             if d.name == 'scada_10min':
-                miss = sorted(want - got) if want else []
-                extra = sorted(got - want) if want else []
-                stat['units'].append((d.name, len(got)))
+                stem_of = lambda s: s.split('-')[0].split('_')[0]      # noqa: E731  WTG01-B2 → WTG01
+                got_main = {stem_of(s) for s in got}
+                extra_main = sorted({s for s in got if stem_of(s) not in want} if want else set())
+                miss = sorted(want - got_main) if want else []
+                sup = sorted(s for s in got if s != stem_of(s))
+                stat['units'].append((d.name, len(got_main)))
                 if miss:
                     res.append(('!', P.rel(d), f'缺 {len(miss)} 台机组的数据: {miss[:6]}'
                                                f'{" …" if len(miss) > 6 else ""} (页面按台取数, 缺台就是空白)', RC_GAP))
-                if extra:
-                    res.append(('!', P.rel(d), f'{len(extra)} 个文件名不是本场机组: {extra[:6]}', RC_STRUCT))
+                if extra_main:
+                    res.append(('!', P.rel(d), f'{len(extra_main)} 个文件名不是本场机组: {extra_main[:6]}', RC_STRUCT))
+                if sup:
+                    res.append(('i', P.rel(d), f'{len(sup)} 个同台补充件将被一并读入'
+                                               f'(<台号>-*.csv, 如 {sup[:3]}): 按列名对齐 + 时间戳去重, 主件优先', 0))
             else:
                 stat['units'].append((d.name, len(got)))
                 if n_want and len(got) != n_want:

+ 5 - 4
scripts/raw_scan.py

@@ -92,10 +92,11 @@ CONSUME: dict[str, dict] = {
     '风机故障记录': dict(consume=('*.xls', '*.xlsx'), data_exts=('.xls', '.xlsx', '.csv'),
                          how='台账读取 *.xls*(rar/jpg 是附件,不摄入)'),
     '油样报告': dict(consume=('*.pdf',), data_exts=('.pdf',), how='油样摄取读 *.pdf'),
-    'scada_10min': dict(consume=('WTG??.csv',), data_exts=('.csv', '.mdb', '.zip'),
-                        how='取数层**按台号精确取 `<台号>.csv`**(WTG01.csv);别的命名不会被读'),
-    'scada_1min': dict(consume=('???.csv',), data_exts=('.csv', '.mdb', '.zip'),
-                       how='取数层按内部台号取 `<台号>.csv`(01E.csv)'),
+    'scada_10min': dict(consume=('WTG??.csv', 'WTG??-*.csv', 'WTG??_*.csv'), data_exts=('.csv', '.mdb', '.zip'),
+                        how='取数层读 `<台号>.csv` **+ 同台补充件** `<台号>-*.csv`(2026-09-20 起: 按列名对齐、'
+                            '按时间戳去重、主件优先)'),
+    'scada_1min': dict(consume=('???.csv', '???-*.csv', '???_*.csv'), data_exts=('.csv', '.mdb', '.zip'),
+                       how='取数层按内部台号取 `<台号>.csv`(01E.csv)+ 同台补充件'),
     'scada_mdb': dict(consume=('*-10min.mdb', '*-1min.mdb', '*.zip'), data_exts=('.mdb', '.zip'),
                       how='取数层读取按月的库(2026-08-10min.mdb);CSV 缺失时才回落'),
     'windcms': dict(consume=('*_decode.json', '*.pdf', '*.docx', '*.md', '*.json'),

+ 16 - 1
src/version.py

@@ -78,7 +78,22 @@ HISTORY: tuple[dict, ...] = (
               '整件摄入, 重复计数) ⇒ 既不是源集的函数也会虚增; 现在 39,211 键稳定、重复 3.6 万行如实去掉; '
               '⑨ 振动窗: 同名窗已存在时原来一律改名 `_reimport`(被 EXCLUDE_DEFAULT 排除 ⇒ **补进来的 CMS 数据'
               '根本进不了生产集**), 现在默认**替换同名窗**(旧窗留档 `_superseded_<时分>`, 已加入排除表; '
-              '要旧行为用 `--reimport-as-new`)。交付包 app_guanlang_v2.8.0.zip'),
+              '要旧行为用 `--reimport-as-new`)。'
+              '★另按用户令「按你的建议执行 先 C 再 B」补完"同台多件 10min 进不来"这条链: '
+              '⑩ **步骤 C(让"放了没人读"当场可见)**:`raw_scan.py` 新增 `CONSUME` 表(逐族写清**消费者真实'
+              '取数口径**),判据从"扩展名白名单没命中"改成"**全部文件里没被消费者读的**"—— 实测 `data/raw/'
+              '如东/scada_10min/WTG01-B2.csv`(856 列 / 2026-08 数据)扩展名 `*.csv` 命中白名单,旧判据'
+              '**永远发现不了**它;现在报成**缺口级**(计入变化 rc=4 + ★[缺口] 块写明消费者口径与件例),'
+              '附件类(.rar/.jpg/厂商软件)仍只列信息;建议行也分流说明"缺口类重算也纳入不了"。'
+              '`raw_data_check.py` 同步: 同台补充件不再误报"文件名不是本场机组",改为 info 行。'
+              '⑪ **步骤 B(真正把新月份的 10min 件吃进来)**:`scada_source.load` 取数从"只认 `<台号>.csv`"'
+              '扩到 **`<台号>.csv` + 同台补充件 `<台号>-*.csv`/`<台号>_*.csv`**:按列名对齐(新导出把类型前缀'
+              '去掉了: `din_wtc_HydLevel_timeon` → `wtc_HydLevel_timeon`,实测老件 598 列里 597 列能这样对上)、'
+              '按时间戳排序去重(**主件优先**,重叠条数写进 `attrs.scada_overlap` 并打印)、来路件名与各件行数'
+              '写进 `attrs.scada_files/scada_rows`。实测 WTG01:主件 77,551 行 + 补充件 5,820 行 → 合并 83,353 行'
+              '(重叠 18 行去重),时间范围由 2026-07-07 延到 **2026-09-01**,2026-08 的 5,819 行关键通道'
+              '非空率 77%(**空白是源件自带的**:该月 144 行/日齐全但约 23% 行的通道为空,如实保留 NaN)。'
+              '交付包 app_guanlang_v2.8.0.zip'),
     dict(version='2.7.0', date='2026-09-19', level='minor',
          title='远程部署消缺:振动窗发布被杀软/索引占用不再打断整条重算链(+ --publish-only 恢复通道);'
                '门户可对外监听(public_host,组件仍只本机);GBK 控制台打印不炸',

+ 106 - 11
src/windscada/scada_source.py

@@ -47,6 +47,14 @@ import tempfile
 CLS = {'10min': ('src_10min', 'scada_10min'), '1min': ('src_1min', 'scada_1min')}
 TURBINE_COL = 'turbine'          # 新库的机组列(csv_to_mdb.py 2026-09-19 起写)
 LEGACY_TURBINE_COL = 'WTG'       # 老库兜底:10min 源件自带 WTG 列
+# ★同台多件 (用户令 2026-09-20「真正把新月份的 10min 件吃进来」): 现场后来补的导出件命名与主件不同
+#   (实测 `WTG01-B2.csv` 856 列 / 2026-08 数据),而原实现只认 `<台号>.csv` ⇒ 这批数据**从未被读过**,
+#   页面时间窗自然停在旧月份。现在本台取数 = 主件 + 同台补充件,按时间戳合并去重(主件优先)。
+SUPPLEMENT_GLOBS = ('{t}-*.csv', '{t}_*.csv')
+# 新旧导出的列名差: 新件把"类型前缀族"去掉了(`din_wtc_HydLevel_timeon` → `wtc_HydLevel_timeon`)。
+# 实测 WTG01 老件 598 列里 597 列能这样对上(唯一对不上的是 `WTG`,新件用 `StationId`)。
+PREFIXES = ('din_', 'dot_', 'prs_', 'tmp_', 'grd_', 'tur_', 'cnt_', 'flg_', 'int_', 'evt_')
+TIME_CANDIDATES = ('TimeStamp', 'timestamp', 'Time', 'time', 'time_stamp', 'occur_time')
 
 _PS = r'''
 $ErrorActionPreference = "Continue"
@@ -199,30 +207,117 @@ def source_of(cls: str, turbine: str, cfg=None) -> str | None:
     return 'mdb' if turbine in mdb_turbines(cls, cfg, limit=1) else None
 
 
+def files_of(cls: str, turbine: str, cfg=None) -> list:
+    """本台这一次要读的 CSV 件: **主件 `<台号>.csv` 在前**, 后面是同台补充件(`<台号>-*.csv` 等)。
+
+    用户令 2026-09-20: 现场补的件命名与主件不同(实测 `WTG01-B2.csv`),只认 `<台号>.csv` 会让
+    "新来的月份"静默漏读。这里如实列出这次会读的件(顺序=优先级),供取数/台账/页面说明来路。
+    """
+    csv_dir, _ = dirs_of(cls, cfg)
+    out = []
+    pri = csv_dir / f'{turbine}.csv'
+    if pri.is_file():
+        out.append(pri)
+    for pat in SUPPLEMENT_GLOBS:
+        out += sorted(csv_dir.glob(pat.format(t=turbine)))
+    seen, res = set(), []
+    for p in out:
+        if p.name not in seen:
+            seen.add(p.name)
+            res.append(p)
+    return res
+
+
+def _align_columns(df, want):
+    """按列名对齐新旧导出: 想要的列不在、但"去掉类型前缀"后在同表里 ⇒ 认回来。
+
+    返回 (df, 认回的列数)。实测: 老 598 列 → 新 856 列, 597/598 靠这条规则对上。
+    """
+    if not want or df is None or not len(df.columns):
+        return df, 0
+    have = set(df.columns)
+    back = {}
+    for c in want:
+        if c in have:
+            continue
+        for pre in PREFIXES:
+            if c.startswith(pre) and c[len(pre):] in have:
+                back[c[len(pre):]] = c
+                break
+    return (df.rename(columns=back) if back else df), len(back)
+
+
+def _time_col(frames, first_cols):
+    """时间列: 先按已知名找(合并后的表列名可能来自任一形态),再退回主件的首列。"""
+    cols = set(first_cols)
+    for f in frames:
+        cols |= set(f.columns)
+    for c in TIME_CANDIDATES:
+        if c in cols:
+            return c
+    return first_cols[0] if first_cols else None
+
+
 def load(cls: str, turbine: str, columns=None, cfg=None, month: str | None = None):
     """取一台的数据(**CSV 优先**;该台没有 CSV 时回落 MDB)。
 
     columns = 白名单(两形态都按"实际有的列"取交集,缺列不报错 —— 与 `data.load_10min` 的既有口径一致)。
     month   = 'YYYY-MM',只取该月(MDB 形态天然是按月的;CSV 形态按首列前缀过滤)。
+    ★同台多件 (用户令 2026-09-20): `<台号>.csv` + `<台号>-*.csv` 一起读, 按列名对齐 + 按时间戳去重
+      (**主件优先**,重叠条数如实写在 `attrs['scada_overlap']` 里)。
     """
     import pandas as pd
-    csv_dir, _ = dirs_of(cls, cfg)
-    fp = csv_dir / f'{turbine}.csv'
-    if fp.is_file():
-        if columns:
+    fps = files_of(cls, turbine, cfg)
+    if fps:
+        frames, per = [], {}
+        for i, fp in enumerate(fps):
             have = set(pd.read_csv(fp, nrows=0).columns)
-            cols = [c for c in columns if c in have]
-        else:
-            cols = None
-        d = pd.read_csv(fp, usecols=cols, engine='pyarrow') if cols else pd.read_csv(fp, engine='pyarrow')
-        if month:
-            ts = d.columns[0]
+            take = None
+            if columns:
+                take = []
+                for c in columns:
+                    if c in have:
+                        take.append(c)
+                        continue
+                    for pre in PREFIXES:                     # 新导出把前缀去了 ⇒ 按去前缀名取列
+                        if c.startswith(pre) and c[len(pre):] in have:
+                            take.append(c[len(pre):])
+                            break
+            d = pd.read_csv(fp, usecols=take, engine='pyarrow') if take else \
+                (pd.read_csv(fp, usecols=lambda c: c in have, engine='pyarrow') if columns else
+                 pd.read_csv(fp, engine='pyarrow'))
+            d, n_back = _align_columns(d, columns or [])
+            first_cols = list(d.columns)
+            d['_scada_rank'] = i                          # i=0 是主件 ⇒ 去重时优先
+            frames.append(d)
+            per[fp.name] = len(d)
+        ts = _time_col(frames, first_cols)
+        d = pd.concat(frames, ignore_index=True, sort=False)
+        if month and ts:
             d = d[d[ts].astype(str).str.startswith(month)]
+        overlap = 0
+        if len(frames) > 1 and ts:
+            before = len(d)
+            d = d.sort_values(['_scada_rank'], kind='stable').drop_duplicates(subset=[ts], keep='first')
+            overlap = before - len(d)
+        d = d.drop(columns=['_scada_rank'])
+        if ts in d.columns:
+            d = d.sort_values(ts, kind='stable').reset_index(drop=True)
+        else:
+            d = d.reset_index(drop=True)
+        if len(fps) > 1:
+            detail = '、'.join(f'{k} {v} 行' for k, v in per.items())
+            print(f'  [i] {turbine}: 同台 {len(fps)} 件合并读入({detail})'
+                  + (f';同时间戳重叠 {overlap} 行按"主件优先"去重' if overlap else ';无重叠'),
+                  flush=True)
         d.attrs['scada_source'] = 'csv'
+        d.attrs['scada_files'] = [f.name for f in fps]
+        d.attrs['scada_rows'] = per
+        d.attrs['scada_overlap'] = overlap
         return d.reset_index(drop=True)
     if source_of(cls, turbine, cfg) != 'mdb':
         raise RuntimeError(
-            f'{cls} 取数失败: {turbine} 既没有 CSV ({fp}),也没有含该台的 MDB '
+            f'{cls} 取数失败: {turbine} 既没有 CSV ({dirs_of(cls, cfg)[0] / f"{turbine}.csv"}),也没有含该台的 MDB '
             f'({dirs_of(cls, cfg)[1]})。放原始件到 data/raw/<场站>/ 后重跑 scripts/rebuild_all.py。')
     d = read_mdb(cls, turbine, columns, cfg, month)
     d.attrs['scada_source'] = 'mdb'

Деякі файли не було показано, через те що забагато файлів було змінено