Bläddra i källkod

输入数据自动扫描识别 (用户令 2026-09-19): 发现新增 → 纳入重算

新增 scripts/raw_scan.py: 逐族指纹(件数/体积/最新落盘时间/清单摘要, --deep 叠内容哈希) 与快照
outputs/<场>/_raw_scan.json 比 → 报【新增/变化】→ 映射到链上步骤(该跑哪几步)。
rc: 0 无变化 · 4 有新增/变化 · 5 还没有基线; --write 记基线, --json 机器读, --selftest 与
反向呼应族表 + 链上步骤名对账(防两处漂移, 已过)。data/raw/<场>/ 下族表没有的目录如实报"未归类"。

接线:
- rebuild_all.py 新增 ①b 步(raw_scan --check --write, 容忍 rc=4/5 并写清"4=有新数据不是失败")
- rebuild_all.py --auto: 先扫一遍, 新数据落在被 --skip-scada/--skip-vib 跳过的族里就自动取消跳过
  (实测 scada_10min 新增 1 件 → 计划 22 步→24 步 且打印取消原因; 数据删掉后恢复跳过)
- service_worker.py::raw_watch + configs/serve.json 的 raw_watch(默认关): 每 N 分钟扫一次, 有新数据
  写 logs/service.log 并列出该跑的步; auto_rebuild=true 时顺手发起重算(走 _ops_run.py, 已在跑则不重复)

修一个自己踩的解码坑: --auto 里给子进程显式 PYTHONUTF8=1 并按 utf-8 解码 —— 否则子进程写 UTF-8、
父进程按 GBK 解, 步骤名乱码, startswith('③') 永不匹配(扫描发现了新数据却没取消跳过)。

文档: README 第三节 / docs/输入数据放置指导 §4 写清三档用法、族→步骤表、看门狗开关与默认关的口径。
版本 2.7.0 → 2.8.0 (非核心功能新增 ⇒ minor), 版本记录随之重生成。
zhouyang.xie 2 veckor sedan
förälder
incheckning
48703cb080

+ 11 - 0
README_先读我.MD

@@ -43,6 +43,17 @@
     中文说明请看本文件与 docs/; 机器守卫见 src/entry_refs.py(guanlan.py check 会报一行)。
 
 三、重算与产物 (门户菜单「数据重算」, 或直接开 http://127.0.0.1:28084/ops)
+  ★ 输入数据**自动扫描识别** (用户令 2026-09-19): data/raw 下逐族指纹 (件数/体积/最新时间/清单摘要),
+    与上次快照比 —— 有新增/变化就报出来并列出"该跑哪几步":
+      <PY> scripts/raw_scan.py                 # 看当前状态
+      <PY> scripts/raw_scan.py --check         # 与快照比: rc=0 无变化 · rc=4 有新数据 · rc=5 还没基线
+      <PY> scripts/raw_scan.py --write         # 记/更新基线 (重算链第 ①b 步每次都会顺手记)
+    → 重算链里它就是 **①b 步**; 想让"新数据不被 --skip-* 漏掉", 用:
+      <PY> scripts/rebuild_all.py --auto       # 先扫一遍: 新数据落在被跳过的族里就自动取消跳过
+    → 服务侧看门狗 (默认关, 现场自己开): configs/serve.json 的 "raw_watch"
+      {"enabled": true, "minutes": 30, "auto_rebuild": false}
+      开了以后每 30 分钟扫一次, 有新数据就写 logs/service.log 并列出该跑的步; auto_rebuild=true 时
+      顺手发起一次重算 (与运维控制台同一条路; 已在跑则不重复发起)。
   一个按钮一件事: 停/启服务 · 执行重算 · 清除产物。按钮按真实状态启用(不能做的灰, 后端也拒绝,
   直接打 API 返回 409); "启动服务"起来后自动打开门户; "停止组件服务"保留控制台本身; 同时刻只允许
   一个动作, 页面上有进度/退出码/实时日志尾巴。重算进行中按钮按设计禁用 —— 那不是坏了。

+ 7 - 1
configs/serve.json

@@ -14,6 +14,12 @@
   "python": ".venv/Scripts/python.exe",
   "_note": "端口只在这里改 (网关路由表 scripts/guanlan_gateway.py 里的上游端口须同步; v0.1 仍是硬编码, 见说明书 §7)",
   "_note_host": "host = 组件(detail/cms/viewer/sim)监听地址, 出厂 127.0.0.1 = 只本机; public_host = 门户网关监听地址 —— ★用户令 2026-09-19「观澜改为监听所有 IP」: 出厂已设 0.0.0.0(所有网卡, 别的机器可用 http://<本机IP>:28084/ 打开)。只想本机访问就把它改回空串或 127.0.0.1, 换个内网 IP 也行; 改完重启服务。★页面没有鉴权 ⇒ 对外监听请同时限来源(防火墙白名单/反代认证), 别直接挂公网。",
+  "raw_watch": {
+    "enabled": false,
+    "minutes": 30,
+    "auto_rebuild": false
+  },
+  "_note_watch": "输入数据看门狗 (用户令 2026-09-19): 服务每 minutes 分钟扫一次 data/raw 的逐族指纹, 发现新增/变化就写进 logs/service.log 并列出该跑哪几步; auto_rebuild=true 时顺手发起一次重算(走 _ops_run.py, 与运维控制台同一条路; 已在跑则不重复发起)。默认关 —— 一次全量重算要一两个小时, 开不开由现场定。手动口径: python scripts/raw_scan.py --check (rc=4=有新数据)",
   "raw_dir": "data/raw",
   "_note_raw": "原始 SCADA/技术资料目录 (相对本目录或绝对路径, 如 D:\\guanlan\\data\\raw); 启动器导出为 WINDSCADA_RUDONG_SRC; 原始件不随包分发"
-}
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 3
docs/版本记录.md


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

@@ -68,12 +68,25 @@ python scripts\raw_data_check.py --deep
 
 ## 4. 放完之后做什么(体检器会按新增的源类给命令)
 
+★ **不用自己记"放了什么"**(用户令 2026-09-19 增加自动扫描识别):放完直接看扫描结论 ——
+
+```
+python scripts\raw_scan.py --check      # rc=0 无变化 · rc=4 有新数据(它会列出"该跑哪几步") · rc=5 还没基线
+python scripts\rebuild_all.py --auto    # 先扫一遍再重算: 新数据落在被 --skip-* 跳过的族里时自动取消跳过
+```
+
+`--check` 的逐族口径是**指纹**(件数 / 体积 / 最新落盘时间 / 清单摘要;`--deep` 再叠内容哈希),
+snapshot 落在 `outputs\<场>\_raw_scan.json`,重算链第 **①b** 步每次都会顺手更新它。
+若想让服务自己盯着(每 N 分钟扫一次、可选自动重算):`configs\serve.json` 的 `raw_watch`
+(默认关:`{"enabled": false, "minutes": 30, "auto_rebuild": false}`)。
+
 | 新增的源类 | 该跑的重算 | 预计 |
 |---|---|---|
 | `scada_10min` / `scada_1min` | `python scripts\rebuild_all.py` | 含 10 个构建器,约 15 分钟 |
 | `故障报警` / `风机故障记录` / `油样报告` | `python scripts\rebuild_all.py --skip-scada` | 约 2 分钟 |
-| `windcms` / `m5_cms_tcm` | 同上(重算链第 ④b 步自动摄入索引/谱) | 按数据量;**不会**重生成报告(本包缺六层链,见 docs §7) |
-| 只放了文档/技术资料 | 无需重算(不影响页面数值) | — |
+| `windcms` / `m5_cms_tcm` | 同上(重算链第 ④b 步自动摄入索引/谱 + 报告/在升闭环/三层基线) | 按数据量(153 GB 导出约 25 分钟索引+谱) |
+| `scada_mdb`(现场年度归档库) | 同 `scada_10min`(CSV 缺失时取数层会回落到 MDB) | 同 ③/④/④c |
+| 只放了文档/技术资料 | 无需重算(不影响页面数值),但 `西门子4.0技术资料` 变了要重跑 ⑦ 本体层 | — |
 
 重算后核对锚点:报警 **39211** · 工单 **5876** · 油样 **404** · `temp_monthly` **19494** · 本体 **9702** 对象(审计 0 问题);
 页面看 `/detail/` 左栏「系统维护」两屏。若某页仍无数据,先看 `logs/ops/` 里最近一次动作的退出码。

+ 303 - 0
scripts/raw_scan.py

@@ -0,0 +1,303 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""输入数据自动扫描识别 —— 发现 `<安装目录>/data/raw` 下的**新增/变化**,并给出"该重跑哪几步"。
+
+## 为什么要有它(用户令 2026-09-19)
+
+用户令:「对 `<安装目录>/data/raw` 目录下的接入数据处理,增加自动扫描识别机制,以能发现新增数据,并纳入重算。」
+
+在此之前,"放了新数据要不要重算"全靠人记:`place_raw_data.py` 只管把现场包落位,`raw_data_check.py`
+只管结构与命名对不对,`rebuild_all.py` 无脑全跑(或按人给的 `--skip-*` 跳过)——
+**没有任何一处回答"这回新来了什么、因此必须重跑哪几步"**。本脚本补这一环:
+
+    逐族指纹 (件数/体积/最新时间/清单摘要) → 与上次快照比 → 报【新增/变化】 → 映射到链上步骤
+
+## 三档用法
+
+    python scripts/raw_scan.py                     # 只报当前状态(不写快照)
+    python scripts/raw_scan.py --write             # 写/更新快照 outputs/<场>/_raw_scan.json
+    python scripts/raw_scan.py --check             # 与快照比: rc=0 无变化 · rc=4 有新增/变化 · rc=5 没有快照
+    python scripts/raw_scan.py --check --write      # 比完就把新快照记下(重算链第 ①b 步就是这条)
+    python scripts/raw_scan.py --plan               # 只打印"有变化时该跑哪些步"
+    python scripts/raw_scan.py --json               # 机器可读(rebuild_all 用它做"跳过了该跑的步"提醒)
+    python scripts/raw_scan.py --deep               # 变化判定加上**内容**哈希(默认只比 名字/大小/时间)
+    python scripts/raw_scan.py --root <别的 raw>    # 换个根扫(自测/多场)
+    python scripts/raw_scan.py --selftest           # 族表覆盖自检(与族表/链上步骤对账,防两处漂移)
+
+## 口径与纪律
+
+· **指纹**默认 = 每族逐件 `相对路径|字节数|mtime(ns)` 排序后串起来取 sha1 —— 快、且"加了一件/改了一件/
+  删了一件"都会变;`--deep` 再叠一层内容 sha1(小文件才做,避免拿 153 GB 的 CMS 导出拼哈希)。
+· **未归类**:`data/raw/<场>/` 下出现族表里没有的子目录(新来的数据总会有第一次)→ 如实列进
+  `unclassified` 并给出"没有消费者/需要认领"的话,**不猜**它是哪一族。
+· 这里只**发现与建议**,不改数据、不搬文件、不自动重算(要不要跑由 `rebuild_all.py` 或运维决定;
+  自动重算的开关见 `configs/serve.json` 的 `raw_watch`)。rc=4 是"有变化",不是失败。
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import pathlib
+import sys
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+try:                                     # GBK 控制台/日志重定向不再因 '²' 这类字符抛 UnicodeEncodeError
+    sys.stdout.reconfigure(errors='replace')
+except Exception:
+    pass
+
+SNAP_NAME = '_raw_scan.json'
+
+# ── 族表:raw 子目录 → (计件 glob, 链上步骤, 消费者说明) ──────────────────────────────
+# ★步骤标签与 `scripts/rebuild_all.py` 的计划名一一对应(人看到"③ SCADA 侧"就知道去跑哪一步)。
+FAMILIES: tuple[tuple[str, tuple[str, ...], tuple[str, ...], str], ...] = (
+    ('故障报警', ('*.xls', '*.xlsx', '*.csv'), ('② 三门台账',),
+     '报警事件导出 → windscada/alarms.parquet(报警集中/停机账)。缺 TimeOn/Alarmcode 的统计报表件会被摄入器跳过'),
+    ('风机故障记录', ('*.xls', '*.xlsx', '*.csv'), ('② 三门台账',),
+     '检修工单台账 → windscada/workorders.parquet(重复检修/停机损失)'),
+    ('油样报告', ('*.pdf',), ('② 三门台账',),
+     '油液化验报告 → windscada/oil_samples_index.parquet(油液面)'),
+    ('scada_10min', ('*.csv',), ('③ SCADA 侧 10 个构建器', '④ 月度派生件', '④c 变桨面', '⑤b 事实契约 claim'),
+     '10min 导出 → 功率曲线/损失/温度/停机/偏航/液压等 + 变桨面日粒度(温度/性能/变桨面)'),
+    ('scada_1min', ('*.csv',), ('④c 变桨面',),
+     '1min 导出(内部台号 01E…38B)→ 变桨面零位三口径与越线计数'),
+    ('scada_mdb', ('*.mdb', '*.zip'), ('③ SCADA 侧 10 个构建器', '④ 月度派生件', '④c 变桨面'),
+     '现场年度归档 Access 库(按月的 10min/1min)—— CSV 缺失时由 scada_source 回落到它'),
+    ('windcms', ('*_decode.json',), ('④b 振动侧摄入 + CMS 报告 + 标量 z', '④d 振动在升/换件闭环', '④e 三层基线'),
+     'Brande TCM 导出 → 窗索引/谱库/CMS 报告/逐台页 + 在升闭环 + 三层基线(振动面)'),
+    ('m5_cms_tcm', ('*.json', '*.docx', '*.pdf', '*.md'), ('④b 振动侧摄入 + CMS 报告 + 标量 z',),
+     '现场给的正本(handoff_vibration_v2.json / component_history.json)与厂家报告——正本在位时优先于观澜自算'),
+)
+# 机理层资料不在场站目录下(A2 约定): data/raw/西门子4.0技术资料
+TECH_DIR, TECH_PATTERNS = '西门子4.0技术资料', ('*.xlsx', '*.xls', '*.pdf', '*.docx', '*.doc', '*.txt', '*.csv')
+TECH_STEPS = ('⑦ 本体: 码表/手册/文档',)
+TECH_NOTE = '厂商技术资料(故障处理手册/维护 WI/图纸/对译表)→ 本体对象库/检索索引/实机参数表'
+
+
+def _digest(entries: list[tuple[str, int, int]], deep: bool, base: pathlib.Path, files: list[pathlib.Path],
+            deep_max: int) -> str:
+    h = hashlib.sha1()
+    for rel, size, mt in entries:
+        h.update(f'{rel}|{size}|{mt}\n'.encode('utf-8', 'replace'))
+    if deep:
+        for p, (rel, size, _mt) in zip(files, entries):
+            if size > deep_max:
+                continue
+            try:
+                fh = hashlib.sha1()
+                with open(p, 'rb') as f:
+                    for blk in iter(lambda: f.read(1 << 20), b''):
+                        fh.update(blk)
+                h.update(rel.encode('utf-8', 'replace') + b'=' + fh.digest())
+            except OSError:
+                pass
+    return h.hexdigest()
+
+
+def fingerprint(d: pathlib.Path, patterns: tuple[str, ...], deep: bool = False, deep_max: int = 8 << 20) -> dict:
+    """一族目录的指纹:件数/体积/最新时间/清单摘要(--deep 再叠内容哈希)。目录不在 ⇒ files=0。"""
+    files: list[pathlib.Path] = []
+    if d.is_dir():
+        for pat in patterns:
+            files += [p for p in d.rglob(pat) if p.is_file()]
+        files = sorted(set(files))
+    entries: list[tuple[str, int, int]] = []
+    for p in files:
+        try:
+            st = p.stat()
+        except OSError:
+            continue
+        entries.append((p.relative_to(d).as_posix(), st.st_size, st.st_mtime_ns))
+    entries.sort()
+    newest = ''
+    if entries:
+        newest = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(max(e[2] for e in entries) / 1e9))
+    return dict(files=len(entries), bytes=sum(e[1] for e in entries), newest=newest,
+                digest=_digest(entries, deep, d, files, deep_max),
+                top=[e[0] for e in sorted(entries, key=lambda x: -x[2])[:3]])
+
+
+def scan(raw_root: pathlib.Path, deep: bool = False, station_dir: pathlib.Path | None = None) -> dict:
+    """扫一个 raw 根:逐族指纹 + 别处不认识的子目录(未归类)。
+
+    `station_dir` 不给时按**场配置的辨识结果**定场站目录(`raw_station_dir()`,与摄入侧同一口径);
+    显式 `--root` 自测时可传入 `<root>/<场站名>` 之外的位置。
+    """
+    from src.windscada.config import raw_station_dir
+    station = pathlib.Path(station_dir) if station_dir else raw_station_dir()
+    fam: dict[str, dict] = {}
+    for name, pats, steps, note in FAMILIES:
+        fp = fingerprint(station / name, pats, deep)
+        fp.update(steps=list(steps), note=note, where=str((station / name)))
+        fam[name] = fp
+    fp = fingerprint(raw_root / TECH_DIR, TECH_PATTERNS, deep)
+    fp.update(steps=list(TECH_STEPS), note=TECH_NOTE, where=str(raw_root / TECH_DIR))
+    fam[TECH_DIR] = fp
+    known = {n for n, *_ in FAMILIES} | {TECH_DIR}
+    unclassified = []
+    if station.is_dir():
+        for sub in sorted(station.iterdir()):
+            if sub.is_dir() and sub.name not in known:
+                unclassified.append(dict(dir=sub.name, files=sum(1 for p in sub.rglob('*') if p.is_file())))
+    for sub in sorted(raw_root.iterdir()) if raw_root.is_dir() else []:
+        if sub.is_dir() and sub.name not in known and sub != station:
+            unclassified.append(dict(dir=sub.name, files=sum(1 for p in sub.rglob('*') if p.is_file()),
+                                     note='raw 根下(非场站目录)'))
+    return dict(at=time.strftime('%Y-%m-%d %H:%M:%S'), root=str(raw_root), station=str(station),
+                deep=deep, families=fam, unclassified=unclassified)
+
+
+def diff(old: dict, new: dict) -> list[dict]:
+    """逐族比:新增/减少/内容变化(`files` 与 `digest` 分开说,便于人看懂)。"""
+    out = []
+    of = (old or {}).get('families') or {}
+    for name, cur in (new.get('families') or {}).items():
+        prev = of.get(name)
+        if not prev:
+            if cur['files']:
+                out.append(dict(family=name, kind='首次记录', files=cur['files'], steps=cur['steps'],
+                                note=cur['note'], newest=cur['newest']))
+            continue
+        d_files = cur['files'] - prev['files']
+        same_list = cur['digest'] == prev['digest']
+        if d_files == 0 and same_list:
+            continue
+        kind = ('新增 %+d 件' % d_files) if d_files else '内容变化(件数不变)'
+        out.append(dict(family=name, kind=kind, files=cur['files'], delta=d_files,
+                        bytes_delta=cur['bytes'] - prev['bytes'], newest=cur['newest'],
+                        top=cur.get('top'), steps=cur['steps'], note=cur['note']))
+    for u in new.get('unclassified') or []:
+        out.append(dict(family=u['dir'], kind='未归类(族表里没有这个目录)', files=u['files'],
+                        steps=[], note='没有已知消费者 ⇒ 先认领:是新的输入族就得给它配摄入器;否则清出 data/raw'))
+    return out
+
+
+def _steps_of(changes: list[dict]) -> list[str]:
+    seen, out = set(), []
+    for c in changes:
+        for s in c.get('steps') or []:
+            if s not in seen:
+                seen.add(s)
+                out.append(s)
+    return out
+
+
+def snap_path(raw_root: pathlib.Path) -> pathlib.Path:
+    from src import paths as P
+    return P.out_root() / SNAP_NAME
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='输入数据自动扫描识别(发现新增 → 指明该重跑哪几步)')
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--root', default=None, help='覆盖 data/raw 根(自测/多场)')
+    ap.add_argument('--write', action='store_true', help='把本次指纹写进快照')
+    ap.add_argument('--check', action='store_true', help='与快照比:rc=0 无变化 · 4 有新增/变化 · 5 无快照')
+    ap.add_argument('--plan', action='store_true', help='只打印"该跑哪些步"')
+    ap.add_argument('--json', action='store_true', help='输出机器可读 JSON')
+    ap.add_argument('--deep', action='store_true', help='变化判定叠内容哈希(小文件)')
+    ap.add_argument('--selftest', action='store_true', help='族表/步骤覆盖自检(防与族表、链上步骤漂移)')
+    a = ap.parse_args()
+
+    from src import paths as P
+    from src.windscada import config as C
+    if a.farm:
+        os.environ['WINDSCADA_FARM'] = a.farm            # 与其它脚本同一口径(场由环境变量选)
+    raw = pathlib.Path(a.root) if a.root else pathlib.Path(C.RAW_ROOT)
+    # --root 换成别的 raw 根时,场站目录按"根下唯一/同名"识别,避免拿本场辨识结果去别处找
+    st_dir = None
+    if a.root:
+        from src.windscada.config import station_scan
+        sc = station_scan(root=raw)
+        st_dir = (raw / sc['matched']) if sc.get('matched') else None
+    now = scan(raw, deep=a.deep, station_dir=st_dir)
+    sp = snap_path(raw)
+    old = None
+    if sp.is_file():
+        try:
+            old = json.loads(sp.read_text(encoding='utf-8'))
+        except Exception:
+            old = None
+    changes = diff(old, now)
+
+    if a.selftest:
+        return selftest()
+
+    if a.json:
+        print(json.dumps(dict(scan=now, changes=changes, snapshot=str(sp),
+                              steps=_steps_of(changes), has_snapshot=bool(old)), ensure_ascii=False, indent=1))
+        if a.write:
+            sp.parent.mkdir(parents=True, exist_ok=True)
+            sp.write_text(json.dumps(now, ensure_ascii=False, indent=1), encoding='utf-8')
+        if a.check:
+            return 0 if (old and not changes) else (4 if old else 5)
+        return 0
+
+    print(f'输入数据扫描 · {now["root"]}  ({now["at"]}{" · deep" if a.deep else ""})')
+    tot_f = sum(f['files'] for f in now['families'].values())
+    tot_b = sum(f['bytes'] for f in now['families'].values())
+    print(f'  共 {len(now["families"])} 族 · {tot_f} 件 · {tot_b / 1024 / 1024 / 1024:.1f} GB')
+    for name, f in now['families'].items():
+        flag = ''
+        for c in changes:
+            if c['family'] == name:
+                flag = f"  ← {c['kind']}"
+        print(f'    {name:16s} {f["files"]:6d} 件  {f["bytes"] / 1024 / 1024:9.1f} MB  最新 {f["newest"] or "—"}{flag}')
+    if now['unclassified']:
+        print('  未归类(族表里没有,先认领):')
+        for u in now['unclassified']:
+            print(f'    {u["dir"]:16s} {u["files"]:6d} 件  {u.get("note", "没有已知消费者")}')
+
+    if changes:
+        print(f'\n== 本次发现 {len(changes)} 处新增/变化 ==')
+        for c in changes:
+            print(f'  · {c["family"]}: {c["kind"]}'
+                  + (f'(体积 {c["bytes_delta"] / 1024 / 1024:+.1f} MB)' if c.get('bytes_delta') else '')
+                  + f'  最新 {c.get("newest") or "—"}')
+            if c.get('top'):
+                print(f'      最近落盘: ' + '、'.join(c['top']))
+            if c.get('steps'):
+                print(f'      该跑: ' + ' / '.join(c['steps']))
+        print('\n  建议: 跑一次重算把新数据纳入产物 —— python scripts/rebuild_all.py'
+              '(默认全跑;若你用了 --skip-*,请确认没跳过上面列出的步)')
+    else:
+        print('\n== 与上次快照一致,没有新数据 ==' if old else '\n== 还没有快照(跑 --write 记一次基线)==')
+
+    if a.write:
+        sp.parent.mkdir(parents=True, exist_ok=True)
+        sp.write_text(json.dumps(now, ensure_ascii=False, indent=1), encoding='utf-8')
+        print(f'\n快照已更新 → {P.rel(sp)}')
+    if a.check:
+        if not old:
+            print(f'[?] 没有快照可比({P.rel(sp)})—— 先跑 --write 记基线; rc=5')
+            return 5
+        return 4 if changes else 0
+    return 0
+
+
+def selftest() -> int:
+    """自检:① 族表覆盖 `products_reverse_audit.FAMILIES` 里所有 raw 输入目录 ② 步骤名都能在链上找到。"""
+    import importlib.util
+    import re
+    spec = importlib.util.spec_from_file_location('_pra', ROOT / 'scripts' / 'products_reverse_audit.py')
+    mod = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(mod)
+    inputs = {f['input'] for f in mod.FAMILIES if f.get('input')}
+    mine = {n for n, *_ in FAMILIES} | {TECH_DIR}
+    missing = sorted(inputs - mine)
+    chain = (ROOT / 'scripts' / 'rebuild_all.py').read_text(encoding='utf-8')
+    steps = {s for _n, _p, ss, _note in FAMILIES for s in ss} | set(TECH_STEPS)
+    bad_steps = sorted(s for s in steps if s.split()[0] not in chain)
+    print(f'族表覆盖: 反向呼应族表的 raw 输入 {len(inputs)} 个 · 本器覆盖 {len(mine)} 个 · 缺 {missing or "无"}')
+    print(f'步骤标签: {len(steps)} 个 · 在 rebuild_all 里找不到的 {bad_steps or "无"}')
+    ok = not missing and not bad_steps
+    print('[OK] 自检通过' if ok else '[X] 自检不过 —— 族表或步骤标签与链上漂移了')
+    return 0 if ok else 5
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 39 - 0
scripts/rebuild_all.py

@@ -35,6 +35,7 @@ r"""一条命令重算全部 (2026-09-12) —— 把散在各脚本里的重算
 from __future__ import annotations
 
 import argparse
+import json
 import os
 import pathlib
 import subprocess
@@ -50,10 +51,45 @@ def step_cmd(name, cmd, tolerate=(), note=''):
     return (name, cmd, tuple(tolerate), note)
 
 
+def _scan_plan() -> tuple[list, str]:
+    """跑一次输入数据扫描(`--json --check`)→ (该跑的步骤列表, 人读摘要)。扫不动就返回 ([], 原因)。
+
+    为什么要它 (用户令 2026-09-19「增加自动扫描识别机制, 以能发现新增数据, 并纳入重算」):
+    `--auto` 靠它决定"人给的 --skip-* 该不该让路" —— 新数据落在被跳过的族里时自动取消跳过。
+    """
+    try:
+        # ★必须显式给子进程 UTF-8 并按其解码 (2026-09-19 实逮: 子进程写 UTF-8、父进程按 GBK 解 ⇒
+        #   步骤名一串乱码, `startswith('③')` 永远不匹配 —— 扫描明明发现新数据, 却没取消 --skip-*)
+        env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
+        r = subprocess.run([PY, 'scripts/raw_scan.py', '--json', '--check'], cwd=str(ROOT), env=env,
+                           capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=1800)
+        d = json.loads(r.stdout or '{}')
+        return list(d.get('steps') or []), (f'发现 {len(d.get("changes") or [])} 处新增/变化'
+                                            + ('(' + ' / '.join(d.get('steps') or []) + ')' if d.get('steps') else ''))
+    except Exception as e:
+        return [], f'扫描没跑成({type(e).__name__}: {str(e)[:80]})—— 不影响重算, 但 --auto 无依据'
+
+
 def build_plan(a) -> list:
     plan = []
+    if a.auto:
+        # --auto: 先看"这回新来了什么", 再决定人给的 --skip-* 要不要让路 (用户令 2026-09-19)
+        steps, why = _scan_plan()
+        print(f'  [auto] 输入数据扫描: {why}')
+        if steps and a.skip_scada and any(s.startswith(('③', '④ ')) or s.startswith('④c') for s in steps):
+            print('  [auto] 有新数据落在 SCADA/变桨族里 ⇒ **取消 --skip-scada**(新数据必须进重算)')
+            a.skip_scada = False
+        if steps and a.skip_vib and any(s.startswith('④b') for s in steps):
+            print('  [auto] 有新数据落在振动族里 ⇒ **取消 --skip-vib**')
+            a.skip_vib = False
     if a.src:
         plan.append(step_cmd('① 放数据', [PY, 'scripts/place_raw_data.py', '--src', a.src, '--scope', 'full']))
+    # ①b 输入数据扫描 (用户令 2026-09-19): 逐族指纹 → 发现新增/变化 → 指明该跑哪几步, 并把本次指纹记成
+    #    新基线(下次比的基准)。rc: 0 无变化 · 4 有新增/变化(**不是失败**) · 5 首次没有快照(顺手记一份)。
+    plan.append(step_cmd('①b 输入数据扫描 (新增/变化 → 该跑哪些步)',
+                         [PY, 'scripts/raw_scan.py', '--check', '--write'], tolerate=(4, 5),
+                         note='rc=4 = 这次有新数据(不是失败)—— 按它列出的步骤重跑即纳入;'
+                              'rc=5 = 还没有快照,本次顺手记一份基线'))
     plan.append(step_cmd('② 三门台账', [PY, 'scripts/rebuild_from_raw.py']))
     if not a.skip_scada:
         plan.append(step_cmd('③ SCADA 侧 10 个构建器 (约 15 分钟)', [PY, 'scripts/rebuild_from_raw.py', '--scada']))
@@ -195,6 +231,9 @@ def build_plan(a) -> list:
 def main() -> int:
     ap = argparse.ArgumentParser()
     ap.add_argument('--src', default=None, help='现场数据包目录 (给了就先跑 place_raw_data --scope full)')
+    ap.add_argument('--auto', action='store_true',
+                    help='先扫一遍输入数据(raw_scan.py),发现新数据落在被 --skip-* 跳过的族里时自动取消跳过'
+                         '(用户令 2026-09-19「发现新增数据并纳入重算」)')
     ap.add_argument('--skip-scada', action='store_true', help='跳过 SCADA 侧 10 个构建器 (没换 10min 数据时用)')
     ap.add_argument('--skip-vib', action='store_true', help='跳过振动侧摄入 (没换 CMS 原始导出时用)')
     ap.add_argument('--no-vib-report', dest='vib_report', action='store_false',

+ 73 - 0
scripts/service_worker.py

@@ -19,6 +19,7 @@ Windows 服务 (`scripts/win_service.py`) 与 Linux systemd (`scripts/service_ma
 from __future__ import annotations
 
 import datetime as dt
+import os
 import pathlib
 import socket
 import subprocess
@@ -87,6 +88,77 @@ def stop() -> None:
     log(f'停止完成 (rc={rc})')
 
 
+def raw_watch_cfg() -> dict:
+    """输入数据看门狗配置(`configs/serve.json` 的 `raw_watch`)。**默认关** —— 自动重算会占机器约一小时,
+    该由现场决定开不开。读不到/写坏了按"关"处理(不因为配置问题去动数据)。"""
+    d = dict(enabled=False, minutes=30, auto_rebuild=False)
+    try:
+        import json
+        c = json.loads((ROOT / 'configs' / 'serve.json').read_text(encoding='utf-8-sig'))
+        w = c.get('raw_watch') or {}
+        if isinstance(w, dict):
+            d.update({k: w[k] for k in ('enabled', 'minutes', 'auto_rebuild') if k in w})
+    except Exception:
+        pass
+    return d
+
+
+_watch = {'last': 0.0}
+
+
+def raw_watch(stop_evt) -> None:
+    """输入数据看门狗(用户令 2026-09-19「增加自动扫描识别机制,以能发现新增数据,并纳入重算」)。
+
+    每 `minutes` 分钟扫一次 `data/raw`:**发现新增/变化就在服务日志里写明"该跑哪几步"**;
+    `auto_rebuild=true` 时顺手发起一次重算(走 `_ops_run.py`,与运维控制台「执行重算」同一条路)。
+    ★两道保险:① 重算已在跑(`run/ops_job.json` status=running)就不再发起;② 本函数自己吞异常,
+    绝不因为看门狗把组件守护循环带崩。
+    """
+    c = raw_watch_cfg()
+    if not c['enabled']:
+        return
+    now = time.time()
+    if now - _watch['last'] < max(1, int(c['minutes'])) * 60:
+        return
+    _watch['last'] = now
+    try:
+        env = dict(os.environ, PYTHONUTF8='1', PYTHONIOENCODING='utf-8')
+        env['WINDSCADA_ROOT'] = str(ROOT)
+        p = subprocess.run([_py(), 'scripts/raw_scan.py', '--json', '--check'], cwd=str(ROOT), env=env,
+                           capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=1800)
+        if p.returncode == 5:
+            log('输入数据扫描: 还没有基线快照 —— 记一份(下次起就能比出新增)')
+            subprocess.run([_py(), 'scripts/raw_scan.py', '--write'], cwd=str(ROOT), env=env,
+                           capture_output=True, timeout=1800)
+            return
+        if p.returncode != 4:
+            return
+        import json as _json
+        d = _json.loads(p.stdout or '{}')
+        ch = d.get('changes') or []
+        log(f'输入数据有 {len(ch)} 处新增/变化: ' + ';'.join(f'{x["family"]}({x["kind"]})' for x in ch[:6]))
+        if d.get('steps'):
+            log('  该跑: ' + ' / '.join(d['steps']))
+        job = ROOT / 'run' / 'ops_job.json'
+        running = False
+        try:
+            running = (_json.loads(job.read_text(encoding='utf-8')).get('status') == 'running')
+        except Exception:
+            pass
+        if not c['auto_rebuild']:
+            log('  (raw_watch.auto_rebuild=false:只报不跑;要自动重算把它设 true)')
+            return
+        if running:
+            log('  已有重算在跑 —— 本次不重复发起(新数据会在下一轮或那次重算里被扫到)')
+            return
+        log('  按配置发起一次重算(raw_watch.auto_rebuild=true)')
+        subprocess.Popen([_py(), 'scripts/_ops_run.py', '--tag', 'rawwatch', '--', 'scripts/rebuild_all.py',
+                          '--auto'], cwd=str(ROOT), env=env, stdout=subprocess.DEVNULL,
+                         stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
+    except Exception as e:
+        log(f'输入数据看门狗异常: {type(e).__name__}: {e}', 'WARN')
+
+
 def supervise(stop_evt, once: bool = False) -> None:
     """守护循环:直到 stop_evt 被置位(或 once=True 只跑一轮)。"""
     fails = 0
@@ -100,6 +172,7 @@ def supervise(stop_evt, once: bool = False) -> None:
                 if fails:
                     log('网关已恢复')
                 fails = 0
+            raw_watch(stop_evt)               # 输入数据看门狗(默认关; 见 raw_watch_cfg)
         except Exception as e:
             log(f'巡检异常: {type(e).__name__}: {e}', 'ERROR')
         if once:

+ 20 - 1
src/version.py

@@ -19,7 +19,7 @@ import json
 import pathlib
 
 NAME = '观澜·如东样板 v2'
-VERSION = '2.7.0'          # ★ 改版本只改这里
+VERSION = '2.8.0'          # ★ 改版本只改这里
 EDITION = 'offline-single-package'
 PACKAGE_STEM = 'app_guanlang'           # 交付包文件名前缀(用户令 2026-09-17)
 
@@ -49,6 +49,25 @@ BUMP_RULE = ('改动落在"解决方案/架构/核心功能" → 大 +1(中/
 #   level: major/minor/patch 表示这一版**相对上一版**是哪一级变化;legacy 表示该版用的是
 #   旧编号体系(0.x,未按本规则),仅作历史对账用。
 HISTORY: tuple[dict, ...] = (
+    dict(version='2.8.0', date='2026-09-20', level='minor',
+         title='输入数据自动扫描识别:<安装目录>/data/raw 逐族指纹 → 发现新增/变化 → 指明该跑哪几步;'
+               '重算链加 ①b 步 + `rebuild_all.py --auto`(新数据不被 --skip-* 漏掉)+ 服务侧可选看门狗',
+         note='非核心功能新增 ⇒ 中版本 +1(无架构改动)。要点: '
+              '① 新增 `scripts/raw_scan.py`(用户令 2026-09-19「对 <安装目录>/data/raw 目录下的接入数据处理,'
+              '增加自动扫描识别机制,以能发现新增数据,并纳入重算」):逐族指纹 = 件数/体积/最新落盘时间/'
+              '清单摘要(`--deep` 再叠内容哈希),与快照 `outputs/<场>/_raw_scan.json` 比;'
+              'rc: 0 无变化 · 4 有新增/变化 · 5 还没有基线; `--write` 记基线, `--json` 给机器读, '
+              '`--selftest` 对账"族表覆盖反查族表里所有 raw 输入 + 步骤名都能在链上找到"(防两处漂移); '
+              '② 族表把每个 raw 子目录映射到链上步骤(故障报警/工单/油样→②;scada_10min→③④④c⑤b;'
+              'scada_1min→④c;scada_mdb→③④④c;windcms→④b④d④e;m5_cms_tcm→④b;西门子4.0技术资料→⑦),'
+              '`data/raw/<场>/` 下出现族表没有的目录则如实报"未归类、没有已知消费者"(不猜); '
+              '③ 进重算链作 **①b 步**(`--check --write`,容忍 rc=4/5,并在步说明里写清"4 = 有新数据不是失败"); '
+              '④ `rebuild_all.py --auto`:先扫一遍,新数据落在被 `--skip-scada`/`--skip-vib` 跳过的族里就'
+              '**自动取消跳过**(实测: scada_10min 新增 1 件 → 计划从 22 步变 24 步并打印取消原因); '
+              '⑤ 服务侧看门狗(`scripts/service_worker.py::raw_watch`,配置 `configs/serve.json` 的 `raw_watch`,'
+              '**默认关**):每 N 分钟扫一次,有新数据写 logs/service.log 并列出该跑的步;`auto_rebuild=true` '
+              '时顺手发起一次重算(走 ops 同一条路;`run/ops_job.json` 显示在跑则不重复发起)。'
+              '交付包 app_guanlang_v2.8.0.zip'),
     dict(version='2.7.0', date='2026-09-19', level='minor',
          title='远程部署消缺:振动窗发布被杀软/索引占用不再打断整条重算链(+ --publish-only 恢复通道);'
                '门户可对外监听(public_host,组件仍只本机);GBK 控制台打印不炸',

Vissa filer visades inte eftersom för många filer har ändrats