Explorar o código

优先攻 oem_scan 侧: 分母(扫描配置 + 理论频率)已落地并逐值验过 11/11; 新增 oem_scan 脚本(拒绝半成品)

不等口径就把**分母那一半**做完, 并且逐值对拍通过:

① 扫描配置固化成机型常量: reference/rudong/oem_scan_plan.json(新增) —— 11 个部件各用哪条谱/什么分辨率
   (generator_de/nde_6338 → Env_6000_4000_850_Tr dx=1.0625; hs_* → FFT_6000_Tr dx=0.9375;
    ims_* → FFT_2000_fast_Tr dx=0.625; main_front/rear → FFT_62_Tr dx=0.03875)。
   性质: **机型常量**(哪条谱扫哪个部件), 与 data/raw 无关; 权威来源就是那份随包件, 故原样固化并注明来历。

② 理论频率(分母)映射规则反推 + 实测: option 串 → oem_bearing_freqs.json 条目 = 位置 token
   (Pos.150R→150r; 139.1/1060 f-576853.PRL→1391/1060; 6338 M/C3→6338m/c3) + 同位置多候选时按型号 token
   定夺(NSK 284 vs FAG 273; NSK 137 vs FAG 151 —— 两处都验到)。
   `--verify-plan`: 11 个部件的 BPFI_hz/BPFO_hz/BSF_hz + dx_hz **逐值一致(11/11, rc=0)**。

③ 新增 scripts/rudong_tcm_oem_scan.py: --verify-plan / --plan / --status / 默认运行。
   **默认运行 rc=3 且不写盘**: 只写分母的那些件"看着像扫描结果、实则没有分子", 半成品比不产出更危险
   (允许响亮降级, 不许造数)。口径到位后把脚本里 PEAK_PICKING_READY 翻 True 即接上, 分子侧位置已标好。

④ 顺带修掉两处"真原因被顶掉":
   · pipeline._run: text=True 下 stdout/stderr 可能为 None, 直接相加抛
     "unsupported operand type(s) for +: 'NoneType' and 'str'" —— 把真正的失败原因(rc=3 与缺什么口径)
     顶掉了; 改为 (p.stdout or '') + (p.stderr or '')。
   · chain_gap_check: **脚本在位 ≠ 这一步能出产物**, 现在会调各步 --status, 把"部分实现"如实标成 [部分]
     并列出缺什么; 结论改为"2 个未随包; 1 个为部分实现(分母就位/分子待口径) ⇒ 六层链跑不动"。

docs/振动六层链_接口规格与缺口_v0.1.md 增 §9(配置表/映射规则/新脚本/两处修复)。
自检: --verify-plan rc=0; chain_gap_check rc=5(如实); 入口引用闭合与编码守则无问题。
zhouyang.xie hai 3 semanas
pai
achega
af7a6ee4c4

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

@@ -246,4 +246,49 @@ MAD=0.80209 → z=−0.3116 · IQR/1.349=1.46899 → z=−0.1701 ·
 这条已作为**第 7 项**加进取料单(`docs/向振动线取料单_v0.1.md` 第五节)。
 
 **也因此确认**:`oem_scan` 侧那 23 件(谱在盘上,w0316)**仍可在本地推进**,只要拿到峰值拾取口径;
-而 1P 那 662 行需要"口径 + 1 月源件"两样。两条路的先后可以按到料快慢决定。
+而 1P 那 662 行需要"口径 + 1 月源件"两样。两条路的先后可以按到料快慢决定。
+
+---
+
+## 9. `oem_scan` 侧的**分母已落地并逐值验过**(2026-09-17 收口)
+
+在"优先攻 oem_scan 侧"这一轮里,没有等口径就把**分母那一半**做完了,并且是**逐值对拍通过**的:
+
+### 9.1 扫描配置(机型常量)
+
+`reference/rudong/oem_scan_plan.json`(新增)—— 11 个部件各用哪条谱、什么分辨率:
+
+| 部件 | 测点 | shard | dx_hz |
+|---|---|---|---|
+| `generator_de_6338` / `generator_nde_6338` | `Generator_DE` / `Generator_NDE` | `Env_6000_4000_850_Tr` | 1.0625 |
+| `hs_generator_left` / `hs_generator_right_fag` / `hs_generator_right_nsk` / `hs_rotor` | `Gear_HS_generator_side` / `Gear_HS_rotor_side` | `FFT_6000_Tr` | 0.9375 |
+| `ims_generator_fag` / `ims_generator_nsk` / `ims_rotor` | `Gear_IMS` | `FFT_2000_fast_Tr` | 0.625 |
+| `main_front` / `main_rear` | `Main_bearing_front` / `Main_bearing_rear` | `FFT_62_Tr` | 0.03875 |
+
+(性质:**机型常量**,不是原始数据的函数;权威来源是那份随包件,故原样固化并注明来历。)
+
+### 9.2 理论频率(分母)的映射规则 —— 实测 **11/11 逐值一致**
+
+```
+option 串 → oem_bearing_freqs.json 条目:
+  ① 取位置 token:Pos.150R→150r | 139.1/1060 f-576853.PRL→1391/1060 | 6338 M/C3→6338m/c3
+  ② 同位置多候选时按型号 token 定夺(NSK 284 vs FAG 273;NSK 137 vs FAG 151 —— 两处都验到了)
+```
+
+`python scripts/rudong_tcm_oem_scan.py --verify-plan` ⇒ 11 个部件的 `BPFI_hz`/`BPFO_hz`/`BSF_hz`
+与 `dx_hz` **逐值一致(11/11,rc=0)**。
+
+### 9.3 新增 `scripts/rudong_tcm_oem_scan.py`:**分母就位、分子待口径,且拒绝半成品**
+
+- `--verify-plan` 对拍分母与配置;`--plan` 打印扫描计划;`--status` 输出机器可读状态;
+- **默认运行 rc=3 且不写盘** —— 理由写在脚本头:只写分母的那些件"看着像扫描结果、实则没有分子",
+  产出半成品比不产出更危险(本项目一贯口径:允许响亮降级,不许造数);
+- 口径到位后把脚本里 `PEAK_PICKING_READY` 翻成 `True` 即可接上(分子侧实现位置已标好)。
+
+### 9.4 顺带修掉的两处"真原因被顶掉"
+
+1. `src/windcms/pipeline.py::_run`:`text=True` 下 `stdout/stderr` 可能是 `None`,直接相加会抛
+   `unsupported operand type(s) for +: 'NoneType' and 'str'` —— 于是**真正的失败原因被这句无意义异常顶掉**
+   (实测:oem_scan 明确报了 rc=3 与缺什么口径,经壳调用后看到的是那句类型错误)。已改为 `(p.stdout or '') + (p.stderr or '')`。
+2. `scripts/chain_gap_check.py`:**脚本在位 ≠ 这一步能出产物**。现在会调各步的 `--status`,
+   把"部分实现"如实标成 `[部分]`(并列出缺什么),不再给"看起来齐了"的假结论。

+ 23 - 0
reference/rudong/oem_scan_plan.json

@@ -0,0 +1,23 @@
+{
+ "_来源": "逆向工程产物 (2026-09-17): 从随包件 outputs/rudong/m5_cms_tcm/oem_frequency_scan.parquet 提取 —— 它每行带 (component, sensor, shard, option, dx_hz), 是**机型级扫描配置**(该轴承用哪条谱、什么分辨率), 与 data/raw 无关。",
+ "_性质": "机型常量(哪条谱扫哪个部件), 不是原始数据的函数; 唯一权威来源就是那份随包件, 故原样固化在此, 便于实现端读取。",
+ "_与理论表的关系": "option 串 → reference/rudong/oem_bearing_freqs.json 的条目: 先取位置 token(Pos.150R→150r; 139.1/1060 f-576853.PRL→1391/1060; 6338 M/C3→6338m/c3), 同位置多候选(NSK/FAG)时用 option 里的型号 token 定夺。实测 11/11 组件的 BPFI/BPFO/BSF 与样件 *_hz 列逐值一致。",
+ "components": [
+  {"component": "generator_de_6338",       "sensor": "Generator_DE",          "shard": "Env_6000_4000_850_Tr", "dx_hz": 1.0625, "option": "6338 M/C3"},
+  {"component": "generator_nde_6338",      "sensor": "Generator_NDE",         "shard": "Env_6000_4000_850_Tr", "dx_hz": 1.0625, "option": "6338 M/C3"},
+  {"component": "hs_generator_left",       "sensor": "Gear_HS_generator_side", "shard": "FFT_6000_Tr",          "dx_hz": 0.9375, "option": "NSK/FAG Pos.150L"},
+  {"component": "hs_generator_right_fag",  "sensor": "Gear_HS_generator_side", "shard": "FFT_6000_Tr",          "dx_hz": 0.9375, "option": "FAG F-581491.01.TR2SK Pos.150R"},
+  {"component": "hs_generator_right_nsk",  "sensor": "Gear_HS_generator_side", "shard": "FFT_6000_Tr",          "dx_hz": 0.9375, "option": "NSK Pos.150R"},
+  {"component": "hs_rotor",                "sensor": "Gear_HS_rotor_side",     "shard": "FFT_6000_Tr",          "dx_hz": 0.9375, "option": "NSK/FAG Pos.151"},
+  {"component": "ims_generator_fag",       "sensor": "Gear_IMS",               "shard": "FFT_2000_fast_Tr",     "dx_hz": 0.625,  "option": "FAG F-581492.TR2S Pos.450"},
+  {"component": "ims_generator_nsk",       "sensor": "Gear_IMS",               "shard": "FFT_2000_fast_Tr",     "dx_hz": 0.625,  "option": "NSK Pos.450"},
+  {"component": "ims_rotor",               "sensor": "Gear_IMS",               "shard": "FFT_2000_fast_Tr",     "dx_hz": 0.625,  "option": "NSK/FAG Pos.451"},
+  {"component": "main_front",              "sensor": "Main_bearing_front",     "shard": "FFT_62_Tr",            "dx_hz": 0.03875, "option": "139.1/1060 f-576853.PRL"},
+  {"component": "main_rear",               "sensor": "Main_bearing_rear",      "shard": "FFT_62_Tr",            "dx_hz": 0.03875, "option": "240.1/800 F-605082.PRL"}
+ ],
+ "windows_seen_in_sample": ["w0127", "w0707", "w0711", "w0724", "w0805", "w0811"],
+ "ratio_columns": {
+  "BPFI_fleet_ratio": "观测 BPFI 频率 / BPFI_hz(舰队基线)—— 分子待峰值拾取口径",
+  "BPFI_local_ratio": "观测 BPFI 频率 / BPFI_hz(本机局部基线)—— 分子待峰值拾取口径"
+ }
+}

+ 27 - 5
scripts/chain_gap_check.py

@@ -49,6 +49,7 @@ def main() -> int:
     m5 = P.m5(farm)
 
     missing_scripts = [s for s in STEPS if not (ROOT / s[1]).is_file()]
+    partial = []          # 脚本在位、但自报 complete=false ⇒ 只是"部分实现"(分母就位/分子待口径)
     have_fusion_impl = (ROOT / 'scripts/rudong_fusion_run.py').is_file()
     missing_samples = [s for s in SAMPLES if not (m5 / s[0]).is_file()]
 
@@ -58,9 +59,25 @@ def main() -> int:
     print()
     print('A. 四步脚本:')
     for name, rel, what in STEPS:
-        ok = (ROOT / rel).is_file()
-        tag = 'OK ' if ok else '缺 '
-        extra = ''
+        p = ROOT / rel
+        ok = p.is_file()
+        tag, extra = ('OK ', '') if ok else ('缺 ', '')
+        if ok:
+            # ★ 脚本在位 ≠ 这一步能出产物: 逆向实现可能是"分母就位、分子待口径"(自报 --status)。
+            #   这里调一次 --status 把真实状态问出来, 免得自查器给出"看起来齐了"的假结论。
+            import json as _json
+            import subprocess as _sp
+            try:
+                r = _sp.run([sys.executable, str(p), '--status'], capture_output=True, text=True,
+                            errors='replace', timeout=120)
+                st = _json.loads((r.stdout or '').strip().splitlines()[-1]) if (r.stdout or '').strip() else {}
+                if st and st.get('complete') is False:
+                    tag = '部分'
+                    extra = '(' + st.get('denominator', '') + ';缺: ' + ';'.join(st.get('missing', []) or []) + ')'
+            except Exception:
+                pass
+        if ok and tag == '部分':
+            partial.append(name)
         if not ok and name == 'fusion' and have_fusion_impl:
             extra = '(本包已逆向实现其中一件产物: fleet_scalar_z;源码仍缺)'
         print(f'   [{tag}] {name:12s} {rel:44s} {what}{extra}')
@@ -75,8 +92,13 @@ def main() -> int:
         print()
         print('C. 已自研部分(fleet_scalar_z): 跑 python scripts/rudong_fusion_run.py --verify 看逐值对拍')
     print()
-    rc = 5 if missing_scripts else 0
-    print(f'   结论: {"四步脚本齐全 ⇒ 六层链可跑, 接着做逐值对拍" if not missing_scripts else str(len(missing_scripts)) + " 个步骤脚本未随包 ⇒ 六层链跑不动(取料单见上)"}')
+    rc = 5 if (missing_scripts or partial) else 0
+    tail = ('四步脚本齐全且都已落地 ⇒ 六层链可跑' if not (missing_scripts or partial)
+            else (f'{len(missing_scripts)} 个步骤脚本未随包' if missing_scripts else '')
+            + (';' if (missing_scripts and partial) else '')
+            + (f'{len(partial)} 个为"部分实现"(分母就位 / 分子待口径)' if partial else '')
+            + ' ⇒ 六层链跑不动(取料单见上)')
+    print(f'   结论: {tail}')
     if a.check:
         print(f'[{"OK" if rc == 0 else "X"}] chain_gap_check rc={rc}')
     return rc

+ 193 - 0
scripts/rudong_tcm_oem_scan.py

@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""六层链 `oem_scan` 步 —— **逆向工程实现(分母已就位/分子待口径)**,2026-09-17。
+
+## 现状:一半已经落地并逐值验过,另一半明确挡住
+
+壳 `src/windcms/pipeline.py::analyze` 会调本脚本(无参数,读 `M5_WINDOWS` / `SCAN_BANDS` 环境变量),
+产出特征频率扫描件(`bearing_freq_scan` / `gear_freq_scan` / `cage_slip_scan` / `oem_frequency_scan`)。
+
+**已落地(分母 + 配置,逐值对拍通过)**:
+
+1. **扫描配置**:每个部件用哪条谱、什么分辨率 —— `reference/rudong/oem_scan_plan.json`
+   (11 个部件:`shard` 取 `Env_6000_4000_850_Tr` / `FFT_6000_Tr` / `FFT_2000_fast_Tr` / `FFT_62_Tr`,
+   `dx_hz` 1.0625 / 0.9375 / 0.625 / 0.03875)。
+2. **理论频率(分母)**:`option` 串 → `reference/rudong/oem_bearing_freqs.json` 条目,规则
+   = 位置 token(`Pos.150R`→`150r`、`139.1/1060 f-576853.PRL`→`1391/1060`、`6338 M/C3`→`6338m/c3`)
+   + 同位置多候选时按型号 token 定夺(NSK 284 vs FAG 273;NSK 137 vs FAG 151)。
+   **实测:11/11 个部件的 `BPFI_hz`/`BPFO_hz`/`BSF_hz` 与随包样件逐值一致**(`--verify-plan`)。
+
+**未落地(分子)**:`*_fleet_ratio` / `*_local_ratio` 要的是**观测频率 ÷ 理论频率**,而"在谱里怎么取观测峰"
+(带宽 / 取什么量最大 / 是否插值 / 是否按 rpm 阶次跟踪 / 多记录怎么合并)是缺的口径
+(见 `docs/向振动线取料单_v0.1.md` 第 1 项、`docs/振动六层链_接口规格与缺口_v0.1.md` §7)。
+
+**因此本脚本默认行为是"响亮失败"**(rc=3),绝不写半成品:
+在口径到位前,`windcms.py analyze --steps oem_scan` 会明确告诉你缺什么、去哪儿要,而不是产出一堆
+只有一半是真的扫描件让人误以为重算成功了。
+
+## 用法
+
+    python scripts/rudong_tcm_oem_scan.py --verify-plan   # 分母+配置 与随包样件逐值对拍(不写盘)
+    python scripts/rudong_tcm_oem_scan.py --plan          # 打印扫描计划(部件/测点/谱/分辨率/理论频率)
+    python scripts/rudong_tcm_oem_scan.py --status        # 机器可读状态(供 chain_gap_check 用)
+    python scripts/rudong_tcm_oem_scan.py                 # 真跑(口径未到位 ⇒ rc=3, 不写盘)
+
+退出码: 0 成功/对拍通过 · 2 缺参考件 · 3 **峰值拾取口径未到位**(不写盘) · 5 对拍不一致
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import re
+import sys
+
+import pandas as pd
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from src import paths as P                                              # noqa: E402
+
+PLAN = ROOT / 'reference' / 'rudong' / 'oem_scan_plan.json'
+THEORY = ROOT / 'reference' / 'rudong' / 'oem_bearing_freqs.json'
+# 口径闸: 峰值拾取口径到位后把它翻成 True, 本脚本才会真的写产物
+PEAK_PICKING_READY = False
+MISSING = ('峰值拾取口径: 理论频率附近**怎么取观测峰**(带宽 / 取什么量最大 / 是否插值 / '
+           '是否按 rpm 阶次跟踪 / 多记录怎么合并)',)      # ★ 末尾逗号: 这是"缺失项清单", 不是一整句话
+
+
+def _norm(s: str) -> str:
+    return re.sub(r'[^0-9a-z/]', '', str(s).lower())
+
+
+def pos_token(option: str) -> str:
+    """option 串 → 位置 token(见模块头第 2 条的实测规则)。"""
+    s = str(option)
+    m = re.search(r'pos\.?\s*([0-9]+[LR]?|[0-9.]+/[0-9]+)', s, re.I)
+    if m:
+        return _norm(m.group(1))
+    m = re.search(r'([0-9.]+/[0-9]+)', s)
+    if m:
+        return _norm(m.group(1))
+    m = re.search(r'([0-9]{3,4}\s*[A-Z]?\s*/\s*[A-Z0-9]+)', s)
+    if m:
+        return _norm(m.group(1))
+    return _norm(s)
+
+
+def theory_of(option: str, bearings: dict):
+    tok = pos_token(option)
+    cands = [k for k, v in bearings.items() if pos_token(v.get('pos', '')) == tok and tok]
+    if not cands:
+        return None, tok, []
+    if len(cands) == 1:
+        return cands[0], tok, cands
+    no = _norm(option)
+    for k in cands:
+        mo = _norm(bearings[k].get('model', ''))
+        if mo and (mo[:10] in no or mo[:8] in no):
+            return k, tok, cands
+    for k in cands:
+        mk = str(bearings[k].get('model', '')).lower()
+        for v in ('nsk', 'fag', 'ina'):
+            if v in mk and v in no:
+                return k, tok, cands
+    return cands[0], tok, cands
+
+
+def load_plan():
+    if not PLAN.is_file():
+        raise SystemExit(f'[X] 缺扫描配置 {P.rel(PLAN)}')
+    if not THEORY.is_file():
+        raise SystemExit(f'[X] 缺理论频率表 {P.rel(THEORY)}')
+    plan = json.loads(PLAN.read_text(encoding='utf-8'))
+    th = json.loads(THEORY.read_text(encoding='utf-8'))['bearings']
+    out = []
+    for c in plan['components']:
+        k, tok, cands = theory_of(c['option'], th)
+        e = th.get(k) or {}
+        out.append(dict(**c, theory_key=k, pos_token=tok, n_cands=len(cands),
+                        BPFI_hz=e.get('BPFI_Hz'), BPFO_hz=e.get('BPFO_Hz'), BSF_hz=e.get('BSF_Hz'),
+                        shaft_Hz=e.get('shaft_Hz'), model=e.get('model', '')))
+    return plan, out
+
+
+def verify_plan(farm: str | None = None) -> int:
+    """分母 + 配置 与随包样件逐值对拍(样件 = outputs/<场>/m5_cms_tcm/oem_frequency_scan.parquet)。"""
+    farm = farm or P.farm()
+    sample = P.m5(farm) / 'oem_frequency_scan.parquet'
+    if not sample.is_file():
+        print(f'[X] 没有样件可对拍: {P.rel(sample)}')
+        return 2
+    _plan, rows = load_plan()
+    s = pd.read_parquet(sample)
+    got = pd.DataFrame(rows)
+    m = s[['component', 'sensor', 'shard', 'option', 'dx_hz', 'BPFI_hz', 'BPFO_hz', 'BSF_hz']].drop_duplicates()
+    j = m.merge(got, on=['component', 'sensor', 'shard', 'option'], how='outer', suffixes=('_样', '_本'),
+                indicator=True)
+    both = j[j['_merge'] == 'both']
+    ok = 0
+    bad = []
+    for _, r in both.iterrows():
+        good = all(abs(float(r[f'{c}_样']) - float(r[f'{c}_本'])) < 1e-9 for c in ('BPFI_hz', 'BPFO_hz', 'BSF_hz')) \
+            and abs(float(r['dx_hz_样']) - float(r['dx_hz_本'])) < 1e-9
+        ok += good
+        if not good:
+            bad.append(r['component'])
+    print(f'== oem_scan 分母+配置 逐值对拍 · 场站 {farm} ==')
+    print(f'   样件组件 {len(m)} 个 · 本器 {len(got)} 个 · 同键 {len(both)} 个'
+          f' · 仅样件 {int((j["_merge"] == "left_only").sum())} · 仅本器 {int((j["_merge"] == "right_only").sum())}')
+    print(f'   BPFI_hz/BPFO_hz/BSF_hz + dx_hz 逐值一致: {ok}/{len(both)}' + (f'  不一致: {bad}' if bad else ''))
+    for _, r in got.iterrows():
+        print(f'     {r["component"]:24s} {r["sensor"]:22s} {r["shard"]:22s} dx={r["dx_hz"]:<8} '
+              f'BPFI={r["BPFI_hz"]:<6} BPFO={r["BPFO_hz"]:<6} BSF={r["BSF_hz"]:<6} ← {str(r["theory_key"])[:34]}')
+    rc = 0 if (ok == len(both) and len(both) == len(m) == len(got)) else 5
+    print(f'   结论: {"分母映射成立(理论频率 + 扫描配置逐值一致)" if rc == 0 else "有不一致, 见上"}  rc={rc}')
+    return rc
+
+
+def status(farm: str | None = None) -> int:
+    """机器可读状态(JSON)—— 供 scripts/chain_gap_check.py 汇总。"""
+    _plan, rows = load_plan()
+    st = dict(step='oem_scan', complete=bool(PEAK_PICKING_READY), components=len(rows),
+              denominator='ok(理论频率 + 扫描配置已落地, --verify-plan 逐值对拍)',
+              missing=list(MISSING) if not PEAK_PICKING_READY else [],
+              request_sheet='docs/向振动线取料单_v0.1.md')
+    print(json.dumps(st, ensure_ascii=False))
+    return 0 if PEAK_PICKING_READY else 3
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(description='六层链 oem_scan 步(逆向实现: 分母已就位/分子待口径)')
+    ap.add_argument('--farm', default=None)
+    ap.add_argument('--verify-plan', action='store_true', help='分母+配置 与随包样件逐值对拍(不写盘)')
+    ap.add_argument('--plan', action='store_true', help='打印扫描计划')
+    ap.add_argument('--status', action='store_true', help='机器可读状态(JSON)')
+    a = ap.parse_args()
+    if a.status:
+        return status(a.farm)
+    if a.verify_plan:
+        return verify_plan(a.farm)
+    _plan, rows = load_plan()
+    if a.plan:
+        print(f'== 扫描计划({len(rows)} 个部件)==')
+        for r in rows:
+            print(f'  {r["component"]:24s} {r["sensor"]:22s} {r["shard"]:22s} dx={r["dx_hz"]:<8} '
+                  f'BPFI={r["BPFI_hz"]:<6} BPFO={r["BPFO_hz"]:<6} BSF={r["BSF_hz"]:<6} shaft={r["shaft_Hz"]}')
+        return 0
+    if not PEAK_PICKING_READY:
+        print('[X] 峰值拾取口径未到位 —— 本步**拒绝**产出半成品(只写分母会很危险: 那些件看着像扫描结果, 实则没有分子)')
+        print(f'    缺的是: {MISSING[0]}')
+        print('    要料: docs/向振动线取料单_v0.1.md 第 1 项(源码最佳, 一句话亦可)')
+        print('    已就位: 扫描配置 + 理论频率 —— 用 --verify-plan 可逐值对拍(11/11 通过)')
+        return 3
+    raise SystemExit('[!] PEAK_PICKING_READY=True 但实现未完成 —— 请先补上分子侧实现再翻这个开关')
+
+
+if __name__ == '__main__':
+    for _s in (sys.stdout, sys.stderr):
+        try:
+            _s.reconfigure(errors='replace')
+        except Exception:
+            pass
+    sys.exit(main())

+ 7 - 2
src/windcms/pipeline.py

@@ -12,8 +12,13 @@ PY = os.environ.get('WINDCMS_SCRIPT_PY') or sys.executable    # 冻结包也用
 
 def _run(step, cmd, env, log, cwd=ROOT, timeout=7200):
     t0 = time.time()
-    p = subprocess.run(cmd, cwd=str(cwd), env=env, capture_output=True, text=True, timeout=timeout)
-    rec = dict(step=step, cmd=' '.join(map(str, cmd)), rc=p.returncode, seconds=round(time.time() - t0, 1), tail=(p.stdout + p.stderr)[-1500:])
+    p = subprocess.run(cmd, cwd=str(cwd), env=env, capture_output=True, text=True, errors='replace', timeout=timeout)
+    # ★ 2026-09-17: `text=True` 下 stdout/stderr 可能是 None(极少数平台/编码路径) ⇒ 直接相加会抛
+    #   "unsupported operand type(s) for +: 'NoneType' and 'str'", 于是**真正的失败原因被这句
+    #   无意义的异常顶掉**(实测: oem_scan 步明确报了 rc=3 与缺什么口径, 经壳调用后看到的是这句)。
+    out = (p.stdout or '') + (p.stderr or '')
+    rec = dict(step=step, cmd=' '.join(map(str, cmd)), rc=p.returncode, seconds=round(time.time() - t0, 1),
+               tail=out[-1500:])
     log.append(rec)
     print(f'[{step}] rc={p.returncode} {rec["seconds"]}s', flush=True)
     if p.returncode != 0: