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

三张扫描表的行骨架: bearing/gear 可由包内索引直接推出(74/74 逐键一致); cage_slip 是索引子集(缺稳态筛选口径); 窗集合只能从样件复刻

用户令"按建议执行"的落地(行骨架 + 分母那一半, 并明确标注性质、不混为一谈):

· 新增 --skeleton-verify: 在 w0127(样件与索引都在的那一窗)把"由包内索引推的行骨架"与样件逐键对拍。
  实测: bearing_freq_scan 的 GEN_bearing 74/74、HS_bearing 74/74 完全一致 ⇒ 该表行骨架 = 窗 × 机组 ×
  4 个 (component, sensor) 组合, 由索引 turbine/sensor_name 直接给出, **可由数据推**; gear_freq_scan
  同形(每 (窗,机组) 恰好 4 行, 6 窗, 每窗 35~37 台)。
· cage_slip_scan: 样件 83 键/部件 vs 索引 282 ⇒ 样件是索引的**子集**, 少掉的由"稳态段筛选"决定;
  且样本自己的 n_rec 只有 2~7, 与"该(机组,测点,分箱)在索引里的记录数 47~1426"完全不是一回事
  ⇒ n_rec 统计的是"可用记录数"而非分箱记录数。这条口径(稳态判据 + n_rec 定义)只能靠源码/口径。
  分箱本身已知: = condition_key 的 7 个功率分箱(bin ⊆ condition_key 实测成立, bin_idx=0..6)。
· 只能从样件复刻的部分如实标注: **窗集合**(w0127/w0707/w0711/w0724/w0805/w0811)与"哪些窗有哪几台"
  —— 其余 5 窗的索引与谱库都不在本机(盘上只有 w0127 与 w0316 的索引、只有 w0316 的谱)。
  取料单据此加第 8 项: 其余 5 个窗的 CMS 导出件(否则那 5 窗永远无法重建)。

docs/振动六层链_接口规格与缺口_v0.1.md 增 §10(三种成分分开说 + 对照表);
docs/向振动线取料单_v0.1.md 第五节加第 8 项。自检: --verify-plan rc=0(11/11); --skeleton-verify 按上表如实出结论。
zhouyang.xie 3 недель назад
Родитель
Сommit
3a4e702581

+ 2 - 1
docs/向振动线取料单_v0.1.md

@@ -73,4 +73,5 @@ python scripts/products_restore_missing.py --refresh     # 台账账实相符
 
 ⇒ 也就是说,1P 这一族要收口,需要**两样外部输入**:**(a)峰值拾取口径**(第 1 项那句话)**与(b)1 月的 decode 导出件**(本项)。
 两样都到位后,动作是确定的:`rudong_tcm_index.py` → `rudong_tcm_spectra.py`(只转 `FFT_4_Tr` 这类低频谱即可,
-不必 `--meas ALL`)→ 用 `scripts/spectra_lookup.py` 取幅值 → 与样件 `a1P` 逐值对拍。
+不必 `--meas ALL`)→ 用 `scripts/spectra_lookup.py` 取幅值 → 与样件 `a1P` 逐值对拍。
+| 8 | **其余 5 个窗的 CMS 导出件**(`w0707` / `w0711` / `w0724` / `w0805` / `w0811`,即 2026-07、08 月的 decode 件) | 三张扫描表的样件覆盖这 6 个窗,而本机只有 3-4 月(`CMS_RuDong_CGN_202603-04`)的源件 ⇒ 即使拿到口径,也只能复现 w0127(若第 7 项的 1 月件到位)与 w0316,**其余 5 窗无法重建**(索引与谱库都不在) | 实测:四张表的 `window` 取值 = `w0127/w0707/w0711/w0724/w0805/w0811`;`data/raw/如东/windcms/` 只有 3-4 月 |

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

@@ -291,4 +291,33 @@ option 串 → oem_bearing_freqs.json 条目:
    `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`,
-   把"部分实现"如实标成 `[部分]`(并列出缺什么),不再给"看起来齐了"的假结论。
+   把"部分实现"如实标成 `[部分]`(并列出缺什么),不再给"看起来齐了"的假结论。
+
+---
+
+## 10. 三张扫描表的**行骨架**:一张能从数据推、两张要口径(2026-09-17)
+
+`python scripts/rudong_tcm_oem_scan.py --skeleton-verify`(新增,窗 w0127 —— 样件与索引都在的那一窗):
+
+| 键集合 | 样件 | 由包内索引推 | 交集 | 判定 |
+|---|---:|---:|---:|---|
+| `bearing_freq_scan` / `GEN_bearing` | 74 | 74 | 74 | 一致 |
+| `bearing_freq_scan` / `HS_bearing` | 74 | 74 | 74 | 一致 |
+| `cage_slip_scan` / 发电机DE(机组×分箱) | 83 | 282 | 83 | 样件是索引的子集 |
+| `cage_slip_scan` / 发电机NDE | 83 | 282 | 83 | 同上 |
+| `cage_slip_scan` / 高速轴 | 83 | 282 | 83 | 同上 |
+
+**结论(三种成分,必须分开说)**:
+
+1. **能由数据推(已验证)**:`bearing_freq_scan` / `gear_freq_scan` 的行骨架 = 窗 × 机组 × 4 个
+   (component, sensor) 组合,由索引里的 `turbine`/`sensor_name` 直接给出 —— w0127 上 **74/74 逐键一致**。
+   (两张表都是"每 (窗,机组) 恰好 4 行",窗集合 6 个、每窗 35~37 台。)
+2. **要口径(已定量界住)**:`cage_slip_scan` 的键是索引的**子集**(w0127:样件 83 键/部件 vs 索引 282),
+   少掉的由**稳态段筛选**决定 —— 且样本自己的 `n_rec` 只有 **2~7**,与"该 (机组,测点,分箱) 在索引里的
+   记录数 47~1426"**完全不是一回事** ⇒ `n_rec` 统计的不是分箱记录数,而是某种"可用记录数"。
+   分箱本身**已知**:就是 `condition_key` 的 7 个功率分箱(`WPS-ActivePower 0-1600` … `3600-4400`,
+   `bin_idx`=0..6;实测 `bin ⊆ condition_key` 成立)。
+3. **只能从样件复刻**:**窗集合**(`w0127/w0707/w0711/w0724/w0805/w0811`)与"哪些窗有哪几台" ——
+   其余 5 窗的索引与谱库都不在本机(盘上只有 w0127 与 w0316 的索引、只有 w0316 的谱)。
+   取料单第 7 项的 1 月导出件只解决 w0127;要复现全部 6 窗,还需要 **w0707/w0711/w0724/w0805/w0811
+   这 5 个窗的 CMS 导出件**。

+ 86 - 0
scripts/rudong_tcm_oem_scan.py

@@ -157,17 +157,103 @@ def status(farm: str | None = None) -> int:
     return 0 if PEAK_PICKING_READY else 3
 
 
+def skeleton_from_index(win: str = 'w0127'):
+    r"""**从包内索引真推行骨架**(不是从样件抄):窗 × 机组 × 部件/测点 × 分箱。
+
+    骨架分两种成分, 必须分开说清楚(2026-09-17):
+      · **可从数据推**:某窗里"哪些机组有哪个测点的记录""某机组在该窗出现过哪些功率分箱" —— 索引里
+        的 `turbine` / `sensor_name` / `condition_key` 直接给出;w0127 的索引在包内(`m5/tcm_index.parquet`)。
+      · **只能从样件复刻**:**窗集合**与"哪些窗有哪几台"(w07xx/w08xx 的索引与谱库都不在本机)。
+    本函数把前者算出来交给 `--skeleton-verify` 与样件对拍;后者在 `oem_scan_plan.json` 里记为"复刻自样件"。
+    """
+    ix = P.m5() / ('tcm_index.parquet' if win == 'w0127' else f'windows/{win}/index.parquet')
+    if not ix.is_file():
+        raise SystemExit(f'[X] 该窗索引不在位: {P.rel(ix)}(只有 w0127 与 w0316 的索引在包内)')
+    d = pd.read_parquet(ix, columns=['turbine', 'sensor_name', 'condition_key'])
+    out = {}
+    for c in plan_components():
+        sub = d[(d['sensor_name'] == c['sensor'])]
+        turbine_sensor = set(map(tuple, sub[['turbine', 'sensor_name']].drop_duplicates().values))
+        bins = (sub.groupby(['turbine', 'sensor_name', 'condition_key']).size().reset_index()[['turbine', 'sensor_name', 'condition_key']])
+        out[c['component']] = dict(
+            turbine_sensor=turbine_sensor,
+            turbine_sensor_bin=set(map(tuple, bins.values)))
+    return out
+
+
+def plan_components():
+    if not PLAN.is_file():
+        raise SystemExit(f'[X] 缺扫描配置 {P.rel(PLAN)}')
+    return json.loads(PLAN.read_text(encoding='utf-8'))['components']
+
+
+def verify_skeleton(win: str = 'w0127', farm: str | None = None) -> int:
+    """把"从索引推出来的骨架"与样件该窗的键对拍(w0127 是样件与索引都在的那一窗)。"""
+    farm = farm or P.farm()
+    idx_sk = skeleton_from_index(win)
+    # 样件侧键
+    out = {}
+    b = pd.read_parquet(P.m5(farm) / 'bearing_freq_scan.parquet')
+    b = b[b['window'] == win]
+    bs = {}
+    for comp, sens in (('GEN_bearing', ('Generator_DE', 'Generator_NDE')),
+                       ('HS_bearing', ('Gear_HS_generator_side', 'Gear_HS_rotor_side'))):
+        bs[comp] = set(map(tuple, b[b['sensor'].isin(sens)][['turbine', 'sensor']].drop_duplicates().values))
+    out['bearing_freq_scan'] = bs
+    c = pd.read_parquet(P.m5(farm) / 'cage_slip_scan.parquet')
+    c = c[c['window'] == win]
+    cs = {}
+    for comp, sens in (('发电机DE', 'Generator_DE'), ('发电机NDE', 'Generator_NDE'), ('高速轴', 'Gear_HS_generator_side')):
+        cs[comp] = set(map(tuple, c[c['sensor'] == sens][['turbine', 'sensor', 'bin']].drop_duplicates().values))
+    out['cage_slip_scan'] = cs
+
+    print(f'== 行骨架 从索引推 vs 样件 · 窗 {win} ==')
+    rc = 0
+    rows = []
+
+    for comp, sens_set in (('GEN_bearing', ('Generator_DE', 'Generator_NDE')),
+                           ('HS_bearing', ('Gear_HS_generator_side', 'Gear_HS_rotor_side'))):
+        want = out['bearing_freq_scan'][comp]
+        got = set()
+        for c in plan_components():
+            if c['sensor'] in sens_set:
+                got |= {(t, s) for (t, s) in idx_sk[c['component']]['turbine_sensor']}
+        rows.append((f'bearing_freq_scan/{comp}', len(want), len(got), len(want & got)))
+    for comp, sens in (('发电机DE', 'Generator_DE'), ('发电机NDE', 'Generator_NDE'), ('高速轴', 'Gear_HS_generator_side')):
+        want = out['cage_slip_scan'][comp]
+        res = {}
+        for c in plan_components():
+            if c['sensor'] == sens:
+                res[c['component']] = idx_sk[c['component']]['turbine_sensor_bin']
+        got = set().union(*res.values()) if res else set()
+        rows.append((f'cage_slip_scan/{comp}(机组×分箱)', len(want), len(got), len(want & got)))
+    print('   %-38s %6s %6s %6s' % ('键集合', '样件', '索引推', '交集'))
+    for name, w, g, i in rows:
+        exact = (w == g == i)
+        subset = name.startswith('cage_slip') and w <= g and i == w      # 样件是索引的子集
+        rc = rc or (0 if (exact or subset) else 5)
+        tag = '一致' if exact else ('样件是索引的子集(缺稳态筛选口径)' if subset else '有差异')
+        print('   %-38s %6d %6d %6d  %s' % (name, w, g, i, tag))
+    print('   结论: bearing/gear 的行骨架**可由包内索引直接推出**(74/74 逐键一致);'
+          'cage_slip 的键是索引的**子集**(样件 83 / 索引 282),少掉的那些由"稳态段筛选"决定 —— '
+          '口径未知(n_rec 只有 2~7,与索引里的组记录数 47~1426 完全不是一回事)')
+    return rc
+
+
 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)')
+    ap.add_argument('--skeleton-verify', action='store_true', help='行骨架: 由索引推 vs 样件逐键对拍(w0127)')
     a = ap.parse_args()
     if a.status:
         return status(a.farm)
     if a.verify_plan:
         return verify_plan(a.farm)
+    if a.skeleton_verify:
+        return verify_skeleton('w0127', a.farm)
     _plan, rows = load_plan()
     if a.plan:
         print(f'== 扫描计划({len(rows)} 个部件)==')